Add dynamic analyst runtime updates and deployment guides

This commit is contained in:
2026-04-07 09:39:37 +08:00
parent 80ce63da5a
commit 62c7341cf6
45 changed files with 1886 additions and 159 deletions

View File

@@ -6,11 +6,13 @@ Core Pipeline - Orchestrates multi-agent analysis and decision-making
# flake8: noqa: E501
# pylint: disable=W0613,C0301
import asyncio
import json
import logging
import os
import re
from contextlib import nullcontext
from datetime import datetime
from pathlib import Path
from typing import Any, Awaitable, Callable, Dict, List, Optional
@@ -32,7 +34,7 @@ from backend.agents.toolkit_factory import create_agent_toolkit
from backend.agents.workspace_manager import WorkspaceManager
from backend.agents.prompt_loader import get_prompt_loader
from backend.llm.models import get_agent_formatter, get_agent_model
from backend.config.constants import ANALYST_TYPES
from backend.config.constants import ANALYST_TYPES, AGENT_CONFIG
from backend.agents.dynamic_team_types import AnalystConfig
from backend.tools.dynamic_team_tools import DynamicTeamController, set_controller
@@ -230,8 +232,25 @@ class TradingPipeline:
"system",
),
):
# Phase 1.1: Analysts
# Phase 1.0: PM assesses team coverage and expands if needed
if not last_phase or last_phase == "cleared":
_log("Phase 1.0: Team gap assessment")
await self._run_team_gap_assessment(
tickers=tickers,
date=date,
prices=prices,
)
active_analysts = self._get_active_analysts()
if self.runtime_manager:
self._runtime_batch_status(active_analysts, "analysis_in_progress")
self._save_checkpoint(session_key, "team_assessment", {
"prices": prices,
"close_prices": close_prices,
})
last_phase = "team_assessment"
# Phase 1.1: Analysts
if last_phase == "team_assessment":
_log("Phase 1.1: Analyst analysis (parallel)")
analyst_results = await self._run_analysts_parallel(
tickers,
@@ -754,6 +773,7 @@ class TradingPipeline:
await self.state_sync.on_agent_complete(
agent_id="Daily Log",
content=reflection_content,
agent_name="每日记录",
)
# Phase 6: APO (Autonomous Policy Optimization)
@@ -1020,12 +1040,13 @@ class TradingPipeline:
pm_msg = Msg(name="system", content=pm_prompt, role="user")
pm_response = await self.pm.reply(pm_msg)
pm_content = self._extract_text_content(pm_response.content)
if self.state_sync:
pm_content = self._extract_text_content(pm_response.content)
await self.state_sync.on_conference_message(
agent_id="portfolio_manager",
content=pm_content,
agent_name=self._resolve_agent_display_name("portfolio_manager"),
)
# Analysts share perspectives (supports per-round active team updates)
@@ -1050,6 +1071,7 @@ class TradingPipeline:
await self.state_sync.on_conference_message(
agent_id=analyst.name,
content=analyst_content,
agent_name=self._resolve_agent_display_name(analyst.name),
)
if self.state_sync:
@@ -1082,6 +1104,7 @@ class TradingPipeline:
await self.state_sync.on_conference_message(
agent_id="conference summary",
content=conference_summary,
agent_name="会议总结",
)
await self.state_sync.on_conference_end()
@@ -1139,6 +1162,116 @@ class TradingPipeline:
f"and any remaining concerns about {', '.join(tickers)}."
)
async def _run_team_gap_assessment(
self,
*,
tickers: List[str],
date: str,
prices: Optional[Dict[str, float]],
) -> str:
active_analysts = self._get_active_analysts()
team_summary = [
{
"agent_id": analyst.name,
"display_name": self._resolve_agent_display_name(analyst.name),
}
for analyst in active_analysts
]
prompt = (
f"As Portfolio Manager, perform a team coverage assessment before analysis for {date}.\n\n"
f"Tickers: {', '.join(tickers)}\n"
f"Current team: {json.dumps(team_summary, ensure_ascii=False, indent=2)}\n"
f"Current prices snapshot: {json.dumps(prices, ensure_ascii=False, indent=2) if prices else 'N/A'}\n\n"
"Your job in this phase is not to make investment decisions. "
"First decide whether the current team has enough domain coverage. "
"If the team is insufficient, immediately call dynamic team tools to create or clone the needed analysts now. "
"Before creating any analyst, explicitly check whether an existing analyst already covers that role. "
"Do not create duplicate roles with different IDs but the same responsibilities. "
"If the current team is sufficient, explicitly say the current team is sufficient and explain why."
)
msg = Msg(name="system", content=prompt, role="user")
response = await self.pm.reply(msg)
pm_content = self._extract_text_content(response.content)
enforced_pm_content = await self._enforce_pm_team_expansion_if_needed(
tickers=tickers,
date=date,
pm_content=pm_content,
)
if enforced_pm_content:
pm_content = enforced_pm_content
if self.state_sync:
await self.state_sync.on_agent_complete(
agent_id="portfolio_manager",
agent_name=self._resolve_agent_display_name("portfolio_manager"),
content=pm_content,
)
return pm_content
def _pm_requests_team_expansion(self, text: str) -> bool:
normalized = (text or "").strip().lower()
if not normalized:
return False
phrases = [
"创建",
"新增分析师",
"补充分析师",
"扩编团队",
"需要行业分析师",
"需要量化分析师",
"需要宏观分析师",
"需要补充",
"先扩编",
"create analyst",
"create a new analyst",
"add analyst",
"expand the team",
"need a specialist",
"need another analyst",
]
return any(phrase in normalized for phrase in phrases)
async def _enforce_pm_team_expansion_if_needed(
self,
*,
tickers: List[str],
date: str,
pm_content: str,
) -> Optional[str]:
if not self._pm_requests_team_expansion(pm_content):
return None
before_ids = {agent.name for agent in self._get_active_analysts()}
followup_prompt = (
f"You identified a team coverage gap for {date} across {', '.join(tickers)}. "
"This is still the pre-analysis team assessment phase. "
"Do not merely recommend adding analysts. If additional analysts are needed, "
"you must now call the dynamic team tools (`create_analyst` or `clone_analyst`) "
"to add the required specialists before analyst analysis begins. "
"Only after the tool call succeeds may you explain why the new analysts were added. "
"If you truly believe the current team is sufficient, explicitly say the current team is sufficient."
)
followup_msg = Msg(name="system", content=followup_prompt, role="user")
followup_response = await self.pm.reply(followup_msg)
followup_content = self._extract_text_content(followup_response.content)
after_ids = {agent.name for agent in self._get_active_analysts()}
if after_ids != before_ids:
created = sorted(after_ids - before_ids)
logger.info(
"PM team expansion enforced successfully before analysis; added analysts=%s",
created,
)
else:
logger.info(
"PM mentioned expansion in team assessment but did not add analysts after enforcement prompt",
)
return followup_content
def _build_analyst_discussion_prompt(
self,
cycle: int,
@@ -1152,6 +1285,88 @@ class TradingPipeline:
f"Do not use tools - focus on sharing your professional opinion."
)
def _resolve_agent_display_name(self, agent_id: str) -> str:
runtime_name = None
if self.runtime_manager:
state = self.runtime_manager.get_agent_state(agent_id)
runtime_name = getattr(state, "display_name", None) if state else None
if isinstance(runtime_name, str) and runtime_name.strip():
return runtime_name.strip()
static_name = AGENT_CONFIG.get(agent_id, {}).get("name")
if isinstance(static_name, str) and static_name.strip():
return static_name.strip()
profile_path = Path(__file__).resolve().parents[2] / "runs" / self.runtime_manager.config_name / "agents" / agent_id / "PROFILE.md" if self.runtime_manager else None
if profile_path and profile_path.exists():
try:
raw = profile_path.read_text(encoding="utf-8")
for line in raw.splitlines():
text = line.strip()
if text.startswith("角色定位:"):
value = text.split("", 1)[1].strip()
if value:
return value
except Exception:
pass
return agent_id
@staticmethod
def _normalize_role_key(value: str) -> str:
normalized = (value or "").strip().lower()
normalized = normalized.replace("_", "")
normalized = normalized.replace(" ", "")
replacements = {
"analyst": "分析师",
"macro": "宏观",
"technical": "技术",
"tech": "技术",
"sentiment": "情绪",
"fundamentals": "基本面",
"fundamental": "基本面",
"valuation": "估值",
"crypto": "加密",
"cryptocurrency": "加密",
"semiconductor": "半导体",
"industry": "行业",
"sector": "行业",
"risk": "风险",
}
for src, target in replacements.items():
normalized = normalized.replace(src, target)
return normalized
@staticmethod
def _contains_cjk(value: str) -> bool:
text = (value or "").strip()
return any("\u4e00" <= ch <= "\u9fff" for ch in text)
def _find_similar_existing_analyst(
self,
*,
agent_id: str,
analyst_type: str,
custom_config: Optional[AnalystConfig],
) -> Optional[str]:
requested_names = {self._normalize_role_key(agent_id)}
if custom_config and custom_config.persona and custom_config.persona.name:
requested_names.add(self._normalize_role_key(custom_config.persona.name))
for agent in self._all_analysts():
existing_id = getattr(agent, "name", None) or getattr(agent, "agent_id", None)
if not existing_id or existing_id == agent_id:
continue
existing_names = {
self._normalize_role_key(existing_id),
self._normalize_role_key(self._resolve_agent_display_name(existing_id)),
}
if requested_names & existing_names:
return existing_id
return None
async def _collect_final_predictions(
self,
tickers: List[str],
@@ -1300,6 +1515,7 @@ class TradingPipeline:
await self.state_sync.on_agent_complete(
agent_id=analyst.name,
content=text_content,
agent_name=self._resolve_agent_display_name(analyst.name),
)
return results
@@ -1375,6 +1591,7 @@ class TradingPipeline:
await self.state_sync.on_agent_complete(
agent_id=analyst.name,
content=text_content,
agent_name=self._resolve_agent_display_name(analyst.name),
)
else:
logger.warning(
@@ -1456,6 +1673,7 @@ class TradingPipeline:
await self.state_sync.on_agent_complete(
agent_id="risk_manager",
content=text_content,
agent_name=self._resolve_agent_display_name("risk_manager"),
)
return extracted
@@ -1542,6 +1760,7 @@ class TradingPipeline:
await self.state_sync.on_agent_complete(
agent_id="portfolio_manager",
content=text_content,
agent_name=self._resolve_agent_display_name("portfolio_manager"),
)
return extracted
@@ -1776,8 +1995,29 @@ class TradingPipeline:
f"Available: {', '.join(ANALYST_TYPES.keys())}. "
f"Or provide custom_config to create a custom analyst."
)
display_name = (
custom_config.persona.name
if custom_config and custom_config.persona and custom_config.persona.name
else ""
)
if not self._contains_cjk(display_name):
return (
f"Analyst '{agent_id}' requires a Chinese display name. "
"Please provide `name` in Chinese characters when creating dynamic analysts."
)
if agent_id in {agent.name for agent in self._all_analysts()}:
return f"Analyst '{agent_id}' already exists."
similar_existing = self._find_similar_existing_analyst(
agent_id=agent_id,
analyst_type=analyst_type,
custom_config=custom_config,
)
if similar_existing:
return (
f"Analyst '{agent_id}' is too similar to existing analyst "
f"'{similar_existing}'. Reuse or clone the existing analyst instead of "
f"creating a duplicate role."
)
config_name = getattr(self.pm, "config", {}).get("config_name", "default")
project_root = Path(__file__).resolve().parents[2]
@@ -1860,6 +2100,48 @@ class TradingPipeline:
setattr(agent, "workspace_id", config_name)
self._dynamic_analysts[agent_id] = agent
if self.runtime_manager:
display_name = None
if custom_config and custom_config.persona and custom_config.persona.name:
display_name = custom_config.persona.name
self.runtime_manager.register_agent(
agent_id,
display_name=display_name,
)
self.runtime_manager.log_event(
"agent:created",
{
"agent_id": agent_id,
"analyst_type": analyst_type,
"display_name": display_name,
},
)
logger.info(
"Dynamic analyst created: agent_id=%s analyst_type=%s custom=%s",
agent_id,
analyst_type,
bool(custom_config),
)
if self.state_sync:
try:
asyncio.create_task(
self.state_sync.emit(
{
"type": "runtime_agents_updated",
"action": "created",
"agentId": agent_id,
"agentName": display_name or self._resolve_agent_display_name(agent_id),
},
persist=False,
)
)
except Exception as exc:
logger.warning(
"Failed to broadcast runtime_agents_updated(create) for %s: %s",
agent_id,
exc,
)
# Store custom config for future reference (e.g., cloning)
if custom_config:
self._dynamic_analyst_configs[agent_id] = custom_config
@@ -1879,6 +2161,31 @@ class TradingPipeline:
self._dynamic_analysts.pop(agent_id, None)
# Also remove stored config if exists
self._dynamic_analyst_configs.pop(agent_id, None)
if self.runtime_manager:
self.runtime_manager.unregister_agent(agent_id)
self.runtime_manager.log_event(
"agent:removed",
{"agent_id": agent_id},
)
logger.info("Dynamic analyst removed: agent_id=%s", agent_id)
if self.state_sync:
try:
asyncio.create_task(
self.state_sync.emit(
{
"type": "runtime_agents_updated",
"action": "removed",
"agentId": agent_id,
},
persist=False,
)
)
except Exception as exc:
logger.warning(
"Failed to broadcast runtime_agents_updated(remove) for %s: %s",
agent_id,
exc,
)
config_name = getattr(self.pm, "config", {}).get("config_name", "default")
project_root = Path(__file__).resolve().parents[2]
update_active_analysts(