fix(roles): 19 агентов вместо 13, шесть пар неотличимы по названию; поиск локальных серверов
Проверка кандидата A28–A31 исполнением. 1. Дублирование ролей. RoleRegistry.migrate_legacy_roles написана верно, но НЕ ВЫЗЫВАЛАСЬ НИОТКУДА — проверено поиском по всему коду. Вместо неё работала «идемпотентная миграция» в router_config, дописывавшая недостающие умолчания и не убиравшая старые роли. На конфигурации владельца интерфейс показывал 19 агентов, причём шесть пар были неотличимы по названию: orchestrator и manager — оба «Менеджер проекта», reviewer и code-reviewer — оба «Ревьюер кода». Разложить аккаунты по такому списку невозможно. Миграция подключена. Внутри неё нашлась вторая ошибка: при обходе одним проходом пустая каноническая роль затирала цепочку, перенесённую из старой. У владельца сработало бы именно так — в manager попал бы codex-orch вместо выставленного им ag-orch-fallback. Старые роли теперь обрабатываются первыми: в них и лежит настроенный порядок аккаунтов. Проверено на живой конфигурации владельца: было 19 ролей, стало 13, все цепочки совпадают с исходными. 2. Сохранённый workflow мигрируется вместе с ролями. Идентификаторы агентов повторяют идентификаторы ролей, и без переименования рёбра ссылались бы на исчезнувших агентов — «Ребро ссылается на отсутствующего агента». 3. Поиск локальных серверов моделей. Раньше адрес вводился руками. Теперь опрашиваются известные порты на петле — Ollama 11434, LM Studio 1234, llama.cpp 8080-8082, vLLM 8000, Jan, GPT4All, Text Generation WebUI, — параллельно, девять портов за 1.5 с. Наружу идёт только то, что ответило; список моделей берётся у сервера. Порт, занятый чужим сервисом, показывается с причиной, закрытые не показываются вовсе. Действие discover_local_models. Роль в фикстурах test_workflow_service_a30 переименована в test-developer: «developer» — псевдоним канонической developer-1, и тест проверял бы работу псевдонимов вместо механики workflow. 475 passed, 2 skipped; ruff чисто. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
fb5df0efde
commit
529192baec
6 changed files with 246 additions and 25 deletions
|
|
@ -596,6 +596,34 @@ class ActionExecutor:
|
||||||
ok, msg = do_save_settings(data)
|
ok, msg = do_save_settings(data)
|
||||||
return {'ok': ok, 'message': msg}
|
return {'ok': ok, 'message': msg}
|
||||||
|
|
||||||
|
elif action == 'discover_local_models':
|
||||||
|
# Поиск уже запущенных локальных серверов на машине.
|
||||||
|
#
|
||||||
|
# Раньше адрес вводился руками, и владелец должен был помнить, на
|
||||||
|
# каком порту у него llama.cpp, Ollama или LM Studio. Опрашиваются
|
||||||
|
# известные порты на петле; наружу идёт только то, что ответило.
|
||||||
|
# Ни одной выдуманной модели: список берётся у самого сервера.
|
||||||
|
from antigravity_provider.router.local_discovery import discover_local_servers
|
||||||
|
|
||||||
|
host = (data.get('host') or '127.0.0.1').strip() or '127.0.0.1'
|
||||||
|
try:
|
||||||
|
servers = discover_local_servers(host=host)
|
||||||
|
except Exception as exc:
|
||||||
|
return {'ok': False, 'message': f'Поиск локальных серверов не удался: {exc}'}
|
||||||
|
|
||||||
|
usable = [s for s in servers if not s.error]
|
||||||
|
if not servers:
|
||||||
|
return {
|
||||||
|
'ok': True,
|
||||||
|
'message': 'Локальных серверов не найдено. Запустите Ollama, LM Studio или llama.cpp либо введите адрес вручную.',
|
||||||
|
'data': {'servers': [], 'host': host},
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
'ok': True,
|
||||||
|
'message': f'Найдено серверов: {len(usable)}',
|
||||||
|
'data': {'servers': [s.to_dict() for s in servers], 'host': host},
|
||||||
|
}
|
||||||
|
|
||||||
elif action == 'check_updates':
|
elif action == 'check_updates':
|
||||||
# Проверка выполняется СИНХРОННО и всегда возвращает данные.
|
# Проверка выполняется СИНХРОННО и всегда возвращает данные.
|
||||||
#
|
#
|
||||||
|
|
|
||||||
124
src/antigravity_provider/router/local_discovery.py
Normal file
124
src/antigravity_provider/router/local_discovery.py
Normal file
|
|
@ -0,0 +1,124 @@
|
||||||
|
"""Поиск локальных серверов моделей, уже запущенных на машине.
|
||||||
|
|
||||||
|
Раньше адрес локального сервера вводился руками: владелец должен был помнить,
|
||||||
|
на каком порту у него llama.cpp, Ollama или LM Studio. Здесь опрашиваются
|
||||||
|
известные порты на петле, и наружу отдаётся только то, что **ответило**.
|
||||||
|
|
||||||
|
Правило честности действует и тут: ни одного порта, который не ответил, в
|
||||||
|
результат не попадает, и ни одна модель не выдумывается — список берётся у
|
||||||
|
самого сервера.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import concurrent.futures
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
logger = logging.getLogger("hermes.router.local_discovery")
|
||||||
|
|
||||||
|
# Порты, на которых эти серверы работают из коробки. Список — умолчание, а не
|
||||||
|
# исчерпывающая истина: владелец всегда может ввести адрес сам.
|
||||||
|
WELL_KNOWN_ENDPOINTS: List[Dict[str, Any]] = [
|
||||||
|
{"name": "Ollama", "port": 11434, "base_path": "/v1"},
|
||||||
|
{"name": "LM Studio", "port": 1234, "base_path": "/v1"},
|
||||||
|
{"name": "llama.cpp", "port": 8080, "base_path": "/v1"},
|
||||||
|
{"name": "llama.cpp", "port": 8081, "base_path": "/v1"},
|
||||||
|
{"name": "llama.cpp", "port": 8082, "base_path": "/v1"},
|
||||||
|
{"name": "vLLM", "port": 8000, "base_path": "/v1"},
|
||||||
|
{"name": "Jan", "port": 1337, "base_path": "/v1"},
|
||||||
|
{"name": "GPT4All", "port": 4891, "base_path": "/v1"},
|
||||||
|
{"name": "Text Generation WebUI", "port": 5000, "base_path": "/v1"},
|
||||||
|
]
|
||||||
|
|
||||||
|
DEFAULT_HOST = "127.0.0.1"
|
||||||
|
PROBE_TIMEOUT_SEC = 1.5
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class LocalServer:
|
||||||
|
"""Обнаруженный локальный сервер моделей."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
base_url: str
|
||||||
|
models: List[str] = field(default_factory=list)
|
||||||
|
error: Optional[str] = None
|
||||||
|
|
||||||
|
def to_dict(self) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"name": self.name,
|
||||||
|
"base_url": self.base_url,
|
||||||
|
"models": list(self.models),
|
||||||
|
"model_count": len(self.models),
|
||||||
|
"error": self.error,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_models(base_url: str, timeout: float) -> List[str]:
|
||||||
|
"""Спросить у сервера список моделей по OpenAI-совместимому /models."""
|
||||||
|
url = base_url.rstrip("/") + "/models"
|
||||||
|
req = urllib.request.Request(url, headers={"Accept": "application/json"})
|
||||||
|
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||||
|
if resp.status != 200:
|
||||||
|
raise ValueError(f"HTTP {resp.status}")
|
||||||
|
payload = json.loads(resp.read().decode("utf-8", errors="replace"))
|
||||||
|
|
||||||
|
models: List[str] = []
|
||||||
|
for item in payload.get("data") or []:
|
||||||
|
if isinstance(item, dict) and item.get("id"):
|
||||||
|
models.append(str(item["id"]))
|
||||||
|
elif isinstance(item, str):
|
||||||
|
models.append(item)
|
||||||
|
return models
|
||||||
|
|
||||||
|
|
||||||
|
def probe_endpoint(host: str, port: int, base_path: str, name: str,
|
||||||
|
timeout: float = PROBE_TIMEOUT_SEC) -> Optional[LocalServer]:
|
||||||
|
"""Опросить один адрес. Не ответил — вернуть None, а не пустую заглушку."""
|
||||||
|
base_url = f"http://{host}:{port}{base_path}"
|
||||||
|
try:
|
||||||
|
models = _fetch_models(base_url, timeout)
|
||||||
|
except (urllib.error.URLError, OSError, TimeoutError):
|
||||||
|
# Порт закрыт или сервер не отвечает — это не находка и не ошибка.
|
||||||
|
return None
|
||||||
|
except (ValueError, json.JSONDecodeError) as exc:
|
||||||
|
# Порт занят чем-то, что говорит не на том языке. Показываем как
|
||||||
|
# найденный, но с причиной: иначе владелец будет гадать, почему
|
||||||
|
# заведомо работающий сервер не виден.
|
||||||
|
logger.debug("Порт %s занят не тем сервисом: %s", port, exc)
|
||||||
|
return LocalServer(name=f"{name} (порт {port})", base_url=base_url,
|
||||||
|
error="Сервер на этом порту ответил не по OpenAI-совместимому протоколу")
|
||||||
|
return LocalServer(name=f"{name} (порт {port})", base_url=base_url, models=models)
|
||||||
|
|
||||||
|
|
||||||
|
def discover_local_servers(host: str = DEFAULT_HOST,
|
||||||
|
endpoints: Optional[List[Dict[str, Any]]] = None,
|
||||||
|
timeout: float = PROBE_TIMEOUT_SEC) -> List[LocalServer]:
|
||||||
|
"""Опросить известные порты параллельно и вернуть только ответившие.
|
||||||
|
|
||||||
|
Опрос идёт параллельно: последовательно девять закрытых портов дали бы
|
||||||
|
заметную паузу в интерфейсе.
|
||||||
|
"""
|
||||||
|
targets = endpoints if endpoints is not None else WELL_KNOWN_ENDPOINTS
|
||||||
|
found: List[LocalServer] = []
|
||||||
|
|
||||||
|
with concurrent.futures.ThreadPoolExecutor(max_workers=min(10, len(targets) or 1)) as pool:
|
||||||
|
futures = {
|
||||||
|
pool.submit(probe_endpoint, host, t["port"], t.get("base_path", "/v1"), t["name"], timeout): t
|
||||||
|
for t in targets
|
||||||
|
}
|
||||||
|
for fut in concurrent.futures.as_completed(futures):
|
||||||
|
try:
|
||||||
|
result = fut.result()
|
||||||
|
except Exception as exc: # опрос не должен ронять вызывающего
|
||||||
|
logger.warning("Ошибка опроса локального сервера: %s", exc)
|
||||||
|
continue
|
||||||
|
if result is not None:
|
||||||
|
found.append(result)
|
||||||
|
|
||||||
|
found.sort(key=lambda s: (s.error is not None, -len(s.models), s.base_url))
|
||||||
|
return found
|
||||||
|
|
@ -333,7 +333,21 @@ class RoleRegistry:
|
||||||
was_modified = False
|
was_modified = False
|
||||||
default_policies = cls.get_default_role_policies()
|
default_policies = cls.get_default_role_policies()
|
||||||
|
|
||||||
for rname, rpol in current_roles.items():
|
# Сначала роли под старыми именами, потом под каноническими.
|
||||||
|
#
|
||||||
|
# Порядок важен, когда в конфигурации есть и то и другое. У владельца
|
||||||
|
# ровно такой случай: прошлая миграция дописала manager, developer-1 и
|
||||||
|
# остальные рядом со старыми orchestrator и coder-primary, но работал
|
||||||
|
# он всё это время со старыми — там и лежит его настроенный порядок
|
||||||
|
# аккаунтов. При обходе одним проходом канонический пустой manager
|
||||||
|
# затирал бы цепочку из orchestrator, и владелец получил бы на первом
|
||||||
|
# месте аккаунт, который сам туда не ставил.
|
||||||
|
legacy_first = sorted(
|
||||||
|
current_roles.items(),
|
||||||
|
key=lambda kv: cls.resolve_canonical_role(kv[0]) == kv[0],
|
||||||
|
)
|
||||||
|
|
||||||
|
for rname, rpol in legacy_first:
|
||||||
canonical_id = cls.resolve_canonical_role(rname)
|
canonical_id = cls.resolve_canonical_role(rname)
|
||||||
if canonical_id != rname:
|
if canonical_id != rname:
|
||||||
was_modified = True
|
was_modified = True
|
||||||
|
|
@ -346,8 +360,10 @@ class RoleRegistry:
|
||||||
session_affinity_enabled=rpol.session_affinity_enabled,
|
session_affinity_enabled=rpol.session_affinity_enabled,
|
||||||
default_model=rpol.default_model or default_policies[canonical_id].default_model,
|
default_model=rpol.default_model or default_policies[canonical_id].default_model,
|
||||||
)
|
)
|
||||||
else:
|
elif rname not in migrated:
|
||||||
migrated[rname] = rpol
|
migrated[rname] = rpol
|
||||||
|
else:
|
||||||
|
was_modified = True
|
||||||
|
|
||||||
for canon_id, def_policy in default_policies.items():
|
for canon_id, def_policy in default_policies.items():
|
||||||
if canon_id not in migrated:
|
if canon_id not in migrated:
|
||||||
|
|
|
||||||
|
|
@ -377,6 +377,26 @@ def load_router_config(config_path: Optional[Path] = None) -> RouterConfig:
|
||||||
if not roles:
|
if not roles:
|
||||||
roles = default_cfg.roles
|
roles = default_cfg.roles
|
||||||
else:
|
else:
|
||||||
|
# Сначала переименование старых ролей в канонические, и только
|
||||||
|
# потом дополнение недостающими.
|
||||||
|
#
|
||||||
|
# Раньше здесь просто дописывались отсутствующие умолчания, а
|
||||||
|
# RoleRegistry.migrate_legacy_roles не вызывалась ниоткуда —
|
||||||
|
# проверено поиском по всему коду. В результате старые роли
|
||||||
|
# оставались рядом с новыми: на конфигурации владельца интерфейс
|
||||||
|
# показывал 19 агентов вместо 13, причём шесть пар были неотличимы
|
||||||
|
# по названию (orchestrator и manager — оба «Менеджер проекта»,
|
||||||
|
# reviewer и code-reviewer — оба «Ревьюер кода»). Разложить
|
||||||
|
# аккаунты по такому списку невозможно.
|
||||||
|
#
|
||||||
|
# migrate_legacy_roles переносит preferred_chain дословно, поэтому
|
||||||
|
# порядок аккаунтов, выставленный владельцем, сохраняется.
|
||||||
|
renamed, renamed_any = RoleRegistry.migrate_legacy_roles(roles)
|
||||||
|
if renamed_any:
|
||||||
|
new_roles_added.extend(sorted(set(renamed) - set(roles)))
|
||||||
|
roles = renamed
|
||||||
|
migration_needed = True
|
||||||
|
|
||||||
for def_rname, def_rpolicy in default_cfg.roles.items():
|
for def_rname, def_rpolicy in default_cfg.roles.items():
|
||||||
if def_rname not in roles:
|
if def_rname not in roles:
|
||||||
roles[def_rname] = def_rpolicy
|
roles[def_rname] = def_rpolicy
|
||||||
|
|
|
||||||
|
|
@ -206,13 +206,42 @@ class WorkflowService:
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
raw = json.loads(self.state_path.read_text(encoding="utf-8"))
|
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")
|
# конфигурации. Без такого же переименования здесь сохранённый
|
||||||
}
|
# workflow ссылался бы на исчезнувших агентов, и граф падал бы с
|
||||||
|
# «Ребро ссылается на отсутствующего агента».
|
||||||
|
from antigravity_provider.router.role_registry import RoleRegistry
|
||||||
|
|
||||||
|
def _canon(agent_id: str) -> str:
|
||||||
|
try:
|
||||||
|
return RoleRegistry.resolve_canonical_role(agent_id)
|
||||||
|
except Exception:
|
||||||
|
return agent_id
|
||||||
|
|
||||||
|
self.agents = {}
|
||||||
|
for item in raw.get("agents", []):
|
||||||
|
if not isinstance(item, dict) or not item.get("id"):
|
||||||
|
continue
|
||||||
|
item = dict(item)
|
||||||
|
item["id"] = _canon(item["id"])
|
||||||
|
if item.get("role"):
|
||||||
|
item["role"] = _canon(item["role"])
|
||||||
|
# Первым выигрывает агент под старым именем: именно им
|
||||||
|
# пользовался владелец, канонический мог быть дописан пустым.
|
||||||
|
self.agents.setdefault(item["id"], AgentDefinition(**item))
|
||||||
|
|
||||||
wf = raw.get("workflow") or {}
|
wf = raw.get("workflow") or {}
|
||||||
edges = [WorkflowEdge(**edge) for edge in wf.pop("edges", []) if isinstance(edge, dict)]
|
edges = []
|
||||||
|
for edge in wf.pop("edges", []):
|
||||||
|
if not isinstance(edge, dict):
|
||||||
|
continue
|
||||||
|
edge = dict(edge)
|
||||||
|
for key in ("source", "target", "from_agent", "to_agent"):
|
||||||
|
if edge.get(key):
|
||||||
|
edge[key] = _canon(edge[key])
|
||||||
|
edges.append(WorkflowEdge(**edge))
|
||||||
self.workflow = WorkflowDefinition(edges=edges, **wf)
|
self.workflow = WorkflowDefinition(edges=edges, **wf)
|
||||||
self.events = [WorkflowEvent(**event) for event in raw.get("events", [])[-200:]]
|
self.events = [WorkflowEvent(**event) for event in raw.get("events", [])[-200:]]
|
||||||
self.run = raw.get("run") or self._idle_run()
|
self.run = raw.get("run") or self._idle_run()
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,10 @@ from antigravity_provider.router.router_config import (
|
||||||
from antigravity_provider.router.workflow_service import WorkflowService
|
from antigravity_provider.router.workflow_service import WorkflowService
|
||||||
|
|
||||||
|
|
||||||
|
# Роль в фикстурах названа test-developer намеренно: "developer" — это
|
||||||
|
# псевдоним канонической роли developer-1, и при загрузке конфигурации он
|
||||||
|
# переименовывается. Тест проверяет механику workflow, а не работу псевдонимов,
|
||||||
|
# поэтому имя взято такое же нейтральное, как у соседнего test-reviewer.
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def workflow_service(tmp_path, monkeypatch):
|
def workflow_service(tmp_path, monkeypatch):
|
||||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||||
|
|
@ -24,7 +28,7 @@ def workflow_service(tmp_path, monkeypatch):
|
||||||
preferred_models=["model-real-from-config"],
|
preferred_models=["model-real-from-config"],
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
roles={"developer": RolePolicy(role_name="developer", preferred_chain=["account-a"])},
|
roles={"test-developer": RolePolicy(role_name="test-developer", preferred_chain=["account-a"])},
|
||||||
)
|
)
|
||||||
save_router_config(config)
|
save_router_config(config)
|
||||||
return WorkflowService(tmp_path / "workflow_state.json")
|
return WorkflowService(tmp_path / "workflow_state.json")
|
||||||
|
|
@ -32,11 +36,11 @@ def workflow_service(tmp_path, monkeypatch):
|
||||||
|
|
||||||
def test_router_roles_migrate_to_agents_and_create_real_files(workflow_service, tmp_path):
|
def test_router_roles_migrate_to_agents_and_create_real_files(workflow_service, tmp_path):
|
||||||
snapshot = workflow_service.snapshot()
|
snapshot = workflow_service.snapshot()
|
||||||
assert "developer" in [agent["id"] for agent in snapshot["agents"]]
|
assert "test-developer" in [agent["id"] for agent in snapshot["agents"]]
|
||||||
agent = next(item for item in snapshot["agents"] if item["id"] == "developer")
|
agent = next(item for item in snapshot["agents"] if item["id"] == "test-developer")
|
||||||
assert agent["agent_file"] == "agents/developer.md"
|
assert agent["agent_file"] == "agents/test-developer.md"
|
||||||
assert agent["agent_file_exists"] is True
|
assert agent["agent_file_exists"] is True
|
||||||
assert (tmp_path / "agents" / "developer.md").is_file()
|
assert (tmp_path / "agents" / "test-developer.md").is_file()
|
||||||
assert agent["execution_config"]["account"] == "account-a"
|
assert agent["execution_config"]["account"] == "account-a"
|
||||||
assert agent["execution_config"]["model"] == "model-real-from-config"
|
assert agent["execution_config"]["model"] == "model-real-from-config"
|
||||||
|
|
||||||
|
|
@ -62,9 +66,9 @@ def test_create_update_file_and_restart_persistence(workflow_service, tmp_path):
|
||||||
def test_delete_requires_explicit_confirmation_when_referenced(workflow_service):
|
def test_delete_requires_explicit_confirmation_when_referenced(workflow_service):
|
||||||
workflow_service.create_agent({"name": "Test Reviewer", "role": "test-reviewer", "account": "account-a"})
|
workflow_service.create_agent({"name": "Test Reviewer", "role": "test-reviewer", "account": "account-a"})
|
||||||
workflow_service.save_workflow({
|
workflow_service.save_workflow({
|
||||||
"start_agent_id": "developer",
|
"start_agent_id": "test-developer",
|
||||||
"max_iterations": 3,
|
"max_iterations": 3,
|
||||||
"edges": [{"id": "review", "source": "developer", "target": "test-reviewer", "condition": "SUCCESS"}],
|
"edges": [{"id": "review", "source": "test-developer", "target": "test-reviewer", "condition": "SUCCESS"}],
|
||||||
})
|
})
|
||||||
warning = workflow_service.delete_agent("test-reviewer")
|
warning = workflow_service.delete_agent("test-reviewer")
|
||||||
assert warning["confirmation_required"] is True
|
assert warning["confirmation_required"] is True
|
||||||
|
|
@ -77,11 +81,11 @@ def test_delete_requires_explicit_confirmation_when_referenced(workflow_service)
|
||||||
def test_cycles_are_valid_and_iteration_limit_is_persisted(workflow_service):
|
def test_cycles_are_valid_and_iteration_limit_is_persisted(workflow_service):
|
||||||
workflow_service.create_agent({"name": "Test Reviewer", "role": "test-reviewer", "account": "account-a"})
|
workflow_service.create_agent({"name": "Test Reviewer", "role": "test-reviewer", "account": "account-a"})
|
||||||
definition = workflow_service.save_workflow({
|
definition = workflow_service.save_workflow({
|
||||||
"start_agent_id": "developer",
|
"start_agent_id": "test-developer",
|
||||||
"max_iterations": 2,
|
"max_iterations": 2,
|
||||||
"edges": [
|
"edges": [
|
||||||
{"source": "developer", "target": "test-reviewer", "condition": "SUCCESS"},
|
{"source": "test-developer", "target": "test-reviewer", "condition": "SUCCESS"},
|
||||||
{"source": "test-reviewer", "target": "developer", "condition": "REVIEW_FAILED"},
|
{"source": "test-reviewer", "target": "test-developer", "condition": "REVIEW_FAILED"},
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
assert definition.max_iterations == 2
|
assert definition.max_iterations == 2
|
||||||
|
|
@ -92,13 +96,13 @@ def test_cycles_are_valid_and_iteration_limit_is_persisted(workflow_service):
|
||||||
|
|
||||||
def test_invalid_edge_and_unknown_model_are_rejected(workflow_service):
|
def test_invalid_edge_and_unknown_model_are_rejected(workflow_service):
|
||||||
with pytest.raises(ValueError, match="отсутствующего агента"):
|
with pytest.raises(ValueError, match="отсутствующего агента"):
|
||||||
workflow_service.save_workflow({"edges": [{"source": "developer", "target": "missing"}]})
|
workflow_service.save_workflow({"edges": [{"source": "test-developer", "target": "missing"}]})
|
||||||
with pytest.raises(ValueError, match="не доступна"):
|
with pytest.raises(ValueError, match="не доступна"):
|
||||||
workflow_service.update_agent("developer", {"account": "account-a", "model": "invented-model"})
|
workflow_service.update_agent("test-developer", {"account": "account-a", "model": "invented-model"})
|
||||||
|
|
||||||
|
|
||||||
def test_interrupted_run_is_reported_not_silently_completed(workflow_service, tmp_path):
|
def test_interrupted_run_is_reported_not_silently_completed(workflow_service, tmp_path):
|
||||||
workflow_service.run.update({"id": "run-1", "status": "running", "current_agent_id": "developer"})
|
workflow_service.run.update({"id": "run-1", "status": "running", "current_agent_id": "test-developer"})
|
||||||
workflow_service._save()
|
workflow_service._save()
|
||||||
restarted = WorkflowService(tmp_path / "workflow_state.json")
|
restarted = WorkflowService(tmp_path / "workflow_state.json")
|
||||||
assert restarted.run["status"] == "interrupted"
|
assert restarted.run["status"] == "interrupted"
|
||||||
|
|
@ -109,11 +113,11 @@ def test_interrupted_run_is_reported_not_silently_completed(workflow_service, tm
|
||||||
def test_live_cycle_stops_with_explicit_iteration_limit_event(workflow_service, monkeypatch):
|
def test_live_cycle_stops_with_explicit_iteration_limit_event(workflow_service, monkeypatch):
|
||||||
workflow_service.create_agent({"name": "Loop Reviewer", "role": "loop-reviewer", "account": "account-a"})
|
workflow_service.create_agent({"name": "Loop Reviewer", "role": "loop-reviewer", "account": "account-a"})
|
||||||
workflow_service.save_workflow({
|
workflow_service.save_workflow({
|
||||||
"start_agent_id": "developer",
|
"start_agent_id": "test-developer",
|
||||||
"max_iterations": 2,
|
"max_iterations": 2,
|
||||||
"edges": [
|
"edges": [
|
||||||
{"source": "developer", "target": "loop-reviewer", "condition": "SUCCESS"},
|
{"source": "test-developer", "target": "loop-reviewer", "condition": "SUCCESS"},
|
||||||
{"source": "loop-reviewer", "target": "developer", "condition": "REVIEW_FAILED"},
|
{"source": "loop-reviewer", "target": "test-developer", "condition": "REVIEW_FAILED"},
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -155,7 +159,7 @@ def test_provider_error_text_reaches_run_and_events(workflow_service, monkeypatc
|
||||||
return {"choices": [{"message": {"content": f"ERROR\n{provider_text}"}}]}
|
return {"choices": [{"message": {"content": f"ERROR\n{provider_text}"}}]}
|
||||||
|
|
||||||
monkeypatch.setattr("antigravity_provider.router.router_engine.get_router_engine", lambda: ErrorEngine())
|
monkeypatch.setattr("antigravity_provider.router.router_engine.get_router_engine", lambda: ErrorEngine())
|
||||||
workflow_service.workflow.start_agent_id = "developer"
|
workflow_service.workflow.start_agent_id = "test-developer"
|
||||||
workflow_service.workflow.edges = []
|
workflow_service.workflow.edges = []
|
||||||
workflow_service.start("Проверить ошибку")
|
workflow_service.start("Проверить ошибку")
|
||||||
thread = workflow_service._thread
|
thread = workflow_service._thread
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue