claude_agent/output_style/
generator.rs

1//! System prompt generator.
2//!
3//! Generates customized system prompts based on output style configuration.
4//! This is the core logic that implements the keep-coding-instructions behavior.
5
6use std::path::PathBuf;
7
8use super::{
9    ChainOutputStyleProvider, InMemoryOutputStyleProvider, OutputStyle, builtin_styles,
10    default_style, file_output_style_provider,
11};
12use crate::client::DEFAULT_MODEL;
13use crate::common::Provider;
14use crate::common::SourceType;
15use crate::prompts::{
16    base::{BASE_SYSTEM_PROMPT, TOOL_USAGE_POLICY},
17    coding,
18    environment::{current_platform, environment_block, is_git_repository, os_version},
19    identity::CLI_IDENTITY,
20};
21
22/// System prompt generator with output style support.
23///
24/// # System Prompt Structure
25///
26/// The generated system prompt follows this structure:
27///
28/// 1. **CLI Identity** (required for CLI OAuth authentication)
29///    - "You are Claude Code, Anthropic's official CLI for Claude."
30///    - This MUST be included when using CLI OAuth and cannot be replaced
31///
32/// 2. **Base System Prompt** (always included after identity)
33///    - Tone and style, professional objectivity, task management
34///
35/// 3. **Tool Usage Policy** (always included)
36///    - Tool-specific guidelines
37///
38/// 4. **Coding Instructions** (if `keep_coding_instructions: true`)
39///    - Software engineering instructions
40///    - Git commit/PR protocols
41///
42/// 5. **Custom Prompt** (if output style has custom content)
43///    - Style-specific instructions
44///
45/// 6. **Environment Block** (always included)
46///    - Working directory, platform, model info
47#[derive(Debug, Clone)]
48pub struct SystemPromptGenerator {
49    style: OutputStyle,
50    working_dir: Option<PathBuf>,
51    model_name: String,
52    model_id: String,
53    require_cli_identity: bool,
54}
55
56impl Default for SystemPromptGenerator {
57    fn default() -> Self {
58        Self::new()
59    }
60}
61
62impl SystemPromptGenerator {
63    /// Create a new generator with default style.
64    /// CLI identity is NOT required by default.
65    pub fn new() -> Self {
66        Self {
67            style: default_style(),
68            working_dir: None,
69            model_name: "Claude".to_string(),
70            model_id: DEFAULT_MODEL.to_string(),
71            require_cli_identity: false,
72        }
73    }
74
75    /// Create a generator that requires CLI identity.
76    /// Use this when using Claude CLI OAuth authentication.
77    pub fn with_cli_identity() -> Self {
78        Self {
79            style: default_style(),
80            working_dir: None,
81            model_name: "Claude".to_string(),
82            model_id: DEFAULT_MODEL.to_string(),
83            require_cli_identity: true,
84        }
85    }
86
87    /// Set whether CLI identity is required.
88    /// CLI identity MUST be included when using Claude CLI OAuth.
89    pub fn require_cli_identity(mut self, required: bool) -> Self {
90        self.require_cli_identity = required;
91        self
92    }
93
94    /// Set the output style directly.
95    pub fn with_style(mut self, style: OutputStyle) -> Self {
96        self.style = style;
97        self
98    }
99
100    /// Set the working directory for environment block.
101    pub fn with_working_dir(mut self, dir: impl Into<PathBuf>) -> Self {
102        self.working_dir = Some(dir.into());
103        self
104    }
105
106    /// Set the model information.
107    pub fn with_model(mut self, model_id: impl Into<String>) -> Self {
108        let id = model_id.into();
109        self.model_name = derive_model_name(&id);
110        self.model_id = id;
111        self
112    }
113
114    /// Set the model name explicitly.
115    pub fn with_model_name(mut self, name: impl Into<String>) -> Self {
116        self.model_name = name.into();
117        self
118    }
119
120    /// Load and set an output style by name.
121    ///
122    /// Searches in priority order:
123    /// 1. Project styles (.claude/output-styles/) - highest priority
124    /// 2. User styles (~/.claude/output-styles/)
125    /// 3. Built-in styles - lowest priority
126    pub async fn with_style_name(mut self, name: &str) -> crate::Result<Self> {
127        let builtins = InMemoryOutputStyleProvider::new()
128            .with_items(builtin_styles())
129            .with_priority(0)
130            .with_source_type(SourceType::Builtin);
131
132        let mut chain = ChainOutputStyleProvider::new().with(builtins);
133
134        if let Some(ref working_dir) = self.working_dir {
135            let project = file_output_style_provider()
136                .with_project_path(working_dir)
137                .with_priority(20)
138                .with_source_type(SourceType::Project);
139            chain = chain.with(project);
140        }
141
142        let user = file_output_style_provider()
143            .with_user_path()
144            .with_priority(10)
145            .with_source_type(SourceType::User);
146        chain = chain.with(user);
147
148        if let Some(style) = chain.get(name).await? {
149            self.style = style;
150            Ok(self)
151        } else {
152            Err(crate::Error::Config(format!(
153                "Output style '{}' not found",
154                name
155            )))
156        }
157    }
158
159    /// Generate the system prompt.
160    ///
161    /// # Prompt Assembly Logic
162    ///
163    /// - **CLI Identity**: Only if `require_cli_identity: true` (CLI OAuth)
164    /// - **Base System Prompt**: Always included
165    /// - **Tool Usage Policy**: Always included
166    /// - **Coding Instructions**: Only if `keep_coding_instructions: true`
167    /// - **Custom Prompt**: Only if style has non-empty prompt
168    /// - **Environment Block**: Always included
169    pub fn generate(&self) -> String {
170        let mut parts = Vec::new();
171
172        // 1. CLI Identity (required for CLI OAuth, cannot be replaced)
173        if self.require_cli_identity {
174            parts.push(CLI_IDENTITY.to_string());
175        }
176
177        // 2. Base System Prompt (always)
178        parts.push(BASE_SYSTEM_PROMPT.to_string());
179
180        // 3. Tool Usage Policy (always)
181        parts.push(TOOL_USAGE_POLICY.to_string());
182
183        // 4. Coding Instructions (conditional)
184        if self.style.keep_coding_instructions {
185            parts.push(coding::coding_instructions(&self.model_name));
186        }
187
188        // 5. Custom Prompt (if present)
189        if !self.style.prompt.is_empty() {
190            parts.push(self.style.prompt.clone());
191        }
192
193        // 6. Environment Block (always)
194        let is_git = is_git_repository(self.working_dir.as_deref());
195        let platform = current_platform();
196        let os_ver = os_version();
197
198        parts.push(environment_block(
199            self.working_dir.as_deref(),
200            is_git,
201            platform,
202            &os_ver,
203            &self.model_name,
204            &self.model_id,
205        ));
206
207        parts.join("\n\n")
208    }
209
210    /// Generate the system prompt with additional dynamic context.
211    ///
212    /// This is used when rules or other dynamic content needs to be appended.
213    pub fn generate_with_context(&self, additional_context: &str) -> String {
214        let mut prompt = self.generate();
215        if !additional_context.is_empty() {
216            prompt.push_str("\n\n");
217            prompt.push_str(additional_context);
218        }
219        prompt
220    }
221
222    /// Get the current output style.
223    pub fn style(&self) -> &OutputStyle {
224        &self.style
225    }
226
227    /// Check if coding instructions are included.
228    pub fn has_coding_instructions(&self) -> bool {
229        self.style.keep_coding_instructions
230    }
231}
232
233/// Derive a friendly model name from model ID.
234fn derive_model_name(model_id: &str) -> String {
235    // Extract base model name from ID
236    // e.g., "claude-sonnet-4-20250514" -> "Claude Sonnet 4"
237    // e.g., "claude-opus-4-5-20251101" -> "Claude Opus 4.5"
238
239    if model_id.contains("opus-4-5") || model_id.contains("opus-4.5") {
240        "Claude Opus 4.5".to_string()
241    } else if model_id.contains("opus-4") {
242        "Claude Opus 4".to_string()
243    } else if model_id.contains("sonnet-4-5") || model_id.contains("sonnet-4.5") {
244        "Claude Sonnet 4.5".to_string()
245    } else if model_id.contains("sonnet-4") {
246        "Claude Sonnet 4".to_string()
247    } else if model_id.contains("haiku-4-5") || model_id.contains("haiku-4.5") {
248        "Claude Haiku 4.5".to_string()
249    } else if model_id.contains("haiku-4") {
250        "Claude Haiku 4".to_string()
251    } else if model_id.contains("3.5") || model_id.contains("3-5") {
252        if model_id.contains("sonnet") {
253            "Claude 3.5 Sonnet".to_string()
254        } else if model_id.contains("haiku") {
255            "Claude 3.5 Haiku".to_string()
256        } else if model_id.contains("opus") {
257            "Claude 3.5 Opus".to_string()
258        } else {
259            "Claude 3.5".to_string()
260        }
261    } else {
262        "Claude".to_string()
263    }
264}
265
266#[cfg(test)]
267mod tests {
268    use super::*;
269    use crate::output_style::SourceType;
270
271    #[test]
272    fn test_generator_default_no_cli_identity() {
273        let prompt = SystemPromptGenerator::new().generate();
274
275        // CLI Identity should NOT be included by default
276        assert!(!prompt.starts_with(CLI_IDENTITY));
277        assert!(prompt.contains("Doing tasks")); // coding instructions
278        assert!(prompt.contains("<env>")); // environment block
279    }
280
281    #[test]
282    fn test_generator_with_cli_identity() {
283        let prompt = SystemPromptGenerator::with_cli_identity().generate();
284
285        // CLI Identity MUST be the first line
286        assert!(prompt.starts_with(CLI_IDENTITY));
287        assert!(prompt.contains("Doing tasks")); // coding instructions
288        assert!(prompt.contains("<env>")); // environment block
289    }
290
291    #[test]
292    fn test_generator_with_custom_style_keep_coding() {
293        let style = OutputStyle::new("test", "Test style", "Custom instructions here")
294            .with_source_type(SourceType::User)
295            .with_keep_coding_instructions(true);
296
297        let prompt = SystemPromptGenerator::with_cli_identity()
298            .with_style(style)
299            .generate();
300
301        assert!(prompt.starts_with(CLI_IDENTITY));
302        assert!(prompt.contains("Doing tasks")); // coding instructions kept
303        assert!(prompt.contains("Custom instructions here")); // custom prompt
304        assert!(prompt.contains("<env>")); // environment block
305    }
306
307    #[test]
308    fn test_generator_with_custom_style_no_coding() {
309        let style = OutputStyle::new("concise", "Be concise", "Keep responses short.")
310            .with_source_type(SourceType::User)
311            .with_keep_coding_instructions(false);
312
313        let prompt = SystemPromptGenerator::with_cli_identity()
314            .with_style(style)
315            .generate();
316
317        assert!(prompt.starts_with(CLI_IDENTITY)); // CLI Identity preserved
318        assert!(!prompt.contains("Doing tasks")); // coding instructions NOT included
319        assert!(prompt.contains("Keep responses short.")); // custom prompt
320        assert!(prompt.contains("<env>")); // environment block
321    }
322
323    #[test]
324    fn test_generator_with_working_dir() {
325        let prompt = SystemPromptGenerator::new()
326            .with_working_dir("/test/project")
327            .generate();
328
329        assert!(prompt.contains("/test/project"));
330    }
331
332    #[test]
333    fn test_generator_with_model() {
334        let prompt = SystemPromptGenerator::new()
335            .with_model("claude-opus-4-5-20251101")
336            .generate();
337
338        assert!(prompt.contains("claude-opus-4-5-20251101"));
339        assert!(prompt.contains("Claude Opus 4.5"));
340    }
341
342    #[test]
343    fn test_derive_model_name() {
344        assert_eq!(
345            derive_model_name("claude-opus-4-5-20251101"),
346            "Claude Opus 4.5"
347        );
348        assert_eq!(
349            derive_model_name("claude-sonnet-4-20250514"),
350            "Claude Sonnet 4"
351        );
352        assert_eq!(
353            derive_model_name("claude-haiku-4-5-20251001"),
354            "Claude Haiku 4.5"
355        );
356        assert_eq!(
357            derive_model_name("claude-3-5-sonnet-20241022"),
358            "Claude 3.5 Sonnet"
359        );
360    }
361
362    #[test]
363    fn test_generator_with_context() {
364        let prompt = SystemPromptGenerator::new()
365            .generate_with_context("# Dynamic Rules\nSome dynamic content");
366
367        assert!(prompt.contains("# Dynamic Rules"));
368        assert!(prompt.contains("Some dynamic content"));
369    }
370
371    #[test]
372    fn test_has_coding_instructions() {
373        let generator = SystemPromptGenerator::new();
374        assert!(generator.has_coding_instructions());
375
376        let style = OutputStyle::new("no-coding", "", "").with_keep_coding_instructions(false);
377        let generator = SystemPromptGenerator::new().with_style(style);
378        assert!(!generator.has_coding_instructions());
379    }
380
381    #[test]
382    fn test_cli_identity_cannot_be_replaced_by_custom_prompt() {
383        // Even with a custom prompt that tries to replace identity,
384        // CLI Identity should still be first when required
385        let style = OutputStyle::new(
386            "custom",
387            "Custom identity",
388            "I am a different assistant.", // Trying to replace identity
389        )
390        .with_keep_coding_instructions(false);
391
392        let prompt = SystemPromptGenerator::with_cli_identity()
393            .with_style(style)
394            .generate();
395
396        // CLI Identity MUST be first, custom prompt comes after
397        assert!(prompt.starts_with(CLI_IDENTITY));
398        assert!(prompt.contains("I am a different assistant."));
399    }
400}