增加系统提示词框架

This commit is contained in:
xiaoski 2026-04-27 17:07:01 +08:00
parent 0c356e7ac4
commit 75a3bf9df4
6 changed files with 393 additions and 1 deletions

View File

@ -32,3 +32,5 @@ ratatui = "0.27"
crossterm = { version = "0.28", features = ["event-stream"] } crossterm = { version = "0.28", features = ["event-stream"] }
termimad = "0.34" termimad = "0.34"
textwrap = "0.16" textwrap = "0.16"
chrono = "0.4"
hostname = "0.3"

View File

@ -1,3 +1,4 @@
use crate::agent::system_prompt::build_system_prompt;
use crate::bus::message::ContentBlock; use crate::bus::message::ContentBlock;
use crate::bus::ChatMessage; use crate::bus::ChatMessage;
use crate::config::LLMProviderConfig; use crate::config::LLMProviderConfig;
@ -9,6 +10,7 @@ use crate::tools::ToolRegistry;
use std::collections::VecDeque; use std::collections::VecDeque;
use std::hash::{Hash, Hasher}; use std::hash::{Hash, Hasher};
use std::io::Read; use std::io::Read;
use std::path::PathBuf;
use std::sync::Arc; use std::sync::Arc;
use std::time::Instant; use std::time::Instant;
@ -222,6 +224,8 @@ pub struct AgentLoop {
tools: Arc<ToolRegistry>, tools: Arc<ToolRegistry>,
observer: Option<Arc<dyn Observer>>, observer: Option<Arc<dyn Observer>>,
max_iterations: usize, max_iterations: usize,
workspace_dir: PathBuf,
model_name: String,
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@ -234,6 +238,7 @@ impl AgentLoop {
/// Create a new AgentLoop with a provider created from config. /// Create a new AgentLoop with a provider created from config.
pub fn new(provider_config: LLMProviderConfig) -> Result<Self, AgentError> { pub fn new(provider_config: LLMProviderConfig) -> Result<Self, AgentError> {
let max_iterations = provider_config.max_tool_iterations; let max_iterations = provider_config.max_tool_iterations;
let model_name = provider_config.model_id.clone();
let provider = create_provider(provider_config) let provider = create_provider(provider_config)
.map_err(|e| AgentError::ProviderCreation(e.to_string()))?; .map_err(|e| AgentError::ProviderCreation(e.to_string()))?;
@ -242,12 +247,15 @@ impl AgentLoop {
tools: Arc::new(ToolRegistry::new()), tools: Arc::new(ToolRegistry::new()),
observer: None, observer: None,
max_iterations, 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. /// Create a new AgentLoop with provider created from config and given tools.
pub fn with_tools(provider_config: LLMProviderConfig, tools: Arc<ToolRegistry>) -> Result<Self, AgentError> { pub fn with_tools(provider_config: LLMProviderConfig, tools: Arc<ToolRegistry>) -> Result<Self, AgentError> {
let max_iterations = provider_config.max_tool_iterations; let max_iterations = provider_config.max_tool_iterations;
let model_name = provider_config.model_id.clone();
let provider = create_provider(provider_config) let provider = create_provider(provider_config)
.map_err(|e| AgentError::ProviderCreation(e.to_string()))?; .map_err(|e| AgentError::ProviderCreation(e.to_string()))?;
@ -256,16 +264,20 @@ impl AgentLoop {
tools, tools,
observer: None, observer: None,
max_iterations, max_iterations,
workspace_dir: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
model_name,
}) })
} }
/// Create a new AgentLoop with an existing shared provider. /// Create a new AgentLoop with an existing shared provider.
pub fn with_provider(provider: Arc<dyn LLMProvider>, max_iterations: usize) -> Self { pub fn with_provider(provider: Arc<dyn LLMProvider>, max_iterations: usize, model_name: String) -> Self {
Self { Self {
provider, provider,
tools: Arc::new(ToolRegistry::new()), tools: Arc::new(ToolRegistry::new()),
observer: None, observer: None,
max_iterations, max_iterations,
workspace_dir: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
model_name,
} }
} }
@ -274,15 +286,24 @@ impl AgentLoop {
provider: Arc<dyn LLMProvider>, provider: Arc<dyn LLMProvider>,
tools: Arc<ToolRegistry>, tools: Arc<ToolRegistry>,
max_iterations: usize, max_iterations: usize,
model_name: String,
) -> Self { ) -> Self {
Self { Self {
provider, provider,
tools, tools,
observer: None, observer: None,
max_iterations, 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. /// Set an observer for tracking events.
pub fn with_observer(mut self, observer: Arc<dyn Observer>) -> Self { pub fn with_observer(mut self, observer: Arc<dyn Observer>) -> Self {
self.observer = Some(observer); self.observer = Some(observer);
@ -304,6 +325,15 @@ impl AgentLoop {
#[cfg(debug_assertions)] #[cfg(debug_assertions)]
tracing::debug!(history_len = messages.len(), max_iterations = self.max_iterations, "Starting agent process"); 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 // Track tool calls for loop detection
let mut loop_detector = LoopDetector::new(LoopDetectorConfig::default()); let mut loop_detector = LoopDetector::new(LoopDetectorConfig::default());
let mut emitted_messages = Vec::new(); let mut emitted_messages = Vec::new();

View File

@ -1,5 +1,7 @@
pub mod agent_loop; pub mod agent_loop;
pub mod context_compressor; pub mod context_compressor;
pub mod system_prompt;
pub use agent_loop::{AgentLoop, AgentError, AgentProcessResult}; pub use agent_loop::{AgentLoop, AgentError, AgentProcessResult};
pub use context_compressor::ContextCompressor; pub use context_compressor::ContextCompressor;
pub use system_prompt::{build_system_prompt, PromptContext, PromptSection, SystemPromptBuilder};

353
src/agent/system_prompt.rs Normal file
View File

@ -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<Box<dyn PromptSection>>,
}
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<dyn PromptSection>) -> 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<std::path::PathBuf> {
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<String> {
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"));
}
}

View File

@ -167,6 +167,7 @@ impl Session {
self.provider.clone(), self.provider.clone(),
self.tools.clone(), self.tools.clone(),
self.provider_config.max_tool_iterations, self.provider_config.max_tool_iterations,
self.provider_config.model_id.clone(),
)) ))
} }
} }

View File

@ -50,6 +50,10 @@ impl ToolRegistry {
pub fn tool_names(&self) -> Vec<String> { pub fn tool_names(&self) -> Vec<String> {
self.tools.keys().cloned().collect() self.tools.keys().cloned().collect()
} }
pub fn iter(&self) -> impl Iterator<Item = (&String, &Box<dyn ToolTrait>)> {
self.tools.iter()
}
} }
impl Default for ToolRegistry { impl Default for ToolRegistry {