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

@@ -310,11 +310,12 @@ class EvoAgent(ToolGuardMixin, ReActAgent):
)
logger.debug("Registered workspace watch hook")
async def _reasoning(self, **kwargs) -> Msg:
async def _reasoning(self, tool_choice: Optional[str] = None, **kwargs) -> Msg:
"""Override reasoning to execute pre-reasoning hooks.
Args:
**kwargs: Arguments for reasoning
tool_choice: Optional tool choice for structured output
**kwargs: Additional arguments for reasoning
Returns:
Response message
@@ -327,7 +328,7 @@ class EvoAgent(ToolGuardMixin, ReActAgent):
)
# Call parent (which may be ToolGuardMixin's _reasoning)
return await super()._reasoning(**kwargs)
return await super()._reasoning(tool_choice=tool_choice, **kwargs)
def reload_runtime_assets(self, active_skill_dirs: Optional[List[Path]] = None) -> None:
"""Reload toolkit and system prompt from current run assets.
@@ -579,7 +580,7 @@ class EvoAgent(ToolGuardMixin, ReActAgent):
return
try:
self._messenger = AgentMessenger(agent_id=self.agent_id)
self._messenger = AgentMessenger()
self._task_delegator = TaskDelegator(agent=self)
logger.debug(
"Team infrastructure initialized for agent: %s",

View File

@@ -312,12 +312,21 @@ class RunWorkspaceManager:
"- 审阅分析以理解市场观点\n"
"- 在做决策前先考虑风险警告\n"
"- 评估当前投资组合持仓、现金与保证金占用\n"
"- 在做最终决策前,先判断当前团队是否足以覆盖任务;如果覆盖不足,不要勉强给结论,先扩编团队\n"
"- 当现有团队覆盖不足、观点分歧过大、或出现新的专业分析需求时,优先考虑动态创建合适的分析师,再继续讨论\n"
"- 决策必须与整体投资目标和风险约束一致\n\n"
"动态扩编触发条件:\n"
"- 出现当前团队未覆盖的研究领域:期权、宏观、行业专项、事件驱动、监管冲击、加密资产、商品链、特殊市场结构\n"
"- 关键 ticker 的结论依赖某种专业知识,但现有 analyst 无法提供直接证据链\n"
"- 分析师之间存在明显冲突,且仅靠风险经理无法完成裁决\n"
"- 你需要第二个同类型但不同风格的 analyst 来验证一个高风险假设\n\n"
"决策类型:\n"
'- `long`:看涨,建议买入\n'
'- `short`:看跌,建议卖出或做空\n'
'- `hold`:中性,维持当前持仓\n\n'
"输出要求:\n"
"- 触发扩编条件时,必须先使用动态团队工具创建分析师,并在继续决策前吸收其分析输入\n"
"- 不允许口头声称“需要更多分析”但不实际调用创建工具\n"
"- 使用 `make_decision` 工具记录每个股票的最终决策\n"
"- 记录完成后给出投资逻辑总结\n"
"- 最终总结必须使用简体中文\n"
@@ -327,6 +336,10 @@ class RunWorkspaceManager:
"- 在决定数量时考虑可用现金,不要超出现金允许范围\n"
"- 考虑做空头寸的保证金要求\n"
"- 仓位规模相对于组合总资产保持保守\n"
"- 当任务涉及当前团队未覆盖的领域(如期权、宏观、行业专项、事件驱动、加密资产等)时,应优先创建或克隆对应分析师,而不是勉强用现有团队输出低质量结论\n"
"- 当分析师之间长期存在高冲突且缺乏裁决信息时,应考虑增加一个补充视角的分析师\n"
"- 如果你已经识别出覆盖缺口,却没有调用动态团队工具补齐团队,就不应直接输出高置信度交易决策\n"
"- 对新创建分析师的输出必须纳入本轮决策依据,不能创建后忽略\n"
"- 始终为决策提供清晰理由\n"
"- 不要输出英文投资报告或英文结论\n"
)

View File

@@ -7,6 +7,7 @@ import asyncio
import json
import logging
import os
import re
import shutil
import subprocess
import sys
@@ -154,6 +155,7 @@ class RunContextResponse(BaseModel):
class RuntimeAgentState(BaseModel):
agent_id: str
display_name: Optional[str] = None
status: str
last_session: Optional[str] = None
last_updated: str
@@ -300,6 +302,70 @@ def _load_run_server_state(run_dir: Path) -> Dict[str, Any]:
return {}
def _resolve_runtime_agent_display_name(run_id: str, agent_id: str) -> Optional[str]:
"""Best-effort display name for one runtime agent.
Priority:
1. PROFILE.md line like `角色定位:中文名`
2. PROFILE.md YAML frontmatter field `name`
"""
asset_dir = PROJECT_ROOT / "runs" / run_id / "agents" / agent_id
profile_path = asset_dir / "PROFILE.md"
if not profile_path.exists():
return None
try:
raw = profile_path.read_text(encoding="utf-8").strip()
except Exception:
return None
if not raw:
return None
frontmatter_name: Optional[str] = None
if raw.startswith("---"):
parts = raw.split("---", 2)
if len(parts) >= 3:
try:
import yaml
parsed = yaml.safe_load(parts[1].strip()) or {}
if isinstance(parsed, dict):
value = parsed.get("name")
if isinstance(value, str) and value.strip():
frontmatter_name = value.strip()
except Exception:
pass
raw = parts[2].strip()
for line in raw.splitlines():
normalized = line.strip()
if normalized.startswith("角色定位:"):
value = normalized.split("", 1)[1].strip()
if value:
return value
if normalized.lower().startswith("role:"):
value = normalized.split(":", 1)[1].strip()
if value:
return value
return frontmatter_name
def _enrich_runtime_agents(run_id: Optional[str], agents: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
if not run_id:
return agents
enriched: List[Dict[str, Any]] = []
for item in agents:
payload = dict(item)
display_name = payload.get("display_name")
agent_id = str(payload.get("agent_id") or "").strip()
if agent_id and (not isinstance(display_name, str) or not display_name.strip()):
payload["display_name"] = _resolve_runtime_agent_display_name(run_id, agent_id)
enriched.append(payload)
return enriched
def _extract_history_metrics(run_dir: Path) -> tuple[int, Optional[float]]:
"""Prefer runtime state files over dashboard exports for history summaries."""
server_state = _load_run_server_state(run_dir)
@@ -566,10 +632,11 @@ def _validate_gateway_config(bootstrap: Dict[str, Any]) -> List[str]:
# Check LLM configuration
model_name = os.getenv("MODEL_NAME")
openai_key = os.getenv("OPENAI_API_KEY")
dashscope_key = os.getenv("DASHSCOPE_API_KEY")
if not model_name:
errors.append("MODEL_NAME environment variable is not set")
if not openai_key:
errors.append("OPENAI_API_KEY environment variable is not set")
if not openai_key and not dashscope_key:
errors.append("Either OPENAI_API_KEY or DASHSCOPE_API_KEY environment variable must be set")
# Validate tickers
tickers = bootstrap.get("tickers", [])
@@ -722,7 +789,8 @@ async def get_run_context() -> RunContextResponse:
async def get_runtime_agents() -> RuntimeAgentsResponse:
"""Return agent states from the active runtime, or latest persisted run."""
snapshot = _get_active_runtime_snapshot() if _is_gateway_running() else _load_latest_runtime_snapshot()
agents = snapshot.get("agents", [])
run_id = snapshot.get("context", {}).get("config_name")
agents = _enrich_runtime_agents(run_id, snapshot.get("agents", []))
return RuntimeAgentsResponse(
agents=[RuntimeAgentState(**a) for a in agents]
@@ -869,11 +937,24 @@ def _load_latest_runtime_snapshot() -> Dict[str, Any]:
def _get_active_runtime_snapshot() -> Dict[str, Any]:
"""Return the active runtime snapshot, preferring in-memory manager state."""
"""Return the active runtime snapshot.
For a running Gateway, the canonical runtime source of truth is the
run-scoped snapshot file under `runs/<run_id>/state/runtime_state.json`,
because the Gateway subprocess mutates it directly while the parent
runtime_service process may still hold a stale in-memory manager snapshot.
"""
if not _is_gateway_running():
raise HTTPException(status_code=404, detail="No runtime is currently running")
manager = _runtime_state.runtime_manager
if manager is not None:
run_id = str(getattr(manager, "config_name", "") or "").strip()
if run_id:
snapshot_path = _get_run_dir(run_id) / "state" / "runtime_state.json"
if snapshot_path.exists():
return json.loads(snapshot_path.read_text(encoding="utf-8"))
if manager is not None and hasattr(manager, "build_snapshot"):
snapshot = manager.build_snapshot()
context = snapshot.get("context") or {}
@@ -900,11 +981,32 @@ def _read_log_tail(path: Path, max_chars: int = 120_000) -> str:
if not path.exists() or not path.is_file():
return ""
text = path.read_text(encoding="utf-8", errors="replace")
text = _sanitize_runtime_log_text(text)
if len(text) <= max_chars:
return text
return text[-max_chars:]
def _sanitize_runtime_log_text(text: str) -> str:
if not text:
return ""
# Drop repetitive development-only warnings for unsandboxed skill execution.
text = re.sub(
r"(?:^|\n)=+\n"
r"⚠️\s+\[安全警告\]\s+技能在无沙盒模式下运行\s+\(SKILL_SANDBOX_MODE=none\)\n"
r"\s+技能脚本将直接在当前进程中执行,无隔离保护。\n"
r"\s+建议:生产环境请设置\s+SKILL_SANDBOX_MODE=docker\n"
r"=+\n?",
"\n",
text,
flags=re.MULTILINE,
)
text = re.sub(r"\n{3,}", "\n\n", text)
return text.strip()
def _get_current_runtime_context() -> Dict[str, Any]:
"""Return the active runtime context from the latest snapshot."""
if not _is_gateway_running():

View File

@@ -27,8 +27,10 @@ valuation_analyst:
portfolio_manager:
skills:
- portfolio_decisioning
- dynamic_team_management
active_tool_groups:
- portfolio_ops
- dynamic_team
risk_manager:
skills:

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(

View File

@@ -135,6 +135,7 @@ class StateSync:
self,
agent_id: str,
content: str,
agent_name: Optional[str] = None,
**extra,
):
"""
@@ -151,6 +152,7 @@ class StateSync:
{
"type": "agent_message",
"agentId": agent_id,
"agentName": agent_name,
"content": content,
"ts": ts_ms,
**extra,
@@ -212,7 +214,12 @@ class StateSync:
persist=False,
)
async def on_conference_message(self, agent_id: str, content: str):
async def on_conference_message(
self,
agent_id: str,
content: str,
agent_name: Optional[str] = None,
):
"""Called when an agent speaks during conference"""
ts_ms = self._get_timestamp_ms()
@@ -220,6 +227,7 @@ class StateSync:
{
"type": "conference_message",
"agentId": agent_id,
"agentName": agent_name,
"content": content,
"ts": ts_ms,
},

View File

@@ -8,6 +8,7 @@ from typing import Any, Dict
@dataclass
class AgentRuntimeState:
agent_id: str
display_name: str | None = None
status: str = "idle"
last_session: str | None = None
last_updated: datetime = field(default_factory=lambda: datetime.now(UTC))
@@ -20,6 +21,7 @@ class AgentRuntimeState:
def to_dict(self) -> Dict[str, Any]:
return {
"agent_id": self.agent_id,
"display_name": self.display_name,
"status": self.status,
"last_session": self.last_session,
"last_updated": self.last_updated.isoformat(),

View File

@@ -102,12 +102,22 @@ class TradingRuntimeManager:
self._persist_snapshot()
return entry
def register_agent(self, agent_id: str) -> AgentRuntimeState:
state = AgentRuntimeState(agent_id=agent_id)
def register_agent(
self,
agent_id: str,
display_name: Optional[str] = None,
) -> AgentRuntimeState:
state = AgentRuntimeState(agent_id=agent_id, display_name=display_name)
self.registry.register(agent_id, state)
self._persist_snapshot()
return state
def unregister_agent(self, agent_id: str) -> Optional[AgentRuntimeState]:
state = self.registry.unregister(agent_id)
if state is not None:
self._persist_snapshot()
return state
def register_pending_approval(self, approval_id: str, payload: Dict[str, Any]) -> None:
payload.setdefault("status", "pending")
payload.setdefault("created_at", datetime.now(UTC).isoformat())

View File

@@ -13,6 +13,9 @@ class RuntimeRegistry:
def get(self, agent_id: str) -> Optional["AgentRuntimeState"]:
return self._states.get(agent_id)
def unregister(self, agent_id: str) -> Optional["AgentRuntimeState"]:
return self._states.pop(agent_id, None)
def list_agents(self) -> list[str]:
return list(self._states.keys())

View File

@@ -23,15 +23,17 @@ version: 1.0.0
## 3) Decision procedure
1. 汇总并比较 analyst 信号,识别共识与分歧。
2. 将风险警示映射到仓位上限与禁开条件
3. 在资金与保证金约束下,为每个 ticker 生成候选动作与数量
4. 对冲突信号执行保守仲裁:降低仓位、提高触发门槛或改为 `hold`
5. 逐个 ticker 记录最终决策,并给出组合级理由
2. 先判断当前团队是否覆盖了本轮任务所需的专业能力;若未覆盖,优先扩编团队而不是直接仲裁
3. 将风险警示映射到仓位上限与禁开条件
4. 在资金与保证金约束下,为每个 ticker 生成候选动作与数量
5. 对冲突信号执行保守仲裁:降低仓位、提高触发门槛、补充 analyst或改为 `hold`
6. 逐个 ticker 记录最终决策,并给出组合级理由。
## 4) Tool call policy
- 必须使用决策工具记录每个 ticker 的最终 `action/quantity`
- 在讨论阶段如发现当前团队能力不足,可使用团队工具动态创建或移除 analyst再继续讨论
- 在讨论阶段如发现当前团队能力不足、证据链断裂、或观点冲突无法裁决,必须优先使用团队工具动态创建或克隆 analyst再继续讨论
- 如果已经判断“需要更多专业分析”,但没有调用动态团队工具补齐团队,则不得输出高置信度最终决策。
- 若风险工具提示阻断项,优先遵循阻断,不得绕过。
- 工具调用失败时:重试一次;仍失败则输出结构化“未完成决策清单”和人工处理建议。
@@ -46,5 +48,6 @@ version: 1.0.0
## 6) Failure fallback
- 当分析师信号与风险结论显著冲突时,默认采用更小仓位或 `hold`
- 当任务明显超出当前团队能力边界时,优先扩编团队;如果扩编失败,再降级为 `hold` 或条件决策草案。
- 当约束校验失败(现金/保证金不足)时,自动下调数量,不输出不可执行指令。
- 当任务要求完整清单时,不允许遗漏 ticker无法决策时必须显式标记 `hold` 并说明原因。

View File

@@ -10,12 +10,15 @@ description: 整合分析师观点与风险反馈,形成明确的组合层决
## 工作流程
1. 行动前先阅读分析师结论和风险警示。
2. 评估当前组合、现金和保证金约束
3. 使用决策工具为每个 ticker 记录一个明确决策
4. 在全部决策记录完成后,总结组合层面的整体理由
2. 先判断当前团队是否足以覆盖本轮任务;如果不够,先扩编团队再继续
3. 评估当前组合、现金和保证金约束
4. 使用决策工具为每个 ticker 记录一个明确决策
5. 在全部决策记录完成后,总结组合层面的整体理由。
## 约束
- 仓位大小必须遵守资金和保证金限制。
- 当分析师信心与风险信号不一致时,优先采用更小仓位。
- 当任务超出当前团队能力边界时,应优先使用动态团队工具创建或克隆分析师。
- 如果已经识别出覆盖缺口,不应跳过扩编步骤直接给出高置信度结论。
- 当任务要求完整决策清单时,不要让任何 ticker 处于未决状态。

View File

@@ -13,8 +13,11 @@ as described in the Dynamic Team Architecture.
"""
from __future__ import annotations
import json
from typing import Any, Dict, List, Optional, Callable
from dataclasses import asdict
from agentscope.message import TextBlock
from agentscope.tool import ToolResponse
from backend.agents.dynamic_team_types import (
AnalystPersona,
@@ -22,7 +25,7 @@ from backend.agents.dynamic_team_types import (
CreateAnalystResult,
AnalystTypeInfo,
)
from backend.config.constants import ANALYST_TYPES
from backend.config.constants import ANALYST_TYPES, AGENT_CONFIG
# Type alias for callbacks set by pipeline
@@ -30,6 +33,14 @@ CreateAnalystCallback = Callable[[str, str, Optional[AnalystConfig]], str]
RemoveAnalystCallback = Callable[[str], str]
def _to_tool_response(payload: Any) -> ToolResponse:
if isinstance(payload, str):
text = payload
else:
text = json.dumps(payload, ensure_ascii=False, indent=2, default=str)
return ToolResponse(content=[TextBlock(type="text", text=text)])
class DynamicTeamController:
"""Controller for dynamic analyst team management.
@@ -296,6 +307,23 @@ class DynamicTeamController:
Dict with analyst configuration and status
"""
config = self._instance_configs.get(agent_id)
current_analysts = self._get_analysts_callback() if self._get_analysts_callback else []
analyst_map = {
(getattr(agent, "name", None) or getattr(agent, "agent_id", None)): agent
for agent in current_analysts
}
if agent_id in analyst_map and not config:
builtin_meta = AGENT_CONFIG.get(agent_id, {})
return {
"found": True,
"agent_id": agent_id,
"name": builtin_meta.get("name") or agent_id,
"type": agent_id,
"is_custom": False,
"is_clone": False,
"is_builtin": True,
"message": f"Built-in analyst '{agent_id}' is active",
}
if not config:
return {
"found": False,
@@ -310,6 +338,7 @@ class DynamicTeamController:
"is_custom": config.persona is not None,
"is_clone": config.parent_id is not None,
"parent_id": config.parent_id,
"is_builtin": False,
}
def register_analyst_type(
@@ -372,13 +401,26 @@ class DynamicTeamController:
Dict with team composition information
"""
analysts = []
for agent_id, config in self._instance_configs.items():
current_analysts = self._get_analysts_callback() if self._get_analysts_callback else []
instance_configs = self._instance_configs
for agent in current_analysts:
agent_id = getattr(agent, "name", None) or getattr(agent, "agent_id", None)
if not agent_id:
continue
config = instance_configs.get(agent_id)
builtin_meta = AGENT_CONFIG.get(agent_id, {})
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,
"name": (
config.persona.name
if config and config.persona and config.persona.name
else builtin_meta.get("name") or agent_id
),
"type": config.analyst_type if config else agent_id,
"is_custom": bool(config and config.persona is not None),
"is_clone": bool(config and config.parent_id is not None),
"is_builtin": config is None,
})
return {
@@ -418,91 +460,95 @@ def get_controller() -> Optional[DynamicTeamController]:
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]:
name: str = "",
focus: str = "",
description: str = "",
soul_md: str = "",
agents_md: str = "",
model_name: str = "",
) -> ToolResponse:
"""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"}
return _to_tool_response({"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,
return _to_tool_response(
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]:
name: str = "",
focus_additions: str = "",
description_override: str = "",
model_name: str = "",
) -> ToolResponse:
"""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"}
return _to_tool_response({"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,
return _to_tool_response(
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]:
def remove_analyst(agent_id: str) -> ToolResponse:
"""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)
return _to_tool_response({"success": False, "error": "Dynamic team controller not available"})
return _to_tool_response(controller.remove_analyst(agent_id))
def list_analyst_types() -> List[Dict[str, Any]]:
def list_analyst_types() -> ToolResponse:
"""Tool wrapper for list_analyst_types."""
controller = get_controller()
if not controller:
return []
return controller.list_analyst_types()
return _to_tool_response([])
return _to_tool_response(controller.list_analyst_types())
def get_analyst_info(agent_id: str) -> Dict[str, Any]:
def get_analyst_info(agent_id: str) -> ToolResponse:
"""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)
return _to_tool_response({"found": False, "error": "Controller not available"})
return _to_tool_response(controller.get_analyst_info(agent_id))
def get_team_summary() -> Dict[str, Any]:
def get_team_summary() -> ToolResponse:
"""Tool wrapper for get_team_summary."""
controller = get_controller()
if not controller:
return {"error": "Controller not available"}
return controller.get_team_summary()
return _to_tool_response({"error": "Controller not available"})
return _to_tool_response(controller.get_team_summary())
__all__ = [

View File

@@ -19,7 +19,6 @@
import json
import logging
import os
import warnings
from abc import ABC, abstractmethod
from typing import Any
@@ -71,7 +70,6 @@ class NoSandboxBackend(SandboxBackend):
def __init__(self):
self._module_cache = {}
self._warning_shown = False
def _get_script_name(self, function_name: str) -> str:
"""
@@ -96,19 +94,6 @@ class NoSandboxBackend(SandboxBackend):
) -> dict:
"""直接导入模块并执行函数"""
# 首次使用时显示安全警告
if not self._warning_shown:
warnings.warn(
"\n" + "=" * 60 + "\n"
"⚠️ [安全警告] 技能在无沙盒模式下运行 (SKILL_SANDBOX_MODE=none)\n"
" 技能脚本将直接在当前进程中执行,无隔离保护。\n"
" 建议:生产环境请设置 SKILL_SANDBOX_MODE=docker\n"
"=" * 60,
RuntimeWarning,
stacklevel=2,
)
self._warning_shown = True
logger.debug(f"[NoSandbox] 执行技能: {skill_name}.{function_name}")
try:
@@ -345,13 +330,13 @@ class SkillSandbox:
self._backend = self._create_backend()
self._initialized = True
logger.info(f"SkillSandbox 初始化完成,模式: {self.mode}")
logger.debug(f"SkillSandbox 初始化完成,模式: {self.mode}")
def _create_backend(self) -> SandboxBackend:
"""根据模式创建对应后端"""
if self.mode == "none":
logger.info("使用无沙盒模式(直接执行)")
logger.debug("使用无沙盒模式(直接执行)")
return NoSandboxBackend()
elif self.mode == "docker":