Skip to main content

oino_resources/
system_prompt.rs

1//! Structured system prompt with the D13 product substitutions of
2//! runtime §9.4.
3//!
4//! Sections are ordered: `preamble` (untagged), then `tools`, `rules`, `docs` (default prompt
5//! only), `addendum`, `project_context`, `skills`, `cwd`, then custom sections — every non-preamble
6//! section wrapped as `<name>\n…\n</name>`.
7
8use 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
17/// Default preamble (D13).
18pub 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
20/// Tool guideline exported by the bash tool family (D12/D13).
21pub 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/// Options of one prompt build (camelCase on the wire,
26/// sent to extensions as `before_agent_start.systemPromptOptions` and accepted back mutated).
27/// Absent collections deserialise to the normalised defaults.
28#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
29#[serde(rename_all = "camelCase")]
30pub struct BuildSystemPromptOptions {
31    /// Custom prompt replacing the default preamble, tools, rules and docs (SYSTEM.md / CLI).
32    #[serde(default, skip_serializing_if = "Option::is_none")]
33    pub custom_prompt: Option<String>,
34    /// Exact full replacement set by a `before_agent_start` handler (not recorded).
35    #[serde(default, skip_serializing_if = "Option::is_none")]
36    pub force_system_prompt: Option<String>,
37    /// Active tools (default `read, bash, edit, write`).
38    #[serde(default = "default_selected_tools")]
39    pub selected_tools: Vec<String>,
40    /// Selected tools whose declarations are hidden (`prepareLoadout`).
41    #[serde(default)]
42    pub hidden_tools: Vec<String>,
43    /// One-line snippet per tool; tools without one are not listed.
44    #[serde(default)]
45    pub tool_snippets: IndexMap<String, String>,
46    /// Guideline bullets per tool.
47    #[serde(default)]
48    pub tool_guidelines: IndexMap<String, Vec<String>>,
49    /// Extra guideline bullets.
50    #[serde(default)]
51    pub prompt_guidelines: Vec<String>,
52    /// Addendum (`APPEND_SYSTEM.md` / CLI, joined by `\n\n`).
53    #[serde(default)]
54    pub append_system_prompt: String,
55    /// Custom sections keyed by tag name (`^[a-z][a-z0-9_-]*$`, not `preamble`).
56    #[serde(default)]
57    pub sections: IndexMap<String, String>,
58    /// Working directory as shown to the model.
59    #[serde(default)]
60    pub cwd: String,
61    /// Context files.
62    #[serde(default)]
63    pub context_files: Vec<ContextFile>,
64    /// Loaded skills.
65    #[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/// A custom section name that is rejected.
96#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
97#[error("Invalid system prompt section name: {name}")]
98pub struct InvalidSectionName {
99    /// Rejected name.
100    pub name: String,
101}
102
103/// Prompt state of one build: a forced prompt is opaque text with
104/// no sections; otherwise `content` is empty and `sections` carry the prompt.
105#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
106pub struct SystemPromptState {
107    /// Forced prompt text, or empty.
108    pub content: String,
109    /// Structured sections (absent for a forced prompt).
110    #[serde(default, skip_serializing_if = "Option::is_none")]
111    pub sections: Option<IndexMap<String, String>>,
112}
113
114impl SystemPromptState {
115    /// The state as a transcript system message (no tool changes).
116    #[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    /// Rendered text exactly as the transcript replays it.
133    #[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/// The Oino docs section body with `{docs_root}` filled in (runtime §9.4).
140#[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/// R e n d e r   P r o j e c t   C o n t e x t.
156#[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/// Deduplicated, trimmed bullets.
171#[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
218/// `preamble`, `tools`, `rules` and `docs` of the default prompt.
219fn 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    // P-DOCS: an empty docs root means "absent" (nothing was bundled or staged) — omit the
251    // section instead of pointing the model at a missing path.
252    if !docs_root.as_os_str().is_empty() {
253        raw.insert("docs".into(), docs_section(docs_root));
254    }
255}
256
257/// Builds the system prompt sections (ignores `force_system_prompt`).
258///
259/// # Errors
260///
261/// [`InvalidSectionName`] for a custom section named `preamble` or not matching
262/// `^[a-z][a-z0-9_-]*$`.
263pub 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
343/// B u i l d   S y s t e m   P r o m p t   S t a t e.
344///
345/// # Errors
346///
347/// See [`build_system_prompt_sections`].
348pub 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
364/// The rendered prompt text.
365///
366/// # Errors
367///
368/// See [`build_system_prompt_sections`].
369pub 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/// The section patch from `previous` (replayed) to `current`, or
377/// `None` when unchanged.
378#[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        // P-DOCS: an empty docs root (nothing bundled or staged) omits the section instead
425        // of pointing at a missing path.
426        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}