增加系统提示词框架
This commit is contained in:
parent
0c356e7ac4
commit
75a3bf9df4
@ -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"
|
||||
|
||||
@ -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<ToolRegistry>,
|
||||
observer: Option<Arc<dyn Observer>>,
|
||||
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<Self, AgentError> {
|
||||
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<ToolRegistry>) -> Result<Self, AgentError> {
|
||||
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<dyn LLMProvider>, max_iterations: usize) -> Self {
|
||||
pub fn with_provider(provider: Arc<dyn LLMProvider>, 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<dyn LLMProvider>,
|
||||
tools: Arc<ToolRegistry>,
|
||||
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<dyn Observer>) -> 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();
|
||||
|
||||
@ -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};
|
||||
|
||||
353
src/agent/system_prompt.rs
Normal file
353
src/agent/system_prompt.rs
Normal 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"));
|
||||
}
|
||||
}
|
||||
@ -167,6 +167,7 @@ impl Session {
|
||||
self.provider.clone(),
|
||||
self.tools.clone(),
|
||||
self.provider_config.max_tool_iterations,
|
||||
self.provider_config.model_id.clone(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
@ -50,6 +50,10 @@ impl ToolRegistry {
|
||||
pub fn tool_names(&self) -> Vec<String> {
|
||||
self.tools.keys().cloned().collect()
|
||||
}
|
||||
|
||||
pub fn iter(&self) -> impl Iterator<Item = (&String, &Box<dyn ToolTrait>)> {
|
||||
self.tools.iter()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ToolRegistry {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user