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
226 lines
8.5 KiB
Python
226 lines
8.5 KiB
Python
# -*- 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
|