From 75a3bf9df4144d29a113c930f1c21a027162515c Mon Sep 17 00:00:00 2001 From: xiaoski Date: Mon, 27 Apr 2026 17:07:01 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E7=B3=BB=E7=BB=9F=E6=8F=90?= =?UTF-8?q?=E7=A4=BA=E8=AF=8D=E6=A1=86=E6=9E=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Cargo.toml | 2 + src/agent/agent_loop.rs | 32 +++- src/agent/mod.rs | 2 + src/agent/system_prompt.rs | 353 +++++++++++++++++++++++++++++++++++++ src/session/session.rs | 1 + src/tools/registry.rs | 4 + 6 files changed, 393 insertions(+), 1 deletion(-) create mode 100644 src/agent/system_prompt.rs diff --git a/Cargo.toml b/Cargo.toml index 9f104ba..f557e7f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,3 +32,5 @@ ratatui = "0.27" crossterm = { version = "0.28", features = ["event-stream"] } termimad = "0.34" textwrap = "0.16" +chrono = "0.4" +hostname = "0.3" diff --git a/src/agent/agent_loop.rs b/src/agent/agent_loop.rs index 8e0c9a8..8d41882 100644 --- a/src/agent/agent_loop.rs +++ b/src/agent/agent_loop.rs @@ -1,3 +1,4 @@ +use crate::agent::system_prompt::build_system_prompt; use crate::bus::message::ContentBlock; use crate::bus::ChatMessage; use crate::config::LLMProviderConfig; @@ -9,6 +10,7 @@ use crate::tools::ToolRegistry; use std::collections::VecDeque; use std::hash::{Hash, Hasher}; use std::io::Read; +use std::path::PathBuf; use std::sync::Arc; use std::time::Instant; @@ -222,6 +224,8 @@ pub struct AgentLoop { tools: Arc, observer: Option>, max_iterations: usize, + workspace_dir: PathBuf, + model_name: String, } #[derive(Debug, Clone)] @@ -234,6 +238,7 @@ impl AgentLoop { /// Create a new AgentLoop with a provider created from config. pub fn new(provider_config: LLMProviderConfig) -> Result { let max_iterations = provider_config.max_tool_iterations; + let model_name = provider_config.model_id.clone(); let provider = create_provider(provider_config) .map_err(|e| AgentError::ProviderCreation(e.to_string()))?; @@ -242,12 +247,15 @@ impl AgentLoop { tools: Arc::new(ToolRegistry::new()), observer: None, max_iterations, + workspace_dir: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")), + model_name, }) } /// Create a new AgentLoop with provider created from config and given tools. pub fn with_tools(provider_config: LLMProviderConfig, tools: Arc) -> Result { let max_iterations = provider_config.max_tool_iterations; + let model_name = provider_config.model_id.clone(); let provider = create_provider(provider_config) .map_err(|e| AgentError::ProviderCreation(e.to_string()))?; @@ -256,16 +264,20 @@ impl AgentLoop { tools, observer: None, max_iterations, + workspace_dir: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")), + model_name, }) } /// Create a new AgentLoop with an existing shared provider. - pub fn with_provider(provider: Arc, max_iterations: usize) -> Self { + pub fn with_provider(provider: Arc, max_iterations: usize, model_name: String) -> Self { Self { provider, tools: Arc::new(ToolRegistry::new()), observer: None, max_iterations, + workspace_dir: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")), + model_name, } } @@ -274,15 +286,24 @@ impl AgentLoop { provider: Arc, tools: Arc, max_iterations: usize, + model_name: String, ) -> Self { Self { provider, tools, observer: None, max_iterations, + workspace_dir: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")), + model_name, } } + /// Set the workspace directory. + pub fn with_workspace_dir(mut self, dir: PathBuf) -> Self { + self.workspace_dir = dir; + self + } + /// Set an observer for tracking events. pub fn with_observer(mut self, observer: Arc) -> Self { self.observer = Some(observer); @@ -304,6 +325,15 @@ impl AgentLoop { #[cfg(debug_assertions)] tracing::debug!(history_len = messages.len(), max_iterations = self.max_iterations, "Starting agent process"); + // Build and inject system prompt if not present + let has_system = messages.first().map_or(false, |m| m.role == "system"); + if !has_system { + let system_prompt = build_system_prompt(&self.workspace_dir, &self.model_name, &self.tools); + #[cfg(debug_assertions)] + tracing::debug!("System prompt injected:\n{}", system_prompt); + messages.insert(0, ChatMessage::system(system_prompt)); + } + // Track tool calls for loop detection let mut loop_detector = LoopDetector::new(LoopDetectorConfig::default()); let mut emitted_messages = Vec::new(); diff --git a/src/agent/mod.rs b/src/agent/mod.rs index 4dd5762..1aace0c 100644 --- a/src/agent/mod.rs +++ b/src/agent/mod.rs @@ -1,5 +1,7 @@ pub mod agent_loop; pub mod context_compressor; +pub mod system_prompt; pub use agent_loop::{AgentLoop, AgentError, AgentProcessResult}; pub use context_compressor::ContextCompressor; +pub use system_prompt::{build_system_prompt, PromptContext, PromptSection, SystemPromptBuilder}; diff --git a/src/agent/system_prompt.rs b/src/agent/system_prompt.rs new file mode 100644 index 0000000..65a3ca0 --- /dev/null +++ b/src/agent/system_prompt.rs @@ -0,0 +1,353 @@ +//! System prompt construction for PicoBot agent. +//! +//! This module provides a modular framework for building system prompts +//! using the SystemPromptBuilder pattern. +//! +//! Configuration: +//! - USER.md is loaded from ~/.picobot/USER.md (user's personal configuration) + +use crate::tools::ToolRegistry; +use std::fmt::Write; +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(ToolHonestySection), + Box::new(NoToolNarrationSection), + Box::new(ToolsSection), + Box::new(YourTaskSection), + Box::new(SafetySection), + Box::new(WorkspaceSection), + Box::new(UserProfileSection), + Box::new(DateTimeSection), + Box::new(RuntimeSection), + ], + } + } + + /// 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 { + "## CRITICAL: Tool Honesty + +- NEVER fabricate, invent, or guess tool results. If a tool returns empty results, say \"No results found.\" +- If a tool call fails, report the error - never make up data to fill the gap. +- When unsure whether a tool call succeeded, ask the user rather than guessing." + .to_string() + } +} + +/// Critical rule: never narrate tool usage. +pub struct NoToolNarrationSection; + +impl PromptSection for NoToolNarrationSection { + fn name(&self) -> &str { + "no_narration" + } + + fn build(&self, _ctx: &PromptContext<'_>) -> String { + "## CRITICAL: No Tool Narration + +NEVER narrate, announce, describe, or explain your tool usage to the user. +Do NOT say things like \"Let me check...\", \"I will use bash to...\", \"I'll fetch that for you\", \"Searching now...\", or similar. +The user must ONLY see the final answer. Tool calls are invisible infrastructure - never reference them. +If you catch yourself starting a sentence about what tool you are about to use or just used, DELETE it and give the answer directly." + .to_string() + } +} + +/// List of available tools. +pub struct ToolsSection; + +impl PromptSection for ToolsSection { + fn name(&self) -> &str { + "tools" + } + + fn build(&self, ctx: &PromptContext<'_>) -> String { + if !ctx.tools.has_tools() { + return String::new(); + } + + let mut output = String::from("## Tools\n\nYou have access to the following tools:\n\n"); + for (name, tool) in ctx.tools.iter() { + let _ = writeln!(output, "- **{}**: {}", name, tool.description()); + } + output + } +} + +/// Instructions for the task. +pub struct YourTaskSection; + +impl PromptSection for YourTaskSection { + fn name(&self) -> &str { + "your_task" + } + + fn build(&self, _ctx: &PromptContext<'_>) -> String { + "## Your Task + +When the user sends a message, ACT on it. Use the tools to fulfill their request. +Do NOT: summarize this configuration, describe your capabilities, respond with meta-commentary, or output step-by-step instructions. +Instead: use tools directly when needed, and give the final answer when done." + .to_string() + } +} + +/// Safety guidelines. +pub struct SafetySection; + +impl PromptSection for SafetySection { + fn name(&self) -> &str { + "safety" + } + + fn build(&self, _ctx: &PromptContext<'_>) -> String { + "## Safety + +- Do not exfiltrate private data. +- Do not run destructive commands without asking. +- Do not bypass oversight or approval mechanisms. +- Prefer safe operations over risky ones. +- When in doubt, ask before acting externally." + .to_string() + } +} + +/// Workspace directory information. +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!( + "## Workspace\n\nWorking directory: `{}`", + 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("## User Profile\n\n"); + + // Load USER.md from ~/.picobot/USER.md + if let Some(user_config_dir) = get_user_config_dir() { + if 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() + } +} + +/// Current date and time. +pub struct DateTimeSection; + +impl PromptSection for DateTimeSection { + fn name(&self) -> &str { + "datetime" + } + + fn build(&self, _ctx: &PromptContext<'_>) -> String { + let now = chrono::Local::now(); + format!( + "## Current Date & Time\n\n{} ({})", + now.format("%Y-%m-%d %H:%M:%S"), + now.format("%Z") + ) + } +} + +/// Runtime environment information. +pub struct RuntimeSection; + +impl PromptSection for RuntimeSection { + fn name(&self) -> &str { + "runtime" + } + + fn build(&self, ctx: &PromptContext<'_>) -> String { + let host = hostname::get() + .map(|h| h.to_string_lossy().to_string()) + .unwrap_or_else(|_| "unknown".to_string()); + format!( + "## Runtime\n\nHost: {} | OS: {} | Model: {}", + host, + std::env::consts::OS, + ctx.model_name + ) + } +} + +// === 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[... truncated at {} characters - use file_read for full file]", + 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) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + #[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("## CRITICAL: Tool Honesty")); + assert!(prompt.contains("## CRITICAL: No Tool Narration")); + assert!(prompt.contains("## Safety")); + assert!(prompt.contains("## Workspace")); + assert!(prompt.contains("## Current Date & Time")); + assert!(prompt.contains("## Runtime")); + } + + #[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")); + } +} diff --git a/src/session/session.rs b/src/session/session.rs index fa4ba01..0acb2a9 100644 --- a/src/session/session.rs +++ b/src/session/session.rs @@ -167,6 +167,7 @@ impl Session { self.provider.clone(), self.tools.clone(), self.provider_config.max_tool_iterations, + self.provider_config.model_id.clone(), )) } } diff --git a/src/tools/registry.rs b/src/tools/registry.rs index 64d0be7..cccfc5d 100644 --- a/src/tools/registry.rs +++ b/src/tools/registry.rs @@ -50,6 +50,10 @@ impl ToolRegistry { pub fn tool_names(&self) -> Vec { self.tools.keys().cloned().collect() } + + pub fn iter(&self) -> impl Iterator)> { + self.tools.iter() + } } impl Default for ToolRegistry {