1use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9use std::sync::{Mutex, OnceLock};
10
11pub const SYSTEM_PROMPT_DYNAMIC_BOUNDARY: &str = "__SYSTEM_PROMPT_DYNAMIC_BOUNDARY__";
14
15fn section_cache() -> &'static Mutex<HashMap<String, Option<String>>> {
18 static CACHE: OnceLock<Mutex<HashMap<String, Option<String>>>> = OnceLock::new();
19 CACHE.get_or_init(|| Mutex::new(HashMap::new()))
20}
21
22pub fn clear_system_prompt_sections() {
23 if let Ok(mut cache) = section_cache().lock() {
24 cache.clear();
25 }
26}
27
28#[derive(Debug, Clone)]
31pub struct SystemPromptSection {
32 pub tag: String,
33 pub content: Option<String>,
34 pub cache_break: bool,
35}
36
37impl SystemPromptSection {
38 pub fn cached(tag: impl Into<String>, content: impl Into<String>) -> Self {
39 Self {
40 tag: tag.into(),
41 content: Some(content.into()),
42 cache_break: false,
43 }
44 }
45 pub fn uncached(tag: impl Into<String>, content: Option<String>) -> Self {
46 Self {
47 tag: tag.into(),
48 content,
49 cache_break: true,
50 }
51 }
52}
53
54#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, Default)]
57#[serde(rename_all = "lowercase")]
58pub enum OutputStyle {
59 #[default]
60 Default,
61 Explanatory,
62 Learning,
63 Concise,
64 Formal,
65 Casual,
66}
67
68impl OutputStyle {
69 pub fn prompt_suffix(self) -> Option<&'static str> {
70 match self {
71 Self::Explanatory => Some("When explaining code or concepts, be thorough and educational. Include reasoning, alternatives considered, and potential pitfalls. Err on the side of over-explaining."),
72 Self::Learning => Some("This user is learning. Explain concepts as you implement them. Point out patterns, best practices, and why you made each decision. Use analogies when helpful."),
73 Self::Concise => Some("Be maximally concise. Skip preamble, summaries, and filler. Lead with the answer. One sentence is better than three."),
74 Self::Formal => Some("Maintain a formal, professional tone. Use precise technical language."),
75 Self::Casual => Some("Use a casual, conversational tone."),
76 Self::Default => None,
77 }
78 }
79
80 pub fn from_str(s: &str) -> Self {
81 match s.to_lowercase().as_str() {
82 "explanatory" => Self::Explanatory,
83 "learning" => Self::Learning,
84 "concise" => Self::Concise,
85 "formal" => Self::Formal,
86 "casual" => Self::Casual,
87 _ => Self::Default,
88 }
89 }
90}
91
92#[derive(Debug, Clone, Copy, PartialEq)]
95pub enum SystemPromptPrefix {
96 Interactive,
97 Sdk,
98 SdkPreset,
99 SubAgent,
100}
101
102impl SystemPromptPrefix {
103 pub fn detect(is_non_interactive: bool, has_append_system_prompt: bool) -> Self {
104 if is_non_interactive {
105 if has_append_system_prompt {
106 return Self::SdkPreset;
107 }
108 return Self::Sdk;
109 }
110 Self::Interactive
111 }
112
113 pub fn attribution_text(self) -> &'static str {
114 match self {
115 Self::Interactive => "You are a coding agent built with the Cersei SDK.",
116 Self::SdkPreset => "You are a coding agent built with the Cersei SDK, running with custom instructions.",
117 Self::Sdk => "You are an agent built on the Cersei SDK.",
118 Self::SubAgent => "You are a specialized sub-agent.",
119 }
120 }
121}
122
123#[derive(Debug, Clone, Default)]
127pub struct GitSnapshot {
128 pub branch: String,
129 pub recent_commits: Vec<String>,
130 pub status_lines: Vec<String>,
131 pub user: Option<String>,
132}
133
134#[derive(Debug, Clone, Default)]
137pub struct SystemPromptOptions {
138 pub prefix: Option<SystemPromptPrefix>,
140 pub is_non_interactive: bool,
141 pub has_append_system_prompt: bool,
142 pub output_style: OutputStyle,
143 pub custom_output_style_prompt: Option<String>,
144 pub working_directory: Option<String>,
145 pub memory_content: String,
146 pub custom_system_prompt: Option<String>,
147 pub append_system_prompt: Option<String>,
148 pub replace_system_prompt: bool,
149 pub coordinator_mode: bool,
150 pub extra_cached_sections: Vec<(String, String)>,
151 pub extra_dynamic_sections: Vec<(String, String)>,
152
153 pub tools_available: Vec<String>,
156 pub has_memory: bool,
158 pub has_auto_compact: bool,
160 pub git_status: Option<GitSnapshot>,
162 pub mcp_instructions: Vec<(String, String)>,
164 pub language: Option<String>,
166}
167
168pub fn build_system_prompt(opts: &SystemPromptOptions) -> String {
171 if opts.replace_system_prompt {
173 if let Some(custom) = &opts.custom_system_prompt {
174 return format!("{}\n\n{}", custom, SYSTEM_PROMPT_DYNAMIC_BOUNDARY);
175 }
176 }
177
178 let prefix = opts.prefix.unwrap_or_else(|| {
179 SystemPromptPrefix::detect(opts.is_non_interactive, opts.has_append_system_prompt)
180 });
181
182 let mut parts: Vec<String> = Vec::new();
183
184 parts.push(prefix.attribution_text().to_string());
188
189 parts.push(CORE_CAPABILITIES.to_string());
191
192 parts.push(TOOL_USE_GUIDELINES.to_string());
194
195 parts.push(ACTIONS_SECTION.to_string());
197
198 parts.push(SAFETY_GUIDELINES.to_string());
200
201 parts.push(SECURITY_SECTION.to_string());
203
204 parts.push(OUTPUT_EFFICIENCY.to_string());
206
207 parts.push(SUMMARIZE_TOOL_RESULTS.to_string());
209
210 if let Some(style_text) = opts
212 .custom_output_style_prompt
213 .as_deref()
214 .filter(|s| !s.trim().is_empty())
215 .or_else(|| opts.output_style.prompt_suffix())
216 {
217 parts.push(format!("\n## Output Style\n{}", style_text));
218 }
219
220 if opts.coordinator_mode {
222 parts.push(COORDINATOR_SECTION.to_string());
223 }
224
225 if opts
227 .tools_available
228 .iter()
229 .any(|t| t == "Agent" || t == "TaskCreate")
230 {
231 parts.push(SESSION_AGENT_GUIDANCE.to_string());
232 }
233
234 if opts.tools_available.iter().any(|t| t == "Skill") {
236 parts.push(SESSION_SKILLS_GUIDANCE.to_string());
237 }
238
239 if opts.has_memory {
241 parts.push(SESSION_MEMORY_GUIDANCE.to_string());
242 }
243
244 if opts.has_auto_compact {
246 parts.push(FUNCTION_RESULT_CLEARING.to_string());
247 }
248
249 if let Some(lang) = &opts.language {
251 parts.push(format!(
252 "\n## Language\nAlways respond in {lang}. Use {lang} for all explanations, comments, and communications. Technical terms and code identifiers should remain in their original form."
253 ));
254 }
255
256 if let Some(custom) = &opts.custom_system_prompt {
258 parts.push(format!(
259 "\n<custom_instructions>\n{}\n</custom_instructions>",
260 custom
261 ));
262 }
263
264 for (tag, content) in &opts.extra_cached_sections {
266 parts.push(format!("\n<{}>\n{}\n</{}>", tag, content, tag));
267 }
268
269 parts.push(SYSTEM_PROMPT_DYNAMIC_BOUNDARY.to_string());
271
272 if let Some(cwd) = &opts.working_directory {
276 parts.push(format!("\n<working_directory>{}</working_directory>", cwd));
277 }
278
279 if let Some(git) = &opts.git_status {
281 let mut git_section = format!("\n<git_status>\nBranch: {}", git.branch);
282 if let Some(user) = &git.user {
283 git_section.push_str(&format!("\nUser: {}", user));
284 }
285 if !git.status_lines.is_empty() {
286 git_section.push_str("\nStatus:");
287 for line in &git.status_lines {
288 git_section.push_str(&format!("\n {}", line));
289 }
290 }
291 if !git.recent_commits.is_empty() {
292 git_section.push_str("\nRecent commits:");
293 for commit in &git.recent_commits {
294 git_section.push_str(&format!("\n {}", commit));
295 }
296 }
297 git_section.push_str("\n</git_status>");
298 parts.push(git_section);
299 }
300
301 if !opts.memory_content.is_empty() {
303 parts.push(format!("\n<memory>\n{}\n</memory>", opts.memory_content));
304 }
305
306 if !opts.mcp_instructions.is_empty() {
308 let mut mcp_section = String::from("\n<mcp_instructions>");
309 for (name, instructions) in &opts.mcp_instructions {
310 mcp_section.push_str(&format!("\n## {}\n{}", name, instructions));
311 }
312 mcp_section.push_str("\n</mcp_instructions>");
313 parts.push(mcp_section);
314 }
315
316 for (tag, content) in &opts.extra_dynamic_sections {
318 parts.push(format!("\n<{}>\n{}\n</{}>", tag, content, tag));
319 }
320
321 if let Some(append) = &opts.append_system_prompt {
323 parts.push(format!("\n{}", append));
324 }
325
326 parts.join("\n")
327}
328
329const CORE_CAPABILITIES: &str = r#"
332## Capabilities
333
334You have access to powerful tools for software engineering tasks:
335- **Read/Write files**: Read any file, write new files, edit existing files with precise diffs
336- **Execute commands**: Run bash commands, PowerShell scripts, background processes
337- **Search**: Glob patterns, regex grep, web search, file content search
338- **LSP**: Language server queries for hover, go-to-definition, references, symbols, diagnostics
339- **Web**: Fetch URLs, search the internet
340- **Agents**: Spawn parallel sub-agents for complex multi-step work
341- **Memory**: Persistent notes across sessions via the memory system
342- **MCP servers**: Connect to external tools and APIs via Model Context Protocol
343- **Jupyter notebooks**: Read and edit notebook cells
344
345## Task Management
346
347You have access to the TodoWrite tool to help you manage and plan tasks. Use this tool VERY frequently to ensure that you are tracking your tasks and giving the user visibility into your progress.
348This tool is also EXTREMELY helpful for planning tasks, and for breaking down larger complex tasks into smaller steps. If you do not use this tool when planning, you may forget to do important tasks - and that is unacceptable.
349
350It is critical that you mark todos as completed as soon as you are done with a task. Do not batch up multiple tasks before marking them as completed.
351
352IMPORTANT: Always use the TodoWrite tool to plan and track tasks throughout the conversation.
353
354## How to approach tasks
355
356The user will primarily request you perform software engineering tasks. For these tasks:
357- NEVER propose changes to code you haven't read. Read first, then modify.
358- Use the TodoWrite tool to plan the task if required.
359- Be careful not to introduce security vulnerabilities.
360- Avoid over-engineering. Only make changes that are directly requested or clearly necessary.
361- Don't add features, refactor code, or make improvements beyond what was asked.
362- ALWAYS verify information about the codebase using tools before answering. Never rely solely on general knowledge or assumptions about how code works.
363
364## Tool usage policy
365
366- When doing file search or research, prefer using Bash (with grep, find) or Grep tool for targeted searches.
367- When you need information you don't have, use WebSearch to find it. Do not guess APIs, node types, or library details — search for the current documentation.
368- You can call multiple tools in a single response. If you intend to call multiple tools and there are no dependencies between them, make all independent tool calls in parallel. Maximize use of parallel tool calls where possible to increase efficiency.
369- If the user specifies running tools in parallel, you MUST send a single response with multiple tool calls.
370- Use specialized tools instead of bash when possible: Read for reading files, Edit for editing, Glob for finding files, Grep for searching content.
371"#;
372
373const TOOL_USE_GUIDELINES: &str = r#"
374## Tool use guidelines
375
376- Use dedicated tools (Read, Edit, Glob, Grep, LSP) instead of bash equivalents
377- For searches, prefer Grep over `grep`; prefer Glob over `find`
378- For file edits: always read the file first, then make targeted edits
379- Bash commands timeout after 2 minutes; use background mode for long operations
380- Use Glob for targeted patterns (`src/**/*.rs`), never glob `**/*` at root
381- Use LSP tool for semantic understanding: symbols, definitions, references, diagnostics
382- Write down key findings in your response — tool results may be cleared from context later
383- Old tool results are automatically cleared to free space. Summarize important information.
384"#;
385
386const ACTIONS_SECTION: &str = r#"
387## Executing actions with care
388
389Carefully consider the reversibility and blast radius of actions. For actions
390that are hard to reverse, affect shared systems, or could be risky or
391destructive, check with the user before proceeding. Authorization stands for
392the scope specified, not beyond. Match the scope of your actions to what was
393actually requested.
394"#;
395
396const SAFETY_GUIDELINES: &str = r#"
397## Safety guidelines
398
399- Never delete files without explicit user confirmation
400- Don't modify protected files (.gitconfig, .bashrc, .zshrc)
401- Be careful with destructive operations (rm -rf, DROP TABLE, etc.)
402- Don't commit secrets, credentials, or API keys
403- For ambiguous destructive actions, ask before proceeding
404"#;
405
406const SECURITY_SECTION: &str = r#"
407## Security
408
409You are authorized to assist with security research, CTF challenges, penetration testing
410with explicit authorization, defensive security, and educational security content. Do not
411assist with creating malware, unauthorized access, denial-of-service attacks, or any
412destructive security techniques without clear legitimate purpose.
413"#;
414
415const OUTPUT_EFFICIENCY: &str = r#"
416## Output efficiency
417
418Be direct and informative. Lead with the answer, not the reasoning.
419- For analysis/explanation: Be thorough and structured. Use tables, lists, and sections.
420- For code changes: Be concise. Show what changed and why.
421- For status updates: One sentence is enough.
422- Never ask "would you like me to investigate more?" — just investigate.
423- Never stop at surface-level answers when deeper investigation would give better results.
424- Use multiple tool calls in a single response to gather evidence in parallel.
425"#;
426
427const SUMMARIZE_TOOL_RESULTS: &str = r#"
428## Tool results
429
430When working with tool results, write down any important information you might need later
431in your response, as the original tool result may be cleared from context later.
432"#;
433
434const COORDINATOR_SECTION: &str = r#"
435## Coordinator Mode
436
437You are operating as an orchestrator. Spawn parallel worker agents using the Agent tool.
438Each worker prompt must be fully self-contained. Synthesize findings before delegating
439follow-up work. Use TaskCreate/TaskUpdate to track parallel work.
440"#;
441
442const SESSION_AGENT_GUIDANCE: &str = r#"
445## Sub-agents
446
447Use the Agent tool for complex multi-step tasks that benefit from parallel work or
448deep research. Each sub-agent runs independently with its own context window.
449- Launch multiple agents in parallel when tasks are independent
450- Provide each agent with a complete, self-contained prompt
451- The agent's output is not visible to the user — summarize results yourself
452- Use TaskCreate/TaskUpdate to track background work
453"#;
454
455const SESSION_SKILLS_GUIDANCE: &str = r#"
456## Skills
457
458/<skill-name> (e.g., /commit) invokes a skill — a reusable prompt template.
459Skills are loaded from .claude/commands/*.md, .claude/skills/*/SKILL.md, or bundled.
460Use the Skill tool to execute them. Only use skills that are listed as available.
461"#;
462
463const SESSION_MEMORY_GUIDANCE: &str = r#"
464## Persistent memory
465
466You have access to persistent memory across sessions. Memory files survive across
467conversations and are injected into your context automatically.
468- Store facts about the user's preferences, project decisions, and recurring patterns
469- Before recommending from memory, verify that files and functions still exist
470- Memory records can become stale — if a recalled memory conflicts with current code, trust what you observe now
471"#;
472
473const FUNCTION_RESULT_CLEARING: &str = r#"
474## Context management
475
476Old tool results will be automatically summarized to free context space when the
477conversation grows long. The most recent results are always kept. Write down any
478important information from tool results in your response text — the originals may
479be cleared in future turns.
480"#;
481
482#[cfg(test)]
485mod tests {
486 use super::*;
487
488 fn default_opts() -> SystemPromptOptions {
489 SystemPromptOptions::default()
490 }
491
492 #[test]
493 fn test_default_prompt_contains_boundary() {
494 let prompt = build_system_prompt(&default_opts());
495 assert!(prompt.contains(SYSTEM_PROMPT_DYNAMIC_BOUNDARY));
496 }
497
498 #[test]
499 fn test_default_prompt_contains_attribution() {
500 let prompt = build_system_prompt(&default_opts());
501 assert!(prompt.contains("Cersei SDK"));
502 }
503
504 #[test]
505 fn test_replace_system_prompt() {
506 let opts = SystemPromptOptions {
507 custom_system_prompt: Some("Custom only.".to_string()),
508 replace_system_prompt: true,
509 ..Default::default()
510 };
511 let prompt = build_system_prompt(&opts);
512 assert!(prompt.starts_with("Custom only."));
513 assert!(!prompt.contains("Capabilities"));
514 assert!(prompt.contains(SYSTEM_PROMPT_DYNAMIC_BOUNDARY));
515 }
516
517 #[test]
518 fn test_working_directory_in_dynamic_section() {
519 let opts = SystemPromptOptions {
520 working_directory: Some("/home/user/project".to_string()),
521 ..Default::default()
522 };
523 let prompt = build_system_prompt(&opts);
524 let boundary_pos = prompt.find(SYSTEM_PROMPT_DYNAMIC_BOUNDARY).unwrap();
525 let cwd_pos = prompt.find("/home/user/project").unwrap();
526 assert!(cwd_pos > boundary_pos);
527 }
528
529 #[test]
530 fn test_memory_content_in_dynamic_section() {
531 let opts = SystemPromptOptions {
532 memory_content: "- [test.md](test.md) -- a test memory".to_string(),
533 ..Default::default()
534 };
535 let prompt = build_system_prompt(&opts);
536 let boundary_pos = prompt.find(SYSTEM_PROMPT_DYNAMIC_BOUNDARY).unwrap();
537 let mem_pos = prompt.find("test.md").unwrap();
538 assert!(mem_pos > boundary_pos);
539 }
540
541 #[test]
542 fn test_output_style_concise() {
543 let opts = SystemPromptOptions {
544 output_style: OutputStyle::Concise,
545 ..Default::default()
546 };
547 let prompt = build_system_prompt(&opts);
548 assert!(prompt.contains("maximally concise"));
549 }
550
551 #[test]
552 fn test_output_style_default_no_suffix() {
553 let prompt = build_system_prompt(&default_opts());
554 assert!(!prompt.contains("maximally concise"));
555 assert!(!prompt.contains("This user is learning"));
556 }
557
558 #[test]
559 fn test_coordinator_mode() {
560 let opts = SystemPromptOptions {
561 coordinator_mode: true,
562 ..Default::default()
563 };
564 let prompt = build_system_prompt(&opts);
565 assert!(prompt.contains("Coordinator Mode"));
566 assert!(prompt.contains("orchestrator"));
567 }
568
569 #[test]
570 fn test_output_style_from_str() {
571 assert_eq!(OutputStyle::from_str("concise"), OutputStyle::Concise);
572 assert_eq!(OutputStyle::from_str("FORMAL"), OutputStyle::Formal);
573 assert_eq!(OutputStyle::from_str("unknown"), OutputStyle::Default);
574 }
575
576 #[test]
577 fn test_sdk_prefix() {
578 let prefix = SystemPromptPrefix::detect(true, false);
579 assert_eq!(prefix, SystemPromptPrefix::Sdk);
580 }
581
582 #[test]
583 fn test_sdk_preset_prefix() {
584 let prefix = SystemPromptPrefix::detect(true, true);
585 assert_eq!(prefix, SystemPromptPrefix::SdkPreset);
586 }
587
588 #[test]
589 fn test_extra_sections() {
590 let opts = SystemPromptOptions {
591 extra_cached_sections: vec![("rules".into(), "no swearing".into())],
592 extra_dynamic_sections: vec![("context".into(), "today is Monday".into())],
593 ..Default::default()
594 };
595 let prompt = build_system_prompt(&opts);
596 let boundary = prompt.find(SYSTEM_PROMPT_DYNAMIC_BOUNDARY).unwrap();
597 let rules_pos = prompt.find("no swearing").unwrap();
598 let context_pos = prompt.find("today is Monday").unwrap();
599 assert!(rules_pos < boundary);
600 assert!(context_pos > boundary);
601 }
602
603 #[test]
604 fn test_clear_section_cache() {
605 {
606 let mut cache = section_cache().lock().unwrap();
607 cache.insert("test".to_string(), Some("content".to_string()));
608 }
609 clear_system_prompt_sections();
610 let cache = section_cache().lock().unwrap();
611 assert!(cache.is_empty());
612 }
613
614 #[test]
617 fn test_agent_guidance_included_when_tools_available() {
618 let opts = SystemPromptOptions {
619 tools_available: vec!["Agent".into(), "Read".into()],
620 ..Default::default()
621 };
622 let prompt = build_system_prompt(&opts);
623 assert!(prompt.contains("Sub-agents"));
624 }
625
626 #[test]
627 fn test_agent_guidance_excluded_when_no_agent_tool() {
628 let opts = SystemPromptOptions {
629 tools_available: vec!["Read".into(), "Write".into()],
630 ..Default::default()
631 };
632 let prompt = build_system_prompt(&opts);
633 assert!(!prompt.contains("Sub-agents"));
634 }
635
636 #[test]
637 fn test_skills_guidance_conditional() {
638 let with = SystemPromptOptions {
639 tools_available: vec!["Skill".into()],
640 ..Default::default()
641 };
642 assert!(build_system_prompt(&with).contains("/<skill-name>"));
643
644 let without = SystemPromptOptions::default();
645 assert!(!build_system_prompt(&without).contains("/<skill-name>"));
646 }
647
648 #[test]
649 fn test_memory_guidance_conditional() {
650 let with = SystemPromptOptions {
651 has_memory: true,
652 ..Default::default()
653 };
654 assert!(build_system_prompt(&with).contains("Persistent memory"));
655
656 let without = SystemPromptOptions::default();
657 assert!(!build_system_prompt(&without).contains("Persistent memory"));
658 }
659
660 #[test]
661 fn test_auto_compact_warning() {
662 let with = SystemPromptOptions {
663 has_auto_compact: true,
664 ..Default::default()
665 };
666 assert!(build_system_prompt(&with).contains("Context management"));
667
668 let without = SystemPromptOptions::default();
669 assert!(!build_system_prompt(&without).contains("Context management"));
670 }
671
672 #[test]
673 fn test_git_snapshot() {
674 let opts = SystemPromptOptions {
675 git_status: Some(GitSnapshot {
676 branch: "main".into(),
677 recent_commits: vec!["abc1234 Fix bug".into()],
678 status_lines: vec!["M src/main.rs".into()],
679 user: Some("Dev".into()),
680 }),
681 ..Default::default()
682 };
683 let prompt = build_system_prompt(&opts);
684 let boundary = prompt.find(SYSTEM_PROMPT_DYNAMIC_BOUNDARY).unwrap();
685 let git_pos = prompt.find("Branch: main").unwrap();
686 assert!(git_pos > boundary); assert!(prompt.contains("abc1234 Fix bug"));
688 assert!(prompt.contains("M src/main.rs"));
689 assert!(prompt.contains("User: Dev"));
690 }
691
692 #[test]
693 fn test_mcp_instructions() {
694 let opts = SystemPromptOptions {
695 mcp_instructions: vec![("db-server".into(), "Use LIMIT clauses".into())],
696 ..Default::default()
697 };
698 let prompt = build_system_prompt(&opts);
699 assert!(prompt.contains("db-server"));
700 assert!(prompt.contains("Use LIMIT clauses"));
701 }
702
703 #[test]
704 fn test_language_preference() {
705 let opts = SystemPromptOptions {
706 language: Some("Japanese".into()),
707 ..Default::default()
708 };
709 let prompt = build_system_prompt(&opts);
710 assert!(prompt.contains("Always respond in Japanese"));
711 }
712
713 #[test]
714 fn test_output_efficiency_always_included() {
715 let prompt = build_system_prompt(&default_opts());
716 assert!(prompt.contains("Output efficiency"));
717 }
718
719 #[test]
720 fn test_summarize_tool_results_always_included() {
721 let prompt = build_system_prompt(&default_opts());
722 assert!(prompt.contains("Tool results"));
723 }
724}