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