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 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 pub sends_native_tool_specs: bool,
30 pub security_summary: Option<String>,
35 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 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 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 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 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 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<review>&</name>"));
633 assert!(prompt.contains(
634 "<description>Review "unsafe" and 'risky' bits</description>"
635 ));
636 assert!(prompt.contains("<name>run"linter"</name>"));
637 assert!(prompt.contains("<description>Run <lint> & report</description>"));
638 assert!(prompt.contains("<kind>shell&exec</kind>"));
639 assert!(prompt.contains(
640 "<instruction>Use <tool_call> and & keep output "safe"</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}