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:
@@ -1,14 +1,14 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Agents package - EvoAgent architecture for trading system.
|
||||
Agents package for the current mixed runtime.
|
||||
|
||||
Exports:
|
||||
- EvoAgent: Next-generation agent with workspace support
|
||||
- ToolGuardMixin: Tool call approval/denial flow
|
||||
- CommandHandler: System command handling
|
||||
- AgentFactory: Dynamic agent creation and management
|
||||
- WorkspaceManager: Legacy name for the persistent workspace registry
|
||||
- WorkspaceRegistry: Explicit run-time-agnostic workspace registry
|
||||
- AgentFactory: Design-time agent creation under `workspaces/`
|
||||
- WorkspaceManager: Legacy alias for the persistent `workspaces/` registry
|
||||
- WorkspaceRegistry: Explicit design-time `workspaces/` registry
|
||||
- RunWorkspaceManager: Run-scoped workspace asset manager
|
||||
- AgentRegistry: Central agent registry
|
||||
- Legacy compatibility: AnalystAgent, PMAgent, RiskAgent
|
||||
@@ -26,9 +26,6 @@ from .analyst import AnalystAgent
|
||||
from .portfolio_manager import PMAgent
|
||||
from .risk_manager import RiskAgent
|
||||
|
||||
# Compatibility layer
|
||||
from .compat import LegacyAgentAdapter, adapt_agent, adapt_agents, is_legacy_agent
|
||||
|
||||
__all__ = [
|
||||
# New architecture
|
||||
"EvoAgent",
|
||||
@@ -48,9 +45,4 @@ __all__ = [
|
||||
"AnalystAgent",
|
||||
"PMAgent",
|
||||
"RiskAgent",
|
||||
# Compatibility layer
|
||||
"LegacyAgentAdapter",
|
||||
"adapt_agent",
|
||||
"adapt_agents",
|
||||
"is_legacy_agent",
|
||||
]
|
||||
|
||||
@@ -2,7 +2,13 @@
|
||||
"""
|
||||
Analyst Agent - Based on AgentScope ReActAgent
|
||||
Performs analysis using tools and LLM
|
||||
|
||||
.. deprecated:: 0.2.0
|
||||
AnalystAgent is deprecated and will be removed in a future version.
|
||||
Use :class:`backend.agents.base.evo_agent.EvoAgent` instead.
|
||||
See docs/CRITICAL_FIXES.md for migration guide.
|
||||
"""
|
||||
import warnings
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from agentscope.agent import ReActAgent
|
||||
@@ -13,11 +19,23 @@ from ..config.constants import ANALYST_TYPES
|
||||
from ..utils.progress import progress
|
||||
from .prompt_factory import build_agent_system_prompt, clear_prompt_factory_cache
|
||||
|
||||
# Emit deprecation warning on module import
|
||||
warnings.warn(
|
||||
"AnalystAgent is deprecated. Use EvoAgent instead. "
|
||||
"See docs/CRITICAL_FIXES.md for migration guide.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
|
||||
|
||||
class AnalystAgent(ReActAgent):
|
||||
"""
|
||||
Analyst Agent - Uses LLM for tool selection and analysis
|
||||
Inherits from AgentScope's ReActAgent
|
||||
|
||||
.. deprecated:: 0.2.0
|
||||
Use :class:`backend.agents.base.evo_agent.EvoAgent` with
|
||||
workspace-driven configuration instead.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
@@ -33,6 +51,10 @@ class AnalystAgent(ReActAgent):
|
||||
"""
|
||||
Initialize Analyst Agent
|
||||
|
||||
.. deprecated:: 0.2.0
|
||||
Use :class:`backend.agents.unified_factory.UnifiedAgentFactory`
|
||||
or :class:`backend.agents.base.evo_agent.EvoAgent` instead.
|
||||
|
||||
Args:
|
||||
analyst_type: Type of analyst (e.g., "fundamentals", etc.)
|
||||
toolkit: AgentScope Toolkit instance
|
||||
@@ -42,6 +64,14 @@ class AnalystAgent(ReActAgent):
|
||||
config: Configuration dictionary
|
||||
long_term_memory: Optional ReMeTaskLongTermMemory instance
|
||||
"""
|
||||
# Emit runtime deprecation warning
|
||||
warnings.warn(
|
||||
f"AnalystAgent('{analyst_type}') is deprecated. "
|
||||
"Use EvoAgent via UnifiedAgentFactory instead.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
|
||||
if analyst_type not in ANALYST_TYPES:
|
||||
raise ValueError(
|
||||
f"Unknown analyst type: {analyst_type}. "
|
||||
|
||||
@@ -90,6 +90,8 @@ class EvoAgent(ToolGuardMixin, ReActAgent):
|
||||
sys_prompt: Optional[str] = None,
|
||||
max_iters: int = 10,
|
||||
memory: Optional[Any] = None,
|
||||
long_term_memory: Optional[Any] = None,
|
||||
long_term_memory_mode: str = "static_control",
|
||||
enable_tool_guard: bool = True,
|
||||
enable_bootstrap_hook: bool = True,
|
||||
enable_memory_compaction: bool = False,
|
||||
@@ -97,6 +99,9 @@ class EvoAgent(ToolGuardMixin, ReActAgent):
|
||||
memory_compact_threshold: Optional[int] = None,
|
||||
env_context: Optional[str] = None,
|
||||
prompt_files: Optional[List[str]] = None,
|
||||
# Portfolio manager specific parameters
|
||||
initial_cash: Optional[float] = None,
|
||||
margin_requirement: Optional[float] = None,
|
||||
):
|
||||
"""Initialize EvoAgent.
|
||||
|
||||
@@ -144,16 +149,24 @@ class EvoAgent(ToolGuardMixin, ReActAgent):
|
||||
# Initialize hook manager
|
||||
self._hook_manager = HookManager()
|
||||
|
||||
# Build kwargs for parent ReActAgent
|
||||
kwargs = {
|
||||
"name": agent_id,
|
||||
"model": model,
|
||||
"sys_prompt": self._sys_prompt,
|
||||
"toolkit": toolkit,
|
||||
"memory": memory or InMemoryMemory(),
|
||||
"formatter": formatter,
|
||||
"max_iters": max_iters,
|
||||
}
|
||||
|
||||
# Add long-term memory if provided
|
||||
if long_term_memory:
|
||||
kwargs["long_term_memory"] = long_term_memory
|
||||
kwargs["long_term_memory_mode"] = long_term_memory_mode
|
||||
|
||||
# Initialize parent ReActAgent
|
||||
super().__init__(
|
||||
name=agent_id,
|
||||
model=model,
|
||||
sys_prompt=self._sys_prompt,
|
||||
toolkit=toolkit,
|
||||
memory=memory or InMemoryMemory(),
|
||||
formatter=formatter,
|
||||
max_iters=max_iters,
|
||||
)
|
||||
super().__init__(**kwargs)
|
||||
|
||||
# Register hooks
|
||||
self._register_hooks(
|
||||
@@ -366,6 +379,110 @@ class EvoAgent(ToolGuardMixin, ReActAgent):
|
||||
self.toolkit = new_toolkit
|
||||
logger.info("Skills reloaded for agent: %s", self.agent_id)
|
||||
|
||||
def _make_decision(
|
||||
self,
|
||||
ticker: str,
|
||||
action: str,
|
||||
quantity: int,
|
||||
confidence: int = 50,
|
||||
reasoning: str = "",
|
||||
) -> "ToolResponse":
|
||||
"""Record a trading decision for a ticker (PM agent compatibility).
|
||||
|
||||
Args:
|
||||
ticker: Stock ticker symbol (e.g., "AAPL")
|
||||
action: Decision - "long", "short" or "hold"
|
||||
quantity: Number of shares to trade (0 for hold)
|
||||
confidence: Confidence level 0-100
|
||||
reasoning: Explanation for this decision
|
||||
|
||||
Returns:
|
||||
ToolResponse confirming decision recorded
|
||||
"""
|
||||
from agentscope.message import TextBlock
|
||||
from agentscope.tool import ToolResponse
|
||||
|
||||
if action not in ["long", "short", "hold"]:
|
||||
return ToolResponse(
|
||||
content=[
|
||||
TextBlock(
|
||||
type="text",
|
||||
text=f"Invalid action: {action}. Must be 'long', 'short', or 'hold'.",
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
# Store decision in metadata for retrieval
|
||||
if not hasattr(self, "_decisions"):
|
||||
self._decisions = {}
|
||||
|
||||
self._decisions[ticker] = {
|
||||
"action": action,
|
||||
"quantity": quantity if action != "hold" else 0,
|
||||
"confidence": confidence,
|
||||
"reasoning": reasoning,
|
||||
}
|
||||
|
||||
return ToolResponse(
|
||||
content=[
|
||||
TextBlock(
|
||||
type="text",
|
||||
text=f"Decision recorded: {action} {quantity} shares of {ticker} "
|
||||
f"(confidence: {confidence}%)",
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
def get_decisions(self) -> Dict[str, Dict]:
|
||||
"""Get decisions from current cycle (PM compatibility)."""
|
||||
return getattr(self, "_decisions", {}).copy()
|
||||
|
||||
def get_portfolio_state(self) -> Dict[str, Any]:
|
||||
"""Get current portfolio state (PM compatibility)."""
|
||||
return getattr(self, "_portfolio", {}).copy()
|
||||
|
||||
def load_portfolio_state(self, portfolio: Dict[str, Any]) -> None:
|
||||
"""Load portfolio state (PM compatibility).
|
||||
|
||||
Args:
|
||||
portfolio: Portfolio state dict with cash, positions, margin_used
|
||||
"""
|
||||
if not portfolio:
|
||||
return
|
||||
|
||||
if not hasattr(self, "_portfolio"):
|
||||
self._portfolio = {
|
||||
"cash": 100000.0,
|
||||
"positions": {},
|
||||
"margin_used": 0.0,
|
||||
"margin_requirement": 0.25,
|
||||
}
|
||||
|
||||
self._portfolio = {
|
||||
"cash": portfolio.get("cash", self._portfolio["cash"]),
|
||||
"positions": portfolio.get("positions", {}).copy(),
|
||||
"margin_used": portfolio.get("margin_used", 0.0),
|
||||
"margin_requirement": portfolio.get(
|
||||
"margin_requirement",
|
||||
self._portfolio["margin_requirement"],
|
||||
),
|
||||
}
|
||||
|
||||
def update_portfolio(self, portfolio: Dict[str, Any]) -> None:
|
||||
"""Update portfolio after external execution (PM compatibility).
|
||||
|
||||
Args:
|
||||
portfolio: Portfolio updates to apply
|
||||
"""
|
||||
if not hasattr(self, "_portfolio"):
|
||||
self._portfolio = {
|
||||
"cash": 100000.0,
|
||||
"positions": {},
|
||||
"margin_used": 0.0,
|
||||
"margin_requirement": 0.25,
|
||||
}
|
||||
self._portfolio.update(portfolio)
|
||||
|
||||
def rebuild_sys_prompt(self) -> None:
|
||||
"""Rebuild and replace the system prompt at runtime.
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import asyncio
|
||||
import json
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from datetime import UTC, datetime
|
||||
from enum import Enum
|
||||
|
||||
from typing import Any, Callable, Dict, Iterable, List, Optional, Set
|
||||
@@ -73,11 +73,13 @@ class ApprovalRecord:
|
||||
self.tool_name = tool_name
|
||||
self.tool_input = tool_input
|
||||
self.agent_id = agent_id
|
||||
# run_id is the new preferred name; workspace_id is kept for backward compatibility
|
||||
self.run_id = workspace_id
|
||||
self.workspace_id = workspace_id
|
||||
self.session_id = session_id
|
||||
self.status = ApprovalStatus.PENDING
|
||||
self.findings = findings or []
|
||||
self.created_at = datetime.utcnow()
|
||||
self.created_at = datetime.now(UTC)
|
||||
self.resolved_at: Optional[datetime] = None
|
||||
self.resolved_by: Optional[str] = None
|
||||
self.metadata: Dict[str, Any] = {}
|
||||
@@ -90,6 +92,7 @@ class ApprovalRecord:
|
||||
"tool_name": self.tool_name,
|
||||
"tool_input": self.tool_input,
|
||||
"agent_id": self.agent_id,
|
||||
"run_id": self.run_id,
|
||||
"workspace_id": self.workspace_id,
|
||||
"session_id": self.session_id,
|
||||
"findings": [f.to_dict() for f in self.findings],
|
||||
@@ -161,7 +164,7 @@ class ToolGuardStore:
|
||||
return record
|
||||
|
||||
record.status = status
|
||||
record.resolved_at = datetime.utcnow()
|
||||
record.resolved_at = datetime.now(UTC)
|
||||
record.resolved_by = resolved_by
|
||||
if notify_request and record.pending_request:
|
||||
if status == ApprovalStatus.APPROVED:
|
||||
@@ -395,18 +398,34 @@ class ToolGuardMixin:
|
||||
)
|
||||
|
||||
manager = get_global_runtime_manager()
|
||||
approval_data = {
|
||||
"tool_name": record.tool_name,
|
||||
"agent_id": record.agent_id,
|
||||
"workspace_id": record.workspace_id,
|
||||
"session_id": record.session_id,
|
||||
"tool_input": record.tool_input,
|
||||
}
|
||||
|
||||
if manager:
|
||||
manager.register_pending_approval(
|
||||
record.approval_id,
|
||||
{
|
||||
"tool_name": record.tool_name,
|
||||
"agent_id": record.agent_id,
|
||||
"workspace_id": record.workspace_id,
|
||||
"session_id": record.session_id,
|
||||
"tool_input": record.tool_input,
|
||||
},
|
||||
approval_data,
|
||||
)
|
||||
|
||||
# Broadcast WebSocket event for real-time UI updates
|
||||
try:
|
||||
if hasattr(manager, 'broadcast_event'):
|
||||
await manager.broadcast_event({
|
||||
"type": "approval_requested",
|
||||
"approval_id": record.approval_id,
|
||||
"agent_id": record.agent_id,
|
||||
"tool_name": record.tool_name,
|
||||
"timestamp": record.created_at.isoformat(),
|
||||
"data": approval_data,
|
||||
})
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to broadcast approval event: {e}")
|
||||
|
||||
self._pending_approval = ToolApprovalRequest(
|
||||
approval_id=record.approval_id,
|
||||
tool_name=tool_name,
|
||||
|
||||
@@ -1,146 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Compatibility Layer - Adapters for legacy to EvoAgent migration.
|
||||
|
||||
Provides:
|
||||
- LegacyAgentAdapter: Wraps old AnalystAgent to work with new interfaces
|
||||
- Migration utilities for gradual adoption
|
||||
"""
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from agentscope.message import Msg
|
||||
|
||||
from .agent_core import EvoAgent
|
||||
|
||||
|
||||
class LegacyAgentAdapter:
|
||||
"""
|
||||
Adapter to make legacy AnalystAgent compatible with EvoAgent interfaces.
|
||||
|
||||
This allows gradual migration by wrapping existing agents.
|
||||
"""
|
||||
|
||||
def __init__(self, legacy_agent: Any):
|
||||
"""
|
||||
Initialize adapter.
|
||||
|
||||
Args:
|
||||
legacy_agent: Legacy AnalystAgent instance
|
||||
"""
|
||||
self._agent = legacy_agent
|
||||
self.agent_id = getattr(legacy_agent, 'agent_id', getattr(legacy_agent, 'name', 'unknown'))
|
||||
self.analyst_type = getattr(legacy_agent, 'analyst_type_key', None)
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
"""Get agent name."""
|
||||
return getattr(self._agent, 'name', self.agent_id)
|
||||
|
||||
@property
|
||||
def toolkit(self) -> Any:
|
||||
"""Get agent toolkit."""
|
||||
return getattr(self._agent, 'toolkit', None)
|
||||
|
||||
@property
|
||||
def model(self) -> Any:
|
||||
"""Get agent model."""
|
||||
return getattr(self._agent, 'model', None)
|
||||
|
||||
@property
|
||||
def memory(self) -> Any:
|
||||
"""Get agent memory."""
|
||||
return getattr(self._agent, 'memory', None)
|
||||
|
||||
async def reply(self, x: Msg = None) -> Msg:
|
||||
"""
|
||||
Delegate to legacy agent's reply method.
|
||||
|
||||
Args:
|
||||
x: Input message
|
||||
|
||||
Returns:
|
||||
Response message
|
||||
"""
|
||||
return await self._agent.reply(x)
|
||||
|
||||
def reload_runtime_assets(self, active_skill_dirs: Optional[list] = None) -> None:
|
||||
"""
|
||||
Reload runtime assets if supported.
|
||||
|
||||
Args:
|
||||
active_skill_dirs: Optional list of active skill directories
|
||||
"""
|
||||
if hasattr(self._agent, 'reload_runtime_assets'):
|
||||
self._agent.reload_runtime_assets(active_skill_dirs)
|
||||
|
||||
def to_evo_agent(
|
||||
self,
|
||||
workspace_manager: Optional[Any] = None,
|
||||
enable_tool_guard: bool = False,
|
||||
) -> EvoAgent:
|
||||
"""
|
||||
Convert legacy agent to EvoAgent.
|
||||
|
||||
Args:
|
||||
workspace_manager: Optional workspace manager
|
||||
enable_tool_guard: Whether to enable tool guard
|
||||
|
||||
Returns:
|
||||
New EvoAgent instance with same configuration
|
||||
"""
|
||||
return EvoAgent(
|
||||
agent_id=self.agent_id,
|
||||
model=self.model,
|
||||
formatter=getattr(self._agent, 'formatter', None),
|
||||
toolkit=self.toolkit,
|
||||
workspace_manager=workspace_manager,
|
||||
config=getattr(self._agent, 'config', {}),
|
||||
long_term_memory=getattr(self._agent, 'long_term_memory', None),
|
||||
enable_tool_guard=enable_tool_guard,
|
||||
sys_prompt=getattr(self._agent, '_sys_prompt', None),
|
||||
)
|
||||
|
||||
def __getattr__(self, name: str) -> Any:
|
||||
"""Delegate unknown attributes to wrapped agent."""
|
||||
return getattr(self._agent, name)
|
||||
|
||||
|
||||
def is_legacy_agent(agent: Any) -> bool:
|
||||
"""
|
||||
Check if an agent is a legacy agent.
|
||||
|
||||
Args:
|
||||
agent: Agent instance to check
|
||||
|
||||
Returns:
|
||||
True if legacy agent
|
||||
"""
|
||||
return hasattr(agent, 'analyst_type_key') and not isinstance(agent, EvoAgent)
|
||||
|
||||
|
||||
def adapt_agent(agent: Any) -> Any:
|
||||
"""
|
||||
Wrap agent in adapter if it's a legacy agent.
|
||||
|
||||
Args:
|
||||
agent: Agent instance
|
||||
|
||||
Returns:
|
||||
Adapted agent or original if already EvoAgent
|
||||
"""
|
||||
if is_legacy_agent(agent):
|
||||
return LegacyAgentAdapter(agent)
|
||||
return agent
|
||||
|
||||
|
||||
def adapt_agents(agents: list) -> list:
|
||||
"""
|
||||
Wrap multiple agents in adapters.
|
||||
|
||||
Args:
|
||||
agents: List of agent instances
|
||||
|
||||
Returns:
|
||||
List of adapted agents
|
||||
"""
|
||||
return [adapt_agent(agent) for agent in agents]
|
||||
@@ -2,8 +2,13 @@
|
||||
"""
|
||||
Portfolio Manager Agent - Based on AgentScope ReActAgent
|
||||
Responsible for decision-making (NOT trade execution)
|
||||
"""
|
||||
|
||||
.. deprecated:: 0.2.0
|
||||
PMAgent is deprecated and will be removed in a future version.
|
||||
Use :class:`backend.agents.base.evo_agent.EvoAgent` instead.
|
||||
See docs/CRITICAL_FIXES.md for migration guide.
|
||||
"""
|
||||
import warnings
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional, Callable
|
||||
|
||||
@@ -17,11 +22,31 @@ from .prompt_factory import build_agent_system_prompt, clear_prompt_factory_cach
|
||||
from .team_pipeline_config import update_active_analysts
|
||||
from ..config.constants import ANALYST_TYPES
|
||||
|
||||
# Emit deprecation warning on module import
|
||||
warnings.warn(
|
||||
"PMAgent is deprecated. Use EvoAgent instead. "
|
||||
"See docs/CRITICAL_FIXES.md for migration guide.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
|
||||
|
||||
class PMAgent(ReActAgent):
|
||||
"""
|
||||
Portfolio Manager Agent - Makes investment decisions
|
||||
|
||||
Key features:
|
||||
1. PM outputs decisions only (action + quantity per ticker)
|
||||
2. Trade execution happens externally (in pipeline/executor)
|
||||
3. Supports both backtest and live modes
|
||||
|
||||
.. deprecated:: 0.2.0
|
||||
Use :class:`backend.agents.base.evo_agent.EvoAgent` with
|
||||
workspace-driven configuration instead.
|
||||
"""
|
||||
"""
|
||||
Portfolio Manager Agent - Makes investment decisions
|
||||
|
||||
Key features:
|
||||
1. PM outputs decisions only (action + quantity per ticker)
|
||||
2. Trade execution happens externally (in pipeline/executor)
|
||||
@@ -41,6 +66,13 @@ class PMAgent(ReActAgent):
|
||||
toolkit_factory_kwargs: Optional[Dict[str, Any]] = None,
|
||||
toolkit: Optional[Toolkit] = None,
|
||||
):
|
||||
# Emit runtime deprecation warning
|
||||
warnings.warn(
|
||||
"PMAgent is deprecated. Use EvoAgent via UnifiedAgentFactory instead.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
|
||||
object.__setattr__(self, "config", config or {})
|
||||
|
||||
# Portfolio state
|
||||
|
||||
@@ -2,7 +2,13 @@
|
||||
"""
|
||||
Risk Manager Agent - Based on AgentScope ReActAgent
|
||||
Uses LLM for risk assessment
|
||||
|
||||
.. deprecated:: 0.2.0
|
||||
RiskAgent is deprecated and will be removed in a future version.
|
||||
Use :class:`backend.agents.base.evo_agent.EvoAgent` instead.
|
||||
See docs/CRITICAL_FIXES.md for migration guide.
|
||||
"""
|
||||
import warnings
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from agentscope.agent import ReActAgent
|
||||
@@ -13,11 +19,23 @@ from agentscope.tool import Toolkit
|
||||
from ..utils.progress import progress
|
||||
from .prompt_factory import build_agent_system_prompt, clear_prompt_factory_cache
|
||||
|
||||
# Emit deprecation warning on module import
|
||||
warnings.warn(
|
||||
"RiskAgent is deprecated. Use EvoAgent instead. "
|
||||
"See docs/CRITICAL_FIXES.md for migration guide.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
|
||||
|
||||
class RiskAgent(ReActAgent):
|
||||
"""
|
||||
Risk Manager Agent - Uses LLM for risk assessment
|
||||
Inherits from AgentScope's ReActAgent
|
||||
|
||||
.. deprecated:: 0.2.0
|
||||
Use :class:`backend.agents.base.evo_agent.EvoAgent` with
|
||||
workspace-driven configuration instead.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
@@ -32,6 +50,10 @@ class RiskAgent(ReActAgent):
|
||||
"""
|
||||
Initialize Risk Manager Agent
|
||||
|
||||
.. deprecated:: 0.2.0
|
||||
Use :class:`backend.agents.unified_factory.UnifiedAgentFactory`
|
||||
or :class:`backend.agents.base.evo_agent.EvoAgent` instead.
|
||||
|
||||
Args:
|
||||
model: LLM model instance
|
||||
formatter: Message formatter instance
|
||||
@@ -39,6 +61,13 @@ class RiskAgent(ReActAgent):
|
||||
config: Configuration dictionary
|
||||
long_term_memory: Optional ReMeTaskLongTermMemory instance
|
||||
"""
|
||||
# Emit runtime deprecation warning
|
||||
warnings.warn(
|
||||
"RiskAgent is deprecated. Use EvoAgent via UnifiedAgentFactory instead.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
|
||||
object.__setattr__(self, "config", config or {})
|
||||
object.__setattr__(self, "agent_id", name)
|
||||
|
||||
|
||||
433
backend/agents/unified_factory.py
Normal file
433
backend/agents/unified_factory.py
Normal file
@@ -0,0 +1,433 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Unified Agent Factory - Centralized agent creation for 大时代.
|
||||
|
||||
This module provides a unified factory for creating all agent types (analysts,
|
||||
risk manager, portfolio manager) with consistent configuration. It replaces
|
||||
the scattered agent creation logic in main.py, pipeline.py, and pipeline_runner.py.
|
||||
|
||||
Key features:
|
||||
- Single entry point for all agent creation
|
||||
- Automatic EvoAgent vs Legacy Agent selection based on _resolve_evo_agent_ids()
|
||||
- Consistent parameter handling across all agent types
|
||||
- Support for workspace-driven configuration
|
||||
- Long-term memory integration
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, Optional, Protocol, TypeVar, Union
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from backend.agents.base.evo_agent import EvoAgent
|
||||
from backend.agents.analyst import AnalystAgent
|
||||
from backend.agents.risk_manager import RiskAgent
|
||||
from backend.agents.portfolio_manager import PMAgent
|
||||
|
||||
# Type aliases for agent types
|
||||
AgentType = Union["EvoAgent", "AnalystAgent", "RiskAgent", "PMAgent"]
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
class AgentFactoryProtocol(Protocol):
|
||||
"""Protocol for agent factory implementations."""
|
||||
|
||||
def create_analyst(
|
||||
self,
|
||||
analyst_type: str,
|
||||
model: Any,
|
||||
formatter: Any,
|
||||
active_skill_dirs: Optional[list[Path]] = None,
|
||||
long_term_memory: Optional[Any] = None,
|
||||
) -> AnalystAgent | EvoAgent: ...
|
||||
|
||||
def create_risk_manager(
|
||||
self,
|
||||
model: Any,
|
||||
formatter: Any,
|
||||
active_skill_dirs: Optional[list[Path]] = None,
|
||||
long_term_memory: Optional[Any] = None,
|
||||
) -> RiskAgent | EvoAgent: ...
|
||||
|
||||
def create_portfolio_manager(
|
||||
self,
|
||||
model: Any,
|
||||
formatter: Any,
|
||||
initial_cash: float,
|
||||
margin_requirement: float,
|
||||
active_skill_dirs: Optional[list[Path]] = None,
|
||||
long_term_memory: Optional[Any] = None,
|
||||
) -> PMAgent | EvoAgent: ...
|
||||
|
||||
|
||||
class UnifiedAgentFactory:
|
||||
"""Unified factory for creating agents with consistent configuration.
|
||||
|
||||
This factory centralizes agent creation logic and automatically selects
|
||||
between EvoAgent (new) and Legacy Agent based on the EVO_AGENT_IDS
|
||||
environment variable configuration.
|
||||
|
||||
By default, all supported roles use EvoAgent. Set EVO_AGENT_IDS=legacy
|
||||
to disable EvoAgent entirely.
|
||||
|
||||
Example:
|
||||
factory = UnifiedAgentFactory(
|
||||
config_name="smoke_fullstack",
|
||||
skills_manager=skills_manager,
|
||||
)
|
||||
|
||||
# Create analyst
|
||||
analyst = factory.create_analyst(
|
||||
analyst_type="fundamentals_analyst",
|
||||
model=model,
|
||||
formatter=formatter,
|
||||
)
|
||||
|
||||
# Create risk manager
|
||||
risk_mgr = factory.create_risk_manager(
|
||||
model=model,
|
||||
formatter=formatter,
|
||||
)
|
||||
|
||||
# Create portfolio manager
|
||||
pm = factory.create_portfolio_manager(
|
||||
model=model,
|
||||
formatter=formatter,
|
||||
initial_cash=100000.0,
|
||||
margin_requirement=0.5,
|
||||
)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config_name: str,
|
||||
skills_manager: Any,
|
||||
toolkit_factory: Optional[Any] = None,
|
||||
evo_agent_ids: Optional[set[str]] = None,
|
||||
):
|
||||
"""Initialize the agent factory.
|
||||
|
||||
Args:
|
||||
config_name: Run configuration name (e.g., "smoke_fullstack")
|
||||
skills_manager: SkillsManager instance for skill/asset management
|
||||
toolkit_factory: Optional factory function for creating toolkits
|
||||
evo_agent_ids: Optional set of agent IDs to use EvoAgent.
|
||||
If None, uses _resolve_evo_agent_ids() default.
|
||||
"""
|
||||
self.config_name = config_name
|
||||
self.skills_manager = skills_manager
|
||||
self.toolkit_factory = toolkit_factory
|
||||
|
||||
# Determine which agents should use EvoAgent
|
||||
if evo_agent_ids is not None:
|
||||
self._evo_agent_ids = evo_agent_ids
|
||||
else:
|
||||
self._evo_agent_ids = self._resolve_evo_agent_ids()
|
||||
|
||||
def _resolve_evo_agent_ids(self) -> set[str]:
|
||||
"""Return agent ids selected to use EvoAgent.
|
||||
|
||||
By default, all supported roles use EvoAgent.
|
||||
EVO_AGENT_IDS can be used to limit to specific roles.
|
||||
"""
|
||||
from backend.config.constants import ANALYST_TYPES
|
||||
|
||||
all_supported = set(ANALYST_TYPES) | {"risk_manager", "portfolio_manager"}
|
||||
|
||||
raw = os.getenv("EVO_AGENT_IDS", "")
|
||||
if not raw.strip():
|
||||
# Default: all supported roles use EvoAgent
|
||||
return all_supported
|
||||
|
||||
if raw.strip().lower() in ("legacy", "old", "none"):
|
||||
return set()
|
||||
|
||||
requested = {item.strip() for item in raw.split(",") if item.strip()}
|
||||
return {
|
||||
agent_id
|
||||
for agent_id in requested
|
||||
if agent_id in ANALYST_TYPES
|
||||
or agent_id in {"risk_manager", "portfolio_manager"}
|
||||
}
|
||||
|
||||
def _should_use_evo_agent(self, agent_id: str) -> bool:
|
||||
"""Check if an agent should use EvoAgent."""
|
||||
return agent_id in self._evo_agent_ids
|
||||
|
||||
def _create_toolkit(
|
||||
self,
|
||||
agent_type: str,
|
||||
active_skill_dirs: Optional[list[Path]] = None,
|
||||
owner: Optional[Any] = None,
|
||||
) -> Any:
|
||||
"""Create toolkit for an agent."""
|
||||
if self.toolkit_factory is None:
|
||||
from backend.agents.toolkit_factory import create_agent_toolkit
|
||||
|
||||
self.toolkit_factory = create_agent_toolkit
|
||||
|
||||
kwargs: dict[str, Any] = {
|
||||
"active_skill_dirs": active_skill_dirs or [],
|
||||
}
|
||||
if owner is not None:
|
||||
kwargs["owner"] = owner
|
||||
|
||||
return self.toolkit_factory(agent_type, self.config_name, **kwargs)
|
||||
|
||||
def _load_agent_config(self, agent_id: str) -> Any:
|
||||
"""Load agent configuration from workspace."""
|
||||
from backend.agents.agent_workspace import load_agent_workspace_config
|
||||
|
||||
workspace_dir = self.skills_manager.get_agent_asset_dir(
|
||||
self.config_name, agent_id
|
||||
)
|
||||
config_path = workspace_dir / "agent.yaml"
|
||||
|
||||
if config_path.exists():
|
||||
return load_agent_workspace_config(config_path)
|
||||
|
||||
# Return default config if no agent.yaml
|
||||
return type(
|
||||
"AgentConfig",
|
||||
(),
|
||||
{"prompt_files": ["SOUL.md"]},
|
||||
)()
|
||||
|
||||
def _create_evo_agent(
|
||||
self,
|
||||
agent_id: str,
|
||||
model: Any,
|
||||
formatter: Any,
|
||||
toolkit: Any,
|
||||
agent_config: Any,
|
||||
long_term_memory: Optional[Any] = None,
|
||||
extra_kwargs: Optional[dict[str, Any]] = None,
|
||||
) -> "EvoAgent":
|
||||
"""Create an EvoAgent instance."""
|
||||
from backend.agents.base.evo_agent import EvoAgent
|
||||
|
||||
workspace_dir = self.skills_manager.get_agent_asset_dir(
|
||||
self.config_name, agent_id
|
||||
)
|
||||
|
||||
kwargs: dict[str, Any] = {
|
||||
"agent_id": agent_id,
|
||||
"config_name": self.config_name,
|
||||
"workspace_dir": workspace_dir,
|
||||
"model": model,
|
||||
"formatter": formatter,
|
||||
"skills_manager": self.skills_manager,
|
||||
"prompt_files": getattr(agent_config, "prompt_files", ["SOUL.md"]),
|
||||
"long_term_memory": long_term_memory,
|
||||
}
|
||||
|
||||
if extra_kwargs:
|
||||
kwargs.update(extra_kwargs)
|
||||
|
||||
agent = EvoAgent(**kwargs)
|
||||
agent.toolkit = toolkit
|
||||
setattr(agent, "run_id", self.config_name)
|
||||
# Keep workspace_id for backward compatibility
|
||||
setattr(agent, "workspace_id", self.config_name)
|
||||
|
||||
return agent
|
||||
|
||||
def create_analyst(
|
||||
self,
|
||||
analyst_type: str,
|
||||
model: Any,
|
||||
formatter: Any,
|
||||
active_skill_dirs: Optional[list[Path]] = None,
|
||||
long_term_memory: Optional[Any] = None,
|
||||
) -> "AnalystAgent | EvoAgent":
|
||||
"""Create an analyst agent.
|
||||
|
||||
Args:
|
||||
analyst_type: Type of analyst (fundamentals, technical, sentiment, valuation)
|
||||
model: LLM model instance
|
||||
formatter: Message formatter instance
|
||||
active_skill_dirs: Optional list of active skill directories
|
||||
long_term_memory: Optional long-term memory instance
|
||||
|
||||
Returns:
|
||||
AnalystAgent or EvoAgent instance
|
||||
"""
|
||||
toolkit = self._create_toolkit(analyst_type, active_skill_dirs)
|
||||
|
||||
if self._should_use_evo_agent(analyst_type):
|
||||
agent_config = self._load_agent_config(analyst_type)
|
||||
return self._create_evo_agent(
|
||||
agent_id=analyst_type,
|
||||
model=model,
|
||||
formatter=formatter,
|
||||
toolkit=toolkit,
|
||||
agent_config=agent_config,
|
||||
long_term_memory=long_term_memory,
|
||||
)
|
||||
|
||||
# Legacy path
|
||||
from backend.agents.analyst import AnalystAgent
|
||||
|
||||
return AnalystAgent(
|
||||
analyst_type=analyst_type,
|
||||
toolkit=toolkit,
|
||||
model=model,
|
||||
formatter=formatter,
|
||||
agent_id=analyst_type,
|
||||
config={"config_name": self.config_name},
|
||||
long_term_memory=long_term_memory,
|
||||
)
|
||||
|
||||
def create_risk_manager(
|
||||
self,
|
||||
model: Any,
|
||||
formatter: Any,
|
||||
active_skill_dirs: Optional[list[Path]] = None,
|
||||
long_term_memory: Optional[Any] = None,
|
||||
) -> "RiskAgent | EvoAgent":
|
||||
"""Create a risk manager agent.
|
||||
|
||||
Args:
|
||||
model: LLM model instance
|
||||
formatter: Message formatter instance
|
||||
active_skill_dirs: Optional list of active skill directories
|
||||
long_term_memory: Optional long-term memory instance
|
||||
|
||||
Returns:
|
||||
RiskAgent or EvoAgent instance
|
||||
"""
|
||||
toolkit = self._create_toolkit("risk_manager", active_skill_dirs)
|
||||
|
||||
if self._should_use_evo_agent("risk_manager"):
|
||||
agent_config = self._load_agent_config("risk_manager")
|
||||
return self._create_evo_agent(
|
||||
agent_id="risk_manager",
|
||||
model=model,
|
||||
formatter=formatter,
|
||||
toolkit=toolkit,
|
||||
agent_config=agent_config,
|
||||
long_term_memory=long_term_memory,
|
||||
)
|
||||
|
||||
# Legacy path
|
||||
from backend.agents.risk_manager import RiskAgent
|
||||
|
||||
return RiskAgent(
|
||||
model=model,
|
||||
formatter=formatter,
|
||||
name="risk_manager",
|
||||
config={"config_name": self.config_name},
|
||||
long_term_memory=long_term_memory,
|
||||
toolkit=toolkit,
|
||||
)
|
||||
|
||||
def create_portfolio_manager(
|
||||
self,
|
||||
model: Any,
|
||||
formatter: Any,
|
||||
initial_cash: float,
|
||||
margin_requirement: float,
|
||||
active_skill_dirs: Optional[list[Path]] = None,
|
||||
long_term_memory: Optional[Any] = None,
|
||||
) -> "PMAgent | EvoAgent":
|
||||
"""Create a portfolio manager agent.
|
||||
|
||||
Args:
|
||||
model: LLM model instance
|
||||
formatter: Message formatter instance
|
||||
initial_cash: Initial cash allocation
|
||||
margin_requirement: Margin requirement ratio
|
||||
active_skill_dirs: Optional list of active skill directories
|
||||
long_term_memory: Optional long-term memory instance
|
||||
|
||||
Returns:
|
||||
PMAgent or EvoAgent instance
|
||||
"""
|
||||
if self._should_use_evo_agent("portfolio_manager"):
|
||||
agent_config = self._load_agent_config("portfolio_manager")
|
||||
|
||||
# For PM, toolkit is created after agent (needs owner reference)
|
||||
from backend.agents.base.evo_agent import EvoAgent
|
||||
|
||||
workspace_dir = self.skills_manager.get_agent_asset_dir(
|
||||
self.config_name, "portfolio_manager"
|
||||
)
|
||||
|
||||
agent = EvoAgent(
|
||||
agent_id="portfolio_manager",
|
||||
config_name=self.config_name,
|
||||
workspace_dir=workspace_dir,
|
||||
model=model,
|
||||
formatter=formatter,
|
||||
skills_manager=self.skills_manager,
|
||||
prompt_files=getattr(agent_config, "prompt_files", ["SOUL.md"]),
|
||||
initial_cash=initial_cash,
|
||||
margin_requirement=margin_requirement,
|
||||
long_term_memory=long_term_memory,
|
||||
)
|
||||
agent.toolkit = self._create_toolkit(
|
||||
"portfolio_manager", active_skill_dirs, owner=agent
|
||||
)
|
||||
setattr(agent, "run_id", self.config_name)
|
||||
# Keep workspace_id for backward compatibility
|
||||
setattr(agent, "workspace_id", self.config_name)
|
||||
return agent
|
||||
|
||||
# Legacy path
|
||||
from backend.agents.portfolio_manager import PMAgent
|
||||
|
||||
return PMAgent(
|
||||
name="portfolio_manager",
|
||||
model=model,
|
||||
formatter=formatter,
|
||||
initial_cash=initial_cash,
|
||||
margin_requirement=margin_requirement,
|
||||
config={"config_name": self.config_name},
|
||||
long_term_memory=long_term_memory,
|
||||
toolkit_factory=self.toolkit_factory,
|
||||
toolkit_factory_kwargs={"active_skill_dirs": active_skill_dirs or []},
|
||||
)
|
||||
|
||||
|
||||
# Singleton factory instance cache
|
||||
_factory_cache: dict[str, UnifiedAgentFactory] = {}
|
||||
|
||||
|
||||
def get_agent_factory(
|
||||
config_name: str,
|
||||
skills_manager: Any,
|
||||
toolkit_factory: Optional[Any] = None,
|
||||
) -> UnifiedAgentFactory:
|
||||
"""Get or create a cached agent factory instance.
|
||||
|
||||
Args:
|
||||
config_name: Run configuration name
|
||||
skills_manager: SkillsManager instance
|
||||
toolkit_factory: Optional toolkit factory function
|
||||
|
||||
Returns:
|
||||
UnifiedAgentFactory instance (cached per config_name)
|
||||
"""
|
||||
cache_key = f"{config_name}:{id(skills_manager)}"
|
||||
|
||||
if cache_key not in _factory_cache:
|
||||
_factory_cache[cache_key] = UnifiedAgentFactory(
|
||||
config_name=config_name,
|
||||
skills_manager=skills_manager,
|
||||
toolkit_factory=toolkit_factory,
|
||||
)
|
||||
|
||||
return _factory_cache[cache_key]
|
||||
|
||||
|
||||
def clear_factory_cache() -> None:
|
||||
"""Clear the factory cache. Useful for testing."""
|
||||
_factory_cache.clear()
|
||||
|
||||
|
||||
__all__ = [
|
||||
"UnifiedAgentFactory",
|
||||
"AgentFactoryProtocol",
|
||||
"get_agent_factory",
|
||||
"clear_factory_cache",
|
||||
]
|
||||
@@ -1,5 +1,5 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Workspace Manager - Create and manage agent workspaces."""
|
||||
"""Design-time workspace registry stored under `workspaces/`."""
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
@@ -323,5 +323,6 @@ class WorkspaceRegistry:
|
||||
yaml.safe_dump(config.to_dict(), f, allow_unicode=True, sort_keys=False)
|
||||
|
||||
|
||||
# Backward-compatible alias: legacy imports expect WorkspaceManager.
|
||||
# Backward-compatible alias: legacy imports expect WorkspaceManager to mean the
|
||||
# design-time `workspaces/` registry.
|
||||
WorkspaceManager = WorkspaceRegistry
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Initialize run-scoped agent workspace assets."""
|
||||
"""Initialize run-scoped agent workspace assets under `runs/<run_id>/`."""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Dict, Iterable, Optional
|
||||
@@ -479,5 +479,6 @@ class RunWorkspaceManager:
|
||||
)
|
||||
|
||||
|
||||
# Backward-compatible alias: code importing WorkspaceManager from this module should continue to work.
|
||||
# Backward-compatible alias: many runtime paths still import WorkspaceManager
|
||||
# from this module when they mean the run-scoped manager.
|
||||
WorkspaceManager = RunWorkspaceManager
|
||||
|
||||
@@ -2,7 +2,10 @@
|
||||
"""
|
||||
Agent API Routes
|
||||
|
||||
Provides REST API endpoints for agent management within workspaces.
|
||||
Provides REST API endpoints for both:
|
||||
|
||||
- design-time agent management under `workspaces/`
|
||||
- run-scoped agent asset access under `runs/<run_id>/`
|
||||
"""
|
||||
import logging
|
||||
import os
|
||||
@@ -24,6 +27,30 @@ from backend.llm.models import get_agent_model_info
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/workspaces/{workspace_id}/agents", tags=["agents"])
|
||||
DESIGN_SCOPE = "design_workspace"
|
||||
RUNTIME_SCOPE = "runtime_run"
|
||||
RUNTIME_SCOPE_NOTE = (
|
||||
"For profile, skills, and editable agent files, `workspace_id` is treated "
|
||||
"as the active run id under `runs/<run_id>/`, not as the design-time "
|
||||
"`workspaces/` registry."
|
||||
)
|
||||
|
||||
|
||||
def _runtime_scope_fields() -> dict[str, str]:
|
||||
return {
|
||||
"scope_type": RUNTIME_SCOPE,
|
||||
"scope_note": RUNTIME_SCOPE_NOTE,
|
||||
}
|
||||
|
||||
|
||||
def _design_scope_fields() -> dict[str, str]:
|
||||
return {
|
||||
"scope_type": DESIGN_SCOPE,
|
||||
"scope_note": (
|
||||
"For design-time CRUD routes on this surface, `workspace_id` refers "
|
||||
"to the persistent registry under `workspaces/`."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
# Request/Response Models
|
||||
@@ -68,30 +95,40 @@ class AgentResponse(BaseModel):
|
||||
config_path: str
|
||||
agent_dir: str
|
||||
status: str = "inactive"
|
||||
scope_type: str = DESIGN_SCOPE
|
||||
scope_note: Optional[str] = None
|
||||
|
||||
|
||||
class AgentFileResponse(BaseModel):
|
||||
"""Agent file content response."""
|
||||
filename: str
|
||||
content: str
|
||||
scope_type: str = RUNTIME_SCOPE
|
||||
scope_note: Optional[str] = None
|
||||
|
||||
|
||||
class AgentProfileResponse(BaseModel):
|
||||
agent_id: str
|
||||
workspace_id: str
|
||||
profile: Dict[str, Any]
|
||||
scope_type: str = RUNTIME_SCOPE
|
||||
scope_note: Optional[str] = None
|
||||
|
||||
|
||||
class AgentSkillsResponse(BaseModel):
|
||||
agent_id: str
|
||||
workspace_id: str
|
||||
skills: List[Dict[str, Any]]
|
||||
scope_type: str = RUNTIME_SCOPE
|
||||
scope_note: Optional[str] = None
|
||||
|
||||
|
||||
class SkillDetailResponse(BaseModel):
|
||||
agent_id: str
|
||||
workspace_id: str
|
||||
skill: Dict[str, Any]
|
||||
scope_type: str = RUNTIME_SCOPE
|
||||
scope_note: Optional[str] = None
|
||||
|
||||
|
||||
# Dependencies
|
||||
@@ -101,7 +138,7 @@ def get_agent_factory():
|
||||
|
||||
|
||||
def get_workspace_manager():
|
||||
"""Get run-scoped workspace manager instance."""
|
||||
"""Get run-scoped asset manager for one runtime workspace/run id."""
|
||||
return RunWorkspaceManager()
|
||||
|
||||
|
||||
@@ -119,7 +156,7 @@ async def create_agent(
|
||||
registry = Depends(get_registry),
|
||||
):
|
||||
"""
|
||||
Create a new agent in a workspace.
|
||||
Create a new agent in a design-time workspace registry entry.
|
||||
|
||||
Args:
|
||||
workspace_id: Workspace identifier
|
||||
@@ -162,6 +199,7 @@ async def create_agent(
|
||||
config_path=str(agent.config_path),
|
||||
agent_dir=str(agent.agent_dir),
|
||||
status="inactive",
|
||||
**_design_scope_fields(),
|
||||
)
|
||||
|
||||
except ValueError as e:
|
||||
@@ -174,7 +212,7 @@ async def list_agents(
|
||||
factory: AgentFactory = Depends(get_agent_factory),
|
||||
):
|
||||
"""
|
||||
List all agents in a workspace.
|
||||
List all agents in a design-time workspace registry entry.
|
||||
|
||||
Args:
|
||||
workspace_id: Workspace identifier
|
||||
@@ -192,6 +230,7 @@ async def list_agents(
|
||||
config_path=agent["config_path"],
|
||||
agent_dir=str(Path(agent["config_path"]).parent),
|
||||
status="inactive",
|
||||
**_design_scope_fields(),
|
||||
)
|
||||
for agent in agents_data
|
||||
]
|
||||
@@ -206,7 +245,7 @@ async def get_agent(
|
||||
registry = Depends(get_registry),
|
||||
):
|
||||
"""
|
||||
Get agent details.
|
||||
Get design-time agent details from the persistent workspace registry.
|
||||
|
||||
Args:
|
||||
workspace_id: Workspace identifier
|
||||
@@ -227,6 +266,7 @@ async def get_agent(
|
||||
config_path=agent_info.config_path,
|
||||
agent_dir=agent_info.agent_dir,
|
||||
status=agent_info.status,
|
||||
**_design_scope_fields(),
|
||||
)
|
||||
|
||||
|
||||
@@ -275,6 +315,7 @@ async def get_agent_profile(
|
||||
"enabled_skills": agent_config.enabled_skills,
|
||||
"disabled_skills": agent_config.disabled_skills,
|
||||
},
|
||||
**_runtime_scope_fields(),
|
||||
)
|
||||
|
||||
|
||||
@@ -310,7 +351,12 @@ async def get_agent_skills(
|
||||
"status": status,
|
||||
})
|
||||
|
||||
return AgentSkillsResponse(agent_id=agent_id, workspace_id=workspace_id, skills=payload)
|
||||
return AgentSkillsResponse(
|
||||
agent_id=agent_id,
|
||||
workspace_id=workspace_id,
|
||||
skills=payload,
|
||||
**_runtime_scope_fields(),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{agent_id}/skills/{skill_name}", response_model=SkillDetailResponse)
|
||||
@@ -329,7 +375,12 @@ async def get_agent_skill_detail(
|
||||
except FileNotFoundError:
|
||||
raise HTTPException(status_code=404, detail=f"Unknown skill: {skill_name}")
|
||||
|
||||
return SkillDetailResponse(agent_id=agent_id, workspace_id=workspace_id, skill=detail)
|
||||
return SkillDetailResponse(
|
||||
agent_id=agent_id,
|
||||
workspace_id=workspace_id,
|
||||
skill=detail,
|
||||
**_runtime_scope_fields(),
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/{agent_id}")
|
||||
@@ -416,6 +467,7 @@ async def update_agent(
|
||||
config_path=agent_info.config_path,
|
||||
agent_dir=agent_info.agent_dir,
|
||||
status=agent_info.status,
|
||||
**_design_scope_fields(),
|
||||
)
|
||||
|
||||
|
||||
@@ -656,7 +708,7 @@ async def get_agent_file(
|
||||
workspace_manager: RunWorkspaceManager = Depends(get_workspace_manager),
|
||||
):
|
||||
"""
|
||||
Read an agent's workspace file.
|
||||
Read an agent file from the run-scoped asset tree under `runs/<run_id>/`.
|
||||
|
||||
Args:
|
||||
workspace_id: Workspace identifier
|
||||
@@ -672,7 +724,11 @@ async def get_agent_file(
|
||||
agent_id=agent_id,
|
||||
filename=filename,
|
||||
)
|
||||
return AgentFileResponse(filename=filename, content=content)
|
||||
return AgentFileResponse(
|
||||
filename=filename,
|
||||
content=content,
|
||||
**_runtime_scope_fields(),
|
||||
)
|
||||
except FileNotFoundError:
|
||||
raise HTTPException(status_code=404, detail=f"File '{filename}' not found")
|
||||
|
||||
@@ -686,7 +742,7 @@ async def update_agent_file(
|
||||
workspace_manager: RunWorkspaceManager = Depends(get_workspace_manager),
|
||||
):
|
||||
"""
|
||||
Update an agent's workspace file.
|
||||
Update an agent file in the run-scoped asset tree under `runs/<run_id>/`.
|
||||
|
||||
Args:
|
||||
workspace_id: Workspace identifier
|
||||
@@ -704,6 +760,10 @@ async def update_agent_file(
|
||||
filename=filename,
|
||||
content=content,
|
||||
)
|
||||
return AgentFileResponse(filename=filename, content=content)
|
||||
return AgentFileResponse(
|
||||
filename=filename,
|
||||
content=content,
|
||||
**_runtime_scope_fields(),
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@@ -7,7 +7,7 @@ Provides REST API endpoints for tool guard operations.
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, Optional
|
||||
from datetime import datetime
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
@@ -29,7 +29,7 @@ class ToolCallRequest(BaseModel):
|
||||
tool_name: str = Field(..., description="Name of the tool")
|
||||
tool_input: Dict[str, Any] = Field(default_factory=dict, description="Tool parameters")
|
||||
agent_id: str = Field(..., description="Agent making the request")
|
||||
workspace_id: str = Field(..., description="Workspace context")
|
||||
workspace_id: str = Field(..., description="Run context; historical field name retained for compatibility")
|
||||
session_id: Optional[str] = Field(None, description="Session identifier")
|
||||
|
||||
|
||||
@@ -46,6 +46,21 @@ class DenyRequest(BaseModel):
|
||||
reason: Optional[str] = Field(None, description="Reason for denial")
|
||||
|
||||
|
||||
class BatchApprovalRequest(BaseModel):
|
||||
"""Request to approve multiple tool calls."""
|
||||
approval_ids: List[str] = Field(..., description="List of approval request IDs")
|
||||
one_time: bool = Field(True, description="Whether these are one-time approvals")
|
||||
|
||||
|
||||
class BatchApprovalResponse(BaseModel):
|
||||
"""Response for batch approval operation."""
|
||||
approved: List[ApprovalResponse] = Field(default_factory=list, description="Successfully approved")
|
||||
failed: List[Dict[str, Any]] = Field(default_factory=list, description="Failed approvals with errors")
|
||||
total_requested: int
|
||||
total_approved: int
|
||||
total_failed: int
|
||||
|
||||
|
||||
class ToolFinding(BaseModel):
|
||||
"""Tool guard finding."""
|
||||
severity: SeverityLevel
|
||||
@@ -61,11 +76,17 @@ class ApprovalResponse(BaseModel):
|
||||
tool_input: Dict[str, Any]
|
||||
agent_id: str
|
||||
workspace_id: str
|
||||
run_id: str
|
||||
session_id: Optional[str] = None
|
||||
findings: List[ToolFinding] = Field(default_factory=list)
|
||||
created_at: str
|
||||
resolved_at: Optional[str] = None
|
||||
resolved_by: Optional[str] = None
|
||||
scope_type: str = "runtime_run"
|
||||
scope_note: str = (
|
||||
"Approvals are scoped to the active runtime run. `workspace_id` is "
|
||||
"retained as a compatibility field name; prefer `run_id` for display."
|
||||
)
|
||||
|
||||
|
||||
class PendingApprovalsResponse(BaseModel):
|
||||
@@ -91,6 +112,7 @@ def _to_response(record: ApprovalRecord) -> ApprovalResponse:
|
||||
tool_input=record.tool_input,
|
||||
agent_id=record.agent_id,
|
||||
workspace_id=record.workspace_id,
|
||||
run_id=record.workspace_id,
|
||||
session_id=record.session_id,
|
||||
findings=[ToolFinding(**f.to_dict()) for f in record.findings],
|
||||
created_at=record.created_at.isoformat(),
|
||||
@@ -124,7 +146,7 @@ async def check_tool_call(
|
||||
|
||||
if request.tool_name in SAFE_TOOLS:
|
||||
record.status = ApprovalStatus.APPROVED
|
||||
record.resolved_at = datetime.utcnow()
|
||||
record.resolved_at = datetime.now(UTC)
|
||||
record.resolved_by = "system"
|
||||
STORE.set_status(
|
||||
record.approval_id,
|
||||
@@ -156,9 +178,12 @@ async def approve_tool_call(
|
||||
if record.status != ApprovalStatus.PENDING:
|
||||
raise HTTPException(status_code=400, detail=f"Approval already {record.status}")
|
||||
|
||||
record.status = ApprovalStatus.APPROVED
|
||||
record.resolved_at = datetime.utcnow()
|
||||
record.resolved_by = "user"
|
||||
record = STORE.set_status(
|
||||
request.approval_id,
|
||||
ApprovalStatus.APPROVED,
|
||||
resolved_by="user",
|
||||
notify_request=True,
|
||||
)
|
||||
|
||||
return _to_response(record)
|
||||
|
||||
@@ -183,9 +208,12 @@ async def deny_tool_call(
|
||||
if record.status != ApprovalStatus.PENDING:
|
||||
raise HTTPException(status_code=400, detail=f"Approval already {record.status}")
|
||||
|
||||
record.status = ApprovalStatus.DENIED
|
||||
record.resolved_at = datetime.utcnow()
|
||||
record.resolved_by = "user"
|
||||
record = STORE.set_status(
|
||||
request.approval_id,
|
||||
ApprovalStatus.DENIED,
|
||||
resolved_by="user",
|
||||
notify_request=True,
|
||||
)
|
||||
record.metadata["denial_reason"] = request.reason
|
||||
|
||||
return _to_response(record)
|
||||
@@ -200,7 +228,7 @@ async def list_pending_approvals(
|
||||
List pending tool approval requests.
|
||||
|
||||
Args:
|
||||
workspace_id: Filter by workspace
|
||||
workspace_id: Filter by run id (historical query parameter name retained)
|
||||
agent_id: Filter by agent
|
||||
|
||||
Returns:
|
||||
@@ -255,3 +283,58 @@ async def cancel_approval(
|
||||
|
||||
STORE.cancel(approval_id)
|
||||
return _to_response(record)
|
||||
|
||||
|
||||
@router.post("/approve/batch", response_model=BatchApprovalResponse)
|
||||
async def batch_approve_tool_calls(
|
||||
request: BatchApprovalRequest,
|
||||
):
|
||||
"""
|
||||
Approve multiple pending tool calls in a single request.
|
||||
|
||||
Args:
|
||||
request: Batch approval parameters with list of approval IDs
|
||||
|
||||
Returns:
|
||||
Batch approval results with successful and failed approvals
|
||||
"""
|
||||
approved: List[ApprovalResponse] = []
|
||||
failed: List[Dict[str, Any]] = []
|
||||
|
||||
for approval_id in request.approval_ids:
|
||||
record = STORE.get(approval_id)
|
||||
if not record:
|
||||
failed.append({
|
||||
"approval_id": approval_id,
|
||||
"error": "Approval request not found",
|
||||
})
|
||||
continue
|
||||
|
||||
if record.status != ApprovalStatus.PENDING:
|
||||
failed.append({
|
||||
"approval_id": approval_id,
|
||||
"error": f"Approval already {record.status}",
|
||||
})
|
||||
continue
|
||||
|
||||
try:
|
||||
record = STORE.set_status(
|
||||
approval_id,
|
||||
ApprovalStatus.APPROVED,
|
||||
resolved_by="user",
|
||||
notify_request=True,
|
||||
)
|
||||
approved.append(_to_response(record))
|
||||
except Exception as e:
|
||||
failed.append({
|
||||
"approval_id": approval_id,
|
||||
"error": str(e),
|
||||
})
|
||||
|
||||
return BatchApprovalResponse(
|
||||
approved=approved,
|
||||
failed=failed,
|
||||
total_requested=len(request.approval_ids),
|
||||
total_approved=len(approved),
|
||||
total_failed=len(failed),
|
||||
)
|
||||
|
||||
@@ -219,6 +219,22 @@ class GatewayStatusResponse(BaseModel):
|
||||
is_running: bool
|
||||
port: int
|
||||
run_id: Optional[str] = None
|
||||
process_status: Optional[str] = None
|
||||
pid: Optional[int] = None
|
||||
|
||||
|
||||
class GatewayHealthResponse(BaseModel):
|
||||
status: str
|
||||
checks: Dict[str, Any]
|
||||
timestamp: str
|
||||
|
||||
|
||||
class RuntimeModeResponse(BaseModel):
|
||||
mode: str
|
||||
is_backtest: bool
|
||||
run_id: Optional[str] = None
|
||||
schedule_mode: Optional[str] = None
|
||||
is_running: bool
|
||||
|
||||
|
||||
class RuntimeConfigResponse(BaseModel):
|
||||
@@ -264,6 +280,49 @@ def _load_run_snapshot(run_id: str) -> Dict[str, Any]:
|
||||
return json.loads(snapshot_path.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def _load_run_server_state(run_dir: Path) -> Dict[str, Any]:
|
||||
"""Load persisted runtime server state if present."""
|
||||
server_state_path = run_dir / "state" / "server_state.json"
|
||||
if not server_state_path.exists():
|
||||
return {}
|
||||
try:
|
||||
return json.loads(server_state_path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def _extract_history_metrics(run_dir: Path) -> tuple[int, Optional[float]]:
|
||||
"""Prefer runtime state files over dashboard exports for history summaries."""
|
||||
server_state = _load_run_server_state(run_dir)
|
||||
portfolio = server_state.get("portfolio") or {}
|
||||
trades = server_state.get("trades")
|
||||
total_trades = len(trades) if isinstance(trades, list) else 0
|
||||
total_asset_value = None
|
||||
if portfolio.get("total_value") is not None:
|
||||
try:
|
||||
total_asset_value = float(portfolio.get("total_value"))
|
||||
except (TypeError, ValueError):
|
||||
total_asset_value = None
|
||||
|
||||
if total_trades or total_asset_value is not None:
|
||||
return total_trades, total_asset_value
|
||||
|
||||
summary_path = run_dir / "team_dashboard" / "summary.json"
|
||||
if not summary_path.exists():
|
||||
return 0, None
|
||||
try:
|
||||
summary = json.loads(summary_path.read_text(encoding="utf-8"))
|
||||
total_trades = int(summary.get("totalTrades") or 0)
|
||||
total_asset_value = (
|
||||
float(summary.get("totalAssetValue"))
|
||||
if summary.get("totalAssetValue") is not None
|
||||
else None
|
||||
)
|
||||
return total_trades, total_asset_value
|
||||
except Exception:
|
||||
return 0, None
|
||||
|
||||
|
||||
def _copy_path_if_exists(src: Path, dst: Path) -> None:
|
||||
if not src.exists():
|
||||
return
|
||||
@@ -281,7 +340,7 @@ def _restore_run_assets(source_run_id: str, target_run_dir: Path) -> None:
|
||||
raise HTTPException(status_code=404, detail=f"Source run not found: {source_run_id}")
|
||||
|
||||
for relative in [
|
||||
"team_dashboard",
|
||||
"team_dashboard/_internal_state.json",
|
||||
"agents",
|
||||
"skills",
|
||||
"memory",
|
||||
@@ -307,12 +366,10 @@ def _list_runs(limit: int = 50) -> list[RuntimeHistoryItem]:
|
||||
for run_dir in run_dirs[: max(1, int(limit))]:
|
||||
run_id = run_dir.name
|
||||
runtime_state_path = run_dir / "state" / "runtime_state.json"
|
||||
summary_path = run_dir / "team_dashboard" / "summary.json"
|
||||
|
||||
bootstrap: Dict[str, Any] = {}
|
||||
updated_at: Optional[str] = None
|
||||
total_trades = 0
|
||||
total_asset_value: Optional[float] = None
|
||||
total_trades, total_asset_value = _extract_history_metrics(run_dir)
|
||||
|
||||
if runtime_state_path.exists():
|
||||
try:
|
||||
@@ -323,15 +380,6 @@ def _list_runs(limit: int = 50) -> list[RuntimeHistoryItem]:
|
||||
except Exception:
|
||||
bootstrap = {}
|
||||
|
||||
if summary_path.exists():
|
||||
try:
|
||||
summary = json.loads(summary_path.read_text(encoding="utf-8"))
|
||||
total_trades = int(summary.get("totalTrades") or 0)
|
||||
total_asset_value = float(summary.get("totalAssetValue")) if summary.get("totalAssetValue") is not None else None
|
||||
except Exception:
|
||||
total_trades = 0
|
||||
total_asset_value = None
|
||||
|
||||
items.append(
|
||||
RuntimeHistoryItem(
|
||||
run_id=run_id,
|
||||
@@ -436,6 +484,14 @@ def _start_gateway_process(
|
||||
port: int
|
||||
) -> subprocess.Popen:
|
||||
"""Start Gateway as a separate process."""
|
||||
# Validate configuration before starting
|
||||
validation_errors = _validate_gateway_config(bootstrap)
|
||||
if validation_errors:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Gateway configuration validation failed: {'; '.join(validation_errors)}"
|
||||
)
|
||||
|
||||
# Prepare environment
|
||||
env = os.environ.copy()
|
||||
|
||||
@@ -467,6 +523,168 @@ def _start_gateway_process(
|
||||
return process
|
||||
|
||||
|
||||
def _validate_gateway_config(bootstrap: Dict[str, Any]) -> List[str]:
|
||||
"""Validate Gateway bootstrap configuration.
|
||||
|
||||
Returns a list of validation error messages. Empty list means valid.
|
||||
"""
|
||||
errors: List[str] = []
|
||||
|
||||
# Check required environment variables based on mode
|
||||
mode = bootstrap.get("mode", "live")
|
||||
is_backtest = mode == "backtest"
|
||||
|
||||
# Validate mode
|
||||
if mode not in ("live", "backtest"):
|
||||
errors.append(f"Invalid mode '{mode}': must be 'live' or 'backtest'")
|
||||
|
||||
# Check API keys based on mode
|
||||
if not is_backtest:
|
||||
# Live mode requires FINNHUB_API_KEY
|
||||
finnhub_key = os.getenv("FINNHUB_API_KEY")
|
||||
if not finnhub_key:
|
||||
errors.append("FINNHUB_API_KEY environment variable is required for live mode")
|
||||
|
||||
# Check LLM configuration
|
||||
model_name = os.getenv("MODEL_NAME")
|
||||
openai_key = os.getenv("OPENAI_API_KEY")
|
||||
if not model_name:
|
||||
errors.append("MODEL_NAME environment variable is not set")
|
||||
if not openai_key:
|
||||
errors.append("OPENAI_API_KEY environment variable is not set")
|
||||
|
||||
# Validate tickers
|
||||
tickers = bootstrap.get("tickers", [])
|
||||
if not tickers:
|
||||
errors.append("No tickers specified in configuration")
|
||||
elif not isinstance(tickers, list):
|
||||
errors.append("Tickers must be a list")
|
||||
|
||||
# Validate numeric values
|
||||
try:
|
||||
initial_cash = float(bootstrap.get("initial_cash", 0))
|
||||
if initial_cash <= 0:
|
||||
errors.append("initial_cash must be greater than 0")
|
||||
except (TypeError, ValueError):
|
||||
errors.append("initial_cash must be a valid number")
|
||||
|
||||
try:
|
||||
margin_requirement = float(bootstrap.get("margin_requirement", 0))
|
||||
if margin_requirement < 0 or margin_requirement > 1:
|
||||
errors.append("margin_requirement must be between 0 and 1")
|
||||
except (TypeError, ValueError):
|
||||
errors.append("margin_requirement must be a valid number")
|
||||
|
||||
# Validate backtest dates
|
||||
if is_backtest:
|
||||
start_date = bootstrap.get("start_date")
|
||||
end_date = bootstrap.get("end_date")
|
||||
if not start_date:
|
||||
errors.append("start_date is required for backtest mode")
|
||||
if not end_date:
|
||||
errors.append("end_date is required for backtest mode")
|
||||
if start_date and end_date:
|
||||
try:
|
||||
from datetime import datetime
|
||||
start = datetime.strptime(start_date, "%Y-%m-%d")
|
||||
end = datetime.strptime(end_date, "%Y-%m-%d")
|
||||
if start >= end:
|
||||
errors.append("start_date must be before end_date")
|
||||
except ValueError:
|
||||
errors.append("Dates must be in YYYY-MM-DD format")
|
||||
|
||||
# Validate schedule mode
|
||||
schedule_mode = bootstrap.get("schedule_mode", "daily")
|
||||
if schedule_mode not in ("daily", "intraday"):
|
||||
errors.append(f"Invalid schedule_mode '{schedule_mode}': must be 'daily' or 'intraday'")
|
||||
|
||||
return errors
|
||||
|
||||
|
||||
def _get_gateway_process_details() -> Dict[str, Any]:
|
||||
"""Get detailed information about the Gateway process."""
|
||||
process = _runtime_state.gateway_process
|
||||
details = {
|
||||
"pid": None,
|
||||
"status": "not_running",
|
||||
"returncode": None,
|
||||
}
|
||||
|
||||
if process is None:
|
||||
return details
|
||||
|
||||
details["pid"] = process.pid
|
||||
returncode = process.poll()
|
||||
|
||||
if returncode is None:
|
||||
details["status"] = "running"
|
||||
details["returncode"] = None
|
||||
else:
|
||||
details["status"] = "exited"
|
||||
details["returncode"] = returncode
|
||||
|
||||
return details
|
||||
|
||||
|
||||
def _check_gateway_health() -> Dict[str, Any]:
|
||||
"""Perform comprehensive health checks on Gateway."""
|
||||
checks = {
|
||||
"process": {"status": "unknown", "details": {}},
|
||||
"port": {"status": "unknown", "details": {}},
|
||||
"configuration": {"status": "unknown", "details": {}},
|
||||
}
|
||||
|
||||
# Check process status
|
||||
process_details = _get_gateway_process_details()
|
||||
checks["process"]["details"] = process_details
|
||||
|
||||
if process_details["status"] == "running":
|
||||
checks["process"]["status"] = "healthy"
|
||||
elif process_details["status"] == "exited":
|
||||
checks["process"]["status"] = "unhealthy"
|
||||
checks["process"]["details"]["error"] = f"Process exited with code {process_details['returncode']}"
|
||||
else:
|
||||
checks["process"]["status"] = "unknown"
|
||||
|
||||
# Check port connectivity
|
||||
import socket
|
||||
port = _runtime_state.gateway_port
|
||||
try:
|
||||
with socket.create_connection(("127.0.0.1", port), timeout=2):
|
||||
checks["port"]["status"] = "healthy"
|
||||
checks["port"]["details"] = {"port": port, "accessible": True}
|
||||
except OSError as e:
|
||||
checks["port"]["status"] = "unhealthy"
|
||||
checks["port"]["details"] = {"port": port, "accessible": False, "error": str(e)}
|
||||
|
||||
# Check configuration
|
||||
try:
|
||||
if _runtime_state.runtime_manager is not None:
|
||||
checks["configuration"]["status"] = "healthy"
|
||||
checks["configuration"]["details"]["has_runtime_manager"] = True
|
||||
else:
|
||||
checks["configuration"]["status"] = "degraded"
|
||||
checks["configuration"]["details"]["has_runtime_manager"] = False
|
||||
except Exception as e:
|
||||
checks["configuration"]["status"] = "unknown"
|
||||
checks["configuration"]["details"]["error"] = str(e)
|
||||
|
||||
# Determine overall status
|
||||
statuses = [c["status"] for c in checks.values()]
|
||||
if any(s == "unhealthy" for s in statuses):
|
||||
overall_status = "unhealthy"
|
||||
elif all(s == "healthy" for s in statuses):
|
||||
overall_status = "healthy"
|
||||
else:
|
||||
overall_status = "degraded"
|
||||
|
||||
return {
|
||||
"status": overall_status,
|
||||
"checks": checks,
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/context", response_model=RunContextResponse)
|
||||
async def get_run_context() -> RunContextResponse:
|
||||
"""Return active runtime context, or latest persisted context when stopped."""
|
||||
@@ -512,9 +730,10 @@ async def get_runtime_history(limit: int = 20) -> RuntimeHistoryResponse:
|
||||
|
||||
@router.get("/gateway/status", response_model=GatewayStatusResponse)
|
||||
async def get_gateway_status() -> GatewayStatusResponse:
|
||||
"""Get Gateway process status and port."""
|
||||
"""Get Gateway process status and port with detailed process information."""
|
||||
is_running = _is_gateway_running()
|
||||
run_id = None
|
||||
process_details = _get_gateway_process_details()
|
||||
|
||||
if is_running:
|
||||
try:
|
||||
@@ -525,10 +744,55 @@ async def get_gateway_status() -> GatewayStatusResponse:
|
||||
return GatewayStatusResponse(
|
||||
is_running=is_running,
|
||||
port=_runtime_state.gateway_port,
|
||||
run_id=run_id
|
||||
run_id=run_id,
|
||||
process_status=process_details["status"],
|
||||
pid=process_details["pid"],
|
||||
)
|
||||
|
||||
|
||||
@router.get("/gateway/health", response_model=GatewayHealthResponse)
|
||||
async def get_gateway_health() -> GatewayHealthResponse:
|
||||
"""Get comprehensive Gateway health check including process, port, and configuration status."""
|
||||
health = _check_gateway_health()
|
||||
return GatewayHealthResponse(**health)
|
||||
|
||||
|
||||
@router.get("/mode", response_model=RuntimeModeResponse)
|
||||
async def get_runtime_mode() -> RuntimeModeResponse:
|
||||
"""Get current runtime mode (live or backtest) and related configuration."""
|
||||
is_running = _is_gateway_running()
|
||||
|
||||
if not is_running:
|
||||
return RuntimeModeResponse(
|
||||
mode="stopped",
|
||||
is_backtest=False,
|
||||
run_id=None,
|
||||
schedule_mode=None,
|
||||
is_running=False,
|
||||
)
|
||||
|
||||
try:
|
||||
context = _get_active_runtime_context()
|
||||
bootstrap = context.get("bootstrap_values", {})
|
||||
mode = bootstrap.get("mode", "live")
|
||||
|
||||
return RuntimeModeResponse(
|
||||
mode=mode,
|
||||
is_backtest=mode == "backtest",
|
||||
run_id=context.get("config_name"),
|
||||
schedule_mode=bootstrap.get("schedule_mode"),
|
||||
is_running=True,
|
||||
)
|
||||
except HTTPException:
|
||||
return RuntimeModeResponse(
|
||||
mode="unknown",
|
||||
is_backtest=False,
|
||||
run_id=None,
|
||||
schedule_mode=None,
|
||||
is_running=False,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/gateway/port")
|
||||
async def get_gateway_port(request: Request) -> Dict[str, Any]:
|
||||
"""Get WebSocket Gateway port for frontend connection."""
|
||||
@@ -807,14 +1071,38 @@ async def start_runtime(
|
||||
_runtime_state.gateway_process = None
|
||||
log_path = _get_gateway_log_path_for_run(run_id)
|
||||
log_tail = _read_log_tail(log_path, max_chars=4000)
|
||||
|
||||
# Build detailed error message
|
||||
error_details = []
|
||||
error_details.append(f"Gateway process exited unexpectedly")
|
||||
|
||||
process_details = _get_gateway_process_details()
|
||||
if process_details.get("returncode") is not None:
|
||||
error_details.append(f"Exit code: {process_details['returncode']}")
|
||||
|
||||
if log_tail:
|
||||
error_details.append(f"Recent log output:\n{log_tail}")
|
||||
else:
|
||||
error_details.append("No log output available. Check environment configuration.")
|
||||
|
||||
# Check common configuration issues
|
||||
config_errors = _validate_gateway_config(bootstrap)
|
||||
if config_errors:
|
||||
error_details.append(f"Configuration issues detected: {'; '.join(config_errors)}")
|
||||
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Gateway failed to start: {log_tail or 'Unknown error'}"
|
||||
detail="\n".join(error_details)
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
_stop_gateway()
|
||||
raise HTTPException(status_code=500, detail=f"Failed to start Gateway: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to start Gateway: {type(e).__name__}: {str(e)}"
|
||||
)
|
||||
|
||||
return LaunchResponse(
|
||||
run_id=run_id,
|
||||
@@ -861,17 +1149,38 @@ async def stop_runtime(force: bool = True) -> StopResponse:
|
||||
was_running = _is_gateway_running()
|
||||
|
||||
if not was_running:
|
||||
process_details = _get_gateway_process_details()
|
||||
if process_details["status"] == "exited":
|
||||
# Process exited but we have a record of it
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=(
|
||||
f"No runtime is currently running. "
|
||||
f"Previous Gateway process exited with code {process_details['returncode']}. "
|
||||
f"PID: {process_details['pid']}"
|
||||
)
|
||||
)
|
||||
raise HTTPException(status_code=404, detail="No runtime is currently running")
|
||||
|
||||
# Get process details before stopping for the response
|
||||
process_details = _get_gateway_process_details()
|
||||
pid_info = f" (PID: {process_details.get('pid')})" if process_details.get('pid') else ""
|
||||
|
||||
# Stop Gateway process
|
||||
_stop_gateway()
|
||||
stop_success = _stop_gateway()
|
||||
|
||||
if not stop_success:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to stop Gateway process{pid_info}. Process may have already terminated."
|
||||
)
|
||||
|
||||
# Unregister runtime manager
|
||||
unregister_runtime_manager()
|
||||
|
||||
return StopResponse(
|
||||
status="stopped",
|
||||
message="Runtime stopped successfully",
|
||||
message=f"Runtime stopped successfully{pid_info}",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Workspace API Routes
|
||||
Workspace API Routes.
|
||||
|
||||
Provides REST API endpoints for workspace management.
|
||||
These routes manage the design-time `workspaces/` registry, not the run-scoped
|
||||
runtime data under `runs/<run_id>/`.
|
||||
"""
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
@@ -31,7 +32,7 @@ class UpdateWorkspaceRequest(BaseModel):
|
||||
|
||||
|
||||
class WorkspaceResponse(BaseModel):
|
||||
"""Workspace information response."""
|
||||
"""Design-time workspace information response."""
|
||||
workspace_id: str
|
||||
name: str
|
||||
description: str
|
||||
@@ -89,10 +90,10 @@ async def list_workspaces(
|
||||
manager: WorkspaceManager = Depends(get_workspace_manager),
|
||||
):
|
||||
"""
|
||||
List all workspaces.
|
||||
List all design-time workspaces.
|
||||
|
||||
Returns:
|
||||
List of workspaces
|
||||
List of design-time workspaces
|
||||
"""
|
||||
workspaces = manager.list_workspaces()
|
||||
return WorkspaceListResponse(
|
||||
|
||||
@@ -19,13 +19,31 @@ agent_factory: AgentFactory | None = None
|
||||
workspace_manager: WorkspaceManager | None = None
|
||||
|
||||
|
||||
def _build_scope_payload(project_root: Path) -> dict[str, object]:
|
||||
return {
|
||||
"design_time_registry": {
|
||||
"root": str(project_root / "workspaces"),
|
||||
"meaning": "Persistent control-plane workspace registry",
|
||||
},
|
||||
"runtime_assets": {
|
||||
"root": str(project_root / "runs"),
|
||||
"meaning": "Run-scoped runtime state and agent assets",
|
||||
},
|
||||
"agent_route_note": (
|
||||
"On `/api/workspaces/{workspace_id}/agents/...`, design-time CRUD "
|
||||
"routes still use `workspaces/`, while profile/skills/file routes "
|
||||
"use `workspace_id` as a run id under `runs/<run_id>/`."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def create_app(project_root: Path | None = None) -> FastAPI:
|
||||
"""Create the agent control-plane app."""
|
||||
resolved_project_root = project_root or Path(__file__).resolve().parents[2]
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(_app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
"""Initialize workspace and registry state for the control plane."""
|
||||
"""Initialize design-time workspace and registry state for the control plane."""
|
||||
global agent_factory, workspace_manager
|
||||
|
||||
workspace_manager = WorkspaceManager(project_root=resolved_project_root)
|
||||
@@ -34,7 +52,7 @@ def create_app(project_root: Path | None = None) -> FastAPI:
|
||||
|
||||
registry = get_registry()
|
||||
print("✓ 大时代 API started")
|
||||
print(f" - Workspaces root: {agent_factory.workspaces_root}")
|
||||
print(f" - Design workspaces root: {agent_factory.workspaces_root}")
|
||||
print(f" - Registered agents: {registry.get_agent_count()}")
|
||||
|
||||
yield
|
||||
@@ -63,6 +81,7 @@ def create_app(project_root: Path | None = None) -> FastAPI:
|
||||
if workspace_manager
|
||||
else 0
|
||||
),
|
||||
"scope_roots": _build_scope_payload(resolved_project_root),
|
||||
}
|
||||
|
||||
@app.get("/api/status")
|
||||
@@ -72,6 +91,7 @@ def create_app(project_root: Path | None = None) -> FastAPI:
|
||||
return {
|
||||
"status": "operational",
|
||||
"registry": registry.get_stats(),
|
||||
"scope": _build_scope_payload(resolved_project_root),
|
||||
}
|
||||
|
||||
app.include_router(workspaces_router)
|
||||
|
||||
@@ -1,5 +1,21 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Read-only OpenClaw CLI FastAPI surface."""
|
||||
"""Read-only OpenClaw CLI FastAPI surface.
|
||||
|
||||
COMPATIBILITY_SURFACE: deferred
|
||||
OWNER: runtime-team
|
||||
SEE: docs/legacy-inventory.md#openclaw-dual-integration
|
||||
|
||||
This is the REST facade (port 8004) for OpenClaw integration.
|
||||
For the WebSocket gateway integration, see:
|
||||
- backend/services/gateway_openclaw_handlers.py
|
||||
- shared/client/openclaw_websocket_client.py
|
||||
|
||||
Key differences:
|
||||
- REST facade: typed Pydantic models, request/response, polling
|
||||
- WebSocket: event-driven, real-time updates, bidirectional
|
||||
|
||||
Decision needed: which surface becomes the long-term contract?
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ from __future__ import annotations
|
||||
from fastapi import FastAPI
|
||||
|
||||
from backend.api import runtime_router
|
||||
from backend.api.runtime import get_runtime_state
|
||||
from backend.api.runtime import get_runtime_state, _check_gateway_health, _get_gateway_process_details
|
||||
from backend.apps.cors import add_cors_middleware
|
||||
|
||||
|
||||
@@ -22,29 +22,57 @@ def create_app() -> FastAPI:
|
||||
|
||||
@app.get("/health")
|
||||
async def health_check() -> dict[str, object]:
|
||||
"""Health check for the runtime service."""
|
||||
"""Health check for the runtime service with Gateway process status."""
|
||||
runtime_state = get_runtime_state()
|
||||
process = runtime_state.gateway_process
|
||||
process_details = _get_gateway_process_details()
|
||||
|
||||
is_running = process is not None and process.poll() is None
|
||||
|
||||
# Determine overall health status
|
||||
if is_running:
|
||||
status = "healthy"
|
||||
elif process is not None:
|
||||
# Process existed but exited
|
||||
status = "degraded"
|
||||
else:
|
||||
status = "healthy" # Service is healthy even without Gateway running
|
||||
|
||||
return {
|
||||
"status": "healthy",
|
||||
"status": status,
|
||||
"service": "runtime-service",
|
||||
"gateway_running": is_running,
|
||||
"gateway_port": runtime_state.gateway_port,
|
||||
"gateway": {
|
||||
"running": is_running,
|
||||
"port": runtime_state.gateway_port,
|
||||
"pid": process_details.get("pid"),
|
||||
"process_status": process_details.get("status"),
|
||||
"returncode": process_details.get("returncode"),
|
||||
},
|
||||
}
|
||||
|
||||
@app.get("/health/gateway")
|
||||
async def gateway_health_check() -> dict[str, object]:
|
||||
"""Detailed health check for the Gateway subprocess."""
|
||||
health = _check_gateway_health()
|
||||
return health
|
||||
|
||||
@app.get("/api/status")
|
||||
async def api_status() -> dict[str, object]:
|
||||
"""Service-level status payload for runtime orchestration."""
|
||||
runtime_state = get_runtime_state()
|
||||
process = runtime_state.gateway_process
|
||||
process_details = _get_gateway_process_details()
|
||||
|
||||
is_running = process is not None and process.poll() is None
|
||||
|
||||
return {
|
||||
"status": "operational",
|
||||
"service": "runtime-service",
|
||||
"runtime": {
|
||||
"gateway_running": is_running,
|
||||
"gateway_port": runtime_state.gateway_port,
|
||||
"gateway_pid": process_details.get("pid"),
|
||||
"gateway_process_status": process_details.get("status"),
|
||||
"has_runtime_manager": runtime_state.runtime_manager is not None,
|
||||
},
|
||||
}
|
||||
|
||||
328
backend/cli.py
328
backend/cli.py
@@ -5,12 +5,36 @@
|
||||
|
||||
This module provides easy-to-use commands for running backtest, live trading,
|
||||
and frontend development server.
|
||||
|
||||
ARCHITECTURE NOTE:
|
||||
==================
|
||||
This CLI supports TWO distinct runtime modes:
|
||||
|
||||
1. STANDALONE MODE (default):
|
||||
- Uses `evotraders backtest` or `evotraders live` commands
|
||||
- Starts a self-contained monolithic Gateway process with all agents
|
||||
- Suitable for: quick testing, single-machine deployment, development
|
||||
- WebSocket server runs on port 8765 (default)
|
||||
- No external service dependencies
|
||||
|
||||
2. MICROSERVICE MODE (production):
|
||||
- Uses `./start-dev.sh` or manual service orchestration
|
||||
- Runs 4 separate FastAPI services (agent, runtime, trading, news)
|
||||
- Gateway runs as a subprocess of runtime_service
|
||||
- Suitable for: production scaling, distributed deployment
|
||||
- Services communicate via REST APIs
|
||||
|
||||
When microservices are already running, standalone mode will warn you about
|
||||
port conflicts and potential confusion. Use `--force` to override.
|
||||
|
||||
For more details, see: docs/current-architecture.md
|
||||
"""
|
||||
# flake8: noqa: E501
|
||||
# pylint: disable=R0912, R0915
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
from datetime import datetime, timedelta
|
||||
@@ -42,6 +66,17 @@ from backend.data.market_store import MarketStore
|
||||
from backend.enrich.llm_enricher import get_explain_model_info, llm_enrichment_enabled
|
||||
from backend.enrich.news_enricher import enrich_symbols
|
||||
|
||||
# Microservice port definitions (for conflict detection)
|
||||
MICROSERVICE_PORTS = {
|
||||
"agent_service": 8000,
|
||||
"trading_service": 8001,
|
||||
"news_service": 8002,
|
||||
"runtime_service": 8003,
|
||||
}
|
||||
|
||||
# Gateway default port
|
||||
GATEWAY_PORT = 8765
|
||||
|
||||
app = typer.Typer(
|
||||
name="evotraders",
|
||||
help="大时代:自进化多智能体交易系统",
|
||||
@@ -72,6 +107,101 @@ def get_project_root() -> Path:
|
||||
return Path(__file__).parent.parent
|
||||
|
||||
|
||||
def _is_port_in_use(port: int, host: str = "127.0.0.1") -> bool:
|
||||
"""Check if a port is already in use."""
|
||||
try:
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
||||
sock.settimeout(1.0)
|
||||
result = sock.connect_ex((host, port))
|
||||
return result == 0
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _detect_running_microservices() -> dict[str, int]:
|
||||
"""Detect which microservices are already running."""
|
||||
running = {}
|
||||
for service_name, port in MICROSERVICE_PORTS.items():
|
||||
if _is_port_in_use(port):
|
||||
running[service_name] = port
|
||||
return running
|
||||
|
||||
|
||||
def _check_gateway_port_conflict(port: int) -> bool:
|
||||
"""Check if the Gateway port is already in use."""
|
||||
return _is_port_in_use(port)
|
||||
|
||||
|
||||
def _display_mode_warning(
|
||||
running_services: dict[str, int],
|
||||
gateway_port: int,
|
||||
force: bool = False,
|
||||
) -> bool:
|
||||
"""
|
||||
Display warning when microservices are detected.
|
||||
|
||||
Returns:
|
||||
True if should proceed, False if should abort
|
||||
"""
|
||||
if not running_services and not _check_gateway_port_conflict(gateway_port):
|
||||
return True
|
||||
|
||||
console.print()
|
||||
console.print(
|
||||
Panel.fit(
|
||||
"[bold yellow]⚠️ MICROSERVICE MODE DETECTED[/bold yellow]\n\n"
|
||||
"You are attempting to start in STANDALONE mode, but microservices "
|
||||
"appear to already be running. This can cause confusion and port conflicts.",
|
||||
border_style="yellow",
|
||||
)
|
||||
)
|
||||
|
||||
if running_services:
|
||||
console.print("\n[bold]Detected running services:[/bold]")
|
||||
for service, port in running_services.items():
|
||||
console.print(f" • {service}: [cyan]http://localhost:{port}[/cyan]")
|
||||
|
||||
if _check_gateway_port_conflict(gateway_port):
|
||||
console.print(
|
||||
f"\n[bold red]Port {gateway_port} is already in use![/bold red] "
|
||||
"Another Gateway instance may be running."
|
||||
)
|
||||
|
||||
console.print("\n[bold]Options:[/bold]")
|
||||
console.print(" 1. Stop microservices first: [cyan]pkill -f 'uvicorn|backend.main'[/cyan]")
|
||||
console.print(" 2. Use microservice mode instead: [cyan]./start-dev.sh[/cyan]")
|
||||
console.print(" 3. Use a different port: [cyan]--port <other_port>[/cyan]")
|
||||
|
||||
if force:
|
||||
console.print(
|
||||
"\n[yellow]⚠️ --force flag used. Proceeding despite conflicts...[/yellow]"
|
||||
)
|
||||
return True
|
||||
|
||||
console.print()
|
||||
should_proceed = Confirm.ask(
|
||||
"Do you want to proceed anyway?",
|
||||
default=False,
|
||||
)
|
||||
return should_proceed
|
||||
|
||||
|
||||
def _display_standalone_banner(mode: str, config_name: str) -> None:
|
||||
"""Display standalone mode startup banner."""
|
||||
console.print(
|
||||
Panel.fit(
|
||||
f"[bold cyan]大时代 {mode.upper()} Mode[/bold cyan]\n"
|
||||
"[dim]Standalone Mode (Monolithic Gateway)[/dim]",
|
||||
border_style="cyan",
|
||||
)
|
||||
)
|
||||
console.print("\n[dim]Architecture:[/dim]")
|
||||
console.print(" Mode: [yellow]Standalone (Single Process)[/yellow]")
|
||||
console.print(f" Config: [cyan]{config_name}[/cyan]")
|
||||
console.print("\n[dim]Note: This is NOT microservice mode. For distributed deployment,")
|
||||
console.print(" use ./start-dev.sh instead.[/dim]\n")
|
||||
|
||||
|
||||
def handle_history_cleanup(config_name: str, auto_clean: bool = False) -> None:
|
||||
"""
|
||||
Handle cleanup of historical data for a given config.
|
||||
@@ -215,8 +345,8 @@ def run_data_updater(project_root: Path) -> None:
|
||||
)
|
||||
|
||||
|
||||
def initialize_workspace(config_name: str) -> Path:
|
||||
"""Create run-scoped workspace files for a config."""
|
||||
def initialize_run_assets(config_name: str) -> Path:
|
||||
"""Create run-scoped agent assets and bootstrap files for a config."""
|
||||
workspace_manager = WorkspaceManager(project_root=get_project_root())
|
||||
workspace_manager.initialize_default_assets(
|
||||
config_name=config_name,
|
||||
@@ -438,14 +568,18 @@ def init_workspace(
|
||||
"default",
|
||||
"--config-name",
|
||||
"-c",
|
||||
help="Configuration name for the workspace",
|
||||
help="Run label under runs/<config_name> for the initialized asset tree.",
|
||||
),
|
||||
):
|
||||
"""Initialize run-scoped BOOTSTRAP and agent prompt asset files."""
|
||||
run_dir = initialize_workspace(config_name)
|
||||
"""Initialize run-scoped BOOTSTRAP and agent asset files.
|
||||
|
||||
The command name is retained for compatibility even though the target is
|
||||
the run-scoped asset tree under `runs/<config_name>/`.
|
||||
"""
|
||||
run_dir = initialize_run_assets(config_name)
|
||||
console.print(
|
||||
Panel.fit(
|
||||
f"[bold green]Workspace initialized[/bold green]\n[cyan]{run_dir}[/cyan]",
|
||||
f"[bold green]Run assets initialized[/bold green]\n[cyan]{run_dir}[/cyan]",
|
||||
border_style="green",
|
||||
),
|
||||
)
|
||||
@@ -861,6 +995,13 @@ def team_show(
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# STANDALONE MODE COMMANDS (backtest/live)
|
||||
# =============================================================================
|
||||
# These commands start a self-contained monolithic Gateway process.
|
||||
# For microservice mode, use ./start-dev.sh instead.
|
||||
# =============================================================================
|
||||
|
||||
@app.command()
|
||||
def backtest(
|
||||
start: Optional[str] = typer.Option(
|
||||
@@ -876,10 +1017,10 @@ def backtest(
|
||||
help="End date for backtest (YYYY-MM-DD)",
|
||||
),
|
||||
config_name: str = typer.Option(
|
||||
"backtest",
|
||||
"default_backtest_run",
|
||||
"--config-name",
|
||||
"-c",
|
||||
help="Configuration name for this backtest run",
|
||||
help="Run label under runs/<config_name> for this backtest runtime.",
|
||||
),
|
||||
host: str = typer.Option(
|
||||
"0.0.0.0",
|
||||
@@ -887,7 +1028,7 @@ def backtest(
|
||||
help="WebSocket server host",
|
||||
),
|
||||
port: int = typer.Option(
|
||||
8765,
|
||||
GATEWAY_PORT,
|
||||
"--port",
|
||||
"-p",
|
||||
help="WebSocket server port",
|
||||
@@ -907,22 +1048,24 @@ def backtest(
|
||||
"--enable-memory",
|
||||
help="Enable ReMeTaskLongTermMemory for agents (requires MEMORY_API_KEY)",
|
||||
),
|
||||
force: bool = typer.Option(
|
||||
False,
|
||||
"--force",
|
||||
help="Force start even if microservices are detected (may cause conflicts)",
|
||||
),
|
||||
):
|
||||
"""
|
||||
Run backtest mode with historical data.
|
||||
Run backtest mode in STANDALONE mode (monolithic Gateway).
|
||||
|
||||
Example:
|
||||
This starts a self-contained process with all agents. For microservice
|
||||
mode (distributed services), use ./start-dev.sh instead.
|
||||
|
||||
Examples:
|
||||
evotraders backtest --start 2025-11-01 --end 2025-12-01
|
||||
evotraders backtest --config-name my_strategy --port 9000
|
||||
evotraders backtest --clean # Clear historical data before starting
|
||||
evotraders backtest --enable-memory # Enable long-term memory
|
||||
"""
|
||||
console.print(
|
||||
Panel.fit(
|
||||
"[bold cyan]大时代 Backtest Mode[/bold cyan]",
|
||||
border_style="cyan",
|
||||
),
|
||||
)
|
||||
poll_interval = int(_normalize_typer_value(poll_interval, 10))
|
||||
|
||||
# Validate dates - required for backtest
|
||||
@@ -948,13 +1091,18 @@ def backtest(
|
||||
)
|
||||
raise typer.Exit(1) from exc
|
||||
|
||||
# Handle historical data cleanup
|
||||
handle_history_cleanup(config_name, auto_clean=clean)
|
||||
# Check for microservice conflicts
|
||||
running_services = _detect_running_microservices()
|
||||
if running_services or _check_gateway_port_conflict(port):
|
||||
if not _display_mode_warning(running_services, port, force=force):
|
||||
console.print("\n[yellow]Startup aborted.[/yellow]")
|
||||
raise typer.Exit(0)
|
||||
|
||||
# Display standalone mode banner
|
||||
_display_standalone_banner("backtest", config_name)
|
||||
|
||||
# Display configuration
|
||||
console.print("\n[bold]Configuration:[/bold]")
|
||||
console.print(" Mode: Backtest")
|
||||
console.print(f" Config: {config_name}")
|
||||
console.print(f" Period: {start} -> {end}")
|
||||
console.print(f" Server: {host}:{port}")
|
||||
console.print(f" Poll Interval: {poll_interval}s")
|
||||
@@ -964,6 +1112,9 @@ def backtest(
|
||||
console.print("\nAccess frontend at: [cyan]http://localhost:5173[/cyan]")
|
||||
console.print("Press Ctrl+C to stop\n")
|
||||
|
||||
# Handle historical data cleanup
|
||||
handle_history_cleanup(config_name, auto_clean=clean)
|
||||
|
||||
# Change to project root
|
||||
project_root = get_project_root()
|
||||
os.chdir(project_root)
|
||||
@@ -1020,10 +1171,10 @@ def backtest(
|
||||
@app.command()
|
||||
def live(
|
||||
config_name: str = typer.Option(
|
||||
"live",
|
||||
"default_live_run",
|
||||
"--config-name",
|
||||
"-c",
|
||||
help="Configuration name for this live run",
|
||||
help="Run label under runs/<config_name> for this live runtime.",
|
||||
),
|
||||
host: str = typer.Option(
|
||||
"0.0.0.0",
|
||||
@@ -1031,7 +1182,7 @@ def live(
|
||||
help="WebSocket server host",
|
||||
),
|
||||
port: int = typer.Option(
|
||||
8765,
|
||||
GATEWAY_PORT,
|
||||
"--port",
|
||||
"-p",
|
||||
help="WebSocket server port",
|
||||
@@ -1067,11 +1218,19 @@ def live(
|
||||
"--enable-memory",
|
||||
help="Enable ReMeTaskLongTermMemory for agents (requires MEMORY_API_KEY)",
|
||||
),
|
||||
force: bool = typer.Option(
|
||||
False,
|
||||
"--force",
|
||||
help="Force start even if microservices are detected (may cause conflicts)",
|
||||
),
|
||||
):
|
||||
"""
|
||||
Run live trading mode with real-time data.
|
||||
Run live trading mode in STANDALONE mode (monolithic Gateway).
|
||||
|
||||
Example:
|
||||
This starts a self-contained process with all agents. For microservice
|
||||
mode (distributed services), use ./start-dev.sh instead.
|
||||
|
||||
Examples:
|
||||
evotraders live # Run immediately (default)
|
||||
evotraders live -t 22:30 # Run at 22:30 local time daily
|
||||
evotraders live --schedule-mode intraday --interval-minutes 60
|
||||
@@ -1080,12 +1239,16 @@ def live(
|
||||
"""
|
||||
schedule_mode = str(_normalize_typer_value(schedule_mode, "daily"))
|
||||
interval_minutes = int(_normalize_typer_value(interval_minutes, 60))
|
||||
console.print(
|
||||
Panel.fit(
|
||||
"[bold cyan]大时代 LIVE Mode[/bold cyan]",
|
||||
border_style="cyan",
|
||||
),
|
||||
)
|
||||
|
||||
# Check for microservice conflicts
|
||||
running_services = _detect_running_microservices()
|
||||
if running_services or _check_gateway_port_conflict(port):
|
||||
if not _display_mode_warning(running_services, port, force=force):
|
||||
console.print("\n[yellow]Startup aborted.[/yellow]")
|
||||
raise typer.Exit(0)
|
||||
|
||||
# Display standalone mode banner
|
||||
_display_standalone_banner("live", config_name)
|
||||
|
||||
# Check for required API key in live mode
|
||||
env_file = get_project_root() / ".env"
|
||||
@@ -1161,9 +1324,8 @@ def live(
|
||||
# Display configuration
|
||||
console.print("\n[bold]Configuration:[/bold]")
|
||||
console.print(
|
||||
" Mode: [green]LIVE[/green] (Real-time prices via Finnhub)",
|
||||
" Data Mode: [green]LIVE[/green] (Real-time prices via Finnhub)",
|
||||
)
|
||||
console.print(f" Config: {config_name}")
|
||||
console.print(f" Server: {host}:{port}")
|
||||
console.print(f" Poll Interval: {poll_interval}s")
|
||||
console.print(
|
||||
@@ -1230,7 +1392,7 @@ def live(
|
||||
@app.command()
|
||||
def frontend(
|
||||
port: int = typer.Option(
|
||||
8765,
|
||||
GATEWAY_PORT,
|
||||
"--ws-port",
|
||||
"-p",
|
||||
help="WebSocket server port to connect to",
|
||||
@@ -1317,6 +1479,90 @@ def frontend(
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
@app.command()
|
||||
def status(
|
||||
detailed: bool = typer.Option(
|
||||
False,
|
||||
"--detailed",
|
||||
"-d",
|
||||
help="Show detailed service information",
|
||||
),
|
||||
):
|
||||
"""
|
||||
Check the status of running services (microservice or standalone mode).
|
||||
|
||||
Detects whether microservices are running and shows their health status.
|
||||
"""
|
||||
console.print(
|
||||
Panel.fit(
|
||||
"[bold cyan]大时代 Service Status[/bold cyan]",
|
||||
border_style="cyan",
|
||||
)
|
||||
)
|
||||
|
||||
running_services = _detect_running_microservices()
|
||||
gateway_running = _check_gateway_port_conflict(GATEWAY_PORT)
|
||||
|
||||
# Determine mode
|
||||
if running_services:
|
||||
mode = "microservice"
|
||||
console.print(f"\n[bold]Mode:[/bold] [green]{mode.upper()}[/green]")
|
||||
console.print("[dim]Microservices are running on the following ports:[/dim]\n")
|
||||
|
||||
table = Table(title="Running Microservices")
|
||||
table.add_column("Service", style="cyan")
|
||||
table.add_column("Port", justify="right")
|
||||
table.add_column("URL")
|
||||
|
||||
for service, port in running_services.items():
|
||||
url = f"http://localhost:{port}"
|
||||
table.add_row(service, str(port), url)
|
||||
|
||||
if gateway_running:
|
||||
table.add_row(
|
||||
"gateway (WebSocket)",
|
||||
str(GATEWAY_PORT),
|
||||
f"ws://localhost:{GATEWAY_PORT}",
|
||||
)
|
||||
|
||||
console.print(table)
|
||||
elif gateway_running:
|
||||
mode = "standalone"
|
||||
console.print(f"\n[bold]Mode:[/bold] [yellow]{mode.upper()}[/yellow]")
|
||||
console.print("[dim]Standalone Gateway is running (monolithic mode)[/dim]")
|
||||
console.print(f"\n Gateway: [cyan]ws://localhost:{GATEWAY_PORT}[/cyan]")
|
||||
else:
|
||||
console.print(f"\n[bold]Mode:[/bold] [red]NOT RUNNING[/red]")
|
||||
console.print("\n[dim]No services detected. Start with:[/dim]")
|
||||
console.print(" • Standalone: [cyan]evotraders backtest[/cyan] or [cyan]evotraders live[/cyan]")
|
||||
console.print(" • Microservice: [cyan]./start-dev.sh[/cyan]")
|
||||
|
||||
if detailed and running_services:
|
||||
console.print("\n[bold]Health Checks:[/bold]")
|
||||
import urllib.request
|
||||
import json
|
||||
|
||||
for service, port in running_services.items():
|
||||
try:
|
||||
req = urllib.request.Request(
|
||||
f"http://localhost:{port}/health",
|
||||
method="GET",
|
||||
headers={"Accept": "application/json"},
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=2) as response:
|
||||
if response.status == 200:
|
||||
data = json.loads(response.read().decode())
|
||||
status_text = data.get("status", "unknown")
|
||||
color = "green" if status_text == "healthy" else "yellow"
|
||||
console.print(f" {service}: [{color}]{status_text}[/{color}]")
|
||||
else:
|
||||
console.print(f" {service}: [yellow]HTTP {response.status}[/yellow]")
|
||||
except Exception as e:
|
||||
console.print(f" {service}: [red]unreachable ({type(e).__name__})[/red]")
|
||||
|
||||
console.print()
|
||||
|
||||
|
||||
@app.command()
|
||||
def version():
|
||||
"""Show the version of 大时代."""
|
||||
@@ -1330,7 +1576,17 @@ def main():
|
||||
"""
|
||||
大时代:自进化多智能体交易系统
|
||||
|
||||
Use 'evotraders --help' to see available commands.
|
||||
RUNTIME MODES:
|
||||
--------------
|
||||
• STANDALONE (default): Use 'evotraders backtest' or 'evotraders live'
|
||||
Starts a self-contained monolithic Gateway with all agents.
|
||||
Best for: quick testing, single-machine deployment
|
||||
|
||||
• MICROSERVICE: Use './start-dev.sh'
|
||||
Starts 4 separate FastAPI services + Gateway subprocess.
|
||||
Best for: production scaling, distributed deployment
|
||||
|
||||
Use 'evotraders status' to check which mode is currently running.
|
||||
"""
|
||||
|
||||
|
||||
|
||||
@@ -77,7 +77,7 @@ def get_bootstrap_config_for_run(
|
||||
project_root: Path,
|
||||
config_name: str,
|
||||
) -> BootstrapConfig:
|
||||
"""Load BOOTSTRAP.md from the run workspace."""
|
||||
"""Load BOOTSTRAP.md from the run-scoped asset tree."""
|
||||
return load_bootstrap_config(
|
||||
project_root / "runs" / config_name / "BOOTSTRAP.md",
|
||||
)
|
||||
|
||||
@@ -26,13 +26,45 @@ from backend.agents.team_pipeline_config import (
|
||||
resolve_active_analysts,
|
||||
update_active_analysts,
|
||||
)
|
||||
from backend.agents import AnalystAgent
|
||||
from backend.agents import AnalystAgent, EvoAgent
|
||||
from backend.agents.agent_workspace import load_agent_workspace_config
|
||||
from backend.agents.toolkit_factory import create_agent_toolkit
|
||||
from backend.agents.workspace_manager import WorkspaceManager
|
||||
from backend.agents.prompt_loader import get_prompt_loader
|
||||
from backend.llm.models import get_agent_formatter, get_agent_model
|
||||
from backend.config.constants import ANALYST_TYPES
|
||||
|
||||
|
||||
def _resolve_evo_agent_ids() -> set[str]:
|
||||
"""Return agent ids selected to use EvoAgent.
|
||||
|
||||
By default, all supported roles use EvoAgent.
|
||||
EVO_AGENT_IDS can be used to limit to specific roles.
|
||||
|
||||
Supported roles:
|
||||
- analyst roles (fundamentals, technical, sentiment, valuation)
|
||||
- risk_manager
|
||||
- portfolio_manager
|
||||
|
||||
Example:
|
||||
EVO_AGENT_IDS=fundamentals_analyst,risk_manager,portfolio_manager
|
||||
"""
|
||||
raw = os.getenv("EVO_AGENT_IDS", "")
|
||||
if not raw.strip():
|
||||
# Default: all supported roles use EvoAgent
|
||||
return set(ANALYST_TYPES) | {"risk_manager", "portfolio_manager"}
|
||||
|
||||
requested = {
|
||||
item.strip()
|
||||
for item in raw.split(",")
|
||||
if item.strip()
|
||||
}
|
||||
return {
|
||||
agent_id
|
||||
for agent_id in requested
|
||||
if agent_id in ANALYST_TYPES or agent_id in {"risk_manager", "portfolio_manager"}
|
||||
}
|
||||
|
||||
# Team infrastructure imports (graceful import - may not exist yet)
|
||||
try:
|
||||
from backend.agents.team.team_coordinator import TeamCoordinator
|
||||
@@ -140,6 +172,10 @@ class TradingPipeline:
|
||||
session_key = TradingSessionKey(date=date).key()
|
||||
self._session_key = session_key
|
||||
active_analysts = self._get_active_analysts()
|
||||
self._sync_agent_runtime_context(
|
||||
agents=active_analysts + [self.risk_manager, self.pm],
|
||||
session_key=session_key,
|
||||
)
|
||||
if self.runtime_manager:
|
||||
self.runtime_manager.set_session_key(session_key)
|
||||
self._runtime_log_event("cycle:start", {"tickers": tickers, "date": date})
|
||||
@@ -1488,108 +1524,6 @@ class TradingPipeline:
|
||||
return "Decisions: " + "; ".join(decision_texts)
|
||||
return "Portfolio analysis completed. No trades recommended."
|
||||
|
||||
def load_agents_from_workspace(
|
||||
self,
|
||||
workspace_id: str,
|
||||
agent_factory: Optional[Any] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Load agents from workspace using AgentFactory.
|
||||
|
||||
This method supports the new EvoAgent architecture by loading
|
||||
agents from a workspace instead of using hardcoded agents.
|
||||
|
||||
Args:
|
||||
workspace_id: Workspace identifier
|
||||
agent_factory: Optional AgentFactory instance (uses self.agent_factory if None)
|
||||
|
||||
Returns:
|
||||
Dictionary with loaded agents:
|
||||
{
|
||||
"analysts": List[EvoAgent],
|
||||
"risk_manager": EvoAgent,
|
||||
"portfolio_manager": EvoAgent,
|
||||
}
|
||||
|
||||
Raises:
|
||||
ValueError: If workspace doesn't exist or no agents found
|
||||
"""
|
||||
factory = agent_factory or self.agent_factory
|
||||
if factory is None:
|
||||
from backend.agents import AgentFactory
|
||||
factory = AgentFactory()
|
||||
|
||||
# Check workspace exists
|
||||
if not factory.workspaces_root.exists():
|
||||
raise ValueError(f"Workspaces root does not exist: {factory.workspaces_root}")
|
||||
|
||||
workspace_dir = factory.workspaces_root / workspace_id
|
||||
if not workspace_dir.exists():
|
||||
raise ValueError(f"Workspace '{workspace_id}' does not exist")
|
||||
|
||||
# Load agents from workspace
|
||||
agents_data = factory.list_agents(workspace_id=workspace_id)
|
||||
|
||||
if not agents_data:
|
||||
raise ValueError(f"No agents found in workspace '{workspace_id}'")
|
||||
|
||||
# Categorize agents by type
|
||||
analysts = []
|
||||
risk_manager = None
|
||||
portfolio_manager = None
|
||||
|
||||
for agent_data in agents_data:
|
||||
agent_type = agent_data.get("agent_type", "unknown")
|
||||
agent_id = agent_data.get("agent_id")
|
||||
|
||||
# Load full agent configuration
|
||||
config_path = Path(agent_data.get("config_path", ""))
|
||||
if config_path.exists():
|
||||
agent = factory.load_agent(agent_id, workspace_id)
|
||||
|
||||
if agent_type.endswith("_analyst"):
|
||||
analysts.append(agent)
|
||||
elif agent_type == "risk_manager":
|
||||
risk_manager = agent
|
||||
elif agent_type == "portfolio_manager":
|
||||
portfolio_manager = agent
|
||||
|
||||
if not analysts:
|
||||
raise ValueError(f"No analysts found in workspace '{workspace_id}'")
|
||||
if risk_manager is None:
|
||||
raise ValueError(f"No risk_manager found in workspace '{workspace_id}'")
|
||||
if portfolio_manager is None:
|
||||
raise ValueError(f"No portfolio_manager found in workspace '{workspace_id}'")
|
||||
|
||||
return {
|
||||
"analysts": analysts,
|
||||
"risk_manager": risk_manager,
|
||||
"portfolio_manager": portfolio_manager,
|
||||
}
|
||||
|
||||
def reload_agents_from_workspace(self, workspace_id: Optional[str] = None) -> None:
|
||||
"""
|
||||
Reload all agents from workspace.
|
||||
|
||||
This updates self.analysts, self.risk_manager, and self.pm
|
||||
with agents loaded from the specified workspace.
|
||||
|
||||
Args:
|
||||
workspace_id: Workspace ID (uses self.workspace_id if None)
|
||||
"""
|
||||
ws_id = workspace_id or self.workspace_id
|
||||
if not ws_id:
|
||||
raise ValueError("No workspace_id specified")
|
||||
|
||||
loaded = self.load_agents_from_workspace(ws_id)
|
||||
|
||||
self.analysts = loaded["analysts"]
|
||||
self.risk_manager = loaded["risk_manager"]
|
||||
self.pm = loaded["portfolio_manager"]
|
||||
self.workspace_id = ws_id
|
||||
|
||||
logger.info(f"Reloaded {len(self.analysts)} analysts from workspace '{ws_id}'")
|
||||
|
||||
def _runtime_update_status(self, agent: Any, status: str) -> None:
|
||||
if not self.runtime_manager:
|
||||
return
|
||||
@@ -1602,6 +1536,28 @@ class TradingPipeline:
|
||||
for agent in agents:
|
||||
self._runtime_update_status(agent, status)
|
||||
|
||||
def _sync_agent_runtime_context(
|
||||
self,
|
||||
agents: List[Any],
|
||||
session_key: str,
|
||||
) -> None:
|
||||
"""Propagate run/session identifiers onto agent instances.
|
||||
|
||||
EvoAgent's tool-guard approval records depend on workspace/session
|
||||
context being present on the agent object at runtime.
|
||||
"""
|
||||
config_name = getattr(self.pm, "config", {}).get("config_name", "default")
|
||||
for agent in agents:
|
||||
try:
|
||||
setattr(agent, "session_id", session_key)
|
||||
if not getattr(agent, "run_id", None):
|
||||
setattr(agent, "run_id", config_name)
|
||||
# Keep workspace_id for backward compatibility
|
||||
if not getattr(agent, "workspace_id", None):
|
||||
setattr(agent, "workspace_id", config_name)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
def _all_analysts(self) -> List[Any]:
|
||||
"""Return static analysts plus runtime-created analysts."""
|
||||
return list(self.analysts) + list(self._dynamic_analysts.values())
|
||||
@@ -1630,18 +1586,46 @@ class TradingPipeline:
|
||||
),
|
||||
)
|
||||
|
||||
agent = AnalystAgent(
|
||||
analyst_type=analyst_type,
|
||||
toolkit=create_agent_toolkit(
|
||||
# Determine whether to use EvoAgent based on EVO_AGENT_IDS
|
||||
use_evo_agent = analyst_type in _resolve_evo_agent_ids()
|
||||
|
||||
if use_evo_agent:
|
||||
from backend.agents.skills_manager import SkillsManager
|
||||
skills_manager = SkillsManager(project_root=project_root)
|
||||
workspace_dir = skills_manager.get_agent_asset_dir(
|
||||
config_name,
|
||||
agent_id,
|
||||
)
|
||||
agent_config = load_agent_workspace_config(workspace_dir / "agent.yaml")
|
||||
agent = EvoAgent(
|
||||
agent_id=agent_id,
|
||||
config_name=config_name,
|
||||
workspace_dir=workspace_dir,
|
||||
model=get_agent_model(analyst_type),
|
||||
formatter=get_agent_formatter(analyst_type),
|
||||
prompt_files=agent_config.prompt_files,
|
||||
)
|
||||
agent.toolkit = create_agent_toolkit(
|
||||
agent_id=agent_id,
|
||||
config_name=config_name,
|
||||
active_skill_dirs=[],
|
||||
),
|
||||
model=get_agent_model(analyst_type),
|
||||
formatter=get_agent_formatter(analyst_type),
|
||||
agent_id=agent_id,
|
||||
config={"config_name": config_name},
|
||||
)
|
||||
)
|
||||
setattr(agent, "run_id", config_name)
|
||||
# Keep workspace_id for backward compatibility
|
||||
setattr(agent, "workspace_id", config_name)
|
||||
else:
|
||||
agent = AnalystAgent(
|
||||
analyst_type=analyst_type,
|
||||
toolkit=create_agent_toolkit(
|
||||
agent_id=agent_id,
|
||||
config_name=config_name,
|
||||
active_skill_dirs=[],
|
||||
),
|
||||
model=get_agent_model(analyst_type),
|
||||
formatter=get_agent_formatter(analyst_type),
|
||||
agent_id=agent_id,
|
||||
config={"config_name": config_name},
|
||||
)
|
||||
self._dynamic_analysts[agent_id] = agent
|
||||
update_active_analysts(
|
||||
project_root=project_root,
|
||||
|
||||
@@ -12,9 +12,10 @@ import asyncio
|
||||
import os
|
||||
from contextlib import AsyncExitStack
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional, Callable
|
||||
from typing import Any, Dict, List, Optional, Callable
|
||||
|
||||
from backend.agents import AnalystAgent, PMAgent, RiskAgent
|
||||
from backend.agents import AnalystAgent, EvoAgent, PMAgent, RiskAgent
|
||||
from backend.agents.agent_workspace import load_agent_workspace_config
|
||||
from backend.agents.skills_manager import SkillsManager
|
||||
from backend.agents.toolkit_factory import create_agent_toolkit, load_agent_profiles
|
||||
from backend.agents.prompt_loader import get_prompt_loader
|
||||
@@ -41,6 +42,9 @@ _prompt_loader = get_prompt_loader()
|
||||
# Global gateway reference for cleanup
|
||||
_gateway_instance: Optional[Gateway] = None
|
||||
|
||||
# Global long-term memory references for persistence
|
||||
_long_term_memories: List[Any] = []
|
||||
|
||||
|
||||
def _set_gateway(gateway: Optional[Gateway]) -> None:
|
||||
"""Set global gateway reference."""
|
||||
@@ -61,6 +65,101 @@ def stop_gateway() -> None:
|
||||
_gateway_instance = None
|
||||
|
||||
|
||||
def _set_long_term_memories(memories: List[Any]) -> None:
|
||||
"""Set global long-term memory references."""
|
||||
global _long_term_memories
|
||||
_long_term_memories = memories
|
||||
|
||||
|
||||
def _clear_long_term_memories() -> None:
|
||||
"""Clear global long-term memory references."""
|
||||
global _long_term_memories
|
||||
_long_term_memories = []
|
||||
|
||||
|
||||
def _persist_long_term_memories_sync() -> None:
|
||||
"""
|
||||
Synchronously persist all long-term memories before shutdown.
|
||||
|
||||
This function ensures all memory data is flushed to disk/vector store
|
||||
before the process exits. Should be called during cleanup.
|
||||
"""
|
||||
global _long_term_memories
|
||||
if not _long_term_memories:
|
||||
return
|
||||
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.info(f"[MemoryPersistence] Persisting {len(_long_term_memories)} memory instances...")
|
||||
|
||||
for i, memory in enumerate(_long_term_memories):
|
||||
try:
|
||||
# Try to save memory if it has a save method
|
||||
if hasattr(memory, 'save') and callable(getattr(memory, 'save')):
|
||||
if hasattr(memory, 'sync') and callable(getattr(memory, 'sync')):
|
||||
# Use sync version if available
|
||||
memory.sync()
|
||||
logger.debug(f"[MemoryPersistence] Synced memory {i}")
|
||||
else:
|
||||
# Try async save with event loop
|
||||
import asyncio
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
if loop.is_running():
|
||||
# Schedule save in running loop
|
||||
loop.create_task(memory.save())
|
||||
logger.debug(f"[MemoryPersistence] Scheduled save for memory {i}")
|
||||
else:
|
||||
loop.run_until_complete(memory.save())
|
||||
logger.debug(f"[MemoryPersistence] Saved memory {i}")
|
||||
except RuntimeError:
|
||||
# No event loop, skip async save
|
||||
pass
|
||||
|
||||
# Try to flush any pending writes
|
||||
if hasattr(memory, 'flush') and callable(getattr(memory, 'flush')):
|
||||
memory.flush()
|
||||
logger.debug(f"[MemoryPersistence] Flushed memory {i}")
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"[MemoryPersistence] Failed to persist memory {i}: {e}")
|
||||
|
||||
logger.info("[MemoryPersistence] Memory persistence complete")
|
||||
|
||||
|
||||
async def _persist_long_term_memories_async() -> None:
|
||||
"""
|
||||
Asynchronously persist all long-term memories.
|
||||
|
||||
This is the preferred method for persisting memories when
|
||||
an async context is available.
|
||||
"""
|
||||
global _long_term_memories
|
||||
if not _long_term_memories:
|
||||
return
|
||||
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.info(f"[MemoryPersistence] Persisting {len(_long_term_memories)} memory instances async...")
|
||||
|
||||
for i, memory in enumerate(_long_term_memories):
|
||||
try:
|
||||
# Try async save first
|
||||
if hasattr(memory, 'save') and callable(getattr(memory, 'save')):
|
||||
await memory.save()
|
||||
logger.debug(f"[MemoryPersistence] Saved memory {i} (async)")
|
||||
|
||||
# Try flush if available
|
||||
if hasattr(memory, 'flush') and callable(getattr(memory, 'flush')):
|
||||
memory.flush()
|
||||
logger.debug(f"[MemoryPersistence] Flushed memory {i}")
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"[MemoryPersistence] Failed to persist memory {i}: {e}")
|
||||
|
||||
logger.info("[MemoryPersistence] Async memory persistence complete")
|
||||
|
||||
|
||||
def create_long_term_memory(agent_name: str, run_id: str, run_dir: Path):
|
||||
"""Create ReMeTaskLongTermMemory for an agent."""
|
||||
try:
|
||||
@@ -96,6 +195,179 @@ def create_long_term_memory(agent_name: str, run_id: str, run_dir: Path):
|
||||
)
|
||||
|
||||
|
||||
def _resolve_evo_agent_ids() -> set[str]:
|
||||
"""Return agent ids selected to use EvoAgent.
|
||||
|
||||
By default, all supported roles use EvoAgent.
|
||||
"""
|
||||
raw = os.getenv("EVO_AGENT_IDS", "")
|
||||
if not raw.strip():
|
||||
# Default: all supported roles use EvoAgent
|
||||
return set(ANALYST_TYPES) | {"risk_manager", "portfolio_manager"}
|
||||
|
||||
requested = {
|
||||
item.strip()
|
||||
for item in raw.split(",")
|
||||
if item.strip()
|
||||
}
|
||||
return {
|
||||
agent_id
|
||||
for agent_id in requested
|
||||
if agent_id in ANALYST_TYPES or agent_id in {"risk_manager", "portfolio_manager"}
|
||||
}
|
||||
|
||||
|
||||
def _create_analyst_agent(
|
||||
*,
|
||||
analyst_type: str,
|
||||
run_id: str,
|
||||
model,
|
||||
formatter,
|
||||
skills_manager: SkillsManager,
|
||||
active_skill_map: Dict[str, list[Path]],
|
||||
long_term_memory=None,
|
||||
):
|
||||
"""Create one analyst agent, optionally using EvoAgent."""
|
||||
active_skill_dirs = active_skill_map.get(analyst_type, [])
|
||||
toolkit = create_agent_toolkit(
|
||||
analyst_type,
|
||||
run_id,
|
||||
active_skill_dirs=active_skill_dirs,
|
||||
)
|
||||
|
||||
use_evo_agent = analyst_type in _resolve_evo_agent_ids()
|
||||
|
||||
if use_evo_agent:
|
||||
workspace_dir = skills_manager.get_agent_asset_dir(run_id, analyst_type)
|
||||
agent_config = load_agent_workspace_config(workspace_dir / "agent.yaml")
|
||||
agent = EvoAgent(
|
||||
agent_id=analyst_type,
|
||||
config_name=run_id,
|
||||
workspace_dir=workspace_dir,
|
||||
model=model,
|
||||
formatter=formatter,
|
||||
skills_manager=skills_manager,
|
||||
prompt_files=agent_config.prompt_files,
|
||||
long_term_memory=long_term_memory,
|
||||
)
|
||||
agent.toolkit = toolkit
|
||||
setattr(agent, "workspace_id", run_id)
|
||||
return agent
|
||||
|
||||
return AnalystAgent(
|
||||
analyst_type=analyst_type,
|
||||
toolkit=toolkit,
|
||||
model=model,
|
||||
formatter=formatter,
|
||||
agent_id=analyst_type,
|
||||
config={"config_name": run_id},
|
||||
long_term_memory=long_term_memory,
|
||||
)
|
||||
|
||||
|
||||
def _create_risk_manager_agent(
|
||||
*,
|
||||
run_id: str,
|
||||
model,
|
||||
formatter,
|
||||
skills_manager: SkillsManager,
|
||||
active_skill_map: Dict[str, list[Path]],
|
||||
long_term_memory=None,
|
||||
):
|
||||
"""Create the risk manager, optionally using EvoAgent."""
|
||||
active_skill_dirs = active_skill_map.get("risk_manager", [])
|
||||
toolkit = create_agent_toolkit(
|
||||
"risk_manager",
|
||||
run_id,
|
||||
active_skill_dirs=active_skill_dirs,
|
||||
)
|
||||
|
||||
use_evo_agent = "risk_manager" in _resolve_evo_agent_ids()
|
||||
|
||||
if use_evo_agent:
|
||||
workspace_dir = skills_manager.get_agent_asset_dir(run_id, "risk_manager")
|
||||
agent_config = load_agent_workspace_config(workspace_dir / "agent.yaml")
|
||||
agent = EvoAgent(
|
||||
agent_id="risk_manager",
|
||||
config_name=run_id,
|
||||
workspace_dir=workspace_dir,
|
||||
model=model,
|
||||
formatter=formatter,
|
||||
skills_manager=skills_manager,
|
||||
prompt_files=agent_config.prompt_files,
|
||||
long_term_memory=long_term_memory,
|
||||
)
|
||||
agent.toolkit = toolkit
|
||||
setattr(agent, "workspace_id", run_id)
|
||||
return agent
|
||||
|
||||
return RiskAgent(
|
||||
model=model,
|
||||
formatter=formatter,
|
||||
name="risk_manager",
|
||||
config={"config_name": run_id},
|
||||
long_term_memory=long_term_memory,
|
||||
toolkit=toolkit,
|
||||
)
|
||||
|
||||
|
||||
def _create_portfolio_manager_agent(
|
||||
*,
|
||||
run_id: str,
|
||||
model,
|
||||
formatter,
|
||||
initial_cash: float,
|
||||
margin_requirement: float,
|
||||
skills_manager: SkillsManager,
|
||||
active_skill_map: Dict[str, list[Path]],
|
||||
long_term_memory=None,
|
||||
):
|
||||
"""Create the portfolio manager, optionally using EvoAgent."""
|
||||
active_skill_dirs = active_skill_map.get("portfolio_manager", [])
|
||||
use_evo_agent = "portfolio_manager" in _resolve_evo_agent_ids()
|
||||
|
||||
if use_evo_agent:
|
||||
workspace_dir = skills_manager.get_agent_asset_dir(
|
||||
run_id,
|
||||
"portfolio_manager",
|
||||
)
|
||||
agent_config = load_agent_workspace_config(workspace_dir / "agent.yaml")
|
||||
agent = EvoAgent(
|
||||
agent_id="portfolio_manager",
|
||||
config_name=run_id,
|
||||
workspace_dir=workspace_dir,
|
||||
model=model,
|
||||
formatter=formatter,
|
||||
skills_manager=skills_manager,
|
||||
prompt_files=agent_config.prompt_files,
|
||||
initial_cash=initial_cash,
|
||||
margin_requirement=margin_requirement,
|
||||
long_term_memory=long_term_memory,
|
||||
)
|
||||
agent.toolkit = create_agent_toolkit(
|
||||
"portfolio_manager",
|
||||
run_id,
|
||||
owner=agent,
|
||||
active_skill_dirs=active_skill_dirs,
|
||||
)
|
||||
setattr(agent, "workspace_id", run_id)
|
||||
return agent
|
||||
|
||||
return PMAgent(
|
||||
name="portfolio_manager",
|
||||
model=model,
|
||||
formatter=formatter,
|
||||
initial_cash=initial_cash,
|
||||
margin_requirement=margin_requirement,
|
||||
config={"config_name": run_id},
|
||||
long_term_memory=long_term_memory,
|
||||
toolkit_factory=create_agent_toolkit,
|
||||
toolkit_factory_kwargs={
|
||||
"active_skill_dirs": active_skill_dirs,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def create_agents(
|
||||
run_id: str,
|
||||
run_dir: Path,
|
||||
@@ -129,11 +401,6 @@ def create_agents(
|
||||
for analyst_type in ANALYST_TYPES:
|
||||
model = get_agent_model(analyst_type)
|
||||
formatter = get_agent_formatter(analyst_type)
|
||||
toolkit = create_agent_toolkit(
|
||||
analyst_type,
|
||||
run_id,
|
||||
active_skill_dirs=active_skill_map.get(analyst_type, []),
|
||||
)
|
||||
|
||||
long_term_memory = None
|
||||
if enable_long_term_memory:
|
||||
@@ -141,13 +408,13 @@ def create_agents(
|
||||
if long_term_memory:
|
||||
long_term_memories.append(long_term_memory)
|
||||
|
||||
analyst = AnalystAgent(
|
||||
analyst = _create_analyst_agent(
|
||||
analyst_type=analyst_type,
|
||||
toolkit=toolkit,
|
||||
run_id=run_id,
|
||||
model=model,
|
||||
formatter=formatter,
|
||||
agent_id=analyst_type,
|
||||
config={"config_name": run_id},
|
||||
skills_manager=skills_manager,
|
||||
active_skill_map=active_skill_map,
|
||||
long_term_memory=long_term_memory,
|
||||
)
|
||||
analysts.append(analyst)
|
||||
@@ -159,17 +426,13 @@ def create_agents(
|
||||
if risk_long_term_memory:
|
||||
long_term_memories.append(risk_long_term_memory)
|
||||
|
||||
risk_manager = RiskAgent(
|
||||
risk_manager = _create_risk_manager_agent(
|
||||
run_id=run_id,
|
||||
model=get_agent_model("risk_manager"),
|
||||
formatter=get_agent_formatter("risk_manager"),
|
||||
name="risk_manager",
|
||||
config={"config_name": run_id},
|
||||
skills_manager=skills_manager,
|
||||
active_skill_map=active_skill_map,
|
||||
long_term_memory=risk_long_term_memory,
|
||||
toolkit=create_agent_toolkit(
|
||||
"risk_manager",
|
||||
run_id,
|
||||
active_skill_dirs=active_skill_map.get("risk_manager", []),
|
||||
),
|
||||
)
|
||||
|
||||
# Create portfolio manager
|
||||
@@ -179,18 +442,15 @@ def create_agents(
|
||||
if pm_long_term_memory:
|
||||
long_term_memories.append(pm_long_term_memory)
|
||||
|
||||
portfolio_manager = PMAgent(
|
||||
name="portfolio_manager",
|
||||
portfolio_manager = _create_portfolio_manager_agent(
|
||||
run_id=run_id,
|
||||
model=get_agent_model("portfolio_manager"),
|
||||
formatter=get_agent_formatter("portfolio_manager"),
|
||||
initial_cash=initial_cash,
|
||||
margin_requirement=margin_requirement,
|
||||
config={"config_name": run_id},
|
||||
skills_manager=skills_manager,
|
||||
active_skill_map=active_skill_map,
|
||||
long_term_memory=pm_long_term_memory,
|
||||
toolkit_factory=create_agent_toolkit,
|
||||
toolkit_factory_kwargs={
|
||||
"active_skill_dirs": active_skill_map.get("portfolio_manager", []),
|
||||
},
|
||||
)
|
||||
|
||||
return analysts, risk_manager, portfolio_manager, long_term_memories
|
||||
@@ -400,6 +660,9 @@ async def run_pipeline(
|
||||
)
|
||||
_set_gateway(gateway)
|
||||
|
||||
# Set global memory references for persistence
|
||||
_set_long_term_memories(long_term_memories)
|
||||
|
||||
# Start pipeline execution
|
||||
async with AsyncExitStack() as stack:
|
||||
# Enter long-term memory contexts
|
||||
@@ -467,6 +730,12 @@ async def run_pipeline(
|
||||
# Cleanup
|
||||
logger.info("[Pipeline] Cleaning up...")
|
||||
|
||||
# Persist long-term memories before cleanup
|
||||
try:
|
||||
await _persist_long_term_memories_async()
|
||||
except Exception as e:
|
||||
logger.warning(f"[Pipeline] Memory persistence error: {e}")
|
||||
|
||||
# Stop Gateway
|
||||
try:
|
||||
stop_gateway()
|
||||
@@ -474,6 +743,9 @@ async def run_pipeline(
|
||||
except Exception as e:
|
||||
logger.error(f"[Pipeline] Error stopping gateway: {e}")
|
||||
|
||||
# Clear memory references
|
||||
_clear_long_term_memories()
|
||||
|
||||
clear_shutdown_event()
|
||||
clear_global_runtime_manager()
|
||||
from backend.api.runtime import unregister_runtime_manager
|
||||
|
||||
@@ -463,6 +463,34 @@ class StateSync:
|
||||
limit=self.storage.max_feed_history,
|
||||
) or self._state.get("last_day_history", [])
|
||||
|
||||
persisted_state = self.storage.read_persisted_server_state()
|
||||
dashboard_snapshot = (
|
||||
self.storage.build_dashboard_snapshot_from_state(self._state)
|
||||
if include_dashboard
|
||||
else None
|
||||
)
|
||||
dashboard_holdings = (
|
||||
dashboard_snapshot.get("holdings", [])
|
||||
if dashboard_snapshot is not None
|
||||
else self._state.get("holdings", [])
|
||||
)
|
||||
dashboard_trades = (
|
||||
dashboard_snapshot.get("trades", [])
|
||||
if dashboard_snapshot is not None
|
||||
else self._state.get("trades", [])
|
||||
)
|
||||
dashboard_stats = (
|
||||
dashboard_snapshot.get("stats", {})
|
||||
if dashboard_snapshot is not None
|
||||
else self._state.get("stats", {})
|
||||
)
|
||||
dashboard_leaderboard = (
|
||||
dashboard_snapshot.get("leaderboard", [])
|
||||
if dashboard_snapshot is not None
|
||||
else self._state.get("leaderboard", [])
|
||||
)
|
||||
portfolio_state = self._state.get("portfolio") or persisted_state.get("portfolio") or {}
|
||||
|
||||
payload = {
|
||||
"server_mode": self._state.get("server_mode", "live"),
|
||||
"is_backtest": self._state.get("is_backtest", False),
|
||||
@@ -476,24 +504,23 @@ class StateSync:
|
||||
"trading_days_completed",
|
||||
0,
|
||||
),
|
||||
"holdings": self._state.get("holdings", []),
|
||||
"trades": self._state.get("trades", []),
|
||||
"stats": self._state.get("stats", {}),
|
||||
"leaderboard": self._state.get("leaderboard", []),
|
||||
"portfolio": self._state.get("portfolio", {}),
|
||||
"holdings": dashboard_holdings,
|
||||
"trades": dashboard_trades,
|
||||
"stats": dashboard_stats,
|
||||
"leaderboard": dashboard_leaderboard,
|
||||
"portfolio": portfolio_state,
|
||||
"realtime_prices": self._state.get("realtime_prices", {}),
|
||||
"data_sources": self._state.get("data_sources", {}),
|
||||
"price_history": self._state.get("price_history", {}),
|
||||
}
|
||||
|
||||
if include_dashboard:
|
||||
dashboard_snapshot = self.storage.build_dashboard_snapshot_from_state(self._state)
|
||||
payload["dashboard"] = {
|
||||
"summary": dashboard_snapshot.get("summary"),
|
||||
"holdings": dashboard_snapshot.get("holdings"),
|
||||
"stats": dashboard_snapshot.get("stats"),
|
||||
"trades": dashboard_snapshot.get("trades"),
|
||||
"leaderboard": dashboard_snapshot.get("leaderboard"),
|
||||
"holdings": dashboard_holdings,
|
||||
"stats": dashboard_stats,
|
||||
"trades": dashboard_trades,
|
||||
"leaderboard": dashboard_leaderboard,
|
||||
}
|
||||
|
||||
return payload
|
||||
|
||||
275
backend/main.py
275
backend/main.py
@@ -13,10 +13,13 @@ import loguru
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
from backend.agents import AnalystAgent, PMAgent, RiskAgent
|
||||
from backend.agents import AnalystAgent, EvoAgent, PMAgent, RiskAgent
|
||||
from backend.agents.agent_workspace import load_agent_workspace_config
|
||||
from backend.agents.skills_manager import SkillsManager
|
||||
from backend.agents.toolkit_factory import create_agent_toolkit, load_agent_profiles
|
||||
from backend.agents.prompt_loader import get_prompt_loader
|
||||
# WorkspaceManager is RunWorkspaceManager - provides run-scoped asset management
|
||||
# All runtime state lives under runs/<run_id>/
|
||||
from backend.agents.workspace_manager import WorkspaceManager
|
||||
from backend.config.bootstrap_config import resolve_runtime_config
|
||||
from backend.config.constants import ANALYST_TYPES
|
||||
@@ -44,8 +47,13 @@ _prompt_loader = get_prompt_loader()
|
||||
|
||||
|
||||
def _get_run_dir(config_name: str) -> Path:
|
||||
"""Return the canonical run-scoped directory for a config."""
|
||||
"""Return the canonical run-scoped directory for a config.
|
||||
|
||||
This is the authoritative path for runtime state under runs/<run_id>/.
|
||||
All runtime assets, state, and exports are scoped to this directory.
|
||||
"""
|
||||
project_root = Path(__file__).resolve().parents[1]
|
||||
# Use RunWorkspaceManager for run-scoped path resolution
|
||||
return WorkspaceManager(project_root=project_root).get_run_dir(config_name)
|
||||
|
||||
|
||||
@@ -102,6 +110,204 @@ def create_long_term_memory(agent_name: str, config_name: str):
|
||||
)
|
||||
|
||||
|
||||
def _resolve_evo_agent_ids() -> set[str]:
|
||||
"""Return agent ids selected to use EvoAgent.
|
||||
|
||||
By default, all supported roles use EvoAgent.
|
||||
EVO_AGENT_IDS can be used to limit to specific roles (legacy behavior).
|
||||
Set EVO_AGENT_LEGACY=1 to disable EvoAgent entirely.
|
||||
|
||||
Supported roles:
|
||||
- analyst roles (fundamentals, technical, sentiment, valuation)
|
||||
- risk_manager
|
||||
- portfolio_manager
|
||||
|
||||
Example:
|
||||
EVO_AGENT_IDS=fundamentals_analyst,risk_manager,portfolio_manager
|
||||
"""
|
||||
from backend.config.constants import ANALYST_TYPES
|
||||
|
||||
all_supported = set(ANALYST_TYPES) | {"risk_manager", "portfolio_manager"}
|
||||
|
||||
raw = os.getenv("EVO_AGENT_IDS", "")
|
||||
if not raw.strip():
|
||||
# Default: all supported roles use EvoAgent
|
||||
return all_supported
|
||||
|
||||
if raw.strip().lower() in ("legacy", "old", "none"):
|
||||
return set()
|
||||
|
||||
requested = {
|
||||
item.strip()
|
||||
for item in raw.split(",")
|
||||
if item.strip()
|
||||
}
|
||||
return {
|
||||
agent_id
|
||||
for agent_id in requested
|
||||
if agent_id in ANALYST_TYPES or agent_id in {"risk_manager", "portfolio_manager"}
|
||||
}
|
||||
|
||||
|
||||
def _create_analyst_agent(
|
||||
*,
|
||||
analyst_type: str,
|
||||
config_name: str,
|
||||
model,
|
||||
formatter,
|
||||
skills_manager: SkillsManager,
|
||||
active_skill_map: dict[str, list[Path]],
|
||||
long_term_memory=None,
|
||||
):
|
||||
"""Create one analyst agent, optionally using EvoAgent."""
|
||||
active_skill_dirs = active_skill_map.get(analyst_type, [])
|
||||
toolkit = create_agent_toolkit(
|
||||
analyst_type,
|
||||
config_name,
|
||||
active_skill_dirs=active_skill_dirs,
|
||||
)
|
||||
|
||||
use_evo_agent = analyst_type in _resolve_evo_agent_ids()
|
||||
|
||||
if use_evo_agent:
|
||||
workspace_dir = skills_manager.get_agent_asset_dir(config_name, analyst_type)
|
||||
agent_config = load_agent_workspace_config(workspace_dir / "agent.yaml")
|
||||
agent = EvoAgent(
|
||||
agent_id=analyst_type,
|
||||
config_name=config_name,
|
||||
workspace_dir=workspace_dir,
|
||||
model=model,
|
||||
formatter=formatter,
|
||||
skills_manager=skills_manager,
|
||||
prompt_files=agent_config.prompt_files,
|
||||
long_term_memory=long_term_memory,
|
||||
)
|
||||
# Preserve existing analysis tool-group coverage while the EvoAgent
|
||||
# migration is still partial.
|
||||
agent.toolkit = toolkit
|
||||
setattr(agent, "run_id", config_name)
|
||||
# Keep workspace_id for backward compatibility
|
||||
setattr(agent, "workspace_id", config_name)
|
||||
return agent
|
||||
|
||||
return AnalystAgent(
|
||||
analyst_type=analyst_type,
|
||||
toolkit=toolkit,
|
||||
model=model,
|
||||
formatter=formatter,
|
||||
agent_id=analyst_type,
|
||||
config={"config_name": config_name},
|
||||
long_term_memory=long_term_memory,
|
||||
)
|
||||
|
||||
|
||||
def _create_risk_manager_agent(
|
||||
*,
|
||||
config_name: str,
|
||||
model,
|
||||
formatter,
|
||||
skills_manager: SkillsManager,
|
||||
active_skill_map: dict[str, list[Path]],
|
||||
long_term_memory=None,
|
||||
):
|
||||
"""Create the risk manager, optionally using EvoAgent."""
|
||||
active_skill_dirs = active_skill_map.get("risk_manager", [])
|
||||
toolkit = create_agent_toolkit(
|
||||
"risk_manager",
|
||||
config_name,
|
||||
active_skill_dirs=active_skill_dirs,
|
||||
)
|
||||
|
||||
use_evo_agent = "risk_manager" in _resolve_evo_agent_ids()
|
||||
|
||||
if use_evo_agent:
|
||||
workspace_dir = skills_manager.get_agent_asset_dir(config_name, "risk_manager")
|
||||
agent_config = load_agent_workspace_config(workspace_dir / "agent.yaml")
|
||||
agent = EvoAgent(
|
||||
agent_id="risk_manager",
|
||||
config_name=config_name,
|
||||
workspace_dir=workspace_dir,
|
||||
model=model,
|
||||
formatter=formatter,
|
||||
skills_manager=skills_manager,
|
||||
prompt_files=agent_config.prompt_files,
|
||||
long_term_memory=long_term_memory,
|
||||
)
|
||||
agent.toolkit = toolkit
|
||||
setattr(agent, "run_id", config_name)
|
||||
# Keep workspace_id for backward compatibility
|
||||
setattr(agent, "workspace_id", config_name)
|
||||
return agent
|
||||
|
||||
return RiskAgent(
|
||||
model=model,
|
||||
formatter=formatter,
|
||||
name="risk_manager",
|
||||
config={"config_name": config_name},
|
||||
long_term_memory=long_term_memory,
|
||||
toolkit=toolkit,
|
||||
)
|
||||
|
||||
|
||||
def _create_portfolio_manager_agent(
|
||||
*,
|
||||
config_name: str,
|
||||
model,
|
||||
formatter,
|
||||
initial_cash: float,
|
||||
margin_requirement: float,
|
||||
skills_manager: SkillsManager,
|
||||
active_skill_map: dict[str, list[Path]],
|
||||
long_term_memory=None,
|
||||
):
|
||||
"""Create the portfolio manager, optionally using EvoAgent."""
|
||||
active_skill_dirs = active_skill_map.get("portfolio_manager", [])
|
||||
use_evo_agent = "portfolio_manager" in _resolve_evo_agent_ids()
|
||||
|
||||
if use_evo_agent:
|
||||
workspace_dir = skills_manager.get_agent_asset_dir(
|
||||
config_name,
|
||||
"portfolio_manager",
|
||||
)
|
||||
agent_config = load_agent_workspace_config(workspace_dir / "agent.yaml")
|
||||
agent = EvoAgent(
|
||||
agent_id="portfolio_manager",
|
||||
config_name=config_name,
|
||||
workspace_dir=workspace_dir,
|
||||
model=model,
|
||||
formatter=formatter,
|
||||
skills_manager=skills_manager,
|
||||
prompt_files=agent_config.prompt_files,
|
||||
initial_cash=initial_cash,
|
||||
margin_requirement=margin_requirement,
|
||||
long_term_memory=long_term_memory,
|
||||
)
|
||||
agent.toolkit = create_agent_toolkit(
|
||||
"portfolio_manager",
|
||||
config_name,
|
||||
owner=agent,
|
||||
active_skill_dirs=active_skill_dirs,
|
||||
)
|
||||
setattr(agent, "run_id", config_name)
|
||||
# Keep workspace_id for backward compatibility
|
||||
setattr(agent, "workspace_id", config_name)
|
||||
return agent
|
||||
|
||||
return PMAgent(
|
||||
name="portfolio_manager",
|
||||
model=model,
|
||||
formatter=formatter,
|
||||
initial_cash=initial_cash,
|
||||
margin_requirement=margin_requirement,
|
||||
config={"config_name": config_name},
|
||||
long_term_memory=long_term_memory,
|
||||
toolkit_factory=create_agent_toolkit,
|
||||
toolkit_factory_kwargs={
|
||||
"active_skill_dirs": active_skill_dirs,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def create_agents(
|
||||
config_name: str,
|
||||
initial_cash: float,
|
||||
@@ -136,11 +342,6 @@ def create_agents(
|
||||
for analyst_type in ANALYST_TYPES:
|
||||
model = get_agent_model(analyst_type)
|
||||
formatter = get_agent_formatter(analyst_type)
|
||||
toolkit = create_agent_toolkit(
|
||||
analyst_type,
|
||||
config_name,
|
||||
active_skill_dirs=active_skill_map.get(analyst_type, []),
|
||||
)
|
||||
|
||||
long_term_memory = None
|
||||
if enable_long_term_memory:
|
||||
@@ -151,13 +352,13 @@ def create_agents(
|
||||
if long_term_memory:
|
||||
long_term_memories.append(long_term_memory)
|
||||
|
||||
analyst = AnalystAgent(
|
||||
analyst = _create_analyst_agent(
|
||||
analyst_type=analyst_type,
|
||||
toolkit=toolkit,
|
||||
config_name=config_name,
|
||||
model=model,
|
||||
formatter=formatter,
|
||||
agent_id=analyst_type,
|
||||
config={"config_name": config_name},
|
||||
skills_manager=skills_manager,
|
||||
active_skill_map=active_skill_map,
|
||||
long_term_memory=long_term_memory,
|
||||
)
|
||||
analysts.append(analyst)
|
||||
@@ -171,17 +372,13 @@ def create_agents(
|
||||
if risk_long_term_memory:
|
||||
long_term_memories.append(risk_long_term_memory)
|
||||
|
||||
risk_manager = RiskAgent(
|
||||
risk_manager = _create_risk_manager_agent(
|
||||
config_name=config_name,
|
||||
model=get_agent_model("risk_manager"),
|
||||
formatter=get_agent_formatter("risk_manager"),
|
||||
name="risk_manager",
|
||||
config={"config_name": config_name},
|
||||
skills_manager=skills_manager,
|
||||
active_skill_map=active_skill_map,
|
||||
long_term_memory=risk_long_term_memory,
|
||||
toolkit=create_agent_toolkit(
|
||||
"risk_manager",
|
||||
config_name,
|
||||
active_skill_dirs=active_skill_map.get("risk_manager", []),
|
||||
),
|
||||
)
|
||||
|
||||
pm_long_term_memory = None
|
||||
@@ -193,21 +390,15 @@ def create_agents(
|
||||
if pm_long_term_memory:
|
||||
long_term_memories.append(pm_long_term_memory)
|
||||
|
||||
portfolio_manager = PMAgent(
|
||||
name="portfolio_manager",
|
||||
portfolio_manager = _create_portfolio_manager_agent(
|
||||
config_name=config_name,
|
||||
model=get_agent_model("portfolio_manager"),
|
||||
formatter=get_agent_formatter("portfolio_manager"),
|
||||
initial_cash=initial_cash,
|
||||
margin_requirement=margin_requirement,
|
||||
config={"config_name": config_name},
|
||||
skills_manager=skills_manager,
|
||||
active_skill_map=active_skill_map,
|
||||
long_term_memory=pm_long_term_memory,
|
||||
toolkit_factory=create_agent_toolkit,
|
||||
toolkit_factory_kwargs={
|
||||
"active_skill_dirs": active_skill_map.get(
|
||||
"portfolio_manager",
|
||||
[],
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
return analysts, risk_manager, portfolio_manager, long_term_memories
|
||||
@@ -343,15 +534,29 @@ async def run_with_gateway(args):
|
||||
await stack.enter_async_context(memory)
|
||||
await gateway.start(host=args.host, port=args.port)
|
||||
finally:
|
||||
# Persist long-term memories before cleanup
|
||||
for memory in long_term_memories:
|
||||
try:
|
||||
if hasattr(memory, 'save') and callable(getattr(memory, 'save')):
|
||||
await memory.save()
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to persist memory: {e}")
|
||||
unregister_runtime_manager()
|
||||
clear_global_runtime_manager()
|
||||
|
||||
|
||||
def main():
|
||||
"""Main entry point"""
|
||||
def build_arg_parser() -> argparse.ArgumentParser:
|
||||
"""Build the CLI parser for the gateway runtime entrypoint."""
|
||||
parser = argparse.ArgumentParser(description="Trading System")
|
||||
parser.add_argument("--mode", choices=["live", "backtest"], default="live")
|
||||
parser.add_argument("--config-name", default="live")
|
||||
parser.add_argument(
|
||||
"--config-name",
|
||||
default="default_run",
|
||||
help=(
|
||||
"Run label under runs/<config_name>; not a special root-level "
|
||||
"live/backtest/production directory."
|
||||
),
|
||||
)
|
||||
parser.add_argument("--host", default="0.0.0.0")
|
||||
parser.add_argument("--port", type=int, default=8765)
|
||||
parser.add_argument(
|
||||
@@ -369,6 +574,12 @@ def main():
|
||||
action="store_true",
|
||||
help="Enable ReMeTaskLongTermMemory for agents",
|
||||
)
|
||||
return parser
|
||||
|
||||
|
||||
def main():
|
||||
"""Main entry point"""
|
||||
parser = build_arg_parser()
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -117,3 +117,35 @@ evaluation_hook.complete_evaluation(success=True)
|
||||
### 评估结果存储
|
||||
|
||||
评估结果自动保存到 `runs/{run_id}/evaluations/{agent_id}/{skill_name}_{timestamp}.json`
|
||||
|
||||
---
|
||||
|
||||
## Skill Sandbox Execution | 技能沙盒执行
|
||||
|
||||
技能脚本(如估值报告生成)通过沙盒执行器运行,支持三种隔离模式:
|
||||
|
||||
| 模式 | 描述 | 适用场景 |
|
||||
|------|------|---------|
|
||||
| `none` | 直接执行,无隔离 | 开发环境(默认) |
|
||||
| `docker` | Docker 容器隔离 | 生产环境 |
|
||||
| `kubernetes` | Kubernetes Pod 隔离 | 企业级(预留) |
|
||||
|
||||
### 沙盒配置
|
||||
|
||||
环境变量控制沙盒行为:
|
||||
|
||||
```bash
|
||||
SKILL_SANDBOX_MODE=none # none | docker | kubernetes
|
||||
SKILL_SANDBOX_IMAGE=python:3.11-slim
|
||||
SKILL_SANDBOX_MEMORY_LIMIT=512m
|
||||
SKILL_SANDBOX_CPU_LIMIT=1.0
|
||||
SKILL_SANDBOX_NETWORK=none
|
||||
SKILL_SANDBOX_TIMEOUT=60
|
||||
```
|
||||
|
||||
### 开发注意事项
|
||||
|
||||
- 默认 `none` 模式会在首次执行时显示安全警告
|
||||
- 生产环境必须设置 `SKILL_SANDBOX_MODE=docker`
|
||||
- 技能脚本应无副作用,输入输出通过函数参数和返回值
|
||||
- 函数命名与脚本文件名的映射通过 `FUNCTION_TO_SCRIPT_MAP` 处理(如 `build_ev_ebitda_report` 在 `multiple_valuation_report.py` 中)
|
||||
|
||||
@@ -28,6 +28,19 @@ def test_agent_service_excludes_runtime_routes(tmp_path):
|
||||
assert "/api/runtime/gateway/port" not in paths
|
||||
|
||||
|
||||
def test_agent_service_status_includes_scope_metadata(tmp_path):
|
||||
app = create_app(project_root=tmp_path)
|
||||
|
||||
with TestClient(app) as client:
|
||||
response = client.get("/api/status")
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload["scope"]["design_time_registry"]["root"] == str(tmp_path / "workspaces")
|
||||
assert payload["scope"]["runtime_assets"]["root"] == str(tmp_path / "runs")
|
||||
assert "runs/<run_id>" in payload["scope"]["agent_route_note"]
|
||||
|
||||
|
||||
def test_agent_service_read_routes(monkeypatch, tmp_path):
|
||||
class _FakeSkillsManager:
|
||||
project_root = tmp_path
|
||||
@@ -96,9 +109,14 @@ def test_agent_service_read_routes(monkeypatch, tmp_path):
|
||||
|
||||
assert profile.status_code == 200
|
||||
assert profile.json()["profile"]["model_name"] == "deepseek-v3.2"
|
||||
assert profile.json()["scope_type"] == "runtime_run"
|
||||
assert skills.status_code == 200
|
||||
assert skills.json()["skills"][0]["skill_name"] == "demo_skill"
|
||||
assert skills.json()["scope_type"] == "runtime_run"
|
||||
assert detail.status_code == 200
|
||||
assert detail.json()["skill"]["content"] == "# demo"
|
||||
assert detail.json()["scope_type"] == "runtime_run"
|
||||
assert workspace_file.status_code == 200
|
||||
assert workspace_file.json()["content"] == "demo:portfolio_manager:MEMORY.md"
|
||||
assert workspace_file.json()["scope_type"] == "runtime_run"
|
||||
assert "runs/<run_id>" in workspace_file.json()["scope_note"]
|
||||
|
||||
@@ -311,7 +311,7 @@ class TestRiskAgent:
|
||||
|
||||
|
||||
class TestStorageService:
|
||||
def test_storage_service_defaults_to_live_config(self):
|
||||
def test_storage_service_defaults_to_runtime_config(self):
|
||||
from backend.services.storage import StorageService
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
@@ -320,7 +320,7 @@ class TestStorageService:
|
||||
initial_cash=100000.0,
|
||||
)
|
||||
|
||||
assert storage.config_name == "live"
|
||||
assert storage.config_name == "runtime"
|
||||
|
||||
def test_calculate_portfolio_value_cash_only(self):
|
||||
from backend.services.storage import StorageService
|
||||
@@ -404,7 +404,7 @@ class TestStorageService:
|
||||
assert trades[0]["qty"] == 50
|
||||
assert trades[0]["price"] == 200.0
|
||||
|
||||
def test_generate_summary(self):
|
||||
def test_build_summary_export(self):
|
||||
from backend.services.storage import StorageService
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
@@ -424,13 +424,12 @@ class TestStorageService:
|
||||
}
|
||||
prices = {"AAPL": 500.0}
|
||||
|
||||
storage._generate_summary(state, 100000.0, prices)
|
||||
summary = storage._build_summary_export(state, 100000.0, prices)
|
||||
|
||||
summary = storage.load_file("summary")
|
||||
assert summary["totalAssetValue"] == 100000.0
|
||||
assert summary["totalReturn"] == 0.0
|
||||
|
||||
def test_generate_holdings(self):
|
||||
def test_build_holdings_export(self):
|
||||
from backend.services.storage import StorageService
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
@@ -448,9 +447,8 @@ class TestStorageService:
|
||||
}
|
||||
prices = {"AAPL": 500.0}
|
||||
|
||||
storage._generate_holdings(state, prices)
|
||||
holdings = storage._build_holdings_export(state, prices)
|
||||
|
||||
holdings = storage.load_file("holdings")
|
||||
assert len(holdings) == 2 # AAPL + CASH
|
||||
|
||||
aapl_holding = next(
|
||||
@@ -461,6 +459,150 @@ class TestStorageService:
|
||||
assert aapl_holding["quantity"] == 100
|
||||
assert aapl_holding["currentPrice"] == 500.0
|
||||
|
||||
def test_export_dashboard_compatibility_files_writes_expected_exports(self):
|
||||
from backend.services.storage import StorageService
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
storage = StorageService(
|
||||
dashboard_dir=Path(tmpdir) / "team_dashboard",
|
||||
initial_cash=100000.0,
|
||||
)
|
||||
state = {
|
||||
"portfolio_state": {
|
||||
"cash": 90000.0,
|
||||
"positions": {"AAPL": {"long": 50, "short": 0}},
|
||||
"margin_used": 0.0,
|
||||
},
|
||||
"equity_history": [{"t": 1000, "v": 100000}],
|
||||
"baseline_history": [{"t": 1000, "v": 100000}],
|
||||
"baseline_vw_history": [{"t": 1000, "v": 100000}],
|
||||
"momentum_history": [{"t": 1000, "v": 100000}],
|
||||
"all_trades": [
|
||||
{
|
||||
"id": "t1",
|
||||
"ts": 1000,
|
||||
"trading_date": "2024-01-15",
|
||||
"side": "LONG",
|
||||
"ticker": "AAPL",
|
||||
"qty": 50,
|
||||
"price": 200.0,
|
||||
}
|
||||
],
|
||||
}
|
||||
prices = {"AAPL": 200.0}
|
||||
|
||||
storage.export_dashboard_compatibility_files(
|
||||
state,
|
||||
net_value=100000.0,
|
||||
prices=prices,
|
||||
)
|
||||
|
||||
assert storage.load_export_file("summary")["totalAssetValue"] == 100000.0
|
||||
holdings = storage.load_export_file("holdings")
|
||||
assert any(item["ticker"] == "AAPL" for item in holdings)
|
||||
assert storage.load_export_file("stats")["totalTrades"] == 1
|
||||
assert storage.load_export_file("trades")[0]["ticker"] == "AAPL"
|
||||
|
||||
def test_build_dashboard_snapshot_prefers_persisted_runtime_state_when_memory_view_is_sparse(self):
|
||||
from backend.services.storage import StorageService
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
dashboard_dir = Path(tmpdir) / "team_dashboard"
|
||||
storage = StorageService(
|
||||
dashboard_dir=dashboard_dir,
|
||||
initial_cash=100000.0,
|
||||
)
|
||||
storage.save_server_state(
|
||||
{
|
||||
"portfolio": {
|
||||
"total_value": 123456.0,
|
||||
"cash": 45678.0,
|
||||
"pnl_percent": 23.45,
|
||||
},
|
||||
"holdings": [{"ticker": "AAPL", "quantity": 10}],
|
||||
"stats": {"totalTrades": 3},
|
||||
"trades": [{"ticker": "AAPL"}],
|
||||
"leaderboard": [{"agentId": "technical_analyst"}],
|
||||
}
|
||||
)
|
||||
|
||||
snapshot = storage.build_dashboard_snapshot_from_state({"portfolio": {}})
|
||||
|
||||
assert snapshot["summary"]["totalAssetValue"] == 123456.0
|
||||
assert snapshot["holdings"][0]["ticker"] == "AAPL"
|
||||
assert snapshot["trades"][0]["ticker"] == "AAPL"
|
||||
assert snapshot["leaderboard"][0]["agentId"] == "technical_analyst"
|
||||
|
||||
def test_runtime_leaderboard_prefers_server_state_and_persists_back(self):
|
||||
from backend.services.storage import StorageService
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
dashboard_dir = Path(tmpdir) / "team_dashboard"
|
||||
storage = StorageService(
|
||||
dashboard_dir=dashboard_dir,
|
||||
initial_cash=100000.0,
|
||||
)
|
||||
storage.save_export_file("leaderboard", [{"agentId": "export_only"}])
|
||||
storage.save_server_state({"leaderboard": [{"agentId": "runtime_state"}]})
|
||||
|
||||
leaderboard = storage.load_runtime_leaderboard()
|
||||
assert leaderboard[0]["agentId"] == "runtime_state"
|
||||
|
||||
updated = [{"agentId": "updated_runtime"}]
|
||||
storage.persist_runtime_leaderboard(updated)
|
||||
|
||||
saved_state = storage.read_persisted_server_state()
|
||||
saved_export = storage.load_export_file("leaderboard")
|
||||
assert saved_state["leaderboard"][0]["agentId"] == "updated_runtime"
|
||||
assert saved_export[0]["agentId"] == "updated_runtime"
|
||||
|
||||
def test_compatibility_exports_can_be_disabled_without_breaking_runtime_leaderboard(self):
|
||||
from backend.services.storage import StorageService
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
dashboard_dir = Path(tmpdir) / "team_dashboard"
|
||||
storage = StorageService(
|
||||
dashboard_dir=dashboard_dir,
|
||||
initial_cash=100000.0,
|
||||
enable_compat_exports=False,
|
||||
)
|
||||
|
||||
storage.generate_leaderboard()
|
||||
storage.export_dashboard_compatibility_files(
|
||||
{
|
||||
"portfolio_state": {
|
||||
"cash": 100000.0,
|
||||
"positions": {},
|
||||
"margin_used": 0.0,
|
||||
},
|
||||
"equity_history": [],
|
||||
"baseline_history": [],
|
||||
"baseline_vw_history": [],
|
||||
"momentum_history": [],
|
||||
"all_trades": [],
|
||||
},
|
||||
net_value=100000.0,
|
||||
prices={},
|
||||
)
|
||||
|
||||
assert not dashboard_dir.joinpath("summary.json").exists()
|
||||
assert storage.load_runtime_leaderboard()
|
||||
persisted = storage.read_persisted_server_state()
|
||||
assert persisted["leaderboard"]
|
||||
|
||||
def test_compatibility_exports_default_can_be_disabled_via_env(self, monkeypatch):
|
||||
from backend.services.storage import StorageService
|
||||
|
||||
monkeypatch.setenv("ENABLE_DASHBOARD_COMPAT_EXPORTS", "false")
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
storage = StorageService(
|
||||
dashboard_dir=Path(tmpdir) / "team_dashboard",
|
||||
initial_cash=100000.0,
|
||||
)
|
||||
|
||||
assert storage.enable_compat_exports is False
|
||||
|
||||
|
||||
class TestTradeExecutor:
|
||||
def test_execute_trade_long(self):
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from pathlib import Path
|
||||
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from backend import cli
|
||||
|
||||
|
||||
@@ -126,6 +128,86 @@ def test_backtest_runs_full_market_store_prepare_before_start(monkeypatch, tmp_p
|
||||
]
|
||||
|
||||
|
||||
def test_live_cli_defaults_to_generic_run_label(monkeypatch, tmp_path):
|
||||
project_root = tmp_path
|
||||
(project_root / ".env").write_text("FINNHUB_API_KEY=test\n", encoding="utf-8")
|
||||
|
||||
calls = []
|
||||
runner = CliRunner()
|
||||
|
||||
monkeypatch.setattr(cli, "get_project_root", lambda: project_root)
|
||||
monkeypatch.setattr(cli, "handle_history_cleanup", lambda config_name, auto_clean=False: None)
|
||||
monkeypatch.setattr(cli, "run_data_updater", lambda project_root: None)
|
||||
monkeypatch.setattr(cli, "auto_update_market_store", lambda config_name, end_date=None: None)
|
||||
monkeypatch.setattr(
|
||||
cli,
|
||||
"auto_enrich_market_store",
|
||||
lambda config_name, end_date=None, lookback_days=120, force=False: None,
|
||||
)
|
||||
monkeypatch.setattr(cli.os, "chdir", lambda path: None)
|
||||
|
||||
def fake_run(cmd, check=True, **kwargs):
|
||||
calls.append(cmd)
|
||||
return 0
|
||||
|
||||
monkeypatch.setattr(cli.subprocess, "run", fake_run)
|
||||
|
||||
result = runner.invoke(cli.app, ["live", "--trigger-time", "now"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert calls
|
||||
assert "--config-name" in calls[0]
|
||||
config_index = calls[0].index("--config-name")
|
||||
assert calls[0][config_index + 1] == "default_live_run"
|
||||
|
||||
|
||||
def test_backtest_cli_defaults_to_generic_run_label(monkeypatch, tmp_path):
|
||||
project_root = tmp_path
|
||||
calls = []
|
||||
runner = CliRunner()
|
||||
|
||||
monkeypatch.setattr(cli, "get_project_root", lambda: project_root)
|
||||
monkeypatch.setattr(cli, "handle_history_cleanup", lambda config_name, auto_clean=False: None)
|
||||
monkeypatch.setattr(cli, "run_data_updater", lambda project_root: None)
|
||||
monkeypatch.setattr(
|
||||
cli,
|
||||
"auto_prepare_backtest_market_store",
|
||||
lambda config_name, start_date, end_date: None,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
cli,
|
||||
"auto_enrich_market_store",
|
||||
lambda config_name, end_date=None, lookback_days=120, force=False: None,
|
||||
)
|
||||
monkeypatch.setattr(cli.os, "chdir", lambda path: None)
|
||||
|
||||
def fake_run(cmd, check=True, **kwargs):
|
||||
calls.append(cmd)
|
||||
return 0
|
||||
|
||||
monkeypatch.setattr(cli.subprocess, "run", fake_run)
|
||||
|
||||
result = runner.invoke(
|
||||
cli.app,
|
||||
["backtest", "--start", "2026-03-01", "--end", "2026-03-10"],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert calls
|
||||
assert "--config-name" in calls[0]
|
||||
config_index = calls[0].index("--config-name")
|
||||
assert calls[0][config_index + 1] == "default_backtest_run"
|
||||
|
||||
|
||||
def test_main_parser_defaults_to_generic_run_label():
|
||||
from backend.main import build_arg_parser
|
||||
|
||||
parser = build_arg_parser()
|
||||
args = parser.parse_args([])
|
||||
|
||||
assert args.config_name == "default_run"
|
||||
|
||||
|
||||
def test_ingest_enrich_runs_batch_enrichment(monkeypatch):
|
||||
calls = []
|
||||
|
||||
|
||||
405
backend/tests/test_evo_agent_integration.py
Normal file
405
backend/tests/test_evo_agent_integration.py
Normal file
@@ -0,0 +1,405 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Integration tests for EvoAgent system.
|
||||
|
||||
These tests verify the integration between:
|
||||
- UnifiedAgentFactory
|
||||
- EvoAgent
|
||||
- ToolGuardMixin
|
||||
- Workspace-driven configuration
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, AsyncMock
|
||||
|
||||
|
||||
class TestUnifiedAgentFactoryIntegration:
|
||||
"""Test UnifiedAgentFactory creates agents correctly."""
|
||||
|
||||
def test_factory_creates_analyst_with_workspace_config(self, tmp_path, monkeypatch):
|
||||
"""Test that factory creates EvoAgent with workspace config."""
|
||||
from backend.agents.unified_factory import UnifiedAgentFactory
|
||||
|
||||
# Setup mock skills manager
|
||||
class MockSkillsManager:
|
||||
def get_agent_asset_dir(self, config_name, agent_id):
|
||||
path = tmp_path / "runs" / config_name / "agents" / agent_id
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
return path
|
||||
|
||||
# Create workspace config
|
||||
workspace_dir = tmp_path / "runs" / "test_config" / "agents" / "fundamentals_analyst"
|
||||
workspace_dir.mkdir(parents=True, exist_ok=True)
|
||||
(workspace_dir / "agent.yaml").write_text(
|
||||
"prompt_files:\n - SOUL.md\n - CUSTOM.md\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
(workspace_dir / "SOUL.md").write_text("System prompt content", encoding="utf-8")
|
||||
(workspace_dir / "CUSTOM.md").write_text("Custom instructions", encoding="utf-8")
|
||||
|
||||
factory = UnifiedAgentFactory(
|
||||
config_name="test_config",
|
||||
skills_manager=MockSkillsManager(),
|
||||
)
|
||||
|
||||
# Mock EvoAgent creation by patching where it's imported
|
||||
created_kwargs = {}
|
||||
|
||||
class MockEvoAgent:
|
||||
def __init__(self, **kwargs):
|
||||
created_kwargs.update(kwargs)
|
||||
self.toolkit = None
|
||||
|
||||
# Patch at the location where EvoAgent is imported in unified_factory
|
||||
import backend.agents.base.evo_agent as evo_agent_module
|
||||
original_evo_agent = evo_agent_module.EvoAgent
|
||||
evo_agent_module.EvoAgent = MockEvoAgent
|
||||
|
||||
try:
|
||||
monkeypatch.setattr(
|
||||
factory,
|
||||
"_create_toolkit",
|
||||
lambda *args, **kwargs: MagicMock(),
|
||||
)
|
||||
|
||||
agent = factory.create_analyst(
|
||||
analyst_type="fundamentals_analyst",
|
||||
model=MagicMock(),
|
||||
formatter=MagicMock(),
|
||||
)
|
||||
|
||||
assert isinstance(agent, MockEvoAgent)
|
||||
assert created_kwargs["agent_id"] == "fundamentals_analyst"
|
||||
assert created_kwargs["config_name"] == "test_config"
|
||||
assert "SOUL.md" in created_kwargs["prompt_files"]
|
||||
finally:
|
||||
evo_agent_module.EvoAgent = original_evo_agent
|
||||
|
||||
def test_factory_creates_risk_manager(self, tmp_path, monkeypatch):
|
||||
"""Test that factory creates risk manager EvoAgent."""
|
||||
from backend.agents.unified_factory import UnifiedAgentFactory
|
||||
|
||||
class MockSkillsManager:
|
||||
def get_agent_asset_dir(self, config_name, agent_id):
|
||||
path = tmp_path / "runs" / config_name / "agents" / agent_id
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
return path
|
||||
|
||||
factory = UnifiedAgentFactory(
|
||||
config_name="test_config",
|
||||
skills_manager=MockSkillsManager(),
|
||||
)
|
||||
|
||||
created_kwargs = {}
|
||||
|
||||
class MockEvoAgent:
|
||||
def __init__(self, **kwargs):
|
||||
created_kwargs.update(kwargs)
|
||||
self.toolkit = None
|
||||
|
||||
import backend.agents.base.evo_agent as evo_agent_module
|
||||
original_evo_agent = evo_agent_module.EvoAgent
|
||||
evo_agent_module.EvoAgent = MockEvoAgent
|
||||
|
||||
try:
|
||||
monkeypatch.setattr(
|
||||
factory,
|
||||
"_create_toolkit",
|
||||
lambda *args, **kwargs: MagicMock(),
|
||||
)
|
||||
|
||||
agent = factory.create_risk_manager(
|
||||
model=MagicMock(),
|
||||
formatter=MagicMock(),
|
||||
)
|
||||
|
||||
assert isinstance(agent, MockEvoAgent)
|
||||
assert created_kwargs["agent_id"] == "risk_manager"
|
||||
finally:
|
||||
evo_agent_module.EvoAgent = original_evo_agent
|
||||
|
||||
def test_factory_creates_portfolio_manager(self, tmp_path, monkeypatch):
|
||||
"""Test that factory creates portfolio manager EvoAgent with financial params."""
|
||||
from backend.agents.unified_factory import UnifiedAgentFactory
|
||||
|
||||
class MockSkillsManager:
|
||||
def get_agent_asset_dir(self, config_name, agent_id):
|
||||
path = tmp_path / "runs" / config_name / "agents" / agent_id
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
return path
|
||||
|
||||
factory = UnifiedAgentFactory(
|
||||
config_name="test_config",
|
||||
skills_manager=MockSkillsManager(),
|
||||
)
|
||||
|
||||
created_kwargs = {}
|
||||
|
||||
def mock_make_decision(*args, **kwargs):
|
||||
pass
|
||||
|
||||
class MockEvoAgent:
|
||||
def __init__(self, **kwargs):
|
||||
created_kwargs.update(kwargs)
|
||||
self.toolkit = None
|
||||
# Add _make_decision for PM toolkit registration
|
||||
self._make_decision = mock_make_decision
|
||||
|
||||
import backend.agents.base.evo_agent as evo_agent_module
|
||||
original_evo_agent = evo_agent_module.EvoAgent
|
||||
evo_agent_module.EvoAgent = MockEvoAgent
|
||||
|
||||
try:
|
||||
agent = factory.create_portfolio_manager(
|
||||
model=MagicMock(),
|
||||
formatter=MagicMock(),
|
||||
initial_cash=50000.0,
|
||||
margin_requirement=0.3,
|
||||
)
|
||||
|
||||
assert isinstance(agent, MockEvoAgent)
|
||||
assert created_kwargs["agent_id"] == "portfolio_manager"
|
||||
assert created_kwargs["initial_cash"] == 50000.0
|
||||
assert created_kwargs["margin_requirement"] == 0.3
|
||||
finally:
|
||||
evo_agent_module.EvoAgent = original_evo_agent
|
||||
|
||||
def test_factory_respects_evo_agent_ids_env(self, monkeypatch, tmp_path):
|
||||
"""Test that factory respects EVO_AGENT_IDS environment variable."""
|
||||
from backend.agents.unified_factory import UnifiedAgentFactory
|
||||
|
||||
# Only enable technical_analyst as EvoAgent
|
||||
monkeypatch.setenv("EVO_AGENT_IDS", "technical_analyst")
|
||||
|
||||
class MockSkillsManager:
|
||||
def get_agent_asset_dir(self, config_name, agent_id):
|
||||
path = tmp_path / "runs" / config_name / "agents" / agent_id
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
return path
|
||||
|
||||
factory = UnifiedAgentFactory(
|
||||
config_name="test_config",
|
||||
skills_manager=MockSkillsManager(),
|
||||
)
|
||||
|
||||
# technical_analyst should use EvoAgent
|
||||
assert factory._should_use_evo_agent("technical_analyst") is True
|
||||
# fundamentals_analyst should use legacy
|
||||
assert factory._should_use_evo_agent("fundamentals_analyst") is False
|
||||
|
||||
def test_factory_legacy_mode_disables_evo_agent(self, monkeypatch):
|
||||
"""Test that EVO_AGENT_IDS=legacy disables all EvoAgents."""
|
||||
from backend.agents.unified_factory import UnifiedAgentFactory
|
||||
|
||||
monkeypatch.setenv("EVO_AGENT_IDS", "legacy")
|
||||
|
||||
factory = UnifiedAgentFactory(
|
||||
config_name="test_config",
|
||||
skills_manager=MagicMock(),
|
||||
)
|
||||
|
||||
assert factory._evo_agent_ids == set()
|
||||
assert factory._should_use_evo_agent("any_agent") is False
|
||||
|
||||
|
||||
class TestToolGuardIntegration:
|
||||
"""Test ToolGuardMixin integration with EvoAgent."""
|
||||
|
||||
def test_tool_guard_intercepts_guarded_tools(self):
|
||||
"""Test that ToolGuard intercepts tools requiring approval."""
|
||||
from backend.agents.base.tool_guard import ToolGuardMixin
|
||||
|
||||
class TestAgent(ToolGuardMixin):
|
||||
def __init__(self):
|
||||
self._init_tool_guard()
|
||||
self.agent_id = "test_agent"
|
||||
self.workspace_id = "test_workspace"
|
||||
self.session_id = "test_session"
|
||||
|
||||
agent = TestAgent()
|
||||
|
||||
# Verify place_order is in guarded tools
|
||||
assert agent._is_tool_guarded("place_order") is True
|
||||
assert agent._is_tool_denied("execute_shell_command") is True
|
||||
|
||||
def test_tool_guard_approval_flow(self):
|
||||
"""Test the full approval flow for a guarded tool."""
|
||||
from backend.agents.base.tool_guard import (
|
||||
ToolGuardStore,
|
||||
ApprovalStatus,
|
||||
)
|
||||
|
||||
store = ToolGuardStore()
|
||||
|
||||
# Create a pending approval record
|
||||
record = store.create_pending(
|
||||
tool_name="place_order",
|
||||
tool_input={"ticker": "AAPL", "quantity": 100},
|
||||
agent_id="test_agent",
|
||||
workspace_id="test_workspace",
|
||||
)
|
||||
|
||||
assert record.status == ApprovalStatus.PENDING
|
||||
assert record.tool_name == "place_order"
|
||||
|
||||
# Approve the request with resolved_by
|
||||
updated = store.set_status(record.approval_id, ApprovalStatus.APPROVED, resolved_by="test_user")
|
||||
assert updated.status == ApprovalStatus.APPROVED
|
||||
assert updated.resolved_by == "test_user"
|
||||
|
||||
def test_tool_guard_default_lists(self):
|
||||
"""Test default guarded and denied tool lists."""
|
||||
from backend.agents.base.tool_guard import (
|
||||
DEFAULT_GUARDED_TOOLS,
|
||||
DEFAULT_DENIED_TOOLS,
|
||||
)
|
||||
|
||||
# Critical tools should be guarded
|
||||
assert "place_order" in DEFAULT_GUARDED_TOOLS
|
||||
assert "modify_position" in DEFAULT_GUARDED_TOOLS
|
||||
assert "write_file" in DEFAULT_GUARDED_TOOLS
|
||||
assert "edit_file" in DEFAULT_GUARDED_TOOLS
|
||||
|
||||
# Dangerous tools should be denied
|
||||
assert "execute_shell_command" in DEFAULT_DENIED_TOOLS
|
||||
|
||||
|
||||
class TestEvoAgentWorkspaceIntegration:
|
||||
"""Test EvoAgent workspace-driven configuration."""
|
||||
|
||||
def test_evo_agent_loads_prompt_files_from_workspace(self, tmp_path, monkeypatch):
|
||||
"""Test that EvoAgent loads prompt files from workspace directory."""
|
||||
from backend.agents.base.evo_agent import EvoAgent
|
||||
|
||||
workspace_dir = tmp_path / "runs" / "demo" / "agents" / "test_analyst"
|
||||
workspace_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Create prompt files
|
||||
(workspace_dir / "SOUL.md").write_text(
|
||||
"You are a test analyst.", encoding="utf-8"
|
||||
)
|
||||
(workspace_dir / "INSTRUCTIONS.md").write_text(
|
||||
"Additional instructions.", encoding="utf-8"
|
||||
)
|
||||
|
||||
class MockToolkit:
|
||||
def __init__(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
def register_agent_skill(self, path):
|
||||
pass
|
||||
|
||||
monkeypatch.setattr(
|
||||
"backend.agents.base.evo_agent.Toolkit",
|
||||
MockToolkit,
|
||||
)
|
||||
|
||||
class MockSkillsManager:
|
||||
def get_agent_active_root(self, config_name, agent_id):
|
||||
return workspace_dir / "skills" / "active"
|
||||
|
||||
def list_active_skill_metadata(self, config_name, agent_id):
|
||||
return []
|
||||
|
||||
agent = EvoAgent(
|
||||
agent_id="test_analyst",
|
||||
config_name="demo",
|
||||
workspace_dir=workspace_dir,
|
||||
model=MagicMock(),
|
||||
formatter=MagicMock(),
|
||||
skills_manager=MockSkillsManager(),
|
||||
prompt_files=["SOUL.md", "INSTRUCTIONS.md"],
|
||||
)
|
||||
|
||||
# Verify prompts are loaded into system prompt
|
||||
assert "You are a test analyst." in agent._sys_prompt
|
||||
assert "Additional instructions." in agent._sys_prompt
|
||||
|
||||
|
||||
class TestFactoryCaching:
|
||||
"""Test UnifiedAgentFactory caching behavior."""
|
||||
|
||||
def test_factory_cache_per_config(self, monkeypatch):
|
||||
"""Test that factory is cached per config name."""
|
||||
from backend.agents.unified_factory import (
|
||||
get_agent_factory,
|
||||
clear_factory_cache,
|
||||
)
|
||||
|
||||
# Clear any existing cache
|
||||
clear_factory_cache()
|
||||
|
||||
mock_skills_manager = MagicMock()
|
||||
|
||||
factory1 = get_agent_factory("config_a", mock_skills_manager)
|
||||
factory2 = get_agent_factory("config_a", mock_skills_manager)
|
||||
factory3 = get_agent_factory("config_b", mock_skills_manager)
|
||||
|
||||
# Same config should return same instance
|
||||
assert factory1 is factory2
|
||||
# Different config should return different instance
|
||||
assert factory1 is not factory3
|
||||
|
||||
def test_clear_factory_cache(self):
|
||||
"""Test that clear_factory_cache removes all cached factories."""
|
||||
from backend.agents.unified_factory import (
|
||||
get_agent_factory,
|
||||
clear_factory_cache,
|
||||
)
|
||||
|
||||
mock_skills_manager = MagicMock()
|
||||
|
||||
factory1 = get_agent_factory("config_c", mock_skills_manager)
|
||||
clear_factory_cache()
|
||||
factory2 = get_agent_factory("config_c", mock_skills_manager)
|
||||
|
||||
# After clearing cache, should be new instance
|
||||
assert factory1 is not factory2
|
||||
|
||||
|
||||
class TestDeprecationWarnings:
|
||||
"""Test that legacy agents emit deprecation warnings."""
|
||||
|
||||
def test_risk_agent_emits_deprecation_warning(self):
|
||||
"""Test that RiskAgent emits deprecation warning on import."""
|
||||
import warnings
|
||||
import sys
|
||||
|
||||
# Clear cache to force reimport
|
||||
modules_to_remove = [
|
||||
k for k in sys.modules.keys()
|
||||
if k.endswith("risk_manager") and "backend.agents" in k
|
||||
]
|
||||
for m in modules_to_remove:
|
||||
del sys.modules[m]
|
||||
|
||||
with warnings.catch_warnings(record=True) as w:
|
||||
warnings.simplefilter("always")
|
||||
from backend.agents.risk_manager import RiskAgent
|
||||
|
||||
deprecation_warnings = [
|
||||
x for x in w if issubclass(x.category, DeprecationWarning)
|
||||
]
|
||||
assert any("RiskAgent is deprecated" in str(x.message) for x in deprecation_warnings)
|
||||
|
||||
def test_pm_agent_emits_deprecation_warning(self):
|
||||
"""Test that PMAgent emits deprecation warning on import."""
|
||||
import warnings
|
||||
import sys
|
||||
|
||||
# Clear cache to force reimport
|
||||
modules_to_remove = [
|
||||
k for k in sys.modules.keys()
|
||||
if k.endswith("portfolio_manager") and "backend.agents" in k
|
||||
]
|
||||
for m in modules_to_remove:
|
||||
del sys.modules[m]
|
||||
|
||||
with warnings.catch_warnings(record=True) as w:
|
||||
warnings.simplefilter("always")
|
||||
from backend.agents.portfolio_manager import PMAgent
|
||||
|
||||
deprecation_warnings = [
|
||||
x for x in w if issubclass(x.category, DeprecationWarning)
|
||||
]
|
||||
assert any("PMAgent is deprecated" in str(x.message) for x in deprecation_warnings)
|
||||
429
backend/tests/test_evo_agent_selection.py
Normal file
429
backend/tests/test_evo_agent_selection.py
Normal file
@@ -0,0 +1,429 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Tests for selective EvoAgent construction."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from backend.config.constants import ANALYST_TYPES
|
||||
|
||||
|
||||
def test_main_resolve_evo_agent_ids_filters_unsupported_roles(monkeypatch):
|
||||
from backend import main as main_module
|
||||
|
||||
monkeypatch.setenv(
|
||||
"EVO_AGENT_IDS",
|
||||
"fundamentals_analyst,portfolio_manager,unknown,technical_analyst",
|
||||
)
|
||||
|
||||
resolved = main_module._resolve_evo_agent_ids()
|
||||
|
||||
assert resolved == {"fundamentals_analyst", "portfolio_manager", "technical_analyst"}
|
||||
|
||||
|
||||
def test_pipeline_runner_resolve_evo_agent_ids_keeps_supported_roles(monkeypatch):
|
||||
from backend.core import pipeline_runner as runner_module
|
||||
|
||||
monkeypatch.setenv("EVO_AGENT_IDS", "risk_manager,valuation_analyst")
|
||||
|
||||
resolved = runner_module._resolve_evo_agent_ids()
|
||||
|
||||
assert resolved == {"risk_manager", "valuation_analyst"}
|
||||
|
||||
|
||||
def test_main_create_analyst_agent_can_build_evo_agent(monkeypatch, tmp_path):
|
||||
from backend import main as main_module
|
||||
|
||||
created = {}
|
||||
|
||||
class DummySkillsManager:
|
||||
def get_agent_asset_dir(self, config_name, agent_id):
|
||||
path = tmp_path / "runs" / config_name / "agents" / agent_id
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
(path / "agent.yaml").write_text(
|
||||
"prompt_files:\n - SOUL.md\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
return path
|
||||
|
||||
class DummyEvoAgent:
|
||||
def __init__(self, **kwargs):
|
||||
created.update(kwargs)
|
||||
self.toolkit = None
|
||||
|
||||
monkeypatch.setenv("EVO_AGENT_IDS", "fundamentals_analyst")
|
||||
monkeypatch.setattr(main_module, "EvoAgent", DummyEvoAgent)
|
||||
monkeypatch.setattr(main_module, "create_agent_toolkit", lambda *args, **kwargs: "toolkit")
|
||||
|
||||
agent = main_module._create_analyst_agent(
|
||||
analyst_type="fundamentals_analyst",
|
||||
config_name="demo",
|
||||
model="model",
|
||||
formatter="formatter",
|
||||
skills_manager=DummySkillsManager(),
|
||||
active_skill_map={"fundamentals_analyst": [Path("/tmp/skill")]},
|
||||
long_term_memory=None,
|
||||
)
|
||||
|
||||
assert isinstance(agent, DummyEvoAgent)
|
||||
assert created["agent_id"] == "fundamentals_analyst"
|
||||
assert created["config_name"] == "demo"
|
||||
assert created["prompt_files"] == ["SOUL.md"]
|
||||
assert agent.toolkit == "toolkit"
|
||||
assert agent.workspace_id == "demo"
|
||||
|
||||
|
||||
def test_main_create_risk_manager_can_build_evo_agent(monkeypatch, tmp_path):
|
||||
from backend import main as main_module
|
||||
|
||||
created = {}
|
||||
|
||||
class DummySkillsManager:
|
||||
def get_agent_asset_dir(self, config_name, agent_id):
|
||||
path = tmp_path / "runs" / config_name / "agents" / agent_id
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
(path / "agent.yaml").write_text(
|
||||
"prompt_files:\n - SOUL.md\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
return path
|
||||
|
||||
class DummyEvoAgent:
|
||||
def __init__(self, **kwargs):
|
||||
created.update(kwargs)
|
||||
self.toolkit = None
|
||||
|
||||
monkeypatch.setenv("EVO_AGENT_IDS", "risk_manager")
|
||||
monkeypatch.setattr(main_module, "EvoAgent", DummyEvoAgent)
|
||||
monkeypatch.setattr(main_module, "create_agent_toolkit", lambda *args, **kwargs: "risk-toolkit")
|
||||
|
||||
agent = main_module._create_risk_manager_agent(
|
||||
config_name="demo",
|
||||
model="model",
|
||||
formatter="formatter",
|
||||
skills_manager=DummySkillsManager(),
|
||||
active_skill_map={"risk_manager": [Path("/tmp/skill")]},
|
||||
long_term_memory=None,
|
||||
)
|
||||
|
||||
assert isinstance(agent, DummyEvoAgent)
|
||||
assert created["agent_id"] == "risk_manager"
|
||||
assert created["config_name"] == "demo"
|
||||
assert created["prompt_files"] == ["SOUL.md"]
|
||||
assert agent.toolkit == "risk-toolkit"
|
||||
assert agent.workspace_id == "demo"
|
||||
|
||||
|
||||
def test_main_create_portfolio_manager_can_build_evo_agent(monkeypatch, tmp_path):
|
||||
from backend import main as main_module
|
||||
|
||||
created = {}
|
||||
|
||||
class DummySkillsManager:
|
||||
def get_agent_asset_dir(self, config_name, agent_id):
|
||||
path = tmp_path / "runs" / config_name / "agents" / agent_id
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
(path / "agent.yaml").write_text(
|
||||
"prompt_files:\n - SOUL.md\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
return path
|
||||
|
||||
class DummyEvoAgent:
|
||||
def __init__(self, **kwargs):
|
||||
created.update(kwargs)
|
||||
self.toolkit = None
|
||||
|
||||
monkeypatch.setenv("EVO_AGENT_IDS", "portfolio_manager")
|
||||
monkeypatch.setattr(main_module, "EvoAgent", DummyEvoAgent)
|
||||
monkeypatch.setattr(
|
||||
main_module,
|
||||
"create_agent_toolkit",
|
||||
lambda *args, **kwargs: "pm-toolkit",
|
||||
)
|
||||
|
||||
agent = main_module._create_portfolio_manager_agent(
|
||||
config_name="demo",
|
||||
model="model",
|
||||
formatter="formatter",
|
||||
initial_cash=12345.0,
|
||||
margin_requirement=0.4,
|
||||
skills_manager=DummySkillsManager(),
|
||||
active_skill_map={"portfolio_manager": [Path("/tmp/skill")]},
|
||||
long_term_memory=None,
|
||||
)
|
||||
|
||||
assert isinstance(agent, DummyEvoAgent)
|
||||
assert created["agent_id"] == "portfolio_manager"
|
||||
assert created["config_name"] == "demo"
|
||||
assert created["prompt_files"] == ["SOUL.md"]
|
||||
assert created["initial_cash"] == 12345.0
|
||||
assert created["margin_requirement"] == 0.4
|
||||
assert agent.toolkit == "pm-toolkit"
|
||||
assert agent.workspace_id == "demo"
|
||||
|
||||
|
||||
def test_evo_agent_reload_runtime_assets_refreshes_prompt_files(monkeypatch, tmp_path):
|
||||
from backend.agents.base.evo_agent import EvoAgent
|
||||
|
||||
workspace_dir = tmp_path / "runs" / "demo" / "agents" / "fundamentals_analyst"
|
||||
workspace_dir.mkdir(parents=True, exist_ok=True)
|
||||
(workspace_dir / "SOUL.md").write_text("soul-v1", encoding="utf-8")
|
||||
(workspace_dir / "MEMORY.md").write_text("memory-v1", encoding="utf-8")
|
||||
(workspace_dir / "agent.yaml").write_text(
|
||||
"prompt_files:\n"
|
||||
" - SOUL.md\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
class DummyToolkit:
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.registered = []
|
||||
|
||||
def register_agent_skill(self, path):
|
||||
self.registered.append(path)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"backend.agents.base.evo_agent.Toolkit",
|
||||
DummyToolkit,
|
||||
)
|
||||
|
||||
class DummyModel:
|
||||
pass
|
||||
|
||||
class DummyFormatter:
|
||||
pass
|
||||
|
||||
agent = EvoAgent(
|
||||
agent_id="fundamentals_analyst",
|
||||
config_name="demo",
|
||||
workspace_dir=workspace_dir,
|
||||
model=DummyModel(),
|
||||
formatter=DummyFormatter(),
|
||||
skills_manager=type(
|
||||
"SkillsManagerStub",
|
||||
(),
|
||||
{
|
||||
"get_agent_active_root": staticmethod(lambda config_name, agent_id: workspace_dir / "skills" / "active"),
|
||||
"list_active_skill_metadata": staticmethod(lambda config_name, agent_id: []),
|
||||
},
|
||||
)(),
|
||||
)
|
||||
|
||||
assert "soul-v1" in agent._sys_prompt
|
||||
assert "memory-v1" not in agent._sys_prompt
|
||||
|
||||
(workspace_dir / "agent.yaml").write_text(
|
||||
"prompt_files:\n"
|
||||
" - SOUL.md\n"
|
||||
" - MEMORY.md\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
agent.reload_runtime_assets(active_skill_dirs=[])
|
||||
|
||||
assert "memory-v1" in agent._sys_prompt
|
||||
assert agent.workspace_id == "demo"
|
||||
assert agent.config == {"config_name": "demo"}
|
||||
|
||||
|
||||
|
||||
|
||||
def test_pipeline_resolve_evo_agent_ids_filters_unsupported_roles(monkeypatch):
|
||||
"""Test that pipeline._resolve_evo_agent_ids filters unsupported roles."""
|
||||
from backend.core import pipeline as pipeline_module
|
||||
|
||||
monkeypatch.setenv(
|
||||
"EVO_AGENT_IDS",
|
||||
"fundamentals_analyst,portfolio_manager,unknown,technical_analyst",
|
||||
)
|
||||
|
||||
resolved = pipeline_module._resolve_evo_agent_ids()
|
||||
|
||||
assert resolved == {"fundamentals_analyst", "portfolio_manager", "technical_analyst"}
|
||||
|
||||
|
||||
def test_pipeline_create_runtime_analyst_uses_evo_agent_when_enabled(monkeypatch, tmp_path):
|
||||
"""Test that _create_runtime_analyst creates EvoAgent when in EVO_AGENT_IDS."""
|
||||
from backend.core import pipeline as pipeline_module
|
||||
|
||||
created = {}
|
||||
|
||||
class DummyEvoAgent:
|
||||
def __init__(self, **kwargs):
|
||||
created.update(kwargs)
|
||||
self.toolkit = None
|
||||
|
||||
class DummyAnalystAgent:
|
||||
def __init__(self, **kwargs):
|
||||
created.update(kwargs)
|
||||
self.toolkit = None
|
||||
|
||||
monkeypatch.setenv("EVO_AGENT_IDS", "fundamentals_analyst")
|
||||
monkeypatch.setattr(pipeline_module, "EvoAgent", DummyEvoAgent)
|
||||
monkeypatch.setattr(pipeline_module, "AnalystAgent", DummyAnalystAgent)
|
||||
monkeypatch.setattr(
|
||||
pipeline_module,
|
||||
"create_agent_toolkit",
|
||||
lambda *args, **kwargs: "toolkit",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
pipeline_module,
|
||||
"get_agent_model",
|
||||
lambda x: "model",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
pipeline_module,
|
||||
"get_agent_formatter",
|
||||
lambda x: "formatter",
|
||||
)
|
||||
|
||||
# Create a mock pipeline instance
|
||||
class MockPM:
|
||||
def __init__(self):
|
||||
self.config = {"config_name": "demo"}
|
||||
|
||||
pipeline = pipeline_module.TradingPipeline(
|
||||
analysts=[],
|
||||
risk_manager=None,
|
||||
portfolio_manager=MockPM(),
|
||||
)
|
||||
|
||||
# Mock workspace_manager methods
|
||||
monkeypatch.setattr(
|
||||
pipeline_module.WorkspaceManager,
|
||||
"ensure_agent_assets",
|
||||
lambda *args, **kwargs: None,
|
||||
)
|
||||
|
||||
result = pipeline._create_runtime_analyst("test_analyst", "fundamentals_analyst")
|
||||
|
||||
assert "Created runtime analyst" in result
|
||||
assert created.get("agent_id") == "test_analyst"
|
||||
assert created.get("config_name") == "demo"
|
||||
|
||||
|
||||
def test_pipeline_create_runtime_analyst_uses_legacy_when_not_in_evo_ids(monkeypatch, tmp_path):
|
||||
"""Test that _create_runtime_analyst creates legacy AnalystAgent when not in EVO_AGENT_IDS."""
|
||||
from backend.core import pipeline as pipeline_module
|
||||
|
||||
created = {}
|
||||
|
||||
class DummyEvoAgent:
|
||||
def __init__(self, **kwargs):
|
||||
created.update(kwargs)
|
||||
self.toolkit = None
|
||||
|
||||
class DummyAnalystAgent:
|
||||
def __init__(self, **kwargs):
|
||||
created.update(kwargs)
|
||||
self.toolkit = None
|
||||
|
||||
# EVO_AGENT_IDS does not include fundamentals_analyst
|
||||
monkeypatch.setenv("EVO_AGENT_IDS", "technical_analyst")
|
||||
monkeypatch.setattr(pipeline_module, "EvoAgent", DummyEvoAgent)
|
||||
monkeypatch.setattr(pipeline_module, "AnalystAgent", DummyAnalystAgent)
|
||||
monkeypatch.setattr(
|
||||
pipeline_module,
|
||||
"create_agent_toolkit",
|
||||
lambda *args, **kwargs: "toolkit",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
pipeline_module,
|
||||
"get_agent_model",
|
||||
lambda x: "model",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
pipeline_module,
|
||||
"get_agent_formatter",
|
||||
lambda x: "formatter",
|
||||
)
|
||||
|
||||
# Create a mock pipeline instance
|
||||
class MockPM:
|
||||
def __init__(self):
|
||||
self.config = {"config_name": "demo"}
|
||||
|
||||
pipeline = pipeline_module.TradingPipeline(
|
||||
analysts=[],
|
||||
risk_manager=None,
|
||||
portfolio_manager=MockPM(),
|
||||
)
|
||||
|
||||
# Mock workspace_manager methods
|
||||
monkeypatch.setattr(
|
||||
pipeline_module.WorkspaceManager,
|
||||
"ensure_agent_assets",
|
||||
lambda *args, **kwargs: None,
|
||||
)
|
||||
|
||||
result = pipeline._create_runtime_analyst("test_analyst", "fundamentals_analyst")
|
||||
|
||||
assert "Created runtime analyst" in result
|
||||
# Should use legacy AnalystAgent
|
||||
assert created.get("analyst_type") == "fundamentals_analyst"
|
||||
|
||||
|
||||
def test_main_resolve_evo_agent_ids_returns_all_by_default(monkeypatch):
|
||||
"""Test that _resolve_evo_agent_ids returns all supported roles by default."""
|
||||
from backend import main as main_module
|
||||
from backend.config.constants import ANALYST_TYPES
|
||||
|
||||
# Unset EVO_AGENT_IDS to test default behavior
|
||||
monkeypatch.delenv("EVO_AGENT_IDS", raising=False)
|
||||
|
||||
resolved = main_module._resolve_evo_agent_ids()
|
||||
|
||||
expected = set(ANALYST_TYPES) | {"risk_manager", "portfolio_manager"}
|
||||
assert resolved == expected
|
||||
|
||||
|
||||
def test_evo_agent_supports_long_term_memory(monkeypatch, tmp_path):
|
||||
"""Test that EvoAgent can be created with long_term_memory."""
|
||||
from backend import main as main_module
|
||||
|
||||
created = {}
|
||||
|
||||
class DummySkillsManager:
|
||||
def get_agent_asset_dir(self, config_name, agent_id):
|
||||
path = tmp_path / "runs" / config_name / "agents" / agent_id
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
(path / "agent.yaml").write_text(
|
||||
"prompt_files:\n - SOUL.md\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
return path
|
||||
|
||||
class DummyEvoAgent:
|
||||
def __init__(self, **kwargs):
|
||||
created.update(kwargs)
|
||||
self.toolkit = None
|
||||
|
||||
# Default: all roles use EvoAgent
|
||||
monkeypatch.delenv("EVO_AGENT_IDS", raising=False)
|
||||
monkeypatch.setattr(main_module, "EvoAgent", DummyEvoAgent)
|
||||
monkeypatch.setattr(main_module, "create_agent_toolkit", lambda *args, **kwargs: "toolkit")
|
||||
|
||||
# Create with long_term_memory - should still use EvoAgent
|
||||
dummy_memory = {"type": "reme"}
|
||||
agent = main_module._create_analyst_agent(
|
||||
analyst_type="fundamentals_analyst",
|
||||
config_name="demo",
|
||||
model="model",
|
||||
formatter="formatter",
|
||||
skills_manager=DummySkillsManager(),
|
||||
active_skill_map={"fundamentals_analyst": []},
|
||||
long_term_memory=dummy_memory,
|
||||
)
|
||||
|
||||
assert isinstance(agent, DummyEvoAgent)
|
||||
assert created["agent_id"] == "fundamentals_analyst"
|
||||
assert created["long_term_memory"] is dummy_memory
|
||||
|
||||
|
||||
def test_evo_agent_legacy_mode(monkeypatch):
|
||||
"""Test that EVO_AGENT_IDS=legacy disables EvoAgent."""
|
||||
from backend import main as main_module
|
||||
|
||||
monkeypatch.setenv("EVO_AGENT_IDS", "legacy")
|
||||
|
||||
resolved = main_module._resolve_evo_agent_ids()
|
||||
assert resolved == set()
|
||||
@@ -5,6 +5,7 @@ from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from backend.core.state_sync import StateSync
|
||||
from backend.services import gateway_cycle_support, gateway_runtime_support
|
||||
|
||||
|
||||
@@ -43,6 +44,12 @@ class _DummyStorage:
|
||||
self.initial_cash = 100000.0
|
||||
self.is_live_session_active = False
|
||||
self.server_state_updates = []
|
||||
self.max_feed_history = 200
|
||||
self.runtime_db = SimpleNamespace(
|
||||
get_recent_feed_events=lambda limit=200: [],
|
||||
get_last_day_feed_events=lambda current_date=None, limit=200: [],
|
||||
)
|
||||
self._persisted_server_state = {}
|
||||
|
||||
def can_apply_initial_cash(self):
|
||||
return True
|
||||
@@ -54,6 +61,9 @@ class _DummyStorage:
|
||||
def update_server_state_from_dashboard(self, state):
|
||||
self.server_state_updates.append(state)
|
||||
|
||||
def read_persisted_server_state(self):
|
||||
return dict(self._persisted_server_state)
|
||||
|
||||
def load_file(self, name):
|
||||
if name == "summary":
|
||||
return {"totalAssetValue": self.initial_cash}
|
||||
@@ -199,3 +209,70 @@ async def test_refresh_market_store_for_watchlist_emits_system_messages(monkeypa
|
||||
|
||||
assert gateway.state_sync.system_messages[0] == "正在同步自选股市场数据: AAPL, MSFT"
|
||||
assert "自选股市场数据已同步:" in gateway.state_sync.system_messages[1]
|
||||
|
||||
|
||||
def test_initial_state_payload_prefers_dashboard_snapshot_for_top_level_views():
|
||||
storage = _DummyStorage()
|
||||
sync = StateSync(storage=storage)
|
||||
sync._state = {
|
||||
"holdings": [],
|
||||
"trades": [],
|
||||
"stats": {},
|
||||
"leaderboard": [],
|
||||
"portfolio": {"total_value": 100000.0},
|
||||
}
|
||||
|
||||
payload = sync.get_initial_state_payload(include_dashboard=True)
|
||||
|
||||
assert payload["holdings"] == []
|
||||
assert payload["trades"] == []
|
||||
assert payload["stats"] == {}
|
||||
assert payload["leaderboard"] == []
|
||||
assert payload["dashboard"]["summary"]["totalAssetValue"] == 100000.0
|
||||
|
||||
|
||||
def test_initial_state_payload_uses_dashboard_snapshot_for_sparse_runtime_state():
|
||||
class SnapshotStorage(_DummyStorage):
|
||||
def build_dashboard_snapshot_from_state(self, state):
|
||||
return {
|
||||
"summary": {"totalAssetValue": 123456.0},
|
||||
"holdings": [{"ticker": "AAPL"}],
|
||||
"stats": {"totalTrades": 3},
|
||||
"trades": [{"ticker": "AAPL"}],
|
||||
"leaderboard": [{"agentId": "technical_analyst"}],
|
||||
}
|
||||
|
||||
sync = StateSync(storage=SnapshotStorage())
|
||||
sync._state = {
|
||||
"holdings": [],
|
||||
"trades": [],
|
||||
"stats": {},
|
||||
"leaderboard": [],
|
||||
}
|
||||
|
||||
payload = sync.get_initial_state_payload(include_dashboard=True)
|
||||
|
||||
assert payload["holdings"][0]["ticker"] == "AAPL"
|
||||
assert payload["trades"][0]["ticker"] == "AAPL"
|
||||
assert payload["stats"]["totalTrades"] == 3
|
||||
assert payload["leaderboard"][0]["agentId"] == "technical_analyst"
|
||||
|
||||
|
||||
def test_initial_state_payload_falls_back_to_persisted_portfolio():
|
||||
storage = _DummyStorage()
|
||||
storage._persisted_server_state = {
|
||||
"portfolio": {
|
||||
"total_value": 123456.0,
|
||||
"pnl_percent": 12.34,
|
||||
"equity": [{"t": 1, "v": 123456.0}],
|
||||
}
|
||||
}
|
||||
sync = StateSync(storage=storage)
|
||||
sync._state = {
|
||||
"portfolio": {},
|
||||
}
|
||||
|
||||
payload = sync.get_initial_state_payload(include_dashboard=True)
|
||||
|
||||
assert payload["portfolio"]["total_value"] == 123456.0
|
||||
assert payload["portfolio"]["pnl_percent"] == 12.34
|
||||
|
||||
225
backend/tests/test_migration_boundaries.py
Normal file
225
backend/tests/test_migration_boundaries.py
Normal 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
|
||||
@@ -178,3 +178,84 @@ def test_news_service_range_explain(monkeypatch):
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["result"]["news_count"] == 1
|
||||
|
||||
|
||||
def test_news_service_contract_stability():
|
||||
"""Verify news service API maintains contract stability."""
|
||||
app = create_app()
|
||||
routes = {route.path: route for route in app.routes if hasattr(route, "methods")}
|
||||
|
||||
# Health endpoint
|
||||
assert "/health" in routes
|
||||
|
||||
# News/explain endpoints
|
||||
assert "/api/enriched-news" in routes
|
||||
assert "/api/news-for-date" in routes
|
||||
assert "/api/news-timeline" in routes
|
||||
assert "/api/categories" in routes
|
||||
assert "/api/similar-days" in routes
|
||||
assert "/api/stories/{ticker}" in routes
|
||||
assert "/api/range-explain" in routes
|
||||
|
||||
# Verify all are GET endpoints (read-only service)
|
||||
for path in ["/api/enriched-news", "/api/news-for-date", "/api/news-timeline",
|
||||
"/api/categories", "/api/similar-days", "/api/stories/{ticker}",
|
||||
"/api/range-explain"]:
|
||||
assert "GET" in routes[path].methods
|
||||
|
||||
|
||||
def test_news_service_enriched_news_contract(monkeypatch):
|
||||
"""Test enriched news endpoint maintains response contract."""
|
||||
app = create_app()
|
||||
app.dependency_overrides.clear()
|
||||
from backend.apps import news_service as news_service_module
|
||||
|
||||
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": [{"id": "1", "title": "Test"}]},
|
||||
)
|
||||
|
||||
with TestClient(app) as client:
|
||||
response = client.get(
|
||||
"/api/enriched-news",
|
||||
params={"ticker": "AAPL", "end_date": "2026-03-23"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert "news" in payload
|
||||
|
||||
|
||||
def test_news_service_stories_contract(monkeypatch):
|
||||
"""Test stories endpoint maintains response contract."""
|
||||
app = create_app()
|
||||
from backend.apps import news_service as news_service_module
|
||||
|
||||
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},
|
||||
)
|
||||
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": "story body",
|
||||
"source": "local",
|
||||
"headline": "Test Headline",
|
||||
},
|
||||
)
|
||||
|
||||
with TestClient(app) as client:
|
||||
response = client.get(
|
||||
"/api/stories/AAPL",
|
||||
params={"as_of_date": "2026-03-23"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert "symbol" in payload
|
||||
assert "as_of_date" in payload
|
||||
assert "story" in payload
|
||||
|
||||
@@ -242,7 +242,6 @@ def test_runtime_cleanup_endpoint_prunes_old_runs(monkeypatch, tmp_path):
|
||||
def test_runtime_history_lists_recent_runs(monkeypatch, tmp_path):
|
||||
run_dir = tmp_path / "runs" / "20260324_120000"
|
||||
(run_dir / "state").mkdir(parents=True)
|
||||
(run_dir / "team_dashboard").mkdir(parents=True)
|
||||
(run_dir / "state" / "runtime_state.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
@@ -256,8 +255,13 @@ def test_runtime_history_lists_recent_runs(monkeypatch, tmp_path):
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
(run_dir / "team_dashboard" / "summary.json").write_text(
|
||||
json.dumps({"totalTrades": 3, "totalAssetValue": 123456.0}),
|
||||
(run_dir / "state" / "server_state.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"portfolio": {"total_value": 123456.0},
|
||||
"trades": [{}, {}, {}],
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
@@ -270,6 +274,7 @@ def test_runtime_history_lists_recent_runs(monkeypatch, tmp_path):
|
||||
payload = response.json()
|
||||
assert payload["runs"][0]["run_id"] == "20260324_120000"
|
||||
assert payload["runs"][0]["total_trades"] == 3
|
||||
assert payload["runs"][0]["total_asset_value"] == 123456.0
|
||||
|
||||
|
||||
def test_restore_run_assets_copies_state(monkeypatch, tmp_path):
|
||||
@@ -278,6 +283,7 @@ def test_restore_run_assets_copies_state(monkeypatch, tmp_path):
|
||||
(source_run / "state").mkdir(parents=True)
|
||||
(source_run / "agents").mkdir(parents=True)
|
||||
(source_run / "team_dashboard" / "_internal_state.json").write_text("{}", encoding="utf-8")
|
||||
(source_run / "team_dashboard" / "summary.json").write_text("{}", encoding="utf-8")
|
||||
(source_run / "state" / "server_state.json").write_text("{}", encoding="utf-8")
|
||||
|
||||
target_run = tmp_path / "runs" / "20260324_130000"
|
||||
@@ -288,6 +294,237 @@ def test_restore_run_assets_copies_state(monkeypatch, tmp_path):
|
||||
|
||||
assert (target_run / "team_dashboard" / "_internal_state.json").exists()
|
||||
assert (target_run / "state" / "server_state.json").exists()
|
||||
assert not (target_run / "team_dashboard" / "summary.json").exists()
|
||||
|
||||
|
||||
def test_runtime_service_routes_contract_stability():
|
||||
"""Verify runtime API routes maintain contract stability."""
|
||||
app = create_app()
|
||||
routes = {route.path: route for route in app.routes if hasattr(route, "methods")}
|
||||
|
||||
# Core runtime lifecycle endpoints
|
||||
assert "/api/runtime/start" in routes
|
||||
assert "/api/runtime/stop" in routes
|
||||
assert "/api/runtime/restart" in routes
|
||||
assert "/api/runtime/current" in routes
|
||||
|
||||
# Configuration endpoints
|
||||
assert "/api/runtime/config" in routes
|
||||
|
||||
# Query endpoints
|
||||
assert "/api/runtime/agents" in routes
|
||||
assert "/api/runtime/events" in routes
|
||||
assert "/api/runtime/history" in routes
|
||||
assert "/api/runtime/context" in routes
|
||||
assert "/api/runtime/logs" in routes
|
||||
|
||||
# Gateway endpoints
|
||||
assert "/api/runtime/gateway/status" in routes
|
||||
assert "/api/runtime/gateway/port" in routes
|
||||
|
||||
# Maintenance endpoints
|
||||
assert "/api/runtime/cleanup" in routes
|
||||
|
||||
|
||||
def test_runtime_service_start_stop_lifecycle_contract(monkeypatch, tmp_path):
|
||||
"""Test the start/stop lifecycle maintains expected contract."""
|
||||
run_dir = tmp_path / "runs" / "test_run"
|
||||
state_dir = run_dir / "state"
|
||||
state_dir.mkdir(parents=True)
|
||||
# Create runtime_state.json so /api/runtime/current can find the context after stop
|
||||
(state_dir / "runtime_state.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"context": {
|
||||
"config_name": "test_run",
|
||||
"run_dir": str(run_dir),
|
||||
"bootstrap_values": {"tickers": ["AAPL", "MSFT"]},
|
||||
}
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
class _DummyManager:
|
||||
def __init__(self, config_name, run_dir, bootstrap):
|
||||
self.config_name = config_name
|
||||
self.run_dir = Path(run_dir)
|
||||
self.bootstrap = bootstrap
|
||||
self.context = None
|
||||
|
||||
def prepare_run(self):
|
||||
self.context = type(
|
||||
"Ctx",
|
||||
(),
|
||||
{
|
||||
"config_name": self.config_name,
|
||||
"run_dir": self.run_dir,
|
||||
"bootstrap_values": self.bootstrap,
|
||||
},
|
||||
)()
|
||||
return self.context
|
||||
|
||||
class _DummyProcess:
|
||||
def poll(self):
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(runtime_module, "PROJECT_ROOT", tmp_path)
|
||||
monkeypatch.setattr(runtime_module, "_find_available_port", lambda start_port=8765, max_port=9000: 8765)
|
||||
monkeypatch.setattr(runtime_module, "_start_gateway_process", lambda **kwargs: _DummyProcess())
|
||||
monkeypatch.setattr(runtime_module, "_stop_gateway", lambda: True)
|
||||
monkeypatch.setattr("backend.runtime.manager.TradingRuntimeManager", _DummyManager)
|
||||
runtime_state = runtime_module.get_runtime_state()
|
||||
runtime_state.gateway_process = None
|
||||
|
||||
with TestClient(create_app()) as client:
|
||||
# Start runtime
|
||||
start_response = client.post(
|
||||
"/api/runtime/start",
|
||||
json={
|
||||
"launch_mode": "fresh",
|
||||
"tickers": ["AAPL", "MSFT"],
|
||||
"schedule_mode": "daily",
|
||||
"interval_minutes": 60,
|
||||
"trigger_time": "09:30",
|
||||
"max_comm_cycles": 2,
|
||||
"initial_cash": 100000.0,
|
||||
"margin_requirement": 0.0,
|
||||
"enable_memory": False,
|
||||
"mode": "live",
|
||||
"poll_interval": 10,
|
||||
},
|
||||
)
|
||||
|
||||
assert start_response.status_code == 200
|
||||
start_payload = start_response.json()
|
||||
assert "run_id" in start_payload
|
||||
assert "status" in start_payload
|
||||
assert "run_dir" in start_payload
|
||||
assert "gateway_port" in start_payload
|
||||
assert "message" in start_payload
|
||||
assert start_payload["status"] == "started"
|
||||
|
||||
# Get current runtime while running
|
||||
current_response = client.get("/api/runtime/current")
|
||||
assert current_response.status_code == 200
|
||||
current_payload = current_response.json()
|
||||
assert "run_id" in current_payload
|
||||
assert "run_dir" in current_payload
|
||||
assert "is_running" in current_payload
|
||||
assert "gateway_port" in current_payload
|
||||
assert "bootstrap" in current_payload
|
||||
|
||||
# Stop runtime
|
||||
stop_response = client.post("/api/runtime/stop?force=true")
|
||||
assert stop_response.status_code == 200
|
||||
stop_payload = stop_response.json()
|
||||
assert "status" in stop_payload
|
||||
assert "message" in stop_payload
|
||||
assert stop_payload["status"] == "stopped"
|
||||
|
||||
|
||||
def test_runtime_service_agents_events_contract(monkeypatch, tmp_path):
|
||||
"""Test agents and events endpoints maintain contract."""
|
||||
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": [
|
||||
{
|
||||
"agent_id": "fundamentals_analyst",
|
||||
"status": "idle",
|
||||
"last_session": "2026-03-30",
|
||||
"last_updated": "2026-03-30T10:00:00",
|
||||
},
|
||||
{
|
||||
"agent_id": "technical_analyst",
|
||||
"status": "analyzing",
|
||||
"last_session": None,
|
||||
"last_updated": "2026-03-30T10:05:00",
|
||||
},
|
||||
],
|
||||
"events": [
|
||||
{
|
||||
"timestamp": "2026-03-30T10:00:00",
|
||||
"event": "agent_registered",
|
||||
"details": {"agent_id": "fundamentals_analyst"},
|
||||
"session": "2026-03-30",
|
||||
}
|
||||
],
|
||||
}
|
||||
),
|
||||
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
|
||||
|
||||
with TestClient(create_app()) as client:
|
||||
# Agents endpoint
|
||||
agents_response = client.get("/api/runtime/agents")
|
||||
assert agents_response.status_code == 200
|
||||
agents_payload = agents_response.json()
|
||||
assert "agents" in agents_payload
|
||||
assert len(agents_payload["agents"]) == 2
|
||||
agent = agents_payload["agents"][0]
|
||||
assert "agent_id" in agent
|
||||
assert "status" in agent
|
||||
assert "last_session" in agent
|
||||
assert "last_updated" in agent
|
||||
|
||||
# Events endpoint
|
||||
events_response = client.get("/api/runtime/events")
|
||||
assert events_response.status_code == 200
|
||||
events_payload = events_response.json()
|
||||
assert "events" in events_payload
|
||||
assert len(events_payload["events"]) == 1
|
||||
event = events_payload["events"][0]
|
||||
assert "timestamp" in event
|
||||
assert "event" in event
|
||||
assert "details" in event
|
||||
assert "session" in event
|
||||
|
||||
|
||||
def test_runtime_service_gateway_status_contract(monkeypatch, tmp_path):
|
||||
"""Test gateway status endpoint maintains contract."""
|
||||
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": {},
|
||||
}
|
||||
}
|
||||
),
|
||||
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
|
||||
|
||||
with TestClient(create_app()) as client:
|
||||
response = client.get("/api/runtime/gateway/status")
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert "is_running" in payload
|
||||
assert "port" in payload
|
||||
assert "run_id" in payload
|
||||
assert payload["is_running"] is True
|
||||
assert payload["port"] == 8765
|
||||
assert payload["run_id"] == "demo"
|
||||
|
||||
|
||||
def test_start_runtime_restore_reuses_historical_run_id(monkeypatch, tmp_path):
|
||||
|
||||
@@ -200,6 +200,179 @@ def test_trading_service_market_cap_endpoint(monkeypatch):
|
||||
}
|
||||
|
||||
|
||||
def test_trading_service_contract_stability():
|
||||
"""Verify trading service API maintains contract stability."""
|
||||
app = create_app()
|
||||
routes = {route.path: route for route in app.routes if hasattr(route, "methods")}
|
||||
|
||||
# Health endpoint
|
||||
assert "/health" in routes
|
||||
|
||||
# Trading data endpoints
|
||||
assert "/api/prices" in routes
|
||||
assert "/api/financials" in routes
|
||||
assert "/api/news" in routes
|
||||
assert "/api/insider-trades" in routes
|
||||
assert "/api/market/status" in routes
|
||||
assert "/api/market-cap" in routes
|
||||
assert "/api/line-items" in routes
|
||||
|
||||
# Verify all are GET endpoints (read-only service)
|
||||
for path in ["/api/prices", "/api/financials", "/api/news", "/api/insider-trades",
|
||||
"/api/market/status", "/api/market-cap", "/api/line-items"]:
|
||||
assert "GET" in routes[path].methods
|
||||
|
||||
|
||||
def test_trading_service_prices_contract(monkeypatch):
|
||||
"""Test prices endpoint maintains response contract."""
|
||||
monkeypatch.setattr(
|
||||
"backend.domains.trading.get_prices_payload",
|
||||
lambda ticker, start_date, end_date: {
|
||||
"ticker": ticker,
|
||||
"prices": [
|
||||
Price(
|
||||
open=1.0,
|
||||
close=2.0,
|
||||
high=2.5,
|
||||
low=0.5,
|
||||
volume=100,
|
||||
time="2026-03-20",
|
||||
)
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
with TestClient(create_app()) as client:
|
||||
response = client.get(
|
||||
"/api/prices",
|
||||
params={
|
||||
"ticker": "AAPL",
|
||||
"start_date": "2026-03-01",
|
||||
"end_date": "2026-03-20",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert "ticker" in payload
|
||||
assert "prices" in payload
|
||||
assert isinstance(payload["prices"], list)
|
||||
if payload["prices"]:
|
||||
price = payload["prices"][0]
|
||||
assert "open" in price
|
||||
assert "close" in price
|
||||
assert "high" in price
|
||||
assert "low" in price
|
||||
assert "volume" in price
|
||||
assert "time" in price
|
||||
|
||||
|
||||
def test_trading_service_financials_contract(monkeypatch):
|
||||
"""Test financials endpoint maintains response contract."""
|
||||
monkeypatch.setattr(
|
||||
"backend.domains.trading.get_financials_payload",
|
||||
lambda ticker, end_date, period, limit: {
|
||||
"financial_metrics": [
|
||||
FinancialMetrics(
|
||||
ticker=ticker,
|
||||
report_period=end_date,
|
||||
period=period,
|
||||
currency="USD",
|
||||
market_cap=123.0,
|
||||
enterprise_value=None,
|
||||
price_to_earnings_ratio=None,
|
||||
price_to_book_ratio=None,
|
||||
price_to_sales_ratio=None,
|
||||
enterprise_value_to_ebitda_ratio=None,
|
||||
enterprise_value_to_revenue_ratio=None,
|
||||
free_cash_flow_yield=None,
|
||||
peg_ratio=None,
|
||||
gross_margin=None,
|
||||
operating_margin=None,
|
||||
net_margin=None,
|
||||
return_on_equity=None,
|
||||
return_on_assets=None,
|
||||
return_on_invested_capital=None,
|
||||
asset_turnover=None,
|
||||
inventory_turnover=None,
|
||||
receivables_turnover=None,
|
||||
days_sales_outstanding=None,
|
||||
operating_cycle=None,
|
||||
working_capital_turnover=None,
|
||||
current_ratio=None,
|
||||
quick_ratio=None,
|
||||
cash_ratio=None,
|
||||
operating_cash_flow_ratio=None,
|
||||
debt_to_equity=None,
|
||||
debt_to_assets=None,
|
||||
interest_coverage=None,
|
||||
revenue_growth=None,
|
||||
earnings_growth=None,
|
||||
book_value_growth=None,
|
||||
earnings_per_share_growth=None,
|
||||
free_cash_flow_growth=None,
|
||||
operating_income_growth=None,
|
||||
ebitda_growth=None,
|
||||
payout_ratio=None,
|
||||
earnings_per_share=None,
|
||||
book_value_per_share=None,
|
||||
free_cash_flow_per_share=None,
|
||||
)
|
||||
]
|
||||
},
|
||||
)
|
||||
|
||||
with TestClient(create_app()) as client:
|
||||
response = client.get(
|
||||
"/api/financials",
|
||||
params={"ticker": "AAPL", "end_date": "2026-03-20"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert "financial_metrics" in payload
|
||||
assert isinstance(payload["financial_metrics"], list)
|
||||
|
||||
|
||||
def test_trading_service_market_status_contract(monkeypatch):
|
||||
"""Test market status endpoint maintains response contract."""
|
||||
monkeypatch.setattr(
|
||||
"backend.domains.trading.get_market_status_payload",
|
||||
lambda: {"status": "open", "status_text": "Open", "next_open": "09:30"},
|
||||
)
|
||||
|
||||
with TestClient(create_app()) as client:
|
||||
response = client.get("/api/market/status")
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert "status" in payload
|
||||
|
||||
|
||||
def test_trading_service_market_cap_contract(monkeypatch):
|
||||
"""Test market cap endpoint maintains response contract."""
|
||||
monkeypatch.setattr(
|
||||
"backend.domains.trading.get_market_cap_payload",
|
||||
lambda ticker, end_date: {
|
||||
"ticker": ticker,
|
||||
"end_date": end_date,
|
||||
"market_cap": 3.5e12,
|
||||
},
|
||||
)
|
||||
|
||||
with TestClient(create_app()) as client:
|
||||
response = client.get(
|
||||
"/api/market-cap",
|
||||
params={"ticker": "AAPL", "end_date": "2026-03-20"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert "ticker" in payload
|
||||
assert "end_date" in payload
|
||||
assert "market_cap" in payload
|
||||
|
||||
|
||||
def test_trading_service_line_items_endpoint(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"backend.domains.trading.get_line_items_payload",
|
||||
|
||||
@@ -22,16 +22,6 @@ from agentscope.message import TextBlock
|
||||
from agentscope.tool import ToolResponse
|
||||
|
||||
from backend.data.provider_utils import normalize_symbol
|
||||
from backend.skills.builtin.valuation_review.scripts.dcf_report import (
|
||||
build_dcf_report,
|
||||
)
|
||||
from backend.skills.builtin.valuation_review.scripts.multiple_valuation_report import (
|
||||
build_ev_ebitda_report,
|
||||
build_residual_income_report,
|
||||
)
|
||||
from backend.skills.builtin.valuation_review.scripts.owner_earnings_report import (
|
||||
build_owner_earnings_report,
|
||||
)
|
||||
from backend.tools.data_tools import (
|
||||
get_company_news,
|
||||
get_financial_metrics,
|
||||
@@ -41,10 +31,12 @@ from backend.tools.data_tools import (
|
||||
prices_to_df,
|
||||
search_line_items,
|
||||
)
|
||||
from backend.tools.sandboxed_executor import get_sandbox
|
||||
from backend.tools.technical_signals import StockTechnicalAnalyzer
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
_technical_analyzer = StockTechnicalAnalyzer()
|
||||
_sandbox = get_sandbox()
|
||||
|
||||
|
||||
def _to_text_response(text: str) -> ToolResponse:
|
||||
@@ -869,7 +861,13 @@ def dcf_valuation_analysis(
|
||||
},
|
||||
)
|
||||
|
||||
return _to_text_response(build_dcf_report(rows, current_date))
|
||||
return _to_text_response(
|
||||
_sandbox.execute_skill(
|
||||
skill_name="builtin/valuation_review",
|
||||
function_name="build_dcf_report",
|
||||
function_args={"rows": rows, "current_date": current_date},
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@safe
|
||||
@@ -958,7 +956,13 @@ def owner_earnings_valuation_analysis(
|
||||
},
|
||||
)
|
||||
|
||||
return _to_text_response(build_owner_earnings_report(rows, current_date))
|
||||
return _to_text_response(
|
||||
_sandbox.execute_skill(
|
||||
skill_name="builtin/valuation_review",
|
||||
function_name="build_owner_earnings_report",
|
||||
function_args={"rows": rows, "current_date": current_date},
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@safe
|
||||
@@ -1033,7 +1037,13 @@ def ev_ebitda_valuation_analysis(
|
||||
},
|
||||
)
|
||||
|
||||
return _to_text_response(build_ev_ebitda_report(rows, current_date))
|
||||
return _to_text_response(
|
||||
_sandbox.execute_skill(
|
||||
skill_name="builtin/valuation_review",
|
||||
function_name="build_ev_ebitda_report",
|
||||
function_args={"rows": rows, "current_date": current_date},
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@safe
|
||||
@@ -1114,7 +1124,13 @@ def residual_income_valuation_analysis(
|
||||
},
|
||||
)
|
||||
|
||||
return _to_text_response(build_residual_income_report(rows, current_date))
|
||||
return _to_text_response(
|
||||
_sandbox.execute_skill(
|
||||
skill_name="builtin/valuation_review",
|
||||
function_name="build_residual_income_report",
|
||||
function_args={"rows": rows, "current_date": current_date},
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
# Tool Registry for dynamic toolkit creation
|
||||
|
||||
457
backend/tools/sandboxed_executor.py
Normal file
457
backend/tools/sandboxed_executor.py
Normal file
@@ -0,0 +1,457 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
多模式技能沙盒执行器
|
||||
|
||||
支持三种模式:
|
||||
- none: 直接执行(默认,开发环境)
|
||||
- docker: Docker 容器隔离
|
||||
- kubernetes: Kubernetes Pod 隔离
|
||||
|
||||
环境变量:
|
||||
SKILL_SANDBOX_MODE: 沙盒模式 (none/docker/kubernetes),默认 none
|
||||
SKILL_SANDBOX_IMAGE: Docker 镜像,默认 python:3.11-slim
|
||||
SKILL_SANDBOX_MEMORY_LIMIT: 内存限制,默认 512m
|
||||
SKILL_SANDBOX_CPU_LIMIT: CPU 限制,默认 1.0
|
||||
SKILL_SANDBOX_NETWORK: 网络模式,默认 none
|
||||
SKILL_SANDBOX_TIMEOUT: 超时时间(秒),默认 60
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import warnings
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SandboxBackend(ABC):
|
||||
"""沙盒后端抽象基类"""
|
||||
|
||||
@abstractmethod
|
||||
def execute(
|
||||
self,
|
||||
skill_name: str,
|
||||
function_name: str,
|
||||
function_args: dict,
|
||||
) -> dict:
|
||||
"""
|
||||
执行技能函数
|
||||
|
||||
Args:
|
||||
skill_name: 技能名称,如 "builtin/valuation_review"
|
||||
function_name: 要执行的函数名,如 "build_dcf_report"
|
||||
function_args: 函数参数字典
|
||||
|
||||
Returns:
|
||||
执行结果字典
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class NoSandboxBackend(SandboxBackend):
|
||||
"""
|
||||
无沙盒模式 - 直接执行(默认,仅用于开发环境)
|
||||
|
||||
特性:
|
||||
- 直接导入并执行技能模块
|
||||
- 零性能开销
|
||||
- 无隔离,依赖代码审查保证安全
|
||||
"""
|
||||
|
||||
# 函数名到脚本模块名的映射
|
||||
FUNCTION_TO_SCRIPT_MAP = {
|
||||
# valuation_review 技能
|
||||
"build_dcf_report": "dcf_report",
|
||||
"build_owner_earnings_report": "owner_earnings_report",
|
||||
"build_ev_ebitda_report": "multiple_valuation_report",
|
||||
"build_residual_income_report": "multiple_valuation_report",
|
||||
}
|
||||
|
||||
def __init__(self):
|
||||
self._module_cache = {}
|
||||
self._warning_shown = False
|
||||
|
||||
def _get_script_name(self, function_name: str) -> str:
|
||||
"""
|
||||
根据函数名获取脚本模块名
|
||||
|
||||
优先使用预定义映射,否则尝试自动推断
|
||||
"""
|
||||
if function_name in self.FUNCTION_TO_SCRIPT_MAP:
|
||||
return self.FUNCTION_TO_SCRIPT_MAP[function_name]
|
||||
|
||||
# 自动推断: build_X_report -> X_report
|
||||
if function_name.startswith("build_") and function_name.endswith("_report"):
|
||||
return function_name[6:] # 去掉 "build_" 前缀
|
||||
|
||||
return function_name
|
||||
|
||||
def execute(
|
||||
self,
|
||||
skill_name: str,
|
||||
function_name: str,
|
||||
function_args: dict,
|
||||
) -> dict:
|
||||
"""直接导入模块并执行函数"""
|
||||
|
||||
# 首次使用时显示安全警告
|
||||
if not self._warning_shown:
|
||||
warnings.warn(
|
||||
"\n" + "=" * 60 + "\n"
|
||||
"⚠️ [安全警告] 技能在无沙盒模式下运行 (SKILL_SANDBOX_MODE=none)\n"
|
||||
" 技能脚本将直接在当前进程中执行,无隔离保护。\n"
|
||||
" 建议:生产环境请设置 SKILL_SANDBOX_MODE=docker\n"
|
||||
"=" * 60,
|
||||
RuntimeWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
self._warning_shown = True
|
||||
|
||||
logger.debug(f"[NoSandbox] 执行技能: {skill_name}.{function_name}")
|
||||
|
||||
try:
|
||||
# 将技能路径转换为模块路径
|
||||
# builtin/valuation_review -> backend.skills.builtin.valuation_review.scripts
|
||||
module_path = f"backend.skills.{skill_name.replace('/', '.')}.scripts"
|
||||
|
||||
# 从 function_name 获取脚本模块名
|
||||
script_name = self._get_script_name(function_name)
|
||||
submodule_path = f"{module_path}.{script_name}"
|
||||
|
||||
logger.debug(f"[NoSandbox] 导入模块: {submodule_path}.{function_name}")
|
||||
|
||||
# 缓存已加载的模块
|
||||
if submodule_path not in self._module_cache:
|
||||
self._module_cache[submodule_path] = __import__(
|
||||
submodule_path,
|
||||
fromlist=[function_name],
|
||||
)
|
||||
|
||||
module = self._module_cache[submodule_path]
|
||||
func = getattr(module, function_name)
|
||||
|
||||
# 执行函数
|
||||
result = func(**function_args)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"result": result,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[NoSandbox] 执行失败: {e}")
|
||||
return {
|
||||
"status": "error",
|
||||
"error": str(e),
|
||||
"error_type": type(e).__name__,
|
||||
}
|
||||
|
||||
|
||||
class DockerSandboxBackend(SandboxBackend):
|
||||
"""
|
||||
Docker 沙盒模式 - 容器隔离
|
||||
|
||||
特性:
|
||||
- 使用 Docker 容器隔离执行
|
||||
- 支持资源限制(CPU、内存)
|
||||
- 支持网络隔离
|
||||
- 临时容器,执行后销毁
|
||||
|
||||
依赖:
|
||||
pip install agentscope-runtime
|
||||
Docker 守护进程运行中
|
||||
"""
|
||||
|
||||
# 函数名到脚本模块名的映射
|
||||
FUNCTION_TO_SCRIPT_MAP = {
|
||||
# valuation_review 技能
|
||||
"build_dcf_report": "dcf_report",
|
||||
"build_owner_earnings_report": "owner_earnings_report",
|
||||
"build_ev_ebitda_report": "multiple_valuation_report",
|
||||
"build_residual_income_report": "multiple_valuation_report",
|
||||
}
|
||||
|
||||
def __init__(self, config: dict):
|
||||
self.config = config
|
||||
self._available = None
|
||||
|
||||
def _get_script_name(self, function_name: str) -> str:
|
||||
"""
|
||||
根据函数名获取脚本模块名
|
||||
|
||||
优先使用预定义映射,否则尝试自动推断
|
||||
"""
|
||||
if function_name in self.FUNCTION_TO_SCRIPT_MAP:
|
||||
return self.FUNCTION_TO_SCRIPT_MAP[function_name]
|
||||
|
||||
# 自动推断: build_X_report -> X_report
|
||||
if function_name.startswith("build_") and function_name.endswith("_report"):
|
||||
return function_name[6:] # 去掉 "build_" 前缀
|
||||
|
||||
return function_name
|
||||
|
||||
def _check_availability(self) -> bool:
|
||||
"""检查 Docker 是否可用"""
|
||||
if self._available is not None:
|
||||
return self._available
|
||||
|
||||
try:
|
||||
from agentscope_runtime.sandbox import BaseSandbox
|
||||
self._available = True
|
||||
except ImportError:
|
||||
logger.error(
|
||||
"AgentScope Runtime 未安装,无法使用 Docker 沙盒。"
|
||||
"请运行: pip install agentscope-runtime"
|
||||
)
|
||||
self._available = False
|
||||
|
||||
return self._available
|
||||
|
||||
def execute(
|
||||
self,
|
||||
skill_name: str,
|
||||
function_name: str,
|
||||
function_args: dict,
|
||||
) -> dict:
|
||||
"""在 Docker 容器中执行"""
|
||||
|
||||
if not self._check_availability():
|
||||
raise RuntimeError(
|
||||
"Docker 沙盒不可用,请安装 agentscope-runtime "
|
||||
"或切换到 SKILL_SANDBOX_MODE=none"
|
||||
)
|
||||
|
||||
from agentscope_runtime.sandbox import BaseSandbox
|
||||
|
||||
logger.info(f"[DockerSandbox] 执行技能: {skill_name}.{function_name}")
|
||||
|
||||
# 获取脚本模块名
|
||||
script_name = self._get_script_name(function_name)
|
||||
|
||||
# 构建执行代码
|
||||
code = f"""
|
||||
import sys
|
||||
import json
|
||||
|
||||
# 挂载路径
|
||||
sys.path.insert(0, '/skill/scripts')
|
||||
|
||||
# 导入函数
|
||||
from {script_name} import {function_name}
|
||||
|
||||
# 执行
|
||||
args = json.loads('{json.dumps(function_args)}')
|
||||
result = {function_name}(**args)
|
||||
|
||||
# 输出结果
|
||||
print(json.dumps({{"status": "success", "result": result}}))
|
||||
"""
|
||||
|
||||
try:
|
||||
with BaseSandbox(**self.config) as box:
|
||||
# 挂载技能目录(只读)
|
||||
host_skill_path = f"backend/skills/{skill_name}"
|
||||
box.mount(
|
||||
host_path=host_skill_path,
|
||||
container_path="/skill",
|
||||
read_only=True,
|
||||
)
|
||||
|
||||
# 执行代码
|
||||
exec_result = box.run_ipython_cell(code=code)
|
||||
|
||||
# 解析结果
|
||||
if exec_result.get("exit_code") == 0:
|
||||
output = exec_result.get("stdout", "")
|
||||
return json.loads(output)
|
||||
else:
|
||||
return {
|
||||
"status": "error",
|
||||
"error": exec_result.get("stderr", "Unknown error"),
|
||||
"exit_code": exec_result.get("exit_code"),
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[DockerSandbox] 执行失败: {e}")
|
||||
return {
|
||||
"status": "error",
|
||||
"error": str(e),
|
||||
"error_type": type(e).__name__,
|
||||
}
|
||||
|
||||
|
||||
class KubernetesSandboxBackend(SandboxBackend):
|
||||
"""
|
||||
Kubernetes 沙盒模式 - Pod 隔离(预留接口)
|
||||
|
||||
特性:
|
||||
- 使用 Kubernetes Pod 隔离执行
|
||||
- 企业级隔离和调度
|
||||
- 支持资源配额和命名空间
|
||||
|
||||
TODO: 待实现
|
||||
"""
|
||||
|
||||
def __init__(self, config: dict):
|
||||
self.config = config
|
||||
raise NotImplementedError(
|
||||
"Kubernetes 沙盒模式尚未实现,"
|
||||
"请使用 SKILL_SANDBOX_MODE=docker 或 none"
|
||||
)
|
||||
|
||||
def execute(
|
||||
self,
|
||||
skill_name: str,
|
||||
function_name: str,
|
||||
function_args: dict,
|
||||
) -> dict:
|
||||
raise NotImplementedError()
|
||||
|
||||
|
||||
class SkillSandbox:
|
||||
"""
|
||||
技能沙盒执行器
|
||||
|
||||
统一接口,根据配置自动选择后端。
|
||||
默认使用 none 模式(无沙盒)。
|
||||
|
||||
示例:
|
||||
>>> sandbox = SkillSandbox()
|
||||
>>> result = sandbox.execute_skill(
|
||||
... skill_name="builtin/valuation_review",
|
||||
... function_name="build_dcf_report",
|
||||
... function_args={"rows": [...], "current_date": "2024-01-01"}
|
||||
... )
|
||||
>>> print(result)
|
||||
{"status": "success", "result": "..."}
|
||||
"""
|
||||
|
||||
_instance = None
|
||||
_mode = None
|
||||
|
||||
def __new__(cls):
|
||||
"""单例模式"""
|
||||
if cls._instance is None:
|
||||
cls._instance = super().__new__(cls)
|
||||
cls._instance._initialized = False
|
||||
return cls._instance
|
||||
|
||||
def __init__(self):
|
||||
if self._initialized:
|
||||
return
|
||||
|
||||
self.mode = os.getenv("SKILL_SANDBOX_MODE", "none").lower()
|
||||
self._backend = self._create_backend()
|
||||
self._initialized = True
|
||||
|
||||
logger.info(f"SkillSandbox 初始化完成,模式: {self.mode}")
|
||||
|
||||
def _create_backend(self) -> SandboxBackend:
|
||||
"""根据模式创建对应后端"""
|
||||
|
||||
if self.mode == "none":
|
||||
logger.info("使用无沙盒模式(直接执行)")
|
||||
return NoSandboxBackend()
|
||||
|
||||
elif self.mode == "docker":
|
||||
config = {
|
||||
"image": os.getenv(
|
||||
"SKILL_SANDBOX_IMAGE", "python:3.11-slim"
|
||||
),
|
||||
"memory_limit": os.getenv(
|
||||
"SKILL_SANDBOX_MEMORY_LIMIT", "512m"
|
||||
),
|
||||
"cpu_limit": float(
|
||||
os.getenv("SKILL_SANDBOX_CPU_LIMIT", "1.0")
|
||||
),
|
||||
"network": os.getenv("SKILL_SANDBOX_NETWORK", "none"),
|
||||
"timeout": int(os.getenv("SKILL_SANDBOX_TIMEOUT", "60")),
|
||||
}
|
||||
logger.info(f"使用 Docker 沙盒模式,配置: {config}")
|
||||
return DockerSandboxBackend(config)
|
||||
|
||||
elif self.mode == "kubernetes":
|
||||
config = {
|
||||
"namespace": os.getenv(
|
||||
"SKILL_SANDBOX_NAMESPACE", "agentscope"
|
||||
),
|
||||
"memory_limit": os.getenv(
|
||||
"SKILL_SANDBOX_MEMORY_LIMIT", "512Mi"
|
||||
),
|
||||
"cpu_limit": os.getenv("SKILL_SANDBOX_CPU_LIMIT", "1000m"),
|
||||
"timeout": int(os.getenv("SKILL_SANDBOX_TIMEOUT", "60")),
|
||||
}
|
||||
logger.info(f"使用 Kubernetes 沙盒模式,配置: {config}")
|
||||
return KubernetesSandboxBackend(config)
|
||||
|
||||
else:
|
||||
raise ValueError(
|
||||
f"未知的沙盒模式: {self.mode},"
|
||||
f"请设置 SKILL_SANDBOX_MODE=none/docker/kubernetes"
|
||||
)
|
||||
|
||||
def execute_skill(
|
||||
self,
|
||||
skill_name: str,
|
||||
function_name: str,
|
||||
function_args: dict | None = None,
|
||||
) -> Any:
|
||||
"""
|
||||
执行技能函数
|
||||
|
||||
Args:
|
||||
skill_name: 技能名称,如 "builtin/valuation_review"
|
||||
function_name: 函数名,如 "build_dcf_report"
|
||||
function_args: 函数参数,默认 None
|
||||
|
||||
Returns:
|
||||
函数执行结果(成功时返回 result 字段,失败时抛出异常)
|
||||
|
||||
Raises:
|
||||
RuntimeError: 执行失败
|
||||
"""
|
||||
if function_args is None:
|
||||
function_args = {}
|
||||
|
||||
logger.debug(
|
||||
f"执行技能: {skill_name}.{function_name} "
|
||||
f"(模式: {self.mode})"
|
||||
)
|
||||
|
||||
result = self._backend.execute(
|
||||
skill_name=skill_name,
|
||||
function_name=function_name,
|
||||
function_args=function_args,
|
||||
)
|
||||
|
||||
if result.get("status") == "error":
|
||||
error_msg = result.get("error", "Unknown error")
|
||||
error_type = result.get("error_type", "Exception")
|
||||
raise RuntimeError(f"[{error_type}] {error_msg}")
|
||||
|
||||
return result.get("result")
|
||||
|
||||
@property
|
||||
def current_mode(self) -> str:
|
||||
"""获取当前沙盒模式"""
|
||||
return self.mode
|
||||
|
||||
|
||||
def get_sandbox() -> SkillSandbox:
|
||||
"""
|
||||
获取 SkillSandbox 单例实例
|
||||
|
||||
Returns:
|
||||
SkillSandbox 实例
|
||||
"""
|
||||
return SkillSandbox()
|
||||
|
||||
|
||||
def reset_sandbox():
|
||||
"""
|
||||
重置沙盒实例(用于测试)
|
||||
"""
|
||||
SkillSandbox._instance = None
|
||||
SkillSandbox._mode = None
|
||||
@@ -228,12 +228,12 @@ class SettlementCoordinator:
|
||||
|
||||
all_evaluations = {**analyst_evaluations, **pm_evaluations}
|
||||
|
||||
leaderboard = self.storage.load_export_file("leaderboard") or []
|
||||
leaderboard = self.storage.load_runtime_leaderboard()
|
||||
updated_leaderboard = update_leaderboard_with_evaluations(
|
||||
leaderboard,
|
||||
all_evaluations,
|
||||
)
|
||||
self.storage.save_export_file("leaderboard", updated_leaderboard)
|
||||
self.storage.persist_runtime_leaderboard(updated_leaderboard)
|
||||
|
||||
self._update_summary_with_baselines(
|
||||
date,
|
||||
|
||||
Reference in New Issue
Block a user