6 Commits

Author SHA1 Message Date
a9d863073f chore: ignore codex local artifacts 2026-04-03 13:51:21 +08:00
4ea8fc4c32 chore: ignore local codex state 2026-04-03 13:50:48 +08:00
771de8c49c docs: refresh runtime guidance 2026-04-03 13:48:49 +08:00
a399384e07 feat: update frontend runtime team controls 2026-04-03 13:48:39 +08:00
ecfbd87244 feat: add runtime dynamic team controls 2026-04-03 13:48:31 +08:00
dc0b250adc chore: remove legacy startup paths 2026-04-03 13:45:57 +08:00
66 changed files with 3326 additions and 2763 deletions

8
.gitignore vendored
View File

@@ -51,11 +51,19 @@ node_modules
outputs/ outputs/
/production/ /production/
/smoke_test/ /smoke_test/
/frontend/dist/
/frontend/test-results/
# Local tooling state # Local tooling state
.omc/ .omc/
/.codex/
/.codex
/.pydeps/ /.pydeps/
/referance/ /referance/
/.pids/
/.pytest_cache/
/.ruff_cache/
/evotraders.egg-info/
# Run outputs # Run outputs
/runs/ /runs/

View File

@@ -1 +0,0 @@
73343

View File

@@ -1 +0,0 @@
73348

View File

@@ -1 +0,0 @@
66939

View File

@@ -1 +0,0 @@
73345

View File

@@ -1 +0,0 @@
73347

View File

@@ -1 +0,0 @@
73346

View File

@@ -1 +0,0 @@
73344

View File

@@ -115,7 +115,8 @@ npm run test # Vitest 单元测试
| 文件 | 职责 | | 文件 | 职责 |
|------|------| |------|------|
| `pipeline.py` | TradingPipeline - 核心编排器(分析→沟通→决策→执行→评估) | | `pipeline.py` | TradingPipeline - 核心编排器(分析→沟通→决策→执行→评估),支持断点 Checkpoint |
| `apo.py` | PolicyOptimizer - (APO) 自动策略优化器,根据 P&L 自动修改 Agent POLICY.md |
| `pipeline_runner.py` | REST API 触发的独立执行5 阶段启动 | | `pipeline_runner.py` | REST API 触发的独立执行5 阶段启动 |
| `scheduler.py` | BacktestScheduler、Scheduler - 回测/实盘调度 | | `scheduler.py` | BacktestScheduler、Scheduler - 回测/实盘调度 |
| `state_sync.py` | StateSync - 状态同步和广播 | | `state_sync.py` | StateSync - 状态同步和广播 |
@@ -168,7 +169,8 @@ backend/
│ └── models.py # ProcessRun、ProcessRunState │ └── models.py # ProcessRun、ProcessRunState
├── core/ # Pipeline 执行 ├── core/ # Pipeline 执行
│ ├── pipeline.py # TradingPipeline核心编排器 │ ├── pipeline.py # TradingPipeline核心编排器,支持恢复
│ ├── apo.py # PolicyOptimizer自动调优
│ ├── pipeline_runner.py # 独立 Pipeline 执行 │ ├── pipeline_runner.py # 独立 Pipeline 执行
│ ├── scheduler.py # 调度器 │ ├── scheduler.py # 调度器
│ └── state_sync.py # 状态同步 │ └── state_sync.py # 状态同步

View File

@@ -21,8 +21,11 @@ The repository name still uses `evotraders`, but the product-facing branding now
**Multi-agent trading team** **Multi-agent trading team**
Six roles collaborate like a real desk: four specialist analysts (fundamentals, technical, sentiment, valuation), one portfolio manager, and one risk manager. Six roles collaborate like a real desk: four specialist analysts (fundamentals, technical, sentiment, valuation), one portfolio manager, and one risk manager.
**Continuous learning** **Continuous learning & Evolution**
Agents can persist long-term memory with ReMe, reflect after each cycle, and evolve their decision patterns over time. Agents persist long-term memory with ReMe and reflect after each cycle. The **Autonomous Policy Optimizer (APO)** automatically tunes agent operational policies (`POLICY.md`) based on daily P&L feedback to correct recurring mistakes.
**Robust execution with recovery**
The trading pipeline supports **phase-based checkpointing**. If a run is interrupted, it can resume from the last successful phase (Analysis, Risk, Discussion, Decision, Execution, or Settlement), ensuring resilience in production.
**Backtest and live modes** **Backtest and live modes**
The same runtime model supports historical simulation and live execution with real-time market data. The same runtime model supports historical simulation and live execution with real-time market data.
@@ -68,6 +71,7 @@ Runtime state is stored in `runs/<run_id>/` — this is the **runtime source of
### Documentation ### Documentation
- [docs/README.md](./docs/README.md) — documentation index and reading order
- [docs/current-architecture.md](./docs/current-architecture.md) — canonical architecture facts - [docs/current-architecture.md](./docs/current-architecture.md) — canonical architecture facts
- [services/README.md](./services/README.md) — service boundaries and migration details - [services/README.md](./services/README.md) — service boundaries and migration details
- [docs/current-architecture.excalidraw](./docs/current-architecture.excalidraw) — visual diagram - [docs/current-architecture.excalidraw](./docs/current-architecture.excalidraw) — visual diagram
@@ -84,15 +88,11 @@ Runtime state is stored in `runs/<run_id>/` — this is the **runtime source of
# clone this repository, then: # clone this repository, then:
cd evotraders cd evotraders
# backend runtime dependencies
uv pip install -r requirements.txt
# install package entrypoint in editable mode # install package entrypoint in editable mode
uv pip install -e . uv pip install -e .
# optional # optional dev dependencies
# uv pip install -e ".[dev]" # uv pip install -e ".[dev]"
# pip install -e .
``` ```
Frontend dependencies: Frontend dependencies:
@@ -103,7 +103,7 @@ npm ci
cd .. cd ..
``` ```
Production deployment should prefer `requirements.txt` for backend and `npm ci` for frontend so the pulled environment matches the checked-in lockfiles and version pins. Production deployment should prefer the checked-in Python package metadata in `pyproject.toml` for backend installation and `npm ci` for frontend so the pulled environment matches the checked-in dependency declarations and lockfiles.
### 2. Configure environment ### 2. Configure environment
@@ -206,13 +206,13 @@ Recommended local development flow:
./start-dev.sh ./start-dev.sh
``` ```
This starts: This starts directly from the script:
- `agent_service` at `http://localhost:8000` - `agent_service` at `http://localhost:8000`
- `trading_service` at `http://localhost:8001` - `trading_service` at `http://localhost:8001`
- `news_service` at `http://localhost:8002` - `news_service` at `http://localhost:8002`
- `runtime_service` at `http://localhost:8003` - `runtime_service` at `http://localhost:8003`
- gateway WebSocket at `ws://localhost:8765` - gateway WebSocket at `ws://localhost:8765` via `runtime_service` managed startup
Then start the frontend in another terminal: Then start the frontend in another terminal:
@@ -229,31 +229,34 @@ python -m uvicorn backend.apps.agent_service:app --host 0.0.0.0 --port 8000 --re
python -m uvicorn backend.apps.trading_service:app --host 0.0.0.0 --port 8001 --reload python -m uvicorn backend.apps.trading_service:app --host 0.0.0.0 --port 8001 --reload
python -m uvicorn backend.apps.news_service:app --host 0.0.0.0 --port 8002 --reload python -m uvicorn backend.apps.news_service:app --host 0.0.0.0 --port 8002 --reload
python -m uvicorn backend.apps.runtime_service:app --host 0.0.0.0 --port 8003 --reload python -m uvicorn backend.apps.runtime_service:app --host 0.0.0.0 --port 8003 --reload
# compatibility gateway path, not the recommended primary dev entrypoint
python -m backend.main --mode live --host 0.0.0.0 --port 8765 # then create a runtime so runtime_service can spawn the Gateway subprocess
curl -X POST http://localhost:8003/api/runtime/start \
-H "Content-Type: application/json" \
-d '{"launch_mode":"fresh","tickers":["AAPL","MSFT"],"mode":"live"}'
``` ```
### 4. Run backtest or live mode ### 4. Run backtest or live mode
Backtest: Backtest:
```bash ```bash
python backend/main.py --mode backtest --config-name smoke_fullstack --start-date 2025-11-01 --end-date 2025-12-01 curl -X POST http://localhost:8003/api/runtime/start \
python backend/main.py --mode backtest --config-name smoke_fullstack --start-date 2025-11-01 --end-date 2025-12-01 --enable-memory -H "Content-Type: application/json" \
-d '{"launch_mode":"fresh","mode":"backtest","tickers":["AAPL","MSFT"],"start_date":"2025-11-01","end_date":"2025-12-01"}'
``` ```
Live: Live:
```bash ```bash
python backend/main.py --mode live --config-name live curl -X POST http://localhost:8003/api/runtime/start \
python backend/main.py --mode live --config-name live --enable-memory -H "Content-Type: application/json" \
python backend/main.py --mode live --config-name live --interval-minutes 60 -d '{"launch_mode":"fresh","mode":"live","tickers":["AAPL","MSFT"]}'
python backend/main.py --mode live --config-name live --trigger-time 22:30
``` ```
Help: Help:
```bash ```bash
python backend/main.py --help python backend/main.py --help # compatibility standalone entrypoint only
``` ```
### Offline backtest data ### Offline backtest data
@@ -311,7 +314,7 @@ If these are not set, the frontend falls back to its local defaults and compatib
```text ```text
Market data -> independent analyst work -> team communication -> portfolio decision -> Market data -> independent analyst work -> team communication -> portfolio decision ->
risk review -> execution/settlement -> reflection/memory update risk review -> execution/settlement -> reflection/memory update -> APO policy tuning
``` ```
The runtime manager also tracks: The runtime manager also tracks:

View File

@@ -66,7 +66,9 @@ frontend -> runtime_service/control APIs -> gateway/runtime manager -> market se
### 文档 ### 文档
- [docs/README.md](./docs/README.md) — 文档索引与阅读顺序
- [docs/current-architecture.md](./docs/current-architecture.md) — 权威架构事实 - [docs/current-architecture.md](./docs/current-architecture.md) — 权威架构事实
- [docs/project-layout.md](./docs/project-layout.md) — 当前目录结构与职责说明
- [services/README.md](./services/README.md) — 服务边界和迁移详情 - [services/README.md](./services/README.md) — 服务边界和迁移详情
- [docs/current-architecture.excalidraw](./docs/current-architecture.excalidraw) — 架构图 - [docs/current-architecture.excalidraw](./docs/current-architecture.excalidraw) — 架构图
- [docs/development-roadmap.md](./docs/development-roadmap.md) — 下一步执行计划 - [docs/development-roadmap.md](./docs/development-roadmap.md) — 下一步执行计划
@@ -82,15 +84,11 @@ frontend -> runtime_service/control APIs -> gateway/runtime manager -> market se
# 克隆仓库后进入项目目录 # 克隆仓库后进入项目目录
cd evotraders cd evotraders
# 安装后端运行时依赖
uv pip install -r requirements.txt
# 安装项目入口(可编辑模式) # 安装项目入口(可编辑模式)
uv pip install -e . uv pip install -e .
# 可选 # 可选开发依赖
# uv pip install -e ".[dev]" # uv pip install -e ".[dev]"
# pip install -e .
``` ```
前端依赖: 前端依赖:
@@ -101,7 +99,7 @@ npm ci
cd .. cd ..
``` ```
生产环境部署建议后端使用 `requirements.txt`,前端使用 `npm ci`,这样拉起的环境会严格跟随仓库中锁定的依赖版本。 生产环境部署建议后端 `pyproject.toml` 中声明的包元数据为准进行安装,前端使用 `npm ci`,这样拉起的环境会严格跟随仓库中声明的依赖和锁定版本。
### 2. 配置环境变量 ### 2. 配置环境变量
@@ -178,7 +176,7 @@ python3 scripts/smoke_evo_runtime.py --agent-id fundamentals_analyst
- `trading_service``http://localhost:8001` - `trading_service``http://localhost:8001`
- `news_service``http://localhost:8002` - `news_service``http://localhost:8002`
- `runtime_service``http://localhost:8003` - `runtime_service``http://localhost:8003`
- gateway WebSocket`ws://localhost:8765` - gateway WebSocket`ws://localhost:8765`,由 `runtime_service` 托管拉起
然后在另一个终端启动前端: 然后在另一个终端启动前端:
@@ -195,8 +193,11 @@ python -m uvicorn backend.apps.agent_service:app --host 0.0.0.0 --port 8000 --re
python -m uvicorn backend.apps.trading_service:app --host 0.0.0.0 --port 8001 --reload python -m uvicorn backend.apps.trading_service:app --host 0.0.0.0 --port 8001 --reload
python -m uvicorn backend.apps.news_service:app --host 0.0.0.0 --port 8002 --reload python -m uvicorn backend.apps.news_service:app --host 0.0.0.0 --port 8002 --reload
python -m uvicorn backend.apps.runtime_service:app --host 0.0.0.0 --port 8003 --reload python -m uvicorn backend.apps.runtime_service:app --host 0.0.0.0 --port 8003 --reload
# 兼容性 gateway 路径,不是推荐的主要开发入口
python -m backend.main --mode live --host 0.0.0.0 --port 8765 # 然后通过 runtime_service 创建运行时,由它拉起 Gateway 子进程
curl -X POST http://localhost:8003/api/runtime/start \
-H "Content-Type: application/json" \
-d '{"launch_mode":"fresh","tickers":["AAPL","MSFT"],"mode":"live"}'
``` ```
仓库里部署脚本使用的 `production` 只是一个示例 run label不应再把它理解成 仓库里部署脚本使用的 `production` 只是一个示例 run label不应再把它理解成
@@ -207,24 +208,19 @@ python -m backend.main --mode live --host 0.0.0.0 --port 8765
回测: 回测:
```bash ```bash
python backend/main.py --mode backtest --config-name smoke_fullstack --start-date 2025-11-01 --end-date 2025-12-01 curl -X POST http://localhost:8003/api/runtime/start \
python backend/main.py --mode backtest --config-name smoke_fullstack --start-date 2025-11-01 --end-date 2025-12-01 --enable-memory -H "Content-Type: application/json" \
-d '{"launch_mode":"fresh","mode":"backtest","tickers":["AAPL","MSFT"],"start_date":"2025-11-01","end_date":"2025-12-01"}'
``` ```
实盘: 实盘:
```bash ```bash
python backend/main.py --mode live --config-name live curl -X POST http://localhost:8003/api/runtime/start \
python backend/main.py --mode live --config-name live --enable-memory -H "Content-Type: application/json" \
python backend/main.py --mode live --config-name live --interval-minutes 60 -d '{"launch_mode":"fresh","mode":"live","tickers":["AAPL","MSFT"]}'
python backend/main.py --mode live --config-name live --trigger-time 22:30
``` ```
帮助:
```bash
python backend/main.py --help
```
### 离线回测数据 ### 离线回测数据
如果只是想快速体验回测,不依赖外部行情 API可以下载离线数据包并解压到 `backend/data` 如果只是想快速体验回测,不依赖外部行情 API可以下载离线数据包并解压到 `backend/data`

View File

@@ -0,0 +1,372 @@
# -*- coding: utf-8 -*-
"""Dynamic Team Types - Core data types for PM-driven analyst team management.
This module provides data structures for:
- Analyst persona definitions (custom analyst types)
- Analyst creation configuration (custom SOUL.md, AGENTS.md, etc.)
- Dynamic team runtime state tracking
These types enable the Portfolio Manager to dynamically create, clone, and manage
analyst agents with custom configurations beyond the predefined 4 analyst types.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Optional, Dict, Any, List
from datetime import datetime
@dataclass
class AnalystPersona:
"""Analyst role definition - extends or replaces personas.yaml entries.
Defines the identity, focus areas, and characteristics of an analyst type.
Can be used to create entirely new analyst types at runtime.
Attributes:
name: Display name for the analyst (e.g., "期权策略分析师")
focus: List of focus areas (e.g., ["期权定价", "波动率交易"])
description: Detailed description of the analyst's role and expertise
preferred_tools: Optional list of preferred tool types or categories
icon: Optional icon identifier for frontend display
"""
name: str
focus: List[str]
description: str
preferred_tools: Optional[List[str]] = None
icon: Optional[str] = None
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary for serialization."""
return {
"name": self.name,
"focus": self.focus,
"description": self.description,
"preferred_tools": self.preferred_tools,
"icon": self.icon,
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> AnalystPersona:
"""Create from dictionary."""
return cls(
name=data["name"],
focus=data.get("focus", []),
description=data.get("description", ""),
preferred_tools=data.get("preferred_tools"),
icon=data.get("icon"),
)
@dataclass
class AnalystConfig:
"""Complete configuration for dynamically creating an analyst.
This dataclass allows the PM to specify all aspects of analyst creation,
including custom workspace files, model overrides, and skill selections.
Attributes:
persona: Complete persona definition (if creating custom type)
analyst_type: Reference to predefined type (e.g., "technical_analyst")
soul_md: Custom SOUL.md content (overrides default generation)
agents_md: Custom AGENTS.md content (overrides default generation)
profile_md: Custom PROFILE.md content (overrides default generation)
skills: List of skill IDs to enable for this analyst
model_name: Override default model for this analyst
memory_config: Custom memory system configuration
tags: Classification tags (e.g., ["options", "derivatives"])
parent_id: If cloned, the source analyst ID
"""
# Identity configuration
persona: Optional[AnalystPersona] = None
analyst_type: Optional[str] = None # Reference to predefined type
# Workspace file contents (override default generation)
soul_md: Optional[str] = None
agents_md: Optional[str] = None
profile_md: Optional[str] = None
bootstrap_md: Optional[str] = None
# Runtime configuration
skills: Optional[List[str]] = field(default_factory=list)
model_name: Optional[str] = None
memory_config: Optional[Dict[str, Any]] = field(default_factory=dict)
# Metadata
tags: Optional[List[str]] = field(default_factory=list)
parent_id: Optional[str] = None # For clone tracking
def __post_init__(self):
"""Initialize default collections."""
if self.skills is None:
self.skills = []
if self.memory_config is None:
self.memory_config = {}
if self.tags is None:
self.tags = []
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary for serialization."""
return {
"persona": self.persona.to_dict() if self.persona else None,
"analyst_type": self.analyst_type,
"soul_md": self.soul_md,
"agents_md": self.agents_md,
"profile_md": self.profile_md,
"bootstrap_md": self.bootstrap_md,
"skills": self.skills,
"model_name": self.model_name,
"memory_config": self.memory_config,
"tags": self.tags,
"parent_id": self.parent_id,
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> AnalystConfig:
"""Create from dictionary."""
persona_data = data.get("persona")
return cls(
persona=AnalystPersona.from_dict(persona_data) if persona_data else None,
analyst_type=data.get("analyst_type"),
soul_md=data.get("soul_md"),
agents_md=data.get("agents_md"),
profile_md=data.get("profile_md"),
bootstrap_md=data.get("bootstrap_md"),
skills=data.get("skills", []),
model_name=data.get("model_name"),
memory_config=data.get("memory_config", {}),
tags=data.get("tags", []),
parent_id=data.get("parent_id"),
)
def get_effective_analyst_type(self) -> Optional[str]:
"""Get the effective analyst type for tool selection.
Returns analyst_type if set, otherwise derives from persona name.
"""
if self.analyst_type:
return self.analyst_type
if self.persona:
# Derive type ID from persona name (e.g., "期权策略分析师" -> "options_strategist")
return self._derive_type_id(self.persona.name)
return None
@staticmethod
def _derive_type_id(name: str) -> str:
"""Derive a type ID from a display name."""
import re
# Convert Chinese or mixed names to snake_case
# Remove special characters, keep alphanumeric and spaces
cleaned = re.sub(r'[^\w\s]', '', name)
# Convert to lowercase and replace spaces with underscores
return cleaned.lower().strip().replace(' ', '_')
@dataclass
class DynamicAnalystInstance:
"""Runtime information about a dynamically created analyst.
Tracks the creation metadata and current state of a dynamic analyst.
Attributes:
agent_id: Unique identifier for this analyst instance
config: The configuration used to create this analyst
created_at: Timestamp when the analyst was created
created_by: Identifier of the agent that created this analyst (usually PM)
status: Current status (active, paused, removed)
"""
agent_id: str
config: AnalystConfig
created_at: str = field(default_factory=lambda: datetime.now().isoformat())
created_by: str = "portfolio_manager"
status: str = "active" # active, paused, removed
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary for serialization."""
return {
"agent_id": self.agent_id,
"config": self.config.to_dict(),
"created_at": self.created_at,
"created_by": self.created_by,
"status": self.status,
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> DynamicAnalystInstance:
"""Create from dictionary."""
return cls(
agent_id=data["agent_id"],
config=AnalystConfig.from_dict(data.get("config", {})),
created_at=data.get("created_at", datetime.now().isoformat()),
created_by=data.get("created_by", "portfolio_manager"),
status=data.get("status", "active"),
)
@dataclass
class DynamicTeamState:
"""Complete runtime state for dynamic analyst team management.
This state is persisted alongside TEAM_PIPELINE.yaml and tracks:
- Custom analyst types registered at runtime
- All dynamically created analyst instances
- Configuration snapshots for cloning
Attributes:
run_id: The run configuration this state belongs to
registered_types: Runtime-registered analyst type definitions
instances: Dynamically created analyst instances
version: State format version for migration handling
"""
run_id: str
registered_types: Dict[str, AnalystPersona] = field(default_factory=dict)
instances: Dict[str, DynamicAnalystInstance] = field(default_factory=dict)
version: int = 1
def register_type(self, type_id: str, persona: AnalystPersona) -> bool:
"""Register a new analyst type.
Returns:
True if registered, False if type_id already exists
"""
if type_id in self.registered_types:
return False
self.registered_types[type_id] = persona
return True
def add_instance(self, instance: DynamicAnalystInstance) -> None:
"""Add a new analyst instance."""
self.instances[instance.agent_id] = instance
def remove_instance(self, agent_id: str) -> bool:
"""Mark an instance as removed.
Returns:
True if instance was found and removed
"""
if agent_id in self.instances:
self.instances[agent_id].status = "removed"
return True
return False
def get_active_instances(self) -> List[DynamicAnalystInstance]:
"""Get all active (non-removed) analyst instances."""
return [
inst for inst in self.instances.values()
if inst.status == "active"
]
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary for serialization."""
return {
"run_id": self.run_id,
"registered_types": {
k: v.to_dict() for k, v in self.registered_types.items()
},
"instances": {
k: v.to_dict() for k, v in self.instances.items()
},
"version": self.version,
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> DynamicTeamState:
"""Create from dictionary."""
registered_types = {
k: AnalystPersona.from_dict(v)
for k, v in data.get("registered_types", {}).items()
}
instances = {
k: DynamicAnalystInstance.from_dict(v)
for k, v in data.get("instances", {}).items()
}
return cls(
run_id=data.get("run_id", "unknown"),
registered_types=registered_types,
instances=instances,
version=data.get("version", 1),
)
@dataclass
class CreateAnalystResult:
"""Result of creating a dynamic analyst.
Attributes:
success: Whether creation was successful
agent_id: The ID of the created analyst (if successful)
message: Human-readable result message
error: Error details (if failed)
"""
success: bool
agent_id: Optional[str] = None
message: str = ""
error: Optional[str] = None
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary for API responses."""
return {
"success": self.success,
"agent_id": self.agent_id,
"message": self.message,
"error": self.error,
}
@dataclass
class CloneAnalystRequest:
"""Request to clone an existing analyst.
Attributes:
source_id: ID of the analyst to clone
new_id: ID for the new analyst
config_overrides: Configuration fields to override
"""
source_id: str
new_id: str
config_overrides: Optional[Dict[str, Any]] = field(default_factory=dict)
def __post_init__(self):
if self.config_overrides is None:
self.config_overrides = {}
@dataclass
class AnalystTypeInfo:
"""Information about an available analyst type.
Used for listing all available types (predefined + runtime-registered).
Attributes:
type_id: Unique identifier for this type
name: Display name
description: Type description
is_builtin: Whether this is a built-in type or runtime-registered
source: Source of this type (e.g., "constants", "runtime", "config")
"""
type_id: str
name: str
description: str
is_builtin: bool
source: str
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary for API responses."""
return {
"type_id": self.type_id,
"name": self.name,
"description": self.description,
"is_builtin": self.is_builtin,
"source": self.source,
}
__all__ = [
"AnalystPersona",
"AnalystConfig",
"DynamicAnalystInstance",
"DynamicTeamState",
"CreateAnalystResult",
"CloneAnalystRequest",
"AnalystTypeInfo",
]

View File

@@ -14,6 +14,14 @@ from backend.agents.agent_workspace import load_agent_workspace_config
from backend.agents.skills_manager import SkillsManager from backend.agents.skills_manager import SkillsManager
from backend.agents.skill_metadata import parse_skill_metadata from backend.agents.skill_metadata import parse_skill_metadata
from backend.config.bootstrap_config import get_bootstrap_config_for_run from backend.config.bootstrap_config import get_bootstrap_config_for_run
from backend.tools.dynamic_team_tools import (
create_analyst,
clone_analyst,
remove_analyst,
list_analyst_types,
get_analyst_info,
get_team_summary,
)
def load_agent_profiles() -> Dict[str, Dict[str, Any]]: def load_agent_profiles() -> Dict[str, Dict[str, Any]]:
@@ -138,6 +146,23 @@ def _register_portfolio_tool_groups(toolkit: Any, pm_agent: Any) -> None:
group_name="portfolio_ops", group_name="portfolio_ops",
) )
# Register dynamic team management tools
toolkit.create_tool_group(
group_name="dynamic_team",
description="Dynamic analyst team management tools.",
active=False,
notes=(
"Use these tools to create, clone, and manage analyst agents dynamically. "
"Only available when allow_dynamic_team_update is enabled."
),
)
toolkit.register_tool_function(create_analyst, group_name="dynamic_team")
toolkit.register_tool_function(clone_analyst, group_name="dynamic_team")
toolkit.register_tool_function(remove_analyst, group_name="dynamic_team")
toolkit.register_tool_function(list_analyst_types, group_name="dynamic_team")
toolkit.register_tool_function(get_analyst_info, group_name="dynamic_team")
toolkit.register_tool_function(get_team_summary, group_name="dynamic_team")
def _register_risk_tool_groups(toolkit: Any) -> None: def _register_risk_tool_groups(toolkit: Any) -> None:
"""注册风险工具组""" """注册风险工具组"""

View File

@@ -13,6 +13,7 @@ from .workspaces import router as workspaces_router
from .guard import router as guard_router from .guard import router as guard_router
from .runtime import router as runtime_router from .runtime import router as runtime_router
from .runs import router as runs_router from .runs import router as runs_router
from .dynamic_team import router as dynamic_team_router
__all__ = [ __all__ = [
"agents_router", "agents_router",
@@ -20,4 +21,5 @@ __all__ = [
"guard_router", "guard_router",
"runtime_router", "runtime_router",
"runs_router", "runs_router",
"dynamic_team_router",
] ]

View File

@@ -1,46 +1,18 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """Agent API routes for design-time workspace registry CRUD only."""
Agent API Routes
Provides REST API endpoints for both:
- design-time agent management under `workspaces/`
- run-scoped agent asset access under `runs/<run_id>/`
"""
import logging import logging
import os
import tempfile
from pathlib import Path from pathlib import Path
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
from fastapi import APIRouter, HTTPException, Depends, Body, UploadFile, File, Form from fastapi import APIRouter, HTTPException, Depends
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from backend.agents import AgentFactory, get_registry from backend.agents import AgentFactory, get_registry
from backend.agents.workspace_manager import RunWorkspaceManager
from backend.agents.agent_workspace import load_agent_workspace_config
from backend.agents.skills_manager import SkillsManager
from backend.agents.toolkit_factory import load_agent_profiles
from backend.config.bootstrap_config import get_bootstrap_config_for_run
from backend.llm.models import get_agent_model_info
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/workspaces/{workspace_id}/agents", tags=["agents"]) router = APIRouter(prefix="/api/workspaces/{workspace_id}/agents", tags=["agents"])
DESIGN_SCOPE = "design_workspace" 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]: def _design_scope_fields() -> dict[str, str]:
@@ -65,26 +37,9 @@ class CreateAgentRequest(BaseModel):
class UpdateAgentRequest(BaseModel): class UpdateAgentRequest(BaseModel):
"""Request to update an agent.""" """Request to update design-time agent metadata."""
name: Optional[str] = None name: Optional[str] = None
description: Optional[str] = None description: Optional[str] = None
enabled_skills: Optional[List[str]] = None
disabled_skills: Optional[List[str]] = None
class InstallExternalSkillRequest(BaseModel):
"""Request to install an external skill for one agent."""
source: str = Field(..., description="Directory path, zip path, or http(s) zip URL")
name: Optional[str] = Field(None, description="Optional override skill name")
activate: bool = Field(True, description="Whether to enable skill immediately")
class LocalSkillRequest(BaseModel):
skill_name: str = Field(..., description="Local skill name")
class LocalSkillContentRequest(BaseModel):
content: str = Field(..., description="Updated SKILL.md content")
class AgentResponse(BaseModel): class AgentResponse(BaseModel):
@@ -99,54 +54,12 @@ class AgentResponse(BaseModel):
scope_note: Optional[str] = None 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 # Dependencies
def get_agent_factory(): def get_agent_factory():
"""Get AgentFactory instance.""" """Get AgentFactory instance."""
return AgentFactory() return AgentFactory()
def get_workspace_manager():
"""Get run-scoped asset manager for one runtime workspace/run id."""
return RunWorkspaceManager()
def get_skills_manager():
"""Get SkillsManager instance."""
return SkillsManager()
# Routes # Routes
@router.post("", response_model=AgentResponse) @router.post("", response_model=AgentResponse)
async def create_agent( async def create_agent(
@@ -270,119 +183,6 @@ async def get_agent(
) )
@router.get("/{agent_id}/profile", response_model=AgentProfileResponse)
async def get_agent_profile(
workspace_id: str,
agent_id: str,
skills_manager: SkillsManager = Depends(get_skills_manager),
):
asset_dir = skills_manager.get_agent_asset_dir(workspace_id, agent_id)
agent_config = load_agent_workspace_config(asset_dir / "agent.yaml")
profiles = load_agent_profiles()
profile = profiles.get(agent_id, {})
bootstrap = get_bootstrap_config_for_run(skills_manager.project_root, workspace_id)
override = bootstrap.agent_override(agent_id)
active_tool_groups = override.get("active_tool_groups", agent_config.active_tool_groups or profile.get("active_tool_groups", []))
if not isinstance(active_tool_groups, list):
active_tool_groups = []
disabled_tool_groups = agent_config.disabled_tool_groups
if disabled_tool_groups:
disabled_set = set(disabled_tool_groups)
active_tool_groups = [group_name for group_name in active_tool_groups if group_name not in disabled_set]
default_skills = profile.get("skills", [])
if not isinstance(default_skills, list):
default_skills = []
resolved_skills = skills_manager.resolve_agent_skill_names(
config_name=workspace_id,
agent_id=agent_id,
default_skills=default_skills,
)
prompt_files = agent_config.prompt_files or ["SOUL.md", "PROFILE.md", "AGENTS.md", "POLICY.md", "MEMORY.md"]
model_name, model_provider = get_agent_model_info(agent_id)
return AgentProfileResponse(
agent_id=agent_id,
workspace_id=workspace_id,
profile={
"model_name": model_name,
"model_provider": model_provider,
"prompt_files": prompt_files,
"default_skills": default_skills,
"resolved_skills": resolved_skills,
"active_tool_groups": active_tool_groups,
"disabled_tool_groups": disabled_tool_groups,
"enabled_skills": agent_config.enabled_skills,
"disabled_skills": agent_config.disabled_skills,
},
**_runtime_scope_fields(),
)
@router.get("/{agent_id}/skills", response_model=AgentSkillsResponse)
async def get_agent_skills(
workspace_id: str,
agent_id: str,
skills_manager: SkillsManager = Depends(get_skills_manager),
):
agent_asset_dir = skills_manager.get_agent_asset_dir(workspace_id, agent_id)
agent_config = load_agent_workspace_config(agent_asset_dir / "agent.yaml")
resolved_skills = set(skills_manager.resolve_agent_skill_names(config_name=workspace_id, agent_id=agent_id, default_skills=[]))
enabled = set(agent_config.enabled_skills)
disabled = set(agent_config.disabled_skills)
payload = []
for item in skills_manager.list_agent_skill_catalog(workspace_id, agent_id):
if item.skill_name in disabled:
status = "disabled"
elif item.skill_name in enabled:
status = "enabled"
elif item.skill_name in resolved_skills:
status = "active"
else:
status = "available"
payload.append({
"skill_name": item.skill_name,
"name": item.name,
"description": item.description,
"version": item.version,
"source": item.source,
"tools": item.tools,
"status": status,
})
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)
async def get_agent_skill_detail(
workspace_id: str,
agent_id: str,
skill_name: str,
skills_manager: SkillsManager = Depends(get_skills_manager),
):
try:
detail = skills_manager.load_agent_skill_document(
config_name=workspace_id,
agent_id=agent_id,
skill_name=skill_name,
)
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,
**_runtime_scope_fields(),
)
@router.delete("/{agent_id}") @router.delete("/{agent_id}")
async def delete_agent( async def delete_agent(
workspace_id: str, workspace_id: str,
@@ -448,16 +248,6 @@ async def update_agent(
if metadata_updates: if metadata_updates:
registry.update_metadata(agent_id, metadata_updates) registry.update_metadata(agent_id, metadata_updates)
# Update skills if provided
if request.enabled_skills or request.disabled_skills:
skills_manager = SkillsManager()
skills_manager.update_agent_skill_overrides(
config_name=workspace_id,
agent_id=agent_id,
enable=request.enabled_skills or [],
disable=request.disabled_skills or [],
)
# Get updated info # Get updated info
agent_info = registry.get(agent_id) agent_info = registry.get(agent_id)
return AgentResponse( return AgentResponse(
@@ -469,301 +259,3 @@ async def update_agent(
status=agent_info.status, status=agent_info.status,
**_design_scope_fields(), **_design_scope_fields(),
) )
@router.post("/{agent_id}/skills/{skill_name}/enable")
async def enable_skill(
workspace_id: str,
agent_id: str,
skill_name: str,
registry = Depends(get_registry),
):
"""
Enable a skill for an agent.
Args:
workspace_id: Workspace identifier
agent_id: Agent identifier
skill_name: Skill name to enable
Returns:
Success message
"""
agent_info = registry.get(agent_id)
if not agent_info or agent_info.workspace_id != workspace_id:
raise HTTPException(status_code=404, detail=f"Agent '{agent_id}' not found")
skills_manager = SkillsManager()
result = skills_manager.update_agent_skill_overrides(
config_name=workspace_id,
agent_id=agent_id,
enable=[skill_name],
)
return {
"message": f"Skill '{skill_name}' enabled for agent '{agent_id}'",
"enabled_skills": result["enabled_skills"],
}
@router.post("/{agent_id}/skills/{skill_name}/disable")
async def disable_skill(
workspace_id: str,
agent_id: str,
skill_name: str,
registry = Depends(get_registry),
):
"""
Disable a skill for an agent.
Args:
workspace_id: Workspace identifier
agent_id: Agent identifier
skill_name: Skill name to disable
Returns:
Success message
"""
agent_info = registry.get(agent_id)
if not agent_info or agent_info.workspace_id != workspace_id:
raise HTTPException(status_code=404, detail=f"Agent '{agent_id}' not found")
skills_manager = SkillsManager()
result = skills_manager.update_agent_skill_overrides(
config_name=workspace_id,
agent_id=agent_id,
disable=[skill_name],
)
return {
"message": f"Skill '{skill_name}' disabled for agent '{agent_id}'",
"disabled_skills": result["disabled_skills"],
}
@router.post("/{agent_id}/skills/install")
async def install_external_skill(
workspace_id: str,
agent_id: str,
request: InstallExternalSkillRequest,
registry=Depends(get_registry),
):
"""Install an external skill into one agent's local skills."""
agent_info = registry.get(agent_id)
if not agent_info or agent_info.workspace_id != workspace_id:
raise HTTPException(status_code=404, detail=f"Agent '{agent_id}' not found")
skills_manager = SkillsManager()
try:
result = skills_manager.install_external_skill_for_agent(
config_name=workspace_id,
agent_id=agent_id,
source=request.source,
skill_name=request.name,
activate=request.activate,
)
except (FileNotFoundError, ValueError) as exc:
raise HTTPException(status_code=400, detail=str(exc))
return {
"message": f"Installed external skill '{result['skill_name']}' for '{agent_id}'",
**result,
}
@router.post("/{agent_id}/skills/local")
async def create_local_skill(
workspace_id: str,
agent_id: str,
request: LocalSkillRequest,
registry=Depends(get_registry),
):
agent_info = registry.get(agent_id)
if not agent_info or agent_info.workspace_id != workspace_id:
raise HTTPException(status_code=404, detail=f"Agent '{agent_id}' not found")
skills_manager = SkillsManager()
try:
skills_manager.create_agent_local_skill(
config_name=workspace_id,
agent_id=agent_id,
skill_name=request.skill_name,
)
except (ValueError, FileExistsError) as exc:
raise HTTPException(status_code=400, detail=str(exc))
return {"message": f"Created local skill '{request.skill_name}' for '{agent_id}'"}
@router.put("/{agent_id}/skills/local/{skill_name}")
async def update_local_skill(
workspace_id: str,
agent_id: str,
skill_name: str,
request: LocalSkillContentRequest,
registry=Depends(get_registry),
):
agent_info = registry.get(agent_id)
if not agent_info or agent_info.workspace_id != workspace_id:
raise HTTPException(status_code=404, detail=f"Agent '{agent_id}' not found")
skills_manager = SkillsManager()
try:
skills_manager.update_agent_local_skill(
config_name=workspace_id,
agent_id=agent_id,
skill_name=skill_name,
content=request.content,
)
except (ValueError, FileNotFoundError) as exc:
raise HTTPException(status_code=400, detail=str(exc))
return {"message": f"Updated local skill '{skill_name}' for '{agent_id}'"}
@router.delete("/{agent_id}/skills/local/{skill_name}")
async def delete_local_skill(
workspace_id: str,
agent_id: str,
skill_name: str,
registry=Depends(get_registry),
):
agent_info = registry.get(agent_id)
if not agent_info or agent_info.workspace_id != workspace_id:
raise HTTPException(status_code=404, detail=f"Agent '{agent_id}' not found")
skills_manager = SkillsManager()
try:
skills_manager.delete_agent_local_skill(
config_name=workspace_id,
agent_id=agent_id,
skill_name=skill_name,
)
skills_manager.forget_agent_skill_overrides(
config_name=workspace_id,
agent_id=agent_id,
skill_names=[skill_name],
)
except (ValueError, FileNotFoundError) as exc:
raise HTTPException(status_code=400, detail=str(exc))
return {"message": f"Deleted local skill '{skill_name}' for '{agent_id}'"}
@router.post("/{agent_id}/skills/upload")
async def upload_external_skill(
workspace_id: str,
agent_id: str,
file: UploadFile = File(...),
name: Optional[str] = Form(None),
activate: bool = Form(True),
registry=Depends(get_registry),
):
"""Upload a zip skill package from frontend and install for one agent."""
agent_info = registry.get(agent_id)
if not agent_info or agent_info.workspace_id != workspace_id:
raise HTTPException(status_code=404, detail=f"Agent '{agent_id}' not found")
original_name = (file.filename or "").strip()
if not original_name.lower().endswith(".zip"):
raise HTTPException(status_code=400, detail="Uploaded file must be a .zip archive")
suffix = Path(original_name).suffix or ".zip"
temp_path: Optional[str] = None
try:
with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp:
temp_path = tmp.name
content = await file.read()
tmp.write(content)
skills_manager = SkillsManager()
result = skills_manager.install_external_skill_for_agent(
config_name=workspace_id,
agent_id=agent_id,
source=temp_path,
skill_name=name,
activate=activate,
)
except (FileNotFoundError, ValueError) as exc:
raise HTTPException(status_code=400, detail=str(exc))
finally:
try:
await file.close()
except Exception as e:
logger.warning(f"Failed to close uploaded file: {e}")
if temp_path and os.path.exists(temp_path):
os.remove(temp_path)
return {
"message": f"Uploaded and installed external skill '{result['skill_name']}' for '{agent_id}'",
**result,
}
@router.get("/{agent_id}/files/{filename}", response_model=AgentFileResponse)
async def get_agent_file(
workspace_id: str,
agent_id: str,
filename: str,
workspace_manager: RunWorkspaceManager = Depends(get_workspace_manager),
):
"""
Read an agent file from the run-scoped asset tree under `runs/<run_id>/`.
Args:
workspace_id: Workspace identifier
agent_id: Agent identifier
filename: File to read (e.g., SOUL.md, PROFILE.md)
Returns:
File content
"""
try:
content = workspace_manager.load_agent_file(
config_name=workspace_id,
agent_id=agent_id,
filename=filename,
)
return AgentFileResponse(
filename=filename,
content=content,
**_runtime_scope_fields(),
)
except FileNotFoundError:
raise HTTPException(status_code=404, detail=f"File '{filename}' not found")
@router.put("/{agent_id}/files/{filename}", response_model=AgentFileResponse)
async def update_agent_file(
workspace_id: str,
agent_id: str,
filename: str,
content: str = Body(..., media_type="text/plain"),
workspace_manager: RunWorkspaceManager = Depends(get_workspace_manager),
):
"""
Update an agent file in the run-scoped asset tree under `runs/<run_id>/`.
Args:
workspace_id: Workspace identifier
agent_id: Agent identifier
filename: File to update
content: New file content
Returns:
Updated file information
"""
try:
workspace_manager.update_agent_file(
config_name=workspace_id,
agent_id=agent_id,
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))

404
backend/api/dynamic_team.py Normal file
View File

@@ -0,0 +1,404 @@
# -*- coding: utf-8 -*-
"""Dynamic Team API - REST endpoints for managing analyst team dynamically.
This module provides API endpoints for:
- Creating new analysts with custom configuration
- Cloning existing analysts
- Removing analysts
- Listing available analyst types
- Getting analyst information
- Managing team composition
These endpoints allow both the PM agent (via tool calls) and frontend
(via HTTP) to manage the analyst team dynamically.
"""
from __future__ import annotations
import logging
from pathlib import Path
from typing import Any, Dict, List, Optional
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, Field
from backend.agents.dynamic_team_types import (
AnalystPersona,
AnalystConfig,
AnalystTypeInfo,
)
from backend.config.constants import ANALYST_TYPES
from backend.agents.prompt_loader import get_prompt_loader
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/dynamic-team", tags=["dynamic-team"])
PROJECT_ROOT = Path(__file__).resolve().parents[2]
# Pydantic models for API requests/responses
class AnalystPersonaRequest(BaseModel):
"""Request model for analyst persona definition."""
name: str = Field(..., description="Display name for the analyst")
focus: List[str] = Field(default_factory=list, description="List of focus areas")
description: str = Field(..., description="Detailed description")
preferred_tools: Optional[List[str]] = Field(None, description="Preferred tool categories")
icon: Optional[str] = Field(None, description="Icon identifier")
class CreateAnalystRequest(BaseModel):
"""Request model for creating a new analyst."""
agent_id: str = Field(..., description="Unique identifier for the new analyst")
analyst_type: str = Field(..., description="Base type or custom identifier")
persona: Optional[AnalystPersonaRequest] = Field(None, description="Custom persona definition")
soul_md: Optional[str] = Field(None, description="Custom SOUL.md content")
agents_md: Optional[str] = Field(None, description="Custom AGENTS.md content")
profile_md: Optional[str] = Field(None, description="Custom PROFILE.md content")
bootstrap_md: Optional[str] = Field(None, description="Custom BOOTSTRAP.md content")
model_name: Optional[str] = Field(None, description="Override default LLM model")
skills: Optional[List[str]] = Field(None, description="List of skill IDs to enable")
tags: Optional[List[str]] = Field(None, description="Classification tags")
class CloneAnalystRequest(BaseModel):
"""Request model for cloning an analyst."""
source_id: str = Field(..., description="ID of the analyst to clone")
new_id: str = Field(..., description="Unique identifier for the new analyst")
name: Optional[str] = Field(None, description="New display name")
focus_additions: Optional[List[str]] = Field(None, description="Additional focus areas")
description_override: Optional[str] = Field(None, description="New description")
model_name: Optional[str] = Field(None, description="Override model from source")
class RegisterTypeRequest(BaseModel):
"""Request model for registering a new analyst type."""
type_id: str = Field(..., description="Unique identifier for this type")
name: str = Field(..., description="Display name")
focus: List[str] = Field(..., description="List of focus areas")
description: str = Field(..., description="Detailed description")
preferred_tools: Optional[List[str]] = Field(None, description="Preferred tool categories")
class AnalystResponse(BaseModel):
"""Response model for analyst operations."""
success: bool
agent_id: Optional[str] = None
message: str
error: Optional[str] = None
class AnalystTypeResponse(BaseModel):
"""Response model for analyst type information."""
type_id: str
name: str
description: str
is_builtin: bool
source: str
class AnalystInfoResponse(BaseModel):
"""Response model for detailed analyst information."""
found: bool
agent_id: str
config: Optional[Dict[str, Any]] = None
is_custom: bool = False
is_clone: bool = False
parent_id: Optional[str] = None
message: Optional[str] = None
class TeamSummaryResponse(BaseModel):
"""Response model for team summary."""
total_analysts: int
custom_analysts: int
cloned_analysts: int
analysts: List[Dict[str, Any]]
registered_types: int
# Helper function to get the current pipeline instance
def _get_pipeline(run_id: str) -> Optional[Any]:
"""Get the TradingPipeline instance for a run.
Args:
run_id: The run configuration ID
Returns:
TradingPipeline instance or None if not found
"""
# Import here to avoid circular imports
try:
from backend.apps.runtime_service import get_runtime_state
runtime_state = get_runtime_state()
if runtime_state and hasattr(runtime_state, 'pipeline'):
return runtime_state.pipeline
except Exception as e:
logger.warning(f"Could not get pipeline for run {run_id}: {e}")
return None
def _get_controller(run_id: str) -> Optional[Any]:
"""Get the DynamicTeamController for a run.
Args:
run_id: The run configuration ID
Returns:
DynamicTeamController instance or None if not available
"""
try:
from backend.tools.dynamic_team_tools import get_controller
return get_controller()
except Exception as e:
logger.warning(f"Could not get controller for run {run_id}: {e}")
return None
# API Endpoints
@router.get("/types", response_model=List[AnalystTypeResponse])
async def list_analyst_types() -> List[AnalystTypeResponse]:
"""List all available analyst types.
Returns both built-in types (from ANALYST_TYPES) and runtime-registered types.
"""
result = []
# Add built-in types
for type_id, info in ANALYST_TYPES.items():
result.append(AnalystTypeResponse(
type_id=type_id,
name=info.get("display_name", type_id),
description=info.get("description", ""),
is_builtin=True,
source="constants",
))
# Try to get runtime registered types
controller = _get_controller("default")
if controller:
for type_id, persona in controller._registered_types.items():
result.append(AnalystTypeResponse(
type_id=type_id,
name=persona.name,
description=persona.description,
is_builtin=False,
source="runtime",
))
return result
@router.get("/personas")
async def get_personas() -> Dict[str, Any]:
"""Get all analyst personas from personas.yaml.
Returns the persona definitions used for analyst initialization.
"""
try:
personas = get_prompt_loader().load_yaml_config("analyst", "personas")
return {"success": True, "personas": personas}
except Exception as e:
logger.error(f"Failed to load personas: {e}")
raise HTTPException(status_code=500, detail=f"Failed to load personas: {e}")
@router.post("/runs/{run_id}/analysts", response_model=AnalystResponse)
async def create_analyst(
run_id: str,
request: CreateAnalystRequest,
) -> AnalystResponse:
"""Create a new analyst in the specified run.
Args:
run_id: The run configuration ID
request: Analyst creation configuration
Returns:
Result of the creation operation
"""
controller = _get_controller(run_id)
if not controller:
raise HTTPException(
status_code=503,
detail="Dynamic team controller not available. Is the pipeline running?"
)
# Build persona if provided
persona = None
if request.persona:
persona = AnalystPersona(
name=request.persona.name,
focus=request.persona.focus,
description=request.persona.description,
preferred_tools=request.persona.preferred_tools,
icon=request.persona.icon,
)
# Build config
config = AnalystConfig(
persona=persona,
analyst_type=request.analyst_type if request.analyst_type in ANALYST_TYPES else None,
soul_md=request.soul_md,
agents_md=request.agents_md,
profile_md=request.profile_md,
bootstrap_md=request.bootstrap_md,
model_name=request.model_name,
skills=request.skills or [],
tags=request.tags or [],
)
# Create the analyst
result = controller.create_analyst(
agent_id=request.agent_id,
analyst_type=request.analyst_type,
name=persona.name if persona else None,
focus=persona.focus if persona else None,
description=persona.description if persona else None,
soul_md=config.soul_md,
agents_md=config.agents_md,
model_name=config.model_name,
)
return AnalystResponse(**result)
@router.post("/runs/{run_id}/analysts/clone", response_model=AnalystResponse)
async def clone_analyst(
run_id: str,
request: CloneAnalystRequest,
) -> AnalystResponse:
"""Clone an existing analyst.
Args:
run_id: The run configuration ID
request: Clone configuration
Returns:
Result of the clone operation
"""
controller = _get_controller(run_id)
if not controller:
raise HTTPException(
status_code=503,
detail="Dynamic team controller not available. Is the pipeline running?"
)
result = controller.clone_analyst(
source_id=request.source_id,
new_id=request.new_id,
name=request.name,
focus_additions=request.focus_additions,
description_override=request.description_override,
model_name=request.model_name,
)
return AnalystResponse(**result)
@router.delete("/runs/{run_id}/analysts/{agent_id}", response_model=AnalystResponse)
async def remove_analyst(run_id: str, agent_id: str) -> AnalystResponse:
"""Remove a dynamically created analyst.
Args:
run_id: The run configuration ID
agent_id: The analyst to remove
Returns:
Result of the removal operation
"""
controller = _get_controller(run_id)
if not controller:
raise HTTPException(
status_code=503,
detail="Dynamic team controller not available. Is the pipeline running?"
)
result = controller.remove_analyst(agent_id)
return AnalystResponse(**result)
@router.get("/runs/{run_id}/analysts/{agent_id}", response_model=AnalystInfoResponse)
async def get_analyst_info(run_id: str, agent_id: str) -> AnalystInfoResponse:
"""Get information about a specific analyst.
Args:
run_id: The run configuration ID
agent_id: The analyst ID
Returns:
Analyst configuration and status
"""
controller = _get_controller(run_id)
if not controller:
raise HTTPException(
status_code=503,
detail="Dynamic team controller not available. Is the pipeline running?"
)
result = controller.get_analyst_info(agent_id)
return AnalystInfoResponse(**result)
@router.get("/runs/{run_id}/summary", response_model=TeamSummaryResponse)
async def get_team_summary(run_id: str) -> TeamSummaryResponse:
"""Get a summary of the current analyst team.
Args:
run_id: The run configuration ID
Returns:
Team composition information
"""
controller = _get_controller(run_id)
if not controller:
raise HTTPException(
status_code=503,
detail="Dynamic team controller not available. Is the pipeline running?"
)
result = controller.get_team_summary()
return TeamSummaryResponse(**result)
@router.post("/runs/{run_id}/types", response_model=AnalystTypeResponse)
async def register_analyst_type(
run_id: str,
request: RegisterTypeRequest,
) -> AnalystTypeResponse:
"""Register a new analyst type.
Args:
run_id: The run configuration ID
request: Type registration configuration
Returns:
Registered type information
"""
controller = _get_controller(run_id)
if not controller:
raise HTTPException(
status_code=503,
detail="Dynamic team controller not available. Is the pipeline running?"
)
result = controller.register_analyst_type(
type_id=request.type_id,
name=request.name,
focus=request.focus,
description=request.description,
preferred_tools=request.preferred_tools,
)
if not result.get("success", False):
raise HTTPException(status_code=400, detail=result.get("message", "Registration failed"))
return AnalystTypeResponse(
type_id=request.type_id,
name=request.name,
description=request.description,
is_builtin=False,
source="runtime",
)

View File

@@ -6,7 +6,7 @@ Provides REST API endpoints for runtime agent asset access under `runs/<run_id>/
This module separates runtime concerns from design-time workspace management: This module separates runtime concerns from design-time workspace management:
- `/api/runs/{run_id}/agents/*` - Runtime agent assets and configuration - `/api/runs/{run_id}/agents/*` - Runtime agent assets and configuration
- `/api/workspaces/{workspace_id}/agents/*` - Design-time workspace registry (deprecated) - design-time workspace registry CRUD lives under `/api/workspaces/{workspace_id}/...`
""" """
import logging import logging
import os import os

View File

@@ -29,6 +29,17 @@ router = APIRouter(prefix="/api/runtime", tags=["runtime"])
PROJECT_ROOT = Path(__file__).resolve().parents[2] PROJECT_ROOT = Path(__file__).resolve().parents[2]
def _normalize_schedule_mode(value: Any) -> str:
"""Normalize schedule mode to the current public vocabulary.
`intraday` is kept as a backward-compatible alias for `interval`.
"""
mode = str(value or "daily").strip().lower()
if mode == "intraday":
return "interval"
return mode or "daily"
class RuntimeState: class RuntimeState:
"""Thread-safe singleton for managing runtime state. """Thread-safe singleton for managing runtime state.
@@ -439,6 +450,11 @@ def _is_gateway_running() -> bool:
Checks both the internally-managed gateway process and falls back to Checks both the internally-managed gateway process and falls back to
port availability (for externally-managed gateway processes). port availability (for externally-managed gateway processes).
The fallback matters because this codebase may still encounter two startup
shapes while historical artifacts remain in-tree:
1. runtime_service-managed Gateway subprocesses
2. externally started historical Gateway processes outside the supported dev flow
""" """
process = _runtime_state.gateway_process process = _runtime_state.gateway_process
if process is not None and process.poll() is None: if process is not None and process.poll() is None:
@@ -481,7 +497,11 @@ def _start_gateway_process(
bootstrap: Dict[str, Any], bootstrap: Dict[str, Any],
port: int port: int
) -> subprocess.Popen: ) -> subprocess.Popen:
"""Start Gateway as a separate process.""" """Start Gateway as a runtime_service-managed subprocess.
This path is used when runtime lifecycle is driven through the runtime API.
It is not the only supported way a Gateway may exist in the current repo.
"""
# Validate configuration before starting # Validate configuration before starting
validation_errors = _validate_gateway_config(bootstrap) validation_errors = _validate_gateway_config(bootstrap)
if validation_errors: if validation_errors:
@@ -592,9 +612,9 @@ def _validate_gateway_config(bootstrap: Dict[str, Any]) -> List[str]:
errors.append("Dates must be in YYYY-MM-DD format") errors.append("Dates must be in YYYY-MM-DD format")
# Validate schedule mode # Validate schedule mode
schedule_mode = bootstrap.get("schedule_mode", "daily") schedule_mode = _normalize_schedule_mode(bootstrap.get("schedule_mode", "daily"))
if schedule_mode not in ("daily", "intraday"): if schedule_mode not in ("daily", "interval"):
errors.append(f"Invalid schedule_mode '{schedule_mode}': must be 'daily' or 'intraday'") errors.append(f"Invalid schedule_mode '{schedule_mode}': must be 'daily' or 'interval'")
return errors return errors
@@ -778,7 +798,7 @@ async def get_runtime_mode() -> RuntimeModeResponse:
mode=mode, mode=mode,
is_backtest=mode == "backtest", is_backtest=mode == "backtest",
run_id=context.get("config_name"), run_id=context.get("config_name"),
schedule_mode=bootstrap.get("schedule_mode"), schedule_mode=_normalize_schedule_mode(bootstrap.get("schedule_mode")),
is_running=True, is_running=True,
) )
except HTTPException: except HTTPException:
@@ -909,7 +929,7 @@ def _resolve_runtime_response(run_id: str) -> RuntimeConfigResponse:
project_root=PROJECT_ROOT, project_root=PROJECT_ROOT,
config_name=run_id, config_name=run_id,
enable_memory=bool(bootstrap.get("enable_memory", False)), enable_memory=bool(bootstrap.get("enable_memory", False)),
schedule_mode=str(bootstrap.get("schedule_mode", "daily")), schedule_mode=_normalize_schedule_mode(bootstrap.get("schedule_mode", "daily")),
interval_minutes=int(bootstrap.get("interval_minutes", 60) or 60), interval_minutes=int(bootstrap.get("interval_minutes", 60) or 60),
trigger_time=str(bootstrap.get("trigger_time", "09:30") or "09:30"), trigger_time=str(bootstrap.get("trigger_time", "09:30") or "09:30"),
) )
@@ -929,11 +949,11 @@ def _normalize_runtime_config_updates(
updates: Dict[str, Any] = {} updates: Dict[str, Any] = {}
if request.schedule_mode is not None: if request.schedule_mode is not None:
schedule_mode = str(request.schedule_mode).strip().lower() schedule_mode = _normalize_schedule_mode(request.schedule_mode)
if schedule_mode not in {"daily", "intraday"}: if schedule_mode not in {"daily", "interval"}:
raise HTTPException( raise HTTPException(
status_code=400, status_code=400,
detail="schedule_mode must be 'daily' or 'intraday'", detail="schedule_mode must be 'daily' or 'interval'",
) )
updates["schedule_mode"] = schedule_mode updates["schedule_mode"] = schedule_mode

View File

@@ -31,8 +31,7 @@ def _build_scope_payload(project_root: Path) -> dict[str, object]:
}, },
"agent_route_note": ( "agent_route_note": (
"Runtime routes use `/api/runs/{run_id}/agents/...`. " "Runtime routes use `/api/runs/{run_id}/agents/...`. "
"Legacy `/api/workspaces/{workspace_id}/agents/...` routes are deprecated " "Design-time CRUD routes use `/api/workspaces/{workspace_id}/agents/...`."
"but remain for backward compatibility."
), ),
} }

View File

@@ -5,7 +5,7 @@ from __future__ import annotations
from fastapi import FastAPI from fastapi import FastAPI
from backend.api import runtime_router from backend.api import runtime_router, dynamic_team_router
from backend.api.runtime import get_runtime_state, _check_gateway_health, _get_gateway_process_details from backend.api.runtime import get_runtime_state, _check_gateway_health, _get_gateway_process_details
from backend.apps.cors import add_cors_middleware from backend.apps.cors import add_cors_middleware
@@ -78,6 +78,7 @@ def create_app() -> FastAPI:
} }
app.include_router(runtime_router) app.include_router(runtime_router)
app.include_router(dynamic_team_router)
return app return app

View File

@@ -131,6 +131,13 @@ def _coerce_bool(value: Any) -> bool:
return bool(value) return bool(value)
def _normalize_schedule_mode(value: Any) -> str:
mode = str(value or "daily").strip().lower()
if mode == "intraday":
return "interval"
return mode or "daily"
def resolve_runtime_config( def resolve_runtime_config(
project_root: Path, project_root: Path,
config_name: str, config_name: str,
@@ -162,9 +169,9 @@ def resolve_runtime_config(
get_env_int("MAX_COMM_CYCLES", 2), get_env_int("MAX_COMM_CYCLES", 2),
), ),
), ),
"schedule_mode": str( "schedule_mode": _normalize_schedule_mode(
bootstrap.get("schedule_mode", schedule_mode), bootstrap.get("schedule_mode", schedule_mode),
).strip().lower() or schedule_mode, ),
"interval_minutes": int( "interval_minutes": int(
bootstrap.get( bootstrap.get(
"interval_minutes", "interval_minutes",

197
backend/core/apo.py Normal file
View File

@@ -0,0 +1,197 @@
# -*- coding: utf-8 -*-
"""
Autonomous Policy Optimizer (APO)
Automatically tunes agent policies based on performance feedback.
"""
import logging
import json
import os
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, List, Optional
from agentscope.message import Msg
from backend.llm.models import get_agent_model, get_agent_formatter
from backend.agents.workspace_manager import WorkspaceManager
logger = logging.getLogger(__name__)
class PolicyOptimizer:
"""
PolicyOptimizer analyzes trading performance and automatically updates
agent workspace files (POLICY.md, AGENTS.md) to improve future results.
"""
def __init__(self, config_name: str, project_root: Optional[Path] = None):
self.config_name = config_name
self.workspace_manager = WorkspaceManager(project_root=project_root)
# Use a high-capability model for the optimizer (meta-agent)
self.model = get_agent_model("portfolio_manager")
self.formatter = get_agent_formatter("portfolio_manager")
async def run_optimization(
self,
date: str,
reflection_content: str,
settlement_result: Dict[str, Any],
analyst_results: List[Dict[str, Any]],
decisions: Dict[str, Dict],
) -> Dict[str, Any]:
"""
Run the optimization loop if performance indicates a need for change.
"""
total_pnl = settlement_result.get("portfolio_value", 0) - 100000.0 # Assuming 100k initial
# You might want to use a more sophisticated trigger, like 3 consecutive losses
if total_pnl >= 0:
logger.info(f"APO: Positive P&L (${total_pnl:,.2f}) for {date}, skipping optimization.")
return {"status": "skipped", "reason": "positive_pnl"}
logger.info(f"APO: Negative P&L (${total_pnl:,.2f}) detected for {date}. Starting optimization...")
# 1. Identify underperforming agents or logic
# 2. Generate policy updates
# 3. Apply updates
optimizations = []
# Focus on agents that gave high confidence but wrong direction
underperformers = self._identify_underperformers(settlement_result, analyst_results)
for agent_id in underperformers:
update = await self._generate_policy_update(
agent_id,
date,
reflection_content,
settlement_result,
analyst_results,
decisions
)
if update:
self._apply_update(agent_id, update)
optimizations.append({
"agent_id": agent_id,
"file": update.get("file", "POLICY.md"),
"change": update.get("change", "")
})
return {
"status": "completed",
"date": date,
"total_pnl": total_pnl,
"optimizations": optimizations
}
def _identify_underperformers(
self,
settlement_result: Dict[str, Any],
analyst_results: List[Dict[str, Any]]
) -> List[str]:
"""Identify which agents might need policy adjustments."""
underperformers = []
# Simple logic: if the overall day was a loss, all active analysts might need a check,
# but specifically those whose predictions didn't match the market.
# For now, let's include all analysts involved in the day.
for result in analyst_results:
agent_id = result.get("agent")
if agent_id:
underperformers.append(agent_id)
# Also include PM and Risk Manager as they are critical
underperformers.append("portfolio_manager")
underperformers.append("risk_manager")
return list(set(underperformers))
async def _generate_policy_update(
self,
agent_id: str,
date: str,
reflection_content: str,
settlement_result: Dict[str, Any],
analyst_results: List[Dict[str, Any]],
decisions: Dict[str, Dict],
) -> Optional[Dict[str, str]]:
"""Use LLM to generate a specific policy update for an agent."""
# Load current policy
try:
current_policy = self.workspace_manager.load_agent_file(
config_name=self.config_name,
agent_id=agent_id,
filename="POLICY.md"
)
except Exception:
current_policy = "No existing policy found."
prompt = f"""
As an Expert Meta-Optimizer for a multi-agent trading system, your task is to update the operational POLICY for an agent named '{agent_id}' based on recent performance failures.
[Current Context]
Date: {date}
Daily Reflection:
{reflection_content}
[Agent's Current POLICY.md]
{current_policy}
[Task]
Analyze why the system failed (loss occurred). Identify what '{agent_id}' could have done differently or what new constraint/heuristic should be added to its policy to prevent similar mistakes in the future.
Provide a specific, concise addition or modification to the POLICY.md file.
The output MUST be a JSON object with:
1. "reasoning": Brief explanation of why this change is needed.
2. "file": Always "POLICY.md".
3. "change": The EXACT markdown text to APPEND or REPLACE in the file. Keep it in Chinese as the system uses Chinese prompts.
Output ONLY the JSON object.
"""
msg = Msg(name="system", content=prompt, role="user")
response = await self.model.reply(msg)
content = response.content
if isinstance(content, list):
content = content[0].get("text", "")
# Clean JSON if wrapped in markdown
if "```json" in content:
content = content.split("```json")[1].split("```")[0].strip()
try:
return json.loads(content)
except Exception as e:
logger.error(f"APO: Failed to parse optimization response for {agent_id}: {e}")
return None
def _apply_update(self, agent_id: str, update: Dict[str, str]) -> None:
"""Apply the suggested update to the agent's workspace."""
filename = update.get("file", "POLICY.md")
change = update.get("change", "")
if not change:
return
try:
current_content = self.workspace_manager.load_agent_file(
config_name=self.config_name,
agent_id=agent_id,
filename=filename
)
# Check if change is already there to avoid duplicates
if change.strip() in current_content:
logger.info(f"APO: Change already present in {agent_id}/{filename}")
return
new_content = current_content + "\n\n### APO Update (" + datetime.now().strftime("%Y-%m-%d") + ")\n" + change
self.workspace_manager.update_agent_file(
config_name=self.config_name,
agent_id=agent_id,
filename=filename,
content=new_content
)
logger.info(f"APO: Updated {agent_id}/{filename} with new heuristics.")
except Exception as e:
logger.error(f"APO: Failed to apply update to {agent_id}/{filename}: {e}")

View File

@@ -33,6 +33,8 @@ from backend.agents.workspace_manager import WorkspaceManager
from backend.agents.prompt_loader import get_prompt_loader from backend.agents.prompt_loader import get_prompt_loader
from backend.llm.models import get_agent_formatter, get_agent_model from backend.llm.models import get_agent_formatter, get_agent_model
from backend.config.constants import ANALYST_TYPES from backend.config.constants import ANALYST_TYPES
from backend.agents.dynamic_team_types import AnalystConfig
from backend.tools.dynamic_team_tools import DynamicTeamController, set_controller
def _resolve_evo_agent_ids() -> set[str]: def _resolve_evo_agent_ids() -> set[str]:
@@ -84,6 +86,9 @@ def _log(msg: str) -> None:
logger.info(msg) logger.info(msg)
from backend.core.apo import PolicyOptimizer
class TradingPipeline: class TradingPipeline:
""" """
Trading Pipeline - Orchestrates the complete trading cycle Trading Pipeline - Orchestrates the complete trading cycle
@@ -127,7 +132,21 @@ class TradingPipeline:
self.runtime_manager = runtime_manager self.runtime_manager = runtime_manager
self._session_key: Optional[str] = None self._session_key: Optional[str] = None
self._dynamic_analysts: Dict[str, Any] = {} self._dynamic_analysts: Dict[str, Any] = {}
self._dynamic_analyst_configs: Dict[str, AnalystConfig] = {}
# Initialize APO (Autonomous Policy Optimizer)
config_name = workspace_id or (runtime_manager.config_name if runtime_manager else "default")
self.apo = PolicyOptimizer(config_name=config_name)
# Initialize dynamic team controller and inject into PM
self._team_controller = DynamicTeamController(
create_callback=self._create_runtime_analyst,
remove_callback=self._remove_runtime_analyst,
get_analysts_callback=self._all_analysts,
)
set_controller(self._team_controller)
# Backward compatibility: also set individual callbacks if PM expects them
if hasattr(self.pm, "set_team_controller"): if hasattr(self.pm, "set_team_controller"):
self.pm.set_team_controller( self.pm.set_team_controller(
create_agent_callback=self._create_runtime_analyst, create_agent_callback=self._create_runtime_analyst,
@@ -150,23 +169,7 @@ class TradingPipeline:
execute_decisions: bool = True, execute_decisions: bool = True,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
""" """
Run one complete trading cycle Run one complete trading cycle with checkpointing support.
Args:
tickers: List of stock tickers
date: Trading date (YYYY-MM-DD)
prices: Open prices {ticker: price} (for backtest)
close_prices: Close prices for settlement (for backtest)
market_caps: Optional market caps for baseline calculation
get_open_prices_fn: Async callback to wait for open prices (live mode)
get_close_prices_fn: Async callback to wait for close prices (live mode)
For live mode:
- Analysis runs immediately
- Execution waits for market open via get_open_prices_fn
- Settlement waits for market close via get_close_prices_fn
Each agent's result is broadcast immediately via StateSync.
""" """
_log(f"Starting cycle {date} - {len(tickers)} tickers") _log(f"Starting cycle {date} - {len(tickers)} tickers")
session_key = TradingSessionKey(date=date).key() session_key = TradingSessionKey(date=date).key()
@@ -176,14 +179,45 @@ class TradingPipeline:
agents=active_analysts + [self.risk_manager, self.pm], agents=active_analysts + [self.risk_manager, self.pm],
session_key=session_key, session_key=session_key,
) )
# Load checkpoint if exists
checkpoint = self._load_checkpoint(session_key)
checkpoint_data = checkpoint.get("data", {}) if checkpoint else {}
last_phase = checkpoint.get("phase") if checkpoint else None
if checkpoint:
_log(f"Resuming from checkpoint: {last_phase}")
# Restore state from checkpoint
analyst_results = checkpoint_data.get("analyst_results", [])
risk_assessment = checkpoint_data.get("risk_assessment", {})
self.conference_summary = checkpoint_data.get("conference_summary")
final_predictions = checkpoint_data.get("final_predictions", [])
pm_result = checkpoint_data.get("pm_result", {})
execution_result = checkpoint_data.get("execution_result", {})
settlement_result = checkpoint_data.get("settlement_result")
# Prefer passed prices if not hold in checkpoint
if not prices:
prices = checkpoint_data.get("prices")
if not close_prices:
close_prices = checkpoint_data.get("close_prices")
else:
analyst_results = []
risk_assessment = {}
self.conference_summary = None
final_predictions = []
pm_result = {}
execution_result = {}
settlement_result = None
if self.runtime_manager: if self.runtime_manager:
self.runtime_manager.set_session_key(session_key) self.runtime_manager.set_session_key(session_key)
self._runtime_log_event("cycle:start", {"tickers": tickers, "date": date}) self._runtime_log_event("cycle:start", {"tickers": tickers, "date": date, "resumed": checkpoint is not None})
self._runtime_batch_status(active_analysts, "analysis_in_progress") self._runtime_batch_status(active_analysts, "analysis_in_progress")
# Phase 0: Clear short-term memory to avoid cross-day context pollution # Phase 0: Clear memory (only if not resuming or if resuming from very start)
_log("Phase 0: Clearing memory") if not last_phase:
await self._clear_all_agent_memory() _log("Phase 0: Clearing memory")
await self._clear_all_agent_memory()
participants = self._all_analysts() + [self.risk_manager, self.pm] participants = self._all_analysts() + [self.risk_manager, self.pm]
@@ -196,125 +230,219 @@ class TradingPipeline:
"system", "system",
), ),
): ):
# Phase 1.1: Analysts (parallel execution with TeamCoordinator) # Phase 1.1: Analysts
_log("Phase 1.1: Analyst analysis (parallel)") if not last_phase or last_phase == "cleared":
analyst_results = await self._run_analysts_parallel( _log("Phase 1.1: Analyst analysis (parallel)")
tickers, analyst_results = await self._run_analysts_parallel(
date, tickers,
active_analysts=active_analysts, date,
) active_analysts=active_analysts,
)
self._save_checkpoint(session_key, "analysis", {
"analyst_results": analyst_results,
"prices": prices,
"close_prices": close_prices
})
last_phase = "analysis"
# Phase 1.2: Risk Manager # Phase 1.2: Risk Manager
_log("Phase 1.2: Risk assessment") if last_phase == "analysis":
self._runtime_update_status(self.risk_manager, "risk_assessment") _log("Phase 1.2: Risk assessment")
risk_assessment = await self._run_risk_manager_with_sync( self._runtime_update_status(self.risk_manager, "risk_assessment")
tickers, risk_assessment = await self._run_risk_manager_with_sync(
date, tickers,
prices, date,
) prices,
)
self._save_checkpoint(session_key, "risk_assessment", {
"analyst_results": analyst_results,
"risk_assessment": risk_assessment,
"prices": prices,
"close_prices": close_prices
})
last_phase = "risk_assessment"
# Phase 2.1: Conference discussion (within same MsgHub) # Phase 2.1: Conference discussion
_log("Phase 2.1: Conference discussion") if last_phase == "risk_assessment":
conference_summary = await self._run_conference_cycles( _log("Phase 2.1: Conference discussion")
tickers=tickers, conference_summary = await self._run_conference_cycles(
date=date, tickers=tickers,
prices=prices, date=date,
analyst_results=analyst_results, prices=prices,
risk_assessment=risk_assessment, analyst_results=analyst_results,
) risk_assessment=risk_assessment,
self.conference_summary = conference_summary )
self.conference_summary = conference_summary
self._save_checkpoint(session_key, "conference", {
"analyst_results": analyst_results,
"risk_assessment": risk_assessment,
"conference_summary": conference_summary,
"prices": prices,
"close_prices": close_prices
})
last_phase = "conference"
# Phase 2.2: Analysts generate final structured predictions # Phase 2.2: Analysts generate final structured predictions
_log("Phase 2.2: Analysts generate final structured predictions") if last_phase == "conference":
final_predictions = await self._collect_final_predictions( _log("Phase 2.2: Analysts generate final structured predictions")
tickers, final_predictions = await self._collect_final_predictions(
date, tickers,
active_analysts=active_analysts, date,
) active_analysts=active_analysts,
)
self._save_checkpoint(session_key, "predictions", {
"analyst_results": analyst_results,
"risk_assessment": risk_assessment,
"conference_summary": conference_summary,
"final_predictions": final_predictions,
"prices": prices,
"close_prices": close_prices
})
last_phase = "predictions"
# Record final predictions for leaderboard ranking # Record final predictions
if self.settlement_coordinator: if last_phase == "predictions" and self.settlement_coordinator:
self.settlement_coordinator.record_analyst_predictions( self.settlement_coordinator.record_analyst_predictions(
final_predictions, final_predictions,
) )
# Live mode: wait for market open before execution # Live mode: wait for market open
if get_open_prices_fn: if not prices and get_open_prices_fn:
_log("Waiting for market open...") _log("Waiting for market open...")
prices = await get_open_prices_fn() prices = await get_open_prices_fn()
_log(f"Got open prices: {prices}") _log(f"Got open prices: {prices}")
# Update prices in checkpoint if we just got them
self._save_checkpoint(session_key, "predictions", {
"analyst_results": analyst_results,
"risk_assessment": risk_assessment,
"conference_summary": conference_summary,
"final_predictions": final_predictions,
"prices": prices,
"close_prices": close_prices
})
# Phase 3: PM makes decisions # Phase 3: PM makes decisions
_log("Phase 3.1: PM makes decisions") if last_phase == "predictions":
self._runtime_update_status(self.pm, "decision_phase") _log("Phase 3.1: PM makes decisions")
pm_result = await self._run_pm_with_sync( self._runtime_update_status(self.pm, "decision_phase")
tickers, pm_result = await self._run_pm_with_sync(
date, tickers,
prices, date,
analyst_results, prices,
risk_assessment, analyst_results,
) risk_assessment,
)
self._save_checkpoint(session_key, "decisions", {
"analyst_results": analyst_results,
"risk_assessment": risk_assessment,
"conference_summary": conference_summary,
"final_predictions": final_predictions,
"pm_result": pm_result,
"prices": prices,
"close_prices": close_prices
})
last_phase = "decisions"
decisions = pm_result.get("decisions", {}) # Outside MsgHub for execution and settlement
execution_result = { decisions = pm_result.get("decisions", {}) if pm_result else {}
"executed_trades": [], if not execution_result:
"portfolio": self.pm.get_portfolio_state(), execution_result = {
} "executed_trades": [],
if execute_decisions: "portfolio": self.pm.get_portfolio_state(),
_log("Phase 4: Executing trades") }
self._runtime_update_status(self.pm, "executing")
execution_result = self._execute_decisions(decisions, prices, date)
else:
_log("Phase 4: Skipping trade execution")
# Live mode: wait for market close before settlement if last_phase == "decisions":
if get_close_prices_fn: if execute_decisions:
_log("Phase 4: Executing trades")
self._runtime_update_status(self.pm, "executing")
execution_result = self._execute_decisions(decisions, prices, date)
else:
_log("Phase 4: Skipping trade execution")
self._save_checkpoint(session_key, "execution", {
"analyst_results": analyst_results,
"risk_assessment": risk_assessment,
"conference_summary": conference_summary,
"final_predictions": final_predictions,
"pm_result": pm_result,
"execution_result": execution_result,
"prices": prices,
"close_prices": close_prices
})
last_phase = "execution"
# Live mode: wait for market close
if not close_prices and get_close_prices_fn:
_log("Waiting for market close") _log("Waiting for market close")
close_prices = await get_close_prices_fn() close_prices = await get_close_prices_fn()
_log(f"Got close prices: {close_prices}") _log(f"Got close prices: {close_prices}")
# Update close_prices in checkpoint
self._save_checkpoint(session_key, "execution", {
"analyst_results": analyst_results,
"risk_assessment": risk_assessment,
"conference_summary": conference_summary,
"final_predictions": final_predictions,
"pm_result": pm_result,
"execution_result": execution_result,
"prices": prices,
"close_prices": close_prices
})
# Phase 5: Settlement - run after close prices available # Phase 5: Settlement
settlement_result = None if last_phase == "execution":
if close_prices and self.settlement_coordinator: if close_prices and self.settlement_coordinator:
_log("Phase 5: Daily review and generate memories") _log("Phase 5: Daily review and generate memories")
self._runtime_batch_status( self._runtime_batch_status(
[self.risk_manager] + self._all_analysts() + [self.pm], [self.risk_manager] + self._all_analysts() + [self.pm],
"settlement", "settlement",
) )
agent_trajectories = await self._capture_agent_trajectories() agent_trajectories = await self._capture_agent_trajectories()
if market_caps is None: if market_caps is None:
market_caps = {ticker: 1e9 for ticker in tickers} market_caps = {ticker: 1e9 for ticker in tickers}
settlement_result = ( settlement_result = (
self.settlement_coordinator.run_daily_settlement( self.settlement_coordinator.run_daily_settlement(
date=date,
tickers=tickers,
open_prices=prices,
close_prices=close_prices,
market_caps=market_caps,
agent_portfolio=execution_result.get("portfolio", {}),
analyst_results=analyst_results,
pm_decisions=decisions,
)
)
await self._run_reflection(
date=date, date=date,
tickers=tickers, agent_trajectories=agent_trajectories,
analyst_results=analyst_results,
decisions=decisions,
executed_trades=execution_result.get("executed_trades", []),
open_prices=prices, open_prices=prices,
close_prices=close_prices, close_prices=close_prices,
market_caps=market_caps, settlement_result=settlement_result,
agent_portfolio=execution_result.get("portfolio", {}), conference_summary=self.conference_summary,
analyst_results=analyst_results, )
pm_decisions=decisions, self._runtime_batch_status(
[self.risk_manager] + self._all_analysts() + [self.pm],
"reflection",
) )
)
await self._run_reflection( self._save_checkpoint(session_key, "settlement", {
date=date, "analyst_results": analyst_results,
agent_trajectories=agent_trajectories, "risk_assessment": risk_assessment,
analyst_results=analyst_results, "conference_summary": conference_summary,
decisions=decisions, "final_predictions": final_predictions,
executed_trades=execution_result.get("executed_trades", []), "pm_result": pm_result,
open_prices=prices, "execution_result": execution_result,
close_prices=close_prices, "settlement_result": settlement_result,
settlement_result=settlement_result, "prices": prices,
conference_summary=self.conference_summary, "close_prices": close_prices
) })
self._runtime_batch_status( last_phase = "settlement"
[self.risk_manager] + self._all_analysts() + [self.pm],
"reflection",
)
_log(f"Cycle complete: {date}") _log(f"Cycle complete: {date}")
self._runtime_batch_status( self._runtime_batch_status(
@@ -323,6 +451,11 @@ class TradingPipeline:
) )
self._runtime_log_event("cycle:end", {"tickers": tickers, "date": date}) self._runtime_log_event("cycle:end", {"tickers": tickers, "date": date})
# Optional: Clean up checkpoint after successful completion
# path = self._get_checkpoint_path(session_key)
# if path and path.exists():
# path.unlink()
return { return {
"analyst_results": analyst_results, "analyst_results": analyst_results,
"risk_assessment": risk_assessment, "risk_assessment": risk_assessment,
@@ -385,6 +518,44 @@ class TradingPipeline:
await self.risk_manager.memory.clear() await self.risk_manager.memory.clear()
await self.pm.memory.clear() await self.pm.memory.clear()
def _get_checkpoint_path(self, session_key: str) -> Optional[Path]:
"""Get the path to the pipeline checkpoint file."""
if not self.runtime_manager or not self.runtime_manager.run_dir:
return None
checkpoint_dir = self.runtime_manager.run_dir / "state" / "checkpoints"
checkpoint_dir.mkdir(parents=True, exist_ok=True)
return checkpoint_dir / f"pipeline_{session_key}.json"
def _save_checkpoint(self, session_key: str, phase: str, data: Dict[str, Any]) -> None:
"""Save the current pipeline state to a checkpoint file."""
path = self._get_checkpoint_path(session_key)
if not path:
return
checkpoint = {
"session_key": session_key,
"phase": phase,
"timestamp": datetime.now().isoformat(),
"data": data
}
try:
path.write_text(json.dumps(checkpoint, ensure_ascii=False, indent=2, default=str), encoding="utf-8")
_log(f"Checkpoint saved: {phase} for {session_key}")
except Exception as e:
logger.error(f"Failed to save checkpoint: {e}")
def _load_checkpoint(self, session_key: str) -> Optional[Dict[str, Any]]:
"""Load the pipeline state from a checkpoint file."""
path = self._get_checkpoint_path(session_key)
if not path or not path.exists():
return None
try:
return json.loads(path.read_text(encoding="utf-8"))
except Exception as e:
logger.error(f"Failed to load checkpoint: {e}")
return None
async def _sync_memory_if_retrieved(self, agent: Any) -> None: async def _sync_memory_if_retrieved(self, agent: Any) -> None:
""" """
Check agent's short-term memory for retrieved long-term memory and sync to frontend. Check agent's short-term memory for retrieved long-term memory and sync to frontend.
@@ -585,6 +756,25 @@ class TradingPipeline:
content=reflection_content, content=reflection_content,
) )
# Phase 6: APO (Autonomous Policy Optimization)
# If the day was a loss, let APO suggest and apply policy updates.
if hasattr(self, "apo") and self.apo:
_log(f"Phase 6: APO - Running autonomous policy optimization for {date}")
try:
apo_result = await self.apo.run_optimization(
date=date,
reflection_content=reflection_content,
settlement_result=settlement_result or {"portfolio_value": 100000.0 + total_pnl},
analyst_results=analyst_results,
decisions=decisions
)
if apo_result.get("status") == "completed":
_log(f"APO: Successfully applied {len(apo_result.get('optimizations', []))} policy updates.")
# Reload assets for next cycle to ensure they are picked up
self.reload_runtime_assets()
except Exception as e:
logger.error(f"APO: Optimization failed: {e}")
def _build_reflection_content( def _build_reflection_content(
self, self,
date: str, date: str,
@@ -1562,28 +1752,74 @@ class TradingPipeline:
"""Return static analysts plus runtime-created analysts.""" """Return static analysts plus runtime-created analysts."""
return list(self.analysts) + list(self._dynamic_analysts.values()) return list(self.analysts) + list(self._dynamic_analysts.values())
def _create_runtime_analyst(self, agent_id: str, analyst_type: str) -> str: def _create_runtime_analyst(
"""Create one runtime analyst instance.""" self,
if analyst_type not in ANALYST_TYPES: agent_id: str,
analyst_type: str,
custom_config: Optional[AnalystConfig] = None,
) -> str:
"""Create one runtime analyst instance.
Args:
agent_id: Unique identifier for the new analyst
analyst_type: Type of analyst (e.g., "technical_analyst")
custom_config: Optional custom configuration for the analyst,
including persona, soul_md, agents_md, etc.
Returns:
Success or error message
"""
# Validate analyst_type or custom_config
if analyst_type not in ANALYST_TYPES and not custom_config:
return ( return (
f"Unknown analyst_type '{analyst_type}'. " f"Unknown analyst_type '{analyst_type}'. "
f"Available: {', '.join(ANALYST_TYPES.keys())}" f"Available: {', '.join(ANALYST_TYPES.keys())}. "
f"Or provide custom_config to create a custom analyst."
) )
if agent_id in {agent.name for agent in self._all_analysts()}: if agent_id in {agent.name for agent in self._all_analysts()}:
return f"Analyst '{agent_id}' already exists." return f"Analyst '{agent_id}' already exists."
config_name = getattr(self.pm, "config", {}).get("config_name", "default") config_name = getattr(self.pm, "config", {}).get("config_name", "default")
project_root = Path(__file__).resolve().parents[2] project_root = Path(__file__).resolve().parents[2]
personas = get_prompt_loader().load_yaml_config("analyst", "personas")
persona = personas.get(analyst_type, {}) # Get persona: use custom_config if provided, else load from personas.yaml
if custom_config and custom_config.persona:
persona = {
"name": custom_config.persona.name,
"focus": custom_config.persona.focus,
"description": custom_config.persona.description,
}
else:
personas = get_prompt_loader().load_yaml_config("analyst", "personas")
persona = personas.get(analyst_type, {})
workspace_manager = WorkspaceManager(project_root=project_root) workspace_manager = WorkspaceManager(project_root=project_root)
# Build file contents: use custom if provided, else generate from persona
file_contents = {}
if custom_config:
if custom_config.soul_md:
file_contents["SOUL.md"] = custom_config.soul_md
if custom_config.agents_md:
file_contents["AGENTS.md"] = custom_config.agents_md
if custom_config.profile_md:
file_contents["PROFILE.md"] = custom_config.profile_md
if custom_config.bootstrap_md:
file_contents["BOOTSTRAP.md"] = custom_config.bootstrap_md
# Fill in any missing files with defaults
if not file_contents or len(file_contents) < 4:
default_files = workspace_manager.build_default_agent_files(
agent_id=agent_id,
persona=persona,
)
for key, value in default_files.items():
if key not in file_contents:
file_contents[key] = value
workspace_manager.ensure_agent_assets( workspace_manager.ensure_agent_assets(
config_name=config_name, config_name=config_name,
agent_id=agent_id, agent_id=agent_id,
file_contents=workspace_manager.build_default_agent_files( file_contents=file_contents,
agent_id=agent_id,
persona=persona,
),
) )
# Create EvoAgent with workspace-driven configuration # Create EvoAgent with workspace-driven configuration
@@ -1594,11 +1830,23 @@ class TradingPipeline:
agent_id, agent_id,
) )
agent_config = load_agent_workspace_config(workspace_dir / "agent.yaml") agent_config = load_agent_workspace_config(workspace_dir / "agent.yaml")
# Support model override from custom_config
if custom_config and custom_config.model_name:
# Import create_model for custom model creation
from backend.llm.models import create_model
# Use specified model name, default to openai provider
model = create_model(
model_name=custom_config.model_name,
model_provider=custom_config.memory_config.get("model_provider", "openai") if custom_config.memory_config else "openai"
)
else:
model = get_agent_model(analyst_type)
agent = EvoAgent( agent = EvoAgent(
agent_id=agent_id, agent_id=agent_id,
config_name=config_name, config_name=config_name,
workspace_dir=workspace_dir, workspace_dir=workspace_dir,
model=get_agent_model(analyst_type), model=model,
formatter=get_agent_formatter(analyst_type), formatter=get_agent_formatter(analyst_type),
prompt_files=agent_config.prompt_files, prompt_files=agent_config.prompt_files,
) )
@@ -1611,6 +1859,11 @@ class TradingPipeline:
# Keep workspace_id for backward compatibility # Keep workspace_id for backward compatibility
setattr(agent, "workspace_id", config_name) setattr(agent, "workspace_id", config_name)
self._dynamic_analysts[agent_id] = agent self._dynamic_analysts[agent_id] = agent
# Store custom config for future reference (e.g., cloning)
if custom_config:
self._dynamic_analyst_configs[agent_id] = custom_config
update_active_analysts( update_active_analysts(
project_root=project_root, project_root=project_root,
config_name=config_name, config_name=config_name,
@@ -1624,6 +1877,8 @@ class TradingPipeline:
if agent_id not in self._dynamic_analysts: if agent_id not in self._dynamic_analysts:
return f"Runtime analyst '{agent_id}' not found." return f"Runtime analyst '{agent_id}' not found."
self._dynamic_analysts.pop(agent_id, None) self._dynamic_analysts.pop(agent_id, None)
# Also remove stored config if exists
self._dynamic_analyst_configs.pop(agent_id, None)
config_name = getattr(self.pm, "config", {}).get("config_name", "default") config_name = getattr(self.pm, "config", {}).get("config_name", "default")
project_root = Path(__file__).resolve().parents[2] project_root = Path(__file__).resolve().parents[2]
update_active_analysts( update_active_analysts(

View File

@@ -4,6 +4,12 @@ Pipeline Runner - Independent trading pipeline execution
This module provides functions to start/stop trading pipelines This module provides functions to start/stop trading pipelines
that can be called from the REST API. that can be called from the REST API.
COMPATIBILITY_NOTE:
This module still carries selected fallback creation paths used by managed
runtime startup and compatibility flows. New runtime behavior should be judged
against the run-scoped helpers and current pipeline selection rules rather than
assuming every constructor here is the long-term default.
""" """
from __future__ import annotations from __future__ import annotations
@@ -11,6 +17,7 @@ from __future__ import annotations
import asyncio import asyncio
import os import os
from contextlib import AsyncExitStack from contextlib import AsyncExitStack
from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Any, Dict, List, Optional, Callable from typing import Any, Dict, List, Optional, Callable
@@ -22,7 +29,7 @@ from backend.agents.prompt_loader import get_prompt_loader
from backend.agents.workspace_manager import WorkspaceManager from backend.agents.workspace_manager import WorkspaceManager
from backend.config.constants import ANALYST_TYPES from backend.config.constants import ANALYST_TYPES
from backend.core.pipeline import TradingPipeline from backend.core.pipeline import TradingPipeline
from backend.core.scheduler import BacktestScheduler, Scheduler from backend.core.scheduler import BacktestScheduler, Scheduler, normalize_schedule_mode
from backend.llm.models import get_agent_formatter, get_agent_model from backend.llm.models import get_agent_formatter, get_agent_model
from backend.runtime.manager import ( from backend.runtime.manager import (
TradingRuntimeManager, TradingRuntimeManager,
@@ -46,6 +53,21 @@ _gateway_instance: Optional[Gateway] = None
_long_term_memories: List[Any] = [] _long_term_memories: List[Any] = []
@dataclass
class GatewayRuntimeBundle:
"""Assembled runtime components for a Gateway-backed execution path."""
runtime_manager: TradingRuntimeManager
market_service: MarketService
storage_service: StorageService
pipeline: TradingPipeline
gateway: Gateway
scheduler: Optional[Scheduler]
scheduler_callback: Optional[Callable]
long_term_memories: List[Any]
trading_dates: List[str]
def _set_gateway(gateway: Optional[Gateway]) -> None: def _set_gateway(gateway: Optional[Gateway]) -> None:
"""Set global gateway reference.""" """Set global gateway reference."""
global _gateway_instance global _gateway_instance
@@ -443,6 +465,151 @@ def create_agents(
return analysts, risk_manager, portfolio_manager, long_term_memories return analysts, risk_manager, portfolio_manager, long_term_memories
def build_gateway_runtime_bundle(
*,
run_id: str,
run_dir: Path,
bootstrap: Dict[str, Any],
poll_interval: int = 10,
) -> GatewayRuntimeBundle:
"""Build the full Gateway runtime component graph for one run."""
tickers = bootstrap.get("tickers", ["AAPL", "MSFT", "GOOGL", "AMZN", "NVDA", "META", "TSLA", "AMD", "NFLX", "AVGO", "PLTR", "COIN"])
initial_cash = float(bootstrap.get("initial_cash", 100000.0))
margin_requirement = float(bootstrap.get("margin_requirement", 0.0))
max_comm_cycles = int(bootstrap.get("max_comm_cycles", 2))
schedule_mode = normalize_schedule_mode(bootstrap.get("schedule_mode", "daily"))
trigger_time = bootstrap.get("trigger_time", "09:30")
interval_minutes = int(bootstrap.get("interval_minutes", 60))
heartbeat_interval = int(bootstrap.get("heartbeat_interval", 0))
mode = bootstrap.get("mode", "live")
start_date = bootstrap.get("start_date")
end_date = bootstrap.get("end_date")
enable_memory = bootstrap.get("enable_memory", False)
is_backtest = mode == "backtest"
runtime_manager = TradingRuntimeManager(
config_name=run_id,
run_dir=run_dir,
bootstrap=bootstrap,
)
runtime_manager.prepare_run()
market_service = MarketService(
tickers=tickers,
poll_interval=poll_interval,
backtest_mode=is_backtest,
api_key=os.getenv("FINNHUB_API_KEY") if not is_backtest else None,
backtest_start_date=start_date if is_backtest else None,
backtest_end_date=end_date if is_backtest else None,
)
storage_service = StorageService(
dashboard_dir=run_dir / "team_dashboard",
initial_cash=initial_cash,
config_name=run_id,
)
if not storage_service.files["summary"].exists():
storage_service.initialize_empty_dashboard()
else:
storage_service.update_leaderboard_model_info()
analysts, risk_manager, pm, long_term_memories = create_agents(
run_id=run_id,
run_dir=run_dir,
initial_cash=initial_cash,
margin_requirement=margin_requirement,
enable_long_term_memory=enable_memory,
)
for agent in analysts + [risk_manager, pm]:
agent_id = getattr(agent, "agent_id", None) or getattr(agent, "name", None)
if agent_id:
runtime_manager.register_agent(agent_id)
portfolio_state = storage_service.load_portfolio_state()
pm.load_portfolio_state(portfolio_state)
settlement_coordinator = SettlementCoordinator(
storage=storage_service,
initial_capital=initial_cash,
)
pipeline = TradingPipeline(
analysts=analysts,
risk_manager=risk_manager,
portfolio_manager=pm,
settlement_coordinator=settlement_coordinator,
max_comm_cycles=max_comm_cycles,
runtime_manager=runtime_manager,
)
scheduler_callback = None
live_scheduler = None
trading_dates: List[str] = []
if is_backtest:
backtest_scheduler = BacktestScheduler(
start_date=start_date,
end_date=end_date,
trading_calendar="NYSE",
delay_between_days=0.5,
)
trading_dates = backtest_scheduler.get_trading_dates()
async def scheduler_callback_fn(callback):
await backtest_scheduler.start(callback)
scheduler_callback = scheduler_callback_fn
else:
live_scheduler = Scheduler(
mode=schedule_mode,
trigger_time=trigger_time,
interval_minutes=interval_minutes,
heartbeat_interval=heartbeat_interval if heartbeat_interval > 0 else None,
config={"config_name": run_id},
)
async def scheduler_callback_fn(callback):
await live_scheduler.start(callback)
scheduler_callback = scheduler_callback_fn
gateway = Gateway(
market_service=market_service,
storage_service=storage_service,
pipeline=pipeline,
scheduler_callback=scheduler_callback,
config={
"mode": mode,
"backtest_mode": is_backtest,
"tickers": tickers,
"config_name": run_id,
"schedule_mode": schedule_mode,
"interval_minutes": interval_minutes,
"trigger_time": trigger_time,
"heartbeat_interval": heartbeat_interval,
"initial_cash": initial_cash,
"margin_requirement": margin_requirement,
"max_comm_cycles": max_comm_cycles,
"enable_memory": enable_memory,
},
scheduler=live_scheduler,
)
if is_backtest:
gateway.set_backtest_dates(trading_dates)
return GatewayRuntimeBundle(
runtime_manager=runtime_manager,
market_service=market_service,
storage_service=storage_service,
pipeline=pipeline,
gateway=gateway,
scheduler=live_scheduler,
scheduler_callback=scheduler_callback,
long_term_memories=long_term_memories,
trading_dates=trading_dates,
)
async def run_pipeline( async def run_pipeline(
run_id: str, run_id: str,
run_dir: Path, run_dir: Path,
@@ -483,7 +650,7 @@ async def run_pipeline(
initial_cash = float(bootstrap.get("initial_cash", 100000.0)) initial_cash = float(bootstrap.get("initial_cash", 100000.0))
margin_requirement = float(bootstrap.get("margin_requirement", 0.0)) margin_requirement = float(bootstrap.get("margin_requirement", 0.0))
max_comm_cycles = int(bootstrap.get("max_comm_cycles", 2)) max_comm_cycles = int(bootstrap.get("max_comm_cycles", 2))
schedule_mode = bootstrap.get("schedule_mode", "daily") schedule_mode = normalize_schedule_mode(bootstrap.get("schedule_mode", "daily"))
trigger_time = bootstrap.get("trigger_time", "09:30") trigger_time = bootstrap.get("trigger_time", "09:30")
interval_minutes = int(bootstrap.get("interval_minutes", 60)) interval_minutes = int(bootstrap.get("interval_minutes", 60))
heartbeat_interval = int(bootstrap.get("heartbeat_interval", 0)) heartbeat_interval = int(bootstrap.get("heartbeat_interval", 0))

View File

@@ -17,6 +17,14 @@ NYSE_TZ = ZoneInfo("America/New_York")
NYSE_CALENDAR = mcal.get_calendar("NYSE") NYSE_CALENDAR = mcal.get_calendar("NYSE")
def normalize_schedule_mode(mode: str | None) -> str:
"""Normalize schedule mode to the current public vocabulary."""
value = str(mode or "daily").strip().lower()
if value == "intraday":
return "interval"
return value or "daily"
class Scheduler: class Scheduler:
""" """
Market-aware scheduler for live trading. Market-aware scheduler for live trading.
@@ -31,7 +39,7 @@ class Scheduler:
heartbeat_interval: Optional[int] = None, heartbeat_interval: Optional[int] = None,
config: Optional[dict] = None, config: Optional[dict] = None,
): ):
self.mode = mode self.mode = normalize_schedule_mode(mode)
self.trigger_time = trigger_time or "09:30" # NYSE timezone self.trigger_time = trigger_time or "09:30" # NYSE timezone
self.trigger_now = self.trigger_time == "now" self.trigger_now = self.trigger_time == "now"
self.interval_minutes = interval_minutes or 60 self.interval_minutes = interval_minutes or 60
@@ -107,7 +115,7 @@ class Scheduler:
if self.mode == "daily": if self.mode == "daily":
self._task = asyncio.create_task(self._run_daily(self._callback)) self._task = asyncio.create_task(self._run_daily(self._callback))
elif self.mode == "intraday": elif self.mode == "interval":
self._task = asyncio.create_task( self._task = asyncio.create_task(
self._run_intraday(self._callback), self._run_intraday(self._callback),
) )
@@ -124,8 +132,13 @@ class Scheduler:
"""Update scheduler parameters in-place and restart its timing loop.""" """Update scheduler parameters in-place and restart its timing loop."""
changed = False changed = False
if mode and mode != self.mode: if mode:
self.mode = mode normalized_mode = normalize_schedule_mode(mode)
else:
normalized_mode = None
if normalized_mode and normalized_mode != self.mode:
self.mode = normalized_mode
changed = True changed = True
if trigger_time and trigger_time != self.trigger_time: if trigger_time and trigger_time != self.trigger_time:
@@ -233,13 +246,13 @@ class Scheduler:
await callback(date=current_date) await callback(date=current_date)
async def _run_intraday(self, callback: Callable): async def _run_intraday(self, callback: Callable):
"""Run every N minutes (for future use)""" """Run every N minutes in interval mode."""
while self.running: while self.running:
now = self._now_nyse() now = self._now_nyse()
current_date = now.strftime("%Y-%m-%d") current_date = now.strftime("%Y-%m-%d")
if self._is_trading_day(now): if self._is_trading_day(now):
logger.info(f"Triggering intraday cycle for {current_date}") logger.info(f"Triggering interval cycle for {current_date}")
await callback(date=current_date) await callback(date=current_date)
await asyncio.sleep(self.interval_minutes * 60) await asyncio.sleep(self.interval_minutes * 60)

View File

@@ -1,15 +1,14 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
"""Gateway Server - Entry point for Gateway subprocess. """Gateway Server - Entry point for the managed Gateway subprocess.
This module is launched as a subprocess by the Control Plane (FastAPI) This module is launched by `runtime_service` when the runtime API is used to
to run the Data Plane (Gateway + Pipeline). spawn a run-scoped Gateway process.
""" """
import argparse import argparse
import asyncio import asyncio
import json import json
import logging import logging
import os
import sys import sys
from contextlib import AsyncExitStack from contextlib import AsyncExitStack
from pathlib import Path from pathlib import Path
@@ -19,22 +18,13 @@ from dotenv import load_dotenv
# Load environment variables # Load environment variables
load_dotenv() load_dotenv()
from backend.agents.prompt_loader import get_prompt_loader from backend.core.pipeline_runner import build_gateway_runtime_bundle
from backend.core.pipeline import TradingPipeline
from backend.core.pipeline_runner import create_agents
from backend.core.scheduler import BacktestScheduler, Scheduler
from backend.runtime.manager import ( from backend.runtime.manager import (
TradingRuntimeManager,
set_global_runtime_manager, set_global_runtime_manager,
clear_global_runtime_manager, clear_global_runtime_manager,
) )
from backend.services.gateway import Gateway
from backend.services.market import MarketService
from backend.services.storage import StorageService
from backend.utils.settlement import SettlementCoordinator
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
_prompt_loader = get_prompt_loader()
INFO_LOGGER_PREFIXES = ( INFO_LOGGER_PREFIXES = (
@@ -110,153 +100,24 @@ async def run_gateway(
port: int port: int
): ):
"""Run Gateway with Pipeline.""" """Run Gateway with Pipeline."""
# Extract config
tickers = bootstrap.get("tickers", ["AAPL", "MSFT", "GOOGL", "AMZN", "NVDA", "META", "TSLA", "AMD", "NFLX", "AVGO", "PLTR", "COIN"])
initial_cash = float(bootstrap.get("initial_cash", 100000.0))
margin_requirement = float(bootstrap.get("margin_requirement", 0.0))
max_comm_cycles = int(bootstrap.get("max_comm_cycles", 2))
schedule_mode = bootstrap.get("schedule_mode", "daily")
trigger_time = bootstrap.get("trigger_time", "09:30")
interval_minutes = int(bootstrap.get("interval_minutes", 60))
heartbeat_interval = int(bootstrap.get("heartbeat_interval", 0)) # 0 = disabled
mode = bootstrap.get("mode", "live")
start_date = bootstrap.get("start_date")
end_date = bootstrap.get("end_date")
enable_memory = bootstrap.get("enable_memory", False)
poll_interval = int(bootstrap.get("poll_interval", 10)) poll_interval = int(bootstrap.get("poll_interval", 10))
is_backtest = mode == "backtest"
logger.info(f"[Gateway Server] Starting run {run_id} on port {port}") logger.info(f"[Gateway Server] Starting run {run_id} on port {port}")
# Create runtime manager
runtime_manager = TradingRuntimeManager(
config_name=run_id,
run_dir=run_dir,
bootstrap=bootstrap,
)
runtime_manager.prepare_run()
set_global_runtime_manager(runtime_manager)
try: try:
bundle = build_gateway_runtime_bundle(
run_id=run_id,
run_dir=run_dir,
bootstrap=bootstrap,
poll_interval=poll_interval,
)
set_global_runtime_manager(bundle.runtime_manager)
async with AsyncExitStack() as stack: async with AsyncExitStack() as stack:
# Create services for memory in bundle.long_term_memories:
market_service = MarketService(
tickers=tickers,
poll_interval=poll_interval,
backtest_mode=is_backtest,
api_key=os.getenv("FINNHUB_API_KEY") if not is_backtest else None,
backtest_start_date=start_date if is_backtest else None,
backtest_end_date=end_date if is_backtest else None,
)
storage_service = StorageService(
dashboard_dir=run_dir / "team_dashboard",
initial_cash=initial_cash,
config_name=run_id,
)
if not storage_service.files["summary"].exists():
storage_service.initialize_empty_dashboard()
else:
storage_service.update_leaderboard_model_info()
# Create agents
analysts, risk_manager, pm, long_term_memories = create_agents(
run_id=run_id,
run_dir=run_dir,
initial_cash=initial_cash,
margin_requirement=margin_requirement,
enable_long_term_memory=enable_memory,
)
# Register agents
for agent in analysts + [risk_manager, pm]:
agent_id = getattr(agent, "agent_id", None) or getattr(agent, "name", None)
if agent_id:
runtime_manager.register_agent(agent_id)
# Load portfolio state
portfolio_state = storage_service.load_portfolio_state()
pm.load_portfolio_state(portfolio_state)
# Create settlement coordinator
settlement_coordinator = SettlementCoordinator(
storage=storage_service,
initial_capital=initial_cash,
)
# Create pipeline
pipeline = TradingPipeline(
analysts=analysts,
risk_manager=risk_manager,
portfolio_manager=pm,
settlement_coordinator=settlement_coordinator,
max_comm_cycles=max_comm_cycles,
runtime_manager=runtime_manager,
)
# Create scheduler
scheduler_callback = None
live_scheduler = None
if is_backtest:
backtest_scheduler = BacktestScheduler(
start_date=start_date,
end_date=end_date,
trading_calendar="NYSE",
delay_between_days=0.5,
)
async def scheduler_callback_fn(callback):
await backtest_scheduler.start(callback)
scheduler_callback = scheduler_callback_fn
else:
live_scheduler = Scheduler(
mode=schedule_mode,
trigger_time=trigger_time,
interval_minutes=interval_minutes,
heartbeat_interval=heartbeat_interval if heartbeat_interval > 0 else None,
config={"config_name": run_id},
)
async def scheduler_callback_fn(callback):
await live_scheduler.start(callback)
scheduler_callback = scheduler_callback_fn
# Enter long-term memory contexts
for memory in long_term_memories:
await stack.enter_async_context(memory) await stack.enter_async_context(memory)
# Create Gateway
gateway = Gateway(
market_service=market_service,
storage_service=storage_service,
pipeline=pipeline,
scheduler_callback=scheduler_callback,
config={
"mode": mode,
"backtest_mode": is_backtest,
"tickers": tickers,
"config_name": run_id,
"schedule_mode": schedule_mode,
"interval_minutes": interval_minutes,
"trigger_time": trigger_time,
"heartbeat_interval": heartbeat_interval,
"initial_cash": initial_cash,
"margin_requirement": margin_requirement,
"max_comm_cycles": max_comm_cycles,
"enable_memory": enable_memory,
},
scheduler=live_scheduler,
)
# Start Gateway (blocks until shutdown)
logger.info(f"[Gateway Server] Gateway starting on port {port}") logger.info(f"[Gateway Server] Gateway starting on port {port}")
await gateway.start(host="0.0.0.0", port=port) await bundle.gateway.start(host="0.0.0.0", port=port)
except asyncio.CancelledError: except asyncio.CancelledError:
logger.info("[Gateway Server] Cancelled") logger.info("[Gateway Server] Cancelled")

View File

@@ -1,596 +0,0 @@
# -*- coding: utf-8 -*-
"""
Main Entry Point
Supports: backtest, live modes
"""
import argparse
import asyncio
import logging
import os
from contextlib import AsyncExitStack
from pathlib import Path
import loguru
from dotenv import load_dotenv
from backend.agents import EvoAgent
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
from backend.core.pipeline import TradingPipeline
from backend.core.scheduler import BacktestScheduler, Scheduler
from backend.llm.models import get_agent_formatter, get_agent_model
from backend.api.runtime import unregister_runtime_manager
from backend.runtime.manager import (
TradingRuntimeManager,
set_global_runtime_manager,
clear_global_runtime_manager,
)
from backend.gateway_server import configure_gateway_logging
from backend.services.gateway import Gateway
from backend.services.market import MarketService
from backend.services.storage import StorageService
from backend.utils.settlement import SettlementCoordinator
load_dotenv()
logger = logging.getLogger(__name__)
loguru.logger.disable("flowllm")
loguru.logger.disable("reme_ai")
configure_gateway_logging(verbose=os.getenv("LOG_LEVEL", "").upper() == "DEBUG")
_prompt_loader = get_prompt_loader()
def _get_run_dir(config_name: str) -> Path:
"""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)
def _resolve_runtime_config(args) -> dict:
"""Merge env defaults with run-scoped bootstrap config."""
project_root = Path(__file__).resolve().parents[1]
return resolve_runtime_config(
project_root=project_root,
config_name=args.config_name,
enable_memory=args.enable_memory,
schedule_mode=args.schedule_mode,
interval_minutes=args.interval_minutes,
trigger_time=args.trigger_time,
)
def create_long_term_memory(agent_name: str, config_name: str):
"""
Create ReMeTaskLongTermMemory for an agent
Requires DASHSCOPE_API_KEY env var
"""
from agentscope.memory import ReMeTaskLongTermMemory
from agentscope.model import DashScopeChatModel
from agentscope.embedding import DashScopeTextEmbedding
api_key = os.getenv("MEMORY_API_KEY")
if not api_key:
logger.warning("MEMORY_API_KEY not set, long-term memory disabled")
return None
memory_dir = str(_get_run_dir(config_name) / "memory")
return ReMeTaskLongTermMemory(
agent_name=agent_name,
user_name=agent_name,
model=DashScopeChatModel(
model_name=os.getenv("MEMORY_MODEL_NAME", "qwen3-max"),
api_key=api_key,
stream=False,
),
embedding_model=DashScopeTextEmbedding(
model_name=os.getenv(
"MEMORY_EMBEDDING_MODEL",
"text-embedding-v4",
),
api_key=api_key,
dimensions=1024,
),
**{
"vector_store.default.backend": "local",
"vector_store.default.params.store_dir": memory_dir,
},
)
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,
)
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,
)
agent.toolkit = toolkit
setattr(agent, "run_id", config_name)
# Keep workspace_id for backward compatibility
setattr(agent, "workspace_id", config_name)
return agent
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,
margin_requirement: float,
enable_long_term_memory: bool = False,
):
"""Create all agents for the system
Returns:
tuple: (analysts, risk_manager, portfolio_manager, long_term_memories)
long_term_memories is a list of memory
"""
analysts = []
long_term_memories = []
workspace_manager = WorkspaceManager()
workspace_manager.initialize_default_assets(
config_name=config_name,
agent_ids=list(ANALYST_TYPES.keys())
+ ["risk_manager", "portfolio_manager"],
analyst_personas=_prompt_loader.load_yaml_config("analyst", "personas"),
)
profiles = load_agent_profiles()
skills_manager = SkillsManager()
active_skill_map = skills_manager.prepare_active_skills(
config_name=config_name,
agent_defaults={
agent_id: profile.get("skills", [])
for agent_id, profile in profiles.items()
},
)
for analyst_type in ANALYST_TYPES:
model = get_agent_model(analyst_type)
formatter = get_agent_formatter(analyst_type)
long_term_memory = None
if enable_long_term_memory:
long_term_memory = create_long_term_memory(
analyst_type,
config_name,
)
if long_term_memory:
long_term_memories.append(long_term_memory)
analyst = _create_analyst_agent(
analyst_type=analyst_type,
config_name=config_name,
model=model,
formatter=formatter,
skills_manager=skills_manager,
active_skill_map=active_skill_map,
long_term_memory=long_term_memory,
)
analysts.append(analyst)
risk_long_term_memory = None
if enable_long_term_memory:
risk_long_term_memory = create_long_term_memory(
"risk_manager",
config_name,
)
if risk_long_term_memory:
long_term_memories.append(risk_long_term_memory)
risk_manager = _create_risk_manager_agent(
config_name=config_name,
model=get_agent_model("risk_manager"),
formatter=get_agent_formatter("risk_manager"),
skills_manager=skills_manager,
active_skill_map=active_skill_map,
long_term_memory=risk_long_term_memory,
)
pm_long_term_memory = None
if enable_long_term_memory:
pm_long_term_memory = create_long_term_memory(
"portfolio_manager",
config_name,
)
if pm_long_term_memory:
long_term_memories.append(pm_long_term_memory)
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,
skills_manager=skills_manager,
active_skill_map=active_skill_map,
long_term_memory=pm_long_term_memory,
)
return analysts, risk_manager, portfolio_manager, long_term_memories
async def run_with_gateway(args):
"""Run with WebSocket gateway"""
is_backtest = args.mode == "backtest"
runtime_config = _resolve_runtime_config(args)
config_name = args.config_name
tickers = runtime_config["tickers"]
initial_cash = runtime_config["initial_cash"]
margin_requirement = runtime_config["margin_requirement"]
runtime_manager = TradingRuntimeManager(
config_name=config_name,
run_dir=_get_run_dir(config_name),
bootstrap=runtime_config,
)
runtime_manager.prepare_run()
set_global_runtime_manager(runtime_manager)
# Create market service
market_service = MarketService(
tickers=tickers,
poll_interval=args.poll_interval,
backtest_mode=is_backtest,
api_key=os.getenv("FINNHUB_API_KEY") if not is_backtest else None,
backtest_start_date=args.start_date if is_backtest else None,
backtest_end_date=args.end_date if is_backtest else None,
)
# Create storage service
storage_service = StorageService(
dashboard_dir=_get_run_dir(config_name) / "team_dashboard",
initial_cash=initial_cash,
config_name=config_name,
)
if not storage_service.files["summary"].exists():
storage_service.initialize_empty_dashboard()
else:
storage_service.update_leaderboard_model_info()
# Create agents and pipeline
analysts, risk_manager, pm, long_term_memories = create_agents(
config_name=config_name,
initial_cash=initial_cash,
margin_requirement=margin_requirement,
enable_long_term_memory=runtime_config["enable_memory"],
)
for agent in analysts + [risk_manager, pm]:
agent_id = getattr(agent, "agent_id", None) or getattr(agent, "name", None)
if agent_id:
runtime_manager.register_agent(agent_id)
portfolio_state = storage_service.load_portfolio_state()
pm.load_portfolio_state(portfolio_state)
settlement_coordinator = SettlementCoordinator(
storage=storage_service,
initial_capital=initial_cash,
)
pipeline = TradingPipeline(
analysts=analysts,
risk_manager=risk_manager,
portfolio_manager=pm,
settlement_coordinator=settlement_coordinator,
max_comm_cycles=runtime_config["max_comm_cycles"],
runtime_manager=runtime_manager,
)
# Create scheduler callback
scheduler_callback = None
trading_dates = []
live_scheduler = None
if is_backtest:
backtest_scheduler = BacktestScheduler(
start_date=args.start_date,
end_date=args.end_date,
trading_calendar="NYSE",
delay_between_days=0.5,
)
trading_dates = backtest_scheduler.get_trading_dates()
async def scheduler_callback_fn(callback):
await backtest_scheduler.start(callback)
scheduler_callback = scheduler_callback_fn
else:
# Live mode: use daily or intraday scheduler with NYSE timezone
live_scheduler = Scheduler(
mode=runtime_config["schedule_mode"],
trigger_time=runtime_config["trigger_time"],
interval_minutes=runtime_config["interval_minutes"],
config={"config_name": config_name},
)
async def scheduler_callback_fn(callback):
await live_scheduler.start(callback)
scheduler_callback = scheduler_callback_fn
# Create gateway
gateway = Gateway(
market_service=market_service,
storage_service=storage_service,
pipeline=pipeline,
scheduler_callback=scheduler_callback,
config={
"mode": args.mode,
"backtest_mode": is_backtest,
"tickers": tickers,
"config_name": config_name,
"schedule_mode": runtime_config["schedule_mode"],
"interval_minutes": runtime_config["interval_minutes"],
"trigger_time": runtime_config["trigger_time"],
"initial_cash": initial_cash,
"margin_requirement": margin_requirement,
"max_comm_cycles": runtime_config["max_comm_cycles"],
"enable_memory": runtime_config["enable_memory"],
},
scheduler=live_scheduler if not is_backtest else None,
)
if is_backtest:
gateway.set_backtest_dates(trading_dates)
# Start long-term memory contexts and run gateway
async with AsyncExitStack() as stack:
try:
for memory in long_term_memories:
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 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="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(
"--schedule-mode",
choices=["daily", "intraday"],
default="daily",
)
parser.add_argument("--trigger-time", default="09:30") # NYSE market open
parser.add_argument("--interval-minutes", type=int, default=60)
parser.add_argument("--poll-interval", type=int, default=10)
parser.add_argument("--start-date")
parser.add_argument("--end-date")
parser.add_argument(
"--enable-memory",
action="store_true",
help="Enable ReMeTaskLongTermMemory for agents",
)
return parser
def main():
"""Main entry point"""
parser = build_arg_parser()
args = parser.parse_args()
# Load config from env for logging
runtime_config = _resolve_runtime_config(args)
tickers = runtime_config["tickers"]
initial_cash = runtime_config["initial_cash"]
logger.info("=" * 60)
logger.info(f"Mode: {args.mode}, Config: {args.config_name}")
logger.info(f"Tickers: {tickers}")
logger.info(f"Initial Cash: ${initial_cash:,.2f}")
logger.info(
"Long-term Memory: %s",
"enabled" if runtime_config["enable_memory"] else "disabled",
)
if args.mode == "backtest":
if not args.start_date or not args.end_date:
parser.error(
"--start-date and --end-date required for backtest mode",
)
logger.info(f"Backtest: {args.start_date} to {args.end_date}")
logger.info("=" * 60)
asyncio.run(run_with_gateway(args))
if __name__ == "__main__":
main()

View File

@@ -25,6 +25,13 @@ from backend.config.bootstrap_config import (
from backend.llm.models import get_agent_model_info from backend.llm.models import get_agent_model_info
def _normalize_schedule_mode(value: Any) -> str:
mode = str(value or "daily").strip().lower()
if mode == "intraday":
return "interval"
return mode or "daily"
async def handle_reload_runtime_assets(gateway: Any) -> None: async def handle_reload_runtime_assets(gateway: Any) -> None:
config_name = gateway.config.get("config_name", "default") config_name = gateway.config.get("config_name", "default")
runtime_config = resolve_runtime_config( runtime_config = resolve_runtime_config(
@@ -44,10 +51,10 @@ async def handle_reload_runtime_assets(gateway: Any) -> None:
async def handle_update_runtime_config(gateway: Any, websocket: Any, data: dict[str, Any]) -> None: async def handle_update_runtime_config(gateway: Any, websocket: Any, data: dict[str, Any]) -> None:
updates: dict[str, Any] = {} updates: dict[str, Any] = {}
schedule_mode = str(data.get("schedule_mode", "")).strip().lower() schedule_mode = _normalize_schedule_mode(data.get("schedule_mode", ""))
if schedule_mode: if schedule_mode:
if schedule_mode not in {"daily", "intraday"}: if schedule_mode not in {"daily", "interval"}:
await websocket.send(json.dumps({"type": "error", "message": "schedule_mode must be 'daily' or 'intraday'."}, ensure_ascii=False)) await websocket.send(json.dumps({"type": "error", "message": "schedule_mode must be 'daily' or 'interval'."}, ensure_ascii=False))
return return
updates["schedule_mode"] = schedule_mode updates["schedule_mode"] = schedule_mode

View File

@@ -208,7 +208,7 @@ async def run_live_cycle(gateway: Any, date: str, tickers: list[str]) -> None:
market_status = gateway.market_service.get_market_status() market_status = gateway.market_service.get_market_status()
current_prices = gateway.market_service.get_all_prices() current_prices = gateway.market_service.get_all_prices()
if schedule_mode == "intraday": if schedule_mode in {"interval", "intraday"}:
execute_decisions = market_status.get("status") == "open" execute_decisions = market_status.get("status") == "open"
if execute_decisions: if execute_decisions:
await gateway.state_sync.on_system_message("定时任务触发:当前处于交易时段,本轮将执行交易决策") await gateway.state_sync.on_system_message("定时任务触发:当前处于交易时段,本轮将执行交易决策")

View File

@@ -8,6 +8,13 @@ from typing import Any
from backend.data.provider_utils import normalize_symbol from backend.data.provider_utils import normalize_symbol
def _normalize_schedule_mode(value: Any) -> str:
mode = str(value or "daily").strip().lower()
if mode == "intraday":
return "interval"
return mode or "daily"
def normalize_watchlist(raw_tickers: Any) -> list[str]: def normalize_watchlist(raw_tickers: Any) -> list[str]:
"""Parse watchlist payloads from websocket messages.""" """Parse watchlist payloads from websocket messages."""
if raw_tickers is None: if raw_tickers is None:
@@ -51,9 +58,11 @@ def apply_runtime_config(gateway: Any, runtime_config: dict[str, Any]) -> dict[s
gateway.pipeline.max_comm_cycles = int(runtime_config["max_comm_cycles"]) gateway.pipeline.max_comm_cycles = int(runtime_config["max_comm_cycles"])
gateway.config["max_comm_cycles"] = gateway.pipeline.max_comm_cycles gateway.config["max_comm_cycles"] = gateway.pipeline.max_comm_cycles
gateway.config["schedule_mode"] = runtime_config.get( gateway.config["schedule_mode"] = _normalize_schedule_mode(
"schedule_mode", runtime_config.get(
gateway.config.get("schedule_mode", "daily"), "schedule_mode",
gateway.config.get("schedule_mode", "daily"),
),
) )
gateway.config["interval_minutes"] = int( gateway.config["interval_minutes"] = int(
runtime_config.get( runtime_config.get(

View File

@@ -0,0 +1,189 @@
---
name: dynamic_team_management
description: 动态管理团队中的分析师Agent包括创建、克隆、移除分析师以及查看可用分析师类型。
version: 1.0.0
tools:
- create_analyst
- clone_analyst
- remove_analyst
- list_analyst_types
- get_analyst_info
- get_team_summary
---
# 动态团队管理
当你需要调整分析师团队组成时,使用这个技能。投资经理可以动态创建新的分析师、克隆现有分析师进行定制、或移除不再需要的分析师。
## 1) When to use
- 当前团队缺乏特定领域的分析能力如期权、加密货币、ESG等
- 需要多个相同类型但不同配置的分析师(如"激进型技术分析师"和"保守型技术分析师"
- 需要临时增加分析力量应对特殊市场环境
- 发现某个分析师配置不当,需要移除并重建
- 在团队讨论中发现需要新的分析视角
## 2) Required inputs
### 创建分析师 (create_analyst)
- **agent_id**: 唯一标识符(如 "options_specialist_01"
- **analyst_type**: 基础类型(如 "technical_analyst")或自定义标识
- **可选**: name, focus, description, soul_md, agents_md, model_name
### 克隆分析师 (clone_analyst)
- **source_id**: 源分析师ID如 "technical_analyst"
- **new_id**: 新分析师ID如 "crypto_technical_01"
- **可选**: name, focus_additions, description_override, model_name
### 移除分析师 (remove_analyst)
- **agent_id**: 要移除的分析师ID
## 3) Decision procedure
1. **评估当前团队能力缺口**
- 查看当前活跃的分析师列表
- 识别缺失的分析视角或专业领域
2. **选择创建策略**
- 基于现有类型创建指定analyst_type提供自定义配置
- 完全自定义提供完整的persona定义
- 克隆并修改:从现有分析师复制并应用覆盖
3. **配置分析师**
- 设置唯一agent_id
- 定义显示名称和关注点
- 可选提供自定义SOUL.md内容以精确定义行为
4. **验证创建结果**
- 检查返回的success状态
- 确认新分析师已加入活跃列表
## 4) Tool call policy
- **create_analyst**: 用于创建全新的分析师实例
- 必须提供唯一的agent_id
- 基于预定义类型时analyst_type必须在可用类型列表中或提供完整自定义配置
- 工具调用失败时检查agent_id是否已存在
- **clone_analyst**: 用于基于现有分析师创建变体
- 适用于创建专注于特定行业的分析师如从technical_analyst克隆crypto_technical
- 新实例继承源配置,应用指定的覆盖
- **remove_analyst**: 用于移除动态创建的分析师
- 只能移除通过本技能创建的分析师
- 系统预定义分析师fundamentals_analyst等不可移除
- **list_analyst_types**: 用于查看所有可用分析师类型
- 返回预定义类型 + 运行时注册类型
- **get_analyst_info**: 用于查看特定分析师的详细配置
- **get_team_summary**: 用于查看团队整体构成
## 5) Output schema
### create_analyst / clone_analyst 输出
```json
{
"success": true,
"agent_id": "options_specialist_01",
"message": "Created runtime analyst 'options_specialist_01' (technical_analyst).",
"analyst_type": "technical_analyst"
}
```
### remove_analyst 输出
```json
{
"success": true,
"agent_id": "options_specialist_01",
"message": "Removed runtime analyst 'options_specialist_01'."
}
```
### list_analyst_types 输出
```json
[
{
"type_id": "fundamentals_analyst",
"name": "Fundamentals Analyst",
"description": "...",
"is_builtin": true,
"source": "constants"
}
]
```
## 6) Failure fallback
- **agent_id已存在**: 返回错误提示选择新的agent_id或使用clone_analyst基于现有创建变体
- **analyst_type未知**: 提示使用list_analyst_types查看可用类型或提供完整的自定义persona
- **创建失败**: 检查系统日志,可能原因包括:模型配置错误、工作空间权限问题
- **移除失败**: 确认分析师是通过动态创建(系统预定义分析师不可移除)
## 重要约定
### Agent ID 命名规则
为了使新创建的分析师能够正常工作,**agent_id 必须以 `_analyst` 结尾**。这是系统识别分析师类型并分配相应工具的关键约定。
-**正确**: `options_specialist_analyst`, `crypto_technical_analyst`
-**错误**: `options_specialist`, `crypto_expert`
如果不遵循此约定,分析师将无法获得分析工具组(基本面、技术、情绪、估值等工具)。
### 全新自定义类型 vs 基于现有类型
**基于现有类型**(推荐用于快速创建):
- 使用 `analyst_type: "technical_analyst"` 等预定义类型
- 可以覆盖 persona、SOUL.md 等配置
- 工具组根据 `analyst_type` 自动选择
**全新自定义类型**(用于完全自定义):
- 设置 `analyst_type` 为自定义标识(如 `"custom"`)或任意字符串
- 必须提供完整的 `persona` 定义
- 建议提供 `soul_md` 精确定义行为
- **agent_id 必须仍然以 `_analyst` 结尾**
## 最佳实践
1. **命名约定**: 使用描述性agent_id`industry_tech_analyst` 而非 `analyst_01`**必须以 `_analyst` 结尾**
2. **版本控制**: 克隆分析师时在new_id中包含版本信息`technical_v2_crypto_analyst`
3. **文档记录**: 创建自定义分析师时提供详细的description便于后续理解和维护
4. **资源管理**: 定期使用get_team_summary检查团队规模移除不再需要的分析师
## 示例场景
### 场景1: 添加加密货币分析师
```
创建一个新的分析师,专注于加密货币技术分析:
- agent_id: "crypto_technical_01"
- analyst_type: "technical_analyst"
- name: "加密货币技术分析师"
- focus: ["链上数据分析", "DeFi协议", "加密货币技术指标"]
```
### 场景2: 克隆并定制
```
基于technical_analyst创建一个更激进的版本
- source_id: "technical_analyst"
- new_id: "technical_aggressive_01"
- name: "激进技术分析师"
- focus_additions: ["高波动交易", "突破策略"]
- description_override: "专注于高风险高回报的技术策略..."
```
### 场景3: 创建全新自定义类型(期权专家)
```
创建一个完全自定义的期权分析师注意agent_id以_analyst结尾
- agent_id: "options_strategist_analyst"
- analyst_type: "custom" # 使用非预定义类型
- name: "期权策略分析师"
- focus: ["期权定价", "希腊字母", "波动率曲面"]
- soul_md: "# 角色定义\n你是期权策略专家专注于..."
```
**说明**:
- 即使 `analyst_type` 是 "custom"(不在预定义类型中),只要提供完整的 `persona``soul_md`,系统就能创建功能完整的分析师
- `agent_id` 必须以 `_analyst` 结尾才能获得分析工具
- 模型使用全局默认,或通过 `model_name` 参数指定

View File

@@ -5,7 +5,7 @@
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from backend.apps.agent_service import create_app from backend.apps.agent_service import create_app
from backend.api import agents as agents_module from backend.api import runs as runs_module
def test_agent_service_routes_include_control_plane_endpoints(tmp_path): def test_agent_service_routes_include_control_plane_endpoints(tmp_path):
@@ -73,10 +73,10 @@ def test_agent_service_read_routes(monkeypatch, tmp_path):
def load_agent_file(self, config_name, agent_id, filename): def load_agent_file(self, config_name, agent_id, filename):
return f"{config_name}:{agent_id}:{filename}" return f"{config_name}:{agent_id}:{filename}"
monkeypatch.setattr(agents_module, "load_agent_profiles", lambda: {"portfolio_manager": {"skills": ["demo_skill"]}}) monkeypatch.setattr(runs_module, "load_agent_profiles", lambda: {"portfolio_manager": {"skills": ["demo_skill"]}})
monkeypatch.setattr(agents_module, "get_agent_model_info", lambda agent_id: ("deepseek-v3.2", "DASHSCOPE")) monkeypatch.setattr(runs_module, "get_agent_model_info", lambda agent_id: ("deepseek-v3.2", "DASHSCOPE"))
monkeypatch.setattr( monkeypatch.setattr(
agents_module, runs_module,
"load_agent_workspace_config", "load_agent_workspace_config",
lambda path: type( lambda path: type(
"Cfg", "Cfg",
@@ -91,20 +91,20 @@ def test_agent_service_read_routes(monkeypatch, tmp_path):
)(), )(),
) )
monkeypatch.setattr( monkeypatch.setattr(
agents_module, runs_module,
"get_bootstrap_config_for_run", "get_bootstrap_config_for_run",
lambda project_root, config_name: type("Bootstrap", (), {"agent_override": lambda self, agent_id: {}})(), lambda project_root, config_name: type("Bootstrap", (), {"agent_override": lambda self, agent_id: {}})(),
) )
app = create_app(project_root=tmp_path) app = create_app(project_root=tmp_path)
app.dependency_overrides[agents_module.get_skills_manager] = lambda: _FakeSkillsManager() app.dependency_overrides[runs_module.get_skills_manager] = lambda: _FakeSkillsManager()
app.dependency_overrides[agents_module.get_workspace_manager] = lambda: _FakeWorkspaceManager() app.dependency_overrides[runs_module.get_workspace_manager] = lambda: _FakeWorkspaceManager()
with TestClient(app) as client: with TestClient(app) as client:
profile = client.get("/api/workspaces/demo/agents/portfolio_manager/profile") profile = client.get("/api/runs/demo/agents/portfolio_manager/profile")
skills = client.get("/api/workspaces/demo/agents/portfolio_manager/skills") skills = client.get("/api/runs/demo/agents/portfolio_manager/skills")
detail = client.get("/api/workspaces/demo/agents/portfolio_manager/skills/demo_skill") detail = client.get("/api/runs/demo/agents/portfolio_manager/skills/demo_skill")
workspace_file = client.get("/api/workspaces/demo/agents/portfolio_manager/files/MEMORY.md") workspace_file = client.get("/api/runs/demo/agents/portfolio_manager/files/MEMORY.md")
assert profile.status_code == 200 assert profile.status_code == 200
assert profile.json()["profile"]["model_name"] == "deepseek-v3.2" assert profile.json()["profile"]["model_name"] == "deepseek-v3.2"
@@ -118,4 +118,3 @@ def test_agent_service_read_routes(monkeypatch, tmp_path):
assert workspace_file.status_code == 200 assert workspace_file.status_code == 200
assert workspace_file.json()["content"] == "demo:portfolio_manager:MEMORY.md" assert workspace_file.json()["content"] == "demo:portfolio_manager:MEMORY.md"
assert workspace_file.json()["scope_type"] == "runtime_run" assert workspace_file.json()["scope_type"] == "runtime_run"
assert "runs/<run_id>" in workspace_file.json()["scope_note"]

View File

@@ -6,14 +6,14 @@ from pathlib import Path
def test_main_resolve_evo_agent_ids_filters_unsupported_roles(monkeypatch): def test_main_resolve_evo_agent_ids_filters_unsupported_roles(monkeypatch):
from backend import main as main_module from backend.core import pipeline_runner as runner_module
monkeypatch.setenv( monkeypatch.setenv(
"EVO_AGENT_IDS", "EVO_AGENT_IDS",
"fundamentals_analyst,portfolio_manager,unknown,technical_analyst", "fundamentals_analyst,portfolio_manager,unknown,technical_analyst",
) )
resolved = main_module._resolve_evo_agent_ids() resolved = runner_module._resolve_evo_agent_ids()
assert resolved == {"fundamentals_analyst", "portfolio_manager", "technical_analyst"} assert resolved == {"fundamentals_analyst", "portfolio_manager", "technical_analyst"}
@@ -29,7 +29,7 @@ def test_pipeline_runner_resolve_evo_agent_ids_keeps_supported_roles(monkeypatch
def test_main_create_analyst_agent_can_build_evo_agent(monkeypatch, tmp_path): def test_main_create_analyst_agent_can_build_evo_agent(monkeypatch, tmp_path):
from backend import main as main_module from backend.core import pipeline_runner as runner_module
created = {} created = {}
@@ -49,12 +49,12 @@ def test_main_create_analyst_agent_can_build_evo_agent(monkeypatch, tmp_path):
self.toolkit = None self.toolkit = None
monkeypatch.setenv("EVO_AGENT_IDS", "fundamentals_analyst") monkeypatch.setenv("EVO_AGENT_IDS", "fundamentals_analyst")
monkeypatch.setattr(main_module, "EvoAgent", DummyEvoAgent) monkeypatch.setattr(runner_module, "EvoAgent", DummyEvoAgent)
monkeypatch.setattr(main_module, "create_agent_toolkit", lambda *args, **kwargs: "toolkit") monkeypatch.setattr(runner_module, "create_agent_toolkit", lambda *args, **kwargs: "toolkit")
agent = main_module._create_analyst_agent( agent = runner_module._create_analyst_agent(
analyst_type="fundamentals_analyst", analyst_type="fundamentals_analyst",
config_name="demo", run_id="demo",
model="model", model="model",
formatter="formatter", formatter="formatter",
skills_manager=DummySkillsManager(), skills_manager=DummySkillsManager(),
@@ -71,7 +71,7 @@ def test_main_create_analyst_agent_can_build_evo_agent(monkeypatch, tmp_path):
def test_main_create_risk_manager_can_build_evo_agent(monkeypatch, tmp_path): def test_main_create_risk_manager_can_build_evo_agent(monkeypatch, tmp_path):
from backend import main as main_module from backend.core import pipeline_runner as runner_module
created = {} created = {}
@@ -91,11 +91,11 @@ def test_main_create_risk_manager_can_build_evo_agent(monkeypatch, tmp_path):
self.toolkit = None self.toolkit = None
monkeypatch.setenv("EVO_AGENT_IDS", "risk_manager") monkeypatch.setenv("EVO_AGENT_IDS", "risk_manager")
monkeypatch.setattr(main_module, "EvoAgent", DummyEvoAgent) monkeypatch.setattr(runner_module, "EvoAgent", DummyEvoAgent)
monkeypatch.setattr(main_module, "create_agent_toolkit", lambda *args, **kwargs: "risk-toolkit") monkeypatch.setattr(runner_module, "create_agent_toolkit", lambda *args, **kwargs: "risk-toolkit")
agent = main_module._create_risk_manager_agent( agent = runner_module._create_risk_manager_agent(
config_name="demo", run_id="demo",
model="model", model="model",
formatter="formatter", formatter="formatter",
skills_manager=DummySkillsManager(), skills_manager=DummySkillsManager(),
@@ -112,7 +112,7 @@ def test_main_create_risk_manager_can_build_evo_agent(monkeypatch, tmp_path):
def test_main_create_portfolio_manager_can_build_evo_agent(monkeypatch, tmp_path): def test_main_create_portfolio_manager_can_build_evo_agent(monkeypatch, tmp_path):
from backend import main as main_module from backend.core import pipeline_runner as runner_module
created = {} created = {}
@@ -132,15 +132,15 @@ def test_main_create_portfolio_manager_can_build_evo_agent(monkeypatch, tmp_path
self.toolkit = None self.toolkit = None
monkeypatch.setenv("EVO_AGENT_IDS", "portfolio_manager") monkeypatch.setenv("EVO_AGENT_IDS", "portfolio_manager")
monkeypatch.setattr(main_module, "EvoAgent", DummyEvoAgent) monkeypatch.setattr(runner_module, "EvoAgent", DummyEvoAgent)
monkeypatch.setattr( monkeypatch.setattr(
main_module, runner_module,
"create_agent_toolkit", "create_agent_toolkit",
lambda *args, **kwargs: "pm-toolkit", lambda *args, **kwargs: "pm-toolkit",
) )
agent = main_module._create_portfolio_manager_agent( agent = runner_module._create_portfolio_manager_agent(
config_name="demo", run_id="demo",
model="model", model="model",
formatter="formatter", formatter="formatter",
initial_cash=12345.0, initial_cash=12345.0,
@@ -372,13 +372,13 @@ def test_pipeline_create_runtime_analyst_uses_legacy_when_not_in_evo_ids(monkeyp
def test_main_resolve_evo_agent_ids_returns_all_by_default(monkeypatch): def test_main_resolve_evo_agent_ids_returns_all_by_default(monkeypatch):
"""Test that _resolve_evo_agent_ids returns all supported roles by default.""" """Test that _resolve_evo_agent_ids returns all supported roles by default."""
from backend import main as main_module from backend.core import pipeline_runner as runner_module
from backend.config.constants import ANALYST_TYPES from backend.config.constants import ANALYST_TYPES
# Unset EVO_AGENT_IDS to test default behavior # Unset EVO_AGENT_IDS to test default behavior
monkeypatch.delenv("EVO_AGENT_IDS", raising=False) monkeypatch.delenv("EVO_AGENT_IDS", raising=False)
resolved = main_module._resolve_evo_agent_ids() resolved = runner_module._resolve_evo_agent_ids()
expected = set(ANALYST_TYPES) | {"risk_manager", "portfolio_manager"} expected = set(ANALYST_TYPES) | {"risk_manager", "portfolio_manager"}
assert resolved == expected assert resolved == expected

View File

@@ -159,11 +159,11 @@ def test_apply_runtime_config_updates_gateway_state():
) )
assert gateway.config["tickers"] == ["MSFT", "NVDA"] assert gateway.config["tickers"] == ["MSFT", "NVDA"]
assert gateway.config["schedule_mode"] == "intraday" assert gateway.config["schedule_mode"] == "interval"
assert gateway.storage.initial_cash == 150000.0 assert gateway.storage.initial_cash == 150000.0
assert result["runtime_config_applied"]["max_comm_cycles"] == 4 assert result["runtime_config_applied"]["max_comm_cycles"] == 4
assert gateway.scheduler.calls[-1] == { assert gateway.scheduler.calls[-1] == {
"mode": "intraday", "mode": "interval",
"trigger_time": "10:30", "trigger_time": "10:30",
"interval_minutes": 30, "interval_minutes": 30,
} }

View File

@@ -86,7 +86,7 @@ def test_runtime_service_get_runtime_config(monkeypatch, tmp_path):
"---\n" "---\n"
"tickers:\n" "tickers:\n"
" - AAPL\n" " - AAPL\n"
"schedule_mode: intraday\n" "schedule_mode: interval\n"
"interval_minutes: 30\n" "interval_minutes: 30\n"
"trigger_time: '10:00'\n" "trigger_time: '10:00'\n"
"max_comm_cycles: 3\n" "max_comm_cycles: 3\n"
@@ -102,7 +102,7 @@ def test_runtime_service_get_runtime_config(monkeypatch, tmp_path):
"run_dir": str(run_dir), "run_dir": str(run_dir),
"bootstrap_values": { "bootstrap_values": {
"tickers": ["AAPL"], "tickers": ["AAPL"],
"schedule_mode": "intraday", "schedule_mode": "interval",
"interval_minutes": 30, "interval_minutes": 30,
"trigger_time": "10:00", "trigger_time": "10:00",
"max_comm_cycles": 3, "max_comm_cycles": 3,
@@ -123,7 +123,7 @@ def test_runtime_service_get_runtime_config(monkeypatch, tmp_path):
assert response.status_code == 200 assert response.status_code == 200
payload = response.json() payload = response.json()
assert payload["run_id"] == "demo" assert payload["run_id"] == "demo"
assert payload["bootstrap"]["schedule_mode"] == "intraday" assert payload["bootstrap"]["schedule_mode"] == "interval"
assert payload["resolved"]["interval_minutes"] == 30 assert payload["resolved"]["interval_minutes"] == 30
assert payload["resolved"]["enable_memory"] is True assert payload["resolved"]["enable_memory"] is True
@@ -190,7 +190,7 @@ def test_runtime_service_update_runtime_config_persists_bootstrap(monkeypatch, t
response = client.put( response = client.put(
"/api/runtime/config", "/api/runtime/config",
json={ json={
"schedule_mode": "intraday", "schedule_mode": "interval",
"interval_minutes": 15, "interval_minutes": 15,
"trigger_time": "10:15", "trigger_time": "10:15",
"max_comm_cycles": 4, "max_comm_cycles": 4,
@@ -199,7 +199,7 @@ def test_runtime_service_update_runtime_config_persists_bootstrap(monkeypatch, t
assert response.status_code == 200 assert response.status_code == 200
payload = response.json() payload = response.json()
assert payload["bootstrap"]["schedule_mode"] == "intraday" assert payload["bootstrap"]["schedule_mode"] == "interval"
assert payload["resolved"]["interval_minutes"] == 15 assert payload["resolved"]["interval_minutes"] == 15
assert "interval_minutes: 15" in (run_dir / "BOOTSTRAP.md").read_text(encoding="utf-8") assert "interval_minutes: 15" in (run_dir / "BOOTSTRAP.md").read_text(encoding="utf-8")
@@ -547,7 +547,7 @@ def test_start_runtime_restore_reuses_historical_run_id(monkeypatch, tmp_path):
"run_dir": str(run_dir), "run_dir": str(run_dir),
"bootstrap_values": { "bootstrap_values": {
"tickers": ["AAPL"], "tickers": ["AAPL"],
"schedule_mode": "intraday", "schedule_mode": "interval",
"interval_minutes": 30, "interval_minutes": 30,
"trigger_time": "now", "trigger_time": "now",
"max_comm_cycles": 2, "max_comm_cycles": 2,

View File

@@ -0,0 +1,518 @@
# -*- coding: utf-8 -*-
"""Dynamic Team Management Tools - Tools for PM to manage analyst team dynamically.
This module provides tools for the Portfolio Manager to:
- Create new analysts with custom configuration
- Clone existing analysts with variations
- Remove analysts from the team
- List available analyst types
- Get analyst information
These tools are registered with the PM's toolkit and enable dynamic team management
as described in the Dynamic Team Architecture.
"""
from __future__ import annotations
from typing import Any, Dict, List, Optional, Callable
from dataclasses import asdict
from backend.agents.dynamic_team_types import (
AnalystPersona,
AnalystConfig,
CreateAnalystResult,
AnalystTypeInfo,
)
from backend.config.constants import ANALYST_TYPES
# Type alias for callbacks set by pipeline
CreateAnalystCallback = Callable[[str, str, Optional[AnalystConfig]], str]
RemoveAnalystCallback = Callable[[str], str]
class DynamicTeamController:
"""Controller for dynamic analyst team management.
This class is instantiated by TradingPipeline and injected into the PM agent
via set_team_controller(). It provides methods that the PM can call through
tools to manage the analyst team dynamically.
Attributes:
create_callback: Callback to _create_runtime_analyst in pipeline
remove_callback: Callback to _remove_runtime_analyst in pipeline
get_analysts_callback: Callback to get current analysts list
registered_types: Runtime-registered custom analyst types
"""
def __init__(
self,
create_callback: CreateAnalystCallback,
remove_callback: RemoveAnalystCallback,
get_analysts_callback: Optional[Callable[[], List[Any]]] = None,
):
"""Initialize the controller with callbacks from pipeline.
Args:
create_callback: Function to create a runtime analyst
remove_callback: Function to remove a runtime analyst
get_analysts_callback: Optional function to get current analysts
"""
self._create_callback = create_callback
self._remove_callback = remove_callback
self._get_analysts_callback = get_analysts_callback
self._registered_types: Dict[str, AnalystPersona] = {}
self._instance_configs: Dict[str, AnalystConfig] = {}
def create_analyst(
self,
agent_id: str,
analyst_type: str,
name: Optional[str] = None,
focus: Optional[List[str]] = None,
description: Optional[str] = None,
soul_md: Optional[str] = None,
agents_md: Optional[str] = None,
model_name: Optional[str] = None,
preferred_tools: Optional[List[str]] = None,
) -> Dict[str, Any]:
"""Create a new analyst with optional custom configuration.
This tool allows the Portfolio Manager to dynamically create new analysts
during a trading session. The analyst can be based on a predefined type
or fully customized with a unique persona.
Args:
agent_id: Unique identifier for the new analyst (e.g., "crypto_specialist_01")
analyst_type: Base type (e.g., "technical_analyst") or custom identifier
name: Display name for the analyst (overrides default)
focus: List of focus areas (overrides default)
description: Detailed description (overrides default)
soul_md: Custom SOUL.md content for the analyst's workspace
agents_md: Custom AGENTS.md content
model_name: Override the default LLM model
preferred_tools: List of preferred tool categories
Returns:
Dict with success status, message, and analyst info
Example:
>>> result = create_analyst(
... agent_id="options_specialist",
... analyst_type="technical_analyst",
... name="期权策略分析师",
... focus=["期权定价", "波动率交易"],
... description="专注于期权市场分析和波动率交易策略...",
... )
"""
# Build custom config if any customization is provided
custom_config = None
if any([name, focus, description, soul_md, agents_md, model_name, preferred_tools]):
persona = None
if name or focus or description:
persona = AnalystPersona(
name=name or f"Custom {analyst_type}",
focus=focus or ["General Analysis"],
description=description or f"Custom analyst based on {analyst_type}",
preferred_tools=preferred_tools,
)
custom_config = AnalystConfig(
persona=persona,
analyst_type=analyst_type if analyst_type in ANALYST_TYPES else None,
soul_md=soul_md,
agents_md=agents_md,
model_name=model_name,
)
# Call the pipeline's create method
result_message = self._create_callback(agent_id, analyst_type, custom_config)
# Parse result
success = result_message.startswith("Created")
if success:
self._instance_configs[agent_id] = custom_config if custom_config else AnalystConfig(
analyst_type=analyst_type
)
return {
"success": success,
"agent_id": agent_id if success else None,
"message": result_message,
"analyst_type": analyst_type,
}
def clone_analyst(
self,
source_id: str,
new_id: str,
name: Optional[str] = None,
focus_additions: Optional[List[str]] = None,
description_override: Optional[str] = None,
model_name: Optional[str] = None,
) -> Dict[str, Any]:
"""Clone an existing analyst with optional modifications.
Creates a new analyst by copying the configuration of an existing one
and applying specified overrides. Useful for creating specialized
variants (e.g., "crypto_technical" from "technical_analyst").
Args:
source_id: ID of the analyst to clone
new_id: Unique identifier for the new analyst
name: New display name (if different from source)
focus_additions: Additional focus areas to add
description_override: Completely new description
model_name: Override the model from source
Returns:
Dict with success status, message, and new analyst info
Example:
>>> result = clone_analyst(
... source_id="technical_analyst",
... new_id="crypto_technical_01",
... name="加密货币技术分析师",
... focus_additions=["链上数据", "DeFi协议分析"],
... )
"""
# Get source config if available
source_config = self._instance_configs.get(source_id)
# Determine base type and config
if source_config:
base_type = source_config.analyst_type or source_id
base_persona = source_config.persona
else:
# Assume source_id is a known type
base_type = source_id
base_persona = None
# Build new persona
new_focus = list(base_persona.focus) if base_persona else []
if focus_additions:
new_focus.extend(focus_additions)
new_name = name or (base_persona.name if base_persona else new_id)
new_description = description_override or (base_persona.description if base_persona else "")
# Create new config with parent reference
new_config = AnalystConfig(
persona=AnalystPersona(
name=new_name,
focus=new_focus,
description=new_description,
preferred_tools=base_persona.preferred_tools if base_persona else None,
),
analyst_type=base_type if base_type in ANALYST_TYPES else None,
soul_md=source_config.soul_md if source_config else None,
agents_md=source_config.agents_md if source_config else None,
model_name=model_name or (source_config.model_name if source_config else None),
parent_id=source_id,
)
# Create the new analyst
result_message = self._create_callback(new_id, base_type, new_config)
success = result_message.startswith("Created")
if success:
self._instance_configs[new_id] = new_config
return {
"success": success,
"agent_id": new_id if success else None,
"parent_id": source_id,
"message": result_message,
}
def remove_analyst(self, agent_id: str) -> Dict[str, Any]:
"""Remove a dynamically created analyst from the team.
Args:
agent_id: ID of the analyst to remove
Returns:
Dict with success status and message
Example:
>>> result = remove_analyst("options_specialist")
"""
result_message = self._remove_callback(agent_id)
success = result_message.startswith("Removed") or "not found" not in result_message.lower()
if success and agent_id in self._instance_configs:
del self._instance_configs[agent_id]
return {
"success": success,
"agent_id": agent_id,
"message": result_message,
}
def list_analyst_types(self) -> List[Dict[str, Any]]:
"""List all available analyst types.
Returns a list of all available analyst types, including:
- Built-in types from ANALYST_TYPES
- Runtime registered custom types
Returns:
List of analyst type information dictionaries
Example:
>>> types = list_analyst_types()
>>> print(types[0]["type_id"]) # "fundamentals_analyst"
"""
result = []
# Add built-in types
for type_id, info in ANALYST_TYPES.items():
result.append({
"type_id": type_id,
"name": info.get("display_name", type_id),
"description": info.get("description", ""),
"is_builtin": True,
"source": "constants",
})
# Add runtime registered types
for type_id, persona in self._registered_types.items():
result.append({
"type_id": type_id,
"name": persona.name,
"description": persona.description,
"is_builtin": False,
"source": "runtime",
})
return result
def get_analyst_info(self, agent_id: str) -> Dict[str, Any]:
"""Get information about a specific analyst.
Args:
agent_id: ID of the analyst
Returns:
Dict with analyst configuration and status
"""
config = self._instance_configs.get(agent_id)
if not config:
return {
"found": False,
"agent_id": agent_id,
"message": f"No configuration found for '{agent_id}'",
}
return {
"found": True,
"agent_id": agent_id,
"config": config.to_dict(),
"is_custom": config.persona is not None,
"is_clone": config.parent_id is not None,
"parent_id": config.parent_id,
}
def register_analyst_type(
self,
type_id: str,
name: str,
focus: List[str],
description: str,
preferred_tools: Optional[List[str]] = None,
) -> Dict[str, Any]:
"""Register a new analyst type for later creation.
This allows defining reusable analyst personas that can be instantiated
multiple times with different configurations.
Args:
type_id: Unique identifier for this type (e.g., "options_analyst")
name: Display name
focus: List of focus areas
description: Detailed description
preferred_tools: Optional list of preferred tool categories
Returns:
Dict with success status and type info
Example:
>>> result = register_analyst_type(
... type_id="options_analyst",
... name="期权分析师",
... focus=["期权定价", "希腊字母分析"],
... description="专注于期权策略和波动率分析",
... )
"""
if type_id in self._registered_types or type_id in ANALYST_TYPES:
return {
"success": False,
"type_id": type_id,
"message": f"Type '{type_id}' already exists",
}
persona = AnalystPersona(
name=name,
focus=focus,
description=description,
preferred_tools=preferred_tools,
)
self._registered_types[type_id] = persona
return {
"success": True,
"type_id": type_id,
"persona": persona.to_dict(),
"message": f"Registered new analyst type '{type_id}'",
}
def get_team_summary(self) -> Dict[str, Any]:
"""Get a summary of the current analyst team.
Returns:
Dict with team composition information
"""
analysts = []
for agent_id, config in self._instance_configs.items():
analysts.append({
"agent_id": agent_id,
"name": config.persona.name if config.persona else agent_id,
"type": config.analyst_type,
"is_custom": config.persona is not None,
"is_clone": config.parent_id is not None,
})
return {
"total_analysts": len(analysts),
"custom_analysts": len([a for a in analysts if a["is_custom"]]),
"cloned_analysts": len([a for a in analysts if a["is_clone"]]),
"analysts": analysts,
"registered_types": len(self._registered_types),
}
# Global controller instance - set by pipeline
_controller_instance: Optional[DynamicTeamController] = None
def set_controller(controller: DynamicTeamController) -> None:
"""Set the global controller instance.
Called by TradingPipeline when initializing the PM agent.
"""
global _controller_instance
_controller_instance = controller
def get_controller() -> Optional[DynamicTeamController]:
"""Get the global controller instance.
Returns:
DynamicTeamController instance or None if not set
"""
return _controller_instance
# Tool functions that wrap the controller methods
# These are registered with the PM's toolkit
def create_analyst(
agent_id: str,
analyst_type: str,
name: Optional[str] = None,
focus: Optional[str] = None,
description: Optional[str] = None,
soul_md: Optional[str] = None,
agents_md: Optional[str] = None,
model_name: Optional[str] = None,
) -> Dict[str, Any]:
"""Tool wrapper for create_analyst.
Note: focus parameter accepts comma-separated string for tool compatibility.
"""
controller = get_controller()
if not controller:
return {"success": False, "error": "Dynamic team controller not available"}
focus_list = [f.strip() for f in focus.split(",")] if focus else None
return controller.create_analyst(
agent_id=agent_id,
analyst_type=analyst_type,
name=name,
focus=focus_list,
description=description,
soul_md=soul_md,
agents_md=agents_md,
model_name=model_name,
)
def clone_analyst(
source_id: str,
new_id: str,
name: Optional[str] = None,
focus_additions: Optional[str] = None,
description_override: Optional[str] = None,
model_name: Optional[str] = None,
) -> Dict[str, Any]:
"""Tool wrapper for clone_analyst.
Note: focus_additions accepts comma-separated string.
"""
controller = get_controller()
if not controller:
return {"success": False, "error": "Dynamic team controller not available"}
additions_list = [f.strip() for f in focus_additions.split(",")] if focus_additions else None
return controller.clone_analyst(
source_id=source_id,
new_id=new_id,
name=name,
focus_additions=additions_list,
description_override=description_override,
model_name=model_name,
)
def remove_analyst(agent_id: str) -> Dict[str, Any]:
"""Tool wrapper for remove_analyst."""
controller = get_controller()
if not controller:
return {"success": False, "error": "Dynamic team controller not available"}
return controller.remove_analyst(agent_id)
def list_analyst_types() -> List[Dict[str, Any]]:
"""Tool wrapper for list_analyst_types."""
controller = get_controller()
if not controller:
return []
return controller.list_analyst_types()
def get_analyst_info(agent_id: str) -> Dict[str, Any]:
"""Tool wrapper for get_analyst_info."""
controller = get_controller()
if not controller:
return {"found": False, "error": "Controller not available"}
return controller.get_analyst_info(agent_id)
def get_team_summary() -> Dict[str, Any]:
"""Tool wrapper for get_team_summary."""
controller = get_controller()
if not controller:
return {"error": "Controller not available"}
return controller.get_team_summary()
__all__ = [
"DynamicTeamController",
"set_controller",
"get_controller",
"create_analyst",
"clone_analyst",
"remove_analyst",
"list_analyst_types",
"get_analyst_info",
"get_team_summary",
]

View File

@@ -10,37 +10,12 @@ For the planned convergence work, see
## Contents ## Contents
- [deploy/systemd/evotraders.service](./systemd/evotraders.service)
- systemd unit for the long-running 大时代 gateway process
- [scripts/run_prod.sh](../scripts/run_prod.sh)
- production launch script used by the systemd unit
- [deploy/nginx/bigtime.cillinn.com.conf](./nginx/bigtime.cillinn.com.conf) - [deploy/nginx/bigtime.cillinn.com.conf](./nginx/bigtime.cillinn.com.conf)
- HTTPS nginx config with WebSocket proxying - HTTPS nginx config with WebSocket proxying
- [deploy/nginx/bigtime.cillinn.com.http.conf](./nginx/bigtime.cillinn.com.http.conf) - [deploy/nginx/bigtime.cillinn.com.http.conf](./nginx/bigtime.cillinn.com.http.conf)
- plain HTTP/static-site variant - plain HTTP/static-site variant
## Deployment Topology Options ## Recommended Topology
This directory documents two deployment topologies:
### 1. Compatibility Topology (backend.main) - CURRENT PRODUCTION DEFAULT
The checked-in production path uses the **compatibility gateway** (`backend.main`):
- nginx serves the built frontend from `/var/www/bigtime/current`
- public domain examples use `bigtime.cillinn.com`
- nginx proxies `/ws` to `127.0.0.1:8765`
- systemd runs `scripts/run_prod.sh`
- `scripts/run_prod.sh` starts `python3 -m backend.main` in live mode on `127.0.0.1:8765`
This is a **monolithic gateway** that embeds all services internally. It is the
current production default for simplicity but does not expose the split FastAPI
services directly.
**When to use**: Single-server deployments, simpler operational requirements,
backwards compatibility with existing monitoring.
### 2. Preferred Topology (Split Services) - RECOMMENDED FOR NEW DEPLOYMENTS
The modern architecture exposes individual FastAPI services: The modern architecture exposes individual FastAPI services:
@@ -76,27 +51,9 @@ To deploy in split-service mode, you would:
## systemd ## systemd
The current systemd unit: No maintained systemd unit is checked into the repository anymore. If deployment
work resumes, add units that mirror the split-service topology used in local
- uses `WorkingDirectory=/root/code/evotraders` development.
- executes [scripts/run_prod.sh](../scripts/run_prod.sh)
- restarts automatically on failure
Enable and start:
```bash
sudo cp deploy/systemd/evotraders.service /etc/systemd/system/evotraders.service
sudo systemctl daemon-reload
sudo systemctl enable evotraders
sudo systemctl start evotraders
```
Check status and logs:
```bash
sudo systemctl status evotraders
journalctl -u evotraders -f
```
## nginx ## nginx
@@ -124,7 +81,7 @@ The checked-in TLS config expects Let's Encrypt assets at:
Before using the production scripts, ensure the runtime environment has: Before using the production scripts, ensure the runtime environment has:
- a usable Python environment - a usable Python environment
- backend dependencies installed from `requirements.txt` - backend dependencies installed from the checked-in Python package metadata in `pyproject.toml`
- the package installed with `pip install -e .` or `uv pip install -e .` - the package installed with `pip install -e .` or `uv pip install -e .`
- frontend dependencies installed with `npm ci` - frontend dependencies installed with `npm ci`
- repo dependencies installed - repo dependencies installed
@@ -136,30 +93,10 @@ Recommended production install sequence:
```bash ```bash
python3 -m venv .venv python3 -m venv .venv
source .venv/bin/activate source .venv/bin/activate
pip install -r requirements.txt
pip install -e . pip install -e .
cd frontend && npm ci && npm run build && cd .. cd frontend && npm ci && npm run build && cd ..
``` ```
The production script currently sets:
```bash
PYTHONPATH=/root/code/evotraders/.pydeps:.
TICKERS=${TICKERS:-AAPL,MSFT,GOOGL,AMZN,NVDA,META,TSLA,AMD,NFLX,AVGO,PLTR,COIN}
```
It then launches the current compatibility gateway/runtime process:
```bash
python3 -m backend.main \
--mode live \
--config-name production \
--host 127.0.0.1 \
--port 8765 \
--trigger-time now \
--poll-interval 15
```
## Skill Sandbox Configuration ## Skill Sandbox Configuration
Production deployments should enable Docker-based skill sandbox for security isolation: Production deployments should enable Docker-based skill sandbox for security isolation:
@@ -172,7 +109,7 @@ pip install -e ".[docker-sandbox]"
docker info docker info
``` ```
Environment variables (set by `scripts/run_prod.sh` with defaults): Example environment variables for a future deployment:
| Variable | Default | Description | | Variable | Default | Description |
|----------|---------|-------------| |----------|---------|-------------|
@@ -196,6 +133,5 @@ The checked-in deployment artifacts do not currently document or automate:
- frontend build/publish steps - frontend build/publish steps
- secret management - secret management
If you move production fully to split-service mode, update this directory so it If deployment returns to active development, rewrite this directory around the
documents the new service topology explicitly instead of relying on the gateway- same split-service topology used by `start-dev.sh`.
only path.

View File

@@ -1,22 +0,0 @@
[Unit]
Description=大时代 Production Service
After=network.target
# COMPATIBILITY_SURFACE: stable
# OWNER: ops-team
# SEE: docs/legacy-inventory.md#gateway-first-production-example
#
# This systemd unit runs the gateway-first production topology.
# It executes scripts/run_prod.sh which launches backend.main as the
# primary gateway/runtime process. For split-service deployment topology,
# see docs/current-architecture.md and deploy/README.md
[Service]
Type=simple
WorkingDirectory=/root/code/evotraders
ExecStart=/root/code/evotraders/scripts/run_prod.sh
Restart=always
RestartSec=5
Environment=PYTHONUNBUFFERED=1
[Install]
WantedBy=multi-user.target

View File

@@ -1,239 +0,0 @@
# 关键代码修复方案
## 1. EvoAgent 长期记忆支持 ✅
**状态**: EvoAgent 已支持 `long_term_memory` 参数,但需要移除 Legacy 回退逻辑
**需要修改的文件**:
- `backend/main.py` 第 158-176 行 - 移除记忆启用时的 Legacy 回退
- `backend/core/pipeline.py` - 同样更新
- `backend/core/pipeline_runner.py` - 同样更新
**修复代码** (main.py):
```python
def _create_analyst_agent(...):
# ... 工具包创建代码 ...
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, # 已支持
long_term_memory_mode="static_control",
)
agent.toolkit = toolkit
setattr(agent, "workspace_id", config_name)
return agent
# Legacy fallback (deprecated)
return AnalystAgent(...)
```
## 2. Workspace ID 语义清理
**问题**: `workspace_id` 同时用于 design-time 和 runtime 两个不同概念
**修复方案**:
```python
# backend/api/workspaces.py
# 明确区分两种资源
# Design-time workspaces (CRUD)
@router.get("/design-workspaces/{workspace_id}/...")
async def get_design_workspace(workspace_id: str): ...
# Runtime runs (只读)
@router.get("/runs/{run_id}/agents/{agent_id}/...")
async def get_runtime_agent(run_id: str, agent_id: str): ...
```
## 3. ToolGuard 与 Gateway 审批同步 ✅ 已完成
**状态**: 审批同步已完善,添加了批量审批支持
**API 端点**:
- `POST /api/guard/check` - 检查工具调用是否需要审批
- `POST /api/guard/approve` - 批准单个工具调用
- `POST /api/guard/approve/batch` - ✅ 批量批准多个工具调用(新增)
- `POST /api/guard/deny` - 拒绝工具调用
- `GET /api/guard/pending` - 获取待审批列表
**批量审批示例**:
```python
# 批量批准
await approve_tool_calls(
BatchApprovalRequest(
approval_ids=["approval_001", "approval_002", "approval_003"],
one_time=True,
)
)
```
**超时处理**: 默认 300 秒超时,可在 `ToolGuardMixin._init_tool_guard()` 中配置
## 4. Smoke Test 依赖修复
**需要的依赖**:
```bash
pip install pandas numpy matplotlib seaborn
pip install finnhub-python yfinance
pip install loguru rich
pip install websockets
pip install httpx requests
pip install PyYAML
pip install pandas-market-calendars exchange-calendars
```
## 5. 统一 Agent 工厂 ✅ 已完成
**文件** `backend/agents/unified_factory.py`:
统一工厂已创建,支持:
- 所有 6 种 Agent 角色的创建
- 自动 EvoAgent vs Legacy Agent 选择
- Workspace 驱动配置
- 长期记忆支持
```python
from backend.agents.unified_factory import UnifiedAgentFactory, get_agent_factory
# 使用示例
factory = UnifiedAgentFactory(
config_name="smoke_fullstack",
skills_manager=skills_manager,
)
# 创建分析师
analyst = factory.create_analyst(
analyst_type="fundamentals_analyst",
model=model,
formatter=formatter,
long_term_memory=memory,
)
```
## 6. EvoAgent 默认启用
**修改** `backend/config/constants.py`:
```python
# 默认所有角色使用 EvoAgent
DEFAULT_EVO_AGENT_ROLES = {
"fundamentals_analyst",
"technical_analyst",
"sentiment_analyst",
"valuation_analyst",
"risk_manager",
"portfolio_manager",
}
# EVO_AGENT_IDS 现在用于选择性地禁用 EvoAgent
# 如果设置,只启用指定的角色
# 如果未设置,启用所有角色
```
**修改** `backend/main.py`:
```python
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.
"""
from backend.config.constants import DEFAULT_EVO_AGENT_ROLES
raw = os.getenv("EVO_AGENT_IDS", "")
if raw.strip():
# Filter to only valid roles
requested = {x.strip() for x in raw.split(",") if x.strip()}
return requested & DEFAULT_EVO_AGENT_ROLES
# Default: all roles use EvoAgent
return DEFAULT_EVO_AGENT_ROLES
```
## 7. 遗留代码清理
**可以删除的文件**:
- `backend/agents/compat.py` ✅ 已删除
- `frontend/src/hooks/useWebsocketSessionSync.js` ✅ 已删除
**标记为废弃的文件** ✅ 已完成:
- `backend/agents/analyst.py` - 已添加 DeprecationWarning
- `backend/agents/risk_manager.py` - 已添加 DeprecationWarning
- `backend/agents/portfolio_manager.py` - 已添加 DeprecationWarning
## 8. 测试修复
**更新** `backend/tests/test_evo_agent_selection.py`:
移除这些测试 ✅ 已完成:
- `test_main_create_analyst_agent_falls_back_to_legacy_when_memory_enabled`
- `test_main_create_risk_manager_falls_back_to_legacy_when_memory_enabled`
- `test_main_create_portfolio_manager_falls_back_to_legacy_when_memory_enabled`
添加新测试 ✅ 已完成:
- `test_evo_agent_supports_long_term_memory`
- `test_all_roles_use_evo_agent_by_default`
新增集成测试文件 ✅ 已完成:
- `backend/tests/test_evo_agent_integration.py` - 13 个集成测试覆盖 Factory、ToolGuard、Workspace 集成
## 9. 快速修复清单
运行以下命令应用关键修复:
```bash
# 1. 修复 EvoAgent 记忆支持 (修改 main.py, pipeline.py, pipeline_runner.py)
# 移除 long_term_memory 检查导致的 Legacy 回退
# 2. 修复默认 EvoAgent 启用
sed -i 's/def _resolve_evo_agent_ids():/def _resolve_evo_agent_ids() -> set[str]:/' backend/main.py
# 3. 确保所有测试通过
pytest backend/tests/test_evo_agent_selection.py -v
# 4. 运行 smoke test
python3 scripts/smoke_evo_runtime.py --test-all-roles
```
## 10. 实施进度
### ✅ 已完成
| 任务 | 状态 | 文件 |
|------|------|------|
| EvoAgent 长期记忆支持 | ✅ 已完成 | `evo_agent.py`, `main.py` |
| 默认启用所有角色 EvoAgent | ✅ 已完成 | `main.py`, `pipeline.py` |
| 统一 Agent 工厂 | ✅ 已完成 | `unified_factory.py` |
| ToolGuard Gateway 同步 | ✅ 已完成 | `tool_guard.py`, `guard.py` |
| ToolGuard 批量审批 | ✅ 已完成 | `guard.py` |
| 废弃标记 Legacy Agent | ✅ 已完成 | `analyst.py`, `risk_manager.py`, `portfolio_manager.py` |
| 集成测试 | ✅ 已完成 | `test_evo_agent_integration.py` |
| 类型注解 | ✅ 已完成 | `unified_factory.py` |
| Team 基础设施 | ✅ 已完成 | `messenger.py`, `task_delegator.py` |
| Skills 沙盒执行 | ✅ 已完成 | `sandboxed_executor.py` |
### 🚧 待完成
| 优先级 | 任务 | 说明 |
|--------|------|------|
| P0 | Smoke Test 依赖修复 | 需要安装 pandas, finnhub, pandas-market-calendars 等 |
| P1 | Workspace ID 语义清理 | ✅ 已添加 `run_id`,保留 `workspace_id` 用于向后兼容 |
| P2 | 文档完善 | ✅ 已完成 |
*最后更新: 2026-04-02*
---
*文档生成时间: 2026-04-01*

View File

@@ -1,249 +0,0 @@
# 大时代项目优化和功能补齐计划
## 当前状态评估
### 已完成的工作
1. ✅ EvoAgent 核心实现 (`backend/agents/base/evo_agent.py`)
2. ✅ ToolGuardMixin 工具守卫 (`backend/agents/base/tool_guard.py`)
3. ✅ Hooks 系统 (`backend/agents/base/hooks.py`)
4. ✅ Smoke test 脚本 (`scripts/smoke_evo_runtime.py`)
5. ✅ 选择性 EvoAgent 测试 (`backend/tests/test_evo_agent_selection.py`)
6. ✅ 删除 `backend/agents/compat.py` 兼容性层
7. ✅ 删除 `useWebsocketSessionSync.js` 旧钩子
### 遗留问题清单
#### 🔴 P0: 阻塞 EvoAgent 全面推出
| # | 问题 | 位置 | 影响 | 解决方案 |
|---|------|------|------|----------|
| P0-1 | EvoAgent 不支持长期记忆 | `evo_agent.py:165-166` | 启用 memory 时回退到 Legacy Agent | 集成 ReMe 记忆系统 |
| P0-2 | Pipeline 运行时分析师创建路径不一致 | `pipeline.py` | 运行时动态创建可能跳过 EvoAgent 路径 | 统一 `_create_runtime_analyst` 逻辑 |
| P0-3 | Workspace 加载路径混乱 | `workspace.py`, `workspace_manager.py` | `workspace_id` vs `run_id` 语义混合 | 明确区分 design-time 和 runtime 路径 |
| P0-4 | Smoke test 失败排查 | `scripts/smoke_evo_runtime.py` | 无法验证 EvoAgent 是否正确启动 | 修复测试并确保通过 |
#### 🟡 P1: 功能完善
| # | 问题 | 位置 | 影响 | 解决方案 |
|---|------|------|------|----------|
| P1-1 | Team 基础设施未完成 | `evo_agent.py:41-48` | Agent 间通信和任务委托不可用 | 完成 messenger 和 task_delegator |
| P1-2 | ToolGuard 与 Gateway 审批流程集成 | `tool_guard.py`, `api/guard.py` | 审批状态同步可能不一致 | 统一审批存储和事件通知 |
| P1-3 | Skills 沙盒执行 | `tools/sandboxed_executor.py` | 生产环境需要 Docker 隔离 | 完善沙盒执行器 |
| P1-4 | 错误处理和重试机制 | 多处 | 部分错误未正确处理 | 添加统一的错误处理 |
#### 🟢 P2: 代码质量和可维护性
| # | 问题 | 位置 | 影响 | 解决方案 |
|---|------|------|------|----------|
| P2-1 | 重复的 Agent 创建逻辑 | `main.py`, `pipeline.py`, `pipeline_runner.py` | 维护困难,容易遗漏 | 提取统一的 Agent 工厂 |
| P2-2 | 类型注解不完整 | 多处 | IDE 提示不足 | 完善类型注解 |
| P2-3 | 缺少 EvoAgent 集成测试 | `backend/tests/` | 无法确保功能完整 | 添加集成测试 |
| P2-4 | 文档和注释 | 多处 | 新贡献者理解困难 | 完善文档 |
---
## 详细实施方案
### Phase 1: P0 阻塞问题修复
#### P0-1: EvoAgent 长期记忆支持
**问题描述**:
```python
# main.py 中当前逻辑
if long_term_memory and agent_id not in EVO_AGENT_IDS:
# 使用 Legacy Agent
else:
# 使用 EvoAgent
```
**目标**: EvoAgent 支持 ReMe 长期记忆系统
**实施步骤**:
1.`EvoAgent.__init__` 中正确接收 `long_term_memory` 参数
2. 集成 ReMe 记忆系统的读写
3. 在 Hooks 中添加记忆相关的生命周期管理
4. 修改 `main.py`, `pipeline.py` 中移除 EvoAgent 的记忆回退逻辑
**文件修改**:
- `backend/agents/base/evo_agent.py`
- `backend/main.py`
- `backend/core/pipeline.py`
#### P0-2: Pipeline 运行时分析师创建统一
**问题描述**:
`TradingPipeline._create_runtime_analyst` 方法需要确保:
1. 检查 `EVO_AGENT_IDS` 环境变量
2. 正确传递所有必要参数给 EvoAgent
3. 处理 workspace 资产准备
**实施步骤**:
1. 统一 `pipeline.py``main.py` 中的 Agent 创建逻辑
2. 确保 EvoAgent 路径和 Legacy 路径参数一致
3. 添加运行时动态 Agent 创建的测试
**文件修改**:
- `backend/core/pipeline.py`
- `backend/main.py`
#### P0-3: Workspace 路径清理
**问题描述**:
- `workspace_id` 有时指 `workspaces/` 目录下的设计时 workspace
- 有时指 `runs/<run_id>/` 下的运行时 workspace
**解决方案**:
1. 明确命名:`design_workspace_id` vs `run_id`
2. 在 API 路由中区分两种资源
3. 内部统一使用 `run_id` 作为运行时标识
**文件修改**:
- `backend/api/workspaces.py`
- `backend/api/agents.py`
- `backend/agents/workspace_manager.py`
#### P0-4: Smoke Test 修复
**当前测试**:
```bash
python3 scripts/smoke_evo_runtime.py --agent-id fundamentals_analyst
```
**验证点**:
1. Gateway 正常启动
2. EvoAgent 日志出现
3. `runtime_state.json` 正确写入
4. 审批流程正常工作
**实施步骤**:
1. 运行测试并识别失败点
2. 修复 EvoAgent 初始化问题
3. 确保所有 6 个角色都能通过测试
---
### Phase 2: P1 功能完善
#### P1-1: Team 基础设施
**当前状态**:
```python
try:
from backend.agents.team.messenger import AgentMessenger
from backend.agents.team.task_delegator import TaskDelegator
TEAM_INFRA_AVAILABLE = True
except ImportError:
TEAM_INFRA_AVAILABLE = False
```
**目标**: 完成 Agent 间通信和任务委托
**实施步骤**:
1. 完成 `AgentMessenger` 实现
2. 完成 `TaskDelegator` 实现
3. 添加 Agent 团队协调的测试
#### P1-2: ToolGuard 与 Gateway 集成
**当前状态**:
- `ToolGuardStore` 是内存存储
- Gateway 通过 `get_global_runtime_manager()` 访问
**改进**:
1. 确保审批状态在 Gateway 和 Agent 间同步
2. 添加审批超时处理
3. 支持批量审批
#### P1-3: Skills 沙盒执行
**当前状态**:
```python
SKILL_SANDBOX_MODE=none # 开发模式,直接执行
```
**目标**: 生产环境使用 Docker 隔离
**实施步骤**:
1. 完成 `DockerSandboxBackend`
2. 添加资源限制CPU、内存、网络
3. 添加执行超时控制
---
### Phase 3: P2 代码质量
#### P2-1: 统一 Agent 工厂
**目标**: 提取 `AgentFactory` 统一处理所有 Agent 创建
**设计**:
```python
class AgentFactory:
def create_analyst(self, analyst_type: str, **kwargs) -> BaseAgent
def create_risk_manager(self, **kwargs) -> BaseAgent
def create_portfolio_manager(self, **kwargs) -> BaseAgent
```
#### P2-2: 类型注解
**目标**: 所有公共 API 完整的类型注解
#### P2-3: 集成测试
**目标**: EvoAgent 完整的端到端测试
---
## 实施顺序
### Week 1: P0 阻塞问题
1. [ ] P0-4: 运行 Smoke Test识别失败点
2. [ ] P0-1: EvoAgent 长期记忆支持
3. [ ] P0-2: Pipeline 运行时统一
4. [ ] P0-3: Workspace 路径清理
5. [ ] 验证所有 Smoke Test 通过
### Week 2: P1 功能完善
1. [ ] P1-1: Team 基础设施
2. [ ] P1-2: ToolGuard 集成优化
3. [ ] P1-3: Skills 沙盒执行
### Week 3: P2 代码质量
1. [ ] P2-1: 统一 Agent 工厂
2. [ ] P2-2: 类型注解
3. [ ] P2-3: 集成测试
4. [ ] P2-4: 文档完善
---
## 成功标准
### EvoAgent 全面推出标准
1. ✅ 所有 6 个角色通过 smoke test
2. ✅ 长期记忆功能正常工作
3. ✅ 无需 `EVO_AGENT_IDS` 环境变量即可使用 EvoAgent
4. ✅ Legacy Agent 代码标记为 deprecated
5. ✅ 集成测试覆盖主要使用场景
### 架构清理标准
1.`runs/<run_id>/` 是唯一的运行时数据来源
2.`workspaces/` 仅用于设计时注册表
3. ✅ 所有服务边界清晰,无循环依赖
4. ✅ 文档和代码一致
---
## 风险和对策
| 风险 | 可能性 | 影响 | 对策 |
|------|--------|------|------|
| EvoAgent 与 Legacy 行为不一致 | 中 | 高 | 并行运行对比测试 |
| 长期记忆集成复杂 | 中 | 中 | 分阶段实现,先支持基础功能 |
| 性能下降 | 低 | 高 | 基准测试,性能剖析 |
| 迁移期间系统不稳定 | 中 | 高 | 保持 Legacy 作为回退 |
---
*计划创建日期: 2026-04-01*
*负责: Claude Code*

29
docs/README.md Normal file
View File

@@ -0,0 +1,29 @@
# Docs Index
这个目录只保留当前仍有维护价值的文档。
阅读顺序建议从“权威事实”开始,再看“兼容清理”和“规划说明”。
## 权威事实
- [`current-architecture.md`](./current-architecture.md) — 当前项目架构、运行路径和真相源定义
- [`project-layout.md`](./project-layout.md) — 当前仓库目录职责与阅读方式
- [`terminology.md`](./terminology.md) — runtime、run、workspace 等核心术语
- [`runtime-api-changes.md`](./runtime-api-changes.md) — 当前 `runtime_service` API 约定与行为补充
- [`current-architecture.excalidraw`](./current-architecture.excalidraw) — 架构图源文件
## 兼容与遗留清理
- [`legacy-inventory.md`](./legacy-inventory.md) — 兼容层、遗留代码和历史边界清单
- [`compat-removal-plan.md`](./compat-removal-plan.md) — 兼容移除计划与收敛方向
## 规划说明
- [`development-roadmap.md`](./development-roadmap.md) — 后续演进方向和阶段性路线
## 使用原则
- 讨论“现在系统怎么工作”,优先看 `current-architecture.md`
- 讨论“顶层目录应该怎么理解”,优先看 `project-layout.md`
- 讨论“某个旧入口为什么还在”,优先看 `legacy-inventory.md`
- 讨论“准备删哪些兼容路径”,优先看 `compat-removal-plan.md`
- 不要把历史计划或阶段性修复记录当成当前事实源

View File

@@ -67,11 +67,11 @@ backward-compatible behavior while migration settles.
## Remaining Migration Risks ## Remaining Migration Risks
### Split service deployment is not yet the checked-in production default ### Checked-in deployment artifacts still lag the development topology
- The repo documents split-service local development clearly. - The repo documents split-service local development clearly.
- The checked-in production example still centers on `backend.main` and nginx - The checked-in deployment docs still center on historical nginx
WebSocket proxying. WebSocket proxying rather than the active dev topology.
- This is a topology mismatch to keep in mind when changing deploy docs or prod - This is a topology mismatch to keep in mind when changing deploy docs or prod
automation. automation.
@@ -93,13 +93,13 @@ backward-compatible behavior while migration settles.
Migration can be considered effectively complete when all of the following are Migration can be considered effectively complete when all of the following are
true: true:
1. Production deployment docs and scripts explicitly run the same split-service 1. Deployment docs and scripts explicitly run the same split-service
topology used in development, or intentionally document a different stable topology used in development, or are removed from the repo.
production topology.
2. Critical read paths no longer require ambiguous fallback behavior to local 2. Critical read paths no longer require ambiguous fallback behavior to local
module implementations. module implementations.
3. OpenClaw integration is documented as a stable contract with clear guidance 3. OpenClaw integration is documented as a stable contract with clear guidance
on when to use the WebSocket gateway versus the REST surface. on when to use the WebSocket gateway versus the small set of CLI-backed
gateway read helpers.
4. The frontend-service routing model is stable enough that direct-service and 4. The frontend-service routing model is stable enough that direct-service and
gateway-mediated paths are deliberate design choices rather than migration gateway-mediated paths are deliberate design choices rather than migration
leftovers. leftovers.
@@ -137,9 +137,6 @@ Recommended next action:
These still have an operational reason to exist and should be documented rather These still have an operational reason to exist and should be documented rather
than treated as accidental leftovers. than treated as accidental leftovers.
- `backend.main`
- compatibility gateway/runtime process
- still relevant for websocket transport and current deploy topology
- `runs/<run_id>/team_dashboard/*.json` - `runs/<run_id>/team_dashboard/*.json`
- export/consumer compatibility layer - export/consumer compatibility layer
- gateway-mediated websocket/event flow - gateway-mediated websocket/event flow
@@ -147,8 +144,8 @@ than treated as accidental leftovers.
Recommended next action: Recommended next action:
- keep these, but document them as intentional compatibility surfaces with - keep only surfaces with an active operational consumer, and avoid routing new
explicit ownership. development through them.
### 3. Defer Until Topology Decisions Are Final ### 3. Defer Until Topology Decisions Are Final
@@ -157,8 +154,8 @@ churn without simplifying the current runtime.
- `workspaces/` design-time registry versus `runs/<run_id>/` runtime state - `workspaces/` design-time registry versus `runs/<run_id>/` runtime state
- env-dependent service fallback behavior - env-dependent service fallback behavior
- checked-in deployment docs centered on `backend.main` - checked-in deployment docs that have not yet been rewritten around split services
- dual OpenClaw shapes: gateway integration and REST facade - dual OpenClaw access patterns: gateway integration and CLI-backed read helpers
Recommended next action: Recommended next action:

View File

@@ -386,13 +386,13 @@
"updated": 1, "updated": 1,
"link": null, "link": null,
"locked": false, "locked": false,
"text": "Gateway :8765\\nbackend.main\\nWebSocket + runtime orchestration", "text": "Gateway :8765\\nGateway process\\nWebSocket + runtime orchestration",
"fontSize": 18, "fontSize": 18,
"fontFamily": 5, "fontFamily": 5,
"textAlign": "center", "textAlign": "center",
"verticalAlign": "middle", "verticalAlign": "middle",
"containerId": null, "containerId": null,
"originalText": "Gateway :8765\nbackend.main\nWebSocket + runtime orchestration", "originalText": "Gateway :8765\nGateway process\nWebSocket + runtime orchestration",
"lineHeight": 1.2 "lineHeight": 1.2
}, },
{ {
@@ -696,13 +696,13 @@
"updated": 1, "updated": 1,
"link": null, "link": null,
"locked": false, "locked": false,
"text": "OpenClaw WS :18789\\noptional REST :8004", "text": "OpenClaw WS :18789\\nCLI-backed reads via gateway",
"fontSize": 20, "fontSize": 20,
"fontFamily": 5, "fontFamily": 5,
"textAlign": "center", "textAlign": "center",
"verticalAlign": "middle", "verticalAlign": "middle",
"containerId": null, "containerId": null,
"originalText": "OpenClaw WS :18789\noptional REST :8004", "originalText": "OpenClaw WS :18789\nCLI-backed reads via gateway",
"lineHeight": 1.2 "lineHeight": 1.2
}, },
{ {

View File

@@ -1,8 +1,8 @@
# Current Architecture # Current Architecture
This file describes the current code-supported architecture only. Historical This file describes the current code-supported architecture only. Historical
paths and partial migrations are intentionally excluded unless called out as paths and partial migrations are intentionally excluded unless brief historical
legacy compatibility. context is needed to explain the current shape.
Reference material: Reference material:
@@ -11,40 +11,21 @@ Reference material:
- legacy inventory: [legacy-inventory.md](./legacy-inventory.md) - legacy inventory: [legacy-inventory.md](./legacy-inventory.md)
- terminology guide: [terminology.md](./terminology.md) - terminology guide: [terminology.md](./terminology.md)
## Runtime Modes ## Runtime Mode
The system supports two distinct runtime modes: The supported runtime model is the split-service development architecture.
### Standalone Mode (Legacy Compatibility) Split-service architecture with a dedicated runtime API surface and a separate
Gateway process.
Direct Gateway startup via `backend.main` as a monolithic entrypoint.
```bash
python -m backend.main --mode live --port 8765
```
**Characteristics:**
- Single process runs Gateway, Pipeline, Market Service, and Scheduler
- No service discovery or process management
- Suitable for single-node deployments and quick testing
- All components share the same memory space
**Use cases:**
- Quick local testing without service orchestration
- Single-node production deployments
- Backward compatibility with legacy startup scripts
### Microservice Mode (Default for Development)
Split-service architecture with dedicated runtime_service managing the Gateway lifecycle.
```bash ```bash
./start-dev.sh # Starts all services including runtime_service and Gateway ./start-dev.sh # Starts all services including runtime_service and Gateway
``` ```
**Characteristics:** **Characteristics:**
- `runtime_service` (:8003) acts as Gateway Process Manager - `runtime_service` (:8003) provides runtime lifecycle APIs
- Gateway runs as a subprocess managed by runtime_service - the checked-in `start-dev.sh` starts split services and lets `runtime_service` spawn Gateway
- manual split-service flows can also let `runtime_service` spawn Gateway
- Clear separation between Control Plane (runtime_service) and Data Plane (Gateway) - Clear separation between Control Plane (runtime_service) and Data Plane (Gateway)
- Service discovery via environment variables - Service discovery via environment variables
- Independent scaling and deployment of each service - Independent scaling and deployment of each service
@@ -54,20 +35,7 @@ Split-service architecture with dedicated runtime_service managing the Gateway l
- Multi-node deployments - Multi-node deployments
- Production environments requiring service isolation - Production environments requiring service isolation
## Mode Comparison ## Default Runtime Shape
| Aspect | Standalone Mode | Microservice Mode |
|--------|-----------------|-------------------|
| **Entry point** | `python -m backend.main` | `./start-dev.sh` or individual services |
| **Process model** | Single monolithic process | Multiple specialized processes |
| **Gateway management** | Self-contained | Managed by runtime_service |
| **Service discovery** | None (in-process) | Environment variable based |
| **Hot reload** | Full restart required | Per-service reload |
| **Scaling** | Vertical only | Horizontal possible |
| **Complexity** | Lower | Higher |
| **Use case** | Testing, simple deployments | Development, production |
## Default Runtime Shape (Microservice Mode)
The active runtime path is: The active runtime path is:
@@ -83,7 +51,6 @@ Current service surfaces:
- read-only explain/news APIs - read-only explain/news APIs
- `backend.apps.runtime_service` on `:8003` - `backend.apps.runtime_service` on `:8003`
- runtime lifecycle and gateway process management - runtime lifecycle and gateway process management
- optional OpenClaw REST facade
- gateway WebSocket on `:8765` - gateway WebSocket on `:8765`
- live feed/event transport and pipeline coordination - live feed/event transport and pipeline coordination
@@ -163,11 +130,11 @@ use `docker` mode with appropriate resource limits.
### Current State ### Current State
The system is in a transitional state: The system is in an active development state:
1. **Microservice infrastructure is operational** - runtime_service can start/stop Gateway as subprocess 1. **Microservice infrastructure is operational** - runtime_service can start/stop Gateway as subprocess
2. **Pipeline logic remains in Gateway** - full Pipeline execution still happens within Gateway process 2. **Pipeline logic remains in Gateway** - full Pipeline execution still happens within Gateway process
3. **Standalone mode is preserved** - direct `backend.main` startup for compatibility 3. **Direct gateway startup has been removed** - the repository now exposes a single supported startup model
### Future Direction ### Future Direction
@@ -184,9 +151,9 @@ Phase 3: Pipeline decomposition (planned)
- Gateway becomes a thin event router - Gateway becomes a thin event router
- runtime_service evolves into full orchestrator - runtime_service evolves into full orchestrator
Phase 4: Standalone mode deprecation (future) Phase 4: Deployment convergence (future)
- Remove direct `backend.main` entry point - Remove or rewrite historical deployment artifacts
- All deployments use microservice mode - Keep all documented startup paths aligned with `runtime_service`
## Legacy Compatibility ## Legacy Compatibility
@@ -194,8 +161,8 @@ These items still exist, but they are not the recommended source of truth for
new development: new development:
- root-level runtime data directories such as `live/`, `production/`, `backtest/` - root-level runtime data directories such as `live/`, `production/`, `backtest/`
- direct `backend.main` startup as the primary development path - historical documentation gaps that have not yet been fully rewritten
The current runtime still creates legacy `AnalystAgent` / `RiskAgent` / Legacy fallback agent paths still exist in compatibility-oriented creation
`PMAgent` instances directly. EvoAgent remains an in-progress migration target, flows, but the default `TradingPipeline` runtime now prefers `EvoAgent` for the
not the default execution path. supported roles unless rollout settings explicitly reduce that set.

View File

@@ -63,14 +63,14 @@ Goal: preserve only intentional compatibility layers.
Planned work: Planned work:
- identify startup scripts and deploy artifacts that still center on - identify any remaining deployment docs that still lag the split-service topology
`backend.main` as a monolithic entrypoint
- classify compatibility surfaces into: - classify compatibility surfaces into:
- stable and intentional - stable and intentional
- temporary and shrinking - temporary and shrinking
- removable once replacements are fully active - removable once replacements are fully active
- reduce env-dependent fallback ambiguity for read-only service routing where practical - reduce env-dependent fallback ambiguity for read-only service routing where practical
- document the difference between OpenClaw WebSocket integration and the optional REST facade - document the difference between OpenClaw WebSocket integration and the small
set of CLI-backed gateway read helpers
Definition of done: Definition of done:

View File

@@ -21,7 +21,7 @@ These are the current defaults to build against:
- `backend.apps.trading_service` on `:8001` - `backend.apps.trading_service` on `:8001`
- `backend.apps.news_service` on `:8002` - `backend.apps.news_service` on `:8002`
- gateway process - gateway process
- `backend.main` - `backend.gateway_server` in the default managed-runtime path
- `backend.services.gateway` on `:8765` - `backend.services.gateway` on `:8765`
## Compatibility Surface Classification ## Compatibility Surface Classification
@@ -35,7 +35,6 @@ compatibility surfaces with explicit ownership.
| Surface | Location | Owner | Reason | | Surface | Location | Owner | Reason |
|---------|----------|-------|--------| |---------|----------|-------|--------|
| Gateway-first production | `scripts/run_prod.sh`, `deploy/systemd/`, `deploy/nginx/` | ops-team | Current production example runs gateway directly and proxies `/ws` |
| Dashboard export layer | `runs/<run_id>/team_dashboard/*.json` | frontend-team | Downstream dashboard consumers read these exports | | Dashboard export layer | `runs/<run_id>/team_dashboard/*.json` | frontend-team | Downstream dashboard consumers read these exports |
| Design-time workspace registry | `workspaces/`, `backend.api.workspaces` | control-plane-team | Control-plane editing and registry-style management | | Design-time workspace registry | `workspaces/`, `backend.api.workspaces` | control-plane-team | Control-plane editing and registry-style management |
| Gateway WebSocket transport | `backend.services.gateway` on `:8765` | runtime-team | Live event streaming contract for frontend | | Gateway WebSocket transport | `backend.services.gateway` on `:8765` | runtime-team | Live event streaming contract for frontend |
@@ -50,8 +49,8 @@ in use.
| Surface | Location | Replacement | ETA | | Surface | Location | Replacement | ETA |
|---------|----------|-------------|-----| |---------|----------|-------------|-----|
| Legacy analyst agents | `backend.agents.analyst.*` | `EvoAgent` | After EvoAgent smoke tests pass | | Legacy agent fallback paths | compatibility constructors in `backend.core.pipeline_runner` | `EvoAgent` | After fallback-free runtime cutover |
| Mixed workspace_id semantics | `/api/workspaces/{id}/agents/...` | ✅ `/api/runs/{run_id}/agents/...` routes added | Completed | | Mixed workspace_id semantics | removed from runtime agent routes | ✅ `/api/runs/{run_id}/agents/...` is the only runtime agent route | Completed |
| Root-level runtime directories | `live/`, `backtest/`, `production/` | `runs/<run_id>/` | ✅ Removed, backed up to runs/_legacy/ | | Root-level runtime directories | `live/`, `backtest/`, `production/` | `runs/<run_id>/` | ✅ Removed, backed up to runs/_legacy/ |
**Status**: Do not add new code using these surfaces. Migrate existing usage **Status**: Do not add new code using these surfaces. Migrate existing usage
@@ -67,43 +66,13 @@ topology and service-routing policy are frozen.
|---------|---------------|-----------------| |---------|---------------|-----------------|
| OpenClaw integration | Gateway WebSocket (port 18789) | Stable — REST facade removed | | OpenClaw integration | Gateway WebSocket (port 18789) | Stable — REST facade removed |
| Env-dependent service fallbacks | `TRADING_SERVICE_URL`, `NEWS_SERVICE_URL` fallbacks to local modules | Remove fallbacks and require explicit URLs? | | Env-dependent service fallbacks | `TRADING_SERVICE_URL`, `NEWS_SERVICE_URL` fallbacks to local modules | Remove fallbacks and require explicit URLs? |
| Split-service production deploy | Docs show gateway-first, dev uses split-service | Align production with dev topology? | | Split-service deployment docs | Deployment docs are still partial compared with the dev topology | Rewrite deploy docs around split services? |
**Status**: Document current behavior. Do not actively remove until topology **Status**: Document current behavior. Do not actively remove until topology
decisions are finalized. decisions are finalized.
## Detailed Surface Documentation ## Detailed Surface Documentation
### Gateway-First Production Example
**Files**:
- `scripts/run_prod.sh` - Production launch script
- `deploy/systemd/evotraders.service` - systemd unit
- `deploy/nginx/bigtime.cillinn.com.conf` - HTTPS + WebSocket proxy
- `deploy/nginx/bigtime.cillinn.com.http.conf` - HTTP variant
**Behavior**:
```bash
# scripts/run_prod.sh launches:
python3 -m backend.main \
--mode live \
--config-name production \
--host 127.0.0.1 \
--port 8765
```
**nginx proxies**:
- `/ws` -> `127.0.0.1:8765` (WebSocket upgrade)
- `/` -> static files in `/var/www/bigtime/current`
**Why this exists**:
- Simpler production deployment (single process + nginx)
- WebSocket is the practical live event contract for frontend
- Split-service topology adds operational complexity not needed for all deployments
**Ownership**: ops-team
**Status**: Stable and intentional
### Dashboard Export Layer ### Dashboard Export Layer
**Files**: `runs/<run_id>/team_dashboard/*.json` **Files**: `runs/<run_id>/team_dashboard/*.json`
@@ -154,19 +123,14 @@ These remain in the tree, but they should not define the architecture for new wo
- treat these as historical or compatibility-oriented data/layout artifacts - treat these as historical or compatibility-oriented data/layout artifacts
- do not use them as the default runtime contract for new features - do not use them as the default runtime contract for new features
### Mixed `workspace_id` semantics on agent routes ### Historical mixed `workspace_id` semantics on agent routes
- `/api/workspaces/{workspace_id}/agents/...` This compatibility shape has been removed from runtime agent routes.
**Read**: **Current rule**:
- design-time CRUD routes use `workspace_id` as a registry workspace id - design-time CRUD routes use `/api/workspaces/{workspace_id}/agents/...`
- profile, skills, and editable file routes use `workspace_id` as a run id - runtime agent assets use `/api/runs/{run_id}/agents/...`
**Mitigation already in repo**:
- `agent_service /api/status` exposes scope metadata
- runtime-read responses expose `scope_type` and `scope_note`
### Partial EvoAgent rollout ### Partial EvoAgent rollout
@@ -175,8 +139,10 @@ These remain in the tree, but they should not define the architecture for new wo
**Read**: **Read**:
- EvoAgent is still a controlled rollout path - EvoAgent is the default selection path for supported roles in the current
- legacy analyst/risk/PM implementations remain the default runtime path for now pipeline
- legacy implementations remain as compatibility fallbacks in selected startup
and runner paths
## Recommended Usage ## Recommended Usage
@@ -185,13 +151,14 @@ When in doubt:
1. trust `docs/current-architecture.md` 1. trust `docs/current-architecture.md`
2. trust `runs/<run_id>/` over root-level runtime directories 2. trust `runs/<run_id>/` over root-level runtime directories
3. treat `workspaces/` as control-plane registry, not runtime truth 3. treat `workspaces/` as control-plane registry, not runtime truth
4. treat deploy artifacts as the current checked-in example, not the full system contract 4. treat deploy artifacts as partial references, not the full system contract
5. check this file's **Compatibility Surface Classification** before assuming something is legacy 5. check this file's **Compatibility Surface Classification** before assuming something is legacy
6. prefer `runtime_service`-managed startup for all new work
## Change Log ## Change Log
| Date | Change | | Date | Change |
|------|--------| |------|--------|
| 2026-03-31 | Added Compatibility Surface Classification (3 buckets) | | 2026-03-31 | Added Compatibility Surface Classification (3 buckets) |
| 2026-03-31 | Documented OpenClaw dual integration (REST vs WebSocket) | | 2026-03-31 | Clarified OpenClaw integration documentation |
| 2026-03-31 | Added ownership and status to all surfaces | | 2026-03-31 | Added ownership and status to all surfaces |

80
docs/project-layout.md Normal file
View File

@@ -0,0 +1,80 @@
# Project Layout
这个文件只描述当前仓库目录的职责划分,不记录历史迁移过程。
如果你想知道“系统现在怎么工作”,优先看 `current-architecture.md`
如果你想知道“这个目录现在应该怎么理解”,看本文。
## 顶层目录
### 主线代码
- `backend/`
- 后端运行时、服务、API、Pipeline、Agent、工具与测试
- `frontend/`
- React/Vite 前端
- `shared/`
- 前后端共享 schema 与 client 封装
### 当前有效的数据与状态目录
- `runs/`
- 运行态真相源
- 每个 `run_id` 下保存 BOOTSTRAP、agent 资产、state、logs、dashboard 导出
- `workspaces/`
- 设计时注册表
- 用于 control-plane CRUD不是默认运行时执行目录
- `data/`
- 项目使用的数据资产与本地数据文件
### 文档与部署
- `docs/`
- 当前仍维护的架构、兼容边界、路线图、目录说明
- `services/`
- 服务边界说明
- `deploy/`
- 部署示例、systemd、nginx 配置
- `scripts/`
- 启动、检查、烟测与辅助脚本
### 项目入口与元数据
- `README.md`
- 英文主说明
- `README_zh.md`
- 中文主说明
- `pyproject.toml`
- Python 项目元数据与依赖
- `start-dev.sh`
- 本地默认开发入口
- `start.sh`
- 偏生产风格的本地启动入口
## 本地环境目录
这些目录通常只对当前机器有效,不应作为架构事实源:
- `.venv/`
- 本地 Python 虚拟环境
- `.pydeps/`
- 本地依赖落地目录
- `.omc/`
- 本地工具状态
- `.codex/`
- 本地代理/工具状态
## 阅读原则
- 看运行时行为,优先从 `backend/``frontend/``runs/` 开始
- 看控制面编辑与设计态资产,优先看 `workspaces/`
- 看服务边界,优先看 `services/README.md`
- 看目录时,不要把本地环境目录当成项目结构的一部分
- 新增目录前,先判断它属于“主线代码 / 运行态数据 / 文档部署 / 本地环境”中的哪一类
## 当前约束
- 不再新增参考仓、副本仓、样例快照目录到顶层
- 不再把测试输出、PID、构建产物、缓存目录提交到仓库
- 运行态相关的新文件优先放到 `runs/<run_id>/`
- 设计态注册相关的新文件优先放到 `workspaces/`

View File

@@ -1,8 +1,26 @@
# Runtime Service API 变更文档 # Runtime Service API 说明
## 概述 ## 概述
本文档描述 `runtime_service` API 的改进,包括新增端点、增强响应字段和改进的错误处理 本文档描述当前 `runtime_service` API 的关键端点、增强响应字段和启动/健康检查相关行为
它应被视为 runtime API 的补充说明,而不是迁移过程中的一次性变更记录。
## 适用范围
- 运行时控制面:`backend.apps.runtime_service`
- 运行时 API 前缀:`/api/runtime/*`
- 当前推荐配套阅读:
- `README.md`
- `README_zh.md`
- `docs/current-architecture.md`
## 关于 `schedule_mode` 的当前约定
当前对外约定统一使用 `daily` / `interval`
- `interval` 是当前公开的盘中轮询名称
- `intraday` 仅作为向后兼容输入别名保留
- runtime API 的响应和写回配置应优先输出 `interval`
## 新增端点 ## 新增端点
@@ -26,7 +44,8 @@
- `mode`: 运行模式,`"live"`(实盘)或 `"backtest"`(回测),运行时停止时为 `"stopped"` - `mode`: 运行模式,`"live"`(实盘)或 `"backtest"`(回测),运行时停止时为 `"stopped"`
- `is_backtest`: 是否为回测模式 - `is_backtest`: 是否为回测模式
- `run_id`: 当前运行的任务 ID - `run_id`: 当前运行的任务 ID
- `schedule_mode`: 调度模式`"daily"``"intraday"` - `schedule_mode`: 调度模式字段。当前公开值为 `daily``interval`
历史输入别名 `intraday` 会在服务端归一化为 `interval`
- `is_running`: Gateway 是否正在运行 - `is_running`: Gateway 是否正在运行
--- ---
@@ -113,7 +132,7 @@
--- ---
## 改进的端点 ## 关键端点说明
### GET /api/runtime/gateway/status ### GET /api/runtime/gateway/status
@@ -256,7 +275,8 @@ Gateway 启动前会自动验证以下配置:
- `start_date` 必须早于 `end_date` - `start_date` 必须早于 `end_date`
6. **调度模式** 6. **调度模式**
- `schedule_mode` 必须是 `"daily"` `"intraday"` - 当前公开校验值为 `"daily"` / `"interval"`
- 历史输入 `"intraday"` 会被兼容性归一化为 `"interval"`
**验证失败响应**: **验证失败响应**:

View File

@@ -51,19 +51,18 @@ Examples:
## Compatibility Rule ## Compatibility Rule
Some API paths and fields still use legacy names: Some fields still use legacy names:
- `/api/workspaces/{workspace_id}/agents/...`
- `workspace_id` on approval records - `workspace_id` on approval records
When reading those surfaces: Current API path rule:
- design-time CRUD routes use `workspace_id` literally - design-time CRUD uses `/api/workspaces/{workspace_id}/...`
- runtime-read routes may use the same slot for `run_id` - runtime agent assets use `/api/runs/{run_id}/agents/...`
For new code: For new code:
- prefer `runId` for runtime variables - prefer `runId` for runtime variables and runtime API paths
- prefer `workspaceId` only for design-time registry flows - prefer `workspaceId` only for design-time registry flows
## UI Wording ## UI Wording

View File

@@ -24,7 +24,7 @@ That gives you:
- trading service at `http://localhost:8001` - trading service at `http://localhost:8001`
- news service at `http://localhost:8002` - news service at `http://localhost:8002`
- runtime service at `http://localhost:8003/api/runtime` - runtime service at `http://localhost:8003/api/runtime`
- gateway WebSocket at `ws://localhost:8765` - gateway WebSocket at `ws://localhost:8765` started directly by `start-dev.sh`
## Frontend Environment Variables ## Frontend Environment Variables

View File

@@ -382,7 +382,7 @@ export default function RuntimeSettingsPanel({
}} }}
> >
<option value="daily">每日定时</option> <option value="daily">每日定时</option>
<option value="intraday">盘中轮询</option> <option value="interval">间隔轮询</option>
</select> </select>
</label> </label>

View File

@@ -154,6 +154,32 @@ export const WS_URL =
? `${FALLBACK_WS_PROTOCOL}//${FALLBACK_WS_HOST}:8765` ? `${FALLBACK_WS_PROTOCOL}//${FALLBACK_WS_HOST}:8765`
: `${FALLBACK_WS_PROTOCOL}//${FALLBACK_WS_HOST}${FALLBACK_WS_PORT}/ws`); : `${FALLBACK_WS_PROTOCOL}//${FALLBACK_WS_HOST}${FALLBACK_WS_PORT}/ws`);
// Dynamic Team Management API
const DEFAULT_DYNAMIC_TEAM_API_BASE = isLocalDevHost()
? "http://localhost:8003/api/dynamic-team"
: `${DEFAULT_CONTROL_API_BASE}/dynamic-team`;
export const DYNAMIC_TEAM_API_BASE =
trimTrailingSlash(import.meta.env.VITE_DYNAMIC_TEAM_API_BASE_URL || "") ||
DEFAULT_DYNAMIC_TEAM_API_BASE;
// Dynamic Team API Endpoints
export const DYNAMIC_TEAM_ENDPOINTS = {
// Get all available analyst types (builtin + runtime registered)
listTypes: () => `${DYNAMIC_TEAM_API_BASE}/types`,
// Get personas from personas.yaml
getPersonas: () => `${DYNAMIC_TEAM_API_BASE}/personas`,
// Create a new analyst
createAnalyst: (runId) => `${DYNAMIC_TEAM_API_BASE}/runs/${runId}/analysts`,
// Clone an existing analyst
cloneAnalyst: (runId) => `${DYNAMIC_TEAM_API_BASE}/runs/${runId}/analysts/clone`,
// Remove an analyst
removeAnalyst: (runId, agentId) => `${DYNAMIC_TEAM_API_BASE}/runs/${runId}/analysts/${agentId}`,
// Get analyst info
getAnalystInfo: (runId, agentId) => `${DYNAMIC_TEAM_API_BASE}/runs/${runId}/analysts/${agentId}`,
// Get team summary
getTeamSummary: (runId) => `${DYNAMIC_TEAM_API_BASE}/runs/${runId}/summary`,
};
// Initial ticker symbols for the production watchlist // Initial ticker symbols for the production watchlist
export const INITIAL_TICKERS = [ export const INITIAL_TICKERS = [
{ symbol: "AAPL", price: null, change: null }, { symbol: "AAPL", price: null, change: null },
@@ -170,3 +196,191 @@ export const INITIAL_TICKERS = [
{ symbol: "COIN", price: null, change: null } { symbol: "COIN", price: null, change: null }
]; ];
// ============================================
// Dynamic Analyst Team Management
// ============================================
/**
* Built-in analyst types that can be used as base for dynamic analysts
*
* IMPORTANT: When creating dynamic analysts, the agent_id MUST end with '_analyst'
* to receive analysis tool groups (fundamentals, technical, sentiment, valuation tools).
* Example: 'crypto_specialist_analyst' (correct) vs 'crypto_specialist' (incorrect)
*/
export const BUILTIN_ANALYST_TYPES = [
{
typeId: "fundamentals_analyst",
name: "基本面分析师",
description: "Uses LLM to intelligently select analysis tools, focuses on financial data and company fundamental analysis",
icon: "fundamentals",
},
{
typeId: "technical_analyst",
name: "技术分析师",
description: "Uses LLM to intelligently select analysis tools, focuses on technical indicators and chart analysis",
icon: "technical",
},
{
typeId: "sentiment_analyst",
name: "情绪分析师",
description: "Uses LLM to intelligently select analysis tools, analyzes market sentiment and news sentiment",
icon: "sentiment",
},
{
typeId: "valuation_analyst",
name: "估值分析师",
description: "Uses LLM to intelligently select analysis tools, focuses on company valuation and value assessment",
icon: "valuation",
},
];
/**
* Default colors for dynamically created analysts
* Cycles through these colors for new analysts
*/
export const DYNAMIC_ANALYST_COLORS = [
{ bg: "#F9FDFF", text: "#1565C0", accent: "#1565C0" }, // Blue
{ bg: "#FFF8F8", text: "#C62828", accent: "#C62828" }, // Red
{ bg: "#FAFFFA", text: "#2E7D32", accent: "#2E7D32" }, // Green
{ bg: "#FCFAFF", text: "#6A1B9A", accent: "#6A1B9A" }, // Purple
{ bg: "#FFFCF7", text: "#E65100", accent: "#E65100" }, // Orange
{ bg: "#F9FEFF", text: "#00838F", accent: "#00838F" }, // Cyan
{ bg: "#FFF9F5", text: "#D84315", accent: "#D84315" }, // Deep Orange
{ bg: "#F5F5FF", text: "#4527A0", accent: "#4527A0" }, // Deep Purple
];
/**
* Generate a color scheme for a dynamic analyst based on index
* @param {number} index - The index of the analyst
* @returns {Object} Color scheme object
*/
export const getDynamicAnalystColors = (index) => {
return DYNAMIC_ANALYST_COLORS[index % DYNAMIC_ANALYST_COLORS.length];
};
/**
* Generate a default avatar URL for dynamic analysts
* Uses a hash of the agentId to select from available avatars
* @param {string} agentId - The agent ID
* @returns {string} Avatar URL
*/
export const getDynamicAnalystAvatar = (agentId) => {
const avatars = [
CDN_ASSETS.companyRoom.agent_1,
CDN_ASSETS.companyRoom.agent_2,
CDN_ASSETS.companyRoom.agent_3,
CDN_ASSETS.companyRoom.agent_4,
CDN_ASSETS.companyRoom.agent_5,
CDN_ASSETS.companyRoom.agent_6,
];
// Simple hash function to consistently map agentId to an avatar
const hash = agentId.split("").reduce((acc, char) => {
return acc + char.charCodeAt(0);
}, 0);
return avatars[hash % avatars.length];
};
/**
* Create a dynamic analyst configuration object
* @param {Object} config - Configuration object
* @param {string} config.agentId - Unique identifier
* @param {string} config.baseType - Base analyst type (e.g., "technical_analyst")
* @param {string} config.name - Display name
* @param {string[]} config.focus - Focus areas
* @param {string} config.description - Description
* @param {number} index - Index for color assignment
* @returns {Object} Complete agent configuration
*/
export const createDynamicAnalystConfig = ({
agentId,
baseType,
name,
focus = [],
description = "",
index = 0,
}) => {
return {
id: agentId,
name: name || agentId,
role: name || agentId,
baseType,
focus,
description,
avatar: getDynamicAnalystAvatar(agentId),
colors: getDynamicAnalystColors(index),
isDynamic: true,
isCustom: true,
};
};
/**
* Check if an agent is a dynamic analyst
* @param {Object} agent - Agent object
* @returns {boolean}
*/
export const isDynamicAnalyst = (agent) => {
return agent?.isDynamic === true || agent?.id?.includes("_");
};
/**
* Validate agent ID format for dynamic analysts
* @param {string} agentId - Agent ID to validate
* @returns {Object} Validation result
*/
export const validateAgentId = (agentId) => {
const errors = [];
const warnings = [];
if (!agentId) {
errors.push("Agent ID is required");
} else if (typeof agentId !== "string") {
errors.push("Agent ID must be a string");
} else {
if (agentId.length < 3) {
errors.push("Agent ID must be at least 3 characters");
}
if (agentId.length > 50) {
errors.push("Agent ID must be at most 50 characters");
}
if (!/^[a-zA-Z0-9_]+$/.test(agentId)) {
errors.push("Agent ID can only contain letters, numbers, and underscores");
}
// Reserved IDs that cannot be used
const reservedIds = ["portfolio_manager", "risk_manager"];
if (reservedIds.includes(agentId)) {
errors.push(`"${agentId}" is a reserved ID and cannot be used`);
}
// Warning: agent_id should end with '_analyst' to get analysis tools
if (!agentId.endsWith("_analyst")) {
warnings.push(
"Agent ID should end with '_analyst' to receive analysis tool groups"
);
}
}
return {
valid: errors.length === 0,
errors,
warnings,
};
};
/**
* Generate a suggested agent ID from a name
* IMPORTANT: Agent ID must end with '_analyst' to receive analysis tools
* @param {string} name - Display name
* @param {string} baseType - Base analyst type
* @returns {string} Suggested agent ID (guaranteed to end with '_analyst')
*/
export const suggestAgentId = (name, baseType) => {
const timestamp = Date.now().toString(36).slice(-4);
const normalized = name
.toLowerCase()
.replace(/[^a-z0-9\s]/g, "")
.replace(/\s+/g, "_")
.replace(/_analyst$/, "") // Remove '_analyst' suffix if present to avoid duplication
.slice(0, 20);
// Must end with '_analyst' to get analysis tools registered
return `${normalized || baseType}_${timestamp}_analyst`;
};

View File

@@ -49,11 +49,24 @@ export function useAgentDataRequests(clientRef) {
return runId; return runId;
}, []); }, []);
const sendWs = useCallback((payload) => {
const client = clientRef.current;
if (!client) {
return false;
}
return client.send(payload);
}, [clientRef]);
const requestAgentSkills = useCallback((agentId) => { const requestAgentSkills = useCallback((agentId) => {
const normalized = typeof agentId === 'string' ? agentId.trim() : ''; const normalized = typeof agentId === 'string' ? agentId.trim() : '';
if (!normalized) return false; if (!normalized) return false;
setIsAgentSkillsLoading(true); setIsAgentSkillsLoading(true);
setAgentSkillsFeedback(null); setAgentSkillsFeedback(null);
const sent = sendWs({ type: 'get_agent_skills', agent_id: normalized });
if (sent) {
return true;
}
void resolveRunId() void resolveRunId()
.then((runId) => fetchAgentSkills(runId, normalized)) .then((runId) => fetchAgentSkills(runId, normalized))
.then((payload) => { .then((payload) => {
@@ -61,22 +74,19 @@ export function useAgentDataRequests(clientRef) {
setIsAgentSkillsLoading(false); setIsAgentSkillsLoading(false);
}) })
.catch(() => { .catch(() => {
if (!clientRef.current) { setIsAgentSkillsLoading(false);
setIsAgentSkillsLoading(false);
return;
}
console.debug('REST agent skills request failed, falling back to websocket compatibility path');
const success = clientRef.current.send({ type: 'get_agent_skills', agent_id: normalized });
if (!success) {
setIsAgentSkillsLoading(false);
}
}); });
return true; return true;
}, [clientRef, resolveRunId, setAgentSkillsByAgent, setIsAgentSkillsLoading, setAgentSkillsFeedback]); }, [resolveRunId, sendWs, setAgentSkillsByAgent, setIsAgentSkillsLoading, setAgentSkillsFeedback]);
const requestAgentProfile = useCallback((agentId) => { const requestAgentProfile = useCallback((agentId) => {
const normalized = typeof agentId === 'string' ? agentId.trim() : ''; const normalized = typeof agentId === 'string' ? agentId.trim() : '';
if (!normalized) return false; if (!normalized) return false;
const sent = sendWs({ type: 'get_agent_profile', agent_id: normalized });
if (sent) {
return true;
}
void resolveRunId() void resolveRunId()
.then((runId) => fetchAgentProfile(runId, normalized)) .then((runId) => fetchAgentProfile(runId, normalized))
.then((payload) => { .then((payload) => {
@@ -85,20 +95,20 @@ export function useAgentDataRequests(clientRef) {
[normalized]: payload?.profile && typeof payload.profile === 'object' ? payload.profile : {} [normalized]: payload?.profile && typeof payload.profile === 'object' ? payload.profile : {}
})); }));
}) })
.catch(() => { .catch(() => {});
if (clientRef.current) {
console.debug('REST agent profile request failed, falling back to websocket compatibility path');
clientRef.current.send({ type: 'get_agent_profile', agent_id: normalized });
}
});
return true; return true;
}, [clientRef, resolveRunId, setAgentProfilesByAgent]); }, [resolveRunId, sendWs, setAgentProfilesByAgent]);
const requestSkillDetail = useCallback((skillName) => { const requestSkillDetail = useCallback((skillName) => {
const normalized = typeof skillName === 'string' ? skillName.trim() : ''; const normalized = typeof skillName === 'string' ? skillName.trim() : '';
if (!normalized) return false; if (!normalized) return false;
const detailKey = `${selectedSkillAgentId}:${normalized}`; const detailKey = `${selectedSkillAgentId}:${normalized}`;
setSkillDetailLoadingKey(detailKey); setSkillDetailLoadingKey(detailKey);
const sent = sendWs({ type: 'get_skill_detail', agent_id: selectedSkillAgentId, skill_name: normalized });
if (sent) {
return true;
}
void resolveRunId() void resolveRunId()
.then((runId) => fetchAgentSkillDetail(runId, selectedSkillAgentId, normalized)) .then((runId) => fetchAgentSkillDetail(runId, selectedSkillAgentId, normalized))
.then((payload) => { .then((payload) => {
@@ -110,18 +120,10 @@ export function useAgentDataRequests(clientRef) {
setSkillDetailLoadingKey(null); setSkillDetailLoadingKey(null);
}) })
.catch(() => { .catch(() => {
if (!clientRef.current) { setSkillDetailLoadingKey(null);
setSkillDetailLoadingKey(null);
return;
}
console.debug('REST skill detail request failed, falling back to websocket compatibility path');
const success = clientRef.current.send({ type: 'get_skill_detail', agent_id: selectedSkillAgentId, skill_name: normalized });
if (!success) {
setSkillDetailLoadingKey(null);
}
}); });
return true; return true;
}, [clientRef, resolveRunId, selectedSkillAgentId, setSkillDetailLoadingKey, setSkillDetailsByName]); }, [resolveRunId, selectedSkillAgentId, sendWs, setSkillDetailLoadingKey, setSkillDetailsByName]);
const handleCreateLocalSkill = useCallback((skillName) => { const handleCreateLocalSkill = useCallback((skillName) => {
const normalized = typeof skillName === 'string' ? skillName.trim() : ''; const normalized = typeof skillName === 'string' ? skillName.trim() : '';
@@ -131,6 +133,11 @@ export function useAgentDataRequests(clientRef) {
} }
setAgentSkillsSavingKey(`${selectedSkillAgentId}:${normalized}:create`); setAgentSkillsSavingKey(`${selectedSkillAgentId}:${normalized}:create`);
setAgentSkillsFeedback(null); setAgentSkillsFeedback(null);
const sent = sendWs({ type: 'create_agent_local_skill', agent_id: selectedSkillAgentId, skill_name: normalized });
if (sent) {
return;
}
void resolveRunId() void resolveRunId()
.then((runId) => createAgentLocalSkill(runId, selectedSkillAgentId, normalized)) .then((runId) => createAgentLocalSkill(runId, selectedSkillAgentId, normalized))
.then(() => { .then(() => {
@@ -140,19 +147,10 @@ export function useAgentDataRequests(clientRef) {
requestSkillDetail(normalized); requestSkillDetail(normalized);
}) })
.catch(() => { .catch(() => {
if (!clientRef.current) { setAgentSkillsSavingKey(null);
setAgentSkillsSavingKey(null); setAgentSkillsFeedback({ type: 'error', text: '连接未就绪,稍后重试' });
setAgentSkillsFeedback({ type: 'error', text: '连接未就绪,稍后重试' });
return;
}
console.debug('REST local skill create failed, falling back to websocket compatibility path');
const success = clientRef.current.send({ type: 'create_agent_local_skill', agent_id: selectedSkillAgentId, skill_name: normalized });
if (!success) {
setAgentSkillsSavingKey(null);
setAgentSkillsFeedback({ type: 'error', text: '发送失败,请检查连接状态' });
}
}); });
}, [clientRef, requestAgentSkills, requestSkillDetail, resolveRunId, selectedSkillAgentId, setAgentSkillsFeedback, setAgentSkillsSavingKey]); }, [requestAgentSkills, requestSkillDetail, resolveRunId, selectedSkillAgentId, sendWs, setAgentSkillsFeedback, setAgentSkillsSavingKey]);
const handleLocalSkillDraftChange = useCallback((skillName, content) => { const handleLocalSkillDraftChange = useCallback((skillName, content) => {
const detailKey = `${selectedSkillAgentId}:${skillName}`; const detailKey = `${selectedSkillAgentId}:${skillName}`;
@@ -165,6 +163,11 @@ export function useAgentDataRequests(clientRef) {
if (typeof content !== 'string') return; if (typeof content !== 'string') return;
setAgentSkillsSavingKey(`${selectedSkillAgentId}:${skillName}:content`); setAgentSkillsSavingKey(`${selectedSkillAgentId}:${skillName}:content`);
setAgentSkillsFeedback(null); setAgentSkillsFeedback(null);
const sent = sendWs({ type: 'update_agent_local_skill', agent_id: selectedSkillAgentId, skill_name: skillName, content });
if (sent) {
return;
}
void resolveRunId() void resolveRunId()
.then((runId) => updateAgentLocalSkill(runId, selectedSkillAgentId, skillName, content)) .then((runId) => updateAgentLocalSkill(runId, selectedSkillAgentId, skillName, content))
.then(() => { .then(() => {
@@ -173,23 +176,19 @@ export function useAgentDataRequests(clientRef) {
requestSkillDetail(skillName); requestSkillDetail(skillName);
}) })
.catch(() => { .catch(() => {
if (!clientRef.current) { setAgentSkillsSavingKey(null);
setAgentSkillsSavingKey(null); setAgentSkillsFeedback({ type: 'error', text: '连接未就绪,稍后重试' });
setAgentSkillsFeedback({ type: 'error', text: '连接未就绪,稍后重试' });
return;
}
console.debug('REST local skill save failed, falling back to websocket compatibility path');
const success = clientRef.current.send({ type: 'update_agent_local_skill', agent_id: selectedSkillAgentId, skill_name: skillName, content });
if (!success) {
setAgentSkillsSavingKey(null);
setAgentSkillsFeedback({ type: 'error', text: '发送失败,请检查连接状态' });
}
}); });
}, [clientRef, localSkillDraftsByKey, requestSkillDetail, resolveRunId, selectedSkillAgentId, setAgentSkillsFeedback, setAgentSkillsSavingKey]); }, [localSkillDraftsByKey, requestSkillDetail, resolveRunId, selectedSkillAgentId, sendWs, setAgentSkillsFeedback, setAgentSkillsSavingKey]);
const handleLocalSkillDelete = useCallback((skillName) => { const handleLocalSkillDelete = useCallback((skillName) => {
setAgentSkillsSavingKey(`${selectedSkillAgentId}:${skillName}:delete`); setAgentSkillsSavingKey(`${selectedSkillAgentId}:${skillName}:delete`);
setAgentSkillsFeedback(null); setAgentSkillsFeedback(null);
const sent = sendWs({ type: 'delete_agent_local_skill', agent_id: selectedSkillAgentId, skill_name: skillName });
if (sent) {
return;
}
void resolveRunId() void resolveRunId()
.then((runId) => deleteAgentLocalSkill(runId, selectedSkillAgentId, skillName)) .then((runId) => deleteAgentLocalSkill(runId, selectedSkillAgentId, skillName))
.then(() => { .then(() => {
@@ -198,23 +197,19 @@ export function useAgentDataRequests(clientRef) {
requestAgentSkills(selectedSkillAgentId); requestAgentSkills(selectedSkillAgentId);
}) })
.catch(() => { .catch(() => {
if (!clientRef.current) { setAgentSkillsSavingKey(null);
setAgentSkillsSavingKey(null); setAgentSkillsFeedback({ type: 'error', text: '连接未就绪,稍后重试' });
setAgentSkillsFeedback({ type: 'error', text: '连接未就绪,稍后重试' });
return;
}
console.debug('REST local skill delete failed, falling back to websocket compatibility path');
const success = clientRef.current.send({ type: 'delete_agent_local_skill', agent_id: selectedSkillAgentId, skill_name: skillName });
if (!success) {
setAgentSkillsSavingKey(null);
setAgentSkillsFeedback({ type: 'error', text: '发送失败,请检查连接状态' });
}
}); });
}, [clientRef, requestAgentSkills, resolveRunId, selectedSkillAgentId, setAgentSkillsFeedback, setAgentSkillsSavingKey]); }, [requestAgentSkills, resolveRunId, selectedSkillAgentId, sendWs, setAgentSkillsFeedback, setAgentSkillsSavingKey]);
const handleRemoveSharedSkill = useCallback((skillName) => { const handleRemoveSharedSkill = useCallback((skillName) => {
setAgentSkillsSavingKey(`${selectedSkillAgentId}:${skillName}:remove`); setAgentSkillsSavingKey(`${selectedSkillAgentId}:${skillName}:remove`);
setAgentSkillsFeedback(null); setAgentSkillsFeedback(null);
const sent = sendWs({ type: 'remove_agent_skill', agent_id: selectedSkillAgentId, skill_name: skillName });
if (sent) {
return;
}
void resolveRunId() void resolveRunId()
.then((runId) => disableAgentSkill(runId, selectedSkillAgentId, skillName)) .then((runId) => disableAgentSkill(runId, selectedSkillAgentId, skillName))
.then(() => { .then(() => {
@@ -223,24 +218,20 @@ export function useAgentDataRequests(clientRef) {
requestAgentSkills(selectedSkillAgentId); requestAgentSkills(selectedSkillAgentId);
}) })
.catch(() => { .catch(() => {
if (!clientRef.current) { setAgentSkillsSavingKey(null);
setAgentSkillsSavingKey(null); setAgentSkillsFeedback({ type: 'error', text: '连接未就绪,稍后重试' });
setAgentSkillsFeedback({ type: 'error', text: '连接未就绪,稍后重试' });
return;
}
console.debug('REST shared skill remove failed, falling back to websocket compatibility path');
const success = clientRef.current.send({ type: 'remove_agent_skill', agent_id: selectedSkillAgentId, skill_name: skillName });
if (!success) {
setAgentSkillsSavingKey(null);
setAgentSkillsFeedback({ type: 'error', text: '发送失败,请检查连接状态' });
}
}); });
}, [clientRef, requestAgentSkills, resolveRunId, selectedSkillAgentId, setAgentSkillsFeedback, setAgentSkillsSavingKey]); }, [requestAgentSkills, resolveRunId, selectedSkillAgentId, sendWs, setAgentSkillsFeedback, setAgentSkillsSavingKey]);
const handleAgentSkillToggle = useCallback((skillName, enabled) => { const handleAgentSkillToggle = useCallback((skillName, enabled) => {
const agentId = selectedSkillAgentId; const agentId = selectedSkillAgentId;
setAgentSkillsSavingKey(`${agentId}:${skillName}`); setAgentSkillsSavingKey(`${agentId}:${skillName}`);
setAgentSkillsFeedback(null); setAgentSkillsFeedback(null);
const sent = sendWs({ type: 'update_agent_skill', agent_id: agentId, skill_name: skillName, enabled });
if (sent) {
return;
}
void resolveRunId() void resolveRunId()
.then((runId) => enabled .then((runId) => enabled
? enableAgentSkill(runId, agentId, skillName) ? enableAgentSkill(runId, agentId, skillName)
@@ -251,19 +242,10 @@ export function useAgentDataRequests(clientRef) {
requestAgentSkills(agentId); requestAgentSkills(agentId);
}) })
.catch(() => { .catch(() => {
if (!clientRef.current) { setAgentSkillsSavingKey(null);
setAgentSkillsSavingKey(null); setAgentSkillsFeedback({ type: 'error', text: '连接未就绪,稍后重试' });
setAgentSkillsFeedback({ type: 'error', text: '连接未就绪,稍后重试' });
return;
}
console.debug('REST skill toggle failed, falling back to websocket compatibility path');
const success = clientRef.current.send({ type: 'update_agent_skill', agent_id: agentId, skill_name: skillName, enabled });
if (!success) {
setAgentSkillsSavingKey(null);
setAgentSkillsFeedback({ type: 'error', text: '发送失败,请检查连接状态' });
}
}); });
}, [clientRef, requestAgentSkills, resolveRunId, selectedSkillAgentId, setAgentSkillsFeedback, setAgentSkillsSavingKey]); }, [requestAgentSkills, resolveRunId, selectedSkillAgentId, sendWs, setAgentSkillsFeedback, setAgentSkillsSavingKey]);
const handleSkillAgentChange = useCallback((agentId) => { const handleSkillAgentChange = useCallback((agentId) => {
setSelectedSkillAgentId(agentId); setSelectedSkillAgentId(agentId);
@@ -278,6 +260,11 @@ export function useAgentDataRequests(clientRef) {
if (!normalizedAgentId || !normalizedFilename) return false; if (!normalizedAgentId || !normalizedFilename) return false;
setIsWorkspaceFileLoading(true); setIsWorkspaceFileLoading(true);
setWorkspaceFileFeedback(null); setWorkspaceFileFeedback(null);
const sent = sendWs({ type: 'get_agent_workspace_file', agent_id: normalizedAgentId, filename: normalizedFilename });
if (sent) {
return true;
}
void resolveRunId() void resolveRunId()
.then((runId) => fetchAgentWorkspaceFile(runId, normalizedAgentId, normalizedFilename)) .then((runId) => fetchAgentWorkspaceFile(runId, normalizedAgentId, normalizedFilename))
.then((payload) => { .then((payload) => {
@@ -292,18 +279,10 @@ export function useAgentDataRequests(clientRef) {
setIsWorkspaceFileLoading(false); setIsWorkspaceFileLoading(false);
}) })
.catch(() => { .catch(() => {
if (!clientRef.current) { setIsWorkspaceFileLoading(false);
setIsWorkspaceFileLoading(false);
return;
}
console.debug('REST workspace file read failed, falling back to websocket compatibility path');
const success = clientRef.current.send({ type: 'get_agent_workspace_file', agent_id: normalizedAgentId, filename: normalizedFilename });
if (!success) {
setIsWorkspaceFileLoading(false);
}
}); });
return true; return true;
}, [clientRef, resolveRunId, setIsWorkspaceFileLoading, setWorkspaceDraftContent, setWorkspaceFileFeedback, setWorkspaceFilesByAgent]); }, [resolveRunId, sendWs, setIsWorkspaceFileLoading, setWorkspaceDraftContent, setWorkspaceFileFeedback, setWorkspaceFilesByAgent]);
const handleWorkspaceFileChange = useCallback((filename) => { const handleWorkspaceFileChange = useCallback((filename) => {
useAgentStore.getState().setSelectedWorkspaceFile(filename); useAgentStore.getState().setSelectedWorkspaceFile(filename);
@@ -314,6 +293,16 @@ export function useAgentDataRequests(clientRef) {
const key = `${selectedSkillAgentId}:${selectedWorkspaceFile}`; const key = `${selectedSkillAgentId}:${selectedWorkspaceFile}`;
setWorkspaceFileSavingKey(key); setWorkspaceFileSavingKey(key);
setWorkspaceFileFeedback(null); setWorkspaceFileFeedback(null);
const sent = sendWs({
type: 'update_agent_workspace_file',
agent_id: selectedSkillAgentId,
filename: selectedWorkspaceFile,
content: workspaceDraftContent
});
if (sent) {
return;
}
void resolveRunId() void resolveRunId()
.then((runId) => updateAgentWorkspaceFile(runId, selectedSkillAgentId, selectedWorkspaceFile, workspaceDraftContent)) .then((runId) => updateAgentWorkspaceFile(runId, selectedSkillAgentId, selectedWorkspaceFile, workspaceDraftContent))
.then((payload) => { .then((payload) => {
@@ -328,24 +317,10 @@ export function useAgentDataRequests(clientRef) {
})); }));
}) })
.catch(() => { .catch(() => {
if (!clientRef.current) { setWorkspaceFileSavingKey(null);
setWorkspaceFileSavingKey(null); setWorkspaceFileFeedback({ type: 'error', text: '连接未就绪,稍后重试' });
setWorkspaceFileFeedback({ type: 'error', text: '连接未就绪,稍后重试' });
return;
}
console.debug('REST workspace file save failed, falling back to websocket compatibility path');
const success = clientRef.current.send({
type: 'update_agent_workspace_file',
agent_id: selectedSkillAgentId,
filename: selectedWorkspaceFile,
content: workspaceDraftContent
});
if (!success) {
setWorkspaceFileSavingKey(null);
setWorkspaceFileFeedback({ type: 'error', text: '发送失败,请检查连接状态' });
}
}); });
}, [clientRef, resolveRunId, selectedSkillAgentId, selectedWorkspaceFile, setWorkspaceFileFeedback, setWorkspaceFileSavingKey, setWorkspaceFilesByAgent, workspaceDraftContent]); }, [resolveRunId, selectedSkillAgentId, selectedWorkspaceFile, sendWs, setWorkspaceFileFeedback, setWorkspaceFileSavingKey, setWorkspaceFilesByAgent, workspaceDraftContent]);
const handleUploadExternalSkill = useCallback(async (file) => { const handleUploadExternalSkill = useCallback(async (file) => {
if (!(file instanceof File)) { if (!(file instanceof File)) {

View File

@@ -26,6 +26,14 @@ export function useStockDataRequests(clientRef, { setRequestStockHistory, setReq
const { setOhlcHistoryByTicker, setPriceHistoryByTicker, setHistorySourceByTicker, const { setOhlcHistoryByTicker, setPriceHistoryByTicker, setHistorySourceByTicker,
setNewsByTicker, setInsiderTradesByTicker } = useMarketStore(); setNewsByTicker, setInsiderTradesByTicker } = useMarketStore();
const sendWs = useCallback((payload) => {
const client = clientRef.current;
if (!client) {
return false;
}
return client.send(payload);
}, [clientRef]);
const requestStockHistory = useCallback((symbol, { force = false } = {}) => { const requestStockHistory = useCallback((symbol, { force = false } = {}) => {
const normalized = typeof symbol === 'string' ? symbol.trim().toUpperCase() : ''; const normalized = typeof symbol === 'string' ? symbol.trim().toUpperCase() : '';
if (!normalized) return false; if (!normalized) return false;
@@ -40,6 +48,13 @@ export function useStockDataRequests(clientRef, { setRequestStockHistory, setReq
start.setDate(start.getDate() - 120); start.setDate(start.getDate() - 120);
const startDate = start.toISOString().slice(0, 10); const startDate = start.toISOString().slice(0, 10);
const wsPayload = { type: 'get_stock_history', ticker: normalized, lookback_days: 120 };
const wsSent = sendWs(wsPayload);
if (wsSent) {
requestedStockHistoryRef.current.add(normalized);
return true;
}
if (hasDirectTradingService()) { if (hasDirectTradingService()) {
void fetchStockHistoryDirect(normalized, startDate, endDate) void fetchStockHistoryDirect(normalized, startDate, endDate)
.then((payload) => { .then((payload) => {
@@ -59,42 +74,36 @@ export function useStockDataRequests(clientRef, { setRequestStockHistory, setReq
setHistorySourceByTicker((prev) => ({ ...prev, [normalized]: 'trading_service' })); setHistorySourceByTicker((prev) => ({ ...prev, [normalized]: 'trading_service' }));
}) })
.catch((error) => { .catch((error) => {
console.error('Direct stock-history fetch failed, falling back to websocket:', error); console.error('Direct stock-history fetch failed:', error);
if (clientRef.current) {
const success = clientRef.current.send({
type: 'get_stock_history',
ticker: normalized,
lookback_days: 120
});
if (success) requestedStockHistoryRef.current.add(normalized);
}
}); });
requestedStockHistoryRef.current.add(normalized); requestedStockHistoryRef.current.add(normalized);
return true; return true;
} }
if (!clientRef.current) return false; return false;
const success = clientRef.current.send({ type: 'get_stock_history', ticker: normalized, lookback_days: 120 }); }, [currentDate, hasDirectTradingService, sendWs, setOhlcHistoryByTicker, setPriceHistoryByTicker, setHistorySourceByTicker]);
if (success) requestedStockHistoryRef.current.add(normalized);
return success;
}, [clientRef, currentDate, setOhlcHistoryByTicker, setPriceHistoryByTicker, setHistorySourceByTicker]);
const requestStockExplainEvents = useCallback((symbol) => { const requestStockExplainEvents = useCallback((symbol) => {
const normalized = typeof symbol === 'string' ? symbol.trim().toUpperCase() : ''; const normalized = typeof symbol === 'string' ? symbol.trim().toUpperCase() : '';
if (!normalized || !clientRef.current) return false; if (!normalized) return false;
return clientRef.current.send({ type: 'get_stock_explain_events', ticker: normalized }); return sendWs({ type: 'get_stock_explain_events', ticker: normalized });
}, [clientRef]); }, [sendWs]);
const requestStockNews = useCallback((symbol) => { const requestStockNews = useCallback((symbol) => {
const normalized = typeof symbol === 'string' ? symbol.trim().toUpperCase() : ''; const normalized = typeof symbol === 'string' ? symbol.trim().toUpperCase() : '';
if (!normalized || !clientRef.current) return false; if (!normalized) return false;
return clientRef.current.send({ type: 'get_stock_news', ticker: normalized, lookback_days: 45, limit: 12 }); return sendWs({ type: 'get_stock_news', ticker: normalized, lookback_days: 45, limit: 12 });
}, [clientRef]); }, [sendWs]);
const requestStockNewsForDate = useCallback((symbol, date) => { const requestStockNewsForDate = useCallback((symbol, date) => {
const normalized = typeof symbol === 'string' ? symbol.trim().toUpperCase() : ''; const normalized = typeof symbol === 'string' ? symbol.trim().toUpperCase() : '';
if (!normalized || !date) return false; if (!normalized || !date) return false;
const wsSent = sendWs({ type: 'get_stock_news_for_date', ticker: normalized, date, limit: 20 });
if (wsSent) {
return true;
}
if (hasDirectNewsService()) { if (hasDirectNewsService()) {
void fetchNewsForDateDirect(normalized, date, 20) void fetchNewsForDateDirect(normalized, date, 20)
.then((payload) => { .then((payload) => {
@@ -111,23 +120,19 @@ export function useStockDataRequests(clientRef, { setRequestStockHistory, setReq
})); }));
}) })
.catch((error) => { .catch((error) => {
console.error('Direct news-for-date fetch failed, falling back to websocket:', error); console.error('Direct news-for-date fetch failed:', error);
if (clientRef.current) {
clientRef.current.send({ type: 'get_stock_news_for_date', ticker: normalized, date, limit: 20 });
}
}); });
return true; return true;
} }
if (!clientRef.current) return false; return false;
return clientRef.current.send({ type: 'get_stock_news_for_date', ticker: normalized, date, limit: 20 }); }, [hasDirectNewsService, sendWs, setNewsByTicker]);
}, [clientRef, setNewsByTicker]);
const requestStockNewsTimeline = useCallback((symbol) => { const requestStockNewsTimeline = useCallback((symbol) => {
const normalized = typeof symbol === 'string' ? symbol.trim().toUpperCase() : ''; const normalized = typeof symbol === 'string' ? symbol.trim().toUpperCase() : '';
if (!normalized || !clientRef.current) return false; if (!normalized) return false;
return clientRef.current.send({ type: 'get_stock_news_timeline', ticker: normalized, lookback_days: 90 }); return sendWs({ type: 'get_stock_news_timeline', ticker: normalized, lookback_days: 90 });
}, [clientRef]); }, [sendWs]);
const requestStockNewsCategories = useCallback((symbol) => { const requestStockNewsCategories = useCallback((symbol) => {
const normalized = typeof symbol === 'string' ? symbol.trim().toUpperCase() : ''; const normalized = typeof symbol === 'string' ? symbol.trim().toUpperCase() : '';
@@ -141,6 +146,11 @@ export function useStockDataRequests(clientRef, { setRequestStockHistory, setReq
start.setDate(start.getDate() - 90); start.setDate(start.getDate() - 90);
const startDate = start.toISOString().slice(0, 10); const startDate = start.toISOString().slice(0, 10);
const wsSent = sendWs({ type: 'get_stock_news_categories', ticker: normalized, lookback_days: 90 });
if (wsSent) {
return true;
}
if (hasDirectNewsService()) { if (hasDirectNewsService()) {
void fetchNewsCategoriesDirect(normalized, startDate, endDate, 200) void fetchNewsCategoriesDirect(normalized, startDate, endDate, 200)
.then((payload) => { .then((payload) => {
@@ -157,22 +167,23 @@ export function useStockDataRequests(clientRef, { setRequestStockHistory, setReq
})); }));
}) })
.catch((error) => { .catch((error) => {
console.error('Direct news-categories fetch failed, falling back to websocket:', error); console.error('Direct news-categories fetch failed:', error);
if (clientRef.current) {
clientRef.current.send({ type: 'get_stock_news_categories', ticker: normalized, lookback_days: 90 });
}
}); });
return true; return true;
} }
if (!clientRef.current) return false; return false;
return clientRef.current.send({ type: 'get_stock_news_categories', ticker: normalized, lookback_days: 90 }); }, [currentDate, hasDirectNewsService, sendWs, setNewsByTicker]);
}, [clientRef, currentDate, setNewsByTicker]);
const requestStockInsiderTrades = useCallback((symbol, startDate = null, endDate = null) => { const requestStockInsiderTrades = useCallback((symbol, startDate = null, endDate = null) => {
const normalized = typeof symbol === 'string' ? symbol.trim().toUpperCase() : ''; const normalized = typeof symbol === 'string' ? symbol.trim().toUpperCase() : '';
if (!normalized) return false; if (!normalized) return false;
const wsSent = sendWs({ type: 'get_stock_insider_trades', ticker: normalized, start_date: startDate, end_date: endDate, limit: 50 });
if (wsSent) {
return true;
}
if (hasDirectTradingService()) { if (hasDirectTradingService()) {
void fetchInsiderTradesDirect(normalized, startDate, endDate, 50) void fetchInsiderTradesDirect(normalized, startDate, endDate, 50)
.then((payload) => { .then((payload) => {
@@ -183,28 +194,29 @@ export function useStockDataRequests(clientRef, { setRequestStockHistory, setReq
})); }));
}) })
.catch((error) => { .catch((error) => {
console.error('Direct insider-trades fetch failed, falling back to websocket:', error); console.error('Direct insider-trades fetch failed:', error);
if (clientRef.current) {
clientRef.current.send({ type: 'get_stock_insider_trades', ticker: normalized, start_date: startDate, end_date: endDate, limit: 50 });
}
}); });
return true; return true;
} }
if (!clientRef.current) return false; return false;
return clientRef.current.send({ type: 'get_stock_insider_trades', ticker: normalized, start_date: startDate, end_date: endDate, limit: 50 }); }, [hasDirectTradingService, sendWs, setInsiderTradesByTicker]);
}, [clientRef, setInsiderTradesByTicker]);
const requestStockTechnicalIndicators = useCallback((symbol) => { const requestStockTechnicalIndicators = useCallback((symbol) => {
const normalized = typeof symbol === 'string' ? symbol.trim().toUpperCase() : ''; const normalized = typeof symbol === 'string' ? symbol.trim().toUpperCase() : '';
if (!normalized || !clientRef.current) return false; if (!normalized) return false;
return clientRef.current.send({ type: 'get_stock_technical_indicators', ticker: normalized }); return sendWs({ type: 'get_stock_technical_indicators', ticker: normalized });
}, [clientRef]); }, [sendWs]);
const requestStockRangeExplain = useCallback((symbol, startDate, endDate, articleIds = []) => { const requestStockRangeExplain = useCallback((symbol, startDate, endDate, articleIds = []) => {
const normalized = typeof symbol === 'string' ? symbol.trim().toUpperCase() : ''; const normalized = typeof symbol === 'string' ? symbol.trim().toUpperCase() : '';
if (!normalized || !startDate || !endDate) return false; if (!normalized || !startDate || !endDate) return false;
const wsSent = sendWs({ type: 'get_stock_range_explain', ticker: normalized, start_date: startDate, end_date: endDate, article_ids: Array.isArray(articleIds) ? articleIds : [] });
if (wsSent) {
return true;
}
if (hasDirectNewsService()) { if (hasDirectNewsService()) {
void fetchRangeExplainDirect(normalized, startDate, endDate, articleIds) void fetchRangeExplainDirect(normalized, startDate, endDate, articleIds)
.then((payload) => { .then((payload) => {
@@ -224,22 +236,23 @@ export function useStockDataRequests(clientRef, { setRequestStockHistory, setReq
})); }));
}) })
.catch((error) => { .catch((error) => {
console.error('Direct range explain fetch failed, falling back to websocket:', error); console.error('Direct range explain fetch failed:', error);
if (clientRef.current) {
clientRef.current.send({ type: 'get_stock_range_explain', ticker: normalized, start_date: startDate, end_date: endDate, article_ids: Array.isArray(articleIds) ? articleIds : [] });
}
}); });
return true; return true;
} }
if (!clientRef.current) return false; return false;
return clientRef.current.send({ type: 'get_stock_range_explain', ticker: normalized, start_date: startDate, end_date: endDate, article_ids: Array.isArray(articleIds) ? articleIds : [] }); }, [hasDirectNewsService, sendWs, setNewsByTicker]);
}, [clientRef, setNewsByTicker]);
const requestStockStory = useCallback((symbol, asOfDate = null) => { const requestStockStory = useCallback((symbol, asOfDate = null) => {
const normalized = typeof symbol === 'string' ? symbol.trim().toUpperCase() : ''; const normalized = typeof symbol === 'string' ? symbol.trim().toUpperCase() : '';
if (!normalized) return false; if (!normalized) return false;
const wsSent = sendWs({ type: 'get_stock_story', ticker: normalized, as_of_date: asOfDate });
if (wsSent) {
return true;
}
if (hasDirectNewsService()) { if (hasDirectNewsService()) {
void fetchStockStoryDirect(normalized, asOfDate) void fetchStockStoryDirect(normalized, asOfDate)
.then((payload) => { .then((payload) => {
@@ -258,22 +271,23 @@ export function useStockDataRequests(clientRef, { setRequestStockHistory, setReq
})); }));
}) })
.catch((error) => { .catch((error) => {
console.error('Direct story fetch failed, falling back to websocket:', error); console.error('Direct story fetch failed:', error);
if (clientRef.current) {
clientRef.current.send({ type: 'get_stock_story', ticker: normalized, as_of_date: asOfDate });
}
}); });
return true; return true;
} }
if (!clientRef.current) return false; return false;
return clientRef.current.send({ type: 'get_stock_story', ticker: normalized, as_of_date: asOfDate }); }, [hasDirectNewsService, sendWs, setNewsByTicker]);
}, [clientRef, setNewsByTicker]);
const requestStockSimilarDays = useCallback((symbol, date, topK = 8) => { const requestStockSimilarDays = useCallback((symbol, date, topK = 8) => {
const normalized = typeof symbol === 'string' ? symbol.trim().toUpperCase() : ''; const normalized = typeof symbol === 'string' ? symbol.trim().toUpperCase() : '';
if (!normalized || !date) return false; if (!normalized || !date) return false;
const wsSent = sendWs({ type: 'get_stock_similar_days', ticker: normalized, date, top_k: topK });
if (wsSent) {
return true;
}
if (hasDirectNewsService()) { if (hasDirectNewsService()) {
void fetchSimilarDaysDirect(normalized, date, topK) void fetchSimilarDaysDirect(normalized, date, topK)
.then((payload) => { .then((payload) => {
@@ -291,21 +305,17 @@ export function useStockDataRequests(clientRef, { setRequestStockHistory, setReq
})); }));
}) })
.catch((error) => { .catch((error) => {
console.error('Direct similar-days fetch failed, falling back to websocket:', error); console.error('Direct similar-days fetch failed:', error);
if (clientRef.current) {
clientRef.current.send({ type: 'get_stock_similar_days', ticker: normalized, date, top_k: topK });
}
}); });
return true; return true;
} }
if (!clientRef.current) return false; return false;
return clientRef.current.send({ type: 'get_stock_similar_days', ticker: normalized, date, top_k: topK }); }, [hasDirectNewsService, sendWs, setNewsByTicker]);
}, [clientRef, setNewsByTicker]);
const requestStockEnrich = useCallback((symbol, options = {}) => { const requestStockEnrich = useCallback((symbol, options = {}) => {
const normalized = typeof symbol === 'string' ? symbol.trim().toUpperCase() : ''; const normalized = typeof symbol === 'string' ? symbol.trim().toUpperCase() : '';
if (!normalized || !clientRef.current) return false; if (!normalized) return false;
const startDate = typeof options.startDate === 'string' ? options.startDate.trim() : ''; const startDate = typeof options.startDate === 'string' ? options.startDate.trim() : '';
const endDate = typeof options.endDate === 'string' ? options.endDate.trim() : ''; const endDate = typeof options.endDate === 'string' ? options.endDate.trim() : '';
if (!startDate || !endDate) return false; if (!startDate || !endDate) return false;
@@ -316,7 +326,7 @@ export function useStockDataRequests(clientRef, { setRequestStockHistory, setReq
maintenanceStatus: { running: true, error: null, updatedAt: new Date().toISOString(), stats: null } maintenanceStatus: { running: true, error: null, updatedAt: new Date().toISOString(), stats: null }
} }
})); }));
return clientRef.current.send({ return sendWs({
type: 'run_stock_enrich', type: 'run_stock_enrich',
ticker: normalized, ticker: normalized,
start_date: startDate, start_date: startDate,
@@ -328,7 +338,7 @@ export function useStockDataRequests(clientRef, { setRequestStockHistory, setReq
story_date: options.storyDate || null, story_date: options.storyDate || null,
target_date: options.targetDate || null target_date: options.targetDate || null
}); });
}, [clientRef, setNewsByTicker]); }, [sendWs, setNewsByTicker]);
// Register request functions with WebSocket connection hook // Register request functions with WebSocket connection hook
if (setRequestStockHistory) setRequestStockHistory(requestStockHistory); if (setRequestStockHistory) setRequestStockHistory(requestStockHistory);

View File

@@ -652,6 +652,7 @@ export function useWebSocketConnection({
type: 'success', type: 'success',
text: `${agentId} ${e.enabled ? '已启用' : '已禁用'} ${skillName}` text: `${agentId} ${e.enabled ? '已启用' : '已禁用'} ${skillName}`
}); });
clientRef.current?.send({ type: 'get_agent_skills', agent_id: agentId });
}, },
agent_local_skill_created: (e) => { agent_local_skill_created: (e) => {
@@ -660,6 +661,8 @@ export function useWebSocketConnection({
setAgentSkillsSavingKey(null); setAgentSkillsSavingKey(null);
if (!agentId || !skillName) return; if (!agentId || !skillName) return;
setAgentSkillsFeedback({ type: 'success', text: `${agentId} 已创建本地技能 ${skillName}` }); setAgentSkillsFeedback({ type: 'success', text: `${agentId} 已创建本地技能 ${skillName}` });
clientRef.current?.send({ type: 'get_agent_skills', agent_id: agentId });
clientRef.current?.send({ type: 'get_skill_detail', agent_id: agentId, skill_name: skillName });
}, },
agent_local_skill_updated: (e) => { agent_local_skill_updated: (e) => {
@@ -668,6 +671,7 @@ export function useWebSocketConnection({
setAgentSkillsSavingKey(null); setAgentSkillsSavingKey(null);
if (!agentId || !skillName) return; if (!agentId || !skillName) return;
setAgentSkillsFeedback({ type: 'success', text: `${agentId} 的本地技能 ${skillName} 已保存` }); setAgentSkillsFeedback({ type: 'success', text: `${agentId} 的本地技能 ${skillName} 已保存` });
clientRef.current?.send({ type: 'get_skill_detail', agent_id: agentId, skill_name: skillName });
}, },
agent_local_skill_deleted: (e) => { agent_local_skill_deleted: (e) => {
@@ -686,6 +690,7 @@ export function useWebSocketConnection({
return next; return next;
}); });
setAgentSkillsFeedback({ type: 'success', text: `${agentId} 的本地技能 ${skillName} 已删除` }); setAgentSkillsFeedback({ type: 'success', text: `${agentId} 的本地技能 ${skillName} 已删除` });
clientRef.current?.send({ type: 'get_agent_skills', agent_id: agentId });
}, },
agent_skill_removed: (e) => { agent_skill_removed: (e) => {
@@ -694,6 +699,7 @@ export function useWebSocketConnection({
setAgentSkillsSavingKey(null); setAgentSkillsSavingKey(null);
if (!agentId || !skillName) return; if (!agentId || !skillName) return;
setAgentSkillsFeedback({ type: 'success', text: `${agentId} 已移除共享技能 ${skillName}` }); setAgentSkillsFeedback({ type: 'success', text: `${agentId} 已移除共享技能 ${skillName}` });
clientRef.current?.send({ type: 'get_agent_skills', agent_id: agentId });
}, },
agent_workspace_file_loaded: (e) => { agent_workspace_file_loaded: (e) => {
@@ -716,6 +722,7 @@ export function useWebSocketConnection({
const filename = typeof e.filename === 'string' ? e.filename.trim() : ''; const filename = typeof e.filename === 'string' ? e.filename.trim() : '';
if (!agentId || !filename) return; if (!agentId || !filename) return;
setWorkspaceFileFeedback({ type: 'success', text: `${agentId}${filename} 已保存` }); setWorkspaceFileFeedback({ type: 'success', text: `${agentId}${filename} 已保存` });
clientRef.current?.send({ type: 'get_agent_workspace_file', agent_id: agentId, filename });
}, },
watchlist_updated: (e) => { watchlist_updated: (e) => {

View File

@@ -0,0 +1,147 @@
/**
* Dynamic Team API Service
*
* Provides methods for managing analyst team dynamically:
* - Create new analysts with custom configuration
* - Clone existing analysts
* - Remove analysts
* - List available analyst types
* - Get analyst information
*/
import { DYNAMIC_TEAM_ENDPOINTS } from "../config/constants";
/**
* Fetch wrapper with error handling
*/
async function fetchJson(url, options = {}) {
const response = await fetch(url, {
headers: {
"Content-Type": "application/json",
},
...options,
});
if (!response.ok) {
const error = await response.text();
throw new Error(`API error: ${response.status} - ${error}`);
}
return response.json();
}
/**
* Get all available analyst types (builtin + runtime registered)
* @returns {Promise<Array>} List of analyst types
*/
export async function listAnalystTypes() {
return fetchJson(DYNAMIC_TEAM_ENDPOINTS.listTypes());
}
/**
* Get personas from personas.yaml
* @returns {Promise<Object>} Personas configuration
*/
export async function getPersonas() {
return fetchJson(DYNAMIC_TEAM_ENDPOINTS.getPersonas());
}
/**
* Create a new analyst
* @param {string} runId - The run configuration ID
* @param {Object} config - Analyst configuration
* @param {string} config.agent_id - Unique identifier
* @param {string} config.analyst_type - Base type or custom identifier
* @param {Object} [config.persona] - Custom persona definition
* @param {string} [config.soul_md] - Custom SOUL.md content
* @param {string} [config.agents_md] - Custom AGENTS.md content
* @param {string} [config.profile_md] - Custom PROFILE.md content
* @param {string} [config.model_name] - Override default model
* @param {string[]} [config.skills] - List of skill IDs
* @param {string[]} [config.tags] - Classification tags
* @returns {Promise<Object>} Creation result
*/
export async function createAnalyst(runId, config) {
return fetchJson(DYNAMIC_TEAM_ENDPOINTS.createAnalyst(runId), {
method: "POST",
body: JSON.stringify(config),
});
}
/**
* Clone an existing analyst
* @param {string} runId - The run configuration ID
* @param {Object} config - Clone configuration
* @param {string} config.source_id - Source analyst ID
* @param {string} config.new_id - New analyst ID
* @param {string} [config.name] - New display name
* @param {string[]} [config.focus_additions] - Additional focus areas
* @param {string} [config.description_override] - New description
* @param {string} [config.model_name] - Override model
* @returns {Promise<Object>} Clone result
*/
export async function cloneAnalyst(runId, config) {
return fetchJson(DYNAMIC_TEAM_ENDPOINTS.cloneAnalyst(runId), {
method: "POST",
body: JSON.stringify(config),
});
}
/**
* Remove a dynamically created analyst
* @param {string} runId - The run configuration ID
* @param {string} agentId - The analyst to remove
* @returns {Promise<Object>} Removal result
*/
export async function removeAnalyst(runId, agentId) {
return fetchJson(DYNAMIC_TEAM_ENDPOINTS.removeAnalyst(runId, agentId), {
method: "DELETE",
});
}
/**
* Get information about a specific analyst
* @param {string} runId - The run configuration ID
* @param {string} agentId - The analyst ID
* @returns {Promise<Object>} Analyst information
*/
export async function getAnalystInfo(runId, agentId) {
return fetchJson(DYNAMIC_TEAM_ENDPOINTS.getAnalystInfo(runId, agentId));
}
/**
* Get a summary of the current analyst team
* @param {string} runId - The run configuration ID
* @returns {Promise<Object>} Team summary
*/
export async function getTeamSummary(runId) {
return fetchJson(DYNAMIC_TEAM_ENDPOINTS.getTeamSummary(runId));
}
/**
* Hook for using dynamic team API in React components
* @param {string} runId - The run configuration ID
* @returns {Object} API methods
*/
export function useDynamicTeamApi(runId) {
return {
listTypes: () => listAnalystTypes(),
getPersonas: () => getPersonas(),
createAnalyst: (config) => createAnalyst(runId, config),
cloneAnalyst: (config) => cloneAnalyst(runId, config),
removeAnalyst: (agentId) => removeAnalyst(runId, agentId),
getAnalystInfo: (agentId) => getAnalystInfo(runId, agentId),
getTeamSummary: () => getTeamSummary(runId),
};
}
// Default export for convenience
export default {
listAnalystTypes,
getPersonas,
createAnalyst,
cloneAnalyst,
removeAnalyst,
getAnalystInfo,
getTeamSummary,
useDynamicTeamApi,
};

View File

@@ -133,10 +133,7 @@ function buildRunScopedAgentPath(runId, agentId, suffix = '') {
return `/runs/${encodeURIComponent(runId)}/agents/${encodeURIComponent(agentId)}${suffix}`; return `/runs/${encodeURIComponent(runId)}/agents/${encodeURIComponent(agentId)}${suffix}`;
} }
/** /** Runtime agent routes use `/runs/{run_id}/agents/...`. */
* Runtime agent routes use `/runs/{run_id}/agents/...`.
* Legacy `/workspaces/...` routes are deprecated but remain for backward compatibility.
*/
export function fetchAgentProfile(runId, agentId) { export function fetchAgentProfile(runId, agentId) {
return safeFetch(CONTROL_API_BASE, buildRunScopedAgentPath(runId, agentId, '/profile')); return safeFetch(CONTROL_API_BASE, buildRunScopedAgentPath(runId, agentId, '/profile'));
} }

View File

@@ -20,7 +20,7 @@ describe('runtimeApi run-scoped agent routes', () => {
await fetchAgentProfile('20260330_123000', 'portfolio_manager'); await fetchAgentProfile('20260330_123000', 'portfolio_manager');
expect(fetchMock).toHaveBeenCalledWith( expect(fetchMock).toHaveBeenCalledWith(
expect.stringContaining('/workspaces/20260330_123000/agents/portfolio_manager/profile') expect.stringContaining('/runs/20260330_123000/agents/portfolio_manager/profile')
); );
}); });
@@ -35,7 +35,7 @@ describe('runtimeApi run-scoped agent routes', () => {
await updateAgentWorkspaceFile('20260330_123000', 'risk_manager', 'MEMORY.md', '# demo'); await updateAgentWorkspaceFile('20260330_123000', 'risk_manager', 'MEMORY.md', '# demo');
expect(fetchMock).toHaveBeenCalledWith( expect(fetchMock).toHaveBeenCalledWith(
expect.stringContaining('/workspaces/20260330_123000/agents/risk_manager/files/MEMORY.md'), expect.stringContaining('/runs/20260330_123000/agents/risk_manager/files/MEMORY.md'),
expect.objectContaining({ expect.objectContaining({
method: 'PUT', method: 'PUT',
body: '# demo' body: '# demo'

View File

@@ -64,13 +64,14 @@ export const buildRuntimeSummaryLabel = (runtimeConfig) => {
return null; return null;
} }
const scheduleMode = String(runtimeConfig.schedule_mode || "daily"); const rawScheduleMode = String(runtimeConfig.schedule_mode || "daily");
const scheduleMode = rawScheduleMode === "intraday" ? "interval" : rawScheduleMode;
const intervalMinutes = Number(runtimeConfig.interval_minutes || 60); const intervalMinutes = Number(runtimeConfig.interval_minutes || 60);
const triggerTime = String(runtimeConfig.trigger_time || "now"); const triggerTime = String(runtimeConfig.trigger_time || "now");
const maxCommCycles = Number(runtimeConfig.max_comm_cycles || 2); const maxCommCycles = Number(runtimeConfig.max_comm_cycles || 2);
if (scheduleMode === "intraday") { if (scheduleMode === "interval") {
return `调度 intraday / ${intervalMinutes}m / 讨论 ${maxCommCycles}`; return `调度 interval / ${intervalMinutes}m / 讨论 ${maxCommCycles}`;
} }
if (triggerTime.toLowerCase() === "now") { if (triggerTime.toLowerCase() === "now") {

View File

@@ -51,9 +51,9 @@ describe("runtimeControls", () => {
})).toBe("调度 daily / 09:30 ET / 讨论 3 轮"); })).toBe("调度 daily / 09:30 ET / 讨论 3 轮");
expect(buildRuntimeSummaryLabel({ expect(buildRuntimeSummaryLabel({
schedule_mode: "intraday", schedule_mode: "interval",
interval_minutes: 15, interval_minutes: 15,
max_comm_cycles: 2 max_comm_cycles: 2
})).toBe("调度 intraday / 15m / 讨论 2 轮"); })).toBe("调度 interval / 15m / 讨论 2 轮");
}); });
}); });

View File

@@ -1,4 +0,0 @@
{
"status": "failed",
"failedTests": []
}

View File

@@ -1,34 +0,0 @@
#!/usr/bin/env bash
# COMPATIBILITY_SURFACE: stable
# OWNER: ops-team
# SEE: docs/legacy-inventory.md#gateway-first-production-example
#
# Gateway-first production launch script.
# This is the current checked-in production example, running the gateway
# directly and proxying /ws instead of exposing every split FastAPI service.
# For split-service topology, see start-dev.sh and docs/current-architecture.md
set -euo pipefail
cd /root/code/evotraders
export PYTHONPATH=/root/code/evotraders/.pydeps:.
export TICKERS="${TICKERS:-AAPL,MSFT,GOOGL,AMZN,NVDA,META,TSLA,AMD,NFLX,AVGO,PLTR,COIN}"
# 技能沙盒配置(生产环境建议使用 docker
export SKILL_SANDBOX_MODE="${SKILL_SANDBOX_MODE:-docker}"
export SKILL_SANDBOX_IMAGE="${SKILL_SANDBOX_IMAGE:-python:3.11-slim}"
export SKILL_SANDBOX_MEMORY_LIMIT="${SKILL_SANDBOX_MEMORY_LIMIT:-512m}"
export SKILL_SANDBOX_CPU_LIMIT="${SKILL_SANDBOX_CPU_LIMIT:-1.0}"
export SKILL_SANDBOX_NETWORK="${SKILL_SANDBOX_NETWORK:-none}"
export SKILL_SANDBOX_TIMEOUT="${SKILL_SANDBOX_TIMEOUT:-60}"
# "production" here is an explicit deployment run label, not a required
# root-level runtime directory name.
exec python3 -m backend.main \
--mode live \
--config-name production \
--host 127.0.0.1 \
--port 8765 \
--trigger-time now \
--poll-interval 15

View File

@@ -1,168 +0,0 @@
#!/usr/bin/env python3
"""Verify documentation and script consistency.
This script checks that:
1. README.md mentions correct service ports
2. start-dev.sh starts services on documented ports
3. deploy/README.md is consistent with production scripts
4. Service ports match across all documentation
"""
from __future__ import annotations
import argparse
import re
import sys
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[1]
# Expected service ports (source of truth)
SERVICE_PORTS = {
"agent_service": 8000,
"trading_service": 8001,
"news_service": 8002,
"runtime_service": 8003,
"gateway_websocket": 8765,
}
def check_readme_ports() -> list[str]:
"""Check that README.md documents correct ports."""
errors = []
readme_path = PROJECT_ROOT / "README.md"
readme_content = readme_path.read_text(encoding="utf-8")
# Check for each service port mention
for service, port in SERVICE_PORTS.items():
port_patterns = [
f":{port}",
f"port {port}",
f"localhost:{port}",
]
found = any(pattern in readme_content for pattern in port_patterns)
if not found:
errors.append(f"README.md: Missing documentation for {service} on port {port}")
return errors
def check_start_dev_sh_ports() -> list[str]:
"""Check that start-dev.sh uses correct ports."""
errors = []
script_path = PROJECT_ROOT / "start-dev.sh"
script_content = script_path.read_text(encoding="utf-8")
# Check for port declarations in start_service calls
for service, port in SERVICE_PORTS.items():
if service == "gateway_websocket":
# Gateway uses --port flag
if f"--port {port}" not in script_content:
errors.append(f"start-dev.sh: Gateway not using port {port}")
else:
# Services use port parameter in start_service
pattern = rf'start_service\s+"{service}"\s+"[^"]+"\s+{port}'
if not re.search(pattern, script_content):
# Also check for explicit port mentions
if f"port {port}" not in script_content and f":{port}" not in script_content:
errors.append(f"start-dev.sh: {service} not using port {port}")
return errors
def check_deploy_readme_consistency() -> list[str]:
"""Check that deploy/README.md is consistent with scripts."""
errors = []
deploy_readme_path = PROJECT_ROOT / "deploy" / "README.md"
deploy_content = deploy_readme_path.read_text(encoding="utf-8")
# Check for gateway port consistency
if "127.0.0.1:8765" not in deploy_content:
errors.append("deploy/README.md: Gateway port 8765 not documented correctly")
# Check for production script reference
if "scripts/run_prod.sh" not in deploy_content:
errors.append("deploy/README.md: Missing reference to scripts/run_prod.sh")
return errors
def check_run_prod_sh_ports() -> list[str]:
"""Check that run_prod.sh uses correct ports."""
errors = []
script_path = PROJECT_ROOT / "scripts" / "run_prod.sh"
script_content = script_path.read_text(encoding="utf-8")
# Production script should use port 8765 for gateway
if "--port 8765" not in script_content:
errors.append("scripts/run_prod.sh: Not using gateway port 8765")
return errors
def check_service_main_blocks() -> list[str]:
"""Check that service modules use correct ports in __main__ blocks."""
errors = []
service_files = {
"agent_service": PROJECT_ROOT / "backend" / "apps" / "agent_service.py",
"trading_service": PROJECT_ROOT / "backend" / "apps" / "trading_service.py",
"news_service": PROJECT_ROOT / "backend" / "apps" / "news_service.py",
"runtime_service": PROJECT_ROOT / "backend" / "apps" / "runtime_service.py",
}
for service, file_path in service_files.items():
if not file_path.exists():
errors.append(f"{service}: File not found at {file_path}")
continue
content = file_path.read_text(encoding="utf-8")
expected_port = SERVICE_PORTS[service]
# Check for port= in uvicorn.run or app.run
if f"port={expected_port}" not in content and f"port= {expected_port}" not in content:
errors.append(f"{file_path}: Not using expected port {expected_port}")
return errors
def main() -> int:
parser = argparse.ArgumentParser(
description="Verify documentation and script consistency.",
)
parser.add_argument(
"--strict",
action="store_true",
help="Treat warnings as errors",
)
args = parser.parse_args()
all_errors = []
print("Checking README.md ports...")
all_errors.extend(check_readme_ports())
print("Checking start-dev.sh ports...")
all_errors.extend(check_start_dev_sh_ports())
print("Checking deploy/README.md consistency...")
all_errors.extend(check_deploy_readme_consistency())
print("Checking scripts/run_prod.sh ports...")
all_errors.extend(check_run_prod_sh_ports())
print("Checking service __main__ blocks...")
all_errors.extend(check_service_main_blocks())
if all_errors:
print("\nConsistency errors found:")
for error in all_errors:
print(f" - {error}")
return 1 if args.strict else 0
else:
print("\nAll consistency checks passed!")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -20,31 +20,19 @@ and the next-step execution plan lives at
| `backend.apps.trading_service` | `8001` | Read-only trading data APIs such as prices, financials, insider trades | | `backend.apps.trading_service` | `8001` | Read-only trading data APIs such as prices, financials, insider trades |
| `backend.apps.news_service` | `8002` | Read-only explain/news APIs such as story, similar days, range explain | | `backend.apps.news_service` | `8002` | Read-only explain/news APIs such as story, similar days, range explain |
| `backend.apps.runtime_service` | `8003` | Runtime lifecycle APIs under `/api/runtime/*` | | `backend.apps.runtime_service` | `8003` | Runtime lifecycle APIs under `/api/runtime/*` |
| Gateway (`backend.main`) | `8765` | WebSocket feed, runtime event stream, pipeline execution | | Gateway process | `8765` | WebSocket feed, runtime event stream, pipeline execution |
| OpenClaw Gateway | `18789` | External OpenClaw WebSocket endpoint consumed by 大时代 gateway | | OpenClaw Gateway | `18789` | External OpenClaw WebSocket endpoint consumed by 大时代 gateway |
## Runtime Modes ## Runtime Mode
### Standalone Mode (Direct Gateway Startup) For development and service isolation:
For simple deployments or backward compatibility:
```bash
python -m backend.main --mode live --host 0.0.0.0 --port 8765
```
In this mode, Gateway runs as the primary process with all components
(Pipeline, Market Service, Scheduler) loaded in-process.
### Microservice Mode (Recommended)
For development and production with service isolation:
```bash ```bash
./start-dev.sh ./start-dev.sh
``` ```
This starts all services with `runtime_service` managing the Gateway lifecycle. Today this script starts the split services and then relies on
`runtime_service` to spawn the Gateway during startup.
## What Runs By Default In Dev ## What Runs By Default In Dev
@@ -60,11 +48,9 @@ That script starts:
- `trading_service` on `8001` - `trading_service` on `8001`
- `news_service` on `8002` - `news_service` on `8002`
- `runtime_service` on `8003` - `runtime_service` on `8003`
- 大时代 gateway on `8765` (as subprocess of runtime_service) - 大时代 gateway on `8765` (spawned by `runtime_service`)
It does **not** start. It also expects an OpenClaw WebSocket server to already be
Instead, the gateway expects an OpenClaw WebSocket server to already be
available at `ws://localhost:18789` unless you override the OpenClaw gateway available at `ws://localhost:18789` unless you override the OpenClaw gateway
configuration outside the script. configuration outside the script.
@@ -79,8 +65,9 @@ python -m uvicorn backend.apps.news_service:app --host 0.0.0.0 --port 8002 --rel
python -m uvicorn backend.apps.runtime_service:app --host 0.0.0.0 --port 8003 --reload python -m uvicorn backend.apps.runtime_service:app --host 0.0.0.0 --port 8003 --reload
``` ```
The Gateway is started by `runtime_service` via the `/api/runtime/start` API, If you launch only the split FastAPI surfaces manually, `runtime_service` can
not manually. To start a runtime: start the Gateway via the `/api/runtime/start` API. To start a runtime in that
fully managed flow:
```bash ```bash
curl -X POST http://localhost:8003/api/runtime/start \ curl -X POST http://localhost:8003/api/runtime/start \
@@ -94,11 +81,6 @@ curl -X POST http://localhost:8003/api/runtime/start \
}' }'
``` ```
Optional OpenClaw REST surface:
```bash
```
## Runtime Responsibilities ## Runtime Responsibilities
The runtime path is intentionally split: The runtime path is intentionally split:
@@ -159,7 +141,7 @@ architecture. Its responsibilities:
``` ```
runtime_service (:8003) runtime_service (:8003)
| |
|-- spawns --> Gateway subprocess (:8765) |-- can spawn --> Gateway subprocess (:8765)
| |
|-- TradingPipeline |-- TradingPipeline
|-- MarketService |-- MarketService
@@ -167,8 +149,8 @@ runtime_service (:8003)
|-- WebSocket server |-- WebSocket server
``` ```
The Gateway subprocess runs `backend.gateway_server` module (not `backend.main`) The Gateway subprocess runs `backend.gateway_server` with run-specific
with run-specific configuration passed via CLI arguments. configuration passed by `runtime_service`.
## Environment Variables ## Environment Variables
@@ -221,13 +203,14 @@ Other flows still depend on the gateway WebSocket and control plane APIs.
## OpenClaw Integration Notes ## OpenClaw Integration Notes
There are two separate OpenClaw integration surfaces in this repo: OpenClaw currently appears through two concrete access patterns in this repo:
- OpenClaw WebSocket gateway on `:18789` - OpenClaw WebSocket gateway on `:18789`
- used directly by `backend/services/gateway.py` - used directly by `backend/services/gateway.py`
- this is what `start-dev.sh` assumes exists - this is what `start-dev.sh` assumes exists
- optional REST facade over OpenClaw CLI-backed reads - selected CLI-backed read helpers behind gateway handlers
- useful for typed client access and service-level testing - used for a small set of history/status style reads
- not a separate standalone REST service surface
Do not treat those as interchangeable in docs or deployment config. Do not treat those as interchangeable in docs or deployment config.
@@ -256,8 +239,6 @@ backend.apps.news_service
backend.gateway_server backend.gateway_server
└─ Gateway subprocess entry point (run-scoped) └─ Gateway subprocess entry point (run-scoped)
backend.main
└─ standalone Gateway entry point (compatibility)
``` ```
## Migration Boundaries ## Migration Boundaries
@@ -279,14 +260,14 @@ Also note the remaining naming split:
- Pipeline logic lives in Gateway process - Pipeline logic lives in Gateway process
- Gateway is spawned as subprocess by runtime_service - Gateway is spawned as subprocess by runtime_service
- Standalone mode (`backend.main`) preserved for compatibility - Historical direct gateway startup has been removed from the repository
### Target State ### Target State
- Pipeline stages become independent services - Pipeline stages become independent services
- Gateway becomes thin event router - Gateway becomes thin event router
- runtime_service becomes full orchestrator - runtime_service becomes full orchestrator
- Standalone mode deprecated and removed - Keep converging on the split-service startup model
See [docs/development-roadmap.md](../docs/development-roadmap.md) for detailed See [docs/development-roadmap.md](../docs/development-roadmap.md) for detailed
phase planning. phase planning.

View File

@@ -5,22 +5,16 @@
# #
# 启动模式说明: # 启动模式说明:
# ------------- # -------------
# 本脚本支持两种启动模式: # 本脚本支持当前开发主路径:
# # 启动 4 个独立服务 + 由 runtime_service 托管的 Gateway
# 1. 微服务模式 (默认) - 启动 4 个独立服务 + Gateway # - agent_service (端口 8000): Agent 生命周期管理
# 这是推荐的开发模式,各服务独立运行,便于单独调试和重启 # - runtime_service (端口 8003): 运行时配置和 Pipeline 执行
# - agent_service (端口 8000): Agent 生命周期管理 # - trading_service (端口 8001): 市场数据和交易操作
# - runtime_service (端口 8003): 运行时配置和 Pipeline 执行 # - news_service (端口 8002): 新闻采集和富化
# - trading_service (端口 8001): 市场数据和交易操作 # - gateway (端口 8765): 由 runtime_service 拉起的 WebSocket 网关
# - news_service (端口 8002): 新闻采集和富化
# - gateway (端口 8765): WebSocket 网关,前端连接入口
#
# 2. 独立模式 (--standalone) - 仅启动 Gateway
# Gateway 内部会自行管理服务,适合快速验证或资源受限环境
# #
# 用法: # 用法:
# ./start-dev.sh # 启动微服务模式 # ./start-dev.sh # 启动开发环境
# ./start-dev.sh --standalone # 启动独立模式
# ./start-dev.sh --help # 显示帮助信息 # ./start-dev.sh --help # 显示帮助信息
# #
@@ -51,9 +45,6 @@ readonly NC='\033[0m' # No Color
# 进程 ID 数组 # 进程 ID 数组
PIDS=() PIDS=()
# 启动模式: "microservices" 或 "standalone"
MODE="microservices"
# ============================================ # ============================================
# 工具函数 # 工具函数
# ============================================ # ============================================
@@ -87,25 +78,18 @@ show_help() {
大时代 Development Startup Script 大时代 Development Startup Script
用法: 用法:
./start-dev.sh [选项] ./start-dev.sh [选项]
选项: 选项:
--standalone 以独立模式启动(仅启动 Gateway内部管理服务
--help, -h 显示此帮助信息 --help, -h 显示此帮助信息
模式说明: 开发模式:
启动 4 个独立微服务 + 托管 Gateway各服务独立进程便于单独调试
微服务模式 (默认): - agent_service: http://localhost:8000 (Agent 生命周期)
启动 4 个独立微服务 + Gateway各服务独立进程便于单独调试 - trading_service: http://localhost:8001 (市场数据)
- agent_service: http://localhost:8000 (Agent 生命周期) - news_service: http://localhost:8002 (新闻服务)
- trading_service: http://localhost:8001 (市场数据) - runtime_service: http://localhost:8003 (运行时管理)
- news_service: http://localhost:8002 (新闻服务) - gateway: ws://localhost:8765 (由 runtime_service 托管)
- runtime_service: http://localhost:8003 (运行时管理)
- gateway: ws://localhost:8765 (WebSocket 网关)
独立模式 (--standalone):
仅启动 Gateway由 Gateway 内部自行管理服务
适合快速验证或资源受限环境
环境要求: 环境要求:
- Python 3.9+ - Python 3.9+
@@ -113,8 +97,7 @@ show_help() {
- .env 文件 (可选但推荐) - .env 文件 (可选但推荐)
示例: 示例:
./start-dev.sh # 启动微服务模式 ./start-dev.sh # 启动开发环境
./start-dev.sh --standalone # 启动独立模式
EOF EOF
} }
@@ -125,10 +108,6 @@ EOF
parse_args() { parse_args() {
while [[ $# -gt 0 ]]; do while [[ $# -gt 0 ]]; do
case "$1" in case "$1" in
--standalone)
MODE="standalone"
shift
;;
--help|-h) --help|-h)
show_help show_help
exit 0 exit 0
@@ -249,13 +228,7 @@ check_env_file() {
check_ports() { check_ports() {
log_step "检查端口占用情况..." log_step "检查端口占用情况..."
local ports=() local ports=($AGENT_SERVICE_PORT $TRADING_SERVICE_PORT $NEWS_SERVICE_PORT $RUNTIME_SERVICE_PORT $GATEWAY_PORT)
if [[ "$MODE" == "microservices" ]]; then
ports=($AGENT_SERVICE_PORT $TRADING_SERVICE_PORT $NEWS_SERVICE_PORT $RUNTIME_SERVICE_PORT $GATEWAY_PORT)
else
ports=($GATEWAY_PORT)
fi
local occupied=() local occupied=()
for port in "${ports[@]}"; do for port in "${ports[@]}"; do
@@ -340,16 +313,88 @@ start_service() {
PIDS+=($!) PIDS+=($!)
} }
start_gateway() { wait_for_runtime_service() {
log_step "启动 Gateway (WebSocket 服务)..." log_step "等待 runtime_service 就绪..."
log_info "Gateway 将作为子进程启动 (端口 ${GATEWAY_PORT})"
log_info "前端连接地址: ws://localhost:${GATEWAY_PORT}"
SERVICE_NAME="gateway" python -m backend.main \ local runtime_url="http://127.0.0.1:${RUNTIME_SERVICE_PORT}/health"
--mode live \ local attempts=30
--host 0.0.0.0 \
--port "$GATEWAY_PORT" & for ((i=1; i<=attempts; i++)); do
PIDS+=($!) if python - <<PY >/dev/null 2>&1; then
import urllib.request
with urllib.request.urlopen("${runtime_url}", timeout=1.5) as resp:
raise SystemExit(0 if resp.status == 200 else 1)
PY
log_info "runtime_service 已就绪: ${runtime_url}"
return 0
fi
sleep 1
done
log_error "runtime_service 未在预期时间内就绪"
return 1
}
start_managed_runtime() {
log_step "通过 runtime_service 创建默认运行时..."
local runtime_api="http://127.0.0.1:${RUNTIME_SERVICE_PORT}/api/runtime/start"
if ! python - <<PY; then
import json
import os
import sys
import urllib.request
tickers_env = os.getenv("TICKERS", "")
tickers = [item.strip().upper() for item in tickers_env.split(",") if item.strip()]
if not tickers:
tickers = ["AAPL", "MSFT", "GOOGL", "AMZN", "NVDA", "META", "TSLA", "AMD", "NFLX", "AVGO", "PLTR", "COIN"]
def _env_int(name: str, default: int) -> int:
value = os.getenv(name, "").strip()
return int(value) if value else default
def _env_float(name: str, default: float) -> float:
value = os.getenv(name, "").strip()
return float(value) if value else default
payload = {
"launch_mode": "fresh",
"tickers": tickers,
"schedule_mode": os.getenv("SCHEDULE_MODE", "daily").strip() or "daily",
"interval_minutes": _env_int("INTERVAL_MINUTES", 60),
"trigger_time": os.getenv("TRIGGER_TIME", "09:30").strip() or "09:30",
"max_comm_cycles": _env_int("MAX_COMM_CYCLES", 2),
"initial_cash": _env_float("INITIAL_CASH", 100000.0),
"margin_requirement": _env_float("MARGIN_REQUIREMENT", 0.0),
"enable_memory": os.getenv("ENABLE_MEMORY", "").strip().lower() in {"1", "true", "yes", "on"},
"mode": os.getenv("RUNTIME_MODE", "live").strip() or "live",
"poll_interval": _env_int("POLL_INTERVAL", 10),
}
data = json.dumps(payload).encode("utf-8")
req = urllib.request.Request(
"${runtime_api}",
data=data,
headers={"Content-Type": "application/json"},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=30) as resp:
body = json.loads(resp.read().decode("utf-8"))
except Exception as exc:
print(f"FAILED: {exc}", file=sys.stderr)
raise
print(json.dumps(body, ensure_ascii=False))
PY
log_error "通过 runtime_service 创建运行时失败"
return 1
fi
log_info "默认运行时已创建Gateway 将由 runtime_service 托管"
} }
# ============================================ # ============================================
@@ -366,7 +411,7 @@ start_microservices_mode() {
echo -e " runtime_service: http://localhost:${RUNTIME_SERVICE_PORT}" echo -e " runtime_service: http://localhost:${RUNTIME_SERVICE_PORT}"
echo -e " trading_service: http://localhost:${TRADING_SERVICE_PORT}" echo -e " trading_service: http://localhost:${TRADING_SERVICE_PORT}"
echo -e " news_service: http://localhost:${NEWS_SERVICE_PORT}" echo -e " news_service: http://localhost:${NEWS_SERVICE_PORT}"
echo -e " gateway: ws://localhost:${GATEWAY_PORT}" echo -e " gateway: ws://localhost:${GATEWAY_PORT} (由 runtime_service 拉起)"
echo -e "${CYAN}==========================================${NC}" echo -e "${CYAN}==========================================${NC}"
echo "" echo ""
@@ -390,8 +435,8 @@ start_microservices_mode() {
start_service "trading_service" "backend.apps.trading_service:app" "$TRADING_SERVICE_PORT" start_service "trading_service" "backend.apps.trading_service:app" "$TRADING_SERVICE_PORT"
start_service "news_service" "backend.apps.news_service:app" "$NEWS_SERVICE_PORT" start_service "news_service" "backend.apps.news_service:app" "$NEWS_SERVICE_PORT"
# 启动 Gateway作为子进程 wait_for_runtime_service
start_gateway start_managed_runtime
echo "" echo ""
log_info "所有服务已启动" log_info "所有服务已启动"
@@ -399,30 +444,6 @@ start_microservices_mode() {
echo "" echo ""
} }
# ============================================
# 独立模式启动
# ============================================
start_standalone_mode() {
log_step "启动独立模式..."
echo ""
echo -e "${CYAN}==========================================${NC}"
echo -e "${CYAN} 独立模式 ${NC}"
echo -e "${CYAN}==========================================${NC}"
echo -e " gateway: ws://localhost:${GATEWAY_PORT}"
echo -e "${CYAN}==========================================${NC}"
echo ""
log_info "Gateway 将内部管理服务"
# 启动 Gateway独立模式
start_gateway
echo ""
log_info "Gateway 已启动(独立模式)"
log_info "按 Ctrl+C 停止服务"
echo ""
}
# ============================================ # ============================================
# 清理与信号处理 # 清理与信号处理
# ============================================ # ============================================
@@ -480,12 +501,7 @@ main() {
echo -e "${GREEN}==========================================${NC}" echo -e "${GREEN}==========================================${NC}"
echo "" echo ""
# 根据模式启动服务 start_microservices_mode
if [[ "$MODE" == "standalone" ]]; then
start_standalone_mode
else
start_microservices_mode
fi
# 等待所有后台进程 # 等待所有后台进程
wait wait

View File

@@ -6,7 +6,6 @@
# ./start.sh # 构建前端 + 后台启动全部服务 (默认) # ./start.sh # 构建前端 + 后台启动全部服务 (默认)
# ./start.sh --no-build # 跳过前端构建 # ./start.sh --no-build # 跳过前端构建
# ./start.sh --no-daemon # 前台运行 (不使用 nohup) # ./start.sh --no-daemon # 前台运行 (不使用 nohup)
# ./start.sh --gateway-only # 仅启动 Gateway (配合 nginx)
# ./start.sh stop # 停止所有后台服务 # ./start.sh stop # 停止所有后台服务
# ./start.sh status # 查看服务状态 # ./start.sh status # 查看服务状态
# #
@@ -37,14 +36,12 @@ FRONTEND_DIST="${SCRIPT_DIR}/frontend/dist"
DAEMON=true DAEMON=true
BUILD_FRONTEND=true BUILD_FRONTEND=true
GATEWAY_ONLY=false
ACTION="start" ACTION="start"
for arg in "$@"; do for arg in "$@"; do
case "$arg" in case "$arg" in
--no-daemon) DAEMON=false ;; --no-daemon) DAEMON=false ;;
--no-build) BUILD_FRONTEND=false ;; --no-build) BUILD_FRONTEND=false ;;
--gateway-only) GATEWAY_ONLY=true ;;
stop) ACTION="stop" ;; stop) ACTION="stop" ;;
status) ACTION="status" ;; status) ACTION="status" ;;
*) echo -e "${YELLOW}忽略未知参数: ${arg}${NC}" ;; *) echo -e "${YELLOW}忽略未知参数: ${arg}${NC}" ;;
@@ -164,7 +161,7 @@ do_status() {
print_status "runtime_service" 8003 print_status "runtime_service" 8003
print_status "frontend" "${FRONTEND_PORT}" print_status "frontend" "${FRONTEND_PORT}"
echo "" echo ""
echo -e " ${CYAN}${NC} Gateway 由 runtime_service 管理,通过前端启动任务触发" echo -e " ${CYAN}${NC} Gateway 由 runtime_service 管理,运行日志写入 runs/<run_id>/logs/gateway.log"
echo "" echo ""
if [ -d "${FRONTEND_DIST}" ]; then if [ -d "${FRONTEND_DIST}" ]; then
@@ -208,12 +205,10 @@ start_single_daemon() {
} }
start_daemon() { start_daemon() {
if ! ${GATEWAY_ONLY}; then start_single_daemon "agent_service" "backend.apps.agent_service:app" 8000
start_single_daemon "agent_service" "backend.apps.agent_service:app" 8000 start_single_daemon "trading_service" "backend.apps.trading_service:app" 8001
start_single_daemon "trading_service" "backend.apps.trading_service:app" 8001 start_single_daemon "news_service" "backend.apps.news_service:app" 8002
start_single_daemon "news_service" "backend.apps.news_service:app" 8002 start_single_daemon "runtime_service" "backend.apps.runtime_service:app" 8003
start_single_daemon "runtime_service" "backend.apps.runtime_service:app" 8003
fi
echo -e " ${GREEN}${NC} frontend → http://0.0.0.0:${FRONTEND_PORT}" echo -e " ${GREEN}${NC} frontend → http://0.0.0.0:${FRONTEND_PORT}"
nohup env SERVICE_NAME="frontend" "${PYTHON}" -m uvicorn "backend.apps.frontend_service:app" \ nohup env SERVICE_NAME="frontend" "${PYTHON}" -m uvicorn "backend.apps.frontend_service:app" \
@@ -231,7 +226,8 @@ start_daemon() {
echo " PID 目录: ${PID_DIR}/" echo " PID 目录: ${PID_DIR}/"
echo "" echo ""
echo " 查看状态: ./start.sh status" echo " 查看状态: ./start.sh status"
echo " 查看日志: tail -f ${LOG_DIR}/gateway.log" echo " 查看服务日志: tail -f ${LOG_DIR}/runtime_service.log"
echo " 查看运行日志: tail -f runs/<run_id>/logs/gateway.log"
echo " 停止服务: ./start.sh stop" echo " 停止服务: ./start.sh stop"
echo "" echo ""
} }
@@ -261,12 +257,10 @@ start_single_foreground() {
start_foreground() { start_foreground() {
trap cleanup_foreground EXIT INT TERM trap cleanup_foreground EXIT INT TERM
if ! ${GATEWAY_ONLY}; then start_single_foreground "agent_service" "backend.apps.agent_service:app" 8000
start_single_foreground "agent_service" "backend.apps.agent_service:app" 8000 start_single_foreground "trading_service" "backend.apps.trading_service:app" 8001
start_single_foreground "trading_service" "backend.apps.trading_service:app" 8001 start_single_foreground "news_service" "backend.apps.news_service:app" 8002
start_single_foreground "news_service" "backend.apps.news_service:app" 8002 start_single_foreground "runtime_service" "backend.apps.runtime_service:app" 8003
start_single_foreground "runtime_service" "backend.apps.runtime_service:app" 8003
fi
echo -e " ${GREEN}${NC} frontend → http://0.0.0.0:${FRONTEND_PORT}" echo -e " ${GREEN}${NC} frontend → http://0.0.0.0:${FRONTEND_PORT}"
env SERVICE_NAME="frontend" "${PYTHON}" -m uvicorn "backend.apps.frontend_service:app" \ env SERVICE_NAME="frontend" "${PYTHON}" -m uvicorn "backend.apps.frontend_service:app" \
@@ -326,4 +320,3 @@ case "${ACTION}" in
exit 1 exit 1
;; ;;
esac esac