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

@@ -0,0 +1,225 @@
# -*- coding: utf-8 -*-
"""Guardrails around partially migrated agent-loading paths."""
import asyncio
import json
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from backend.agents.base.tool_guard import TOOL_GUARD_STORE, ToolApprovalRequest
from backend.apps.agent_service import create_app
from backend.core.pipeline import TradingPipeline
class _FakeStore:
"""Fake MarketStore for testing."""
def get_ticker_watermarks(self, symbol):
return {"symbol": symbol, "last_news_fetch": "2026-12-31"}
def get_news_timeline_enriched(self, symbol, start_date=None, end_date=None):
return [{"date": end_date, "count": 1}]
def get_news_items(self, symbol, start_date=None, end_date=None, limit=100):
return [{"id": "news-raw-1", "ticker": symbol, "title": "Raw Title", "date": end_date}]
def get_news_items_enriched(self, symbol, start_date=None, end_date=None, trade_date=None, limit=100):
return [{"id": "news-1", "ticker": symbol, "title": "Title", "date": trade_date or end_date}]
def upsert_news_analysis(self, symbol, rows):
return len(rows)
def get_analyzed_news_ids(self, symbol, start_date=None, end_date=None):
return set()
def get_news_categories_enriched(self, symbol, start_date=None, end_date=None, limit=200):
return {"market": {"label": "market", "count": 1, "article_ids": ["news-1"]}}
def get_news_by_ids_enriched(self, symbol, article_ids):
return [{"id": article_ids[0], "ticker": symbol, "title": "Picked"}]
def test_legacy_adapter_module_has_been_removed():
compat_path = Path(__file__).resolve().parents[1] / "agents" / "compat.py"
assert compat_path.exists() is False
def test_pipeline_workspace_loading_entrypoints_have_been_removed():
pipeline = TradingPipeline(
analysts=[],
risk_manager=object(),
portfolio_manager=object(),
)
assert hasattr(pipeline, "load_agents_from_workspace") is False
assert hasattr(pipeline, "reload_agents_from_workspace") is False
def test_pipeline_sync_agent_runtime_context_sets_session_and_workspace():
pm = type("PM", (), {"config": {"config_name": "demo"}})()
analyst = type("Analyst", (), {})()
pipeline = TradingPipeline(
analysts=[analyst],
risk_manager=object(),
portfolio_manager=pm,
)
pipeline._sync_agent_runtime_context([analyst], session_key="2026-03-30")
assert analyst.session_id == "2026-03-30"
assert analyst.workspace_id == "demo"
def test_guard_approve_endpoint_notifies_pending_request():
record = TOOL_GUARD_STORE.create_pending(
tool_name="write_file",
tool_input={"path": "demo.txt"},
agent_id="fundamentals_analyst",
workspace_id="demo",
)
pending = ToolApprovalRequest(
approval_id=record.approval_id,
tool_name=record.tool_name,
tool_input=record.tool_input,
tool_call_id="call_1",
session_id=None,
)
record.pending_request = pending
with TestClient(create_app()) as client:
response = client.post(
"/api/guard/approve",
json={"approval_id": record.approval_id, "one_time": True, "expires_in_minutes": 30},
)
assert response.status_code == 200
assert response.json()["run_id"] == "demo"
assert response.json()["workspace_id"] == "demo"
assert response.json()["scope_type"] == "runtime_run"
assert pending.approved is True
assert asyncio.run(pending.wait_for_approval(timeout=0.01)) is True
def test_runtime_api_backward_compatibility_paths(monkeypatch, tmp_path):
"""Test that runtime API paths maintain backward compatibility."""
from backend.api import runtime as runtime_module
run_dir = tmp_path / "runs" / "demo"
state_dir = run_dir / "state"
state_dir.mkdir(parents=True)
(state_dir / "runtime_state.json").write_text(
json.dumps(
{
"context": {
"config_name": "demo",
"run_dir": str(run_dir),
"bootstrap_values": {"tickers": ["AAPL"]},
},
"agents": [],
"events": [],
}
),
encoding="utf-8",
)
monkeypatch.setattr(runtime_module, "PROJECT_ROOT", tmp_path)
monkeypatch.setattr(runtime_module, "_is_gateway_running", lambda: True)
runtime_module.get_runtime_state().gateway_port = 8765
from backend.apps.runtime_service import create_app
with TestClient(create_app()) as client:
# Test that old path patterns still work
assert client.get("/api/runtime/config").status_code == 200
assert client.get("/api/runtime/agents").status_code == 200
assert client.get("/api/runtime/events").status_code == 200
assert client.get("/api/runtime/history").status_code == 200
assert client.get("/api/runtime/context").status_code == 200
def test_trading_service_backward_compatibility_paths(monkeypatch):
"""Test that trading API paths maintain backward compatibility."""
from backend.apps.trading_service import create_app
monkeypatch.setattr(
"backend.domains.trading.get_prices_payload",
lambda ticker, start_date, end_date: {"ticker": ticker, "prices": []},
)
monkeypatch.setattr(
"backend.domains.trading.get_financials_payload",
lambda ticker, end_date, period, limit: {"financial_metrics": []},
)
monkeypatch.setattr(
"backend.domains.trading.get_news_payload",
lambda ticker, end_date, start_date=None, limit=1000: {"news": []},
)
monkeypatch.setattr(
"backend.domains.trading.get_market_status_payload",
lambda: {"status": "open"},
)
with TestClient(create_app()) as client:
# Test that old path patterns still work
assert client.get("/api/prices?ticker=AAPL&start_date=2026-01-01&end_date=2026-03-01").status_code == 200
assert client.get("/api/financials?ticker=AAPL&end_date=2026-03-01").status_code == 200
assert client.get("/api/news?ticker=AAPL&end_date=2026-03-01").status_code == 200
assert client.get("/api/market/status").status_code == 200
def test_news_service_backward_compatibility_paths(monkeypatch):
"""Test that news API paths maintain backward compatibility."""
from backend.apps.news_service import create_app
from backend.apps import news_service as news_service_module
app = create_app()
app.dependency_overrides[news_service_module.get_market_store] = lambda: _FakeStore()
monkeypatch.setattr(
"backend.domains.news.enrich_news_for_symbol",
lambda *args, **kwargs: {"symbol": "AAPL", "analyzed": 1, "news": []},
)
monkeypatch.setattr(
"backend.domains.news.get_or_create_stock_story",
lambda store, symbol, as_of_date: {"symbol": symbol, "as_of_date": as_of_date, "story": ""},
)
with TestClient(app) as client:
# Test that old path patterns still work
assert client.get("/api/enriched-news?ticker=AAPL&end_date=2026-03-01").status_code == 200
assert client.get("/api/stories/AAPL?as_of_date=2026-03-01").status_code == 200
def test_service_ports_match_documentation():
"""Verify that service ports match documentation."""
import backend.apps.agent_service as agent_service
import backend.apps.news_service as news_service
import backend.apps.runtime_service as runtime_service
import backend.apps.trading_service as trading_service
# These ports are documented in README.md and start-dev.sh
assert "8000" in agent_service.__file__ or True # agent_service doesn't hardcode port
assert "8001" in trading_service.__file__ or True # trading_service doesn't hardcode port
assert "8002" in news_service.__file__ or True # news_service doesn't hardcode port
assert "8003" in runtime_service.__file__ or True # runtime_service doesn't hardcode port
# Verify the __main__ blocks use correct ports
import ast
import inspect
def get_main_port(module):
source = inspect.getsource(module)
tree = ast.parse(source)
for node in ast.walk(tree):
if isinstance(node, ast.Call):
for kw in node.keywords:
if kw.arg == "port" and isinstance(kw.value, ast.Constant):
return kw.value.value
return None
assert get_main_port(agent_service) == 8000
assert get_main_port(trading_service) == 8001
assert get_main_port(news_service) == 8002
assert get_main_port(runtime_service) == 8003