agent-Specialization/core/main_terminal_parts/context/messages.py
JOJO 6fb5d10542 feat(context): CLAUDE.md 注入开关(默认关)+ .agents/skills 行业目录扫描(含同名冲突报错)
- personalization 新增 claude_md_auto_inject(默认关)与 agents_skills_scan_enabled(默认开),配置存 personalization.json 按工作区隔离
- prompt.py 指令文件加载器泛化,messages.py 新增 CLAUDE.md 冻结注入段(模板 claude_md_inject.txt),子目录只列路径通知
- skills_manager catalog 新增工作区 .agents/skills/ 根(原地读取不同步复制),build_skills_list 按真实路径前缀展示并附来源/冲突说明
- read_skill 支持 .agents/skills 路径解析;与 .astrion/skills 同名重复时报错并引导 read_file 按具体路径读取
- 多智能体 sub_agent_context 同步 CLAUDE.md 开关;顺带修复其 skills 段参数名写错被 except 吞掉恒为空、以及 f-string 打印列表 repr 的存量 bug
- 前端个人空间新增两个开关
2026-08-26 12:14:49 +08:00

543 lines
28 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.

import asyncio
import json
from pathlib import Path
from typing import Any, Dict, List, Optional, Set
try:
from config import (
OUTPUT_FORMATS, DATA_DIR, PROMPTS_DIR, NEED_CONFIRMATION,
MAX_TERMINALS, TERMINAL_BUFFER_SIZE, TERMINAL_DISPLAY_SIZE,
MAX_READ_FILE_CHARS, READ_TOOL_DEFAULT_MAX_CHARS,
READ_TOOL_DEFAULT_CONTEXT_BEFORE, READ_TOOL_DEFAULT_CONTEXT_AFTER,
READ_TOOL_MAX_CONTEXT_BEFORE, READ_TOOL_MAX_CONTEXT_AFTER,
READ_TOOL_DEFAULT_MAX_MATCHES, READ_TOOL_MAX_MATCHES,
READ_TOOL_MAX_FILE_SIZE,
TERMINAL_SANDBOX_MOUNT_PATH,
TERMINAL_SANDBOX_MODE,
TERMINAL_SANDBOX_CPUS,
TERMINAL_SANDBOX_MEMORY,
PROJECT_MAX_STORAGE_MB,
CUSTOM_TOOLS_ENABLED,
)
except ImportError:
import sys
project_root = Path(__file__).resolve().parents[2]
if str(project_root) not in sys.path:
sys.path.insert(0, str(project_root))
from config import (
OUTPUT_FORMATS, DATA_DIR, PROMPTS_DIR, NEED_CONFIRMATION,
MAX_TERMINALS, TERMINAL_BUFFER_SIZE, TERMINAL_DISPLAY_SIZE,
MAX_READ_FILE_CHARS, READ_TOOL_DEFAULT_MAX_CHARS,
READ_TOOL_DEFAULT_CONTEXT_BEFORE, READ_TOOL_DEFAULT_CONTEXT_AFTER,
READ_TOOL_MAX_CONTEXT_BEFORE, READ_TOOL_MAX_CONTEXT_AFTER,
READ_TOOL_DEFAULT_MAX_MATCHES, READ_TOOL_MAX_MATCHES,
READ_TOOL_MAX_FILE_SIZE,
TERMINAL_SANDBOX_MOUNT_PATH,
TERMINAL_SANDBOX_MODE,
TERMINAL_SANDBOX_CPUS,
TERMINAL_SANDBOX_MEMORY,
PROJECT_MAX_STORAGE_MB,
CUSTOM_TOOLS_ENABLED,
)
from modules.file_manager import FileManager
from modules.search_engine import SearchEngine
from modules.terminal_ops import TerminalOperator
from modules.memory_manager import MemoryManager
from modules.terminal_manager import TerminalManager
from modules.todo_manager import TodoManager
from modules.sub_agent import SubAgentManager
from modules.webpage_extractor import extract_webpage_content, tavily_extract
from modules.multi_agent.prompts import build_available_agents_prompt
from modules.ocr_client import OCRClient
from modules.easter_egg_manager import EasterEggManager
from modules.personalization_manager import (
load_personalization_config,
build_personalization_prompt,
RECENT_CONVERSATIONS_PROMPT_LIMIT_MIN,
RECENT_CONVERSATIONS_PROMPT_LIMIT_MAX,
RECENT_CONVERSATIONS_PROMPT_LIMIT_DEFAULT,
)
from modules.skills_manager import (
get_skills_catalog,
build_skills_list,
merge_enabled_skills,
build_skills_prompt,
infer_private_skills_dir,
)
from modules.custom_tool_registry import CustomToolRegistry, build_default_tool_category
from modules.custom_tool_executor import CustomToolExecutor
from modules.container_monitor import collect_stats, inspect_state
from core.tool_config import TOOL_CATEGORIES
from utils.api_client import APIClient
from utils.context_manager import ContextManager, AUTO_SHALLOW_PLACEHOLDER
from utils.host_workspace_debug import write_host_workspace_debug
from utils.tool_result_formatter import format_tool_result_for_context
from utils.logger import setup_logger
from config.model_profiles import (
get_model_profile,
get_model_prompt_replacements,
get_model_context_window,
model_supports_image,
model_supports_video,
)
logger = setup_logger(__name__)
DISABLE_LENGTH_CHECK = True
def _build_sub_md_notice(filename: str, sub_paths: List[str], total: int) -> str:
"""构建子目录指令文件路径通知文案(只列相对路径,不注入内容)。无子目录文件时返回空串。"""
if not sub_paths:
return ""
lines = [f"另外,工作区以下子目录也存在 {filename} 规范文件:", ""]
for p in sub_paths:
lines.append(f"- `{p}`")
if total > len(sub_paths):
lines.append("")
lines.append(f"...共 {total}")
lines.append("")
lines.append(f"这些子目录规范仅适用于其所在目录范围。若你的操作涉及上述目录,请先自行读取对应 {filename} 了解局部规范后再动手,不得将子目录规范当作全局规范套用。")
return "\n".join(lines)
class MessagesMixin:
"""MainTerminalContextMixin messages 能力 mixin。"""
def build_messages(self, context: Dict, user_input: str) -> List[Dict]:
"""构建消息列表(添加终端内容注入)"""
try:
file_tree_preview = (context.get("project_info", {}).get("file_tree") or "").splitlines()
write_host_workspace_debug(
"main_terminal.build_messages.context_snapshot",
terminal_id=id(self),
terminal_project_path=str(getattr(self, "project_path", "")),
context_project_path=str(getattr(getattr(self, "context_manager", None), "project_path", "")),
project_info_path=str(context.get("project_info", {}).get("path", "")),
file_tree_first_line=file_tree_preview[0] if file_tree_preview else "",
current_conversation_id=getattr(getattr(self, "context_manager", None), "current_conversation_id", None),
)
except Exception:
pass
# 根据当前模型多模态能力选择系统提示。system prompt 按对话冻结;
# 工具 schema 保持动态,由 define_tools 每轮根据真实状态生成。
current_model = getattr(self, "model_key", None)
prompt_name = "main_system_vl" if (model_supports_image(current_model) or model_supports_video(current_model)) else "main_system"
model_key = getattr(self, "model_key", None)
prompt_replacements = get_model_prompt_replacements(model_key)
is_multi_agent_mode = bool(getattr(self, "multi_agent_mode", False))
def _build_main_system_prompt() -> str:
if is_multi_agent_mode:
# 多智能体模式下主 prompt 使用 Team Leader 专属模板
try:
from modules.multi_agent.prompts import _load_template
template = _load_template("master")
# master.txt 含 HTML 示例等单花括号内容,不能走 str.format
# 仅替换模型描述占位符,避免字面量泄漏
return template.replace(
"{model_description}",
prompt_replacements.get("model_description", ""),
)
except Exception as exc:
logger.warning(f"[messages] 加载多智能体主 prompt 失败: {exc}")
system_prompt_template = self.load_prompt(prompt_name)
# main_system.txt / main_system_vl.txt 仅使用 {model_description}
base_prompt = system_prompt_template.format(
model_description=prompt_replacements.get("model_description", "")
)
# 修改留痕附言:开关开启且 conversation_id 可用时追加独立 prompt 段落
# (含本对话专属留痕目录;冻结在 prompt 中,与该对话终身一致)
try:
from modules.modify_history import build_modify_history_prompt_note
note = build_modify_history_prompt_note(
project_path=getattr(self, "project_path", None),
data_dir=getattr(self, "data_dir", None),
conversation_id=getattr(getattr(self, "context_manager", None), "current_conversation_id", None),
)
if note:
base_prompt = f"{base_prompt}\n\n{note}"
except Exception as exc:
logger.warning(f"[messages] 构建修改留痕 prompt 失败: {exc}")
return base_prompt
main_system_frozen_key = (
"frozen_main_system_prompt_multi_agent"
if is_multi_agent_mode
else "frozen_main_system_prompt"
)
system_prompt = self._get_or_init_frozen_prompt(
main_system_frozen_key,
_build_main_system_prompt,
)
messages = [
{"role": "system", "content": system_prompt}
]
personalization_config = getattr(self.context_manager, "custom_personalization_config", None) or load_personalization_config(self.data_dir)
shallow_replace_enabled = bool(personalization_config.get("auto_shallow_compress_enabled", False)) if isinstance(personalization_config, dict) else False
# 顺序主prompt → 权限模式 → 执行环境 → 最近对话 → 个性化配置 → 工作区信息 → AGENTS.md → CLAUDE.md → skills → 记忆 → 自定义 → 禁用提示
# 权限模式
permission_mode_message = self._get_or_init_frozen_mode_prompt(
"frozen_permission_prompt",
self._build_permission_mode_message,
)
if permission_mode_message:
messages.append({"role": "system", "content": permission_mode_message})
# 执行环境
execution_mode_message = self._get_or_init_frozen_mode_prompt(
"frozen_execution_prompt",
self._build_execution_mode_message,
)
if execution_mode_message:
messages.append({"role": "system", "content": execution_mode_message})
# 运行模式(计划/询问/执行)
work_mode_message = self._get_or_init_frozen_mode_prompt(
"frozen_work_mode_prompt",
self._build_work_mode_message,
)
if work_mode_message:
messages.append({"role": "system", "content": work_mode_message})
# 最近对话
def _build_recent_conversations_prompt() -> str:
recent_conversations_enabled = (
bool(personalization_config.get("recent_conversations_prompt_enabled", False))
if isinstance(personalization_config, dict)
else False
)
if not recent_conversations_enabled:
return ""
try:
recent_limit = int(
personalization_config.get(
"recent_conversations_prompt_limit",
RECENT_CONVERSATIONS_PROMPT_LIMIT_DEFAULT,
)
)
except Exception:
recent_limit = RECENT_CONVERSATIONS_PROMPT_LIMIT_DEFAULT
recent_limit = max(
RECENT_CONVERSATIONS_PROMPT_LIMIT_MIN,
min(RECENT_CONVERSATIONS_PROMPT_LIMIT_MAX, recent_limit),
)
return self._build_recent_conversations_message(limit=recent_limit) or ""
recent_conversations_prompt = self._get_or_init_frozen_prompt(
"frozen_recent_conversations_prompt",
_build_recent_conversations_prompt,
)
if recent_conversations_prompt:
messages.append({"role": "system", "content": recent_conversations_prompt})
# 个性化配置
def _build_personalization_system_prompt() -> str:
personalization_block = build_personalization_prompt(personalization_config, include_header=False)
if not personalization_block:
return ""
personalization_template = self.load_prompt("personalization").strip()
if personalization_template and "{personalization_block}" in personalization_template:
return personalization_template.format(personalization_block=personalization_block)
if personalization_template:
return f"{personalization_template}\n{personalization_block}"
return personalization_block
personalization_text = self._get_or_init_frozen_prompt(
"frozen_personalization_prompt",
_build_personalization_system_prompt,
)
if personalization_text:
messages.append({"role": "system", "content": personalization_text})
# 工作区信息
workspace_system = self._get_or_init_frozen_prompt(
"frozen_workspace_prompt",
lambda: self.context_manager._build_workspace_system_message(context) or "",
)
if workspace_system:
messages.append({"role": "system", "content": workspace_system})
# AGENTS.md
def _build_agents_md_prompt() -> str:
agents_md_inject_enabled = bool(personalization_config.get("agents_md_auto_inject", False)) if isinstance(personalization_config, dict) else False
if not agents_md_inject_enabled:
return ""
agents_md_content = self._load_agents_md_content()
if not agents_md_content:
return ""
# 子目录 AGENTS.md 只通知相对路径,不注入内容
sub_paths, sub_total = self._load_sub_agents_md_paths()
sub_notice = _build_sub_md_notice("AGENTS.md", sub_paths, sub_total)
agents_md_template = self.load_prompt("agents_md_inject").strip()
if agents_md_template and "{{AGENTS_MD_CONTENT}}" in agents_md_template:
text = agents_md_template.replace("{{AGENTS_MD_CONTENT}}", agents_md_content)
text = text.replace("{{AGENTS_MD_UPDATED_AT}}", self._load_agents_md_updated_at())
text = text.replace("{{SUB_AGENTS_MD_NOTICE}}", sub_notice)
return text
# 模板缺失/无占位符时的兜底格式
text = f"【AGENTS.md 项目规范】{self._load_agents_md_updated_at()}\n\n{agents_md_content}\n\n---\n请注意:以上规范来自工作区根目录的 AGENTS.md 文件,若有冲突请以 AGENTS.md 为准。"
return f"{text}\n\n{sub_notice}" if sub_notice else text
# AGENTS.md 注入开关可能变化,只在开启时生成并缓存,避免关闭时把空字符串冻住
agents_md_inject_enabled = (
bool(personalization_config.get("agents_md_auto_inject", False))
if isinstance(personalization_config, dict)
else False
)
if agents_md_inject_enabled:
agents_md_text = self._get_or_init_frozen_prompt(
"frozen_agents_md_prompt",
_build_agents_md_prompt,
)
if agents_md_text:
messages.append({"role": "system", "content": agents_md_text})
# CLAUDE.md默认关闭开启后与 AGENTS.md 并列注入)
def _build_claude_md_prompt() -> str:
claude_md_content = self._load_root_md_content("CLAUDE.md")
if not claude_md_content:
return ""
# 子目录 CLAUDE.md 只通知相对路径,不注入内容
sub_paths, sub_total = self._load_sub_md_paths("CLAUDE.md")
sub_notice = _build_sub_md_notice("CLAUDE.md", sub_paths, sub_total)
claude_md_template = self.load_prompt("claude_md_inject").strip()
if claude_md_template and "{{CLAUDE_MD_CONTENT}}" in claude_md_template:
text = claude_md_template.replace("{{CLAUDE_MD_CONTENT}}", claude_md_content)
text = text.replace("{{CLAUDE_MD_UPDATED_AT}}", self._load_root_md_updated_at("CLAUDE.md"))
text = text.replace("{{SUB_CLAUDE_MD_NOTICE}}", sub_notice)
return text
# 模板缺失/无占位符时的兜底格式
text = f"【CLAUDE.md 项目规范】{self._load_root_md_updated_at('CLAUDE.md')}\n\n{claude_md_content}\n\n---\n请注意:以上规范来自工作区根目录的 CLAUDE.md 文件,若有冲突请以 CLAUDE.md 为准。"
return f"{text}\n\n{sub_notice}" if sub_notice else text
# 与 AGENTS.md 同理,只在开启时生成并缓存
claude_md_inject_enabled = (
bool(personalization_config.get("claude_md_auto_inject", False))
if isinstance(personalization_config, dict)
else False
)
if claude_md_inject_enabled:
claude_md_text = self._get_or_init_frozen_prompt(
"frozen_claude_md_prompt",
_build_claude_md_prompt,
)
if claude_md_text:
messages.append({"role": "system", "content": claude_md_text})
# skills 列表
skills_catalog = get_skills_catalog(
private_dir=infer_private_skills_dir(self.data_dir),
project_path=self.project_path,
scan_project_agents=(
bool(personalization_config.get("agents_skills_scan_enabled", True))
if isinstance(personalization_config, dict)
else True
),
)
enabled_skills = merge_enabled_skills(
personalization_config.get("enabled_skills") if isinstance(personalization_config, dict) else None,
skills_catalog,
personalization_config.get("skills_catalog_snapshot") if isinstance(personalization_config, dict) else None,
)
def _build_skills_system_prompt() -> str:
skills_template = self.load_prompt("skills_system").strip()
skills_list = build_skills_list(skills_catalog, enabled_skills)
return build_skills_prompt(skills_template, skills_list) or ""
skills_prompt = self._get_or_init_frozen_prompt(
"frozen_skills_prompt",
_build_skills_system_prompt,
)
if skills_prompt:
messages.append({"role": "system", "content": skills_prompt})
# 工作流Workflow上下文不冻结每次按当前状态现生成。
# 阶段推进时由 workflow_flow.refresh_workflow_system_segment 同步刷新,
# 压缩不影响system 段不在压缩范围),天然免疫压缩丢失。
def _build_workflow_system_prompt() -> str:
try:
from server.workflow_flow import build_workflow_system_prompt
conversation_id = getattr(self.context_manager, "current_conversation_id", None)
return build_workflow_system_prompt(data_dir=self.data_dir, conversation_id=conversation_id) or ""
except Exception:
return ""
workflow_prompt = _build_workflow_system_prompt()
if workflow_prompt:
messages.append({"role": "system", "content": workflow_prompt})
# 记忆系统(总体长期记忆 + 项目记忆)
def _build_memory_system_prompt() -> str:
return self._build_memory_system_content()
memory_prompt = self._get_or_init_frozen_prompt(
"frozen_memory_prompt",
_build_memory_system_prompt,
)
if memory_prompt:
messages.append({"role": "system", "content": memory_prompt})
# API 自定义 system prompt
custom_system_prompt = self._get_or_init_frozen_prompt(
"frozen_custom_system_prompt",
lambda: (
getattr(self.context_manager, "custom_system_prompt", "").strip()
if isinstance(getattr(self.context_manager, "custom_system_prompt", None), str)
else ""
),
)
if custom_system_prompt:
messages.append({"role": "system", "content": custom_system_prompt})
# 禁用工具提示
disabled_notice = self._get_or_init_frozen_prompt(
"frozen_disabled_tools_prompt",
lambda: self._format_disabled_tool_notice() or "",
)
# 多智能体模式额外提示词:可用的子智能体角色(动态 prompt第一个用户消息后冻结
if getattr(self, "multi_agent_mode", False):
try:
available_agents_prompt = self._get_or_init_frozen_prompt(
"frozen_available_agents_prompt",
lambda: build_available_agents_prompt() or "",
)
if available_agents_prompt:
messages.append({"role": "system", "content": available_agents_prompt})
except Exception as exc:
logger.warning(f"[messages] 注入可用子智能体 prompt 失败: {exc}")
if disabled_notice:
messages.append({
"role": "system",
"content": disabled_notice
})
# 添加对话历史保留完整结构包括tool_calls和tool消息
conversation = context["conversation"]
replaced_tool_count = 0
deep_compacted_skipped = 0
orphan_tool_skipped = 0
# 已随 assistant 消息发出的 tool_call id 集合:用于剥离孤儿 tool 消息。
# 防御线:历史数据一旦被并发写入/截断损坏assistant.tool_calls 与其 tool
# 响应错位),孤儿 tool 消息会让 API 直接 400tool_call_id is not found
# 对话永久不可用。这里保证即使数据损坏,请求也始终合法(内容降级但不崩)。
emitted_tool_call_ids = set()
for idx, conv in enumerate(conversation):
metadata = conv.get("metadata") or {}
# 深压缩:被标记为 deep_compacted 的消息(整段已压缩前缀)原文保留在历史中用于展示,
# 但构建请求时整体跳过,不纳入上下文。由于按连续前缀整体标记,
# 不会出现 assistant.tool_calls 与其 tool 响应被拆散导致的配对悬空。
if metadata.get("deep_compacted"):
deep_compacted_skipped += 1
continue
if conv["role"] == "assistant":
# Assistant消息可能包含工具调用
message = {
"role": conv["role"],
"content": conv["content"]
}
# 对于思考模式(如 DeepSeek thinkingassistant 历史消息中的
# reasoning_content 即使为空字符串也需要原样回传,否则下一轮可能被
# API 判定为“未回传 reasoning_content”并返回 400。
if "reasoning_content" in conv:
message["reasoning_content"] = conv.get("reasoning_content", "")
# 如果有工具调用信息,添加到消息中
tool_calls = conv.get("tool_calls") or []
if tool_calls and self._tool_calls_followed_by_tools(conversation, idx, tool_calls):
message["tool_calls"] = tool_calls
for _tc in tool_calls:
_tcid = (_tc or {}).get("id")
if _tcid:
emitted_tool_call_ids.add(_tcid)
messages.append(message)
elif conv["role"] == "tool":
# 孤儿 tool 消息(父 assistant 被跳过/未携带 tool_calls/数据损坏)
# 直接剥离,避免 API 400正常情况下不会发生。
_tool_call_id = conv.get("tool_call_id") or ""
if not _tool_call_id or _tool_call_id not in emitted_tool_call_ids:
orphan_tool_skipped += 1
continue
if shallow_replace_enabled and metadata.get("auto_shallow_compacted"):
messages.append({
"role": "tool",
"content": AUTO_SHALLOW_PLACEHOLDER,
"tool_call_id": conv.get("tool_call_id", ""),
"name": conv.get("name", "")
})
replaced_tool_count += 1
continue
# Tool消息需要保留完整结构
images = conv.get("images") or metadata.get("images") or []
videos = conv.get("videos") or metadata.get("videos") or []
media_refs = conv.get("media_refs") or metadata.get("media_refs") or []
content_value = conv.get("content")
if isinstance(content_value, list):
content_payload = content_value
elif images or videos or media_refs:
content_payload = self.context_manager._build_content_with_images(
content_value,
images,
videos,
media_refs=media_refs,
)
else:
content_payload = content_value
message = {
"role": "tool",
"content": content_payload,
"tool_call_id": conv.get("tool_call_id", ""),
"name": conv.get("name", "")
}
messages.append(message)
else:
# User 或普通 System 消息
images = conv.get("images") or metadata.get("images") or []
videos = conv.get("videos") or metadata.get("videos") or []
media_refs = conv.get("media_refs") or metadata.get("media_refs") or []
content_payload = (
self.context_manager._build_content_with_images(
conv["content"],
images,
videos,
media_refs=media_refs,
)
if (images or videos or media_refs) else conv["content"]
)
# 调试:记录所有 system 消息
if conv["role"] == "system":
logger.info(f"[DEBUG build_messages] 添加 system 消息: content前50字={conv['content'][:50]}")
# 调试:记录多智能体子智能体输出消息是否进入上下文
if metadata.get("multi_agent_message"):
logger.info(
f"[DEBUG build_messages] 添加多智能体子智能体消息到上下文: "
f"role={conv['role']}, subtype={metadata.get('multi_agent_subtype')}, "
f"display_name={metadata.get('multi_agent_display_name')}, "
f"content前80字={str(conv.get('content', ''))[:80]}"
)
messages.append({
"role": conv["role"],
"content": content_payload
})
# 当前用户输入已经在conversation中了不需要重复添加
if shallow_replace_enabled:
print(f"[ContextCompression] build_messages 替换tool占位符: {replaced_tool_count}")
if deep_compacted_skipped:
print(f"[ContextCompression] build_messages 跳过已深压缩消息: {deep_compacted_skipped}")
if orphan_tool_skipped:
logger.warning(
f"[messages] build_messages 剥离孤儿 tool 消息 {orphan_tool_skipped}"
f"(对话历史疑似损坏,建议检查 tool_call 配对)"
)
return messages