Files
evotraders/scripts/smoke_evo_runtime.py
cillin 16b54d5ccc feat(agent): complete EvoAgent integration for all 6 agent roles
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
2026-04-02 00:55:08 +08:00

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())