agent-Specialization/modules/sub_agent/task.py
JOJO 811974d6e7 feat(multi-agent): 在现有架构上直接实现多智能体模式实验功能
放弃完全隔离策略,改为在现有 MainTerminal/SubAgentManager/SubAgentTask 主链路
按对话级开关 metadata.multi_agent_mode=true 增加多智能体分支。

新增模块:
- modules/multi_agent/__init__.py: 模块入口
- modules/multi_agent/role_store.py: 角色 Markdown Frontmatter 解析与归档
- modules/multi_agent/state.py: 多智能体会话状态机与消息格式化
- modules/multi_agent/prompts.py: 主智能体(Team Leader) + 子智能体提示词
- modules/multi_agent/tools.py: 9 个主智能体工具 + 4 个子智能体工具定义
- server/multi_agent.py: /multiagent/new 页面 + /api/multiagent/* 蓝图

现有代码改动:
- modules/sub_agent/task.py: 扩展 multi_agent_mode/multi_agent_state/display_name 字段,
  增加 ask_master/ask_other_agent/answer_other_agent/list_active_sub_agents 工具处理逻辑,
  子智能体自然结束 assistant 输出即本轮结束(不调用 finish_task),上下文保留。
- modules/sub_agent/manager.py: create_sub_agent 增加 multi_agent_mode/role_id/display_name 参数,
  增加 get_or_create_multi_agent_state/get_multi_agent_state/inject_message_to_sub_agent/_on_multi_agent_task_done 方法。
- core/main_terminal_parts/tools_definition/agent_tools.py: 多智能体模式下用 modules.multi_agent.tools 替换旧版工具集。
- core/main_terminal_parts/context/messages.py: 多智能体模式下追加 Team Leader 系统提示词。
- core/main_terminal_parts/tools_execution.py: create_sub_agent handler 增加多智能体分支,新增 send_message_to_sub_agent/ask_sub_agent/answer_sub_agent_question/create_custom_agent/list_agents/list_active_sub_agents handler。
- core/web_terminal.py: load_conversation 时检测 metadata.multi_agent_mode 设置 self.multi_agent_mode。
- server/app_legacy.py: 注册 multi_agent_bp 蓝图。

前端改动:
- static/src/auth/LoginApp.vue: 登录页增加'多智能体模式(beta)'按钮
- static/src/app/methods/ui/route.ts: 识别 /multiagent/new 和 /multiagent/conv_xxx 路径,进入多智能体模式并创建带 metadata.multi_agent_mode=true 的对话
- static/src/app/state.ts: 增加 multiAgentMode 状态字段

数据:
- ~/.astrion/astrion/host/mutiagents/agents/: 4 个预置角色 ui-operator / full-stack-engineer / code-reviewer / researcher
- ~/.astrion/astrion/host/mutiagents/conversations/: 会话数据

验证:所有 Python 文件语法检查通过;冒烟测试 test.test_server_refactor_smoke 6 项全通过;前端构建通过(6.04s);模块导入与功能断言测试全部通过。
2026-07-12 03:26:02 +08:00

482 lines
21 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.

from __future__ import annotations
import asyncio
import base64
import json
import mimetypes
import time
import uuid
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, List, Optional, TYPE_CHECKING
from modules.sub_agent.toolkit import (
SUB_AGENT_TOOLS,
FINISH_TOOL,
_format_tool_result,
_build_sub_agent_profile,
)
from utils.api_client import DeepSeekClient
from utils.logger import setup_logger
if TYPE_CHECKING:
from modules.sub_agent.manager import SubAgentManager
from modules.multi_agent.state import MultiAgentState
logger = setup_logger(__name__)
# 多智能体模式下额外加载的工具定义
def _load_multi_agent_sub_agent_tools() -> List[Dict[str, Any]]:
try:
from modules.multi_agent.tools import build_sub_agent_tools_for_role
return build_sub_agent_tools_for_role()
except Exception as exc:
logger.warning(f"[SubAgentTask] 加载多智能体工具失败: {exc}")
return []
class SubAgentTask:
"""单个后台子智能体任务。"""
def __init__(
self,
manager: "SubAgentManager",
task_record: Dict[str, Any],
task_message: str,
system_prompt: str,
model_key: Optional[str],
thinking_mode: Optional[str],
*,
multi_agent_mode: bool = False,
multi_agent_state: Optional["MultiAgentState"] = None,
display_name: Optional[str] = None,
):
self.manager = manager
self.task_record = task_record
self.task_message = task_message
self.system_prompt = system_prompt
self.model_key = model_key
self.thinking_mode = thinking_mode or "fast"
self.task_id = task_record["task_id"]
self.agent_id = task_record["agent_id"]
self.timeout_seconds = int(task_record.get("timeout_seconds") or 180)
self.deliverables_dir = Path(task_record["deliverables_dir"])
self.output_file = Path(task_record["output_file"])
self.stats_file = Path(task_record["stats_file"])
self.progress_file = Path(task_record["progress_file"])
self.conversation_file = Path(task_record["conversation_file"])
self.messages: List[Dict[str, Any]] = [
{"role": "system", "content": system_prompt},
{"role": "user", "content": task_message},
]
self.stats = {
"runtime_start": time.time() * 1000,
"runtime_seconds": 0,
"files_read": 0,
"write_files": 0,
"edit_files": 0,
"searches": 0,
"web_pages": 0,
"commands": 0,
"api_calls": 0,
"token_usage": {"prompt": 0, "completion": 0, "total": 0},
}
self._stdout_lines: List[str] = []
self._cancelled = False
self._task: Optional[asyncio.Task] = None
# 多智能体模式相关字段
self.multi_agent_mode = bool(multi_agent_mode)
self.multi_agent_state = multi_agent_state
# display_name 不传时回退为 'Agent_{agent_id}'
self.display_name = display_name or f"Agent_{self.agent_id}"
def emit(self, type_: str, data: Dict[str, Any]) -> None:
"""输出一行 JSONL 到 progress 文件并缓存。"""
line = json.dumps({"type": type_, **data}, ensure_ascii=False)
self._stdout_lines.append(line)
try:
self.progress_file.parent.mkdir(parents=True, exist_ok=True)
with open(self.progress_file, "a", encoding="utf-8") as f:
f.write(line + "\n")
except Exception:
pass
async def run(self) -> None:
"""主 LLM 循环。"""
try:
await self._run_loop()
except asyncio.CancelledError:
self._cancelled = True
logger.debug(f"[SubAgent] task={self.task_id} 被取消")
# shield 避免取消信号中断最终状态落盘
await asyncio.shield(self._write_failure("子智能体被手动终止"))
raise
except Exception as exc:
logger.exception(f"[SubAgent] task={self.task_id} 执行异常")
await self._write_failure(f"执行异常: {exc}")
async def _run_loop(self) -> None:
client, model_key = self._build_client()
if self.multi_agent_mode:
tools = list(SUB_AGENT_TOOLS)
tools.extend(_load_multi_agent_sub_agent_tools())
# 多智能体模式下不要求 finish_task自然输出结束即本轮任务结束
else:
tools = list(SUB_AGENT_TOOLS)
tools.append(FINISH_TOOL)
start_time = time.time()
max_turns = 50
for turn in range(1, max_turns + 1):
if self._cancelled:
break
elapsed = time.time() - start_time
if elapsed > self.timeout_seconds:
await self._write_timeout(elapsed)
return
self.stats["api_calls"] += 1
self.stats["turn_count"] = turn
self.stats["runtime_seconds"] = int(elapsed)
self.emit("stats", {**self.stats, "turn_count": turn})
assistant_message, reasoning, tool_calls, usage = await self._call_model(client, model_key, tools)
if usage:
self._apply_usage(usage)
# 多智能体模式:把 assistant 文本输出作为进度/完成 output 转发到主对话
if self.multi_agent_mode and self.multi_agent_state and assistant_message.strip():
self._forward_output_to_master(assistant_message)
final_message: Dict[str, Any] = {"role": "assistant", "content": assistant_message}
if reasoning:
final_message["reasoning_content"] = reasoning
if tool_calls:
final_message["tool_calls"] = tool_calls
self.messages.append(final_message)
if not tool_calls:
# 多智能体模式:没有 tool_calls 表示本轮结束,进入 idle 状态
if self.multi_agent_mode:
self._mark_idle()
return
# 普通模式prompt 并要求继续 / finish_task
self.messages.append({
"role": "user",
"content": "如果你已经完成了任务,请调用 finish_task 工具提交完成报告。如果还没有完成,请继续执行任务。",
})
continue
for tool_call in tool_calls:
if self._cancelled:
break
name = tool_call.get("function", {}).get("name", "")
args = self._parse_args(tool_call)
progress_id = tool_call.get("id") or f"tool_{int(time.time() * 1000)}_{uuid.uuid4().hex[:6]}"
if name == "finish_task":
await self._write_finish(args, elapsed)
return
self.emit("progress", {"id": progress_id, "tool": name, "status": "running", "args": args, "ts": int(time.time() * 1000)})
result = await self._execute_tool(name, args)
self.emit("progress", {"id": progress_id, "tool": name, "status": "completed" if result.get("success") else "failed", "args": args, "ts": int(time.time() * 1000)})
self._update_stats(name)
content = _format_tool_result(name, result)
if name == "read_mediafile" and result.get("success"):
content = self._build_media_tool_content(result) or content
self.messages.append({
"role": "tool",
"tool_call_id": tool_call.get("id", progress_id),
"content": content,
})
await self._write_failure("任务执行超过最大轮次限制", max_turns_exceeded=True)
def _forward_output_to_master(self, output_text: str) -> None:
"""把子智能体的 assistant 文本输出转发成主对话的 user 消息。"""
if not self.multi_agent_state:
return
try:
from modules.multi_agent.state import build_sub_agent_output_text
msg = build_sub_agent_output_text(self.display_name, output_text.strip())
self.multi_agent_state.push_master_message(msg)
# 同时记录到实例状态,供 list_active_sub_agents 使用
inst = self.multi_agent_state.get_instance(self.agent_id)
if inst:
inst.last_output = output_text[:500]
except Exception as exc:
logger.warning(f"[SubAgentTask] forward output to master failed: {exc}")
def _mark_idle(self) -> None:
"""多智能体模式下,子智能体自然结束']=本轮任务 结束,进入 idle 状态。"""
if self.multi_agent_state:
self.multi_agent_state.mark_status(self.agent_id, "idle")
def _build_client(self) -> tuple:
"""加载模型配置并初始化 DeepSeekClient。"""
config_path = self.manager.models_config_file
models: List[Dict[str, Any]] = []
default_key = ""
if Path(config_path).exists():
try:
raw = json.loads(Path(config_path).read_text(encoding="utf-8"))
models = raw.get("models", []) if isinstance(raw, dict) else (raw if isinstance(raw, list) else [])
default_key = str(raw.get("default_model", "")) if isinstance(raw, dict) else ""
except Exception as exc:
logger.error(f"[SubAgent] 加载模型配置失败: {exc}")
model_map = {}
valid_models = []
for item in models:
profile = _build_sub_agent_profile(item)
if profile:
key = profile["name"]
model_map[key] = profile
valid_models.append(key)
chosen_key = self.model_key or default_key
if chosen_key not in model_map and valid_models:
chosen_key = valid_models[0]
if chosen_key not in model_map:
raise RuntimeError(f"未找到可用子智能体模型配置: {config_path}")
client = DeepSeekClient(thinking_mode=(self.thinking_mode == "thinking"), web_mode=True)
client.model_key = chosen_key
client.project_path = str(self.manager.project_path)
if self.thinking_mode == "thinking":
# 子智能体的 thinking 模式应全程使用思考模型
client.deep_thinking_session = True
client.apply_profile(model_map[chosen_key])
return client, chosen_key
async def _call_model(
self,
client: DeepSeekClient,
model_key: str,
tools: List[Dict[str, Any]],
) -> tuple:
"""调用模型并解析 assistant 消息。"""
assistant_message = ""
reasoning = ""
tool_calls: List[Dict[str, Any]] = []
usage = None
async for chunk in client.chat(self.messages, tools=tools, stream=True):
if self._cancelled:
break
if chunk.get("error"):
raise RuntimeError(f"API 调用失败: {chunk.get('error')}")
choice = (chunk.get("choices") or [{}])[0]
delta = choice.get("delta") or {}
if delta.get("content"):
assistant_message += delta["content"]
if delta.get("reasoning_content"):
reasoning += delta["reasoning_content"]
elif delta.get("reasoning_details"):
rd = delta["reasoning_details"]
if isinstance(rd, list):
reasoning += "".join(str(d.get("text") or "") for d in rd)
elif isinstance(rd, str):
reasoning += rd
elif isinstance(rd, dict):
reasoning += str(rd.get("text") or "")
for tc in delta.get("tool_calls") or []:
idx = tc.get("index")
if idx is None:
continue
while len(tool_calls) <= idx:
tool_calls.append({"id": "", "type": "function", "function": {"name": "", "arguments": ""}})
existing = tool_calls[idx]
if tc.get("id"):
existing["id"] = tc["id"]
fn = tc.get("function") or {}
if fn.get("name"):
existing["function"]["name"] += fn["name"]
if fn.get("arguments"):
existing["function"]["arguments"] += fn["arguments"]
if chunk.get("usage"):
usage = chunk["usage"]
return assistant_message, reasoning, tool_calls, usage
def _parse_args(self, tool_call: Dict[str, Any]) -> Dict[str, Any]:
raw = tool_call.get("function", {}).get("arguments") or "{}"
try:
return json.loads(raw)
except Exception:
return {"_raw": raw}
async def _execute_tool(self, name: str, args: Dict[str, Any]) -> Dict[str, Any]:
"""通过 manager 调用主进程执行工具。
多智能体模式下对于通信工具ask_master / ask_other_agent / answer_other_agent/
list_active_sub_agents在主进程内直接处理不再转发到 WebTerminal。
"""
if self.multi_agent_mode and self.multi_agent_state:
result = await self._execute_multi_agent_tool(name, args)
if result is not None:
return result
return await self.manager.execute_tool_for_sub_agent(name, args)
async def _execute_multi_agent_tool(self, name: str, args: Dict[str, Any]) -> Optional[Dict[str, Any]]:
"""处理多智能体模式专属的通信工具。返回 None 表示不 属于多智能体工具。"""
state = self.multi_agent_state
if not state:
return None
try:
if name == "ask_master":
question = str(args.get("question") or "").strip()
question_id = str(args.get("question_id") or f"ask_master_{uuid.uuid4().hex[:10]}")
if not question:
return {"success": False, "error": "question 不能为空"}
# 插入到主对话
from modules.multi_agent.state import build_sub_agent_ask_master_text
msg = build_sub_agent_ask_master_text(self.display_name, question, question_id)
state.push_master_message(msg)
inst = state.get_instance(self.agent_id)
if inst:
inst.last_output = f"[ask_master] {question[:200]}"
# 阻塞等待回答(状态标为正在等待主智能体回答)
state.mark_status(self.agent_id, "running")
answer = await state.wait_for_answer(question_id, self.agent_id, timeout=float(args.get("timeout_seconds") or 600))
state.mark_status(self.agent_id, "running")
return {"success": True, "answer": answer, "question_id": question_id}
if name == "ask_other_agent":
target_id = int(args.get("target_agent_id") or 0)
question = str(args.get("question") or "").strip()
question_id = str(args.get("question_id") or f"ask_other_{uuid.uuid4().hex[:10]}")
if not target_id or not question:
return {"success": False, "error": "参数缺失"}
# 查找目标实例
target_inst = state.get_instance(target_id)
if not target_inst:
return {"success": False, "error": f"agent {target_id} 不存在"}
# 构造提问消息并插入到目标子对话;同时要求其在下一轮调用 answer_other_agent
from modules.multi_agent.state import build_sub_agent_ask_other_text
target_display = target_inst.display_name
msg = build_sub_agent_ask_other_text(self.display_name, target_display, question, question_id)
self.manager.inject_message_to_sub_agent(target_id, msg)
# 阻塞等待回答
answer = await state.wait_for_answer(question_id, self.agent_id, timeout=float(args.get("timeout_seconds") or 600))
return {"success": True, "answer": answer, "question_id": question_id}
if name == "answer_other_agent":
source_id = int(args.get("source_agent_id") or 0)
question_id = str(args.get("question_id") or "")
answer = str(args.get("answer") or "").strip()
if not question_id or not answer:
return {"success": False, "error": "参数缺失"}
ok = state.provide_answer(question_id, answer)
return {"success": bool(ok), "question_id": question_id}
if name == "list_active_sub_agents":
return {"success": True, "agents": [a.to_dict() for a in state.list_all()]}
except asyncio.TimeoutError:
return {"success": False, "error": "等待回答超时", "question_id": args.get("question_id")}
except Exception as exc:
logger.exception(f"[SubAgent] 多智能体工具异常: {name}")
return {"success": False, "error": f"多智能体工具异常: {exc}"}
return None
def _update_stats(self, name: str) -> None:
if name == "read_file":
self.stats["files_read"] += 1
elif name == "write_file":
self.stats["write_files"] += 1
elif name == "edit_file":
self.stats["edit_files"] += 1
elif name == "search_workspace":
self.stats["searches"] += 1
elif name in ("web_search", "extract_webpage"):
self.stats["web_pages"] += 1
elif name == "run_command":
self.stats["commands"] += 1
def _apply_usage(self, usage: Any) -> None:
try:
if isinstance(usage, dict):
prompt = usage.get("prompt_tokens") or usage.get("prompt") or 0
completion = usage.get("completion_tokens") or usage.get("completion") or 0
total = usage.get("total_tokens") or usage.get("total") or (prompt + completion)
self.stats["token_usage"]["prompt"] += int(prompt)
self.stats["token_usage"]["completion"] += int(completion)
self.stats["token_usage"]["total"] += int(total)
except Exception:
pass
def _build_media_tool_content(self, result: Dict[str, Any]) -> Any:
"""把 read_mediafile 结果转成 OpenAI 多模态 content。"""
b64 = result.get("b64")
mime = result.get("mime")
file_type = result.get("type")
if not b64 or not mime:
return None
if file_type == "image":
return [
{"type": "text", "text": f"已附加图片: {result.get('path')}"},
{"type": "image_url", "image_url": {"url": f"data:{mime};base64,{b64}"}},
]
if file_type == "video":
return [
{"type": "text", "text": f"已附加视频: {result.get('path')}"},
{"type": "video_url", "video_url": {"url": f"data:{mime};base64,{b64}"}},
]
return None
async def _write_finish(self, args: Dict[str, Any], elapsed: float) -> None:
success = bool(args.get("success", False))
summary = str(args.get("summary") or "").strip()
self._finalize_task(success, summary, elapsed)
async def _write_timeout(self, elapsed: float) -> None:
self._finalize_task(False, "任务超时未完成", elapsed, timeout=True)
async def _write_failure(self, message: str, *, max_turns_exceeded: bool = False, timeout: bool = False) -> None:
elapsed = time.time() - (self.stats["runtime_start"] / 1000)
self._finalize_task(False, message, elapsed, max_turns_exceeded=max_turns_exceeded, timeout=timeout)
def _finalize_task(self, success: bool, summary: str, elapsed: float, *, max_turns_exceeded: bool = False, timeout: bool = False) -> None:
runtime_seconds = int(elapsed)
output_data = {
"success": success,
"summary": summary,
"timeout": timeout,
"max_turns_exceeded": max_turns_exceeded,
"stats": {**self.stats, "runtime_seconds": runtime_seconds, "turn_count": self.stats.get("turn_count", 0)},
}
conversation_data = {
"agent_id": self.agent_id,
"created_at": datetime.fromtimestamp(self.stats["runtime_start"] / 1000).isoformat(),
"completed_at": datetime.now().isoformat(),
"success": success,
"summary": summary,
"messages": self.messages,
"stats": output_data["stats"],
}
stats_data = {**self.stats, "runtime_seconds": runtime_seconds, "turn_count": self.stats.get("turn_count", 0)}
self.output_file.parent.mkdir(parents=True, exist_ok=True)
self.output_file.write_text(json.dumps(output_data, ensure_ascii=False), encoding="utf-8")
self.stats_file.write_text(json.dumps(stats_data, ensure_ascii=False), encoding="utf-8")
self.conversation_file.write_text(json.dumps(conversation_data, ensure_ascii=False), encoding="utf-8")
self.emit("output", output_data)
self.emit("conversation", conversation_data)
self.manager._mark_task_done(self.task_id, success, summary, runtime_seconds)
def cancel(self) -> None:
self._cancelled = True
if self._task and not self._task.done():
self._task.cancel()