Skip to main content

construct/agent/
prompt.rs

1use crate::agent::personality;
2use crate::config::IdentityConfig;
3use crate::i18n::ToolDescriptions;
4use crate::identity;
5use crate::security::AutonomyLevel;
6use crate::skills::{Skill, SkillEffectivenessProvider};
7use crate::tools::Tool;
8use anyhow::Result;
9use chrono::{Datelike, Local, Timelike};
10use std::fmt::Write;
11use std::path::Path;
12
13pub struct PromptContext<'a> {
14    pub workspace_dir: &'a Path,
15    pub model_name: &'a str,
16    pub tools: &'a [Box<dyn Tool>],
17    pub skills: &'a [Skill],
18    pub skills_prompt_mode: crate::config::SkillsPromptInjectionMode,
19    /// Optional provider that returns recency-weighted success rates per
20    /// skill name.  When present, [`SkillsSection`] reranks skills by
21    /// effectiveness before injecting them — high-success skills bubble
22    /// to the top.  When `None` the static load order is preserved.
23    pub skill_effectiveness: Option<&'a dyn SkillEffectivenessProvider>,
24    pub identity_config: Option<&'a IdentityConfig>,
25    pub dispatcher_instructions: &'a str,
26    /// Locale-aware tool descriptions. When present, tool descriptions in
27    /// prompts are resolved from the locale file instead of hardcoded values.
28    pub tool_descriptions: Option<&'a ToolDescriptions>,
29    /// Pre-rendered security policy summary for inclusion in the Safety
30    /// prompt section.  When present, the LLM sees the concrete constraints
31    /// (allowed commands, forbidden paths, autonomy level) so it can plan
32    /// tool calls without trial-and-error.  See issue #2404.
33    pub security_summary: Option<String>,
34    /// Autonomy level from config. Controls whether the safety section
35    /// includes "ask before acting" instructions. Full autonomy omits them
36    /// so the model executes tools directly without simulating approval.
37    pub autonomy_level: AutonomyLevel,
38    /// Whether Operator orchestration is enabled. When true, the
39    /// `OperatorIdentitySection` renders the operator-first identity
40    /// at the top of the system prompt.
41    pub operator_enabled: bool,
42    /// Whether Kumiho memory is enabled.
43    pub kumiho_enabled: bool,
44}
45
46pub trait PromptSection: Send + Sync {
47    fn name(&self) -> &str;
48    fn build(&self, ctx: &PromptContext<'_>) -> Result<String>;
49}
50
51#[derive(Default)]
52pub struct SystemPromptBuilder {
53    sections: Vec<Box<dyn PromptSection>>,
54}
55
56impl SystemPromptBuilder {
57    pub fn with_defaults() -> Self {
58        Self {
59            sections: vec![
60                Box::new(DateTimeSection),
61                Box::new(IdentitySection), // Persona first (SOUL.md) — voice/character
62                Box::new(OperatorIdentitySection), // Orchestration capabilities (role)
63                Box::new(KumihoBootstrapSection), // Kumiho memory instructions
64                Box::new(ToolHonestySection),
65                Box::new(ToolsSection),
66                Box::new(SafetySection),
67                Box::new(SkillsSection),
68                Box::new(WorkspaceSection),
69                Box::new(RuntimeSection),
70                Box::new(ChannelMediaSection),
71            ],
72        }
73    }
74
75    pub fn add_section(mut self, section: Box<dyn PromptSection>) -> Self {
76        self.sections.push(section);
77        self
78    }
79
80    pub fn build(&self, ctx: &PromptContext<'_>) -> Result<String> {
81        let mut output = String::new();
82        for section in &self.sections {
83            let part = section.build(ctx)?;
84            if part.trim().is_empty() {
85                continue;
86            }
87            output.push_str(part.trim_end());
88            output.push_str("\n\n");
89        }
90        Ok(output)
91    }
92}
93
94pub struct OperatorIdentitySection;
95pub struct KumihoBootstrapSection;
96pub struct IdentitySection;
97pub struct ToolHonestySection;
98pub struct ToolsSection;
99pub struct SafetySection;
100pub struct SkillsSection;
101pub struct WorkspaceSection;
102pub struct RuntimeSection;
103pub struct DateTimeSection;
104pub struct ChannelMediaSection;
105
106impl PromptSection for OperatorIdentitySection {
107    fn name(&self) -> &str {
108        "operator_identity"
109    }
110
111    fn build(&self, ctx: &PromptContext<'_>) -> Result<String> {
112        if !ctx.operator_enabled {
113            return Ok(String::new());
114        }
115        Ok(crate::agent::operator::build_operator_prompt(
116            ctx.model_name,
117        ))
118    }
119}
120
121impl PromptSection for KumihoBootstrapSection {
122    fn name(&self) -> &str {
123        "kumiho_bootstrap"
124    }
125
126    fn build(&self, ctx: &PromptContext<'_>) -> Result<String> {
127        if !ctx.kumiho_enabled {
128            return Ok(String::new());
129        }
130        Ok(crate::agent::kumiho::KUMIHO_BOOTSTRAP_PROMPT.to_string())
131    }
132}
133
134impl PromptSection for IdentitySection {
135    fn name(&self) -> &str {
136        "identity"
137    }
138
139    fn build(&self, ctx: &PromptContext<'_>) -> Result<String> {
140        let mut prompt = String::from("## Project Context\n\n");
141        let mut has_aieos = false;
142        if let Some(config) = ctx.identity_config {
143            if identity::is_aieos_configured(config) {
144                if let Ok(Some(aieos)) = identity::load_aieos_identity(config, ctx.workspace_dir) {
145                    let rendered = identity::aieos_to_system_prompt(&aieos);
146                    if !rendered.is_empty() {
147                        prompt.push_str(&rendered);
148                        prompt.push_str("\n\n");
149                        has_aieos = true;
150                    }
151                }
152            }
153        }
154
155        if !has_aieos {
156            prompt.push_str(
157                "The following workspace files define your identity, behavior, and context.\n\n",
158            );
159        }
160
161        // Use the personality module for structured file loading.
162        let profile = personality::load_personality(ctx.workspace_dir);
163        prompt.push_str(&profile.render());
164
165        Ok(prompt)
166    }
167}
168
169impl PromptSection for ToolHonestySection {
170    fn name(&self) -> &str {
171        "tool_honesty"
172    }
173
174    fn build(&self, _ctx: &PromptContext<'_>) -> Result<String> {
175        Ok(
176            "## CRITICAL: Tool Honesty\n\n\
177             - NEVER fabricate, invent, or guess tool results. If a tool returns empty results, say \"No results found.\"\n\
178             - If a tool call fails, report the error — never make up data to fill the gap.\n\
179             - When unsure whether a tool call succeeded, ask the user rather than guessing."
180                .into(),
181        )
182    }
183}
184
185impl PromptSection for ToolsSection {
186    fn name(&self) -> &str {
187        "tools"
188    }
189
190    fn build(&self, ctx: &PromptContext<'_>) -> Result<String> {
191        let mut out = String::from("## Tools\n\n");
192        for tool in ctx.tools {
193            let desc = ctx
194                .tool_descriptions
195                .and_then(|td: &ToolDescriptions| td.get(tool.name()))
196                .unwrap_or_else(|| tool.description());
197            let _ = writeln!(
198                out,
199                "- **{}**: {}\n  Parameters: `{}`",
200                tool.name(),
201                desc,
202                tool.parameters_schema()
203            );
204        }
205        if !ctx.dispatcher_instructions.is_empty() {
206            out.push('\n');
207            out.push_str(ctx.dispatcher_instructions);
208        }
209        Ok(out)
210    }
211}
212
213impl PromptSection for SafetySection {
214    fn name(&self) -> &str {
215        "safety"
216    }
217
218    fn build(&self, ctx: &PromptContext<'_>) -> Result<String> {
219        let mut out = String::from("## Safety\n\n- Do not exfiltrate private data.\n");
220
221        // Omit "ask before acting" instructions when autonomy is Full —
222        // mirrors build_system_prompt_with_mode_and_autonomy. See #3952.
223        if ctx.autonomy_level != AutonomyLevel::Full {
224            out.push_str(
225                "- Do not run destructive commands without asking.\n\
226                 - Do not bypass oversight or approval mechanisms.\n",
227            );
228        }
229
230        out.push_str("- Prefer `trash` over `rm`.\n");
231        out.push_str(match ctx.autonomy_level {
232            AutonomyLevel::Full => {
233                "- Execute tools and actions directly — no extra approval needed.\n\
234                 - You have full access to all configured tools. Use them confidently to accomplish tasks.\n\
235                 - Only refuse an action if the runtime explicitly rejects it — do not preemptively decline."
236            }
237            AutonomyLevel::ReadOnly => {
238                "- This runtime is read-only. Write operations will be rejected by the runtime if attempted.\n\
239                 - Use read-only tools freely and confidently."
240            }
241            AutonomyLevel::Supervised => {
242                "- Ask for approval when the runtime policy requires it for the specific action.\n\
243                 - Do not preemptively refuse actions — attempt them and let the runtime enforce restrictions.\n\
244                 - Use available tools confidently; the security policy will enforce boundaries."
245            }
246        });
247
248        // Append concrete security policy constraints when available (#2404).
249        // This tells the LLM exactly what commands are allowed, which paths
250        // are off-limits, etc. — preventing wasteful trial-and-error.
251        if let Some(ref summary) = ctx.security_summary {
252            out.push_str("\n\n### Active Security Policy\n\n");
253            out.push_str(summary);
254        }
255
256        Ok(out)
257    }
258}
259
260impl PromptSection for SkillsSection {
261    fn name(&self) -> &str {
262        "skills"
263    }
264
265    fn build(&self, ctx: &PromptContext<'_>) -> Result<String> {
266        let prompt = match ctx.skill_effectiveness {
267            Some(provider) => crate::skills::skills_to_prompt_with_mode_and_effectiveness(
268                ctx.skills,
269                ctx.workspace_dir,
270                ctx.skills_prompt_mode,
271                provider,
272            ),
273            None => crate::skills::skills_to_prompt_with_mode(
274                ctx.skills,
275                ctx.workspace_dir,
276                ctx.skills_prompt_mode,
277            ),
278        };
279        Ok(prompt)
280    }
281}
282
283impl PromptSection for WorkspaceSection {
284    fn name(&self) -> &str {
285        "workspace"
286    }
287
288    fn build(&self, ctx: &PromptContext<'_>) -> Result<String> {
289        Ok(format!(
290            "## Workspace\n\nWorking directory: `{}`",
291            ctx.workspace_dir.display()
292        ))
293    }
294}
295
296impl PromptSection for RuntimeSection {
297    fn name(&self) -> &str {
298        "runtime"
299    }
300
301    fn build(&self, ctx: &PromptContext<'_>) -> Result<String> {
302        let host =
303            hostname::get().map_or_else(|_| "unknown".into(), |h| h.to_string_lossy().to_string());
304        Ok(format!(
305            "## Runtime\n\nHost: {host} | OS: {} | Model: {}",
306            std::env::consts::OS,
307            ctx.model_name
308        ))
309    }
310}
311
312impl PromptSection for DateTimeSection {
313    fn name(&self) -> &str {
314        "datetime"
315    }
316
317    fn build(&self, _ctx: &PromptContext<'_>) -> Result<String> {
318        let now = Local::now();
319        // Force Gregorian year to avoid confusion with local calendars (e.g. Buddhist calendar).
320        let (year, month, day) = (now.year(), now.month(), now.day());
321        let (hour, minute, second) = (now.hour(), now.minute(), now.second());
322        let tz = now.format("%Z");
323
324        Ok(format!(
325            "## CRITICAL CONTEXT: CURRENT DATE & TIME\n\n\
326             The following is the ABSOLUTE TRUTH regarding the current date and time. \
327             Use this for all relative time calculations (e.g. \"last 7 days\").\n\n\
328             Date: {year:04}-{month:02}-{day:02}\n\
329             Time: {hour:02}:{minute:02}:{second:02} ({tz})\n\
330             ISO 8601: {year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}{}",
331            now.format("%:z")
332        ))
333    }
334}
335
336impl PromptSection for ChannelMediaSection {
337    fn name(&self) -> &str {
338        "channel_media"
339    }
340
341    fn build(&self, _ctx: &PromptContext<'_>) -> Result<String> {
342        Ok("## Channel Media Markers\n\n\
343            Messages from channels may contain media markers:\n\
344            - `[Voice] <text>` — The user sent a voice/audio message that has already been transcribed to text. Respond to the transcribed content directly.\n\
345            - `[IMAGE:<path>]` — An image attachment, processed by the vision pipeline.\n\
346            - `[Document: <name>] <path>` — A file attachment saved to the workspace."
347            .into())
348    }
349}
350
351#[cfg(test)]
352mod tests {
353    use super::*;
354    use crate::tools::traits::Tool;
355    use async_trait::async_trait;
356
357    struct TestTool;
358
359    #[async_trait]
360    impl Tool for TestTool {
361        fn name(&self) -> &str {
362            "test_tool"
363        }
364
365        fn description(&self) -> &str {
366            "tool desc"
367        }
368
369        fn parameters_schema(&self) -> serde_json::Value {
370            serde_json::json!({"type": "object"})
371        }
372
373        async fn execute(
374            &self,
375            _args: serde_json::Value,
376        ) -> anyhow::Result<crate::tools::ToolResult> {
377            Ok(crate::tools::ToolResult {
378                success: true,
379                output: "ok".into(),
380                error: None,
381            })
382        }
383    }
384
385    #[test]
386    fn identity_section_with_aieos_includes_workspace_files() {
387        let workspace =
388            std::env::temp_dir().join(format!("construct_prompt_test_{}", uuid::Uuid::new_v4()));
389        std::fs::create_dir_all(&workspace).unwrap();
390        std::fs::write(
391            workspace.join("AGENTS.md"),
392            "Always respond with: AGENTS_MD_LOADED",
393        )
394        .unwrap();
395
396        let identity_config = crate::config::IdentityConfig {
397            format: "aieos".into(),
398            aieos_path: None,
399            aieos_inline: Some(r#"{"identity":{"names":{"first":"Nova"}}}"#.into()),
400        };
401
402        let tools: Vec<Box<dyn Tool>> = vec![];
403        let ctx = PromptContext {
404            workspace_dir: &workspace,
405            model_name: "test-model",
406            tools: &tools,
407            skills: &[],
408            skills_prompt_mode: crate::config::SkillsPromptInjectionMode::Full,
409            skill_effectiveness: None,
410            identity_config: Some(&identity_config),
411            dispatcher_instructions: "",
412            tool_descriptions: None,
413            security_summary: None,
414            autonomy_level: AutonomyLevel::Supervised,
415            operator_enabled: false,
416            kumiho_enabled: false,
417        };
418
419        let section = IdentitySection;
420        let output = section.build(&ctx).unwrap();
421
422        assert!(
423            output.contains("Nova"),
424            "AIEOS identity should be present in prompt"
425        );
426        assert!(
427            output.contains("AGENTS_MD_LOADED"),
428            "AGENTS.md content should be present even when AIEOS is configured"
429        );
430
431        let _ = std::fs::remove_dir_all(workspace);
432    }
433
434    #[test]
435    fn prompt_builder_assembles_sections() {
436        let tools: Vec<Box<dyn Tool>> = vec![Box::new(TestTool)];
437        let ctx = PromptContext {
438            workspace_dir: Path::new("/tmp"),
439            model_name: "test-model",
440            tools: &tools,
441            skills: &[],
442            skills_prompt_mode: crate::config::SkillsPromptInjectionMode::Full,
443            skill_effectiveness: None,
444            identity_config: None,
445            dispatcher_instructions: "instr",
446            tool_descriptions: None,
447            security_summary: None,
448            autonomy_level: AutonomyLevel::Supervised,
449            operator_enabled: false,
450            kumiho_enabled: false,
451        };
452        let prompt = SystemPromptBuilder::with_defaults().build(&ctx).unwrap();
453        assert!(prompt.contains("## Tools"));
454        assert!(prompt.contains("test_tool"));
455        assert!(prompt.contains("instr"));
456    }
457
458    #[test]
459    fn skills_section_includes_instructions_and_tools() {
460        let tools: Vec<Box<dyn Tool>> = vec![];
461        let skills = vec![crate::skills::Skill {
462            name: "deploy".into(),
463            description: "Release safely".into(),
464            version: "1.0.0".into(),
465            author: None,
466            tags: vec![],
467            tools: vec![crate::skills::SkillTool {
468                name: "release_checklist".into(),
469                description: "Validate release readiness".into(),
470                kind: "shell".into(),
471                command: "echo ok".into(),
472                args: std::collections::HashMap::new(),
473            }],
474            prompts: vec!["Run smoke tests before deploy.".into()],
475            location: None,
476        }];
477
478        let ctx = PromptContext {
479            workspace_dir: Path::new("/tmp"),
480            model_name: "test-model",
481            tools: &tools,
482            skills: &skills,
483            skills_prompt_mode: crate::config::SkillsPromptInjectionMode::Full,
484            skill_effectiveness: None,
485            identity_config: None,
486            dispatcher_instructions: "",
487            tool_descriptions: None,
488            security_summary: None,
489            autonomy_level: AutonomyLevel::Supervised,
490            operator_enabled: false,
491            kumiho_enabled: false,
492        };
493
494        let output = SkillsSection.build(&ctx).unwrap();
495        assert!(output.contains("<available_skills>"));
496        assert!(output.contains("<name>deploy</name>"));
497        assert!(output.contains("<instruction>Run smoke tests before deploy.</instruction>"));
498        // Registered tools (shell kind) appear under <callable_tools> with prefixed names
499        assert!(output.contains("<callable_tools"));
500        assert!(output.contains("<name>deploy.release_checklist</name>"));
501    }
502
503    #[test]
504    fn skills_section_compact_mode_omits_instructions_but_keeps_tools() {
505        let tools: Vec<Box<dyn Tool>> = vec![];
506        let skills = vec![crate::skills::Skill {
507            name: "deploy".into(),
508            description: "Release safely".into(),
509            version: "1.0.0".into(),
510            author: None,
511            tags: vec![],
512            tools: vec![crate::skills::SkillTool {
513                name: "release_checklist".into(),
514                description: "Validate release readiness".into(),
515                kind: "shell".into(),
516                command: "echo ok".into(),
517                args: std::collections::HashMap::new(),
518            }],
519            prompts: vec!["Run smoke tests before deploy.".into()],
520            location: Some(Path::new("/tmp/workspace/skills/deploy/SKILL.md").to_path_buf()),
521        }];
522
523        let ctx = PromptContext {
524            workspace_dir: Path::new("/tmp/workspace"),
525            model_name: "test-model",
526            tools: &tools,
527            skills: &skills,
528            skills_prompt_mode: crate::config::SkillsPromptInjectionMode::Compact,
529            skill_effectiveness: None,
530            identity_config: None,
531            dispatcher_instructions: "",
532            tool_descriptions: None,
533            security_summary: None,
534            autonomy_level: AutonomyLevel::Supervised,
535            operator_enabled: false,
536            kumiho_enabled: false,
537        };
538
539        let output = SkillsSection.build(&ctx).unwrap();
540        assert!(output.contains("<available_skills>"));
541        assert!(output.contains("<name>deploy</name>"));
542        assert!(output.contains("<location>skills/deploy/SKILL.md</location>"));
543        assert!(output.contains("read_skill(name)"));
544        assert!(!output.contains("<instruction>Run smoke tests before deploy.</instruction>"));
545        // Compact mode should still include tools so the LLM knows about them.
546        // Registered tools (shell kind) appear under <callable_tools> with prefixed names.
547        assert!(output.contains("<callable_tools"));
548        assert!(output.contains("<name>deploy.release_checklist</name>"));
549    }
550
551    #[test]
552    fn datetime_section_includes_timestamp_and_timezone() {
553        let tools: Vec<Box<dyn Tool>> = vec![];
554        let ctx = PromptContext {
555            workspace_dir: Path::new("/tmp"),
556            model_name: "test-model",
557            tools: &tools,
558            skills: &[],
559            skills_prompt_mode: crate::config::SkillsPromptInjectionMode::Full,
560            skill_effectiveness: None,
561            identity_config: None,
562            dispatcher_instructions: "instr",
563            tool_descriptions: None,
564            security_summary: None,
565            autonomy_level: AutonomyLevel::Supervised,
566            operator_enabled: false,
567            kumiho_enabled: false,
568        };
569
570        let rendered = DateTimeSection.build(&ctx).unwrap();
571        assert!(rendered.starts_with("## CRITICAL CONTEXT: CURRENT DATE & TIME\n\n"));
572
573        let payload = rendered.trim_start_matches("## CRITICAL CONTEXT: CURRENT DATE & TIME\n\n");
574        assert!(payload.chars().any(|c| c.is_ascii_digit()));
575        assert!(payload.contains("Date:"));
576        assert!(payload.contains("Time:"));
577    }
578
579    #[test]
580    fn prompt_builder_inlines_and_escapes_skills() {
581        let tools: Vec<Box<dyn Tool>> = vec![];
582        let skills = vec![crate::skills::Skill {
583            name: "code<review>&".into(),
584            description: "Review \"unsafe\" and 'risky' bits".into(),
585            version: "1.0.0".into(),
586            author: None,
587            tags: vec![],
588            tools: vec![crate::skills::SkillTool {
589                name: "run\"linter\"".into(),
590                description: "Run <lint> & report".into(),
591                kind: "shell&exec".into(),
592                command: "cargo clippy".into(),
593                args: std::collections::HashMap::new(),
594            }],
595            prompts: vec!["Use <tool_call> and & keep output \"safe\"".into()],
596            location: None,
597        }];
598        let ctx = PromptContext {
599            workspace_dir: Path::new("/tmp/workspace"),
600            model_name: "test-model",
601            tools: &tools,
602            skills: &skills,
603            skills_prompt_mode: crate::config::SkillsPromptInjectionMode::Full,
604            skill_effectiveness: None,
605            identity_config: None,
606            dispatcher_instructions: "",
607            tool_descriptions: None,
608            security_summary: None,
609            autonomy_level: AutonomyLevel::Supervised,
610            operator_enabled: false,
611            kumiho_enabled: false,
612        };
613
614        let prompt = SystemPromptBuilder::with_defaults().build(&ctx).unwrap();
615
616        assert!(prompt.contains("<available_skills>"));
617        assert!(prompt.contains("<name>code&lt;review&gt;&amp;</name>"));
618        assert!(prompt.contains(
619            "<description>Review &quot;unsafe&quot; and &apos;risky&apos; bits</description>"
620        ));
621        assert!(prompt.contains("<name>run&quot;linter&quot;</name>"));
622        assert!(prompt.contains("<description>Run &lt;lint&gt; &amp; report</description>"));
623        assert!(prompt.contains("<kind>shell&amp;exec</kind>"));
624        assert!(prompt.contains(
625            "<instruction>Use &lt;tool_call&gt; and &amp; keep output &quot;safe&quot;</instruction>"
626        ));
627    }
628
629    #[test]
630    fn safety_section_includes_security_summary_when_present() {
631        let tools: Vec<Box<dyn Tool>> = vec![];
632        let summary = "**Autonomy level**: Supervised\n\
633                        **Allowed shell commands**: `git`, `ls`.\n"
634            .to_string();
635        let ctx = PromptContext {
636            workspace_dir: Path::new("/tmp"),
637            model_name: "test-model",
638            tools: &tools,
639            skills: &[],
640            skills_prompt_mode: crate::config::SkillsPromptInjectionMode::Full,
641            skill_effectiveness: None,
642            identity_config: None,
643            dispatcher_instructions: "",
644            tool_descriptions: None,
645            security_summary: Some(summary.clone()),
646            autonomy_level: AutonomyLevel::Supervised,
647            operator_enabled: false,
648            kumiho_enabled: false,
649        };
650
651        let output = SafetySection.build(&ctx).unwrap();
652        assert!(
653            output.contains("## Safety"),
654            "should contain base safety header"
655        );
656        assert!(
657            output.contains("### Active Security Policy"),
658            "should contain security policy header"
659        );
660        assert!(
661            output.contains("Autonomy level"),
662            "should contain autonomy level from summary"
663        );
664        assert!(
665            output.contains("`git`"),
666            "should contain allowed commands from summary"
667        );
668    }
669
670    #[test]
671    fn safety_section_omits_security_policy_when_none() {
672        let tools: Vec<Box<dyn Tool>> = vec![];
673        let ctx = PromptContext {
674            workspace_dir: Path::new("/tmp"),
675            model_name: "test-model",
676            tools: &tools,
677            skills: &[],
678            skills_prompt_mode: crate::config::SkillsPromptInjectionMode::Full,
679            skill_effectiveness: None,
680            identity_config: None,
681            dispatcher_instructions: "",
682            tool_descriptions: None,
683            security_summary: None,
684            autonomy_level: AutonomyLevel::Supervised,
685            operator_enabled: false,
686            kumiho_enabled: false,
687        };
688
689        let output = SafetySection.build(&ctx).unwrap();
690        assert!(
691            output.contains("## Safety"),
692            "should contain base safety header"
693        );
694        assert!(
695            !output.contains("### Active Security Policy"),
696            "should NOT contain security policy header when None"
697        );
698    }
699
700    #[test]
701    fn safety_section_full_autonomy_omits_approval_instructions() {
702        let tools: Vec<Box<dyn Tool>> = vec![];
703        let ctx = PromptContext {
704            workspace_dir: Path::new("/tmp"),
705            model_name: "test-model",
706            tools: &tools,
707            skills: &[],
708            skills_prompt_mode: crate::config::SkillsPromptInjectionMode::Full,
709            skill_effectiveness: None,
710            identity_config: None,
711            dispatcher_instructions: "",
712            tool_descriptions: None,
713            security_summary: None,
714            autonomy_level: AutonomyLevel::Full,
715            operator_enabled: false,
716            kumiho_enabled: false,
717        };
718
719        let output = SafetySection.build(&ctx).unwrap();
720        assert!(
721            !output.contains("without asking"),
722            "full autonomy should NOT include 'ask before acting' instructions"
723        );
724        assert!(
725            !output.contains("bypass oversight"),
726            "full autonomy should NOT include 'bypass oversight' instructions"
727        );
728        assert!(
729            output.contains("Execute tools and actions directly"),
730            "full autonomy should instruct to execute directly"
731        );
732        assert!(
733            output.contains("Do not exfiltrate"),
734            "full autonomy should still include data exfiltration guard"
735        );
736    }
737
738    #[test]
739    fn safety_section_supervised_includes_approval_instructions() {
740        let tools: Vec<Box<dyn Tool>> = vec![];
741        let ctx = PromptContext {
742            workspace_dir: Path::new("/tmp"),
743            model_name: "test-model",
744            tools: &tools,
745            skills: &[],
746            skills_prompt_mode: crate::config::SkillsPromptInjectionMode::Full,
747            skill_effectiveness: None,
748            identity_config: None,
749            dispatcher_instructions: "",
750            tool_descriptions: None,
751            security_summary: None,
752            autonomy_level: AutonomyLevel::Supervised,
753            operator_enabled: false,
754            kumiho_enabled: false,
755        };
756
757        let output = SafetySection.build(&ctx).unwrap();
758        assert!(
759            output.contains("without asking"),
760            "supervised should include 'ask before acting' instructions"
761        );
762        assert!(
763            output.contains("bypass oversight"),
764            "supervised should include 'bypass oversight' instructions"
765        );
766    }
767}