Skip to main content

zeroclaw_runtime/agent/
prompt.rs

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