Add dynamic analyst runtime updates and deployment guides
This commit is contained in:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user