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 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 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
101pub 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 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 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 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 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 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<review>&</name>"));
663 assert!(prompt.contains(
664 "<description>Review "unsafe" and 'risky' bits</description>"
665 ));
666 assert!(prompt.contains("<name>run"linter"</name>"));
667 assert!(prompt.contains("<description>Run <lint> & report</description>"));
668 assert!(prompt.contains("<kind>shell&exec</kind>"));
669 assert!(prompt.contains(
670 "<instruction>Use <tool_call> and & keep output "safe"</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}