- _call_model 中硬取消时直接抛出 CancelledError,避免返回半成品 tool_calls - 软停止时仍优雅 break,后续由 _run_loop 丢弃半成品并进入 idle - 保持 _mark_task_done 不覆盖 terminated 状态 - 侧边栏当前对话运行态标记修复保留
449 lines
18 KiB
Python
449 lines
18 KiB
Python
"""子智能体状态管理 Mixin。"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import time
|
||
from pathlib import Path
|
||
from typing import Any, Dict, List, Optional
|
||
|
||
from utils.logger import setup_logger
|
||
from modules.multi_agent.debug_logger import ma_debug
|
||
|
||
logger = setup_logger(__name__)
|
||
TERMINAL_STATUSES = {"completed", "failed", "timeout"}
|
||
|
||
|
||
class SubAgentStateMixin:
|
||
"""提供子智能体任务状态加载、保存、刷新与结果落盘能力。"""
|
||
|
||
state_file: Path
|
||
tasks: Dict[str, Dict[str, Any]]
|
||
conversation_agents: Dict[str, List[int]]
|
||
_running_tasks: Dict[str, Any]
|
||
_state_lock: Any
|
||
|
||
def _load_state(self):
|
||
with self._state_lock:
|
||
if not self.state_file.exists():
|
||
self.tasks = {}
|
||
self.conversation_agents = {}
|
||
return
|
||
try:
|
||
data = json.loads(self.state_file.read_text(encoding="utf-8"))
|
||
loaded_tasks = data.get("tasks", {})
|
||
loaded_agents = data.get("conversation_agents", {})
|
||
except json.JSONDecodeError:
|
||
logger.warning("子智能体状态文件损坏,已忽略。")
|
||
self.tasks = {}
|
||
self.conversation_agents = {}
|
||
return
|
||
|
||
runtime_only_keys = {"_stdout_lines"}
|
||
merged_tasks: Dict[str, Dict] = {}
|
||
for task_id, task in loaded_tasks.items():
|
||
existing = self.tasks.get(task_id)
|
||
if existing:
|
||
for key in runtime_only_keys:
|
||
if key in existing:
|
||
task[key] = existing[key]
|
||
merged_tasks[task_id] = task
|
||
self.tasks = merged_tasks
|
||
self.conversation_agents = loaded_agents
|
||
|
||
# 恢复多智能体运行态(如果状态文件包含)
|
||
try:
|
||
from modules.multi_agent.state import MultiAgentState
|
||
|
||
manager = self
|
||
multi_agent_states = getattr(manager, "multi_agent_states", None)
|
||
if multi_agent_states is not None and isinstance(multi_agent_states, dict):
|
||
loaded_ma_states = data.get("multi_agent_states", {})
|
||
for conv_id, snapshot in loaded_ma_states.items():
|
||
try:
|
||
if isinstance(snapshot, dict):
|
||
# 关键:不要覆盖内存中已存在的 MultiAgentState,
|
||
# 否则 SubAgentTask 持有的旧引用上的 pending_master_messages
|
||
# 会被新的空 state 覆盖,导致子智能体输出丢失。
|
||
if conv_id in multi_agent_states:
|
||
ma_debug(
|
||
"load_state_skip_existing_ma_state",
|
||
conversation_id=conv_id,
|
||
existing_state_id=id(multi_agent_states[conv_id]),
|
||
)
|
||
continue
|
||
multi_agent_states[conv_id] = MultiAgentState.from_snapshot(snapshot)
|
||
ma_debug(
|
||
"load_state_restore_ma_state",
|
||
conversation_id=conv_id,
|
||
state_id=id(multi_agent_states[conv_id]),
|
||
)
|
||
except Exception as exc:
|
||
logger.warning(f"恢复多智能体状态失败 {conv_id}: {exc}")
|
||
except Exception as exc:
|
||
logger.warning(f"加载多智能体状态失败: {exc}")
|
||
|
||
if self.tasks:
|
||
migrated = False
|
||
for task in self.tasks.values():
|
||
if task.get("parent_conversation_id"):
|
||
continue
|
||
candidate = task.get("conversation_id") or (task.get("service_payload") or {}).get("parent_conversation_id")
|
||
if candidate:
|
||
task["parent_conversation_id"] = candidate
|
||
migrated = True
|
||
if migrated:
|
||
self._save_state_unsafe()
|
||
|
||
def _save_state(self):
|
||
with self._state_lock:
|
||
self._save_state_unsafe()
|
||
|
||
def _save_state_unsafe(self):
|
||
payload = {
|
||
"tasks": self.tasks,
|
||
"conversation_agents": self.conversation_agents,
|
||
}
|
||
# 多智能体运行态持久化
|
||
try:
|
||
manager = self
|
||
multi_agent_states = getattr(manager, "multi_agent_states", None)
|
||
if multi_agent_states:
|
||
payload["multi_agent_states"] = {
|
||
conv_id: state.to_snapshot()
|
||
for conv_id, state in multi_agent_states.items()
|
||
if isinstance(state, object) and hasattr(state, "to_snapshot")
|
||
}
|
||
except Exception as exc:
|
||
logger.warning(f"保存多智能体状态失败: {exc}")
|
||
try:
|
||
self.state_file.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
||
except Exception as exc:
|
||
logger.warning(f"保存子智能体状态失败: {exc}")
|
||
|
||
def _check_task_status(self, task: Dict) -> Dict:
|
||
"""检查任务状态,读取输出文件。"""
|
||
task_id = task["task_id"]
|
||
output_file = Path(task.get("output_file", ""))
|
||
if not output_file.exists():
|
||
running_task = self._running_tasks.get(task_id)
|
||
if running_task and running_task.done():
|
||
try:
|
||
running_task.result(timeout=0)
|
||
except Exception:
|
||
pass
|
||
task["status"] = "failed"
|
||
task["updated_at"] = time.time()
|
||
return {"status": task.get("status", "running"), "task_id": task_id}
|
||
|
||
try:
|
||
output = json.loads(output_file.read_text(encoding="utf-8"))
|
||
except Exception as exc:
|
||
logger.warning(f"[check_task_status] output 文件解析失败: {exc}")
|
||
task["status"] = "failed"
|
||
task["updated_at"] = time.time()
|
||
return {"success": False, "status": "failed", "task_id": task_id, "message": f"输出文件解析失败: {exc}"}
|
||
|
||
success = output.get("success", False)
|
||
summary = output.get("summary", "")
|
||
stats = output.get("stats", {})
|
||
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"):
|
||
status = "timeout"
|
||
elif output.get("max_turns_exceeded"):
|
||
status = "failed"
|
||
summary = f"任务执行超过最大轮次限制。{summary}"
|
||
elif success:
|
||
status = "completed"
|
||
else:
|
||
status = "failed"
|
||
|
||
ma_debug("check_task_status_result", task_id=task_id, agent_id=task.get("agent_id"), status=status)
|
||
task["status"] = status
|
||
task["updated_at"] = time.time()
|
||
if status == "completed" and elapsed_seconds is not None:
|
||
task["elapsed_seconds"] = elapsed_seconds
|
||
task["runtime_seconds"] = elapsed_seconds
|
||
|
||
agent_id = task.get("agent_id")
|
||
task_summary = task.get("summary")
|
||
deliverables_dir = task.get("deliverables_dir")
|
||
stats_summary = self._build_stats_summary(stats)
|
||
|
||
if status == "completed":
|
||
system_message = self._compose_sub_agent_message(
|
||
prefix=f"✅ 子智能体{agent_id} 任务摘要:{task_summary} 已完成。",
|
||
stats_summary=stats_summary,
|
||
summary=summary,
|
||
deliverables_dir=deliverables_dir,
|
||
duration_seconds=elapsed_seconds,
|
||
)
|
||
elif status == "timeout":
|
||
system_message = self._compose_sub_agent_message(
|
||
prefix=f"⏱️ 子智能体{agent_id} 任务摘要:{task_summary} 超时未完成。",
|
||
stats_summary=stats_summary,
|
||
summary=summary,
|
||
)
|
||
else:
|
||
system_message = self._compose_sub_agent_message(
|
||
prefix=f"❌ 子智能体{agent_id} 任务摘要:{task_summary} 执行失败。",
|
||
stats_summary=stats_summary,
|
||
summary=summary,
|
||
)
|
||
|
||
result = {
|
||
"success": success,
|
||
"status": status,
|
||
"task_id": task_id,
|
||
"agent_id": agent_id,
|
||
"message": summary,
|
||
"deliverables_dir": deliverables_dir,
|
||
"stats": stats,
|
||
"stats_summary": stats_summary,
|
||
"system_message": system_message,
|
||
}
|
||
if status == "completed" and elapsed_seconds is not None:
|
||
result["elapsed_seconds"] = elapsed_seconds
|
||
result["runtime_seconds"] = elapsed_seconds
|
||
task["final_result"] = result
|
||
return result
|
||
|
||
def _handle_timeout(self, task: Dict) -> Dict:
|
||
"""处理任务超时。"""
|
||
task_id = task["task_id"]
|
||
running_task = self._running_tasks.pop(task_id, None)
|
||
if running_task and not running_task.done():
|
||
running_task.cancel()
|
||
deadline = time.time() + 5
|
||
while not running_task.done() and time.time() < deadline:
|
||
time.sleep(0.05)
|
||
|
||
task["status"] = "timeout"
|
||
task["updated_at"] = time.time()
|
||
|
||
stats = {}
|
||
stats_file = Path(task.get("stats_file", ""))
|
||
if stats_file.exists():
|
||
try:
|
||
stats = json.loads(stats_file.read_text(encoding="utf-8"))
|
||
except Exception:
|
||
stats = {}
|
||
stats_summary = self._build_stats_summary(stats)
|
||
system_message = self._compose_sub_agent_message(
|
||
prefix=f"⏱️ 子智能体{task.get('agent_id')} 任务摘要:{task.get('summary')} 超时未完成。",
|
||
stats_summary=stats_summary,
|
||
summary="等待超时,子智能体已被终止。",
|
||
)
|
||
|
||
result = {
|
||
"success": False,
|
||
"status": "timeout",
|
||
"task_id": task_id,
|
||
"agent_id": task.get("agent_id"),
|
||
"message": "等待超时,子智能体已被终止。",
|
||
"stats": stats,
|
||
"stats_summary": stats_summary,
|
||
"system_message": system_message,
|
||
}
|
||
task["final_result"] = result
|
||
self._save_state()
|
||
return result
|
||
|
||
def _mark_task_terminated(
|
||
self,
|
||
task: Dict[str, Any],
|
||
*,
|
||
message: str,
|
||
system_message: Optional[str] = None,
|
||
notified: bool = False,
|
||
) -> Dict[str, Any]:
|
||
task["status"] = "terminated"
|
||
task["updated_at"] = time.time()
|
||
if notified:
|
||
task["notified"] = True
|
||
result = {
|
||
"success": False,
|
||
"status": "terminated",
|
||
"task_id": task.get("task_id"),
|
||
"agent_id": task.get("agent_id"),
|
||
"message": message,
|
||
"system_message": system_message or message,
|
||
}
|
||
task["final_result"] = result
|
||
return result
|
||
|
||
def _mark_task_done(
|
||
self,
|
||
task_id: str,
|
||
success: bool,
|
||
summary: str,
|
||
runtime_seconds: int,
|
||
) -> None:
|
||
task = self.tasks.get(task_id)
|
||
if not task:
|
||
return
|
||
# 如果 terminate_sub_agent 已把任务标为 terminated,下渗异步任务自然结束时
|
||
# 不要被“已完成/fulfilled覆盖为failed”值覆盖,否则 task 被碰成 failed 但实际上
|
||
# 用户手动终止已发生,状态语义错误且“successful=false”。
|
||
existing_status = task.get("status")
|
||
if existing_status in TERMINAL_STATUSES.union({"terminated"}):
|
||
ma_debug(
|
||
"mark_task_done_skip_already_terminal",
|
||
task_id=task_id,
|
||
existing_status=existing_status,
|
||
)
|
||
return
|
||
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["updated_at"] = time.time()
|
||
task["runtime_seconds"] = runtime_seconds
|
||
self._check_task_status(task)
|
||
self._save_state()
|
||
|
||
def _refresh_task_runtime_state(self, task: Dict[str, Any]) -> Dict[str, Any]:
|
||
"""刷新单个任务运行态。"""
|
||
status = task.get("status")
|
||
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
|
||
if running_task:
|
||
if running_task.done():
|
||
try:
|
||
running_task.result(timeout=0)
|
||
except Exception:
|
||
pass
|
||
# 多智能体模式:任务自然进入 idle 时不写输出文件,不应标记为失败
|
||
if task.get("multi_agent_mode") and not Path(task.get("output_file", "")).exists():
|
||
task["status"] = "idle"
|
||
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}
|
||
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}
|
||
|
||
output_file = Path(task.get("output_file", ""))
|
||
if output_file.exists():
|
||
result = self._check_task_status(task)
|
||
ma_debug("refresh_task_runtime_state_output_result", task_id=task_id, result=result)
|
||
return result
|
||
|
||
# 多智能体模式:没有输出文件表示 idle,不强制清理
|
||
if task.get("multi_agent_mode"):
|
||
task["status"] = "idle"
|
||
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}
|
||
|
||
if self._should_force_cleanup_stale_task(task):
|
||
return self._mark_task_terminated(
|
||
task,
|
||
message="子智能体疑似僵尸任务,已超时自动清理运行状态。",
|
||
system_message="⚠️ 子智能体长时间未结束,系统已自动清理运行状态。",
|
||
notified=True,
|
||
)
|
||
|
||
return self._mark_task_terminated(
|
||
task,
|
||
message="检测到子智能体任务已退出,已自动清理运行状态。",
|
||
system_message="⚠️ 子智能体任务异常退出,系统已自动清理运行状态。",
|
||
notified=True,
|
||
)
|
||
|
||
def reconcile_task_states(self, conversation_id: Optional[str] = None) -> int:
|
||
"""修正运行态任务状态,返回修正条目数。"""
|
||
changed = 0
|
||
ma_debug("reconcile_task_states_start", conversation_id=conversation_id, task_count=len(self.tasks))
|
||
for task in self.tasks.values():
|
||
if not isinstance(task, dict):
|
||
continue
|
||
if conversation_id and task.get("conversation_id") != conversation_id:
|
||
continue
|
||
before_status = task.get("status")
|
||
before_notified = task.get("notified")
|
||
self._refresh_task_runtime_state(task)
|
||
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
|
||
if changed:
|
||
self._save_state()
|
||
ma_debug("reconcile_task_states_end", conversation_id=conversation_id, changed=changed)
|
||
return changed
|
||
|
||
def _should_force_cleanup_stale_task(self, task: Dict[str, Any]) -> bool:
|
||
try:
|
||
created_at = float(task.get("created_at") or 0)
|
||
except (TypeError, ValueError):
|
||
created_at = 0
|
||
if created_at <= 0:
|
||
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
|
||
timeout_seconds = int(raw_timeout or SUB_AGENT_DEFAULT_TIMEOUT or 0)
|
||
timeout_seconds = max(timeout_seconds, 1)
|
||
grace_seconds = 120
|
||
elapsed = time.time() - created_at
|
||
if elapsed <= (timeout_seconds + grace_seconds):
|
||
return False
|
||
output_file = Path(task.get("output_file", ""))
|
||
if output_file.exists():
|
||
return False
|
||
progress_file = Path(task.get("progress_file", ""))
|
||
if progress_file.exists():
|
||
try:
|
||
stale_span = time.time() - progress_file.stat().st_mtime
|
||
if stale_span <= grace_seconds:
|
||
return False
|
||
except Exception:
|
||
pass
|
||
return True
|