Skip to main content

a3s_code_core/
prompts.rs

1// Prompt Registry
2//
3// Central registry for default system prompts and prompt templates used in
4// A3S Code. Every LLM-facing default pack is externalized here as a
5// compile-time `include_str!` so the shipping defaults are visible in one place.
6//
7// Ownership (`HARNESS-CONV6`):
8// - Core owns AgentStyle → hard permission overlays, runtime/tool contracts,
9//   and `SystemPromptSlots` assembly. Prompt text never grants a capability.
10// - Specialty markdown bodies under `prompts/` are a replaceable default pack.
11//   Hosts may replace role/guidelines/extra (and eventually the whole pack);
12//   reviewer rubrics and product prompts remain host-owned.
13//
14// Directory layout:
15//   prompts/
16//   ├── common/   — shared runtime prompts used by multiple subsystems
17//   ├── analysis/ — intent classification and pre-analysis prompts
18//   ├── planning/ — planner and plan execution prompts
19//   └── agents/   — built-in delegated-agent role prompts
20
21// ============================================================================
22// Default System Prompt
23// ============================================================================
24
25use crate::llm::LlmClient;
26use anyhow::Context;
27
28/// Default agentic system prompt — injected when no system prompt is configured.
29///
30/// Instructs the LLM to behave as an autonomous coding agent: use tools to act,
31/// verify results, and keep working until the task is fully complete.
32pub const SYSTEM_DEFAULT: &str = include_str!("../prompts/common/system_default.md");
33
34/// Continuation message — injected as a user turn when the LLM stops without
35/// completing the task (i.e. stops calling tools mid-task).
36pub const CONTINUATION: &str = include_str!("../prompts/common/continuation.md");
37
38/// Safety boundaries (injection hygiene, secret handling, malicious-code refusal).
39///
40/// Single source of truth, appended to every assembled system prompt by
41/// [`SystemPromptSlots::build_with_style`] so it applies uniformly across all
42/// agent styles and delegated subagents (which build through the same path).
43pub const BOUNDARIES: &str = include_str!("../prompts/common/boundaries.md");
44
45/// Canonical repository-tool arguments and context-efficient usage strategy.
46///
47/// Appended to writable / general styles so parameter and pagination guidance
48/// stay aligned with the registered schemas.
49pub const REPOSITORY_TOOL_CONTRACT: &str =
50    include_str!("../prompts/common/repository_tool_contract.md");
51
52/// Read-oriented repository-tool contract for specialty styles that must not
53/// advertise mutating tools while claiming a read-only role.
54pub const REPOSITORY_TOOL_CONTRACT_READONLY: &str =
55    include_str!("../prompts/common/repository_tool_contract_readonly.md");
56
57/// Shared runtime contract for authority, scope, run control, evidence, and
58/// completion semantics.
59///
60/// This contract is appended to every assembled prompt.  It describes the
61/// model-facing boundary; enforcement remains in the host runtime and tool
62/// implementations.
63pub const RUNTIME_CONTRACT: &str = include_str!("../prompts/common/runtime_contract.md");
64
65// ============================================================================
66// Delegated Run Prompts
67// ============================================================================
68
69/// Explore delegated run — read-only codebase exploration
70pub const AGENT_EXPLORE: &str = include_str!("../prompts/agents/explore.md");
71
72/// Plan delegated run — read-only planning and analysis
73pub const AGENT_PLAN: &str = include_str!("../prompts/agents/plan.md");
74
75/// Code review delegated run — issue finding and review focus
76pub const AGENT_CODE_REVIEW: &str = include_str!("../prompts/agents/code_review.md");
77
78// ============================================================================
79// Session — Context Compaction
80// ============================================================================
81
82/// User template for context compaction. Placeholder: `{conversation}`
83pub const CONTEXT_COMPACT: &str = include_str!("../prompts/common/context_compact.md");
84
85/// Prefix for compacted summary messages
86pub const CONTEXT_SUMMARY_PREFIX: &str =
87    include_str!("../prompts/common/context_summary_prefix.md");
88
89// ============================================================================
90// LLM Planner — JSON-structured prompts
91// ============================================================================
92
93/// System prompt for LLM planner: plan creation (JSON output)
94pub const LLM_PLAN_SYSTEM: &str = include_str!("../prompts/planning/llm_plan_system.md");
95
96/// System prompt for LLM planner: goal extraction (JSON output)
97pub const LLM_GOAL_EXTRACT_SYSTEM: &str =
98    include_str!("../prompts/planning/llm_goal_extract_system.md");
99
100/// System prompt for LLM planner: goal achievement check (JSON output)
101pub const LLM_GOAL_CHECK_SYSTEM: &str =
102    include_str!("../prompts/planning/llm_goal_check_system.md");
103
104/// System prompt for pre-analysis: combined intent + goal + plan + input optimization.
105pub const PRE_ANALYSIS_SYSTEM: &str = include_str!("../prompts/analysis/pre_analysis_system.md");
106
107// ============================================================================
108// Plan Execution Templates
109// ============================================================================
110
111/// Template for initial plan execution message
112pub const PLAN_EXECUTE_GOAL: &str = include_str!("../prompts/planning/plan_execute_goal.md");
113
114/// Template for per-step execution prompt
115pub const PLAN_EXECUTE_STEP: &str = include_str!("../prompts/planning/plan_execute_step.md");
116
117/// Skill catalog header injected before listing available skill names/descriptions.
118pub const SKILLS_CATALOG_HEADER: &str = include_str!("../prompts/common/skills_catalog_header.md");
119
120// ============================================================================
121// Verification Agent
122// ============================================================================
123
124/// Verification agent — adversarial specialist that tries to break code
125pub const AGENT_VERIFICATION: &str = include_str!("../prompts/agents/verification.md");
126
127// ============================================================================
128// Intent Classification
129// ============================================================================
130
131/// System prompt for LLM-based intent classification
132pub const INTENT_CLASSIFY_SYSTEM: &str =
133    include_str!("../prompts/analysis/intent_classify_system.md");
134
135// ============================================================================
136// Planning Mode (Auto-Detection)
137// ============================================================================
138
139use serde::{Deserialize, Serialize};
140
141/// Planning mode — controls when planning phase is used.
142///
143/// When set to `Auto` (the default), the system detects from the user's
144/// message whether planning should be enabled. When explicitly `Enabled`,
145/// planning runs on every execution. When `Disabled`, planning is skipped.
146#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
147pub enum PlanningMode {
148    /// Automatically detect from message content — enables planning when the
149    /// message benefits from structured pre-analysis. Local keyword detection is
150    /// only a fallback when pre-analysis is unavailable.
151    #[default]
152    Auto,
153    /// Explicitly disabled — never use planning phase.
154    Disabled,
155    /// Explicitly enabled — always use planning phase.
156    Enabled,
157}
158
159impl PlanningMode {
160    /// Returns true for the local no-LLM fallback path.
161    ///
162    /// Normal agent execution runs pre-analysis in `Auto` mode and uses its
163    /// structured `requires_planning` decision instead of this heuristic.
164    pub fn should_plan(&self, message: &str) -> bool {
165        match self {
166            PlanningMode::Auto => AgentStyle::detect_from_message(message).requires_planning(),
167            PlanningMode::Enabled => true,
168            PlanningMode::Disabled => false,
169        }
170    }
171}
172
173// ============================================================================
174// Agent Style (Intent-Based Prompt Selection)
175// ============================================================================
176
177/// Agent style — determines which system prompt template is used.
178///
179/// Each style has a different focus and behavior, selected based on the user's
180/// apparent intent from their message.
181#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
182pub enum AgentStyle {
183    /// Default — general purpose coding agent for research and multi-step tasks.
184    #[default]
185    GeneralPurpose,
186    /// Read-only planning and architecture analysis.
187    /// Prohibited from modifying files, focuses on design and planning.
188    Plan,
189    /// Adversarial verification specialist — tries to break code, not confirm it works.
190    Verification,
191    /// Fast file search and codebase exploration.
192    /// Read-only, optimized for finding files and patterns quickly.
193    Explore,
194    /// Code review focused — analyzes code quality, best practices, potential issues.
195    CodeReview,
196}
197
198/// Detection confidence level for style detection.
199#[derive(Debug, Clone, Copy, PartialEq, Eq)]
200pub enum DetectionConfidence {
201    /// High confidence — very specific keywords, skip LLM classification.
202    High,
203    /// Medium confidence — some indicators present, LLM classification helpful.
204    Medium,
205    /// Low confidence — no clear indicators, LLM classification recommended.
206    Low,
207}
208
209impl AgentStyle {
210    /// Returns true if this style is a specialty read-oriented role whose
211    /// prompt must not advertise workspace mutations as normal work.
212    pub fn uses_readonly_tool_contract(&self) -> bool {
213        matches!(
214            self,
215            AgentStyle::Plan
216                | AgentStyle::Explore
217                | AgentStyle::Verification
218                | AgentStyle::CodeReview
219        )
220    }
221
222    /// Repository-tool contract fragment for this style.
223    pub fn repository_tool_contract(&self) -> &'static str {
224        if self.uses_readonly_tool_contract() {
225            REPOSITORY_TOOL_CONTRACT_READONLY
226        } else {
227            REPOSITORY_TOOL_CONTRACT
228        }
229    }
230
231    /// Returns the base system prompt for this style.
232    pub fn base_prompt(&self) -> &'static str {
233        match self {
234            AgentStyle::GeneralPurpose => SYSTEM_DEFAULT,
235            AgentStyle::Plan => AGENT_PLAN,
236            AgentStyle::Verification => AGENT_VERIFICATION,
237            AgentStyle::Explore => AGENT_EXPLORE,
238            AgentStyle::CodeReview => AGENT_CODE_REVIEW,
239        }
240    }
241
242    /// Returns style-specific guidelines if any.
243    pub fn guidelines(&self) -> Option<&'static str> {
244        match self {
245            AgentStyle::GeneralPurpose => None,
246            AgentStyle::Plan => None, // Already embedded in agents/plan.md
247            AgentStyle::Verification => None, // Already embedded in agents/verification.md
248            AgentStyle::Explore => None, // Already embedded in agents/explore.md
249            AgentStyle::CodeReview => None,
250        }
251    }
252
253    /// Returns a one-line description of this style.
254    pub fn description(&self) -> &'static str {
255        match self {
256            AgentStyle::GeneralPurpose => {
257                "General purpose coding agent for research and multi-step tasks"
258            }
259            AgentStyle::Plan => "Read-only planning and architecture analysis agent",
260            AgentStyle::Verification => "Adversarial verification specialist — tries to break code",
261            AgentStyle::Explore => "Fast read-only file search and codebase exploration agent",
262            AgentStyle::CodeReview => "Code review focused — analyzes quality and best practices",
263        }
264    }
265
266    /// Returns the canonical built-in delegated-agent name for this style.
267    pub fn builtin_agent_name(&self) -> &'static str {
268        match self {
269            AgentStyle::GeneralPurpose => "general",
270            AgentStyle::Plan => "plan",
271            AgentStyle::Verification => "verification",
272            AgentStyle::Explore => "explore",
273            AgentStyle::CodeReview => "review",
274        }
275    }
276
277    /// Returns the stable runtime mode label for UI/event consumers.
278    pub fn runtime_mode(&self) -> &'static str {
279        match self {
280            AgentStyle::GeneralPurpose => "general",
281            AgentStyle::Plan => "planning",
282            AgentStyle::Verification => "verification",
283            AgentStyle::Explore => "explore",
284            AgentStyle::CodeReview => "code_review",
285        }
286    }
287
288    /// Returns true if this style benefits from a planning phase.
289    ///
290    /// Planning is beneficial for styles that involve multi-step execution
291    /// or where a structured approach improves outcomes.
292    pub fn requires_planning(&self) -> bool {
293        matches!(self, AgentStyle::Plan)
294    }
295
296    /// Detects the most appropriate agent style based on user message content,
297    /// along with a confidence level.
298    ///
299    /// This is a local fallback for environments where LLM pre-analysis is
300    /// unavailable. Normal execution uses structured pre-analysis first.
301    pub fn detect_with_confidence(message: &str) -> (Self, DetectionConfidence) {
302        // Chinese text has high ambiguity in intent classification due to
303        // compound verb structures and context-dependent meaning.
304        // Bypass keyword matching entirely and route to LLM classification.
305        if message
306            .chars()
307            .any(|c| ('\u{4e00}'..='\u{9fff}').contains(&c))
308        {
309            return (AgentStyle::GeneralPurpose, DetectionConfidence::Low);
310        }
311
312        let lower = message.to_lowercase();
313
314        // === HIGH CONFIDENCE: Very specific patterns ===
315
316        // Strong verification indicators
317        if lower.contains("try to break")
318            || lower.contains("find vulnerabilities")
319            || lower.contains("adversarial")
320            || lower.contains("security audit")
321        {
322            return (AgentStyle::Verification, DetectionConfidence::High);
323        }
324
325        // Strong plan indicators
326        if lower.contains("help me plan")
327            || lower.contains("help me design")
328            || lower.contains("create a plan")
329            || lower.contains("implementation plan")
330            || lower.contains("step-by-step plan")
331        {
332            return (AgentStyle::Plan, DetectionConfidence::High);
333        }
334
335        // Strong exploration indicators
336        if lower.contains("find all files")
337            || lower.contains("search for all")
338            || lower.contains("locate all")
339        {
340            return (AgentStyle::Explore, DetectionConfidence::High);
341        }
342
343        // === MEDIUM CONFIDENCE: Specific but less definitive ===
344
345        // Verification keywords
346        if lower.contains("verify")
347            || lower.contains("verification")
348            || lower.contains("break")
349            || lower.contains("debug")
350            || lower.contains("test")
351            || lower.contains("check if")
352        {
353            return (AgentStyle::Verification, DetectionConfidence::Medium);
354        }
355
356        // Plan keywords
357        if lower.contains("plan")
358            || lower.contains("design")
359            || lower.contains("architecture")
360            || lower.contains("approach")
361        {
362            return (AgentStyle::Plan, DetectionConfidence::Medium);
363        }
364
365        // Explore keywords
366        if lower.contains("find")
367            || lower.contains("search")
368            || lower.contains("where is")
369            || lower.contains("where's")
370            || lower.contains("locate")
371            || lower.contains("explore")
372            || lower.contains("look for")
373        {
374            return (AgentStyle::Explore, DetectionConfidence::Medium);
375        }
376
377        // Code review keywords
378        if lower.contains("review")
379            || lower.contains("code review")
380            || lower.contains("analyze")
381            || lower.contains("assess")
382            || lower.contains("quality")
383            || lower.contains("best practice")
384        {
385            return (AgentStyle::CodeReview, DetectionConfidence::Medium);
386        }
387
388        // No clear indicators
389        (AgentStyle::GeneralPurpose, DetectionConfidence::Low)
390    }
391
392    /// Detects the most appropriate agent style based on user message content.
393    ///
394    /// This is a local fallback heuristic. Normal execution uses structured
395    /// pre-analysis first; users can also explicitly set the style via
396    /// `SystemPromptSlots::with_style()`.
397    pub fn detect_from_message(message: &str) -> Self {
398        Self::detect_with_confidence(message).0
399    }
400
401    /// Classifies user intent using LLM when keyword confidence is low.
402    ///
403    /// This helper is available to callers that want explicit one-shot intent
404    /// classification outside the main pre-analysis path.
405    ///
406    /// Uses a lightweight classification prompt that returns a single word.
407    pub async fn detect_with_llm(llm: &dyn LlmClient, message: &str) -> anyhow::Result<Self> {
408        use crate::llm::Message;
409
410        let system = INTENT_CLASSIFY_SYSTEM;
411        let messages = vec![Message::user(message)];
412
413        let response = llm
414            .complete(&messages, Some(system), &[])
415            .await
416            .context("LLM intent classification failed")?;
417
418        let text = response.text().trim().to_lowercase();
419
420        let style = match text.as_str() {
421            "plan" => AgentStyle::Plan,
422            "explore" => AgentStyle::Explore,
423            "verification" => AgentStyle::Verification,
424            "codereview" | "code review" => AgentStyle::CodeReview,
425            _ => AgentStyle::GeneralPurpose,
426        };
427
428        Ok(style)
429    }
430}
431
432// ============================================================================
433// System Prompt Slots
434// ============================================================================
435
436/// Slot-based system prompt customization with intent-based style selection.
437///
438/// Users can customize specific parts of the system prompt without overriding
439/// the core agentic capabilities (tool usage, autonomous behavior, completion
440/// criteria). The default agentic core is ALWAYS included.
441///
442/// ## Assembly Order
443///
444/// ```text
445/// [role]            ← Custom identity/role (e.g. "You are a Python expert")
446/// [CORE]            ← Always present: Core Behaviour + Tool Usage Strategy + Completion Criteria
447/// [runtime]         ← Host authority, scope, run-control, evidence contract
448/// [repository]      ← Canonical repository-tool schemas and usage rules
449/// [boundaries]      ← Injection, secret, and malicious-code boundaries
450/// [guidelines]      ← Custom coding rules / constraints
451/// [response_style]  ← Custom response format (replaces default Response Format section)
452/// [extra]           ← Freeform additional instructions
453/// ```
454///
455/// Host-customizable edges around the Core default prompt pack.
456///
457/// The core agentic loop, runtime contract, repository-tool schema, safety
458/// boundaries, and style→permission overlays always remain authoritative.
459/// Specialty markdown bodies are a default pack only — not a capability source
460/// and not a reviewer/rubric owner (`HARNESS-CONV6` / `PROMPT-ALIGN1`).
461///
462/// ## Intent-Based Selection
463///
464/// When `style` is left as `AgentStyle::GeneralPurpose` (the default), the
465/// system will attempt to detect the user's intent from their first message and
466/// automatically select an appropriate style. To override this behavior, explicitly
467/// set the `style` field.
468#[derive(Debug, Clone, Default)]
469pub struct SystemPromptSlots {
470    /// Agent style — determines which base prompt template is used.
471    ///
472    /// When `None` (default), the style is auto-detected from the user's message.
473    /// Explicitly set this to force a particular style regardless of message content.
474    pub style: Option<AgentStyle>,
475
476    /// Custom role/identity prepended before the core prompt.
477    ///
478    /// Example: "You are a senior Python developer specializing in FastAPI."
479    /// When set, replaces the default "You are A3S Code, an expert AI coding agent" line.
480    pub role: Option<String>,
481
482    /// Custom coding guidelines appended after the core prompt sections.
483    ///
484    /// Example: "Always use type hints. Follow PEP 8. Prefer dataclasses over dicts."
485    pub guidelines: Option<String>,
486
487    /// Custom response style that replaces the default "Response Format" section.
488    ///
489    /// When `None`, the default response format is used.
490    pub response_style: Option<String>,
491
492    /// Explicit user-facing reply language as a BCP-47 tag (for example `zh-CN`
493    /// or `en-US`). When set, overrides the soft "match the user's language"
494    /// default so hosts can pin replies to the product UI locale.
495    pub output_language: Option<String>,
496
497    /// Freeform extra instructions appended at the very end.
498    pub extra: Option<String>,
499}
500
501/// The default role line in SYSTEM_DEFAULT that gets replaced when `role` slot is set.
502const DEFAULT_ROLE_LINE: &str = include_str!("../prompts/common/system_default_role_line.md");
503
504/// The default response format section.
505const DEFAULT_RESPONSE_FORMAT: &str =
506    include_str!("../prompts/common/system_default_response_format.md");
507
508impl SystemPromptSlots {
509    /// Build the final system prompt by assembling slots around the core prompt.
510    ///
511    /// The core agentic behavior, runtime contract, shared repository-tool
512    /// contract, and safety boundaries are always preserved. Users can only
513    /// customize the edges.
514    ///
515    /// Note: This uses `AgentStyle::GeneralPurpose` as the base. Use
516    /// `build_with_message()` to enable automatic intent-based style detection.
517    pub fn build(&self) -> String {
518        self.build_with_style(self.style.unwrap_or_default())
519    }
520
521    /// Build the final system prompt, auto-detecting style from the initial message.
522    ///
523    /// If `self.style` is explicitly set, that style is used regardless of message content.
524    /// Otherwise, the style is detected from `initial_message` using keyword analysis.
525    pub fn build_with_message(&self, initial_message: &str) -> String {
526        let style = self
527            .style
528            .unwrap_or_else(|| AgentStyle::detect_from_message(initial_message));
529        self.build_with_style(style)
530    }
531
532    /// Build the prompt with an explicitly specified style.
533    fn build_with_style(&self, style: AgentStyle) -> String {
534        let mut parts: Vec<String> = Vec::new();
535
536        // Normalize line endings: strip \r so string matching works on Windows
537        // where include_str! may produce \r\n if the file has CRLF endings.
538        let base_prompt = style.base_prompt().replace('\r', "");
539        let default_role_line = DEFAULT_ROLE_LINE.replace('\r', "");
540        let default_response_format = DEFAULT_RESPONSE_FORMAT.replace('\r', "");
541
542        // 1. Role: for GeneralPurpose, replace the default role line.
543        // For other styles (Plan, Explore, Verification), prepend custom role since
544        // those prompts have their own identity embedded.
545        let core = if let Some(ref role) = self.role {
546            if style == AgentStyle::GeneralPurpose {
547                let custom_role = format!(
548                    "{} Working inside the user's workspace, use the tools exposed in the current turn to inspect, change, and verify the requested work; continue until the user's request is genuinely complete.",
549                    role.trim_end_matches('.')
550                );
551                base_prompt.replace(&default_role_line, &custom_role)
552            } else {
553                // Prepend custom role for other styles
554                format!("{}\n\n{}", role, base_prompt)
555            }
556        } else {
557            base_prompt
558        };
559
560        // 2. Core: strip the default response format section if custom one is provided
561        let core = if self.response_style.is_some() {
562            core.replace(&default_response_format, "")
563                .trim_end()
564                .to_string()
565        } else {
566            core.trim_end().to_string()
567        };
568
569        parts.push(core);
570
571        // 2b. Runtime contract — shared by every style so authority, run
572        // control, and evidence semantics cannot drift between built-in agents.
573        parts.push(RUNTIME_CONTRACT.replace('\r', "").trim_end().to_string());
574
575        // 2c. Repository-tool contract — style-scoped so read-oriented roles do
576        // not advertise mutating tools while claiming a read-only task.
577        parts.push(
578            style
579                .repository_tool_contract()
580                .replace('\r', "")
581                .trim_end()
582                .to_string(),
583        );
584
585        // 2d. Safety boundaries — single source of truth, appended uniformly so
586        // every style and delegated subagent (which build through this path)
587        // carries injection-hygiene, secret-handling, and malware-refusal rules.
588        parts.push(BOUNDARIES.replace('\r', "").trim_end().to_string());
589
590        // 2e. Explicit output language — host-pinned product language for UI
591        // locales. Covers every user-visible prose surface (replies, reasoning,
592        // plans). When absent, the soft "user's language" rule remains.
593        if let Some(language) = self
594            .output_language
595            .as_deref()
596            .map(str::trim)
597            .filter(|value| !value.is_empty())
598        {
599            parts.push(output_language_contract(language));
600        }
601
602        // 3. Custom response style (replaces default Response Format)
603        if let Some(ref style) = self.response_style {
604            parts.push(format!("## Response Format\n\n{}", style));
605        }
606
607        // 4. Guidelines: style-specific + custom
608        let style_guidelines = style.guidelines();
609        if style_guidelines.is_some() || self.guidelines.is_some() {
610            let mut guidelines_parts = Vec::new();
611            if let Some(sg) = style_guidelines {
612                guidelines_parts.push(sg.to_string());
613            }
614            if let Some(ref g) = self.guidelines {
615                guidelines_parts.push(g.clone());
616            }
617            parts.push(format!(
618                "## Guidelines\n\n{}",
619                guidelines_parts.join("\n\n")
620            ));
621        }
622
623        // 5. Extra freeform instructions.
624        if let Some(ref extra) = self.extra {
625            parts.push(extra.clone());
626        }
627
628        parts.join("\n\n")
629    }
630
631    /// Returns true if all slots are empty (use pure default prompt).
632    pub fn is_empty(&self) -> bool {
633        self.style.is_none()
634            && self.role.is_none()
635            && self.guidelines.is_none()
636            && self.response_style.is_none()
637            && self.output_language.is_none()
638            && self.extra.is_none()
639    }
640
641    /// Set the agent style explicitly.
642    pub fn with_style(mut self, style: AgentStyle) -> Self {
643        self.style = Some(style);
644        self
645    }
646
647    /// Set the role/identity.
648    pub fn with_role(mut self, role: impl Into<String>) -> Self {
649        self.role = Some(role.into());
650        self
651    }
652
653    /// Set custom guidelines.
654    pub fn with_guidelines(mut self, guidelines: impl Into<String>) -> Self {
655        self.guidelines = Some(guidelines.into());
656        self
657    }
658
659    /// Set custom response style.
660    pub fn with_response_style(mut self, style: impl Into<String>) -> Self {
661        self.response_style = Some(style.into());
662        self
663    }
664
665    /// Pin user-facing replies to a BCP-47 language tag (for example `zh-CN`).
666    pub fn with_output_language(mut self, language: impl Into<String>) -> Self {
667        let language = language.into();
668        self.output_language = (!language.trim().is_empty()).then_some(language);
669        self
670    }
671
672    /// Set extra instructions.
673    pub fn with_extra(mut self, extra: impl Into<String>) -> Self {
674        self.extra = Some(extra.into());
675        self
676    }
677}
678
679/// Single product-language contract for every user-visible prose surface.
680///
681/// Hosts pin a BCP-47 tag once; replies, reasoning/thinking, plans, goals, and
682/// step descriptions all follow it. Identifiers and quoted source stay as-is.
683/// Keep this text in one place so planner prompts and the main system prompt
684/// cannot drift.
685pub fn output_language_contract(language: &str) -> String {
686    format!(
687        "## Output Language\n\n\
688Write all user-visible product prose in {language}: replies, reasoning/thinking, \
689plans, goals, step descriptions, and status updates. Use that one language only; \
690do not mix languages across those surfaces.\n\
691Keep code, identifiers, file paths, commands, URLs, and quoted source text in their original form.\n\
692Do not switch languages because tool output or retrieved sources use another language."
693    )
694}
695
696/// Resolve the language for planning/goal JSON prose.
697///
698/// Prefer an explicit host pin; otherwise infer from the user request so a
699/// Chinese prompt does not yield an English plan when the host forgot to pin.
700pub fn resolve_product_output_language(pinned: Option<&str>, user_text: &str) -> Option<String> {
701    pinned
702        .map(str::trim)
703        .filter(|value| !value.is_empty())
704        .map(str::to_owned)
705        .or_else(|| infer_user_reply_language(user_text).map(str::to_owned))
706}
707
708/// Infer a BCP-47 reply-language tag from user-authored text.
709///
710/// Uses script counts only (no NLP). Returns `None` when the sample is too
711/// short or script evidence is inconclusive, so hosts can keep the previous
712/// pin or the soft "match the user" rule.
713pub fn infer_user_reply_language(text: &str) -> Option<&'static str> {
714    let mut han = 0u32;
715    let mut kana = 0u32;
716    let mut hangul = 0u32;
717    let mut latin = 0u32;
718
719    for ch in text.chars() {
720        if ch.is_ascii_alphabetic() {
721            latin = latin.saturating_add(1);
722            continue;
723        }
724        let code = ch as u32;
725        // CJK Unified Ideographs + Extension A (common Han)
726        if (0x4E00..=0x9FFF).contains(&code) || (0x3400..=0x4DBF).contains(&code) {
727            han = han.saturating_add(1);
728        } else if (0x3040..=0x30FF).contains(&code) {
729            // Hiragana + Katakana
730            kana = kana.saturating_add(1);
731        } else if (0xAC00..=0xD7AF).contains(&code) || (0x1100..=0x11FF).contains(&code) {
732            hangul = hangul.saturating_add(1);
733        }
734    }
735
736    let marked = han + kana + hangul + latin;
737    if marked < 2 {
738        return None;
739    }
740
741    // Prefer non-Latin scripts when the user clearly wrote in them, even if
742    // English identifiers appear ("帮我 fix 这个 bug").
743    if hangul >= 2 && hangul >= han && hangul >= kana {
744        return Some("ko");
745    }
746    if kana >= 2 {
747        return Some("ja");
748    }
749    if han >= 2 {
750        return Some("zh-CN");
751    }
752    // Short English acknowledgements ("ok", "thanks") must not flip an
753    // established reply language; require a stronger Latin sample.
754    if latin >= 12 {
755        return Some("en");
756    }
757    None
758}
759
760// ============================================================================
761// Helper Functions
762// ============================================================================
763
764/// Render a template by replacing `{key}` placeholders with values
765pub fn render(template: &str, vars: &[(&str, &str)]) -> String {
766    let mut result = template.to_string();
767    for (key, value) in vars {
768        result = result.replace(&format!("{{{}}}", key), value);
769    }
770    result
771}
772
773#[cfg(test)]
774#[path = "prompts/tests.rs"]
775mod tests;