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