agent-Specialization/modules/sub_agent/state.py
JOJO 571ed6c81f fix(sub-agent): 修复传统模式 terminate 后子智能体仍继续调用工具的问题
- _call_model 中硬取消时直接抛出 CancelledError,避免返回半成品 tool_calls
- 软停止时仍优雅 break,后续由 _run_loop 丢弃半成品并进入 idle
- 保持 _mark_task_done 不覆盖 terminated 状态
- 侧边栏当前对话运行态标记修复保留
2026-07-14 20:37:57 +08:00

449 lines
18 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""子智能体状态管理 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