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
This commit is contained in:
2026-04-02 00:55:08 +08:00
parent 0fa413380c
commit 16b54d5ccc
73 changed files with 9454 additions and 904 deletions

View File

@@ -1,5 +1,21 @@
# -*- coding: utf-8 -*-
"""OpenClaw WebSocket handlers — gateway calls OpenClaw Gateway via WebSocket."""
"""OpenClaw WebSocket handlers — gateway calls OpenClaw Gateway via WebSocket.
COMPATIBILITY_SURFACE: deferred
OWNER: runtime-team
SEE: docs/legacy-inventory.md#openclaw-dual-integration
This is the WebSocket gateway integration for OpenClaw (port 18789).
For the REST facade, see:
- backend/apps/openclaw_service.py (port 8004)
- backend/api/openclaw.py
Key differences:
- WebSocket: event-driven, real-time updates, bidirectional
- REST facade: typed Pydantic models, request/response, polling
Decision needed: which surface becomes the long-term contract?
"""
from __future__ import annotations

View File

@@ -6,6 +6,7 @@ Handles reading/writing dashboard JSON files and portfolio state
# pylint: disable=R0904
import json
import logging
import os
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, List, Optional
@@ -21,25 +22,31 @@ class StorageService:
Storage service for data persistence
Responsibilities:
1. Export dashboard JSON files
1. Export dashboard JSON files (compatibility layer)
(summary, holdings, stats, trades, leaderboard)
2. Load/save internal state (_internal_state.json)
3. Load/save server state (server_state.json) with feed history
4. Manage portfolio state persistence
5. Support loading from saved state to resume execution
Notes:
- team_dashboard/*.json is treated as an export/compatibility layer
rather than the authoritative runtime source of truth.
- authoritative runtime reads should prefer in-memory state, server_state,
runtime.db, and market_research.db.
Architecture Notes:
- runs/<run_id>/ is the authoritative runtime state root
- team_dashboard/*.json is a NON-AUTHORITATIVE export/compatibility layer
for external consumers (frontend, reports, etc.)
- Authoritative runtime reads should prefer:
1. In-memory state (runtime manager)
2. state/server_state.json
3. state/runtime.db
4. market_research.db
- Compatibility exports can be disabled via ENABLE_DASHBOARD_COMPAT_EXPORTS=false
"""
def __init__(
self,
dashboard_dir: Path,
initial_cash: float = 100000.0,
config_name: str = "live",
config_name: str = "runtime",
enable_compat_exports: Optional[bool] = None,
):
"""
Initialize storage service
@@ -47,12 +54,18 @@ class StorageService:
Args:
dashboard_dir: Directory for dashboard files
initial_cash: Initial cash amount
config_name: Configuration name for state directory
config_name: Logical runtime config/run label for state directory context
enable_compat_exports: Whether to keep writing team_dashboard/*.json
"""
self.dashboard_dir = Path(dashboard_dir)
self.dashboard_dir.mkdir(parents=True, exist_ok=True)
self.initial_cash = initial_cash
self.config_name = config_name
self.enable_compat_exports = (
self._resolve_compat_exports_default()
if enable_compat_exports is None
else bool(enable_compat_exports)
)
# Dashboard export file paths
self.files = {
@@ -88,6 +101,12 @@ class StorageService:
logger.info(f"Storage service initialized: {self.dashboard_dir}")
@staticmethod
def _resolve_compat_exports_default() -> bool:
"""Default compatibility export policy, overridable via env."""
raw = str(os.getenv("ENABLE_DASHBOARD_COMPAT_EXPORTS", "true")).strip().lower()
return raw not in {"0", "false", "no", "off"}
def load_export_file(self, file_type: str) -> Optional[Any]:
"""Load dashboard export JSON file."""
file_path = self.files.get(file_type)
@@ -106,7 +125,9 @@ class StorageService:
return self.load_export_file(file_type)
def save_export_file(self, file_type: str, data: Any):
"""Save dashboard export JSON file."""
"""Save one compatibility dashboard export JSON file."""
if not self.enable_compat_exports:
return
file_path = self.files.get(file_type)
if not file_path:
logger.error(f"Unknown file type: {file_type}")
@@ -127,17 +148,79 @@ class StorageService:
"""Backward-compatible alias for export-layer JSON writes."""
self.save_export_file(file_type, data)
def save_dashboard_exports(self, exports: Dict[str, Any]) -> None:
"""Persist compatibility dashboard exports from a normalized snapshot."""
if not self.enable_compat_exports:
return
for file_type in ("summary", "holdings", "stats", "trades", "leaderboard"):
if file_type in exports:
self.save_export_file(file_type, exports[file_type])
def read_persisted_server_state(self) -> Dict[str, Any]:
"""Read server_state.json without logging or DB side effects."""
if not self.server_state_file.exists():
return {}
try:
with open(self.server_state_file, "r", encoding="utf-8") as f:
payload = json.load(f)
return payload if isinstance(payload, dict) else {}
except Exception as exc:
logger.warning("Failed to read persisted server state: %s", exc)
return {}
def load_runtime_leaderboard(self, state: Optional[Dict[str, Any]] = None) -> List[Dict[str, Any]]:
"""Prefer runtime state for leaderboard reads, fall back to export JSON."""
runtime_state = state or self.read_persisted_server_state()
leaderboard = runtime_state.get("leaderboard")
if isinstance(leaderboard, list) and leaderboard:
return leaderboard
return self.load_export_file("leaderboard") or []
def persist_runtime_leaderboard(
self,
leaderboard: List[Dict[str, Any]],
state: Optional[Dict[str, Any]] = None,
) -> None:
"""Persist leaderboard to runtime state first, keeping JSON export for compatibility."""
self.save_export_file("leaderboard", leaderboard)
runtime_state = state or self.read_persisted_server_state()
if not runtime_state:
runtime_state = self.load_server_state()
runtime_state["leaderboard"] = leaderboard
self.save_server_state(runtime_state)
def build_dashboard_snapshot_from_state(
self,
state: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
"""Build dashboard view data from runtime state instead of JSON exports."""
runtime_state = state or self.load_server_state()
portfolio = dict(runtime_state.get("portfolio") or {})
holdings = list(runtime_state.get("holdings") or [])
stats = runtime_state.get("stats") or self._get_default_stats()
trades = list(runtime_state.get("trades") or [])
leaderboard = list(runtime_state.get("leaderboard") or [])
persisted_state = self.read_persisted_server_state() if state is not None else {}
portfolio = dict(
runtime_state.get("portfolio")
or persisted_state.get("portfolio")
or {},
)
holdings = list(
runtime_state.get("holdings")
or persisted_state.get("holdings")
or [],
)
stats = (
runtime_state.get("stats")
or persisted_state.get("stats")
or self._get_default_stats()
)
trades = list(
runtime_state.get("trades")
or persisted_state.get("trades")
or [],
)
leaderboard = list(
runtime_state.get("leaderboard")
or persisted_state.get("leaderboard")
or [],
)
summary = {
"totalAssetValue": portfolio.get("total_value", self.initial_cash),
@@ -331,48 +414,38 @@ class StorageService:
self.save_internal_state(internal_state)
def initialize_empty_dashboard(self):
"""Initialize empty dashboard files with default values"""
# Summary
self.save_export_file(
"summary",
"""Initialize compatibility dashboard exports with default values."""
self.save_dashboard_exports(
{
"totalAssetValue": self.initial_cash,
"totalReturn": 0.0,
"cashPosition": self.initial_cash,
"tickerWeights": {},
"totalTrades": 0,
"pnlPct": 0.0,
"balance": self.initial_cash,
"equity": [],
"baseline": [],
"baseline_vw": [],
"momentum": [],
},
)
# Holdings
self.save_export_file("holdings", [])
# Stats
self.save_export_file(
"stats",
{
"totalAssetValue": self.initial_cash,
"totalReturn": 0.0,
"cashPosition": self.initial_cash,
"tickerWeights": {},
"totalTrades": 0,
"winRate": 0.0,
"bullBear": {
"bull": {"n": 0, "win": 0},
"bear": {"n": 0, "win": 0},
"summary": {
"totalAssetValue": self.initial_cash,
"totalReturn": 0.0,
"cashPosition": self.initial_cash,
"tickerWeights": {},
"totalTrades": 0,
"pnlPct": 0.0,
"balance": self.initial_cash,
"equity": [],
"baseline": [],
"baseline_vw": [],
"momentum": [],
},
"holdings": [],
"stats": {
"totalAssetValue": self.initial_cash,
"totalReturn": 0.0,
"cashPosition": self.initial_cash,
"tickerWeights": {},
"totalTrades": 0,
"winRate": 0.0,
"bullBear": {
"bull": {"n": 0, "win": 0},
"bear": {"n": 0, "win": 0},
},
},
"trades": [],
},
)
# Trades
self.save_export_file("trades", [])
# Leaderboard with model info
self.generate_leaderboard()
@@ -411,7 +484,7 @@ class StorageService:
ranking_entries.append(entry)
leaderboard = team_entries + ranking_entries
self.save_export_file("leaderboard", leaderboard)
self.persist_runtime_leaderboard(leaderboard)
logger.info("Leaderboard generated with model info")
def update_leaderboard_model_info(self):
@@ -421,7 +494,7 @@ class StorageService:
from ..config.constants import AGENT_CONFIG
from ..llm.models import get_agent_model_info
existing = self.load_file("leaderboard") or []
existing = self.load_runtime_leaderboard()
if not existing:
self.generate_leaderboard()
@@ -434,7 +507,7 @@ class StorageService:
entry["modelName"] = model_name
entry["modelProvider"] = model_provider
self.save_export_file("leaderboard", existing)
self.persist_runtime_leaderboard(existing)
logger.info("Leaderboard model info updated")
def get_current_timestamp_ms(self, date: str = None) -> int:
@@ -640,21 +713,21 @@ class StorageService:
state["last_update_date"] = date
self.save_internal_state(state)
self._generate_summary(state, net_value, prices)
self._generate_holdings(state, prices)
self._generate_stats(state, net_value)
self._generate_trades(state)
self.export_dashboard_compatibility_files(
state,
net_value=net_value,
prices=prices,
)
logger.info(f"Dashboard updated: net_value=${net_value:,.2f}")
def _generate_summary(
def _build_summary_export(
self,
state: Dict[str, Any],
net_value: float,
prices: Dict[str, float],
):
"""Generate summary.json"""
) -> Dict[str, Any]:
"""Build compatibility summary export payload."""
portfolio_state = state.get("portfolio_state", {})
cash = portfolio_state.get("cash", self.initial_cash)
@@ -675,7 +748,7 @@ class StorageService:
(net_value - self.initial_cash) / self.initial_cash
) * 100
summary = {
return {
"totalAssetValue": round(net_value, 2),
"totalReturn": round(total_return, 2),
"cashPosition": round(cash, 2),
@@ -689,14 +762,12 @@ class StorageService:
"momentum": state.get("momentum_history", []),
}
self.save_export_file("summary", summary)
def _generate_holdings(
def _build_holdings_export(
self,
state: Dict[str, Any],
prices: Dict[str, float],
):
"""Generate holdings.json"""
) -> List[Dict[str, Any]]:
"""Build compatibility holdings export payload."""
portfolio_state = state.get("portfolio_state", {})
positions = portfolio_state.get("positions", {})
cash = portfolio_state.get("cash", self.initial_cash)
@@ -750,18 +821,17 @@ class StorageService:
# Sort by weight
holdings.sort(key=lambda x: abs(x["weight"]), reverse=True)
return holdings
self.save_export_file("holdings", holdings)
def _generate_stats(self, state: Dict[str, Any], net_value: float):
"""Generate stats.json"""
def _build_stats_export(self, state: Dict[str, Any], net_value: float) -> Dict[str, Any]:
"""Build compatibility stats export payload."""
portfolio_state = state.get("portfolio_state", {})
cash = portfolio_state.get("cash", self.initial_cash)
total_return = (
(net_value - self.initial_cash) / self.initial_cash
) * 100
stats = {
return {
"totalAssetValue": round(net_value, 2),
"totalReturn": round(total_return, 2),
"cashPosition": round(cash, 2),
@@ -774,10 +844,8 @@ class StorageService:
},
}
self.save_export_file("stats", stats)
def _generate_trades(self, state: Dict[str, Any]):
"""Generate trades.json"""
def _build_trades_export(self, state: Dict[str, Any]) -> List[Dict[str, Any]]:
"""Build compatibility trades export payload."""
all_trades = state.get("all_trades", [])
sorted_trades = sorted(
@@ -800,7 +868,24 @@ class StorageService:
},
)
self.save_export_file("trades", trades)
return trades
def export_dashboard_compatibility_files(
self,
state: Dict[str, Any],
*,
net_value: float,
prices: Dict[str, float],
) -> None:
"""Write compatibility dashboard exports from current runtime state."""
self.save_dashboard_exports(
{
"summary": self._build_summary_export(state, net_value, prices),
"holdings": self._build_holdings_export(state, prices),
"stats": self._build_stats_export(state, net_value),
"trades": self._build_trades_export(state),
},
)
# Server State Management Methods