Files
blackbird/aibox/subsystems/prompt_manager.py
T
qing 457bb900fb fix(aibox): round-5 audit batch K - max_turns clamp, task input validation, XSS hardening, atomic writes, broadcast timeout
第五轮审计(J1~J16)全量修复 + 子代理 max_turns 钳制:
- 子代理 max_turns 钳制(0/None 回退默认 10,负数钳制 1)+ 14 项回归测试
- J1 任务系统投毒:add_memo 入库校验(时间/间隔/类型)、调度器预检与触发事务
  防御、展示路径兜底、register 移入 try、add_user_task 补 repeat_end_time 透传
- J2/J13 前端 XSS:三页面 marked 输出经 DOMPurify 净化、entry1 系统消息/任务
  分隔条改 textContent、CDN 脚本补 SRI
- J3 FTS 删除换序(先 fts 后内容表,索引不再残留)
- J5 原子写推广:新增 common/atomic.py(mkstemp+fsync+os.replace),config/prompt/
  history 全部接入;损坏配置备份后回退
- J6 单例初始化加锁(PromptManager/ConfigManager 并发首例不双重加载)
- J7 broadcast 并发发送 + wait_for 超时回收僵死连接
- J4/J8/J9~J12/J14~J16:验证异常脱敏、交互输入容错、MemoDatabase WAL+busy_timeout、
  memo 批量删除、/new 竞态、skills 引号转义、voice_client 清理、死代码移除、
  skill_manager 同名键/help 截断
2026-08-19 07:01:13 +08:00

331 lines
13 KiB
Python
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.
#!/usr/bin/env python3
"""
subsystems.prompt_manager - 提示词管理器
抽离自 aibox.py,负责从 prompts 目录加载系统提示词。
单例模式(__new__ + 类变量 _instance),多实例共享同一份 _prompts。
文件命名规范:
- 00_IDENTITY.md : 用户身份信息
- N_*.md/txt : 基础技能提示词(N 为数字),按数字升序加载
- 99_SOUL.md : 智能体灵魂文件(角色定位、身份定义、性格等)
- 100_STATUS.md : 智能体状态记录
依赖:common (ConfigManager, constants) + 标准库
不反向依赖 aibox 任何符号。
"""
import os
import re
import shutil
import threading
from datetime import datetime
from typing import Dict, List, Optional
from common import ConfigManager
from common.atomic import atomic_write_text
class PromptManager:
"""提示词管理器 - 负责从 prompts 目录加载系统提示词
文件命名规范:
- 00_IDENTITY.md: 用户身份信息
- N_*.md/txt: 基础技能提示词(N 为数字,如 1_memory_search.md、10_task_handling.md),按数字升序加载
- 99_SOUL.md: 智能体灵魂文件(角色定位、身份定义、性格等)
- 100_STATUS.md: 智能体状态记录
"""
_instance = None
_instance_lock = threading.Lock() # I 批:并发首例化竞态防护
_prompts = []
_prompts_dir = None
# 核心文件
IDENTITY_FILE = "00_IDENTITY.md"
STATUS_FILE = "100_STATUS.md"
SOUL_FILE = "99_SOUL.md" # 智能体灵魂文件 - 所有角色定位内容
# N9(审计 2026-08-18):Agent 可控内容直写 00/99/100 文件并作为后续
# 会话系统提示词加载(持久化注入面)。内容长度上限约束注入规模,
# 超长内容拒绝写入(工具层有更友好的提示)。
MAX_CONTENT_LENGTH = 10000
# 基础技能文件名模式:N_xxx.md(N 为数字,如 1_memory_search.md、10_task_handling.md)
_SKILL_FILE_RE = re.compile(r'^(\d+)_')
def __new__(cls):
with cls._instance_lock:
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
def __init__(self):
# J6(审计 2026-08-19):__new__ 的锁只防双实例,多线程首例并发
# 同时进入 __init__ 会使 _load_prompts 双重执行,_prompts(类属性)
# 整份翻倍——初始化检查与加载收进同一把锁
with self._instance_lock:
if not self._prompts:
self._load_prompts()
def _get_prompts_dir(self) -> str:
config = ConfigManager()
prompts_dir = config.get("system.prompts_dir", "~/.aibox/prompts")
return os.path.expanduser(prompts_dir)
def _load_prompts(self, target: Optional[List[Dict]] = None):
"""加载提示词到 target 列表(缺省 self._prompts)。
M6:reload 传入新列表本地构建,完成后由 reload 原子替换——
此前先清空 self._prompts 再逐文件重读,并发读者(其他连接构建
system 消息)会读到空/半截提示词列表。
"""
prompts = target if target is not None else self._prompts
self._prompts_dir = self._get_prompts_dir()
os.makedirs(self._prompts_dir, exist_ok=True)
# 确保核心文件存在
self._ensure_identity_file()
self._ensure_status_file()
self._ensure_soul_file() # 智能体灵魂文件
# 确保基础技能文件存在
self._ensure_skill_files()
prompt_files = []
if os.path.exists(self._prompts_dir):
# 按文件名排序,确保1_开头的技能文件在99_SOUL.md之前加载
prompt_files = sorted([f for f in os.listdir(self._prompts_dir)
if os.path.isfile(os.path.join(self._prompts_dir, f))
and not f.startswith('.')])
for filename in prompt_files:
filepath = os.path.join(self._prompts_dir, filename)
try:
with open(filepath, 'r', encoding='utf-8') as f:
content = f.read().strip()
if content:
prompts.append({
'file': filename,
'content': content
})
except Exception as e:
print(f"⚠️ 读取提示词文件 {filename} 失败: {e}")
if not prompts:
self._create_default_prompts()
def _get_template_dir(self) -> str:
"""获取仓库中的提示词模板目录(aibox/prompts/)"""
script_dir = os.path.dirname(os.path.abspath(__file__))
# subsystems/ 上一层是 aibox/,模板在其下的 prompts/
aibox_dir = os.path.dirname(script_dir)
return os.path.join(aibox_dir, 'prompts')
def _ensure_from_template(self, filename: str):
"""从模板目录复制提示词文件到运行目录。
模板目录为 aibox/prompts/,若其中存在同名文件则复制过去;
若模板不存在则跳过(由调用方保证模板齐全)。
已存在的运行时文件不会被覆盖。
"""
target_path = os.path.join(self._prompts_dir, filename)
if os.path.exists(target_path):
return # 已存在,不覆盖
template_path = os.path.join(self._get_template_dir(), filename)
if os.path.exists(template_path):
try:
shutil.copy2(template_path, target_path)
print(f"✅ 已从模板创建: {filename}")
except Exception as e:
print(f"⚠️ 从模板复制 {filename} 失败: {e}")
def _ensure_identity_file(self):
"""确保用户身份文件存在"""
self._ensure_from_template(self.IDENTITY_FILE)
def _ensure_status_file(self):
"""确保状态文件存在"""
self._ensure_from_template(self.STATUS_FILE)
def _ensure_soul_file(self):
"""确保智能体灵魂文件存在"""
self._ensure_from_template(self.SOUL_FILE)
def _ensure_skill_files(self):
"""确保基础技能文件存在 - 从模板目录复制所有 N_*.md 文件"""
template_dir = self._get_template_dir()
if not os.path.isdir(template_dir):
return
for filename in sorted(os.listdir(template_dir)):
if self._SKILL_FILE_RE.match(filename): # N_ 开头的文件
self._ensure_from_template(filename)
def update_identity(self, content: str) -> bool:
"""更新用户身份信息"""
if len(content or "") > self.MAX_CONTENT_LENGTH:
print(f"⚠️ 身份内容超长({len(content)} > {self.MAX_CONTENT_LENGTH} 字符),拒绝写入")
return False
identity_path = os.path.join(self._prompts_dir, self.IDENTITY_FILE)
try:
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
updated_content = f"""# 用户身份信息
{content}
【更新记录】
- 上次更新:{timestamp}
"""
# J5:原子写,崩溃不留半截提示词文件
atomic_write_text(identity_path, updated_content)
self.reload()
return True
except Exception as e:
print(f"⚠️ 更新身份文件失败: {e}")
return False
def update_status(self, content: str) -> bool:
"""更新智能体状态"""
if len(content or "") > self.MAX_CONTENT_LENGTH:
print(f"⚠️ 状态内容超长({len(content)} > {self.MAX_CONTENT_LENGTH} 字符),拒绝写入")
return False
status_path = os.path.join(self._prompts_dir, self.STATUS_FILE)
try:
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
updated_content = f"""# 智能体状态记录
{content}
【更新记录】
- 上次更新:{timestamp}
"""
# J5:原子写,崩溃不留半截提示词文件
atomic_write_text(status_path, updated_content)
self.reload()
return True
except Exception as e:
print(f"⚠️ 更新状态文件失败: {e}")
return False
def update_soul(self, content: str) -> bool:
"""更新智能体灵魂定义 - 所有角色定位内容"""
if len(content or "") > self.MAX_CONTENT_LENGTH:
print(f"⚠️ 灵魂内容超长({len(content)} > {self.MAX_CONTENT_LENGTH} 字符),拒绝写入")
return False
soul_path = os.path.join(self._prompts_dir, self.SOUL_FILE)
try:
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
updated_content = f"""# 智能体灵魂定义 - 我是谁
{content}
【更新记录】
- 上次更新:{timestamp}
"""
# J5:原子写,崩溃不留半截提示词文件
atomic_write_text(soul_path, updated_content)
self.reload()
return True
except Exception as e:
print(f"⚠️ 更新灵魂文件失败: {e}")
return False
def get_identity(self) -> str:
"""获取用户身份信息"""
identity_path = os.path.join(self._prompts_dir, self.IDENTITY_FILE)
try:
if os.path.exists(identity_path):
with open(identity_path, 'r', encoding='utf-8') as f:
return f.read().strip()
except Exception as e:
print(f"⚠️ 读取身份文件失败: {e}")
return "尚不清楚 - 无法读取身份信息"
def get_status(self) -> str:
"""获取智能体状态"""
status_path = os.path.join(self._prompts_dir, self.STATUS_FILE)
try:
if os.path.exists(status_path):
with open(status_path, 'r', encoding='utf-8') as f:
return f.read().strip()
except Exception as e:
print(f"⚠️ 读取状态文件失败: {e}")
return "这是第一次对话 - 无法读取状态信息"
def get_soul(self) -> str:
"""获取智能体灵魂定义 - 角色定位核心"""
soul_path = os.path.join(self._prompts_dir, self.SOUL_FILE)
try:
if os.path.exists(soul_path):
with open(soul_path, 'r', encoding='utf-8') as f:
return f.read().strip()
except Exception as e:
print(f"⚠️ 读取灵魂文件失败: {e}")
return """我是DeepSeek,一个智能AI助手。
请创建 99_SOUL.md 文件来定义我的完整身份、角色、性格和能力边界。"""
def get_soul_prompt(self) -> Optional[Dict]:
"""获取灵魂提示词(99_SOUL.md)"""
for p in self._prompts:
if p['file'] == self.SOUL_FILE:
return p
return None
def _create_default_prompts(self):
"""创建默认提示词(已由各 _ensure_* 方法处理)"""
pass
@classmethod
def _skill_sort_key(cls, filename: str):
"""技能文件按前缀数字排序,非技能文件返回 None。"""
m = cls._SKILL_FILE_RE.match(filename)
return int(m.group(1)) if m else None
def _prompt_sort_key(self, p):
"""排序规则:00_IDENTITY → 技能文件(按数字升序) → 99_SOUL → 100_STATUS → 其他"""
file = p['file']
if file == self.IDENTITY_FILE:
return (0,)
skill_num = self._skill_sort_key(file)
if skill_num is not None:
return (1, skill_num)
if file == self.SOUL_FILE:
return (2,)
if file == self.STATUS_FILE:
return (4,)
return (3, file) # 其他文件按名字排序
def get_prompts(self, join_with: str = "\n\n") -> str:
"""获取所有提示词,按顺序拼接(identity → 技能文件 → soul → status)"""
if not self._prompts:
return ""
sorted_prompts = sorted(self._prompts, key=self._prompt_sort_key)
contents = [p['content'] for p in sorted_prompts]
return join_with.join(contents)
def get_sorted_prompts_list(self) -> List[Dict]:
"""获取按正确顺序排列的提示词列表(用于 system 消息构建)。"""
return sorted(self._prompts, key=self._prompt_sort_key)
def get_prompts_list(self) -> List[Dict]:
"""获取所有提示词列表(原始加载顺序,已弃用,请用 get_sorted_prompts_list)。"""
return self._prompts.copy()
def get_skill_prompts(self) -> List[Dict]:
"""获取所有基础技能提示词(N_ 开头的文件)。"""
return [p for p in self._prompts if self._skill_sort_key(p['file']) is not None]
def reload(self):
"""重新加载所有提示词(M6:构建新列表后原子替换,并发读者始终读到完整列表)"""
new_prompts: List[Dict] = []
self._load_prompts(new_prompts)
self._prompts = new_prompts
return len(self._prompts)
def get_prompt_count(self) -> int:
return len(self._prompts)
def get_prompts_dir_path(self) -> str:
return self._prompts_dir