PicoBot/src/agent/system_prompt.rs
xiaoxixi 5501c539fc feat: remove agent run groups, add WebUI agent definition management
- drop agent_run_groups table and group_id/scope_kind/scope_id columns (schema v8)
- remove group_id from AgentExecutionContext and recovery group counters
- flatten TasksPage background tab into a per-run list
- add WebUI Agents page with definition CRUD and inline provider/model
- bump version to 1.11.0
2026-08-13 14:03:01 +08:00

615 lines
20 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! 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<Box<dyn PromptSection>>,
}
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<String>,
) -> Self {
let mut sections: Vec<Box<dyn PromptSection>> = 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<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 {
"## 关键规则:工具诚实性
- 绝对不要编造、虚构或猜测工具结果。
- 如果工具返回空结果,说\"没有找到结果\";如果工具失败,直接报告错误。
- 不确定时先询问或再试一次,不要用猜测补空白。"
.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` 格式为 `<channel>:<chat_id>` 或 `<channel>:<chat_id>:<dialog_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<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[... 已截断至 {} 字符 - 使用 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>,
) -> 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("当前日期与时间"));
}
}