Migrate all agent roles from Legacy to EvoAgent architecture: - fundamentals_analyst, technical_analyst, sentiment_analyst, valuation_analyst - risk_manager, portfolio_manager Key changes: - EvoAgent now supports Portfolio Manager compatibility methods (_make_decision, get_decisions, get_portfolio_state, load_portfolio_state, update_portfolio) - Add UnifiedAgentFactory for centralized agent creation - ToolGuard with batch approval API and WebSocket broadcast - Legacy agents marked deprecated (AnalystAgent, RiskAgent, PMAgent) - Remove backend/agents/compat.py migration shim - Add run_id alongside workspace_id for semantic clarity - Complete integration test coverage (13 tests) - All smoke tests passing for 6 agent roles Constraint: Must maintain backward compatibility with existing run configs Constraint: Memory support must work with EvoAgent (no fallback to Legacy) Rejected: Separate PM implementation for EvoAgent | unified approach cleaner Confidence: high Scope-risk: broad Directive: EVO_AGENT_IDS env var still respected but defaults to all roles Not-tested: Kubernetes sandbox mode for skill execution
291 lines
9.0 KiB
Python
291 lines
9.0 KiB
Python
#!/usr/bin/env python3
|
|
"""Smoke-test the EvoAgent runtime rollout path.
|
|
|
|
This script validates the current staged rollout shape:
|
|
- start runtime via backend.api.runtime
|
|
- confirm the gateway starts on an available port
|
|
- confirm the gateway log shows the selected agent running as EvoAgent
|
|
- confirm runtime_state.json is written
|
|
- confirm guard approval API logic wakes a pending ToolApprovalRequest
|
|
|
|
It intentionally avoids browser/front-end dependencies and does not require
|
|
local HTTP callbacks.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import asyncio
|
|
import json
|
|
import os
|
|
import sys
|
|
import time
|
|
from pathlib import Path
|
|
|
|
import websocket
|
|
|
|
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
|
PYDEPS = PROJECT_ROOT / ".pydeps"
|
|
|
|
_reordered_sys_path = [
|
|
str(PROJECT_ROOT),
|
|
str(PYDEPS),
|
|
]
|
|
for entry in list(sys.path):
|
|
if entry in _reordered_sys_path:
|
|
continue
|
|
_reordered_sys_path.append(entry)
|
|
sys.path[:] = _reordered_sys_path
|
|
|
|
from fastapi import BackgroundTasks
|
|
|
|
from backend.agents.base.tool_guard import TOOL_GUARD_STORE, ToolApprovalRequest
|
|
from backend.api.guard import ApprovalRequest, approve_tool_call
|
|
from backend.api.runtime import (
|
|
LaunchConfig,
|
|
_is_gateway_running,
|
|
get_runtime_state,
|
|
start_runtime,
|
|
stop_runtime,
|
|
)
|
|
|
|
|
|
# All 6 agent roles supported by EvoAgent
|
|
ALL_EVO_AGENT_ROLES = [
|
|
"fundamentals_analyst",
|
|
"technical_analyst",
|
|
"sentiment_analyst",
|
|
"valuation_analyst",
|
|
"risk_manager",
|
|
"portfolio_manager",
|
|
]
|
|
|
|
|
|
def _parse_args() -> argparse.Namespace:
|
|
parser = argparse.ArgumentParser(
|
|
description="Smoke-test the staged EvoAgent runtime rollout.",
|
|
)
|
|
parser.add_argument(
|
|
"--agent-id",
|
|
default="fundamentals_analyst",
|
|
help="Agent id to enable via EVO_AGENT_IDS (use 'all' to test all 6 roles)",
|
|
)
|
|
parser.add_argument(
|
|
"--ticker",
|
|
default="AAPL",
|
|
help="Ticker to include in the smoke runtime bootstrap",
|
|
)
|
|
parser.add_argument(
|
|
"--max-wait-seconds",
|
|
type=float,
|
|
default=15.0,
|
|
help="Maximum time to wait for gateway.log to appear",
|
|
)
|
|
parser.add_argument(
|
|
"--test-all-roles",
|
|
action="store_true",
|
|
help="Test all 6 EvoAgent roles sequentially",
|
|
)
|
|
return parser.parse_args()
|
|
|
|
|
|
def _wait_for_file(path: Path, timeout_seconds: float) -> None:
|
|
deadline = time.time() + timeout_seconds
|
|
while time.time() < deadline:
|
|
if path.exists():
|
|
return
|
|
time.sleep(0.2)
|
|
raise TimeoutError(f"Timed out waiting for file: {path}")
|
|
|
|
|
|
def _wait_for_initial_state(gateway_port: int, timeout_seconds: float) -> dict[str, object]:
|
|
deadline = time.time() + timeout_seconds
|
|
last_error: Exception | None = None
|
|
while time.time() < deadline:
|
|
try:
|
|
ws = websocket.create_connection(
|
|
f"ws://127.0.0.1:{gateway_port}",
|
|
timeout=3,
|
|
)
|
|
try:
|
|
payload = json.loads(ws.recv())
|
|
return payload
|
|
finally:
|
|
ws.close()
|
|
except Exception as exc: # pragma: no cover - best-effort smoke polling
|
|
last_error = exc
|
|
time.sleep(0.2)
|
|
raise TimeoutError(
|
|
f"Timed out waiting for gateway initial_state on port {gateway_port}: {last_error}"
|
|
)
|
|
|
|
|
|
async def _run_smoke(agent_id: str, ticker: str, max_wait_seconds: float) -> dict[str, object]:
|
|
previous_env = os.environ.get("EVO_AGENT_IDS")
|
|
os.environ["EVO_AGENT_IDS"] = agent_id
|
|
|
|
try:
|
|
if _is_gateway_running():
|
|
await stop_runtime(force=True)
|
|
|
|
response = await start_runtime(
|
|
LaunchConfig(
|
|
launch_mode="fresh",
|
|
tickers=[ticker],
|
|
schedule_mode="daily",
|
|
interval_minutes=60,
|
|
trigger_time="09:30",
|
|
max_comm_cycles=1,
|
|
initial_cash=100000.0,
|
|
margin_requirement=0.0,
|
|
enable_memory=False,
|
|
mode="backtest",
|
|
start_date="2025-11-01",
|
|
end_date="2025-11-30",
|
|
poll_interval=30,
|
|
),
|
|
BackgroundTasks(),
|
|
)
|
|
|
|
run_dir = Path(response.run_dir)
|
|
log_path = run_dir / "logs" / "gateway.log"
|
|
state_path = run_dir / "state" / "runtime_state.json"
|
|
|
|
_wait_for_file(log_path, max_wait_seconds)
|
|
_wait_for_file(state_path, max_wait_seconds)
|
|
initial_state_payload = _wait_for_initial_state(
|
|
response.gateway_port,
|
|
max_wait_seconds,
|
|
)
|
|
|
|
log_text = log_path.read_text(encoding="utf-8")
|
|
state = json.loads(state_path.read_text(encoding="utf-8"))
|
|
|
|
record = TOOL_GUARD_STORE.create_pending(
|
|
tool_name="write_file",
|
|
tool_input={"path": "smoke.txt"},
|
|
agent_id=agent_id,
|
|
workspace_id=response.run_id,
|
|
)
|
|
pending = ToolApprovalRequest(
|
|
approval_id=record.approval_id,
|
|
tool_name=record.tool_name,
|
|
tool_input=record.tool_input,
|
|
tool_call_id="smoke_call",
|
|
)
|
|
record.pending_request = pending
|
|
await approve_tool_call(
|
|
ApprovalRequest(
|
|
approval_id=record.approval_id,
|
|
one_time=True,
|
|
expires_in_minutes=30,
|
|
)
|
|
)
|
|
|
|
result = {
|
|
"run_id": response.run_id,
|
|
"gateway_port": response.gateway_port,
|
|
"gateway_running": _is_gateway_running(),
|
|
"runtime_manager": get_runtime_state().runtime_manager is not None,
|
|
"evo_log_present": f"EvoAgent initialized: {agent_id}" in log_text,
|
|
"runtime_state_written": state_path.exists(),
|
|
"registered_agents": [item["agent_id"] for item in state.get("agents", [])],
|
|
"pending_request_approved": pending.approved is True,
|
|
"ws_initial_type": initial_state_payload.get("type"),
|
|
"ws_initial_tickers": (
|
|
(initial_state_payload.get("state") or {}).get("tickers") or []
|
|
),
|
|
}
|
|
return result
|
|
finally:
|
|
if _is_gateway_running():
|
|
await stop_runtime(force=True)
|
|
if previous_env is None:
|
|
os.environ.pop("EVO_AGENT_IDS", None)
|
|
else:
|
|
os.environ["EVO_AGENT_IDS"] = previous_env
|
|
|
|
|
|
def _verify_skills_loaded(log_text: str, agent_id: str) -> dict[str, bool]:
|
|
"""Verify that skills were loaded for the agent."""
|
|
return {
|
|
"skills_loaded": f"Loading skills for {agent_id}" in log_text or "skills" in log_text.lower(),
|
|
"tools_registered": "tool" in log_text.lower(),
|
|
}
|
|
|
|
|
|
async def _run_smoke_for_role(role: str, ticker: str, max_wait_seconds: float) -> dict[str, object]:
|
|
"""Run smoke test for a single agent role."""
|
|
print(f"\n>>> Testing EvoAgent role: {role}", file=sys.stderr)
|
|
result = await _run_smoke(
|
|
agent_id=role,
|
|
ticker=ticker,
|
|
max_wait_seconds=max_wait_seconds,
|
|
)
|
|
result["agent_role"] = role
|
|
result["success"] = (
|
|
result.get("evo_log_present", False)
|
|
and result.get("runtime_state_written", False)
|
|
and result.get("pending_request_approved", False)
|
|
)
|
|
return result
|
|
|
|
|
|
def main() -> int:
|
|
args = _parse_args()
|
|
|
|
if args.test_all_roles:
|
|
# Test all 6 agent roles
|
|
results = []
|
|
all_passed = True
|
|
|
|
for role in ALL_EVO_AGENT_ROLES:
|
|
try:
|
|
result = asyncio.run(
|
|
_run_smoke_for_role(
|
|
role=role,
|
|
ticker=args.ticker,
|
|
max_wait_seconds=args.max_wait_seconds,
|
|
)
|
|
)
|
|
results.append(result)
|
|
if not result.get("success", False):
|
|
all_passed = False
|
|
print(f" FAILED: {role}", file=sys.stderr)
|
|
else:
|
|
print(f" PASSED: {role}", file=sys.stderr)
|
|
except Exception as e:
|
|
all_passed = False
|
|
print(f" ERROR: {role} - {e}", file=sys.stderr)
|
|
results.append({
|
|
"agent_role": role,
|
|
"success": False,
|
|
"error": str(e),
|
|
})
|
|
|
|
summary = {
|
|
"test_mode": "all_roles",
|
|
"total_roles": len(ALL_EVO_AGENT_ROLES),
|
|
"passed": sum(1 for r in results if r.get("success", False)),
|
|
"failed": sum(1 for r in results if not r.get("success", False)),
|
|
"all_passed": all_passed,
|
|
"results": results,
|
|
}
|
|
print(json.dumps(summary, ensure_ascii=False, indent=2))
|
|
return 0 if all_passed else 1
|
|
else:
|
|
# Test single agent
|
|
result = asyncio.run(
|
|
_run_smoke(
|
|
agent_id=args.agent_id,
|
|
ticker=args.ticker,
|
|
max_wait_seconds=args.max_wait_seconds,
|
|
)
|
|
)
|
|
print(json.dumps(result, ensure_ascii=False, indent=2))
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|