agent-Specialization/modules/sub_agent/state.py
JOJO 9ed956518c refactor(sub_agent): 子智能体从 Node.js 子进程改为主进程内 Python 协程
- 重写子智能体执行核心,不再启动 easyagent Node.js 子进程
- 新增 modules/sub_agent/ 包集中管理子智能体逻辑
- 工具调用复用主进程 WebTerminal.handle_tool_call,自然经过沙箱/容器链路
- 子智能体模型独立读取 ~/.agents/<mode>/config/sub_agent_models.json
- 支持 8 个工具:read_file/write_file/edit_file(replacements+replace_all)/run_command/web_search/extract_webpage/search_workspace/read_mediafile
- 修复子智能体进度弹窗:标题颜色、write_file 显示、过滤非 progress 条目、统一滚动条样式
- 更新 AGENTS.md / CLAUDE.md 子智能体描述
- 新增 test/test_sub_agent_regression.py 回归测试
2026-06-20 00:26:45 +08:00

325 lines
12 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
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
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:
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:
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)
if output.get("timeout"):
status = "timeout"
elif output.get("max_turns_exceeded"):
status = "failed"
summary = f"任务执行超过最大轮次限制。{summary}"
elif success:
status = "completed"
else:
status = "failed"
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
status = "completed" if success else "failed"
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")
if status in TERMINAL_STATUSES.union({"terminated"}):
return {"status": status, "task_id": task.get("task_id")}
task_id = task.get("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
return self._check_task_status(task)
return {"status": "running", "task_id": task_id}
output_file = Path(task.get("output_file", ""))
if output_file.exists():
return self._check_task_status(task)
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
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)
if task.get("status") != before_status or task.get("notified") != before_notified:
changed += 1
if changed:
self._save_state()
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
from config import SUB_AGENT_DEFAULT_TIMEOUT
timeout_seconds = int(task.get("timeout_seconds") 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