agent-Specialization/core/main_terminal_parts/context/messages.py
JOJO e29ccb318e fix(multi-agent): 修复情况2主消息池派发链路
修复多智能体模式下主智能体空闲时收不到子智能体输出推送的一系列 bug:

1. poll_multi_agent_notifications 死锁:原实现先等所有 running 实例
   退出再 drain 消息池,导致 ask_master 在 await 期间永远等不到主对话
   回答。改为池优先:pool 有消息立即派发,不管 running 状态。

2. _dispatch_multi_agent_idle_messages 缺 import:调用
   inject_multi_agent_master_message 但文件顶部从未导入,NameError
   被外层 except 吞掉,task 永远建不起来。

3. dispatch 内调试日志引用 rec 错位:dispatch_ma_idle_sender_user_message
   被放到 create_chat_task 之前,触发 UnboundLocalError,task 同样建不起来。

4. session_data['auto_user_message_payload'] / preceding_user_notices
   payload 漏写 auto_message_type:前端 isMultiAgentMessage() 只认
   auto_message_type.startsWith('multi_agent_'),空字符串走 fallback
   通知渲染。

5. dispatch 第①步重复持久化:对包含 last 的全部消息都调
   inject_multi_agent_master_message 落盘,之后 task 又在 handle_task_with_sender
   再 add_conversation,导致历史里出现两条相同 user 消息(前一条多智能体渲染,
   后一条通知渲染)。前置 N-1 条只持久化一次,最后一条交给后续 task 自己持久化。

6. last 赋值时机错位:last_emit_payload 在 last=parsed_messages[-1] 之前引用,
   UnboundLocalError 再次吃掉后续链路。

7. handle_task_with_sender 多智能体分支漏写 visibility='chat':
   _user_message_ui_defaults('sub_agent') 默认 visibility='compact',
   透传到落盘 metadata 后,前端从后端加载历史时走通知渲染分支。显式
   user_message_metadata['visibility']='chat' 强制走多智能体专用渲染。
2026-07-13 20:05:02 +08:00

408 lines
20 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
try:
from config.limits import THINKING_FAST_INTERVAL
except ImportError:
THINKING_FAST_INTERVAL = 10
from modules.container_monitor import collect_stats, inspect_state
from core.tool_config import TOOL_CATEGORIES
from utils.api_client import DeepSeekClient
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
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)
def _build_main_system_prompt() -> str:
system_prompt_template = self.load_prompt(prompt_name)
# main_system.txt / main_system_vl.txt 仅使用 {model_description}
return system_prompt_template.format(
model_description=prompt_replacements.get("model_description", "")
)
system_prompt = self._get_or_init_frozen_prompt(
"frozen_main_system_prompt",
_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 → 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})
# 最近对话
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_template = self.load_prompt("agents_md_inject").strip()
if agents_md_template and "{{AGENTS_MD_CONTENT}}" in agents_md_template:
return agents_md_template.replace("{{AGENTS_MD_CONTENT}}", agents_md_content)
return f"【AGENTS.md 项目规范】\n\n{agents_md_content}\n\n---\n请注意:以上规范来自工作区根目录的 AGENTS.md 文件,若有冲突请以 AGENTS.md 为准。"
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})
# skills 列表
skills_catalog = get_skills_catalog(private_dir=infer_private_skills_dir(self.data_dir))
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})
# 记忆系统(总体长期记忆 + 项目记忆)
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 "",
)
# 多智能体模式提示词:当会话 metadata.multi_agent_mode 为真时追加
if getattr(self, "multi_agent_mode", False):
try:
from modules.multi_agent.prompts import build_multi_agent_master_prompt
multi_agent_prompt = build_multi_agent_master_prompt(self.project_path, base="")
if multi_agent_prompt:
messages.append({"role": "system", "content": multi_agent_prompt})
except Exception as exc:
logger.warning(f"[messages] 注入多智能体 prompt 失败: {exc}")
# 可用的子智能体角色(动态 prompt第一个用户消息后冻结
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
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
messages.append(message)
elif conv["role"] == "tool":
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}")
return messages