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