fix(multi-agent): 修复子智能体状态误判、后台任务隔离与 idle 等待异常

This commit is contained in:
JOJO 2026-07-12 18:39:21 +08:00
parent f652118527
commit 7f2ad9144d
16 changed files with 659 additions and 102 deletions

View File

@ -16,11 +16,13 @@ def _load_dotenv():
import sys import sys
pre_existing_keys = set(os.environ.keys()) pre_existing_keys = set(os.environ.keys())
# 1) 仓库根 .env开发便利不覆盖已有环境变量 # 1) 仓库根 .env开发便利不覆盖已有的环境变量但 ASTRION_DATA_ROOT
# 作为项目数据根目录必须优先以 .env 为准,避免外部 shell 误指到 clone
if getattr(sys, 'frozen', False): if getattr(sys, 'frozen', False):
env_path = Path.home() / '.astrion' / 'astrion' / '.env' env_path = Path.home() / '.astrion' / 'astrion' / '.env'
else: else:
env_path = Path(__file__).resolve().parents[1] / '.env' env_path = Path(__file__).resolve().parents[1] / '.env'
env_from_file: dict = {}
if env_path.exists(): if env_path.exists():
try: try:
for raw_line in env_path.read_text(encoding="utf-8").splitlines(): for raw_line in env_path.read_text(encoding="utf-8").splitlines():
@ -34,11 +36,14 @@ def _load_dotenv():
value = value.strip().strip('"').strip("'") value = value.strip().strip('"').strip("'")
if not key: if not key:
continue continue
env_from_file[key] = value
if key in pre_existing_keys: if key in pre_existing_keys:
continue continue
os.environ[key] = value os.environ[key] = value
except Exception: except Exception:
pass pass
if "ASTRION_DATA_ROOT" in env_from_file:
os.environ["ASTRION_DATA_ROOT"] = str(Path(env_from_file["ASTRION_DATA_ROOT"]).expanduser())
# 2) settings.json数据根下的统一配置 # 2) settings.json数据根下的统一配置
data_root = os.environ.get("ASTRION_DATA_ROOT", str(Path.home() / ".astrion" / "astrion")) data_root = os.environ.get("ASTRION_DATA_ROOT", str(Path.home() / ".astrion" / "astrion"))

View File

@ -101,6 +101,7 @@ from utils.api_client import DeepSeekClient
from utils.context_manager import ContextManager from utils.context_manager import ContextManager
from utils.tool_result_formatter import format_tool_result_for_context from utils.tool_result_formatter import format_tool_result_for_context
from utils.logger import setup_logger from utils.logger import setup_logger
from modules.multi_agent.debug_logger import ma_debug
from config.model_profiles import ( from config.model_profiles import (
get_model_profile, get_model_profile,
get_model_prompt_replacements, get_model_prompt_replacements,
@ -1128,6 +1129,27 @@ class MainTerminalToolsExecutionMixin:
task_ids.append(task.get("task_id")) task_ids.append(task.get("task_id"))
if missing: if missing:
result = {"success": False, "error": f"未找到对应子智能体: {missing}"} result = {"success": False, "error": f"未找到对应子智能体: {missing}"}
elif getattr(self, "multi_agent_mode", False):
# 多智能体模式:子智能体通过自然输出结束,不调用 finish_task。
# sleep 不再阻塞等待,而是直接返回各实例最近一次输出内容。
conv_id = self.context_manager.current_conversation_id
ma_state = manager.get_multi_agent_state(conv_id)
outputs = []
for aid in normalized_ids:
inst = ma_state.get_instance(aid) if ma_state else None
outputs.append({
"agent_id": aid,
"display_name": inst.display_name if inst else f"Agent_{aid}",
"status": inst.status if inst else "unknown",
"last_output": inst.last_output if inst else "",
})
result = {
"success": True,
"mode": "wait_sub_agent_ids",
"agent_ids": normalized_ids,
"outputs": outputs,
"message": f"已获取 {len(normalized_ids)} 个子智能体最近一次输出"
}
else: else:
wait_results = [] wait_results = []
waited_task_ids = [] waited_task_ids = []
@ -1763,7 +1785,7 @@ class MainTerminalToolsExecutionMixin:
agent_id=int(agent_id), agent_id=int(agent_id),
summary=summary_text, summary=summary_text,
task=arguments.get("task", ""), task=arguments.get("task", ""),
run_in_background=bool(arguments.get("run_in_background", True)), run_in_background=False,
timeout_seconds=arguments.get("timeout_seconds"), timeout_seconds=arguments.get("timeout_seconds"),
conversation_id=conv_id, conversation_id=conv_id,
model_key=role.model_key, model_key=role.model_key,
@ -1774,8 +1796,8 @@ class MainTerminalToolsExecutionMixin:
system_prompt=system_prompt, system_prompt=system_prompt,
task_message=task_message, task_message=task_message,
) )
# 在多智能体模式下,主进程 create_sub_agent 总是后台启动, # 在多智能体模式下,子智能体是团队协作成员,不是传统后台任务。
# 主智能体不需要阻塞等待,而是通过子智能体输出转发拿进度 # run_in_background=False 避免触发后台完成通知轮询,保持主对话输入区可用
except Exception as exc: except Exception as exc:
logger.exception("[multi_agent] create_sub_agent failed") logger.exception("[multi_agent] create_sub_agent failed")
result = {"success": False, "error": str(exc)} result = {"success": False, "error": str(exc)}
@ -1865,12 +1887,27 @@ class MainTerminalToolsExecutionMixin:
else: else:
# 构造消息文本并插入子对话 # 构造消息文本并插入子对话
text = build_master_message_to_sub_agent(message) text = build_master_message_to_sub_agent(message)
ma_debug(
"tool_send_message_to_sub_agent",
agent_id=agent_id,
raw_message=str(message)[:500],
wrapped_message_preview=text[:500],
conversation_id=conv_id,
)
ok = self.sub_agent_manager.inject_message_to_sub_agent(agent_id, text) ok = self.sub_agent_manager.inject_message_to_sub_agent(agent_id, text)
if not ok: if not ok:
result = {"success": False, "error": f"子智能体 {agent_id} 不存在或已结束"} result = {"success": False, "error": f"子智能体 {agent_id} 不存在或已结束"}
else: else:
result = {"success": True, "agent_id": agent_id} result = {"success": True, "agent_id": agent_id}
ma_debug(
"tool_send_message_to_sub_agent_result",
agent_id=agent_id,
conversation_id=conv_id,
ok=ok,
result=result,
)
except Exception as exc: except Exception as exc:
logger.exception("[multi_agent] send_message_to_sub_agent failed")
result = {"success": False, "error": str(exc)} result = {"success": False, "error": str(exc)}
elif tool_name == "ask_sub_agent": elif tool_name == "ask_sub_agent":
@ -1896,6 +1933,14 @@ class MainTerminalToolsExecutionMixin:
msg_id=question_id, msg_id=question_id,
target=state.get_instance(agent_id).display_name if state.get_instance(agent_id) else f"Agent_{agent_id}", target=state.get_instance(agent_id).display_name if state.get_instance(agent_id) else f"Agent_{agent_id}",
) )
ma_debug(
"tool_ask_sub_agent",
agent_id=agent_id,
question=str(question)[:500],
question_id=question_id,
wrapped_message_preview=text[:500],
conversation_id=conv_id,
)
ok = self.sub_agent_manager.inject_message_to_sub_agent(agent_id, text) ok = self.sub_agent_manager.inject_message_to_sub_agent(agent_id, text)
if not ok: if not ok:
result = {"success": False, "error": f"子智能体 {agent_id} 不存在"} result = {"success": False, "error": f"子智能体 {agent_id} 不存在"}
@ -1921,6 +1966,12 @@ class MainTerminalToolsExecutionMixin:
answer = arguments.get("answer", "") answer = arguments.get("answer", "")
conv_id = self.context_manager.current_conversation_id conv_id = self.context_manager.current_conversation_id
state = self.sub_agent_manager.get_multi_agent_state(conv_id) state = self.sub_agent_manager.get_multi_agent_state(conv_id)
ma_debug(
"tool_answer_sub_agent_question",
question_id=question_id,
answer_preview=str(answer)[:500],
conversation_id=conv_id,
)
if not state: if not state:
result = {"success": False, "error": "多智能体状态未就绪"} result = {"success": False, "error": "多智能体状态未就绪"}
else: else:

View File

@ -3,7 +3,7 @@
import json import json
import time import time
from datetime import datetime from datetime import datetime
from typing import Dict, List, Optional, Callable, TYPE_CHECKING from typing import Any, Dict, List, Optional, Callable, TYPE_CHECKING
import os import os
from core.main_terminal import MainTerminal from core.main_terminal import MainTerminal
from utils.logger import setup_logger from utils.logger import setup_logger
@ -105,13 +105,19 @@ class WebTerminal(MainTerminal):
# 新增对话管理相关方法Web版本 # 新增对话管理相关方法Web版本
# =========================================== # ===========================================
def create_new_conversation(self, thinking_mode: bool = None, run_mode: Optional[str] = None) -> Dict: def create_new_conversation(
self,
thinking_mode: bool = None,
run_mode: Optional[str] = None,
metadata_overrides: Optional[Dict[str, Any]] = None,
) -> Dict:
""" """
创建新对话Web版本 创建新对话Web版本
Args: Args:
thinking_mode: 思考模式None则使用当前设置 thinking_mode: 思考模式None则使用当前设置
run_mode: 显式的运行模式fast/thinking/deep run_mode: 显式的运行模式fast/thinking/deep
metadata_overrides: 额外写入对话 metadata 的字段
Returns: Returns:
Dict: 包含新对话信息 Dict: 包含新对话信息
@ -171,17 +177,21 @@ class WebTerminal(MainTerminal):
try: try:
# 先创建新对话。start_new_conversation 会先 save_current_conversation() # 先创建新对话。start_new_conversation 会先 save_current_conversation()
# 此时 terminal.model_key 仍是旧对话的模型,避免把旧对话覆盖成默认模型。 # 此时 terminal.model_key 仍是旧对话的模型,避免把旧对话覆盖成默认模型。
conversation_id = self.context_manager.start_new_conversation( metadata_overrides_merged = {
project_path=self.project_path,
thinking_mode=thinking_mode,
run_mode=self.run_mode,
metadata_overrides={
"permission_mode": self.get_permission_mode(), "permission_mode": self.get_permission_mode(),
"execution_mode": self.get_execution_mode() if hasattr(self, "get_execution_mode") else "sandbox", "execution_mode": self.get_execution_mode() if hasattr(self, "get_execution_mode") else "sandbox",
"pending_permission_mode": None, "pending_permission_mode": None,
"pending_execution_mode": None, "pending_execution_mode": None,
# frozen_*_prompt 不在创建时预设,由第一次 build_messages 根据当时的实际模式懒加载并冻结 # frozen_*_prompt 不在创建时预设,由第一次 build_messages 根据当时的实际模式懒加载并冻结
}, }
if isinstance(metadata_overrides, dict):
metadata_overrides_merged.update(metadata_overrides)
conversation_id = self.context_manager.start_new_conversation(
project_path=self.project_path,
thinking_mode=thinking_mode,
run_mode=self.run_mode,
metadata_overrides=metadata_overrides_merged,
) )
# 新对话创建完成后再应用默认模型(此时旧对话已安全保存)。 # 新对话创建完成后再应用默认模型(此时旧对话已安全保存)。
@ -377,6 +387,7 @@ class WebTerminal(MainTerminal):
"run_mode": self.run_mode, "run_mode": self.run_mode,
"thinking_mode": self.thinking_mode, "thinking_mode": self.thinking_mode,
"model_key": getattr(self, "model_key", None), "model_key": getattr(self, "model_key", None),
"multi_agent_mode": bool(getattr(self, "multi_agent_mode", False)),
"message": f"对话已加载: {conversation_id}" "message": f"对话已加载: {conversation_id}"
} }
else: else:

View File

@ -0,0 +1,30 @@
"""多智能体模式专用调试日志。
把所有子智能体与主智能体之间的消息往来注入转发工具调用记录下来
便于排查循环/重复输出等问题日志写入 {LOGS_DIR}/multi_agent_loop.log
"""
from __future__ import annotations
import json
import time
from pathlib import Path
from typing import Any
from config.paths import LOGS_DIR
_LOG_PATH = Path(LOGS_DIR) / "multi_agent_loop.log"
def ma_debug(event: str, **kwargs: Any) -> None:
"""追加一条结构化调试日志。"""
try:
_LOG_PATH.parent.mkdir(parents=True, exist_ok=True)
payload = {"t": time.time(), "event": event}
for k, v in kwargs.items():
payload[k] = v
line = json.dumps(payload, ensure_ascii=False, default=str)
with open(_LOG_PATH, "a", encoding="utf-8") as f:
f.write(line + "\n")
except Exception:
pass

View File

@ -25,6 +25,7 @@ from dataclasses import dataclass, field
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Any, Dict, List, Optional, TYPE_CHECKING from typing import Any, Dict, List, Optional, TYPE_CHECKING
from modules.multi_agent.debug_logger import ma_debug
if TYPE_CHECKING: if TYPE_CHECKING:
from modules.sub_agent.task import SubAgentTask from modules.sub_agent.task import SubAgentTask
@ -298,7 +299,17 @@ class MultiAgentState:
if question_id in self.pending_answers: if question_id in self.pending_answers:
return self.pending_answers.pop(question_id) return self.pending_answers.pop(question_id)
if question_id in self.pending_questions: if question_id in self.pending_questions:
old_fut = self.pending_questions[question_id]
try:
old_loop = old_fut.get_loop()
if old_loop.is_closed():
self.pending_questions.pop(question_id, None)
self.pending_question_loops.pop(question_id, None)
else:
raise RuntimeError(f"question_id 已存在: {question_id}") raise RuntimeError(f"question_id 已存在: {question_id}")
except Exception:
self.pending_questions.pop(question_id, None)
self.pending_question_loops.pop(question_id, None)
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
fut: asyncio.Future = loop.create_future() fut: asyncio.Future = loop.create_future()
self.pending_questions[question_id] = fut self.pending_questions[question_id] = fut
@ -329,6 +340,12 @@ class MultiAgentState:
返回 True 表示找到等待中的 futureFalse 表示无等待方或已超时 返回 True 表示找到等待中的 futureFalse 表示无等待方或已超时
支持跨事件循环调用例如主对话循环回答子智能体循环里的提问 支持跨事件循环调用例如主对话循环回答子智能体循环里的提问
""" """
ma_debug(
"state_provide_answer",
question_id=question_id,
has_pending=question_id in self.pending_questions,
answer_preview=str(answer)[:300],
)
# 如果 wait_for_answer 还没注册,先把答案暂存 # 如果 wait_for_answer 还没注册,先把答案暂存
if question_id not in self.pending_questions: if question_id not in self.pending_questions:
self.pending_answers[question_id] = answer self.pending_answers[question_id] = answer
@ -349,8 +366,14 @@ class MultiAgentState:
return True return True
except Exception: except Exception:
pass pass
# 同循环回退 # 同循环回退future 所属循环可能已关闭,失败时把答案暂存,避免阻塞方永远等不到)
try:
return asyncio.run_coroutine_threadsafe(self._do_provide_answer(question_id, answer), asyncio.get_event_loop()).result(timeout=5) return asyncio.run_coroutine_threadsafe(self._do_provide_answer(question_id, answer), asyncio.get_event_loop()).result(timeout=5)
except Exception:
self.pending_questions.pop(question_id, None)
self.pending_question_loops.pop(question_id, None)
self.pending_answers[question_id] = answer
return True
def is_agent_blocking(self, agent_id: int) -> bool: def is_agent_blocking(self, agent_id: int) -> bool:
return agent_id in self.agent_blocking_question return agent_id in self.agent_blocking_question

View File

@ -53,7 +53,10 @@ def _master_tool_create_sub_agent() -> Dict[str, Any]:
"type": "integer", "type": "integer",
"description": "(可选)手动指定实例编号;不传时自动递增。", "description": "(可选)手动指定实例编号;不传时自动递增。",
}, },
"timeout_seconds": {"type": "integer", "description": "超时秒数,默认 600。"}, "timeout_seconds": {
"type": "integer",
"description": "(可选)超时秒数。不填表示该子智能体不会被时间终结,适用于可能多轮长期任务的子智能体;一般情况下推荐创建永久子智能体。",
},
"thinking_mode": { "thinking_mode": {
"type": "string", "type": "string",
"enum": ["fast", "thinking"], "enum": ["fast", "thinking"],

View File

@ -30,6 +30,7 @@ from modules.sub_agent.tools import handle_search_workspace, handle_read_mediafi
from modules.sub_agent.state import SubAgentStateMixin from modules.sub_agent.state import SubAgentStateMixin
from modules.sub_agent.stats import SubAgentStatsMixin from modules.sub_agent.stats import SubAgentStatsMixin
from modules.sub_agent.creation import SubAgentCreationMixin from modules.sub_agent.creation import SubAgentCreationMixin
from modules.multi_agent.debug_logger import ma_debug
if TYPE_CHECKING: if TYPE_CHECKING:
from core.web_terminal import WebTerminal from core.web_terminal import WebTerminal
@ -199,7 +200,8 @@ class SubAgentManager(SubAgentStateMixin, SubAgentStatsMixin, SubAgentCreationMi
if task_message: if task_message:
user_message = task_message user_message = task_message
else: else:
user_message = build_user_message(agent_id, summary, task, deliverables_display, timeout_seconds or SUB_AGENT_DEFAULT_TIMEOUT) display_timeout = timeout_seconds if timeout_seconds is not None else 0
user_message = build_user_message(agent_id, summary, task, deliverables_display, display_timeout or SUB_AGENT_DEFAULT_TIMEOUT)
task_file.write_text(user_message, encoding="utf-8") task_file.write_text(user_message, encoding="utf-8")
if system_prompt: if system_prompt:
@ -208,8 +210,7 @@ class SubAgentManager(SubAgentStateMixin, SubAgentStatsMixin, SubAgentCreationMi
final_system_prompt = build_system_prompt(prompt_workspace) final_system_prompt = build_system_prompt(prompt_workspace)
system_prompt_file.write_text(final_system_prompt, encoding="utf-8") system_prompt_file.write_text(final_system_prompt, encoding="utf-8")
timeout_seconds = timeout_seconds or SUB_AGENT_DEFAULT_TIMEOUT # timeout_seconds 为 None 表示永久子智能体(不会被时间终结)
task_record = { task_record = {
"task_id": task_id, "task_id": task_id,
"agent_id": agent_id, "agent_id": agent_id,
@ -223,6 +224,7 @@ class SubAgentManager(SubAgentStateMixin, SubAgentStatsMixin, SubAgentCreationMi
"updated_at": time.time(), "updated_at": time.time(),
"conversation_id": conversation_id, "conversation_id": conversation_id,
"run_in_background": run_in_background, "run_in_background": run_in_background,
"multi_agent_mode": bool(multi_agent_mode),
"task_root": str(task_root), "task_root": str(task_root),
"output_file": str(output_file), "output_file": str(output_file),
"stats_file": str(stats_file), "stats_file": str(stats_file),
@ -286,6 +288,15 @@ class SubAgentManager(SubAgentStateMixin, SubAgentStatsMixin, SubAgentCreationMi
if multi_agent_mode and display_name: if multi_agent_mode and display_name:
message = f"{display_name} 已创建任务ID: {task_id}" message = f"{display_name} 已创建任务ID: {task_id}"
print(f"{OUTPUT_FORMATS['info']} {message}") print(f"{OUTPUT_FORMATS['info']} {message}")
ma_debug(
"manager_create_sub_agent",
task_id=task_id,
agent_id=agent_id,
display_name=display_name,
multi_agent_mode=multi_agent_mode,
run_in_background=task_record.get("run_in_background"),
timeout_seconds=timeout_seconds,
)
return { return {
"success": True, "success": True,
@ -577,6 +588,26 @@ class SubAgentManager(SubAgentStateMixin, SubAgentStatsMixin, SubAgentCreationMi
"""删除会话状态(会话结束时调用)。""" """删除会话状态(会话结束时调用)。"""
self.multi_agent_states.pop(conversation_id, None) self.multi_agent_states.pop(conversation_id, None)
def reconcile_task_states(self, conversation_id: Optional[str] = None) -> int:
"""修正运行态任务状态。
在父类实现前先根据内存中的 MultiAgentState 给旧任务补上 multi_agent_mode
标记避免任务记录缺字段导致被当成普通子智能体误判为 failed
"""
if conversation_id and conversation_id in self.multi_agent_states:
state = self.multi_agent_states[conversation_id]
agent_ids = {a.agent_id for a in state.list_all()}
for task in self.tasks.values():
if (
isinstance(task, dict)
and task.get("conversation_id") == conversation_id
and task.get("agent_id") in agent_ids
and task.get("multi_agent_mode") is None
):
task["multi_agent_mode"] = True
task["updated_at"] = time.time()
return super().reconcile_task_states(conversation_id=conversation_id)
def inject_message_to_sub_agent(self, agent_id: int, message_text: str) -> bool: def inject_message_to_sub_agent(self, agent_id: int, message_text: str) -> bool:
"""同事件循环中向子智能体上下文插入 user 消息。 """同事件循环中向子智能体上下文插入 user 消息。
@ -585,6 +616,13 @@ class SubAgentManager(SubAgentStateMixin, SubAgentStatsMixin, SubAgentCreationMi
""" """
# 查找该 agent_id 对应的 running SubAgentTask # 查找该 agent_id 对应的 running SubAgentTask
sub_agent = self._find_sub_agent_task_by_agent_id(agent_id) sub_agent = self._find_sub_agent_task_by_agent_id(agent_id)
ma_debug(
"manager_inject_message_to_sub_agent",
agent_id=agent_id,
message_preview=str(message_text)[:500],
found=bool(sub_agent),
task_id=sub_agent.task_id if sub_agent else None,
)
if not sub_agent: if not sub_agent:
return False return False
sub_agent.inject_message(message_text) sub_agent.inject_message(message_text)
@ -606,18 +644,40 @@ class SubAgentManager(SubAgentStateMixin, SubAgentStatsMixin, SubAgentCreationMi
def _on_multi_agent_task_done(self, task_id: str, agent_id: int, state: Any, sub_agent: Any) -> None: def _on_multi_agent_task_done(self, task_id: str, agent_id: int, state: Any, sub_agent: Any) -> None:
"""SubAgentTask 结束回调会调这个更新 MultiAgentState 实例状态。""" """SubAgentTask 结束回调会调这个更新 MultiAgentState 实例状态。"""
# 取出当前 status由 _finalize_task 设置)
final_task = self.tasks.get(task_id) or {} final_task = self.tasks.get(task_id) or {}
final_status = final_task.get("status") or "terminated" final_status_before = final_task.get("status")
# 允许的自然退出running -> idle未走 _finalize vs failed/timeout ma_debug(
# SubAgentTask 在多智能体模式下没有 _finalize_task我们手动赋值 idle "manager_on_multi_agent_task_done",
# 如果状态被结 _finalize 为 failed/timeout则保持该状态 task_id=task_id,
agent_id=agent_id,
sub_agent_idle=getattr(sub_agent, "_idle", False),
sub_agent_cancelled=getattr(sub_agent, "_cancelled", False),
task_status_before=final_status_before,
)
# 多智能体模式下,子智能体自然进入 idle 后 Task 可能被外部事件循环取消,
# 或者 reconcile 把 idle 误判为 failed。优先以 SubAgentTask 自身状态为准:
# - 被手动取消 -> terminated
# - 自然进入 idle -> idle可继续接收消息
# - 真正异常/超时/finish_task 失败 -> failed/timeout
if getattr(sub_agent, "_cancelled", False):
state.mark_status(agent_id, "terminated")
ma_debug("manager_ma_state_set", agent_id=agent_id, status="terminated", reason="sub_agent_cancelled")
return
if getattr(sub_agent, "_idle", False):
state.mark_status(agent_id, "idle")
ma_debug("manager_ma_state_set", agent_id=agent_id, status="idle", reason="sub_agent_idle")
return
# 兜底:取出当前 task status由 _finalize_task 设置)
if final_status in TERMINAL_STATUSES: if final_status in TERMINAL_STATUSES:
state.mark_status(agent_id, final_status, last_output=str(final_task.get("final_result") or "")) state.mark_status(agent_id, final_status, last_output=str(final_task.get("final_result") or ""))
ma_debug("manager_ma_state_set", agent_id=agent_id, status=final_status, reason="task_terminal_status")
elif final_status == "terminated": elif final_status == "terminated":
state.mark_status(agent_id, "terminated") state.mark_status(agent_id, "terminated")
ma_debug("manager_ma_state_set", agent_id=agent_id, status="terminated", reason="task_terminated_status")
else: else:
state.mark_status(agent_id, "idle") state.mark_status(agent_id, "idle")
ma_debug("manager_ma_state_set", agent_id=agent_id, status="idle", reason="fallback_idle")
def _get_runtime_path(self, host_path: Path) -> str: def _get_runtime_path(self, host_path: Path) -> str:
"""将宿主机路径映射为容器内路径(仅用于提示展示)。""" """将宿主机路径映射为容器内路径(仅用于提示展示)。"""

View File

@ -8,6 +8,7 @@ from pathlib import Path
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
from utils.logger import setup_logger from utils.logger import setup_logger
from modules.multi_agent.debug_logger import ma_debug
logger = setup_logger(__name__) logger = setup_logger(__name__)
TERMINAL_STATUSES = {"completed", "failed", "timeout"} TERMINAL_STATUSES = {"completed", "failed", "timeout"}
@ -94,6 +95,7 @@ class SubAgentStateMixin:
try: try:
output = json.loads(output_file.read_text(encoding="utf-8")) output = json.loads(output_file.read_text(encoding="utf-8"))
except Exception as exc: except Exception as exc:
logger.warning(f"[check_task_status] output 文件解析失败: {exc}")
task["status"] = "failed" task["status"] = "failed"
task["updated_at"] = time.time() task["updated_at"] = time.time()
return {"success": False, "status": "failed", "task_id": task_id, "message": f"输出文件解析失败: {exc}"} return {"success": False, "status": "failed", "task_id": task_id, "message": f"输出文件解析失败: {exc}"}
@ -103,6 +105,24 @@ class SubAgentStateMixin:
stats = output.get("stats", {}) stats = output.get("stats", {})
elapsed_seconds = self._compute_elapsed_seconds(task) elapsed_seconds = self._compute_elapsed_seconds(task)
ma_debug(
"check_task_status",
task_id=task_id,
agent_id=task.get("agent_id"),
multi_agent_mode=task.get("multi_agent_mode"),
output_status=output.get("status"),
output_success=success,
)
# 多智能体模式output 中 status 为 running/idle 表示子智能体仍在运行或
# 本轮结束但上下文保留、可继续接收消息,都不是失败/完成。
# 此时不生成 final_result只保持原状态避免 reconcile 把运行中误判为失败。
if task.get("multi_agent_mode") and output.get("status") in {"running", "idle"}:
task["status"] = output["status"]
task["updated_at"] = time.time()
ma_debug("check_task_status_keep_alive", task_id=task_id, status=output["status"])
return {"status": output["status"], "task_id": task_id}
if output.get("timeout"): if output.get("timeout"):
status = "timeout" status = "timeout"
elif output.get("max_turns_exceeded"): elif output.get("max_turns_exceeded"):
@ -113,6 +133,7 @@ class SubAgentStateMixin:
else: else:
status = "failed" status = "failed"
ma_debug("check_task_status_result", task_id=task_id, agent_id=task.get("agent_id"), status=status)
task["status"] = status task["status"] = status
task["updated_at"] = time.time() task["updated_at"] = time.time()
if status == "completed" and elapsed_seconds is not None: if status == "completed" and elapsed_seconds is not None:
@ -237,6 +258,13 @@ class SubAgentStateMixin:
if not task: if not task:
return return
status = "completed" if success else "failed" status = "completed" if success else "failed"
ma_debug(
"mark_task_done",
task_id=task_id,
agent_id=task.get("agent_id"),
success=success,
new_status=status,
)
task["status"] = status task["status"] = status
task["updated_at"] = time.time() task["updated_at"] = time.time()
task["runtime_seconds"] = runtime_seconds task["runtime_seconds"] = runtime_seconds
@ -246,10 +274,19 @@ class SubAgentStateMixin:
def _refresh_task_runtime_state(self, task: Dict[str, Any]) -> Dict[str, Any]: def _refresh_task_runtime_state(self, task: Dict[str, Any]) -> Dict[str, Any]:
"""刷新单个任务运行态。""" """刷新单个任务运行态。"""
status = task.get("status") status = task.get("status")
if status in TERMINAL_STATUSES.union({"terminated"}):
return {"status": status, "task_id": task.get("task_id")}
task_id = task.get("task_id") task_id = task.get("task_id")
agent_id = task.get("agent_id")
multi_agent_flag = task.get("multi_agent_mode")
ma_debug(
"refresh_task_runtime_state_start",
task_id=task_id,
agent_id=agent_id,
before_status=status,
multi_agent_mode=multi_agent_flag,
)
if status in TERMINAL_STATUSES.union({"terminated"}):
return {"status": status, "task_id": task_id}
running_task = self._running_tasks.get(task_id) if task_id else None running_task = self._running_tasks.get(task_id) if task_id else None
if running_task: if running_task:
if running_task.done(): if running_task.done():
@ -261,18 +298,25 @@ class SubAgentStateMixin:
if task.get("multi_agent_mode") and not Path(task.get("output_file", "")).exists(): if task.get("multi_agent_mode") and not Path(task.get("output_file", "")).exists():
task["status"] = "idle" task["status"] = "idle"
task["updated_at"] = time.time() task["updated_at"] = time.time()
ma_debug("refresh_task_runtime_state_idle_no_output", task_id=task_id)
return {"status": "idle", "task_id": task_id} return {"status": "idle", "task_id": task_id}
return self._check_task_status(task) result = self._check_task_status(task)
ma_debug("refresh_task_runtime_state_done_result", task_id=task_id, result=result)
return result
ma_debug("refresh_task_runtime_state_still_running", task_id=task_id)
return {"status": "running", "task_id": task_id} return {"status": "running", "task_id": task_id}
output_file = Path(task.get("output_file", "")) output_file = Path(task.get("output_file", ""))
if output_file.exists(): if output_file.exists():
return self._check_task_status(task) result = self._check_task_status(task)
ma_debug("refresh_task_runtime_state_output_result", task_id=task_id, result=result)
return result
# 多智能体模式:没有输出文件表示 idle不强制清理 # 多智能体模式:没有输出文件表示 idle不强制清理
if task.get("multi_agent_mode"): if task.get("multi_agent_mode"):
task["status"] = "idle" task["status"] = "idle"
task["updated_at"] = time.time() task["updated_at"] = time.time()
ma_debug("refresh_task_runtime_state_idle_no_output_file", task_id=task_id)
return {"status": "idle", "task_id": task_id} return {"status": "idle", "task_id": task_id}
if self._should_force_cleanup_stale_task(task): if self._should_force_cleanup_stale_task(task):
@ -293,6 +337,7 @@ class SubAgentStateMixin:
def reconcile_task_states(self, conversation_id: Optional[str] = None) -> int: def reconcile_task_states(self, conversation_id: Optional[str] = None) -> int:
"""修正运行态任务状态,返回修正条目数。""" """修正运行态任务状态,返回修正条目数。"""
changed = 0 changed = 0
ma_debug("reconcile_task_states_start", conversation_id=conversation_id, task_count=len(self.tasks))
for task in self.tasks.values(): for task in self.tasks.values():
if not isinstance(task, dict): if not isinstance(task, dict):
continue continue
@ -301,10 +346,19 @@ class SubAgentStateMixin:
before_status = task.get("status") before_status = task.get("status")
before_notified = task.get("notified") before_notified = task.get("notified")
self._refresh_task_runtime_state(task) self._refresh_task_runtime_state(task)
if task.get("status") != before_status or task.get("notified") != before_notified: after_status = task.get("status")
if after_status != before_status or task.get("notified") != before_notified:
ma_debug(
"reconcile_task_states_changed",
task_id=task.get("task_id"),
agent_id=task.get("agent_id"),
before_status=before_status,
after_status=after_status,
)
changed += 1 changed += 1
if changed: if changed:
self._save_state() self._save_state()
ma_debug("reconcile_task_states_end", conversation_id=conversation_id, changed=changed)
return changed return changed
def _should_force_cleanup_stale_task(self, task: Dict[str, Any]) -> bool: def _should_force_cleanup_stale_task(self, task: Dict[str, Any]) -> bool:
@ -314,8 +368,12 @@ class SubAgentStateMixin:
created_at = 0 created_at = 0
if created_at <= 0: if created_at <= 0:
return False return False
# timeout_seconds 为 None 表示永久子智能体,不强制清理
raw_timeout = task.get("timeout_seconds")
if raw_timeout is None:
return False
from config import SUB_AGENT_DEFAULT_TIMEOUT from config import SUB_AGENT_DEFAULT_TIMEOUT
timeout_seconds = int(task.get("timeout_seconds") or SUB_AGENT_DEFAULT_TIMEOUT or 0) timeout_seconds = int(raw_timeout or SUB_AGENT_DEFAULT_TIMEOUT or 0)
timeout_seconds = max(timeout_seconds, 1) timeout_seconds = max(timeout_seconds, 1)
grace_seconds = 120 grace_seconds = 120
elapsed = time.time() - created_at elapsed = time.time() - created_at

View File

@ -27,6 +27,8 @@ if TYPE_CHECKING:
from modules.sub_agent.manager import SubAgentManager from modules.sub_agent.manager import SubAgentManager
from modules.multi_agent.state import MultiAgentState from modules.multi_agent.state import MultiAgentState
from modules.multi_agent.debug_logger import ma_debug
logger = setup_logger(__name__) logger = setup_logger(__name__)
# 多智能体模式下额外加载的工具定义 # 多智能体模式下额外加载的工具定义
@ -63,7 +65,8 @@ class SubAgentTask:
self.thinking_mode = thinking_mode or "fast" self.thinking_mode = thinking_mode or "fast"
self.task_id = task_record["task_id"] self.task_id = task_record["task_id"]
self.agent_id = task_record["agent_id"] self.agent_id = task_record["agent_id"]
self.timeout_seconds = int(task_record.get("timeout_seconds") or 180) 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.deliverables_dir = Path(task_record["deliverables_dir"])
self.output_file = Path(task_record["output_file"]) self.output_file = Path(task_record["output_file"])
@ -97,8 +100,9 @@ class SubAgentTask:
# display_name 不传时回退为 'Agent_{self.agent_id}' # display_name 不传时回退为 'Agent_{self.agent_id}'
self.display_name = display_name or f"Agent_{self.agent_id}" self.display_name = display_name or f"Agent_{self.agent_id}"
# 多智能体运行期控制 # 多智能体运行期控制
# 使用 threading.Event 避免跨事件循环唤醒问题 # 使用 asyncio.Event 在子智能体自己的事件循环内等待;
self._continue_event = threading.Event() # inject_message 可能跨线程调用,通过 loop.call_soon_threadsafe 唤醒。
self._continue_event: Optional[asyncio.Event] = None
self._idle = False self._idle = False
self._pending_answer_question_id: Optional[str] = None self._pending_answer_question_id: Optional[str] = None
self._answered_question_ids: Set[str] = set() self._answered_question_ids: Set[str] = set()
@ -116,16 +120,20 @@ class SubAgentTask:
async def run(self) -> None: async def run(self) -> None:
"""主 LLM 循环。""" """主 LLM 循环。"""
# 在子智能体自己的事件循环内初始化 asyncio.Event
self._continue_event = asyncio.Event()
try: try:
await self._run_loop() await self._run_loop()
except asyncio.CancelledError: except asyncio.CancelledError:
self._cancelled = True self._cancelled = True
logger.debug(f"[SubAgent] task={self.task_id} 被取消") 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 避免取消信号中断最终状态落盘 # shield 避免取消信号中断最终状态落盘
await asyncio.shield(self._write_failure("子智能体被手动终止")) await asyncio.shield(self._write_failure("子智能体被手动终止"))
raise raise
except Exception as exc: except Exception as exc:
logger.exception(f"[SubAgent] task={self.task_id} 执行异常") 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}") await self._write_failure(f"执行异常: {exc}")
async def _run_loop(self) -> None: async def _run_loop(self) -> None:
@ -144,22 +152,33 @@ class SubAgentTask:
while not self._cancelled: while not self._cancelled:
elapsed = time.time() - start_time elapsed = time.time() - start_time
if elapsed > self.timeout_seconds: if self.timeout_seconds is not None and elapsed > self.timeout_seconds:
await self._write_timeout(elapsed) await self._write_timeout(elapsed)
return return
# 多智能体模式下idle 时等待新消息或外部回答;超时后继续循环检查 # 多智能体模式下idle 时等待新消息或外部回答;只有真正被注入消息时才继续运行
if self.multi_agent_mode and self._idle: if self.multi_agent_mode and self._idle:
event_set = False
try: try:
await asyncio.wait_for( if self._continue_event is None:
asyncio.get_event_loop().run_in_executor(None, self._continue_event.wait), self._continue_event = asyncio.Event()
timeout=1.0, await asyncio.wait_for(self._continue_event.wait(), timeout=1.0)
) event_set = True
except asyncio.TimeoutError: except asyncio.TimeoutError:
pass pass
self._continue_event.clear()
if self._cancelled: if self._cancelled:
break 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 self._idle = False
continue continue
@ -177,6 +196,18 @@ class SubAgentTask:
if self.multi_agent_mode: if self.multi_agent_mode:
self._pending_answer_question_id = self._peek_pending_question_id() 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) assistant_message, reasoning, tool_calls, usage = await self._call_model(client, model_key, tools)
if usage: if usage:
self._apply_usage(usage) self._apply_usage(usage)
@ -191,12 +222,14 @@ class SubAgentTask:
if tool_calls: if tool_calls:
final_message["tool_calls"] = tool_calls final_message["tool_calls"] = tool_calls
self.messages.append(final_message) self.messages.append(final_message)
self._persist_conversation(partial_summary=assistant_message[:200])
if not tool_calls: if not tool_calls:
# 多智能体模式:没有 tool_calls 表示本轮结束,进入 idle 等待 # 多智能体模式:没有 tool_calls 表示本轮结束,进入 idle 等待
if self.multi_agent_mode: if self.multi_agent_mode:
self._mark_idle() self._mark_idle()
self._idle = True self._idle = True
self._persist_conversation(partial_summary=assistant_message[:200])
continue continue
# 普通模式prompt 并要求继续 / finish_task # 普通模式prompt 并要求继续 / finish_task
self.messages.append({ self.messages.append({
@ -230,6 +263,7 @@ class SubAgentTask:
"tool_call_id": tool_call.get("id", progress_id), "tool_call_id": tool_call.get("id", progress_id),
"content": content, "content": content,
}) })
self._persist_conversation(partial_summary=assistant_message[:200])
# 循环结束(取消或 idle 被外部终止)后的清理 # 循环结束(取消或 idle 被外部终止)后的清理
if self.multi_agent_mode and self._cancelled: if self.multi_agent_mode and self._cancelled:
@ -237,7 +271,7 @@ class SubAgentTask:
self.multi_agent_state.mark_status(self.agent_id, "terminated") self.multi_agent_state.mark_status(self.agent_id, "terminated")
def _forward_output_to_master(self, output_text: str, *, is_final: bool = False) -> None: def _forward_output_to_master(self, output_text: str, *, is_final: bool = False) -> None:
"""把子智能体的 assistant 文本输出转发成主对话的 user 消息""" """把子智能体的 assistant 文本输出转发成主对话的 user 消息,并写入进度文件供前端查看"""
if not self.multi_agent_state: if not self.multi_agent_state:
return return
# 如果这是对 pending 提问的回答,不走主对话转发,而是返回到 ask 工具结果 # 如果这是对 pending 提问的回答,不走主对话转发,而是返回到 ask 工具结果
@ -251,17 +285,58 @@ class SubAgentTask:
inst = self.multi_agent_state.get_instance(self.agent_id) inst = self.multi_agent_state.get_instance(self.agent_id)
if inst: if inst:
inst.last_output = output_text[:500] 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: except Exception as exc:
logger.warning(f"[SubAgentTask] forward output to master failed: {exc}") logger.warning(f"[SubAgentTask] forward output to master failed: {exc}")
def _mark_idle(self) -> None: def _mark_idle(self) -> None:
"""多智能体模式下,子智能体自然结束即本轮任务结束,进入 idle 状态。""" """多智能体模式下,子智能体自然结束即本轮任务结束,进入 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: if self.multi_agent_state:
self.multi_agent_state.mark_status(self.agent_id, "idle") self.multi_agent_state.mark_status(self.agent_id, "idle")
def inject_message(self, message_text: str) -> None: def inject_message(self, message_text: str) -> None:
"""外部向子智能体上下文插入 user 消息,并唤醒 idle 状态。""" """外部向子智能体上下文插入 user 消息,并唤醒 idle 状态。"""
self.messages.append({"role": "user", "content": message_text}) 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() self._continue_event.set()
def _peek_pending_question_id(self) -> Optional[str]: def _peek_pending_question_id(self) -> Optional[str]:
@ -409,6 +484,14 @@ class SubAgentTask:
if name == "ask_master": if name == "ask_master":
question = str(args.get("question") or "").strip() question = str(args.get("question") or "").strip()
question_id = str(args.get("question_id") or f"ask_master_{uuid.uuid4().hex[:10]}") 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: if not question:
return {"success": False, "error": "question 不能为空"} return {"success": False, "error": "question 不能为空"}
# 插入到主对话 # 插入到主对话
@ -428,6 +511,15 @@ class SubAgentTask:
target_id = int(args.get("target_agent_id") or 0) target_id = int(args.get("target_agent_id") or 0)
question = str(args.get("question") or "").strip() question = str(args.get("question") or "").strip()
question_id = str(args.get("question_id") or f"ask_other_{uuid.uuid4().hex[:10]}") 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: if not target_id or not question:
return {"success": False, "error": "参数缺失"} return {"success": False, "error": "参数缺失"}
# 查找目标实例 # 查找目标实例
@ -516,10 +608,75 @@ class SubAgentTask:
async def _write_failure(self, message: str, *, max_turns_exceeded: bool = False, timeout: bool = False) -> None: async def _write_failure(self, message: str, *, max_turns_exceeded: bool = False, timeout: bool = False) -> None:
elapsed = time.time() - (self.stats["runtime_start"] / 1000) 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) 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: def _finalize_task(self, success: bool, summary: str, elapsed: float, *, max_turns_exceeded: bool = False, timeout: bool = False) -> None:
runtime_seconds = int(elapsed) 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 = { output_data = {
"success": success, "success": success,
"summary": summary, "summary": summary,

View File

@ -826,13 +826,13 @@ def _has_pending_completion_work(*, web_terminal, conversation_id: str) -> bool:
has_unnotified_non_ma = True has_unnotified_non_ma = True
if has_running_non_ma or has_unnotified_non_ma: if has_running_non_ma or has_unnotified_non_ma:
return True return True
# 多智能体模式:有未消费的主对话消息 或 有运行中(非 idle实例时继续轮询 # 多智能体模式:只要有未消费的主对话消息,或还有未结束(非 idle/terminated的实例就继续轮询。
if getattr(web_terminal, "multi_agent_mode", False) and has_running_ma: # 即使全部实例都已 idle只要 pending 消息尚未消费,也要继续,避免主任务结束后通知丢失。
if getattr(web_terminal, "multi_agent_mode", False):
state = sub_manager.get_multi_agent_state(conversation_id) state = sub_manager.get_multi_agent_state(conversation_id)
if state: if state:
if state.has_pending_master_messages(): if state.has_pending_master_messages():
return True return True
# 只要还有非 idle 实例就继续;全部 idle 且无 pending 则结束轮询
if any(a.status not in {"idle", "terminated"} for a in state.list_all()): if any(a.status not in {"idle", "terminated"} for a in state.list_all()):
return True return True
return False return False
@ -1839,6 +1839,7 @@ async def handle_task_with_sender(
# 检查是否有后台运行的子智能体或待通知的完成任务 # 检查是否有后台运行的子智能体或待通知的完成任务
manager = getattr(web_terminal, "sub_agent_manager", None) manager = getattr(web_terminal, "sub_agent_manager", None)
has_running_sub_agents = False has_running_sub_agents = False
has_running_multi_agent = False
bg_manager = getattr(web_terminal, "background_command_manager", None) bg_manager = getattr(web_terminal, "background_command_manager", None)
has_running_background_commands = False has_running_background_commands = False
if manager: if manager:
@ -1848,16 +1849,33 @@ async def handle_task_with_sender(
debug_log(f"[SubAgent] reconcile_task_states failed: {exc}") debug_log(f"[SubAgent] reconcile_task_states failed: {exc}")
if not hasattr(web_terminal, "_announced_sub_agent_tasks"): if not hasattr(web_terminal, "_announced_sub_agent_tasks"):
web_terminal._announced_sub_agent_tasks = set() web_terminal._announced_sub_agent_tasks = set()
# 多智能体模式:检测是否还有运行中/idle 的多智能体实例。
# 它们不是传统后台任务,不阻塞前端输入区,但需要通知池在主任务结束后
# 继续消费 pending 消息并触发 Team Leader 响应。
if getattr(web_terminal, "multi_agent_mode", False):
for task in manager.tasks.values():
if (
isinstance(task, dict)
and task.get("conversation_id") == conversation_id
and task.get("multi_agent_mode")
and task.get("status") not in TERMINAL_STATUSES.union({"terminated"})
):
has_running_multi_agent = True
break
running_tasks = [ running_tasks = [
task for task in manager.tasks.values() task for task in manager.tasks.values()
if task.get("status") not in TERMINAL_STATUSES.union({"terminated"}) if task.get("status") not in TERMINAL_STATUSES.union({"terminated"})
and task.get("run_in_background") and task.get("run_in_background")
and not task.get("multi_agent_mode")
and task.get("conversation_id") == conversation_id and task.get("conversation_id") == conversation_id
] ]
pending_notice_tasks = [ pending_notice_tasks = [
task for task in manager.tasks.values() task for task in manager.tasks.values()
if task.get("status") in TERMINAL_STATUSES.union({"terminated"}) if task.get("status") in TERMINAL_STATUSES.union({"terminated"})
and task.get("run_in_background") and task.get("run_in_background")
and not task.get("multi_agent_mode")
and task.get("conversation_id") == conversation_id and task.get("conversation_id") == conversation_id
and task.get("task_id") not in web_terminal._announced_sub_agent_tasks and task.get("task_id") not in web_terminal._announced_sub_agent_tasks
] ]
@ -1884,11 +1902,11 @@ async def handle_task_with_sender(
# 与子智能体完全复用同一 waiting 事件(前端已有稳定处理链路) # 与子智能体完全复用同一 waiting 事件(前端已有稳定处理链路)
sender('sub_agent_waiting', _build_shared_waiting_payload(waiting_items)) sender('sub_agent_waiting', _build_shared_waiting_payload(waiting_items))
# 统一「通知池」轮询器:子智能体 + 后台 run_command 合并为单一轮询链路, # 统一「通知池」轮询器:子智能体 + 后台 run_command + 多智能体 pending 消息
# 每轮一次性取出两路所有待通知项,避免逐条触发「工作 → 停止 → 再工作」循环。 # 合并为单一轮询链路,每轮一次性取出所有待通知项,避免逐条触发「工作 → 停止 → 再工作」循环。
# 只 spawn 一个轮询器(无论是否同时存在子智能体与后台命令),从根本上消除 # 只 spawn 一个轮询器(无论是否同时存在子智能体与后台命令),从根本上消除
# 两个轮询器同时 create_chat_task 撞「单工作区互斥」的问题。 # 两个轮询器同时 create_chat_task 撞「单工作区互斥」的问题。
if has_running_sub_agents or has_running_background_commands: if has_running_sub_agents or has_running_background_commands or has_running_multi_agent:
def run_completion_poll(): def run_completion_poll():
import asyncio import asyncio
loop = asyncio.new_event_loop() loop = asyncio.new_event_loop()

View File

@ -6,6 +6,7 @@ from datetime import datetime
from typing import Any, Callable, Dict, List, Optional from typing import Any, Callable, Dict, List, Optional
from modules.sub_agent import TERMINAL_STATUSES from modules.sub_agent import TERMINAL_STATUSES
from modules.multi_agent.debug_logger import ma_debug
_VALID_SOURCES = { _VALID_SOURCES = {
@ -329,6 +330,14 @@ def inject_multi_agent_master_message(
if not raw: if not raw:
return None return None
ma_debug(
"inject_multi_agent_master_message",
conversation_id=conversation_id,
text_preview=raw[:300],
inline=inline,
after_tool_call_id=after_tool_call_id,
)
metadata = { metadata = {
"runtime_injected": True, "runtime_injected": True,
"source": "sub_agent", "source": "sub_agent",
@ -405,6 +414,12 @@ async def process_multi_agent_master_messages(
if not pending: if not pending:
return 0 return 0
debug_log(f"[MultiAgent] draining {len(pending)} pending master messages") debug_log(f"[MultiAgent] draining {len(pending)} pending master messages")
ma_debug(
"process_multi_agent_master_messages",
conversation_id=conversation_id,
count=len(pending),
previews=[str(m)[:200] for m in pending],
)
for msg in pending: for msg in pending:
inject_multi_agent_master_message( inject_multi_agent_master_message(
web_terminal=web_terminal, web_terminal=web_terminal,

View File

@ -598,6 +598,7 @@ def create_conversation(terminal: WebTerminal, workspace: UserWorkspace, usernam
thinking_mode = data.get('thinking_mode') if preserve_mode and 'thinking_mode' in data else None thinking_mode = data.get('thinking_mode') if preserve_mode and 'thinking_mode' in data else None
run_mode = data.get('mode') if preserve_mode and 'mode' in data else None run_mode = data.get('mode') if preserve_mode and 'mode' in data else None
target_workspace_id = (data.get('workspace_id') or '').strip() target_workspace_id = (data.get('workspace_id') or '').strip()
multi_agent_mode = bool(data.get('multi_agent_mode'))
if target_workspace_id: if target_workspace_id:
try: try:
@ -641,6 +642,7 @@ def create_conversation(terminal: WebTerminal, workspace: UserWorkspace, usernam
metadata_overrides={ metadata_overrides={
"permission_mode": default_permission_mode or getattr(terminal, "get_permission_mode", lambda: "unrestricted")(), "permission_mode": default_permission_mode or getattr(terminal, "get_permission_mode", lambda: "unrestricted")(),
"execution_mode": getattr(terminal, "get_execution_mode", lambda: "sandbox")(), "execution_mode": getattr(terminal, "get_execution_mode", lambda: "sandbox")(),
"multi_agent_mode": multi_agent_mode,
}, },
) )
try: try:
@ -654,7 +656,11 @@ def create_conversation(terminal: WebTerminal, workspace: UserWorkspace, usernam
"safe_navigation": True, "safe_navigation": True,
} }
else: else:
result = terminal.create_new_conversation(thinking_mode=thinking_mode, run_mode=run_mode) result = terminal.create_new_conversation(
thinking_mode=thinking_mode,
run_mode=run_mode,
metadata_overrides={"multi_agent_mode": multi_agent_mode} if multi_agent_mode else None,
)
if result["success"]: if result["success"]:
# 仅在当前工作区创建时更新 session 模式;指定其他工作区时由前端切换后自动同步。 # 仅在当前工作区创建时更新 session 模式;指定其他工作区时由前端切换后自动同步。

View File

@ -122,15 +122,17 @@ export const actionMethods = {
this.versioningInitializingBackupToastId = backupToastId; this.versioningInitializingBackupToastId = backupToastId;
} }
const response = await fetch('/api/conversations', { const isMultiAgent = Boolean(this.multiAgentMode);
const createUrl = isMultiAgent ? '/api/multiagent/conversations' : '/api/conversations';
const createBody = isMultiAgent
? JSON.stringify({ preserve_mode: true, thinking_mode: this.thinkingMode, mode: this.runMode })
: JSON.stringify({ thinking_mode: this.thinkingMode, mode: this.runMode });
const response = await fetch(createUrl, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json' 'Content-Type': 'application/json'
}, },
body: JSON.stringify({ body: createBody
thinking_mode: this.thinkingMode,
mode: this.runMode
})
}); });
const result = await response.json(); const result = await response.json();

View File

@ -209,15 +209,17 @@ export const sendMethods = {
this.versioningInitializingBackupToastId = backupToastId; this.versioningInitializingBackupToastId = backupToastId;
} }
const createResp = await fetch('/api/conversations', { const isMultiAgent = Boolean(this.multiAgentMode);
const createUrl = isMultiAgent ? '/api/multiagent/conversations' : '/api/conversations';
const createBody = isMultiAgent
? JSON.stringify({ preserve_mode: true, thinking_mode: this.thinkingMode, mode: this.runMode })
: JSON.stringify({ thinking_mode: this.thinkingMode, mode: this.runMode });
const createResp = await fetch(createUrl, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json' 'Content-Type': 'application/json'
}, },
body: JSON.stringify({ body: createBody
thinking_mode: this.thinkingMode,
mode: this.runMode
})
}); });
const createResult = await createResp.json().catch(() => ({})); const createResult = await createResp.json().catch(() => ({}));
if (!createResp.ok || !createResult?.success || !createResult?.conversation_id) { if (!createResp.ok || !createResult?.success || !createResult?.conversation_id) {
@ -258,7 +260,8 @@ export const sendMethods = {
} }
const pathFragment = this.stripConversationPrefix(targetConversationId); const pathFragment = this.stripConversationPrefix(targetConversationId);
history.replaceState({ conversationId: targetConversationId }, '', `/${pathFragment}`); const urlPrefix = this.multiAgentMode ? '/multiagent/' : '/';
history.replaceState({ conversationId: targetConversationId }, '', `${urlPrefix}${pathFragment}`);
} catch (error) { } catch (error) {
this.uiPushToast({ this.uiPushToast({
title: '发送失败', title: '发送失败',

View File

@ -29,13 +29,29 @@
</div> </div>
<div class="subagent-activity-body"> <div class="subagent-activity-body">
<div v-if="activityError" class="subagent-activity-error">{{ activityError }}</div> <div v-if="activityError" class="subagent-activity-error">{{ activityError }}</div>
<div v-else-if="!displayItems.length" class="subagent-activity-empty"> <div v-else-if="!timelineItems.length" class="subagent-activity-empty">
{{ activityLoading ? '正在读取子智能体活动...' : '暂无活动记录' }} {{ activityLoading ? '正在读取子智能体活动...' : '暂无活动记录' }}
</div> </div>
<div v-else class="subagent-activity-list"> <div v-else class="subagent-activity-list">
<div v-for="item in displayItems" :key="item.key" class="subagent-activity-item"> <div
v-for="item in timelineItems"
:key="item.key"
class="subagent-activity-item"
:class="{ 'subagent-output-item': item.kind === 'output', expanded: item.kind === 'output' && expandedOutputs.has(item.key) }"
@click="handleItemClick(item)"
>
<template v-if="item.kind === 'output'">
<div class="subagent-output-meta">
<span v-if="item.isFinal" class="subagent-output-final">结束汇报</span>
<span v-else class="subagent-output-progress">进度输出</span>
<span class="subagent-output-hint">{{ expandedOutputs.has(item.key) ? '点击收起' : '点击展开' }}</span>
</div>
<div class="subagent-output-content">{{ item.content }}</div>
</template>
<template v-else>
<span class="subagent-activity-text">{{ item.text }}</span> <span class="subagent-activity-text">{{ item.text }}</span>
<span class="subagent-activity-state" :class="item.state">{{ item.stateLabel }}</span> <span class="subagent-activity-state" :class="item.state">{{ item.stateLabel }}</span>
</template>
</div> </div>
</div> </div>
</div> </div>
@ -50,17 +66,22 @@ import { storeToRefs } from 'pinia';
import { useSubAgentStore } from '@/stores/subAgent'; import { useSubAgentStore } from '@/stores/subAgent';
type ActivityEntry = { type ActivityEntry = {
type?: string;
id?: string; id?: string;
tool?: string; tool?: string;
status?: string; status?: string;
args?: Record<string, any>; args?: Record<string, any>;
ts?: number; ts?: number;
subtype?: string;
content?: string;
is_final?: boolean;
}; };
const subAgentStore = useSubAgentStore(); const subAgentStore = useSubAgentStore();
const { activeAgent, activityEntries, activityLoading, activityError, stoppingTaskIds } = const { activeAgent, activityEntries, activityLoading, activityError, stoppingTaskIds } =
storeToRefs(subAgentStore); storeToRefs(subAgentStore);
const stopError = ref(''); const stopError = ref('');
const expandedOutputs = ref<Set<string>>(new Set());
const close = () => { const close = () => {
subAgentStore.closeSubAgent(); subAgentStore.closeSubAgent();
@ -141,48 +162,138 @@ const isTerminalStatus = (status?: string) => {
return ['completed', 'failed', 'timeout', 'terminated', 'cancelled'].includes(normalized); return ['completed', 'failed', 'timeout', 'terminated', 'cancelled'].includes(normalized);
}; };
const displayItems = computed(() => { const toggleOutput = (key: string) => {
const next = new Set(expandedOutputs.value);
if (next.has(key)) {
next.delete(key);
} else {
next.add(key);
}
expandedOutputs.value = next;
};
const handleItemClick = (item: any) => {
if (item.kind === 'output') {
toggleOutput(item.key);
}
};
const timelineItems = computed(() => {
const entries = activityEntries.value || []; const entries = activityEntries.value || [];
const groups: { key: string; entry: ActivityEntry }[] = []; const rawItems: { kind: 'tool'; key: string; entry: ActivityEntry } | { kind: 'output'; key: string; content: string; isFinal: boolean }[] = [];
let currentToolGroup: { kind: 'tool'; key: string; entry: ActivityEntry } | null = null;
for (let i = 0; i < entries.length; i++) { const flushToolGroup = () => {
const entry = entries[i]; if (currentToolGroup) {
if (!entry || entry.type !== 'progress') continue; rawItems.push(currentToolGroup);
currentToolGroup = null;
}
};
const baseKey = entry.id || `${entry.tool || 'tool'}-${entry.ts || i}`; entries.forEach((entry: ActivityEntry, index: number) => {
const lastGroup = groups[groups.length - 1]; if (entry?.type === 'progress' && entry?.subtype === 'output' && typeof entry.content === 'string') {
flushToolGroup();
// id running -> completed rawItems.push({
// id id kind: 'output',
if ( key: `output-${entry.ts || index}`,
lastGroup && content: entry.content,
(lastGroup.entry.id === entry.id || lastGroup.key === baseKey) && isFinal: !!entry.is_final,
!isTerminalStatus(lastGroup.entry.status) });
) { return;
lastGroup.entry = { ...lastGroup.entry, ...entry };
continue;
} }
// keyid if (!entry || entry.type !== 'progress' || !entry.tool) return;
const baseKey = entry.id || `${entry.tool}-${entry.ts || index}`;
if (
currentToolGroup &&
(currentToolGroup.entry.id === entry.id || currentToolGroup.key === baseKey) &&
!isTerminalStatus(currentToolGroup.entry.status)
) {
currentToolGroup.entry = { ...currentToolGroup.entry, ...entry };
return;
}
flushToolGroup();
let key = baseKey; let key = baseKey;
let suffix = 0; let suffix = 0;
while (groups.some((g) => g.key === key)) { while (rawItems.some((item) => item.kind === 'tool' && item.key === key)) {
suffix++; suffix++;
key = `${baseKey}--${suffix}`; key = `${baseKey}--${suffix}`;
} }
groups.push({ key, entry: { ...entry } }); currentToolGroup = { kind: 'tool', key, entry: { ...entry } };
} });
return groups.map((group) => { flushToolGroup();
const item = group.entry;
const state = normalizeStatus(item.status); return rawItems.map((item) => {
const stateLabel = state === 'completed' ? '完成' : state === 'failed' ? '失败' : '进行中'; if (item.kind === 'output') return item;
const state = normalizeStatus(item.entry.status);
return { return {
key: group.key, kind: 'tool' as const,
key: item.key,
state, state,
stateLabel, stateLabel: state === 'completed' ? '完成' : state === 'failed' ? '失败' : '进行中',
text: buildText(item) text: buildText(item.entry)
}; };
}); });
}); });
</script> </script>
<style scoped>
.subagent-output-section {
margin-bottom: 16px;
}
.subagent-output-title,
.subagent-activity-list-title {
font-weight: 600;
font-size: 14px;
margin-bottom: 8px;
color: var(--text-primary);
}
.subagent-output-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.subagent-output-item {
background: var(--surface-soft);
border: 1px solid var(--border-default);
border-radius: 8px;
padding: 10px 12px;
cursor: pointer;
}
.subagent-output-meta {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
margin-bottom: 6px;
color: var(--text-muted);
}
.subagent-output-final {
color: var(--accent);
font-weight: 600;
}
.subagent-output-progress {
color: var(--text-secondary);
}
.subagent-output-hint {
margin-left: auto;
opacity: 0.7;
}
.subagent-output-content {
color: var(--text-primary);
white-space: pre-wrap;
word-break: break-word;
line-height: 1.5;
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
}
.subagent-output-item.expanded .subagent-output-content {
-webkit-line-clamp: unset;
display: block;
}
</style>

View File

@ -1590,7 +1590,8 @@ export async function initializeLegacySocket(ctx: any) {
const hasRunningSubAgents = !!data?.has_running_sub_agents; const hasRunningSubAgents = !!data?.has_running_sub_agents;
const hasRunningBackgroundCommands = !!data?.has_running_background_commands; const hasRunningBackgroundCommands = !!data?.has_running_background_commands;
const hasRunningBackground = hasRunningSubAgents || hasRunningBackgroundCommands; const hasRunningBackground = hasRunningSubAgents || hasRunningBackgroundCommands;
if (!hasRunningBackground) { // 多智能体模式下,主任务停止即可恢复输入区,不因子智能体后台运行保持停止按钮
if (ctx.multiAgentMode || !hasRunningBackground) {
ctx.taskInProgress = false; ctx.taskInProgress = false;
} else { } else {
ctx.waitingForSubAgent = hasRunningSubAgents; ctx.waitingForSubAgent = hasRunningSubAgents;
@ -1616,8 +1617,8 @@ export async function initializeLegacySocket(ctx: any) {
}); });
socketLog('任务完成', data); socketLog('任务完成', data);
// 如果有运行中的子智能体,不重置任务状态 // 多智能体模式下,主任务完成即视为可发送状态,后台子智能体是否 idle/running 不影响输入区
if (!data.has_running_sub_agents) { if (ctx.multiAgentMode || !data.has_running_sub_agents) {
console.log('[DEBUG] 没有运行中的子智能体,重置任务状态'); console.log('[DEBUG] 没有运行中的子智能体,重置任务状态');
if (ctx.waitingForSubAgent) { if (ctx.waitingForSubAgent) {
ctx.waitingForSubAgent = false; ctx.waitingForSubAgent = false;
@ -1652,6 +1653,8 @@ export async function initializeLegacySocket(ctx: any) {
}); });
socketLog('等待子智能体完成:', data); socketLog('等待子智能体完成:', data);
// 多智能体模式下,子智能体 idle/running 是常态,不阻塞主对话输入区
if (!ctx.multiAgentMode) {
// 设置标志:有子智能体在运行,阻止状态重置 // 设置标志:有子智能体在运行,阻止状态重置
ctx.waitingForSubAgent = true; ctx.waitingForSubAgent = true;
@ -1659,6 +1662,7 @@ export async function initializeLegacySocket(ctx: any) {
ctx.taskInProgress = true; ctx.taskInProgress = true;
ctx.streamingMessage = false; ctx.streamingMessage = false;
ctx.stopRequested = false; ctx.stopRequested = false;
}
console.log('[DEBUG] 当前状态 (after sub_agent_waiting):', { console.log('[DEBUG] 当前状态 (after sub_agent_waiting):', {
taskInProgress: ctx.taskInProgress, taskInProgress: ctx.taskInProgress,