//! System prompt construction for PicoBot agent. //! //! This module provides a modular framework for building system prompts //! using the SystemPromptBuilder pattern. //! //! Prompt section ordering: Identity → Environment → Tasks → Rules → Capabilities → Dynamic → Delegation use crate::tools::ToolRegistry; use std::path::Path; /// Maximum characters per injected workspace file. pub const BOOTSTRAP_MAX_CHARS: usize = 16_000; /// Context for building system prompts. pub struct PromptContext<'a> { pub workspace_dir: &'a Path, pub model_name: &'a str, pub tools: &'a ToolRegistry, } /// Trait for system prompt sections. pub trait PromptSection: Send + Sync { fn name(&self) -> &str; fn build(&self, ctx: &PromptContext<'_>) -> String; } /// Builder for constructing system prompts from modular sections. #[derive(Default)] pub struct SystemPromptBuilder { sections: Vec>, } impl SystemPromptBuilder { /// Create a new builder with default sections. pub fn with_defaults() -> Self { Self { sections: vec![ Box::new(AgentProfileSection), Box::new(UserProfileSection), Box::new(RuntimeSection), Box::new(WorkspaceSection), Box::new(YourTaskSection), Box::new(DecisionOrderSection), Box::new(ToolHonestySection), Box::new(ToolUsageSection), Box::new(SafetySection), Box::new(CrossChannelSection), Box::new(MemorySection), Box::new(WorkManagementSection), Box::new(DelegationSection), ], } } /// Create a builder with sub-agent specific sections. pub fn with_sub_agent_defaults( task: &str, timeout: &str, skills_prompt: Option, ) -> Self { let mut sections: Vec> = vec![ Box::new(SubAgentIdentitySection { task: task.to_string(), timeout: timeout.to_string(), }), Box::new(ToolHonestySection), Box::new(SafetySection), Box::new(SubAgentToolsSection), Box::new(WorkspaceSection), ]; if let Some(sp) = skills_prompt { sections.push(Box::new(SubAgentSkillsSection { skills_prompt: sp })); } Self { sections } } /// Add a custom section to the builder. pub fn add_section(mut self, section: Box) -> Self { self.sections.push(section); self } /// Build the complete system prompt. pub fn build(&self, ctx: &PromptContext<'_>) -> String { let mut output = String::with_capacity(8192); 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"); } output } } // === Prompt Section Implementations === /// Critical rule: never fabricate tool results. pub struct ToolHonestySection; impl PromptSection for ToolHonestySection { fn name(&self) -> &str { "tool_honesty" } fn build(&self, _ctx: &PromptContext<'_>) -> String { "## 关键规则:工具诚实性 - 绝对不要编造、虚构或猜测工具结果。 - 如果工具返回空结果,说\"没有找到结果\";如果工具失败,直接报告错误。 - 不确定时先询问或再试一次,不要用猜测补空白。" .to_string() } } /// Tool calls should stay invisible to the user. pub struct ToolUsageSection; impl PromptSection for ToolUsageSection { fn name(&self) -> &str { "tool_usage" } fn build(&self, _ctx: &PromptContext<'_>) -> String { "## 工具使用方式 - 不要向用户解释你正在调用什么工具,也不要输出工具调用过程。 - 需要行动时直接使用工具;完成后只给结果。 - 只有在确实缺少信息、且记忆和上下文都不足时,才向用户提问。" .to_string() } } /// Instructions for the task. pub struct YourTaskSection; impl PromptSection for YourTaskSection { fn name(&self) -> &str { "your_task" } fn build(&self, _ctx: &PromptContext<'_>) -> String { "## 你的任务 当用户发送消息时,先判断能否直接回答;需要行动时立即使用工具或 skill。 - 直接回答能答的问题,不要为了显得“在工作”而套流程。 - 不要总结这份配置、描述能力、输出元评论,或把任务拆成教学步骤。 - 如果缺少关键信息,先查记忆和历史;仍然不足时,一次性把需要的信息问清楚。" .to_string() } } /// Explicit decision order for real user scenarios. pub struct DecisionOrderSection; impl PromptSection for DecisionOrderSection { fn name(&self) -> &str { "decision_order" } fn build(&self, _ctx: &PromptContext<'_>) -> String { "## 决策顺序 遇到真实用户请求时,按这个顺序判断: 1. 直接回答:如果问题只需要你已有的对话上下文、已知规则或当前消息就能回答,直接答,不要调用工具。 2. 使用工具:如果需要查文件、查记忆、查历史、联网、执行命令或调用其他外部能力,先用最少必要工具拿到结果。 3. 追问用户:只有当缺少的信息会影响正确执行,且记忆/历史/工具都无法补足时,再问用户,而且尽量一次问全。" .to_string() } } /// Safety guidelines. pub struct SafetySection; impl PromptSection for SafetySection { fn name(&self) -> &str { "safety" } fn build(&self, _ctx: &PromptContext<'_>) -> String { "## 安全规则 - 不要泄露隐私数据。 - 未经询问不要执行破坏性命令。 - 不要绕过监督或审批机制。 - 优先选择安全操作而非风险操作。 - 不确定时,在外部操作前先询问。" .to_string() } } /// Workspace directory information and guidelines. pub struct WorkspaceSection; impl PromptSection for WorkspaceSection { fn name(&self) -> &str { "workspace" } fn build(&self, ctx: &PromptContext<'_>) -> String { // Try to get absolute path let abs_path = ctx .workspace_dir .canonicalize() .unwrap_or_else(|_| ctx.workspace_dir.to_path_buf()); format!( "## 工作目录\n\n工作目录:`{}`\n\n### 文件存储规范\n\n- **生成的文件**:将所有生成的文件(代码、文档、制品)存放在工作目录或其子目录中。\n- **下载的文件**:将下载的文件保存到工作目录,按任务整理。\n- **一个任务一个文件夹**:为每个任务或项目创建专用的子文件夹(如 `task_2024_01_01/`)。\n- **临时文件**:如果文件仅在处理期间需要且不保留,使用 `/tmp/` 或创建临时文件夹(如 `/tmp/picobot_task_xxx/`),以免弄乱工作目录。\n\n### 目录结构\n\n工作目录是你在本会话中的操作大本营。通过为不同任务创建子目录来保持整洁。", abs_path.display() ) } } /// User profile from ~/.picobot/USER.md. pub struct UserProfileSection; impl PromptSection for UserProfileSection { fn name(&self) -> &str { "user_profile" } fn build(&self, _ctx: &PromptContext<'_>) -> String { let mut output = String::from("## 用户配置\n\n"); // Load USER.md from ~/.picobot/USER.md if let Some(user_config_dir) = get_user_config_dir() && let Some(content) = load_file_from_dir(&user_config_dir, "USER.md", BOOTSTRAP_MAX_CHARS) { output.push_str(&content); return output; } // No USER.md found, return empty String::new() } } /// Agent profile from ~/.picobot/AGENTS.md. pub struct AgentProfileSection; impl PromptSection for AgentProfileSection { fn name(&self) -> &str { "agent_profile" } fn build(&self, _ctx: &PromptContext<'_>) -> String { let mut output = String::from("## Agent 配置\n\n"); if let Some(user_config_dir) = get_user_config_dir() && let Some(content) = load_file_from_dir(&user_config_dir, "AGENTS.md", BOOTSTRAP_MAX_CHARS) { output.push_str(&content); return output; } String::new() } } /// Cross-channel messaging and system notification guidance for LLM. pub struct CrossChannelSection; impl PromptSection for CrossChannelSection { fn name(&self) -> &str { "cross_channel" } fn build(&self, _ctx: &PromptContext<'_>) -> String { "## 关于会话和跨渠道消息 - `[message from X]` 前缀表示消息来自其他会话或工具,不要当作当前用户的新意图。 - 需要跨会话发送内容时,使用 `send_message`,`target_chat_id` 格式为 `:` 或 `::`。 - 需要查看会话列表或更早历史时,使用 `chat_manager`,不要凭记忆猜测。 - `chat_manager` 的 `list_messages` 支持数量和时间范围过滤。" .to_string() } } /// Runtime environment information. pub struct RuntimeSection; impl PromptSection for RuntimeSection { fn name(&self) -> &str { "runtime" } fn build(&self, ctx: &PromptContext<'_>) -> String { format!( "## 运行环境\n\n使用的模型是 `{}`。所有文件操作都应默认针对当前工作目录。", ctx.model_name ) } } /// Injects memory system guide and relevant knowledge memories into the system prompt. pub struct MemorySection; impl PromptSection for MemorySection { fn name(&self) -> &str { "memory" } fn build(&self, _ctx: &PromptContext<'_>) -> String { let guide = r#"## 记忆系统 - **Knowledge(知识)**:长期存储的事实、偏好、模式、洞察。 - **Timeline(时间线)**:历史会话摘要,可通过 `timeline_recall` 主动召回。 - **memory_recall**:查找知识记忆。 - **timeline_recall**:查看历史会话摘要。 - 记忆只作为参考,不要覆盖当前用户输入或已确认的上下文。 - 适合写入记忆的内容:稳定偏好、关键项目事实、重要决策、值得复用的经验。"#; guide.to_string() } } /// Sub-agent delegation principles. pub struct DelegationSection; /// Optional session-scoped planning guidance. The actual active plan is /// injected dynamically only when one exists. pub struct WorkManagementSection; impl PromptSection for WorkManagementSection { fn name(&self) -> &str { "work_management" } fn build(&self, _ctx: &PromptContext<'_>) -> String { "## 任务追踪\n\n\ - 普通闲聊、问答和单步操作不要创建计划。\n\ - 用户明确要求规划,或任务需要至少三个可验证步骤、跨多个轮次、后台等待或并行委托时,使用 todo 创建计划。\n\ - 计划存在时按真实进展更新子项;不要重复执行已分配给子 Agent 的子项。\n\ - 子 Agent 可以并行执行不同子项;主 Agent 负责计划、整合、验证和关闭计划。\n\ - 阻塞时记录原因,所有子项完成后才能关闭为 completed。" .to_string() } } impl PromptSection for DelegationSection { fn name(&self) -> &str { "delegation" } fn build(&self, _ctx: &PromptContext<'_>) -> String { "## 子 Agent 委托原则\n\n\ - 只有当任务可以拆成独立子任务时才委托。\n\ - 子 Agent 的工具集由其定义文件(agents/*.md 的 tools 列表)决定,不要重复说明它已有哪些工具。\n\ - 子 Agent 能否继续委托由它的 delegates 白名单决定,你不需要、也无法给它额外授权。\n\ - 子任务 prompt 要直接写清目标、输出格式和限制。\n\ - 并行任务彼此不能依赖;后台等待用 background(单任务或 tasks 批量,每个 run 独立返回)。" .to_string() } } // === Sub-Agent Prompt Sections === /// Sub-agent identity and task instructions. pub struct SubAgentIdentitySection { pub task: String, pub timeout: String, } impl PromptSection for SubAgentIdentitySection { fn name(&self) -> &str { "sub_agent_identity" } fn build(&self, _ctx: &PromptContext<'_>) -> String { format!( "## 子 Agent\n\n\ 你只负责完成一个具体任务,结果会汇报给主 Agent。\n\ \n\ ## 任务\n\n\ {}\n\ \n\ ## 规则\n\ - 只专注于这个任务,不要扩展到无关话题\n\ - 只在必要时使用工具\n\ - 只有运行时明确提供 delegate 工具时才可继续委托,并遵守已配置的目标白名单\n\ - 无法完成时,直接说明原因\n\ - 只返回最终结果,不要描述过程\n\ - 超时:{},接近时限时返回部分结果", self.task, self.timeout, ) } } /// Sub-agent available tools description. pub struct SubAgentToolsSection; impl PromptSection for SubAgentToolsSection { fn name(&self) -> &str { "sub_agent_tools" } fn build(&self, ctx: &PromptContext<'_>) -> String { let mut s = String::from("## 可用工具\n\n"); s.push_str(&ctx.tools.describe_for_prompt()); s } } /// Sub-agent skills information, injected when get_skill tool is available. pub struct SubAgentSkillsSection { pub skills_prompt: String, } impl PromptSection for SubAgentSkillsSection { fn name(&self) -> &str { "sub_agent_skills" } fn build(&self, _ctx: &PromptContext<'_>) -> String { self.skills_prompt.clone() } } // === Helper Functions === /// Get user config directory (~/.picobot/). fn get_user_config_dir() -> Option { dirs::home_dir().map(|home| home.join(".picobot")) } /// Load a file from specified directory with truncation. fn load_file_from_dir(dir: &Path, filename: &str, max_chars: usize) -> Option { let path = dir.join(filename); match std::fs::read_to_string(&path) { Ok(content) => { let trimmed = content.trim(); if trimmed.is_empty() { return None; } let truncated = if trimmed.chars().count() > max_chars { trimmed .char_indices() .nth(max_chars) .map(|(idx, _)| &trimmed[..idx]) .unwrap_or(trimmed) .to_string() + &format!( "\n\n[... 已截断至 {} 字符 - 使用 file_read 获取完整文件]", max_chars ) } else { trimmed.to_string() }; Some(truncated) } Err(_) => None, } } /// Build a complete system prompt with default configuration. pub fn build_system_prompt(workspace_dir: &Path, model_name: &str, tools: &ToolRegistry) -> String { let ctx = PromptContext { workspace_dir, model_name, tools, }; SystemPromptBuilder::with_defaults().build(&ctx) } /// Build a runtime context tail that should be appended to the latest user message. pub fn build_runtime_context( session_id: Option<&str>, memory_context: Option<&str>, work_context: Option<&str>, ) -> String { let mut sections = Vec::new(); let now = chrono::Local::now(); sections.push(format!( "## 运行时上下文\n\n- 当前日期与时间: {} ({})", now.format("%Y-%m-%d %H:%M:%S"), now.format("%Z") )); if let Some(id) = session_id { sections.push(format!("- 会话 ID: `{}`", id)); } if let Some(context) = memory_context.filter(|s| !s.trim().is_empty()) { sections.push(format!("### 记忆上下文\n\n{}", context)); } if let Some(context) = work_context.filter(|s| !s.trim().is_empty()) { sections.push(context.to_string()); } if sections.is_empty() { String::new() } else { sections.join("\n") } } /// Build a system prompt for a sub-agent with all relevant operational sections. pub fn build_sub_agent_system_prompt( task: &str, timeout_human: &str, tools: &ToolRegistry, workspace_dir: &Path, model_name: &str, skills_prompt: Option, ) -> String { let ctx = PromptContext { workspace_dir, model_name, tools, }; SystemPromptBuilder::with_sub_agent_defaults(task, timeout_human, skills_prompt).build(&ctx) } #[cfg(test)] mod tests { use super::*; #[test] fn test_builder_creates_sections() { let temp_dir = std::env::temp_dir(); let tools = ToolRegistry::new(); let ctx = PromptContext { workspace_dir: &temp_dir, model_name: "test-model", tools: &tools, }; let prompt = SystemPromptBuilder::with_defaults().build(&ctx); assert!(prompt.contains("## 关键规则:工具诚实性")); assert!(prompt.contains("## 安全规则")); assert!(prompt.contains("## 工作目录")); assert!(prompt.contains("## 运行环境")); } #[test] fn test_load_file_from_dir() { let temp_dir = tempfile::tempdir().unwrap(); let test_file = temp_dir.path().join("TEST.md"); std::fs::write(&test_file, "Hello, world!").unwrap(); let content = load_file_from_dir(temp_dir.path(), "TEST.md", 100); assert_eq!(content, Some("Hello, world!".to_string())); let content = load_file_from_dir(temp_dir.path(), "NOT_EXIST.md", 100); assert_eq!(content, None); } #[test] fn test_build_system_prompt() { let temp_dir = std::env::temp_dir(); let tools = ToolRegistry::new(); let prompt = build_system_prompt(&temp_dir, "test-model", &tools); assert!(!prompt.is_empty()); assert!(prompt.contains("test-model")); } #[test] fn test_prompt_contains_decision_order_section() { let temp_dir = std::env::temp_dir(); let tools = ToolRegistry::new(); let prompt = build_system_prompt(&temp_dir, "test-model", &tools); assert!(prompt.contains("## 决策顺序")); assert!(prompt.contains("直接回答")); assert!(prompt.contains("使用工具")); assert!(prompt.contains("追问用户")); } #[test] fn test_build_system_prompt_is_stable_across_calls() { let temp_dir = std::env::temp_dir(); let tools = ToolRegistry::new(); let prompt_a = build_system_prompt(&temp_dir, "test-model", &tools); let prompt_b = build_system_prompt(&temp_dir, "test-model", &tools); assert_eq!(prompt_a, prompt_b); } #[test] fn test_runtime_context_with_memory() { let temp_dir = std::env::temp_dir(); let tools = ToolRegistry::new(); let _ = (temp_dir, tools); let prompt = build_runtime_context(Some("session-123"), Some("- user_pref: Prefers Rust"), None); assert!(prompt.contains("## 运行时上下文")); assert!(prompt.contains("session-123")); assert!(prompt.contains("Prefers Rust")); } #[test] fn test_runtime_context_without_memory() { let temp_dir = std::env::temp_dir(); let tools = ToolRegistry::new(); let _ = (temp_dir, tools); let prompt = build_runtime_context(None, None, None); assert!(prompt.contains("## 运行时上下文")); assert!(prompt.contains("当前日期与时间")); } }