785 lines
38 KiB
Python
785 lines
38 KiB
Python
"""Persistent agents, workflow graph and live execution for Hermes Hub.
|
||
|
||
The router role registry remains the source of truth for logical agents and
|
||
Provider -> Account -> Model assignment. This module adds the pieces that do
|
||
not fit the routing schema: Agent Files, editor layout, workflow transitions,
|
||
execution checkpoints and a bounded event journal.
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import re
|
||
import threading
|
||
import time
|
||
import uuid
|
||
from dataclasses import asdict, dataclass, field
|
||
from pathlib import Path
|
||
from typing import Any, Optional
|
||
|
||
from antigravity_provider import paths
|
||
from antigravity_provider.router.router_config import RolePolicy, load_router_config, save_router_config
|
||
|
||
|
||
AGENT_STATES = {"waiting", "working", "reviewing", "error", "completed", "not_implemented"}
|
||
EDGE_CONDITIONS = {"SUCCESS", "REVIEW_PASSED", "REVIEW_FAILED", "NEXT", "ERROR", "ALWAYS"}
|
||
|
||
|
||
def _utc_timestamp() -> str:
|
||
import datetime
|
||
|
||
return datetime.datetime.now(datetime.timezone.utc).isoformat(timespec="seconds")
|
||
|
||
|
||
def _slug(value: str) -> str:
|
||
result = re.sub(r"[^a-z0-9]+", "-", value.strip().lower()).strip("-")
|
||
return result or f"agent-{uuid.uuid4().hex[:8]}"
|
||
|
||
|
||
def sanitize_run_data(node: Any) -> Any:
|
||
"""Recursively strip or mask any credentials or secret keys from workflow run state."""
|
||
secret_key_substrings = [
|
||
"api_key", "token", "password", "secret", "jwt", "bearer",
|
||
"access_token", "refresh_token", "client_secret", "authorization",
|
||
]
|
||
if isinstance(node, dict):
|
||
sanitized: dict[str, Any] = {}
|
||
for k, v in node.items():
|
||
k_lower = str(k).lower()
|
||
if any(s in k_lower for s in secret_key_substrings) and k_lower not in ("auth_status", "author", "auth_required"):
|
||
sanitized[k] = "***"
|
||
else:
|
||
sanitized[k] = sanitize_run_data(v)
|
||
return sanitized
|
||
elif isinstance(node, list):
|
||
return [sanitize_run_data(x) for x in node]
|
||
elif isinstance(node, str):
|
||
val = node
|
||
val = re.sub(r'Bearer\s+[a-zA-Z0-9_\-\.]{8,}', 'Bearer ***', val, flags=re.IGNORECASE)
|
||
val = re.sub(r'sk-[a-zA-Z0-9_\-]{8,}', 'sk-***', val)
|
||
val = re.sub(r'gho_[a-zA-Z0-9_\-]{8,}', 'gho_***', val)
|
||
val = re.sub(r'((?:access_token|refresh_token|api_key|token|password|secret|key)=)([^\s&,"]+)', r'\g<1>***', val, flags=re.IGNORECASE)
|
||
return val
|
||
return node
|
||
|
||
|
||
def get_last_run_state(run_state_path: Optional[Path] = None) -> Optional[dict[str, Any]]:
|
||
"""Return the last saved workflow run state from workflow_run_state.json, if any."""
|
||
p = run_state_path or paths.get_workflow_run_state_path()
|
||
if not p.is_file():
|
||
return None
|
||
try:
|
||
data = json.loads(p.read_text(encoding="utf-8"))
|
||
if isinstance(data, dict):
|
||
return sanitize_run_data(data)
|
||
except Exception:
|
||
pass
|
||
return None
|
||
|
||
|
||
def _safe_agent_file(value: str, agent_id: str) -> tuple[Path, str]:
|
||
"""Resolve an Agent File below HERMES_HOME/agents and reject traversal."""
|
||
root = paths.get_agent_files_dir().resolve()
|
||
candidate_name = Path(value or f"{agent_id}.md").name
|
||
if not candidate_name.lower().endswith(".md"):
|
||
candidate_name += ".md"
|
||
target = (root / candidate_name).resolve()
|
||
if target.parent != root:
|
||
raise ValueError("Agent File должен находиться в каталоге agents")
|
||
return target, f"agents/{candidate_name}"
|
||
|
||
|
||
@dataclass
|
||
class AgentDefinition:
|
||
id: str
|
||
name: str
|
||
role: str
|
||
description: str = ""
|
||
agent_file: str = ""
|
||
tools: list[str] = field(default_factory=list)
|
||
memory_configuration: dict[str, Any] = field(default_factory=dict)
|
||
execution_policy: dict[str, Any] = field(default_factory=dict)
|
||
timeout: int = 180
|
||
temperature: Optional[float] = None
|
||
max_tokens: Optional[int] = None
|
||
position: dict[str, float] = field(default_factory=lambda: {"x": 80.0, "y": 80.0})
|
||
metadata: dict[str, Any] = field(default_factory=dict)
|
||
|
||
|
||
@dataclass
|
||
class WorkflowEdge:
|
||
id: str
|
||
source: str
|
||
target: str
|
||
condition: str = "SUCCESS"
|
||
label: str = ""
|
||
|
||
|
||
@dataclass
|
||
class WorkflowDefinition:
|
||
id: str = "main"
|
||
name: str = "Основной workflow"
|
||
edges: list[WorkflowEdge] = field(default_factory=list)
|
||
max_iterations: int = 5
|
||
escalation_agent_id: Optional[str] = None
|
||
start_agent_id: Optional[str] = None
|
||
|
||
|
||
@dataclass
|
||
class WorkflowEvent:
|
||
timestamp: str
|
||
type: str
|
||
message: str
|
||
level: str = "info"
|
||
run_id: Optional[str] = None
|
||
agent_id: Optional[str] = None
|
||
iteration: Optional[int] = None
|
||
provider: Optional[str] = None
|
||
account: Optional[str] = None
|
||
model: Optional[str] = None
|
||
duration_seconds: Optional[float] = None
|
||
error: Optional[str] = None
|
||
|
||
|
||
class WorkflowService:
|
||
_instance: Optional["WorkflowService"] = None
|
||
_instance_lock = threading.Lock()
|
||
|
||
def __init__(self, state_path: Optional[Path] = None, run_state_path: Optional[Path] = None) -> None:
|
||
self.state_path = state_path or paths.get_workflow_state_path()
|
||
self.run_state_path = run_state_path or paths.get_workflow_run_state_path()
|
||
self._lock = threading.RLock()
|
||
self._stop = threading.Event()
|
||
self._thread: Optional[threading.Thread] = None
|
||
self.agents: dict[str, AgentDefinition] = {}
|
||
self.workflow = WorkflowDefinition()
|
||
self.events: list[WorkflowEvent] = []
|
||
self.run: dict[str, Any] = self._idle_run()
|
||
self._completed_steps: list[dict[str, Any]] = []
|
||
self._load()
|
||
self._migrate_router_roles()
|
||
|
||
@classmethod
|
||
def get(cls) -> "WorkflowService":
|
||
if cls._instance is None:
|
||
with cls._instance_lock:
|
||
if cls._instance is None:
|
||
cls._instance = cls()
|
||
return cls._instance
|
||
|
||
@staticmethod
|
||
def _idle_run() -> dict[str, Any]:
|
||
return {
|
||
"id": None,
|
||
"status": "idle",
|
||
"current_agent_id": None,
|
||
"current_task": None,
|
||
"iteration": 0,
|
||
"started_at": None,
|
||
"finished_at": None,
|
||
"elapsed_seconds": None,
|
||
"last_result": None,
|
||
"error": None,
|
||
"agent_states": {},
|
||
}
|
||
|
||
def _load(self) -> None:
|
||
# 1. Load run state from workflow_run_state.json
|
||
if self.run_state_path.is_file():
|
||
try:
|
||
run_state = json.loads(self.run_state_path.read_text(encoding="utf-8"))
|
||
if isinstance(run_state, dict):
|
||
self._completed_steps = list(run_state.get("completed_steps", []))
|
||
if run_state.get("status") in {"RUNNING", "running", "STOPPING", "stopping"}:
|
||
run_state["status"] = "INTERRUPTED"
|
||
run_state["interruption_reason"] = "Прогон был прерван перезапуском сервера или сбоем процесса"
|
||
run_state["updated_at"] = _utc_timestamp()
|
||
sanitized = sanitize_run_data(run_state)
|
||
self.run_state_path.parent.mkdir(parents=True, exist_ok=True)
|
||
temp = self.run_state_path.with_suffix(".tmp")
|
||
temp.write_text(json.dumps(sanitized, ensure_ascii=False, indent=2), encoding="utf-8")
|
||
temp.replace(self.run_state_path)
|
||
except Exception:
|
||
self._completed_steps = []
|
||
|
||
# 2. Load workflow definition and events from workflow_state.json
|
||
if not self.state_path.is_file():
|
||
return
|
||
try:
|
||
raw = json.loads(self.state_path.read_text(encoding="utf-8"))
|
||
self.agents = {
|
||
item["id"]: AgentDefinition(**item)
|
||
for item in raw.get("agents", [])
|
||
if isinstance(item, dict) and item.get("id")
|
||
}
|
||
wf = raw.get("workflow") or {}
|
||
edges = [WorkflowEdge(**edge) for edge in wf.pop("edges", []) if isinstance(edge, dict)]
|
||
self.workflow = WorkflowDefinition(edges=edges, **wf)
|
||
self.events = [WorkflowEvent(**event) for event in raw.get("events", [])[-200:]]
|
||
self.run = raw.get("run") or self._idle_run()
|
||
if self.run.get("status") in {"running", "stopping"}:
|
||
self.run["status"] = "interrupted"
|
||
self.run["error"] = "Выполнение прервано перезапуском Hermes Hub; checkpoint сохранён"
|
||
self._event("WORKFLOW_INTERRUPTED", self.run["error"], level="warning")
|
||
except (OSError, ValueError, TypeError):
|
||
self.agents = {}
|
||
self.workflow = WorkflowDefinition()
|
||
self.events = []
|
||
self.run = self._idle_run()
|
||
|
||
def _save_run_state(
|
||
self,
|
||
status: str,
|
||
step_index: int = 0,
|
||
current_agent: Optional[str] = None,
|
||
iteration: int = 1,
|
||
completed_step: Optional[dict[str, Any]] = None,
|
||
interruption_reason: Optional[str] = None,
|
||
) -> None:
|
||
if completed_step:
|
||
self._completed_steps.append(sanitize_run_data(completed_step))
|
||
|
||
state_payload = {
|
||
"run_id": self.run.get("id"),
|
||
"status": status.upper(),
|
||
"started_at": self.run.get("started_at"),
|
||
"updated_at": _utc_timestamp(),
|
||
"current_step_index": step_index,
|
||
"current_agent_id": current_agent,
|
||
"iteration_count": iteration,
|
||
"completed_steps": list(self._completed_steps),
|
||
"interruption_reason": interruption_reason,
|
||
}
|
||
sanitized = sanitize_run_data(state_payload)
|
||
try:
|
||
self.run_state_path.parent.mkdir(parents=True, exist_ok=True)
|
||
temp = self.run_state_path.with_suffix(".tmp")
|
||
temp.write_text(json.dumps(sanitized, ensure_ascii=False, indent=2), encoding="utf-8")
|
||
temp.replace(self.run_state_path)
|
||
except Exception:
|
||
pass
|
||
|
||
def get_last_run_state(self) -> Optional[dict[str, Any]]:
|
||
"""Return the last saved run state from workflow_run_state.json."""
|
||
return get_last_run_state(self.run_state_path)
|
||
|
||
def _save(self) -> None:
|
||
self.state_path.parent.mkdir(parents=True, exist_ok=True)
|
||
payload = {
|
||
"schema_version": 1,
|
||
"agents": [asdict(agent) for agent in self.agents.values()],
|
||
"workflow": asdict(self.workflow),
|
||
"events": [asdict(event) for event in self.events[-200:]],
|
||
"run": self.run,
|
||
}
|
||
temp = self.state_path.with_suffix(".tmp")
|
||
temp.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
||
temp.replace(self.state_path)
|
||
|
||
def _migrate_router_roles(self) -> None:
|
||
config = load_router_config()
|
||
changed = False
|
||
for index, (role_id, policy) in enumerate(config.roles.items()):
|
||
if role_id in self.agents:
|
||
continue
|
||
target, relative = _safe_agent_file("", role_id)
|
||
name = role_id.replace("-", " ").title()
|
||
description = ""
|
||
try:
|
||
from antigravity_provider.router.role_registry import get_role_definition
|
||
|
||
definition = get_role_definition(role_id)
|
||
name = getattr(definition, "name", None) or getattr(definition, "display_name", None) or name
|
||
description = getattr(definition, "description", "")
|
||
except (ImportError, AttributeError, TypeError):
|
||
pass
|
||
agent = AgentDefinition(
|
||
id=role_id,
|
||
name=name,
|
||
role=role_id,
|
||
description=description,
|
||
agent_file=relative,
|
||
position={"x": 70.0 + (index % 2) * 280.0, "y": 45.0 + (index // 2) * 125.0},
|
||
)
|
||
self.agents[role_id] = agent
|
||
self._ensure_file(target, agent)
|
||
changed = True
|
||
if changed:
|
||
self._save()
|
||
|
||
@staticmethod
|
||
def _ensure_file(target: Path, agent: AgentDefinition) -> None:
|
||
if not target.exists():
|
||
body = f"# {agent.name}\n\n## Роль\n\n{agent.role}\n\n## Назначение\n\n{agent.description or 'Инструкции ещё не заполнены.'}\n"
|
||
target.write_text(body, encoding="utf-8")
|
||
|
||
def _execution_config(self, agent: AgentDefinition) -> dict[str, Any]:
|
||
config = load_router_config()
|
||
policy = config.roles.get(agent.role)
|
||
profile_id = policy.preferred_chain[0] if policy and policy.preferred_chain else None
|
||
profile = config.profiles.get(profile_id) if profile_id else None
|
||
model = policy.default_model if policy else None
|
||
if not model and profile and profile.preferred_models:
|
||
model = profile.preferred_models[0]
|
||
return {
|
||
"provider": profile.provider if profile else None,
|
||
"account": profile_id,
|
||
"model": model,
|
||
"timeout": agent.timeout,
|
||
"temperature": agent.temperature,
|
||
"max_tokens": agent.max_tokens,
|
||
"unavailable_reason": None if profile else "Для роли не назначен доступный аккаунт",
|
||
}
|
||
|
||
def snapshot(self) -> dict[str, Any]:
|
||
with self._lock:
|
||
agents = []
|
||
for agent in self.agents.values():
|
||
item = asdict(agent)
|
||
target, relative = _safe_agent_file(agent.agent_file, agent.id)
|
||
item["agent_file"] = relative
|
||
item["agent_file_exists"] = target.is_file()
|
||
item["execution_config"] = self._execution_config(agent)
|
||
item["runtime_state"] = (self.run.get("agent_states") or {}).get(agent.id, "waiting")
|
||
agents.append(item)
|
||
return {
|
||
"agents": agents,
|
||
"definition": asdict(self.workflow),
|
||
"run": dict(self.run),
|
||
"events": [asdict(event) for event in self.events[-60:]],
|
||
"is_loading": False,
|
||
}
|
||
|
||
def read_agent_file(self, agent_id: str) -> dict[str, Any]:
|
||
with self._lock:
|
||
agent = self._require_agent(agent_id)
|
||
target, relative = _safe_agent_file(agent.agent_file, agent.id)
|
||
if not target.is_file():
|
||
return {"path": relative, "exists": False, "content": None, "reason": "Файл не найден на диске"}
|
||
return {"path": relative, "exists": True, "content": target.read_text(encoding="utf-8")}
|
||
|
||
def save_agent_file(self, agent_id: str, content: str) -> dict[str, Any]:
|
||
with self._lock:
|
||
agent = self._require_agent(agent_id)
|
||
target, relative = _safe_agent_file(agent.agent_file, agent.id)
|
||
temporary = target.with_suffix(".md.tmp")
|
||
temporary.write_text(str(content), encoding="utf-8")
|
||
temporary.replace(target)
|
||
self._event("AGENT_FILE_SAVED", f"Сохранён Agent File {relative}", agent_id=agent_id)
|
||
self._save()
|
||
return {"path": relative, "exists": True}
|
||
|
||
def create_agent(self, data: dict[str, Any]) -> AgentDefinition:
|
||
name = str(data.get("name") or "").strip()
|
||
role = _slug(str(data.get("role") or name))
|
||
agent_id = _slug(str(data.get("id") or role))
|
||
if not name:
|
||
raise ValueError("Укажите название агента")
|
||
with self._lock:
|
||
if agent_id in self.agents:
|
||
raise ValueError("Агент с таким идентификатором уже существует")
|
||
profile_id = str(data.get("account") or data.get("profile_id") or "").strip()
|
||
config = load_router_config()
|
||
if profile_id and profile_id not in config.profiles:
|
||
raise ValueError("Выбранный аккаунт отсутствует в маршрутизаторе")
|
||
config.roles[role] = RolePolicy(
|
||
role_name=role,
|
||
preferred_chain=[profile_id] if profile_id else [],
|
||
fallback_capabilities=list(data.get("fallback_capabilities") or [role]),
|
||
default_model=data.get("model") or None,
|
||
)
|
||
if not save_router_config(config):
|
||
raise OSError("Не удалось сохранить назначение агента")
|
||
target, relative = _safe_agent_file(str(data.get("agent_file") or ""), agent_id)
|
||
agent = AgentDefinition(
|
||
id=agent_id,
|
||
name=name,
|
||
role=role,
|
||
description=str(data.get("description") or ""),
|
||
agent_file=relative,
|
||
tools=[str(item) for item in data.get("tools", [])],
|
||
memory_configuration=dict(data.get("memory_configuration") or {}),
|
||
execution_policy=dict(data.get("execution_policy") or {}),
|
||
timeout=max(1, int(data.get("timeout") or 180)),
|
||
temperature=float(data["temperature"]) if data.get("temperature") is not None else None,
|
||
max_tokens=int(data["max_tokens"]) if data.get("max_tokens") is not None else None,
|
||
position=dict(data.get("position") or {"x": 80.0, "y": 80.0}),
|
||
)
|
||
self.agents[agent_id] = agent
|
||
copy_from = data.get("copy_from")
|
||
if copy_from:
|
||
source = self.read_agent_file(str(copy_from))
|
||
target.write_text(source.get("content") or "", encoding="utf-8")
|
||
else:
|
||
self._ensure_file(target, agent)
|
||
self._event("AGENT_CREATED", f"Создан агент «{name}»", agent_id=agent_id)
|
||
self._save()
|
||
return agent
|
||
|
||
def update_agent(self, agent_id: str, data: dict[str, Any]) -> AgentDefinition:
|
||
with self._lock:
|
||
agent = self._require_agent(agent_id)
|
||
config = load_router_config()
|
||
policy = config.roles.get(agent.role)
|
||
if not policy:
|
||
policy = RolePolicy(role_name=agent.role)
|
||
config.roles[agent.role] = policy
|
||
profile_id = str(data.get("account") or data.get("profile_id") or "").strip()
|
||
if profile_id:
|
||
profile = config.profiles.get(profile_id)
|
||
if not profile:
|
||
raise ValueError("Выбранный аккаунт отсутствует в маршрутизаторе")
|
||
requested_provider = str(data.get("provider") or "").strip()
|
||
if requested_provider and profile.provider != requested_provider:
|
||
raise ValueError("Аккаунт не принадлежит выбранному провайдеру")
|
||
policy.preferred_chain = [profile_id] + [item for item in policy.preferred_chain if item != profile_id]
|
||
if "model" in data:
|
||
model = str(data.get("model") or "").strip() or None
|
||
if model and profile_id:
|
||
profile = config.profiles[profile_id]
|
||
if model not in profile.preferred_models:
|
||
raise ValueError("Модель не доступна выбранному аккаунту")
|
||
policy.default_model = model
|
||
if not save_router_config(config):
|
||
raise OSError("Не удалось сохранить назначение агента")
|
||
for attr in ("name", "description"):
|
||
if attr in data:
|
||
setattr(agent, attr, str(data[attr]).strip())
|
||
for attr in ("tools", "memory_configuration", "execution_policy", "position"):
|
||
if attr in data:
|
||
setattr(agent, attr, type(getattr(agent, attr))(data[attr]))
|
||
for attr in ("timeout", "max_tokens"):
|
||
if attr in data and data[attr] is not None:
|
||
setattr(agent, attr, int(data[attr]))
|
||
if "temperature" in data:
|
||
agent.temperature = float(data["temperature"]) if data["temperature"] is not None else None
|
||
self._event("AGENT_UPDATED", f"Обновлён агент «{agent.name}»", agent_id=agent_id)
|
||
self._save()
|
||
return agent
|
||
|
||
def delete_agent(self, agent_id: str, force: bool = False) -> dict[str, Any]:
|
||
with self._lock:
|
||
agent = self._require_agent(agent_id)
|
||
edge_ids = [edge.id for edge in self.workflow.edges if edge.source == agent_id or edge.target == agent_id]
|
||
route_used = bool(load_router_config().roles.get(agent.role))
|
||
consequences = {"workflow_edges": edge_ids, "routing_role": agent.role if route_used else None}
|
||
if (edge_ids or route_used) and not force:
|
||
return {"deleted": False, "confirmation_required": True, "consequences": consequences}
|
||
config = load_router_config()
|
||
config.roles.pop(agent.role, None)
|
||
if not save_router_config(config):
|
||
raise OSError("Не удалось удалить роль из маршрутизатора")
|
||
self.workflow.edges = [edge for edge in self.workflow.edges if edge.id not in edge_ids]
|
||
self.agents.pop(agent_id)
|
||
self._event("AGENT_DELETED", f"Удалён агент «{agent.name}»", agent_id=agent_id, level="warning")
|
||
self._save()
|
||
return {"deleted": True, "consequences": consequences}
|
||
|
||
def save_workflow(self, data: dict[str, Any]) -> WorkflowDefinition:
|
||
with self._lock:
|
||
if self.run.get("status") in {"running", "stopping"}:
|
||
raise ValueError("Нельзя менять граф в режиме LIVE во время выполнения")
|
||
edges: list[WorkflowEdge] = []
|
||
seen: set[str] = set()
|
||
for raw in data.get("edges", []):
|
||
source, target = str(raw.get("source") or ""), str(raw.get("target") or "")
|
||
condition = str(raw.get("condition") or "SUCCESS").upper()
|
||
if source not in self.agents or target not in self.agents:
|
||
raise ValueError("Ребро ссылается на отсутствующего агента")
|
||
if condition not in EDGE_CONDITIONS:
|
||
raise ValueError(f"Неизвестное условие перехода: {condition}")
|
||
edge_id = str(raw.get("id") or f"edge-{uuid.uuid4().hex[:10]}")
|
||
if edge_id in seen:
|
||
raise ValueError("Идентификаторы рёбер должны быть уникальны")
|
||
seen.add(edge_id)
|
||
edges.append(WorkflowEdge(edge_id, source, target, condition, str(raw.get("label") or "")))
|
||
max_iterations = int(data.get("max_iterations") or self.workflow.max_iterations)
|
||
if not 1 <= max_iterations <= 100:
|
||
raise ValueError("Предел итераций должен быть от 1 до 100")
|
||
for raw_agent in data.get("agents", []):
|
||
agent = self.agents.get(str(raw_agent.get("id") or ""))
|
||
pos = raw_agent.get("position")
|
||
if agent and isinstance(pos, dict):
|
||
agent.position = {"x": float(pos.get("x", 0)), "y": float(pos.get("y", 0))}
|
||
self.workflow = WorkflowDefinition(
|
||
id=str(data.get("id") or self.workflow.id),
|
||
name=str(data.get("name") or self.workflow.name),
|
||
edges=edges,
|
||
max_iterations=max_iterations,
|
||
escalation_agent_id=data.get("escalation_agent_id") or None,
|
||
start_agent_id=data.get("start_agent_id") or None,
|
||
)
|
||
self._event("WORKFLOW_SAVED", f"Сохранён workflow «{self.workflow.name}»")
|
||
self._save()
|
||
return self.workflow
|
||
|
||
def start(self, task: str) -> dict[str, Any]:
|
||
task = str(task or "").strip()
|
||
if not task:
|
||
raise ValueError("Для запуска укажите реальную задачу")
|
||
with self._lock:
|
||
if self._thread and self._thread.is_alive():
|
||
raise ValueError("Workflow уже выполняется")
|
||
start_id = self.workflow.start_agent_id or (next(iter(self.agents), None))
|
||
if not start_id or start_id not in self.agents:
|
||
raise ValueError("В workflow нет стартового агента")
|
||
self._stop.clear()
|
||
self._completed_steps = []
|
||
self.run = self._idle_run()
|
||
self.run.update({
|
||
"id": uuid.uuid4().hex,
|
||
"status": "running",
|
||
"current_agent_id": start_id,
|
||
"current_task": task,
|
||
"iteration": 1,
|
||
"started_at": _utc_timestamp(),
|
||
})
|
||
self._event("WORKFLOW_STARTED", "Workflow запущен", run_id=self.run["id"], iteration=1)
|
||
self._save()
|
||
self._save_run_state("RUNNING", step_index=0, current_agent=start_id, iteration=1)
|
||
self._thread = threading.Thread(target=self._execute, name="HermesWorkflow", daemon=True)
|
||
self._thread.start()
|
||
return dict(self.run)
|
||
|
||
def stop(self) -> dict[str, Any]:
|
||
with self._lock:
|
||
if self.run.get("status") != "running":
|
||
raise ValueError("Нет выполняющегося workflow")
|
||
self.run["status"] = "stopping"
|
||
self._stop.set()
|
||
self._event("WORKFLOW_STOP_REQUESTED", "Запрошена остановка workflow", level="warning")
|
||
self._save()
|
||
self._save_run_state("STOPPED", step_index=len(self._completed_steps), current_agent=self.run.get("current_agent_id"), iteration=self.run.get("iteration", 1), interruption_reason="Остановлено пользователем")
|
||
return dict(self.run)
|
||
|
||
def _execute(self) -> None:
|
||
from antigravity_provider.router.router_engine import get_router_engine
|
||
|
||
started = time.monotonic()
|
||
context = str(self.run.get("current_task") or "")
|
||
current = str(self.run.get("current_agent_id") or "")
|
||
visited: dict[str, int] = {}
|
||
try:
|
||
engine = get_router_engine()
|
||
engine.reload_config()
|
||
while current and not self._stop.is_set():
|
||
with self._lock:
|
||
agent = self._require_agent(current)
|
||
visited[current] = visited.get(current, 0) + 1
|
||
iteration = max(visited.values())
|
||
self.run.update({"current_agent_id": current, "iteration": iteration})
|
||
step_idx = len(self._completed_steps)
|
||
if iteration > self.workflow.max_iterations:
|
||
message = f"Достигнут предел итераций: {self.workflow.max_iterations}"
|
||
self.run.update({"status": "failed", "error": message})
|
||
self._event("WORKFLOW_MAX_ITERATIONS", message, level="error", agent_id=current, iteration=iteration)
|
||
self._save_run_state("FAILED", step_index=step_idx, current_agent=current, iteration=iteration, interruption_reason=message)
|
||
break
|
||
file_data = self.read_agent_file(current)
|
||
if not file_data["exists"]:
|
||
raise FileNotFoundError(f"{file_data['path']}: {file_data['reason']}")
|
||
self.run.setdefault("agent_states", {})[current] = (
|
||
"reviewing" if "review" in agent.role.lower() else "working"
|
||
)
|
||
self._event("AGENT_STARTED", f"{agent.name} начал выполнение", agent_id=current, iteration=iteration)
|
||
self._save()
|
||
self._save_run_state("RUNNING", step_index=step_idx, current_agent=current, iteration=iteration)
|
||
request = {
|
||
"model": self._execution_config(agent).get("model"),
|
||
"messages": [
|
||
{"role": "system", "content": file_data["content"]},
|
||
{"role": "user", "content": context},
|
||
],
|
||
"timeout": agent.timeout,
|
||
"metadata": {"role": agent.role, "workflow_run_id": self.run["id"]},
|
||
}
|
||
if agent.temperature is not None:
|
||
request["temperature"] = agent.temperature
|
||
if agent.max_tokens is not None:
|
||
request["max_tokens"] = agent.max_tokens
|
||
step_started = time.monotonic()
|
||
response = engine.route_request(request, role=agent.role, session_id=self.run["id"])
|
||
duration = round(time.monotonic() - step_started, 3)
|
||
text = self._response_text(response)
|
||
status = self._result_status(response, text)
|
||
metadata = response.get("router_metadata", {}) if isinstance(response, dict) else {}
|
||
with self._lock:
|
||
self.run["last_result"] = {"status": status, "content": text, "router_metadata": metadata}
|
||
self.run.setdefault("agent_states", {})[current] = (
|
||
"error" if status in {"ERROR", "REVIEW_FAILED"} else "completed"
|
||
)
|
||
self._event(
|
||
"AGENT_COMPLETED",
|
||
f"{agent.name}: {status}",
|
||
level="success" if status not in {"ERROR", "REVIEW_FAILED"} else "warning",
|
||
agent_id=current,
|
||
iteration=iteration,
|
||
provider=metadata.get("provider"),
|
||
account=metadata.get("profile_id"),
|
||
model=metadata.get("selected_model") or (metadata.get("selection_trace") or {}).get("selected_model"),
|
||
duration_seconds=duration,
|
||
error=text if status == "ERROR" else None,
|
||
)
|
||
step_summary = {
|
||
"step_index": step_idx,
|
||
"agent_id": current,
|
||
"agent_name": agent.name,
|
||
"iteration": iteration,
|
||
"status": status,
|
||
"duration_seconds": duration,
|
||
"provider": metadata.get("provider"),
|
||
"account": metadata.get("profile_id"),
|
||
"model": metadata.get("selected_model") or (metadata.get("selection_trace") or {}).get("selected_model"),
|
||
"error": text if status in {"ERROR", "REVIEW_FAILED"} else None,
|
||
"timestamp": _utc_timestamp(),
|
||
}
|
||
edge = next(
|
||
(item for item in self.workflow.edges if item.source == current and item.condition in {status, "ALWAYS"}),
|
||
None,
|
||
)
|
||
if not edge and status not in {"ERROR", "REVIEW_FAILED"}:
|
||
edge = next(
|
||
(item for item in self.workflow.edges if item.source == current and item.condition in {"SUCCESS", "NEXT"}),
|
||
None,
|
||
)
|
||
if not edge:
|
||
self.run["status"] = "failed" if status in {"ERROR", "REVIEW_FAILED"} else "completed"
|
||
if status == "ERROR":
|
||
self.run["error"] = text or "Провайдер вернул ERROR без текста"
|
||
self._event(
|
||
"WORKFLOW_FAILED" if self.run["status"] == "failed" else "WORKFLOW_COMPLETED",
|
||
f"Workflow завершён со статусом {status}",
|
||
level="error" if self.run["status"] == "failed" else "success",
|
||
agent_id=current,
|
||
iteration=iteration,
|
||
error=self.run.get("error") if self.run["status"] == "failed" else None,
|
||
)
|
||
if status == "ERROR":
|
||
try:
|
||
from antigravity_provider.router.unified_health import EventLogService
|
||
|
||
EventLogService.get().log(
|
||
"workflow",
|
||
f"Ошибка агента «{agent.name}»",
|
||
details=self.run["error"],
|
||
level="error",
|
||
)
|
||
except Exception:
|
||
pass
|
||
self._save_run_state(
|
||
"FAILED" if self.run["status"] == "failed" else "COMPLETED",
|
||
step_index=step_idx + 1,
|
||
current_agent=current,
|
||
iteration=iteration,
|
||
completed_step=step_summary,
|
||
interruption_reason=self.run.get("error") if self.run["status"] == "failed" else None,
|
||
)
|
||
break
|
||
self._event(
|
||
"WORKFLOW_TRANSITION",
|
||
f"Переход {current} → {edge.target}: {edge.condition}",
|
||
agent_id=current,
|
||
iteration=iteration,
|
||
)
|
||
context = json.dumps({
|
||
"original_task": self.run["current_task"],
|
||
"previous_agent": current,
|
||
"structured_result": {"status": status, "content": text},
|
||
}, ensure_ascii=False)
|
||
current = edge.target
|
||
self._save()
|
||
self._save_run_state(
|
||
"RUNNING",
|
||
step_index=step_idx + 1,
|
||
current_agent=current,
|
||
iteration=iteration,
|
||
completed_step=step_summary,
|
||
)
|
||
with self._lock:
|
||
if self._stop.is_set():
|
||
self.run.update({"status": "stopped", "error": "Остановлено пользователем"})
|
||
self._event("WORKFLOW_STOPPED", "Workflow остановлен пользователем", level="warning")
|
||
self._save_run_state("STOPPED", step_index=len(self._completed_steps), current_agent=current, iteration=iteration, interruption_reason="Остановлено пользователем")
|
||
except Exception as exc:
|
||
with self._lock:
|
||
self.run.update({"status": "failed", "error": str(exc)})
|
||
self._event("PROVIDER_ERROR", "Ошибка выполнения workflow", level="error", agent_id=current, error=str(exc))
|
||
try:
|
||
from antigravity_provider.router.unified_health import EventLogService
|
||
|
||
EventLogService.get().log("workflow", "Ошибка выполнения workflow", details=str(exc), level="error")
|
||
except Exception:
|
||
pass
|
||
self._save_run_state("FAILED", step_index=len(self._completed_steps), current_agent=current, iteration=visited.get(current, 1), interruption_reason=str(exc))
|
||
finally:
|
||
with self._lock:
|
||
self.run["finished_at"] = _utc_timestamp()
|
||
self.run["elapsed_seconds"] = round(time.monotonic() - started, 3)
|
||
self.run["current_agent_id"] = current or self.run.get("current_agent_id")
|
||
self._save()
|
||
|
||
|
||
@staticmethod
|
||
def _response_text(response: Any) -> str:
|
||
if not isinstance(response, dict):
|
||
return str(response)
|
||
choices = response.get("choices")
|
||
if isinstance(choices, list) and choices:
|
||
message = choices[0].get("message", {})
|
||
return str(message.get("content") or choices[0].get("text") or "")
|
||
return str(response.get("content") or response.get("text") or response.get("output") or "")
|
||
|
||
@staticmethod
|
||
def _result_status(response: Any, text: str) -> str:
|
||
if isinstance(response, dict):
|
||
explicit = response.get("status") or response.get("structured_status")
|
||
if explicit and str(explicit).upper() in EDGE_CONDITIONS:
|
||
return str(explicit).upper()
|
||
for status in ("REVIEW_FAILED", "REVIEW_PASSED", "SUCCESS", "ERROR"):
|
||
if re.search(rf"\b{status}\b", text.upper()):
|
||
return status
|
||
return "SUCCESS"
|
||
|
||
def _event(self, event_type: str, message: str, **kwargs: Any) -> None:
|
||
self.events.append(WorkflowEvent(_utc_timestamp(), event_type, message, **kwargs))
|
||
self.events = self.events[-200:]
|
||
|
||
def _require_agent(self, agent_id: str) -> AgentDefinition:
|
||
agent = self.agents.get(agent_id)
|
||
if not agent:
|
||
raise ValueError("Агент не найден")
|
||
return agent
|
||
|
||
|
||
def execute_workflow_action(action: str, data: dict[str, Any]) -> dict[str, Any]:
|
||
"""Execute a workflow action through the shared ActionExecutor layer."""
|
||
service = WorkflowService.get()
|
||
if action == "create_agent":
|
||
result = asdict(service.create_agent(data))
|
||
elif action == "update_agent":
|
||
result = asdict(service.update_agent(str(data.get("agent_id") or ""), data))
|
||
elif action == "delete_agent":
|
||
result = service.delete_agent(str(data.get("agent_id") or ""), bool(data.get("force")))
|
||
elif action == "read_agent_file":
|
||
result = service.read_agent_file(str(data.get("agent_id") or ""))
|
||
elif action == "save_agent_file":
|
||
result = service.save_agent_file(str(data.get("agent_id") or ""), str(data.get("content") or ""))
|
||
elif action == "save_workflow":
|
||
result = asdict(service.save_workflow(data))
|
||
elif action == "start_workflow":
|
||
result = service.start(str(data.get("task") or ""))
|
||
elif action == "stop_workflow":
|
||
result = service.stop()
|
||
else:
|
||
raise ValueError("Неизвестное действие workflow")
|
||
if action in {"create_agent", "update_agent", "delete_agent"}:
|
||
try:
|
||
from antigravity_provider.router.state_store import HubStateStore
|
||
|
||
HubStateStore.get().refresh(force_scan=False)
|
||
except Exception:
|
||
pass
|
||
return {"ok": True, "message": "Выполнено", "data": result}
|
||
|
||
|
||
# Alias for execution service compatibility
|
||
WorkflowExecutionService = WorkflowService
|