from __future__ import annotations import asyncio import base64 import json import mimetypes import re import threading import time import uuid from datetime import datetime from pathlib import Path from typing import Any, Dict, List, Optional, Set, TYPE_CHECKING _QUESTION_ID_RE = re.compile(r"^id:\s*(\S+)", re.MULTILINE) 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 from modules.multi_agent.debug_logger import ma_debug 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"] raw_timeout = task_record.get("timeout_seconds") self.timeout_seconds = int(raw_timeout) if raw_timeout is not None else None 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_{self.agent_id}' self.display_name = display_name or f"Agent_{self.agent_id}" # 多智能体运行期控制 # 使用 asyncio.Event 在子智能体自己的事件循环内等待; # inject_message 可能跨线程调用,通过 loop.call_soon_threadsafe 唤醒。 self._continue_event: Optional[asyncio.Event] = None self._idle = False self._pending_answer_question_id: Optional[str] = None self._answered_question_ids: Set[str] = set() 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 循环。""" # 在子智能体自己的事件循环内初始化 asyncio.Event self._continue_event = asyncio.Event() try: await self._run_loop() except asyncio.CancelledError: self._cancelled = True logger.debug(f"[SubAgent] task={self.task_id} 被取消") ma_debug("sub_agent_run_cancelled", task_id=self.task_id, agent_id=self.agent_id, display_name=self.display_name) # shield 避免取消信号中断最终状态落盘 await asyncio.shield(self._write_failure("子智能体被手动终止")) raise except Exception as exc: logger.exception(f"[SubAgent] task={self.task_id} 执行异常") ma_debug("sub_agent_run_exception", task_id=self.task_id, agent_id=self.agent_id, display_name=self.display_name, error=str(exc)) 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,自然输出结束即进入 idle,可继续接收消息 else: tools = list(SUB_AGENT_TOOLS) tools.append(FINISH_TOOL) start_time = time.time() max_turns = 50 turn = 0 while not self._cancelled: elapsed = time.time() - start_time if self.timeout_seconds is not None and elapsed > self.timeout_seconds: await self._write_timeout(elapsed) return # 多智能体模式下,idle 时等待新消息或外部回答;只有真正被注入消息时才继续运行 if self.multi_agent_mode and self._idle: event_set = False try: if self._continue_event is None: self._continue_event = asyncio.Event() await asyncio.wait_for(self._continue_event.wait(), timeout=1.0) event_set = True except asyncio.TimeoutError: pass if self._cancelled: break if not event_set: # 只是周期性检查取消状态,没有新消息,保持 idle 继续等待 continue self._continue_event.clear() ma_debug( "sub_agent_idle_wake", task_id=self.task_id, agent_id=self.agent_id, display_name=self.display_name, pending_messages=[m.get("role") for m in self.messages[-3:]], ) self._idle = False continue turn += 1 if turn > max_turns: await self._write_failure("任务执行超过最大轮次限制", max_turns_exceeded=True) 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}) # 多智能体模式:在模型调用前识别是否有待回答的提问 if self.multi_agent_mode: self._pending_answer_question_id = self._peek_pending_question_id() # 调试:记录进入本轮模型调用前的上下文摘要 ma_debug( "sub_agent_model_call_start", task_id=self.task_id, agent_id=self.agent_id, display_name=self.display_name, turn=turn, message_count=len(self.messages), last_user_message=self.messages[-1].get("content", "")[:300] if self.messages and self.messages[-1].get("role") == "user" else "", pending_answer_question_id=self._pending_answer_question_id, ) 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, is_final=not tool_calls) 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) self._persist_conversation(partial_summary=assistant_message[:200]) if not tool_calls: # 多智能体模式:没有 tool_calls 表示本轮结束,进入 idle 等待 if self.multi_agent_mode: self._mark_idle() self._idle = True self._persist_conversation(partial_summary=assistant_message[:200]) continue # 普通模式: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, }) self._persist_conversation(partial_summary=assistant_message[:200]) # 循环结束(取消或 idle 被外部终止)后的清理 if self.multi_agent_mode and self._cancelled: if self.multi_agent_state: self.multi_agent_state.mark_status(self.agent_id, "terminated") def _forward_output_to_master(self, output_text: str, *, is_final: bool = False) -> None: """把子智能体的 assistant 文本输出转发成主对话的 user 消息,并写入进度文件供前端查看。""" if not self.multi_agent_state: return # 如果这是对 pending 提问的回答,不走主对话转发,而是返回到 ask 工具结果 if self._provide_answer(output_text): 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(), is_final=is_final) 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] # 写入进度文件,前端子智能体进度弹窗可直接展示 self.emit("progress", { "subtype": "output", "content": output_text, "is_final": is_final, "ts": int(time.time() * 1000), }) ma_debug( "sub_agent_output_forwarded", task_id=self.task_id, agent_id=self.agent_id, display_name=self.display_name, is_final=is_final, content_preview=output_text[:300], ) except Exception as exc: logger.warning(f"[SubAgentTask] forward output to master failed: {exc}") def _mark_idle(self) -> None: """多智能体模式下,子智能体自然结束即本轮任务结束,进入 idle 状态。""" ma_debug( "sub_agent_mark_idle", task_id=self.task_id, agent_id=self.agent_id, display_name=self.display_name, ) if self.multi_agent_state: self.multi_agent_state.mark_status(self.agent_id, "idle") def inject_message(self, message_text: str) -> None: """外部向子智能体上下文插入 user 消息,并唤醒 idle 状态。""" self.messages.append({"role": "user", "content": message_text}) ma_debug( "sub_agent_message_injected", task_id=self.task_id, agent_id=self.agent_id, display_name=self.display_name, message_preview=str(message_text)[:500], was_idle=self._idle, ) # inject_message 可能从其他线程(主对话线程)调用,需要线程安全唤醒。 # 优先使用子智能体 Task 所属事件循环投递 set(),避免跨线程直接操作 Future。 if self._continue_event is None: self._continue_event = asyncio.Event() if self._task is not None: try: task_loop = self._task.get_loop() if task_loop.is_running(): task_loop.call_soon_threadsafe(self._continue_event.set) return except Exception: pass self._continue_event.set() def _peek_pending_question_id(self) -> Optional[str]: """检查最后一条 user 消息是否是向本智能体提问,返回 question_id。""" if not self.multi_agent_mode or not self.messages: return None for msg in reversed(self.messages): if msg.get("role") == "user": content = msg.get("content") or "" if "的提问" in content: m = _QUESTION_ID_RE.search(content) if m: qid = m.group(1) if qid not in self._answered_question_ids: return qid break return None def _provide_answer(self, output_text: str) -> bool: """如果当前输出是对 pending 提问的回答,把回答写回 future 并阻止转发到主对话。""" if not self._pending_answer_question_id or not self.multi_agent_state: return False self.multi_agent_state.provide_answer( self._pending_answer_question_id, output_text.strip(), ) self._answered_question_ids.add(self._pending_answer_question_id) self._pending_answer_question_id = None return True 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]}") ma_debug( "sub_agent_tool_ask_master", task_id=self.task_id, agent_id=self.agent_id, display_name=self.display_name, question_id=question_id, question=question[:500], ) 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]}") ma_debug( "sub_agent_tool_ask_other_agent", task_id=self.task_id, agent_id=self.agent_id, display_name=self.display_name, target_agent_id=target_id, question_id=question_id, question=question[:500], ) 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) ma_debug( "sub_agent_write_failure", task_id=self.task_id, agent_id=self.agent_id, display_name=self.display_name, message=message, max_turns_exceeded=max_turns_exceeded, timeout=timeout, idle=self._idle, cancelled=self._cancelled, ) self._finalize_task(False, message, elapsed, max_turns_exceeded=max_turns_exceeded, timeout=timeout) def _persist_conversation(self, *, partial_summary: str = "") -> None: """每轮结束后立即落盘子智能体对话,避免跑完了才存一次导致中间状态丢失。""" try: runtime_seconds = int((time.time() * 1000 - self.stats["runtime_start"]) / 1000) status = "running" if self._cancelled: status = "terminated" elif self.multi_agent_mode and self._idle: status = "idle" ma_debug( "sub_agent_persist_conversation", task_id=self.task_id, agent_id=self.agent_id, display_name=self.display_name, status=status, idle=self._idle, cancelled=self._cancelled, multi_agent_mode=self.multi_agent_mode, ) conversation_data = { "agent_id": self.agent_id, "task_id": self.task_id, "created_at": datetime.fromtimestamp(self.stats["runtime_start"] / 1000).isoformat(), "updated_at": datetime.now().isoformat(), "status": status, "success": None, "summary": partial_summary, "messages": self.messages, "stats": {**self.stats, "runtime_seconds": runtime_seconds, "turn_count": self.stats.get("turn_count", 0)}, } self.conversation_file.parent.mkdir(parents=True, exist_ok=True) self.conversation_file.write_text(json.dumps(conversation_data, ensure_ascii=False), encoding="utf-8") output_data = { "success": None, "status": status, "summary": partial_summary, "stats": conversation_data["stats"], } 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") except Exception as exc: logger.warning(f"[SubAgentTask] 增量保存失败: {exc}") def _finalize_task(self, success: bool, summary: str, elapsed: float, *, max_turns_exceeded: bool = False, timeout: bool = False) -> None: runtime_seconds = int(elapsed) ma_debug( "sub_agent_finalize_task", task_id=self.task_id, agent_id=self.agent_id, display_name=self.display_name, success=success, summary=summary, idle=self._idle, cancelled=self._cancelled, ) 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()