feat(agent): complete EvoAgent integration for all 6 agent roles

Migrate all agent roles from Legacy to EvoAgent architecture:
- fundamentals_analyst, technical_analyst, sentiment_analyst, valuation_analyst
- risk_manager, portfolio_manager

Key changes:
- EvoAgent now supports Portfolio Manager compatibility methods (_make_decision,
  get_decisions, get_portfolio_state, load_portfolio_state, update_portfolio)
- Add UnifiedAgentFactory for centralized agent creation
- ToolGuard with batch approval API and WebSocket broadcast
- Legacy agents marked deprecated (AnalystAgent, RiskAgent, PMAgent)
- Remove backend/agents/compat.py migration shim
- Add run_id alongside workspace_id for semantic clarity
- Complete integration test coverage (13 tests)
- All smoke tests passing for 6 agent roles

Constraint: Must maintain backward compatibility with existing run configs
Constraint: Memory support must work with EvoAgent (no fallback to Legacy)
Rejected: Separate PM implementation for EvoAgent | unified approach cleaner
Confidence: high
Scope-risk: broad
Directive: EVO_AGENT_IDS env var still respected but defaults to all roles
Not-tested: Kubernetes sandbox mode for skill execution
This commit is contained in:
2026-04-02 00:55:08 +08:00
parent 0fa413380c
commit 16b54d5ccc
73 changed files with 9454 additions and 904 deletions

View File

@@ -1,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",
]

View File

@@ -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}. "

View File

@@ -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.

View File

@@ -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,

View File

@@ -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]

View File

@@ -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

View File

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

View 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",
]

View File

@@ -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

View File

@@ -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

View File

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

View File

@@ -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),
)

View File

@@ -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}",
)

View File

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

View File

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

View File

@@ -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

View File

@@ -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,
},
}

View File

@@ -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.
"""

View File

@@ -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",
)

View File

@@ -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,

View File

@@ -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

View File

@@ -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

View File

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

View File

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

View File

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

View File

@@ -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` 中)

View File

@@ -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"]

View File

@@ -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):

View File

@@ -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 = []

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

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

View File

@@ -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

View File

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

View File

@@ -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

View File

@@ -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):

View File

@@ -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",

View File

@@ -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

View 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

View File

@@ -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,