use crate::config::IdentityConfig; use crate::identity; use crate::skills::Skill; use crate::tools::Tool; use anyhow::Result; use chrono::Local; use std::fmt::Write; use std::path::Path; const BOOTSTRAP_MAX_CHARS: usize = 20_000; pub struct PromptContext<'a> { pub workspace_dir: &'a Path, pub model_name: &'a str, pub tools: &'a [Box], pub skills: &'a [Skill], pub identity_config: Option<&'a IdentityConfig>, pub dispatcher_instructions: &'a str, } pub trait PromptSection: Send + Sync { fn name(&self) -> &str; fn build(&self, ctx: &PromptContext<'_>) -> Result; } #[derive(Default)] pub struct SystemPromptBuilder { sections: Vec>, } impl SystemPromptBuilder { pub fn with_defaults() -> Self { Self { sections: vec![ Box::new(IdentitySection), Box::new(ToolsSection), Box::new(SafetySection), Box::new(SkillsSection), Box::new(WorkspaceSection), Box::new(DateTimeSection), Box::new(RuntimeSection), ], } } pub fn add_section(mut self, section: Box) -> Self { self.sections.push(section); self } pub fn build(&self, ctx: &PromptContext<'_>) -> Result { let mut output = String::new(); for section in &self.sections { let part = section.build(ctx)?; if part.trim().is_empty() { continue; } output.push_str(part.trim_end()); output.push_str("\n\n"); } Ok(output) } } pub struct IdentitySection; pub struct ToolsSection; pub struct SafetySection; pub struct SkillsSection; pub struct WorkspaceSection; pub struct RuntimeSection; pub struct DateTimeSection; impl PromptSection for IdentitySection { fn name(&self) -> &str { "identity" } fn build(&self, ctx: &PromptContext<'_>) -> Result { let mut prompt = String::from("## Project Context\n\n"); if let Some(config) = ctx.identity_config { if identity::is_aieos_configured(config) { if let Ok(Some(aieos)) = identity::load_aieos_identity(config, ctx.workspace_dir) { let rendered = identity::aieos_to_system_prompt(&aieos); if !rendered.is_empty() { prompt.push_str(&rendered); return Ok(prompt); } } } } prompt.push_str( "The following workspace files define your identity, behavior, and context.\n\n", ); for file in [ "AGENTS.md", "SOUL.md", "TOOLS.md", "IDENTITY.md", "USER.md", "HEARTBEAT.md", "BOOTSTRAP.md", "MEMORY.md", ] { inject_workspace_file(&mut prompt, ctx.workspace_dir, file); } Ok(prompt) } } impl PromptSection for ToolsSection { fn name(&self) -> &str { "tools" } fn build(&self, ctx: &PromptContext<'_>) -> Result { let mut out = String::from("## Tools\n\n"); for tool in ctx.tools { let _ = writeln!( out, "- **{}**: {}\n Parameters: `{}`", tool.name(), tool.description(), tool.parameters_schema() ); } if !ctx.dispatcher_instructions.is_empty() { out.push('\n'); out.push_str(ctx.dispatcher_instructions); } Ok(out) } } impl PromptSection for SafetySection { fn name(&self) -> &str { "safety" } fn build(&self, _ctx: &PromptContext<'_>) -> Result { Ok("## Safety\n\n- Do not exfiltrate private data.\n- Do not run destructive commands without asking.\n- Do not bypass oversight or approval mechanisms.\n- Prefer `trash` over `rm`.\n- When in doubt, ask before acting externally.".into()) } } impl PromptSection for SkillsSection { fn name(&self) -> &str { "skills" } fn build(&self, ctx: &PromptContext<'_>) -> Result { if ctx.skills.is_empty() { return Ok(String::new()); } let mut prompt = String::from("## Available Skills\n\n\n"); for skill in ctx.skills { let location = skill.location.clone().unwrap_or_else(|| { ctx.workspace_dir .join("skills") .join(&skill.name) .join("SKILL.md") }); let _ = writeln!( prompt, " \n {}\n {}\n {}\n ", skill.name, skill.description, location.display() ); } prompt.push_str(""); Ok(prompt) } } impl PromptSection for WorkspaceSection { fn name(&self) -> &str { "workspace" } fn build(&self, ctx: &PromptContext<'_>) -> Result { Ok(format!( "## Workspace\n\nWorking directory: `{}`", ctx.workspace_dir.display() )) } } impl PromptSection for RuntimeSection { fn name(&self) -> &str { "runtime" } fn build(&self, ctx: &PromptContext<'_>) -> Result { let host = hostname::get().map_or_else(|_| "unknown".into(), |h| h.to_string_lossy().to_string()); Ok(format!( "## Runtime\n\nHost: {host} | OS: {} | Model: {}", std::env::consts::OS, ctx.model_name )) } } impl PromptSection for DateTimeSection { fn name(&self) -> &str { "datetime" } fn build(&self, _ctx: &PromptContext<'_>) -> Result { let now = Local::now(); Ok(format!( "## Current Date & Time\n\nTimezone: {}", now.format("%Z") )) } } fn inject_workspace_file(prompt: &mut String, workspace_dir: &Path, filename: &str) { let path = workspace_dir.join(filename); match std::fs::read_to_string(&path) { Ok(content) => { let trimmed = content.trim(); if trimmed.is_empty() { return; } let _ = writeln!(prompt, "### {filename}\n"); let truncated = if trimmed.chars().count() > BOOTSTRAP_MAX_CHARS { trimmed .char_indices() .nth(BOOTSTRAP_MAX_CHARS) .map(|(idx, _)| &trimmed[..idx]) .unwrap_or(trimmed) } else { trimmed }; prompt.push_str(truncated); if truncated.len() < trimmed.len() { let _ = writeln!( prompt, "\n\n[... truncated at {BOOTSTRAP_MAX_CHARS} chars — use `read` for full file]\n" ); } else { prompt.push_str("\n\n"); } } Err(_) => { let _ = writeln!(prompt, "### {filename}\n\n[File not found: {filename}]\n"); } } } #[cfg(test)] mod tests { use super::*; use crate::tools::traits::Tool; use async_trait::async_trait; struct TestTool; #[async_trait] impl Tool for TestTool { fn name(&self) -> &str { "test_tool" } fn description(&self) -> &str { "tool desc" } fn parameters_schema(&self) -> serde_json::Value { serde_json::json!({"type": "object"}) } async fn execute( &self, _args: serde_json::Value, ) -> anyhow::Result { Ok(crate::tools::ToolResult { success: true, output: "ok".into(), error: None, }) } } #[test] fn prompt_builder_assembles_sections() { let tools: Vec> = vec![Box::new(TestTool)]; let ctx = PromptContext { workspace_dir: Path::new("/tmp"), model_name: "test-model", tools: &tools, skills: &[], identity_config: None, dispatcher_instructions: "instr", }; let prompt = SystemPromptBuilder::with_defaults().build(&ctx).unwrap(); assert!(prompt.contains("## Tools")); assert!(prompt.contains("test_tool")); assert!(prompt.contains("instr")); } }