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