feat: Add agent workspace system and runtime management
- Add agent core modules (agent_core, factory, registry, skill_loader) - Add runtime system for agent execution management - Add REST API for agents, workspaces, and runtime control - Add process supervisor for agent lifecycle management - Add workspace template system with agent profiles - Add frontend RuntimeView and runtime API integration - Add per-agent skill workspaces for smoke_fullstack run - Refactor skill system with active/installed separation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -96,8 +96,11 @@ evotraders live # 立即运行(默认)
|
|||||||
evotraders live --enable-memory # 使用记忆
|
evotraders live --enable-memory # 使用记忆
|
||||||
evotraders live --mock # Mock 模式(测试)
|
evotraders live --mock # Mock 模式(测试)
|
||||||
evotraders live -t 22:30 # 每天本地时间 22:30 运行(自动转换为 NYSE 时区)
|
evotraders live -t 22:30 # 每天本地时间 22:30 运行(自动转换为 NYSE 时区)
|
||||||
|
evotraders live --schedule-mode intraday --interval-minutes 60 # 每隔 1 小时触发一次;仅交易时段执行交易,其他时段只分析
|
||||||
```
|
```
|
||||||
|
|
||||||
|
前端的“运行设置”面板也支持热更新 `schedule_mode`、`interval_minutes`、`max_comm_cycles`;其中 daily 模式时间当前按 NYSE/ET 配置。
|
||||||
|
|
||||||
**获取帮助:**
|
**获取帮助:**
|
||||||
```bash
|
```bash
|
||||||
evotraders --help # 查看整体命令行帮助
|
evotraders --help # 查看整体命令行帮助
|
||||||
|
|||||||
@@ -1,6 +1,57 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
Agents package - EvoAgent architecture for trading system.
|
||||||
|
|
||||||
|
Exports:
|
||||||
|
- EvoAgent: Next-generation agent with workspace support
|
||||||
|
- ToolGuardMixin: Tool call approval/denial flow
|
||||||
|
- CommandHandler: System command handling
|
||||||
|
- AgentFactory: Dynamic agent creation and management
|
||||||
|
- WorkspaceManager: Legacy name for the persistent workspace registry
|
||||||
|
- WorkspaceRegistry: Explicit run-time-agnostic workspace registry
|
||||||
|
- RunWorkspaceManager: Run-scoped workspace asset manager
|
||||||
|
- AgentRegistry: Central agent registry
|
||||||
|
- Legacy compatibility: AnalystAgent, PMAgent, RiskAgent
|
||||||
|
"""
|
||||||
|
|
||||||
|
# New EvoAgent architecture (from agent_core.py)
|
||||||
|
from .agent_core import EvoAgent, ToolGuardMixin, CommandHandler
|
||||||
|
from .factory import AgentFactory, ModelConfig, RoleConfig
|
||||||
|
from .workspace import WorkspaceManager, WorkspaceRegistry, WorkspaceConfig
|
||||||
|
from .workspace_manager import RunWorkspaceManager
|
||||||
|
from .registry import AgentRegistry, AgentInfo, get_registry, reset_registry
|
||||||
|
|
||||||
|
# Legacy agents (backward compatibility)
|
||||||
from .analyst import AnalystAgent
|
from .analyst import AnalystAgent
|
||||||
from .portfolio_manager import PMAgent
|
from .portfolio_manager import PMAgent
|
||||||
from .risk_manager import RiskAgent
|
from .risk_manager import RiskAgent
|
||||||
|
|
||||||
__all__ = ["AnalystAgent", "PMAgent", "RiskAgent"]
|
# Compatibility layer
|
||||||
|
from .compat import LegacyAgentAdapter, adapt_agent, adapt_agents, is_legacy_agent
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
# New architecture
|
||||||
|
"EvoAgent",
|
||||||
|
"ToolGuardMixin",
|
||||||
|
"CommandHandler",
|
||||||
|
"AgentFactory",
|
||||||
|
"ModelConfig",
|
||||||
|
"RoleConfig",
|
||||||
|
"WorkspaceManager",
|
||||||
|
"WorkspaceRegistry",
|
||||||
|
"WorkspaceConfig",
|
||||||
|
"RunWorkspaceManager",
|
||||||
|
"AgentRegistry",
|
||||||
|
"AgentInfo",
|
||||||
|
"get_registry",
|
||||||
|
"reset_registry",
|
||||||
|
# Legacy compatibility
|
||||||
|
"AnalystAgent",
|
||||||
|
"PMAgent",
|
||||||
|
"RiskAgent",
|
||||||
|
# Compatibility layer
|
||||||
|
"LegacyAgentAdapter",
|
||||||
|
"adapt_agent",
|
||||||
|
"adapt_agents",
|
||||||
|
"is_legacy_agent",
|
||||||
|
]
|
||||||
|
|||||||
18
backend/agents/agent_core.py
Normal file
18
backend/agents/agent_core.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
Compatibility layer for legacy imports.
|
||||||
|
|
||||||
|
This module re-exports the newer base implementations so existing import
|
||||||
|
paths (`from backend.agents.agent_core import EvoAgent`) continue to work while
|
||||||
|
centralizing the actual logic in `backend.agents.base.evo_agent`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from .base.command_handler import CommandHandler
|
||||||
|
from .base.evo_agent import EvoAgent
|
||||||
|
from .base.tool_guard import ToolGuardMixin
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"EvoAgent",
|
||||||
|
"ToolGuardMixin",
|
||||||
|
"CommandHandler",
|
||||||
|
]
|
||||||
23
backend/agents/base/__init__.py
Normal file
23
backend/agents/base/__init__.py
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Base agent module for EvoTraders.
|
||||||
|
|
||||||
|
提供Agent基础类、命令处理、工具守卫和钩子管理等功能。
|
||||||
|
"""
|
||||||
|
|
||||||
|
# 命令处理器 (从command_handler.py导入)
|
||||||
|
from .command_handler import (
|
||||||
|
AgentCommandDispatcher,
|
||||||
|
CommandContext,
|
||||||
|
CommandHandler,
|
||||||
|
CommandResult,
|
||||||
|
create_command_dispatcher,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
# 命令处理
|
||||||
|
"AgentCommandDispatcher",
|
||||||
|
"CommandContext",
|
||||||
|
"CommandHandler",
|
||||||
|
"CommandResult",
|
||||||
|
"create_command_dispatcher",
|
||||||
|
]
|
||||||
543
backend/agents/base/command_handler.py
Normal file
543
backend/agents/base/command_handler.py
Normal file
@@ -0,0 +1,543 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Agent command handler for system commands.
|
||||||
|
|
||||||
|
This module handles system commands like /save, /compact, /skills, /reload, etc.
|
||||||
|
参考CoPaw设计,为EvoAgent提供命令处理能力。
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Protocol
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from .agent import EvoAgent
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CommandResult:
|
||||||
|
"""命令执行结果"""
|
||||||
|
success: bool
|
||||||
|
message: str
|
||||||
|
data: Dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class CommandContext:
|
||||||
|
"""命令执行上下文"""
|
||||||
|
|
||||||
|
def __init__(self, agent: "EvoAgent", raw_query: str, args: str = ""):
|
||||||
|
self.agent = agent
|
||||||
|
self.raw_query = raw_query
|
||||||
|
self.args = args
|
||||||
|
self.config_name = getattr(agent, "config_name", "default")
|
||||||
|
self.agent_id = getattr(agent, "agent_id", "unknown")
|
||||||
|
|
||||||
|
|
||||||
|
class CommandHandler(ABC):
|
||||||
|
"""命令处理器抽象基类"""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def handle(self, ctx: CommandContext) -> CommandResult:
|
||||||
|
"""处理命令"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class SaveCommandHandler(CommandHandler):
|
||||||
|
"""处理 /save <message> 命令 - 保存内容到MEMORY.md"""
|
||||||
|
|
||||||
|
async def handle(self, ctx: CommandContext) -> CommandResult:
|
||||||
|
message = ctx.args.strip()
|
||||||
|
if not message:
|
||||||
|
return CommandResult(
|
||||||
|
success=False,
|
||||||
|
message="Usage: /save <message>\n请提供要保存的内容。"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
memory_path = self._get_memory_path(ctx)
|
||||||
|
memory_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
timestamp = self._get_timestamp()
|
||||||
|
entry = f"\n## {timestamp}\n\n{message}\n"
|
||||||
|
|
||||||
|
with open(memory_path, "a", encoding="utf-8") as f:
|
||||||
|
f.write(entry)
|
||||||
|
|
||||||
|
return CommandResult(
|
||||||
|
success=True,
|
||||||
|
message=f"✅ 内容已保存到 MEMORY.md\n- 路径: {memory_path}\n- 长度: {len(message)} 字符",
|
||||||
|
data={"path": str(memory_path), "length": len(message)}
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to save to MEMORY.md: {e}")
|
||||||
|
return CommandResult(
|
||||||
|
success=False,
|
||||||
|
message=f"❌ 保存失败: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _get_memory_path(self, ctx: CommandContext) -> Path:
|
||||||
|
"""获取MEMORY.md路径"""
|
||||||
|
from backend.agents.skills_manager import SkillsManager
|
||||||
|
sm = SkillsManager()
|
||||||
|
asset_dir = sm.get_agent_asset_dir(ctx.config_name, ctx.agent_id)
|
||||||
|
return asset_dir / "MEMORY.md"
|
||||||
|
|
||||||
|
def _get_timestamp(self) -> str:
|
||||||
|
"""获取当前时间戳"""
|
||||||
|
from datetime import datetime
|
||||||
|
return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
|
||||||
|
|
||||||
|
class CompactCommandHandler(CommandHandler):
|
||||||
|
"""处理 /compact 命令 - 压缩记忆"""
|
||||||
|
|
||||||
|
async def handle(self, ctx: CommandContext) -> CommandResult:
|
||||||
|
try:
|
||||||
|
agent = ctx.agent
|
||||||
|
memory_manager = getattr(agent, "memory_manager", None)
|
||||||
|
|
||||||
|
if memory_manager is None:
|
||||||
|
return CommandResult(
|
||||||
|
success=False,
|
||||||
|
message="❌ Memory Manager 未启用\n\n- 记忆压缩功能不可用\n- 请在配置中启用 memory_manager"
|
||||||
|
)
|
||||||
|
|
||||||
|
messages = await self._get_messages(agent)
|
||||||
|
if not messages:
|
||||||
|
return CommandResult(
|
||||||
|
success=False,
|
||||||
|
message="⚠️ 没有可压缩的消息\n\n- 当前记忆为空\n- 无需执行压缩"
|
||||||
|
)
|
||||||
|
|
||||||
|
compact_content = await memory_manager.compact_memory(messages)
|
||||||
|
await self._update_compressed_summary(agent, compact_content)
|
||||||
|
|
||||||
|
return CommandResult(
|
||||||
|
success=True,
|
||||||
|
message=f"✅ 记忆压缩完成\n\n- 压缩了 {len(messages)} 条消息\n- 摘要长度: {len(compact_content)} 字符",
|
||||||
|
data={"message_count": len(messages), "summary_length": len(compact_content)}
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to compact memory: {e}")
|
||||||
|
return CommandResult(
|
||||||
|
success=False,
|
||||||
|
message=f"❌ 压缩失败: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _get_messages(self, agent: "EvoAgent") -> List[Any]:
|
||||||
|
"""获取Agent的记忆消息"""
|
||||||
|
memory = getattr(agent, "memory", None)
|
||||||
|
if memory is None:
|
||||||
|
return []
|
||||||
|
return await memory.get_memory() if hasattr(memory, "get_memory") else []
|
||||||
|
|
||||||
|
async def _update_compressed_summary(self, agent: "EvoAgent", content: str) -> None:
|
||||||
|
"""更新压缩摘要"""
|
||||||
|
memory = getattr(agent, "memory", None)
|
||||||
|
if memory and hasattr(memory, "update_compressed_summary"):
|
||||||
|
await memory.update_compressed_summary(content)
|
||||||
|
|
||||||
|
|
||||||
|
class SkillsListCommandHandler(CommandHandler):
|
||||||
|
"""处理 /skills list 命令 - 列出已激活技能"""
|
||||||
|
|
||||||
|
async def handle(self, ctx: CommandContext) -> CommandResult:
|
||||||
|
try:
|
||||||
|
from backend.agents.skills_manager import SkillsManager
|
||||||
|
sm = SkillsManager()
|
||||||
|
|
||||||
|
active_skills = sm.list_active_skill_metadata(ctx.config_name, ctx.agent_id)
|
||||||
|
catalog = sm.list_agent_skill_catalog(ctx.config_name, ctx.agent_id)
|
||||||
|
|
||||||
|
lines = ["📋 技能列表", ""]
|
||||||
|
|
||||||
|
if active_skills:
|
||||||
|
lines.append("✅ 已激活技能:")
|
||||||
|
for skill in active_skills:
|
||||||
|
lines.append(f" • {skill.name} - {skill.description[:50]}...")
|
||||||
|
else:
|
||||||
|
lines.append("⚠️ 当前没有激活的技能")
|
||||||
|
|
||||||
|
lines.append("")
|
||||||
|
lines.append(f"📚 可用技能总数: {len(catalog)}")
|
||||||
|
lines.append("💡 使用 /skills enable <name> 启用技能")
|
||||||
|
|
||||||
|
return CommandResult(
|
||||||
|
success=True,
|
||||||
|
message="\n".join(lines),
|
||||||
|
data={
|
||||||
|
"active_count": len(active_skills),
|
||||||
|
"catalog_count": len(catalog),
|
||||||
|
"active": [s.skill_name for s in active_skills]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to list skills: {e}")
|
||||||
|
return CommandResult(
|
||||||
|
success=False,
|
||||||
|
message=f"❌ 获取技能列表失败: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SkillsEnableCommandHandler(CommandHandler):
|
||||||
|
"""处理 /skills enable <name> 命令 - 启用技能"""
|
||||||
|
|
||||||
|
async def handle(self, ctx: CommandContext) -> CommandResult:
|
||||||
|
skill_name = ctx.args.strip()
|
||||||
|
if not skill_name:
|
||||||
|
return CommandResult(
|
||||||
|
success=False,
|
||||||
|
message="Usage: /skills enable <skill_name>\n请提供技能名称。"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
from backend.agents.skills_manager import SkillsManager
|
||||||
|
sm = SkillsManager()
|
||||||
|
|
||||||
|
result = sm.update_agent_skill_overrides(
|
||||||
|
ctx.config_name,
|
||||||
|
ctx.agent_id,
|
||||||
|
enable=[skill_name]
|
||||||
|
)
|
||||||
|
|
||||||
|
return CommandResult(
|
||||||
|
success=True,
|
||||||
|
message=f"✅ 技能已启用: {skill_name}\n\n已启用技能: {', '.join(result['enabled_skills'])}",
|
||||||
|
data=result
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to enable skill: {e}")
|
||||||
|
return CommandResult(
|
||||||
|
success=False,
|
||||||
|
message=f"❌ 启用技能失败: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SkillsDisableCommandHandler(CommandHandler):
|
||||||
|
"""处理 /skills disable <name> 命令 - 禁用技能"""
|
||||||
|
|
||||||
|
async def handle(self, ctx: CommandContext) -> CommandResult:
|
||||||
|
skill_name = ctx.args.strip()
|
||||||
|
if not skill_name:
|
||||||
|
return CommandResult(
|
||||||
|
success=False,
|
||||||
|
message="Usage: /skills disable <skill_name>\n请提供技能名称。"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
from backend.agents.skills_manager import SkillsManager
|
||||||
|
sm = SkillsManager()
|
||||||
|
|
||||||
|
result = sm.update_agent_skill_overrides(
|
||||||
|
ctx.config_name,
|
||||||
|
ctx.agent_id,
|
||||||
|
disable=[skill_name]
|
||||||
|
)
|
||||||
|
|
||||||
|
return CommandResult(
|
||||||
|
success=True,
|
||||||
|
message=f"✅ 技能已禁用: {skill_name}\n\n已禁用技能: {', '.join(result['disabled_skills'])}",
|
||||||
|
data=result
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to disable skill: {e}")
|
||||||
|
return CommandResult(
|
||||||
|
success=False,
|
||||||
|
message=f"❌ 禁用技能失败: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SkillsInstallCommandHandler(CommandHandler):
|
||||||
|
"""处理 /skills install <name> 命令 - 安装技能"""
|
||||||
|
|
||||||
|
async def handle(self, ctx: CommandContext) -> CommandResult:
|
||||||
|
skill_name = ctx.args.strip()
|
||||||
|
if not skill_name:
|
||||||
|
return CommandResult(
|
||||||
|
success=False,
|
||||||
|
message="Usage: /skills install <skill_name>\n请提供技能名称。"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
from backend.agents.skills_manager import SkillsManager
|
||||||
|
from backend.agents.skill_loader import load_skill_from_dir
|
||||||
|
sm = SkillsManager()
|
||||||
|
|
||||||
|
# 查找技能源目录
|
||||||
|
source_dir = self._resolve_skill_source(sm, skill_name)
|
||||||
|
if not source_dir:
|
||||||
|
return CommandResult(
|
||||||
|
success=False,
|
||||||
|
message=f"❌ 技能未找到: {skill_name}\n\n请检查技能名称是否正确,或技能是否存在于 builtin/customized 目录。"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 加载并验证技能
|
||||||
|
skill_info = load_skill_from_dir(source_dir)
|
||||||
|
if not skill_info:
|
||||||
|
return CommandResult(
|
||||||
|
success=False,
|
||||||
|
message=f"❌ 技能加载失败: {skill_name}\n\n技能格式可能不正确。"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 安装到agent的installed目录
|
||||||
|
installed_root = sm.get_agent_installed_root(ctx.config_name, ctx.agent_id)
|
||||||
|
target_dir = installed_root / skill_name
|
||||||
|
|
||||||
|
import shutil
|
||||||
|
if target_dir.exists():
|
||||||
|
shutil.rmtree(target_dir)
|
||||||
|
shutil.copytree(source_dir, target_dir)
|
||||||
|
|
||||||
|
return CommandResult(
|
||||||
|
success=True,
|
||||||
|
message=f"✅ 技能已安装: {skill_name}\n\n- 名称: {skill_info.get('name', skill_name)}\n- 版本: {skill_info.get('version', 'unknown')}\n- 路径: {target_dir}",
|
||||||
|
data={"skill_name": skill_name, "target_dir": str(target_dir)}
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to install skill: {e}")
|
||||||
|
return CommandResult(
|
||||||
|
success=False,
|
||||||
|
message=f"❌ 安装技能失败: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _resolve_skill_source(self, sm: "SkillsManager", skill_name: str) -> Optional[Path]:
|
||||||
|
"""解析技能源目录"""
|
||||||
|
for root in [sm.customized_root, sm.builtin_root]:
|
||||||
|
candidate = root / skill_name
|
||||||
|
if candidate.exists() and (candidate / "SKILL.md").exists():
|
||||||
|
return candidate
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class ReloadCommandHandler(CommandHandler):
|
||||||
|
"""处理 /reload 命令 - 重新加载配置"""
|
||||||
|
|
||||||
|
async def handle(self, ctx: CommandContext) -> CommandResult:
|
||||||
|
try:
|
||||||
|
agent = ctx.agent
|
||||||
|
|
||||||
|
# 重新加载配置
|
||||||
|
if hasattr(agent, "reload_config"):
|
||||||
|
await agent.reload_config()
|
||||||
|
|
||||||
|
# 重新加载技能
|
||||||
|
from backend.agents.skills_manager import SkillsManager
|
||||||
|
sm = SkillsManager()
|
||||||
|
|
||||||
|
# 刷新技能同步
|
||||||
|
active_root = sm.get_agent_active_root(ctx.config_name, ctx.agent_id)
|
||||||
|
if active_root.exists():
|
||||||
|
# 清除缓存,强制重新加载
|
||||||
|
import shutil
|
||||||
|
for item in active_root.iterdir():
|
||||||
|
if item.is_dir():
|
||||||
|
shutil.rmtree(item)
|
||||||
|
|
||||||
|
return CommandResult(
|
||||||
|
success=True,
|
||||||
|
message="✅ 配置已重新加载\n\n- Agent配置已刷新\n- 技能缓存已清除\n- 请重启对话以应用所有更改",
|
||||||
|
data={"config_name": ctx.config_name, "agent_id": ctx.agent_id}
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to reload config: {e}")
|
||||||
|
return CommandResult(
|
||||||
|
success=False,
|
||||||
|
message=f"❌ 重新加载失败: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class StatusCommandHandler(CommandHandler):
|
||||||
|
"""处理 /status 命令 - 显示Agent状态"""
|
||||||
|
|
||||||
|
async def handle(self, ctx: CommandContext) -> CommandResult:
|
||||||
|
try:
|
||||||
|
agent = ctx.agent
|
||||||
|
|
||||||
|
lines = ["📊 Agent 状态", ""]
|
||||||
|
lines.append(f"🆔 Agent ID: {ctx.agent_id}")
|
||||||
|
lines.append(f"⚙️ Config: {ctx.config_name}")
|
||||||
|
|
||||||
|
# 模型信息
|
||||||
|
model = getattr(agent, "model", None)
|
||||||
|
if model:
|
||||||
|
lines.append(f"🤖 Model: {model}")
|
||||||
|
|
||||||
|
# 记忆状态
|
||||||
|
memory = getattr(agent, "memory", None)
|
||||||
|
if memory:
|
||||||
|
msg_count = len(getattr(memory, "content", []))
|
||||||
|
lines.append(f"💾 Memory: {msg_count} messages")
|
||||||
|
|
||||||
|
# 技能状态
|
||||||
|
from backend.agents.skills_manager import SkillsManager
|
||||||
|
sm = SkillsManager()
|
||||||
|
active_skills = sm.list_active_skill_metadata(ctx.config_name, ctx.agent_id)
|
||||||
|
lines.append(f"🔧 Active Skills: {len(active_skills)}")
|
||||||
|
|
||||||
|
# 工具组状态
|
||||||
|
toolkit = getattr(agent, "toolkit", None)
|
||||||
|
if toolkit:
|
||||||
|
groups = getattr(toolkit, "tool_groups", {})
|
||||||
|
active_groups = [name for name, g in groups.items() if getattr(g, "active", False)]
|
||||||
|
lines.append(f"🛠️ Active Tool Groups: {', '.join(active_groups) if active_groups else 'None'}")
|
||||||
|
|
||||||
|
return CommandResult(
|
||||||
|
success=True,
|
||||||
|
message="\n".join(lines),
|
||||||
|
data={
|
||||||
|
"agent_id": ctx.agent_id,
|
||||||
|
"config_name": ctx.config_name,
|
||||||
|
"active_skills_count": len(active_skills)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to get status: {e}")
|
||||||
|
return CommandResult(
|
||||||
|
success=False,
|
||||||
|
message=f"❌ 获取状态失败: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class HelpCommandHandler(CommandHandler):
|
||||||
|
"""处理 /help 命令 - 显示帮助"""
|
||||||
|
|
||||||
|
async def handle(self, ctx: CommandContext) -> CommandResult:
|
||||||
|
help_text = """📖 EvoAgent 命令帮助
|
||||||
|
|
||||||
|
可用命令:
|
||||||
|
/save <message> - 保存内容到 MEMORY.md
|
||||||
|
/compact - 压缩记忆
|
||||||
|
/skills list - 列出已激活技能
|
||||||
|
/skills enable <name> - 启用技能
|
||||||
|
/skills disable <name>- 禁用技能
|
||||||
|
/skills install <name>- 安装技能
|
||||||
|
/reload - 重新加载配置
|
||||||
|
/status - 显示Agent状态
|
||||||
|
/help - 显示此帮助信息
|
||||||
|
|
||||||
|
提示:
|
||||||
|
• 所有命令以 / 开头
|
||||||
|
• 命令不区分大小写
|
||||||
|
• 使用 Tab 键可自动补全命令
|
||||||
|
"""
|
||||||
|
return CommandResult(success=True, message=help_text)
|
||||||
|
|
||||||
|
|
||||||
|
class AgentCommandDispatcher:
|
||||||
|
"""Agent命令分发器
|
||||||
|
|
||||||
|
参考CoPaw的CommandHandler设计,为EvoAgent提供统一的命令处理入口。
|
||||||
|
"""
|
||||||
|
|
||||||
|
# 支持的系统命令
|
||||||
|
SYSTEM_COMMANDS = frozenset({
|
||||||
|
"save", "compact",
|
||||||
|
"skills", "reload",
|
||||||
|
"status", "help"
|
||||||
|
})
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._handlers: Dict[str, CommandHandler] = {}
|
||||||
|
self._subcommands: Dict[str, Dict[str, CommandHandler]] = {}
|
||||||
|
self._register_default_handlers()
|
||||||
|
|
||||||
|
def _register_default_handlers(self) -> None:
|
||||||
|
"""注册默认命令处理器"""
|
||||||
|
self._handlers["save"] = SaveCommandHandler()
|
||||||
|
self._handlers["compact"] = CompactCommandHandler()
|
||||||
|
self._handlers["reload"] = ReloadCommandHandler()
|
||||||
|
self._handlers["status"] = StatusCommandHandler()
|
||||||
|
self._handlers["help"] = HelpCommandHandler()
|
||||||
|
|
||||||
|
# 子命令: /skills list/enable/disable/install
|
||||||
|
self._subcommands["skills"] = {
|
||||||
|
"list": SkillsListCommandHandler(),
|
||||||
|
"enable": SkillsEnableCommandHandler(),
|
||||||
|
"disable": SkillsDisableCommandHandler(),
|
||||||
|
"install": SkillsInstallCommandHandler(),
|
||||||
|
}
|
||||||
|
|
||||||
|
def is_command(self, query: str | None) -> bool:
|
||||||
|
"""检查是否为命令
|
||||||
|
|
||||||
|
Args:
|
||||||
|
query: 用户输入字符串
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True 如果是系统命令
|
||||||
|
"""
|
||||||
|
if not isinstance(query, str) or not query.startswith("/"):
|
||||||
|
return False
|
||||||
|
|
||||||
|
parts = query.strip().lstrip("/").split()
|
||||||
|
if not parts:
|
||||||
|
return False
|
||||||
|
|
||||||
|
cmd = parts[0].lower()
|
||||||
|
|
||||||
|
# 检查主命令
|
||||||
|
if cmd in self.SYSTEM_COMMANDS:
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def handle(self, agent: "EvoAgent", query: str) -> CommandResult:
|
||||||
|
"""处理命令
|
||||||
|
|
||||||
|
Args:
|
||||||
|
agent: EvoAgent实例
|
||||||
|
query: 命令字符串
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
命令执行结果
|
||||||
|
"""
|
||||||
|
if not self.is_command(query):
|
||||||
|
return CommandResult(
|
||||||
|
success=False,
|
||||||
|
message=f"未知命令: {query}\n使用 /help 查看可用命令。"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 解析命令和参数
|
||||||
|
parts = query.strip().lstrip("/").split(maxsplit=1)
|
||||||
|
cmd = parts[0].lower()
|
||||||
|
args = parts[1] if len(parts) > 1 else ""
|
||||||
|
|
||||||
|
logger.info(f"Processing command: {cmd}, args: {args}")
|
||||||
|
|
||||||
|
# 处理子命令 (e.g., /skills list)
|
||||||
|
if cmd in self._subcommands:
|
||||||
|
sub_parts = args.split(maxsplit=1)
|
||||||
|
sub_cmd = sub_parts[0].lower() if sub_parts else ""
|
||||||
|
sub_args = sub_parts[1] if len(sub_parts) > 1 else ""
|
||||||
|
|
||||||
|
handlers = self._subcommands[cmd]
|
||||||
|
handler = handlers.get(sub_cmd)
|
||||||
|
|
||||||
|
if handler is None:
|
||||||
|
available = ", ".join(handlers.keys())
|
||||||
|
return CommandResult(
|
||||||
|
success=False,
|
||||||
|
message=f"未知子命令: {sub_cmd}\n可用子命令: {available}"
|
||||||
|
)
|
||||||
|
|
||||||
|
ctx = CommandContext(agent, query, sub_args)
|
||||||
|
return await handler.handle(ctx)
|
||||||
|
|
||||||
|
# 处理主命令
|
||||||
|
handler = self._handlers.get(cmd)
|
||||||
|
if handler is None:
|
||||||
|
return CommandResult(
|
||||||
|
success=False,
|
||||||
|
message=f"命令未实现: {cmd}"
|
||||||
|
)
|
||||||
|
|
||||||
|
ctx = CommandContext(agent, query, args)
|
||||||
|
return await handler.handle(ctx)
|
||||||
|
|
||||||
|
|
||||||
|
# 便捷函数
|
||||||
|
def create_command_dispatcher() -> AgentCommandDispatcher:
|
||||||
|
"""创建命令分发器实例"""
|
||||||
|
return AgentCommandDispatcher()
|
||||||
411
backend/agents/base/evo_agent.py
Normal file
411
backend/agents/base/evo_agent.py
Normal file
@@ -0,0 +1,411 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""EvoAgent - Core agent implementation for EvoTraders.
|
||||||
|
|
||||||
|
This module provides the main EvoAgent class built on AgentScope's ReActAgent,
|
||||||
|
with integrated tools, skills, and memory management based on CoPaw design.
|
||||||
|
|
||||||
|
Key features:
|
||||||
|
- Workspace-driven configuration from Markdown files
|
||||||
|
- Dynamic skill loading from skills/active directories
|
||||||
|
- Tool-guard security interception
|
||||||
|
- Hook system for extensibility
|
||||||
|
- Runtime skill and prompt reloading
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, List, Optional, Type, TYPE_CHECKING
|
||||||
|
|
||||||
|
from agentscope.agent import ReActAgent
|
||||||
|
from agentscope.memory import InMemoryMemory
|
||||||
|
from agentscope.message import Msg
|
||||||
|
from agentscope.tool import Toolkit
|
||||||
|
|
||||||
|
from .tool_guard import ToolGuardMixin
|
||||||
|
from .hooks import (
|
||||||
|
HookManager,
|
||||||
|
BootstrapHook,
|
||||||
|
MemoryCompactionHook,
|
||||||
|
HOOK_PRE_REASONING,
|
||||||
|
)
|
||||||
|
from ..prompts.builder import (
|
||||||
|
PromptBuilder,
|
||||||
|
build_system_prompt_from_workspace,
|
||||||
|
)
|
||||||
|
from ..agent_workspace import load_agent_workspace_config
|
||||||
|
from ..skills_manager import SkillsManager
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from agentscope.formatter import FormatterBase
|
||||||
|
from agentscope.model import ModelWrapperBase
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class EvoAgent(ToolGuardMixin, ReActAgent):
|
||||||
|
"""EvoAgent with integrated tools, skills, and memory management.
|
||||||
|
|
||||||
|
This agent extends ReActAgent with:
|
||||||
|
- Workspace-driven configuration from AGENTS.md/SOUL.md/PROFILE.md/etc.
|
||||||
|
- Dynamic skill loading from skills/active directories
|
||||||
|
- Tool-guard security interception (via ToolGuardMixin)
|
||||||
|
- Hook system for extensibility (bootstrap, memory compaction)
|
||||||
|
- Runtime skill and prompt reloading
|
||||||
|
|
||||||
|
MRO note
|
||||||
|
~~~~~~~~
|
||||||
|
``ToolGuardMixin`` overrides ``_acting`` and ``_reasoning`` via
|
||||||
|
Python's MRO: EvoAgent → ToolGuardMixin → ReActAgent.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
agent = EvoAgent(
|
||||||
|
agent_id="fundamentals_analyst",
|
||||||
|
config_name="smoke_fullstack",
|
||||||
|
workspace_dir=Path("runs/smoke_fullstack/agents/fundamentals_analyst"),
|
||||||
|
model=model_instance,
|
||||||
|
formatter=formatter_instance,
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
agent_id: str,
|
||||||
|
config_name: str,
|
||||||
|
workspace_dir: Path,
|
||||||
|
model: "ModelWrapperBase",
|
||||||
|
formatter: "FormatterBase",
|
||||||
|
skills_manager: Optional[SkillsManager] = None,
|
||||||
|
sys_prompt: Optional[str] = None,
|
||||||
|
max_iters: int = 10,
|
||||||
|
memory: Optional[Any] = None,
|
||||||
|
enable_tool_guard: bool = True,
|
||||||
|
enable_bootstrap_hook: bool = True,
|
||||||
|
enable_memory_compaction: bool = False,
|
||||||
|
memory_manager: Optional[Any] = None,
|
||||||
|
memory_compact_threshold: Optional[int] = None,
|
||||||
|
env_context: Optional[str] = None,
|
||||||
|
prompt_files: Optional[List[str]] = None,
|
||||||
|
):
|
||||||
|
"""Initialize EvoAgent.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
agent_id: Unique identifier for this agent
|
||||||
|
config_name: Run configuration name (e.g., "smoke_fullstack")
|
||||||
|
workspace_dir: Agent workspace directory containing markdown files
|
||||||
|
model: LLM model instance
|
||||||
|
formatter: Message formatter instance
|
||||||
|
skills_manager: Optional SkillsManager instance
|
||||||
|
sys_prompt: Optional override for system prompt
|
||||||
|
max_iters: Maximum reasoning-acting iterations
|
||||||
|
memory: Optional memory instance (defaults to InMemoryMemory)
|
||||||
|
enable_tool_guard: Enable tool-guard security interception
|
||||||
|
enable_bootstrap_hook: Enable bootstrap guidance on first interaction
|
||||||
|
enable_memory_compaction: Enable automatic memory compaction
|
||||||
|
memory_manager: Optional memory manager for compaction
|
||||||
|
memory_compact_threshold: Token threshold for memory compaction
|
||||||
|
env_context: Optional environment context to prepend to system prompt
|
||||||
|
prompt_files: List of markdown files to load (defaults to standard set)
|
||||||
|
"""
|
||||||
|
self.agent_id = agent_id
|
||||||
|
self.config_name = config_name
|
||||||
|
self.workspace_dir = Path(workspace_dir)
|
||||||
|
self._skills_manager = skills_manager or SkillsManager()
|
||||||
|
self._env_context = env_context
|
||||||
|
self._prompt_files = prompt_files
|
||||||
|
|
||||||
|
# Initialize tool guard
|
||||||
|
if enable_tool_guard:
|
||||||
|
self._init_tool_guard()
|
||||||
|
|
||||||
|
# Load agent configuration from workspace
|
||||||
|
self._agent_config = self._load_agent_config()
|
||||||
|
|
||||||
|
# Build or use provided system prompt
|
||||||
|
if sys_prompt is not None:
|
||||||
|
self._sys_prompt = sys_prompt
|
||||||
|
else:
|
||||||
|
self._sys_prompt = self._build_system_prompt()
|
||||||
|
|
||||||
|
# Create toolkit with skills
|
||||||
|
toolkit = self._create_toolkit()
|
||||||
|
|
||||||
|
# Initialize hook manager
|
||||||
|
self._hook_manager = HookManager()
|
||||||
|
|
||||||
|
# Initialize parent ReActAgent
|
||||||
|
super().__init__(
|
||||||
|
name=agent_id,
|
||||||
|
model=model,
|
||||||
|
sys_prompt=self._sys_prompt,
|
||||||
|
toolkit=toolkit,
|
||||||
|
memory=memory or InMemoryMemory(),
|
||||||
|
formatter=formatter,
|
||||||
|
max_iters=max_iters,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Register hooks
|
||||||
|
self._register_hooks(
|
||||||
|
enable_bootstrap=enable_bootstrap_hook,
|
||||||
|
enable_memory_compaction=enable_memory_compaction,
|
||||||
|
memory_manager=memory_manager,
|
||||||
|
memory_compact_threshold=memory_compact_threshold,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"EvoAgent initialized: %s (workspace: %s)",
|
||||||
|
agent_id,
|
||||||
|
workspace_dir,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _load_agent_config(self) -> Dict[str, Any]:
|
||||||
|
"""Load agent configuration from workspace.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Agent configuration dictionary
|
||||||
|
"""
|
||||||
|
config_path = self.workspace_dir / "agent.yaml"
|
||||||
|
if config_path.exists():
|
||||||
|
loaded = load_agent_workspace_config(config_path)
|
||||||
|
return dict(loaded.values)
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def _build_system_prompt(self) -> str:
|
||||||
|
"""Build system prompt from workspace markdown files.
|
||||||
|
|
||||||
|
Uses PromptBuilder to load and combine AGENTS.md, SOUL.md,
|
||||||
|
PROFILE.md, and other configured files.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Complete system prompt string
|
||||||
|
"""
|
||||||
|
prompt = build_system_prompt_from_workspace(
|
||||||
|
workspace_dir=self.workspace_dir,
|
||||||
|
enabled_files=self._prompt_files,
|
||||||
|
agent_id=self.agent_id,
|
||||||
|
extra_context=self._env_context,
|
||||||
|
)
|
||||||
|
return prompt
|
||||||
|
|
||||||
|
def _create_toolkit(self) -> Toolkit:
|
||||||
|
"""Create and populate toolkit with agent skills.
|
||||||
|
|
||||||
|
Loads skills from the agent's active skills directory and
|
||||||
|
registers them with the toolkit.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Configured Toolkit instance
|
||||||
|
"""
|
||||||
|
toolkit = Toolkit(
|
||||||
|
agent_skill_instruction=(
|
||||||
|
"<system-info>You have access to specialized skills. "
|
||||||
|
"Each skill lives in a directory and is described by SKILL.md. "
|
||||||
|
"Follow the skill instructions when they are relevant to the current task."
|
||||||
|
"</system-info>"
|
||||||
|
),
|
||||||
|
agent_skill_template="- {name} (dir: {dir}): {description}",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Register skills from active directory
|
||||||
|
active_skills_dir = self._skills_manager.get_agent_active_root(
|
||||||
|
self.config_name,
|
||||||
|
self.agent_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
if active_skills_dir.exists():
|
||||||
|
for skill_dir in sorted(active_skills_dir.iterdir()):
|
||||||
|
if skill_dir.is_dir() and (skill_dir / "SKILL.md").exists():
|
||||||
|
try:
|
||||||
|
toolkit.register_agent_skill(str(skill_dir))
|
||||||
|
logger.debug("Registered skill: %s", skill_dir.name)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
"Failed to register skill '%s': %s",
|
||||||
|
skill_dir.name,
|
||||||
|
e,
|
||||||
|
)
|
||||||
|
|
||||||
|
return toolkit
|
||||||
|
|
||||||
|
def _register_hooks(
|
||||||
|
self,
|
||||||
|
enable_bootstrap: bool,
|
||||||
|
enable_memory_compaction: bool,
|
||||||
|
memory_manager: Optional[Any],
|
||||||
|
memory_compact_threshold: Optional[int],
|
||||||
|
) -> None:
|
||||||
|
"""Register agent hooks.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
enable_bootstrap: Enable bootstrap hook
|
||||||
|
enable_memory_compaction: Enable memory compaction hook
|
||||||
|
memory_manager: Memory manager instance
|
||||||
|
memory_compact_threshold: Token threshold for compaction
|
||||||
|
"""
|
||||||
|
# Bootstrap hook - checks BOOTSTRAP.md on first interaction
|
||||||
|
if enable_bootstrap:
|
||||||
|
bootstrap_hook = BootstrapHook(
|
||||||
|
workspace_dir=self.workspace_dir,
|
||||||
|
language="zh",
|
||||||
|
)
|
||||||
|
self._hook_manager.register(
|
||||||
|
hook_type=HOOK_PRE_REASONING,
|
||||||
|
hook_name="bootstrap",
|
||||||
|
hook=bootstrap_hook,
|
||||||
|
)
|
||||||
|
logger.debug("Registered bootstrap hook")
|
||||||
|
|
||||||
|
# Memory compaction hook
|
||||||
|
if enable_memory_compaction and memory_manager is not None:
|
||||||
|
compaction_hook = MemoryCompactionHook(
|
||||||
|
memory_manager=memory_manager,
|
||||||
|
memory_compact_threshold=memory_compact_threshold,
|
||||||
|
)
|
||||||
|
self._hook_manager.register(
|
||||||
|
hook_type=HOOK_PRE_REASONING,
|
||||||
|
hook_name="memory_compaction",
|
||||||
|
hook=compaction_hook,
|
||||||
|
)
|
||||||
|
logger.debug("Registered memory compaction hook")
|
||||||
|
|
||||||
|
async def _reasoning(self, **kwargs) -> Msg:
|
||||||
|
"""Override reasoning to execute pre-reasoning hooks.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
**kwargs: Arguments for reasoning
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Response message
|
||||||
|
"""
|
||||||
|
# Execute pre-reasoning hooks
|
||||||
|
kwargs = await self._hook_manager.execute(
|
||||||
|
hook_type=HOOK_PRE_REASONING,
|
||||||
|
agent=self,
|
||||||
|
kwargs=kwargs,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Call parent (which may be ToolGuardMixin's _reasoning)
|
||||||
|
return await super()._reasoning(**kwargs)
|
||||||
|
|
||||||
|
def reload_skills(self, active_skill_dirs: Optional[List[Path]] = None) -> None:
|
||||||
|
"""Reload skills at runtime.
|
||||||
|
|
||||||
|
Rebuilds the toolkit with current skills from the active directory.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
active_skill_dirs: Optional list of specific skill directories to load
|
||||||
|
"""
|
||||||
|
logger.info("Reloading skills for agent: %s", self.agent_id)
|
||||||
|
|
||||||
|
# Create new toolkit
|
||||||
|
new_toolkit = Toolkit(
|
||||||
|
agent_skill_instruction=(
|
||||||
|
"<system-info>You have access to specialized skills. "
|
||||||
|
"Each skill lives in a directory and is described by SKILL.md. "
|
||||||
|
"Follow the skill instructions when they are relevant to the current task."
|
||||||
|
"</system-info>"
|
||||||
|
),
|
||||||
|
agent_skill_template="- {name} (dir: {dir}): {description}",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Register skills
|
||||||
|
if active_skill_dirs is None:
|
||||||
|
active_skills_dir = self._skills_manager.get_agent_active_root(
|
||||||
|
self.config_name,
|
||||||
|
self.agent_id,
|
||||||
|
)
|
||||||
|
if active_skills_dir.exists():
|
||||||
|
active_skill_dirs = [
|
||||||
|
d for d in active_skills_dir.iterdir()
|
||||||
|
if d.is_dir() and (d / "SKILL.md").exists()
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
active_skill_dirs = []
|
||||||
|
|
||||||
|
for skill_dir in active_skill_dirs:
|
||||||
|
if skill_dir.exists() and (skill_dir / "SKILL.md").exists():
|
||||||
|
try:
|
||||||
|
new_toolkit.register_agent_skill(str(skill_dir))
|
||||||
|
logger.debug("Reloaded skill: %s", skill_dir.name)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
"Failed to reload skill '%s': %s",
|
||||||
|
skill_dir.name,
|
||||||
|
e,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Replace toolkit
|
||||||
|
self.toolkit = new_toolkit
|
||||||
|
logger.info("Skills reloaded for agent: %s", self.agent_id)
|
||||||
|
|
||||||
|
def rebuild_sys_prompt(self) -> None:
|
||||||
|
"""Rebuild and replace the system prompt at runtime.
|
||||||
|
|
||||||
|
Useful after updating AGENTS.md, SOUL.md, PROFILE.md, etc.
|
||||||
|
to ensure the prompt reflects the latest configuration.
|
||||||
|
|
||||||
|
Updates both self._sys_prompt and the first system-role
|
||||||
|
message stored in self.memory.content.
|
||||||
|
"""
|
||||||
|
logger.info("Rebuilding system prompt for agent: %s", self.agent_id)
|
||||||
|
|
||||||
|
# Reload agent config in case it changed
|
||||||
|
self._agent_config = self._load_agent_config()
|
||||||
|
|
||||||
|
# Rebuild prompt
|
||||||
|
self._sys_prompt = self._build_system_prompt()
|
||||||
|
|
||||||
|
# Update memory if system message exists
|
||||||
|
if hasattr(self, "memory") and self.memory.content:
|
||||||
|
for msg, _marks in self.memory.content:
|
||||||
|
if getattr(msg, "role", None) == "system":
|
||||||
|
msg.content = self._sys_prompt
|
||||||
|
logger.debug("Updated system message in memory")
|
||||||
|
break
|
||||||
|
|
||||||
|
logger.info("System prompt rebuilt for agent: %s", self.agent_id)
|
||||||
|
|
||||||
|
async def reply(
|
||||||
|
self,
|
||||||
|
msg: Msg | List[Msg] | None = None,
|
||||||
|
structured_model: Optional[Type[Any]] = None,
|
||||||
|
) -> Msg:
|
||||||
|
"""Process a message and return a response.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
msg: Input message(s) from user
|
||||||
|
structured_model: Optional pydantic model for structured output
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Response message
|
||||||
|
"""
|
||||||
|
# Handle list of messages
|
||||||
|
if isinstance(msg, list):
|
||||||
|
# Process each message in sequence
|
||||||
|
for m in msg[:-1]:
|
||||||
|
await self.memory.add(m)
|
||||||
|
msg = msg[-1] if msg else None
|
||||||
|
|
||||||
|
return await super().reply(msg=msg, structured_model=structured_model)
|
||||||
|
|
||||||
|
def get_agent_info(self) -> Dict[str, Any]:
|
||||||
|
"""Get agent information.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with agent metadata
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
"agent_id": self.agent_id,
|
||||||
|
"config_name": self.config_name,
|
||||||
|
"workspace_dir": str(self.workspace_dir),
|
||||||
|
"skills_count": len([
|
||||||
|
s for s in self._skills_manager.list_active_skill_metadata(
|
||||||
|
self.config_name,
|
||||||
|
self.agent_id,
|
||||||
|
)
|
||||||
|
]),
|
||||||
|
"registered_hooks": self._hook_manager.list_hooks(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["EvoAgent"]
|
||||||
432
backend/agents/base/hooks.py
Normal file
432
backend/agents/base/hooks.py
Normal file
@@ -0,0 +1,432 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Hook system for EvoAgent.
|
||||||
|
|
||||||
|
Provides pre_reasoning and post_acting hooks with built-in implementations:
|
||||||
|
- BootstrapHook: First-time setup guidance
|
||||||
|
- MemoryCompactionHook: Automatic memory compression
|
||||||
|
|
||||||
|
Based on CoPaw's hooks design.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Callable, Dict, List, Optional, TYPE_CHECKING
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from agentscope.agent import ReActAgent
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Hook types
|
||||||
|
HookType = str
|
||||||
|
HOOK_PRE_REASONING: HookType = "pre_reasoning"
|
||||||
|
HOOK_POST_ACTING: HookType = "post_acting"
|
||||||
|
|
||||||
|
|
||||||
|
class Hook(ABC):
|
||||||
|
"""Abstract base class for agent hooks."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def __call__(
|
||||||
|
self,
|
||||||
|
agent: "ReActAgent",
|
||||||
|
kwargs: Dict[str, Any],
|
||||||
|
) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Execute the hook.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
agent: The agent instance
|
||||||
|
kwargs: Input arguments to the method being hooked
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Modified kwargs or None to use original
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class HookManager:
|
||||||
|
"""Manages agent hooks.
|
||||||
|
|
||||||
|
Provides registration and execution of hooks for different
|
||||||
|
lifecycle events in the agent's operation.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._hooks: Dict[HookType, List[tuple[str, Hook]]] = {
|
||||||
|
HOOK_PRE_REASONING: [],
|
||||||
|
HOOK_POST_ACTING: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
def register(
|
||||||
|
self,
|
||||||
|
hook_type: HookType,
|
||||||
|
hook_name: str,
|
||||||
|
hook: Hook | Callable,
|
||||||
|
) -> None:
|
||||||
|
"""Register a hook.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
hook_type: Type of hook (pre_reasoning, post_acting)
|
||||||
|
hook_name: Unique name for this hook
|
||||||
|
hook: Hook instance or callable
|
||||||
|
"""
|
||||||
|
# Remove existing hook with same name
|
||||||
|
self._hooks[hook_type] = [
|
||||||
|
(name, h) for name, h in self._hooks[hook_type] if name != hook_name
|
||||||
|
]
|
||||||
|
self._hooks[hook_type].append((hook_name, hook))
|
||||||
|
logger.debug("Registered hook '%s' for type '%s'", hook_name, hook_type)
|
||||||
|
|
||||||
|
def unregister(self, hook_type: HookType, hook_name: str) -> bool:
|
||||||
|
"""Unregister a hook.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
hook_type: Type of hook
|
||||||
|
hook_name: Name of the hook to remove
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if hook was found and removed
|
||||||
|
"""
|
||||||
|
original_len = len(self._hooks[hook_type])
|
||||||
|
self._hooks[hook_type] = [
|
||||||
|
(name, h) for name, h in self._hooks[hook_type] if name != hook_name
|
||||||
|
]
|
||||||
|
removed = len(self._hooks[hook_type]) < original_len
|
||||||
|
if removed:
|
||||||
|
logger.debug("Unregistered hook '%s' from type '%s'", hook_name, hook_type)
|
||||||
|
return removed
|
||||||
|
|
||||||
|
async def execute(
|
||||||
|
self,
|
||||||
|
hook_type: HookType,
|
||||||
|
agent: "ReActAgent",
|
||||||
|
kwargs: Dict[str, Any],
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Execute all hooks of a given type.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
hook_type: Type of hooks to execute
|
||||||
|
agent: The agent instance
|
||||||
|
kwargs: Input arguments
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Potentially modified kwargs
|
||||||
|
"""
|
||||||
|
for name, hook in self._hooks[hook_type]:
|
||||||
|
try:
|
||||||
|
result = await hook(agent, kwargs)
|
||||||
|
if result is not None:
|
||||||
|
kwargs = result
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Hook '%s' failed: %s", name, e, exc_info=True)
|
||||||
|
|
||||||
|
return kwargs
|
||||||
|
|
||||||
|
def list_hooks(self, hook_type: Optional[HookType] = None) -> List[str]:
|
||||||
|
"""List registered hook names.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
hook_type: Optional type to filter by
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of hook names
|
||||||
|
"""
|
||||||
|
if hook_type:
|
||||||
|
return [name for name, _ in self._hooks.get(hook_type, [])]
|
||||||
|
|
||||||
|
names = []
|
||||||
|
for hooks in self._hooks.values():
|
||||||
|
names.extend([name for name, _ in hooks])
|
||||||
|
return names
|
||||||
|
|
||||||
|
|
||||||
|
class BootstrapHook(Hook):
|
||||||
|
"""Hook for bootstrap guidance on first user interaction.
|
||||||
|
|
||||||
|
This hook looks for a BOOTSTRAP.md file in the working directory
|
||||||
|
and if found, prepends guidance to the first user message to help
|
||||||
|
establish the agent's identity and user preferences.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
workspace_dir: Path,
|
||||||
|
language: str = "zh",
|
||||||
|
):
|
||||||
|
"""Initialize bootstrap hook.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
workspace_dir: Working directory containing BOOTSTRAP.md
|
||||||
|
language: Language code for bootstrap guidance (en/zh)
|
||||||
|
"""
|
||||||
|
self.workspace_dir = Path(workspace_dir)
|
||||||
|
self.language = language
|
||||||
|
self._completed_flag = self.workspace_dir / ".bootstrap_completed"
|
||||||
|
|
||||||
|
def _is_first_user_interaction(self, agent: "ReActAgent") -> bool:
|
||||||
|
"""Check if this is the first user interaction.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
agent: The agent instance
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if first user interaction
|
||||||
|
"""
|
||||||
|
if not hasattr(agent, "memory") or not agent.memory.content:
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Count user messages (excluding system)
|
||||||
|
user_count = sum(
|
||||||
|
1 for msg, _ in agent.memory.content if msg.role == "user"
|
||||||
|
)
|
||||||
|
return user_count <= 1
|
||||||
|
|
||||||
|
def _build_bootstrap_guidance(self) -> str:
|
||||||
|
"""Build bootstrap guidance message.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Formatted bootstrap guidance
|
||||||
|
"""
|
||||||
|
if self.language == "zh":
|
||||||
|
return (
|
||||||
|
"# 引导模式\n"
|
||||||
|
"\n"
|
||||||
|
"工作目录中存在 `BOOTSTRAP.md` — 首次设置。\n"
|
||||||
|
"\n"
|
||||||
|
"1. 阅读 BOOTSTRAP.md,友好地表示初次见面,"
|
||||||
|
"引导用户完成设置。\n"
|
||||||
|
"2. 按照 BOOTSTRAP.md 的指示,"
|
||||||
|
"帮助用户定义你的身份和偏好。\n"
|
||||||
|
"3. 按指南创建/更新必要文件"
|
||||||
|
"(PROFILE.md、MEMORY.md 等)。\n"
|
||||||
|
"4. 完成后删除 BOOTSTRAP.md。\n"
|
||||||
|
"\n"
|
||||||
|
"如果用户希望跳过,直接回答下面的问题即可。\n"
|
||||||
|
"\n"
|
||||||
|
"---\n"
|
||||||
|
"\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
"# BOOTSTRAP MODE\n"
|
||||||
|
"\n"
|
||||||
|
"`BOOTSTRAP.md` exists — first-time setup.\n"
|
||||||
|
"\n"
|
||||||
|
"1. Read BOOTSTRAP.md, greet the user, "
|
||||||
|
"and guide them through setup.\n"
|
||||||
|
"2. Follow BOOTSTRAP.md instructions "
|
||||||
|
"to define identity and preferences.\n"
|
||||||
|
"3. Create/update files "
|
||||||
|
"(PROFILE.md, MEMORY.md, etc.) as described.\n"
|
||||||
|
"4. Delete BOOTSTRAP.md when done.\n"
|
||||||
|
"\n"
|
||||||
|
"If the user wants to skip, answer their "
|
||||||
|
"question directly instead.\n"
|
||||||
|
"\n"
|
||||||
|
"---\n"
|
||||||
|
"\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
async def __call__(
|
||||||
|
self,
|
||||||
|
agent: "ReActAgent",
|
||||||
|
kwargs: Dict[str, Any],
|
||||||
|
) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Check and load BOOTSTRAP.md on first user interaction.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
agent: The agent instance
|
||||||
|
kwargs: Input arguments to the _reasoning method
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
None (hook doesn't modify kwargs)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
bootstrap_path = self.workspace_dir / "BOOTSTRAP.md"
|
||||||
|
|
||||||
|
# Check if bootstrap has already been triggered
|
||||||
|
if self._completed_flag.exists():
|
||||||
|
return None
|
||||||
|
|
||||||
|
if not bootstrap_path.exists():
|
||||||
|
return None
|
||||||
|
|
||||||
|
if not self._is_first_user_interaction(agent):
|
||||||
|
return None
|
||||||
|
|
||||||
|
bootstrap_guidance = self._build_bootstrap_guidance()
|
||||||
|
|
||||||
|
logger.debug("Found BOOTSTRAP.md [%s], prepending guidance", self.language)
|
||||||
|
|
||||||
|
# Prepend to first user message in memory
|
||||||
|
if hasattr(agent, "memory") and agent.memory.content:
|
||||||
|
system_count = sum(
|
||||||
|
1 for msg, _ in agent.memory.content if msg.role == "system"
|
||||||
|
)
|
||||||
|
for msg, _ in agent.memory.content[system_count:]:
|
||||||
|
if msg.role == "user":
|
||||||
|
# Prepend guidance to message content
|
||||||
|
original_content = msg.content
|
||||||
|
msg.content = bootstrap_guidance + original_content
|
||||||
|
break
|
||||||
|
|
||||||
|
logger.debug("Bootstrap guidance prepended to first user message")
|
||||||
|
|
||||||
|
# Create completion flag to prevent repeated triggering
|
||||||
|
self._completed_flag.touch()
|
||||||
|
logger.debug("Created bootstrap completion flag")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to process bootstrap: %s", e, exc_info=True)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class MemoryCompactionHook(Hook):
|
||||||
|
"""Hook for automatic memory compaction when context is full.
|
||||||
|
|
||||||
|
This hook monitors the token count of messages and triggers compaction
|
||||||
|
when it exceeds the threshold. It preserves the system prompt and recent
|
||||||
|
messages while summarizing older conversation history.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
memory_manager: Any,
|
||||||
|
memory_compact_threshold: Optional[int] = None,
|
||||||
|
memory_compact_reserve: Optional[int] = None,
|
||||||
|
enable_tool_result_compact: bool = False,
|
||||||
|
tool_result_compact_keep_n: int = 5,
|
||||||
|
):
|
||||||
|
"""Initialize memory compaction hook.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
memory_manager: Memory manager instance for compaction
|
||||||
|
memory_compact_threshold: Token threshold for compaction
|
||||||
|
memory_compact_reserve: Reserve tokens for recent messages
|
||||||
|
enable_tool_result_compact: Enable tool result compaction
|
||||||
|
tool_result_compact_keep_n: Number of tool results to keep
|
||||||
|
"""
|
||||||
|
self.memory_manager = memory_manager
|
||||||
|
self.memory_compact_threshold = memory_compact_threshold
|
||||||
|
self.memory_compact_reserve = memory_compact_reserve
|
||||||
|
self.enable_tool_result_compact = enable_tool_result_compact
|
||||||
|
self.tool_result_compact_keep_n = tool_result_compact_keep_n
|
||||||
|
|
||||||
|
async def __call__(
|
||||||
|
self,
|
||||||
|
agent: "ReActAgent",
|
||||||
|
kwargs: Dict[str, Any],
|
||||||
|
) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Pre-reasoning hook to check and compact memory if needed.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
agent: The agent instance
|
||||||
|
kwargs: Input arguments to the _reasoning method
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
None (hook doesn't modify kwargs)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
if not hasattr(agent, "memory") or not self.memory_manager:
|
||||||
|
return None
|
||||||
|
|
||||||
|
memory = agent.memory
|
||||||
|
|
||||||
|
# Get current token count estimate
|
||||||
|
messages = await memory.get_memory()
|
||||||
|
total_tokens = self._estimate_tokens(messages)
|
||||||
|
|
||||||
|
if self.memory_compact_threshold is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if total_tokens < self.memory_compact_threshold:
|
||||||
|
return None
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Memory compaction triggered: %d tokens (threshold: %d)",
|
||||||
|
total_tokens,
|
||||||
|
self.memory_compact_threshold,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Compact memory
|
||||||
|
await self._compact_memory(agent, messages)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to compact memory: %s", e, exc_info=True)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _estimate_tokens(self, messages: List[Any]) -> int:
|
||||||
|
"""Estimate token count for messages.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
messages: List of messages
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Estimated token count
|
||||||
|
"""
|
||||||
|
# Simple estimation: ~4 chars per token
|
||||||
|
total_chars = sum(
|
||||||
|
len(str(getattr(msg, "content", "")))
|
||||||
|
for msg in messages
|
||||||
|
)
|
||||||
|
return total_chars // 4
|
||||||
|
|
||||||
|
async def _compact_memory(
|
||||||
|
self,
|
||||||
|
agent: "ReActAgent",
|
||||||
|
messages: List[Any],
|
||||||
|
) -> None:
|
||||||
|
"""Compact memory by summarizing older messages.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
agent: The agent instance
|
||||||
|
messages: Current messages in memory
|
||||||
|
"""
|
||||||
|
if self.memory_compact_reserve is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Keep recent messages
|
||||||
|
keep_count = min(
|
||||||
|
len(messages) // 4,
|
||||||
|
10, # Max 10 recent messages
|
||||||
|
)
|
||||||
|
keep_count = max(keep_count, 2) # At least 2
|
||||||
|
|
||||||
|
messages_to_compact = messages[:-keep_count] if keep_count < len(messages) else []
|
||||||
|
|
||||||
|
if not messages_to_compact:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Use memory manager to compact if available
|
||||||
|
if hasattr(self.memory_manager, "compact_memory"):
|
||||||
|
try:
|
||||||
|
summary = await self.memory_manager.compact_memory(
|
||||||
|
messages=messages_to_compact,
|
||||||
|
)
|
||||||
|
logger.info("Memory compacted: %d messages summarized", len(messages_to_compact))
|
||||||
|
|
||||||
|
# Mark messages as compressed if supported
|
||||||
|
if hasattr(agent.memory, "update_messages_mark"):
|
||||||
|
from agentscope.agent._react_agent import _MemoryMark
|
||||||
|
await agent.memory.update_messages_mark(
|
||||||
|
new_mark=_MemoryMark.COMPRESSED,
|
||||||
|
msg_ids=[msg.id for msg in messages_to_compact],
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Memory manager compaction failed: %s", e)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"Hook",
|
||||||
|
"HookManager",
|
||||||
|
"HookType",
|
||||||
|
"HOOK_PRE_REASONING",
|
||||||
|
"HOOK_POST_ACTING",
|
||||||
|
"BootstrapHook",
|
||||||
|
"MemoryCompactionHook",
|
||||||
|
]
|
||||||
674
backend/agents/base/tool_guard.py
Normal file
674
backend/agents/base/tool_guard.py
Normal file
@@ -0,0 +1,674 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""ToolGuardMixin - Security interception for dangerous tool calls.
|
||||||
|
|
||||||
|
Provides ``_acting`` and ``_reasoning`` overrides that intercept
|
||||||
|
sensitive tool calls before execution, implementing the deny /
|
||||||
|
guard / approve flow.
|
||||||
|
|
||||||
|
Based on CoPaw's tool_guard_mixin.py design.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
from typing import Any, Callable, Dict, Iterable, List, Optional, Set
|
||||||
|
|
||||||
|
from agentscope.message import Msg
|
||||||
|
from backend.runtime.manager import get_global_runtime_manager
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
class SeverityLevel(str, Enum):
|
||||||
|
"""Risk severity level."""
|
||||||
|
|
||||||
|
LOW = "low"
|
||||||
|
MEDIUM = "medium"
|
||||||
|
HIGH = "high"
|
||||||
|
CRITICAL = "critical"
|
||||||
|
|
||||||
|
|
||||||
|
class ApprovalStatus(str, Enum):
|
||||||
|
"""Approval lifecycle state."""
|
||||||
|
|
||||||
|
PENDING = "pending"
|
||||||
|
APPROVED = "approved"
|
||||||
|
DENIED = "denied"
|
||||||
|
EXPIRED = "expired"
|
||||||
|
|
||||||
|
|
||||||
|
class ToolFindingRecord:
|
||||||
|
"""Internal representation of a guard finding."""
|
||||||
|
|
||||||
|
def __init__(self, severity: SeverityLevel, message: str, field: Optional[str] = None) -> None:
|
||||||
|
self.severity = severity
|
||||||
|
self.message = message
|
||||||
|
self.field = field
|
||||||
|
|
||||||
|
def to_dict(self) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"severity": self.severity.value,
|
||||||
|
"message": self.message,
|
||||||
|
"field": self.field,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class ApprovalRecord:
|
||||||
|
"""Stores the state of an approval request."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
approval_id: str,
|
||||||
|
tool_name: str,
|
||||||
|
tool_input: Dict[str, Any],
|
||||||
|
agent_id: str,
|
||||||
|
workspace_id: str,
|
||||||
|
session_id: Optional[str] = None,
|
||||||
|
findings: Optional[List[ToolFindingRecord]] = None,
|
||||||
|
) -> None:
|
||||||
|
self.approval_id = approval_id
|
||||||
|
self.tool_name = tool_name
|
||||||
|
self.tool_input = tool_input
|
||||||
|
self.agent_id = agent_id
|
||||||
|
self.workspace_id = workspace_id
|
||||||
|
self.session_id = session_id
|
||||||
|
self.status = ApprovalStatus.PENDING
|
||||||
|
self.findings = findings or []
|
||||||
|
self.created_at = datetime.utcnow()
|
||||||
|
self.resolved_at: Optional[datetime] = None
|
||||||
|
self.resolved_by: Optional[str] = None
|
||||||
|
self.metadata: Dict[str, Any] = {}
|
||||||
|
self.pending_request: "ToolApprovalRequest" | None = None
|
||||||
|
|
||||||
|
def to_dict(self) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"approval_id": self.approval_id,
|
||||||
|
"status": self.status.value,
|
||||||
|
"tool_name": self.tool_name,
|
||||||
|
"tool_input": self.tool_input,
|
||||||
|
"agent_id": self.agent_id,
|
||||||
|
"workspace_id": self.workspace_id,
|
||||||
|
"session_id": self.session_id,
|
||||||
|
"findings": [f.to_dict() for f in self.findings],
|
||||||
|
"created_at": self.created_at.isoformat(),
|
||||||
|
"resolved_at": self.resolved_at.isoformat() if self.resolved_at else None,
|
||||||
|
"resolved_by": self.resolved_by,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class ToolGuardStore:
|
||||||
|
"""Simple in-memory approval store for development/testing."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._records: Dict[str, ApprovalRecord] = {}
|
||||||
|
self._counter = 0
|
||||||
|
|
||||||
|
def next_id(self) -> str:
|
||||||
|
self._counter += 1
|
||||||
|
return f"approval_{self._counter:06d}"
|
||||||
|
|
||||||
|
def list(
|
||||||
|
self,
|
||||||
|
status: ApprovalStatus | None = None,
|
||||||
|
workspace_id: Optional[str] = None,
|
||||||
|
agent_id: Optional[str] = None,
|
||||||
|
) -> Iterable[ApprovalRecord]:
|
||||||
|
for record in self._records.values():
|
||||||
|
if status and record.status != status:
|
||||||
|
continue
|
||||||
|
if workspace_id and record.workspace_id != workspace_id:
|
||||||
|
continue
|
||||||
|
if agent_id and record.agent_id != agent_id:
|
||||||
|
continue
|
||||||
|
yield record
|
||||||
|
|
||||||
|
def get(self, approval_id: str) -> Optional[ApprovalRecord]:
|
||||||
|
return self._records.get(approval_id)
|
||||||
|
|
||||||
|
def create_pending(
|
||||||
|
self,
|
||||||
|
tool_name: str,
|
||||||
|
tool_input: Dict[str, Any],
|
||||||
|
agent_id: str,
|
||||||
|
workspace_id: str,
|
||||||
|
session_id: Optional[str] = None,
|
||||||
|
findings: Optional[List[ToolFindingRecord]] = None,
|
||||||
|
) -> ApprovalRecord:
|
||||||
|
record = ApprovalRecord(
|
||||||
|
approval_id=self.next_id(),
|
||||||
|
tool_name=tool_name,
|
||||||
|
tool_input=tool_input,
|
||||||
|
agent_id=agent_id,
|
||||||
|
workspace_id=workspace_id,
|
||||||
|
session_id=session_id,
|
||||||
|
findings=findings,
|
||||||
|
)
|
||||||
|
self._records[record.approval_id] = record
|
||||||
|
return record
|
||||||
|
|
||||||
|
def set_status(
|
||||||
|
self,
|
||||||
|
approval_id: str,
|
||||||
|
status: ApprovalStatus,
|
||||||
|
resolved_by: Optional[str] = None,
|
||||||
|
notify_request: bool = True,
|
||||||
|
) -> ApprovalRecord:
|
||||||
|
record = self._records[approval_id]
|
||||||
|
if record.status == status:
|
||||||
|
return record
|
||||||
|
|
||||||
|
record.status = status
|
||||||
|
record.resolved_at = datetime.utcnow()
|
||||||
|
record.resolved_by = resolved_by
|
||||||
|
if notify_request and record.pending_request:
|
||||||
|
if status == ApprovalStatus.APPROVED:
|
||||||
|
record.pending_request.approve()
|
||||||
|
elif status == ApprovalStatus.DENIED:
|
||||||
|
record.pending_request.deny()
|
||||||
|
return record
|
||||||
|
|
||||||
|
def cancel(self, approval_id: str) -> None:
|
||||||
|
self._records.pop(approval_id, None)
|
||||||
|
|
||||||
|
|
||||||
|
TOOL_GUARD_STORE = ToolGuardStore()
|
||||||
|
|
||||||
|
|
||||||
|
def get_tool_guard_store() -> ToolGuardStore:
|
||||||
|
return TOOL_GUARD_STORE
|
||||||
|
|
||||||
|
|
||||||
|
# Default tools that require approval
|
||||||
|
DEFAULT_GUARDED_TOOLS: Set[str] = {
|
||||||
|
"execute_shell_command",
|
||||||
|
"write_file",
|
||||||
|
"edit_file",
|
||||||
|
"place_order",
|
||||||
|
"modify_position",
|
||||||
|
"delete_file",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Default denied tools (cannot be approved)
|
||||||
|
DEFAULT_DENIED_TOOLS: Set[str] = {
|
||||||
|
"execute_shell_command", # Shell execution is dangerous
|
||||||
|
}
|
||||||
|
|
||||||
|
# Mark for tool guard denied messages
|
||||||
|
TOOL_GUARD_DENIED_MARK = "tool_guard_denied"
|
||||||
|
|
||||||
|
|
||||||
|
def default_findings_for_tool(tool_name: str) -> List[ToolFindingRecord]:
|
||||||
|
findings: List[ToolFindingRecord] = []
|
||||||
|
if tool_name in {"execute_trade", "modify_portfolio"}:
|
||||||
|
findings.append(
|
||||||
|
ToolFindingRecord(
|
||||||
|
severity=SeverityLevel.HIGH,
|
||||||
|
message=f"Tool '{tool_name}' touches portfolio state",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return findings
|
||||||
|
|
||||||
|
|
||||||
|
class ToolApprovalRequest:
|
||||||
|
"""Represents a pending tool approval request."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
approval_id: str,
|
||||||
|
tool_name: str,
|
||||||
|
tool_input: Dict[str, Any],
|
||||||
|
tool_call_id: str,
|
||||||
|
session_id: Optional[str] = None,
|
||||||
|
):
|
||||||
|
self.approval_id = approval_id
|
||||||
|
self.tool_name = tool_name
|
||||||
|
self.tool_input = tool_input
|
||||||
|
self.tool_call_id = tool_call_id
|
||||||
|
self.session_id = session_id
|
||||||
|
self.approved: Optional[bool] = None
|
||||||
|
self._event = asyncio.Event()
|
||||||
|
|
||||||
|
async def wait_for_approval(self, timeout: Optional[float] = None) -> bool:
|
||||||
|
"""Wait for approval decision.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
timeout: Maximum time to wait in seconds
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if approved, False otherwise
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(self._event.wait(), timeout=timeout)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
return False
|
||||||
|
return self.approved is True
|
||||||
|
|
||||||
|
def approve(self) -> None:
|
||||||
|
"""Approve this request."""
|
||||||
|
self.approved = True
|
||||||
|
self._event.set()
|
||||||
|
|
||||||
|
def deny(self) -> None:
|
||||||
|
"""Deny this request."""
|
||||||
|
self.approved = False
|
||||||
|
self._event.set()
|
||||||
|
|
||||||
|
|
||||||
|
class ToolGuardMixin:
|
||||||
|
"""Mixin that adds tool-guard interception to a ReActAgent.
|
||||||
|
|
||||||
|
At runtime this class is combined with ReActAgent via MRO,
|
||||||
|
so ``super()._acting`` and ``super()._reasoning`` resolve to
|
||||||
|
the concrete agent methods.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
class MyAgent(ToolGuardMixin, ReActAgent):
|
||||||
|
def __init__(self, ...):
|
||||||
|
super().__init__(...)
|
||||||
|
self._init_tool_guard()
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _init_tool_guard(
|
||||||
|
self,
|
||||||
|
guarded_tools: Optional[Set[str]] = None,
|
||||||
|
denied_tools: Optional[Set[str]] = None,
|
||||||
|
approval_timeout: float = 300.0,
|
||||||
|
) -> None:
|
||||||
|
"""Initialize tool guard.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
guarded_tools: Set of tool names requiring approval
|
||||||
|
denied_tools: Set of tool names that are always denied
|
||||||
|
approval_timeout: Timeout for approval requests in seconds
|
||||||
|
"""
|
||||||
|
self._guarded_tools = guarded_tools or DEFAULT_GUARDED_TOOLS.copy()
|
||||||
|
self._denied_tools = denied_tools or DEFAULT_DENIED_TOOLS.copy()
|
||||||
|
self._approval_timeout = approval_timeout
|
||||||
|
self._pending_approval: Optional[ToolApprovalRequest] = None
|
||||||
|
self._approval_callback: Optional[Callable[[ToolApprovalRequest], None]] = None
|
||||||
|
|
||||||
|
def set_approval_callback(
|
||||||
|
self,
|
||||||
|
callback: Callable[[ToolApprovalRequest], None],
|
||||||
|
) -> None:
|
||||||
|
"""Set callback for approval requests.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
callback: Function called when approval is needed
|
||||||
|
"""
|
||||||
|
self._approval_callback = callback
|
||||||
|
|
||||||
|
def _is_tool_guarded(self, tool_name: str) -> bool:
|
||||||
|
"""Check if a tool requires approval.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tool_name: Name of the tool
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if tool requires approval
|
||||||
|
"""
|
||||||
|
return tool_name in self._guarded_tools
|
||||||
|
|
||||||
|
def _is_tool_denied(self, tool_name: str) -> bool:
|
||||||
|
"""Check if a tool is always denied.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tool_name: Name of the tool
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if tool is denied
|
||||||
|
"""
|
||||||
|
return tool_name in self._denied_tools
|
||||||
|
|
||||||
|
def _last_tool_response_is_denied(self) -> bool:
|
||||||
|
"""Check if the last message is a guard-denied tool result."""
|
||||||
|
if not hasattr(self, "memory") or not self.memory.content:
|
||||||
|
return False
|
||||||
|
|
||||||
|
msg, marks = self.memory.content[-1]
|
||||||
|
return TOOL_GUARD_DENIED_MARK in marks and msg.role == "system"
|
||||||
|
|
||||||
|
async def _cleanup_tool_guard_denied_messages(
|
||||||
|
self,
|
||||||
|
include_denial_response: bool = True,
|
||||||
|
) -> None:
|
||||||
|
"""Remove tool-guard denied messages from memory.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
include_denial_response: Also remove the assistant's denial explanation
|
||||||
|
"""
|
||||||
|
if not hasattr(self, "memory"):
|
||||||
|
return
|
||||||
|
|
||||||
|
ids_to_delete: list[str] = []
|
||||||
|
last_marked_idx = -1
|
||||||
|
|
||||||
|
for i, (msg, marks) in enumerate(self.memory.content):
|
||||||
|
if TOOL_GUARD_DENIED_MARK in marks:
|
||||||
|
ids_to_delete.append(msg.id)
|
||||||
|
last_marked_idx = i
|
||||||
|
|
||||||
|
if (
|
||||||
|
include_denial_response
|
||||||
|
and last_marked_idx >= 0
|
||||||
|
and last_marked_idx + 1 < len(self.memory.content)
|
||||||
|
):
|
||||||
|
next_msg, _ = self.memory.content[last_marked_idx + 1]
|
||||||
|
if next_msg.role == "assistant":
|
||||||
|
ids_to_delete.append(next_msg.id)
|
||||||
|
|
||||||
|
if ids_to_delete:
|
||||||
|
removed = await self.memory.delete(ids_to_delete)
|
||||||
|
logger.info("Tool guard: cleaned up %d denied message(s)", removed)
|
||||||
|
|
||||||
|
async def _request_guard_approval(
|
||||||
|
self,
|
||||||
|
tool_name: str,
|
||||||
|
tool_input: Dict[str, Any],
|
||||||
|
tool_call_id: str,
|
||||||
|
) -> bool:
|
||||||
|
"""Request approval for a guarded tool call.
|
||||||
|
|
||||||
|
This method creates a ToolApprovalRequest and waits for
|
||||||
|
external approval via approve_guard_call() or deny_guard_call().
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tool_name: Name of the tool
|
||||||
|
tool_input: Tool input parameters
|
||||||
|
tool_call_id: ID of the tool call
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if approved, False otherwise
|
||||||
|
"""
|
||||||
|
record = TOOL_GUARD_STORE.create_pending(
|
||||||
|
tool_name=tool_name,
|
||||||
|
tool_input=tool_input,
|
||||||
|
agent_id=getattr(self, "agent_id", "unknown"),
|
||||||
|
workspace_id=getattr(self, "workspace_id", "default"),
|
||||||
|
session_id=getattr(self, "session_id", None),
|
||||||
|
findings=default_findings_for_tool(tool_name),
|
||||||
|
)
|
||||||
|
|
||||||
|
manager = get_global_runtime_manager()
|
||||||
|
if manager:
|
||||||
|
manager.register_pending_approval(
|
||||||
|
record.approval_id,
|
||||||
|
{
|
||||||
|
"tool_name": record.tool_name,
|
||||||
|
"agent_id": record.agent_id,
|
||||||
|
"workspace_id": record.workspace_id,
|
||||||
|
"session_id": record.session_id,
|
||||||
|
"tool_input": record.tool_input,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
self._pending_approval = ToolApprovalRequest(
|
||||||
|
approval_id=record.approval_id,
|
||||||
|
tool_name=tool_name,
|
||||||
|
tool_input=tool_input,
|
||||||
|
tool_call_id=tool_call_id,
|
||||||
|
session_id=getattr(self, "session_id", None),
|
||||||
|
)
|
||||||
|
record.pending_request = self._pending_approval
|
||||||
|
|
||||||
|
# Notify via callback if set
|
||||||
|
if self._approval_callback:
|
||||||
|
self._approval_callback(self._pending_approval)
|
||||||
|
|
||||||
|
# Wait for approval
|
||||||
|
approval_request = self._pending_approval
|
||||||
|
approved = await approval_request.wait_for_approval(
|
||||||
|
timeout=self._approval_timeout
|
||||||
|
)
|
||||||
|
|
||||||
|
if approval_request:
|
||||||
|
status = (
|
||||||
|
ApprovalStatus.APPROVED
|
||||||
|
if approval_request.approved is True
|
||||||
|
else ApprovalStatus.DENIED
|
||||||
|
if approval_request.approved is False
|
||||||
|
else ApprovalStatus.EXPIRED
|
||||||
|
)
|
||||||
|
TOOL_GUARD_STORE.set_status(
|
||||||
|
approval_request.approval_id,
|
||||||
|
status,
|
||||||
|
resolved_by="agent",
|
||||||
|
notify_request=False,
|
||||||
|
)
|
||||||
|
manager = get_global_runtime_manager()
|
||||||
|
if manager:
|
||||||
|
manager.resolve_pending_approval(
|
||||||
|
approval_request.approval_id,
|
||||||
|
resolved_by="agent",
|
||||||
|
status=status.value,
|
||||||
|
)
|
||||||
|
|
||||||
|
self._pending_approval = None
|
||||||
|
return approved
|
||||||
|
|
||||||
|
def approve_guard_call(self, request_id: Optional[str] = None) -> bool:
|
||||||
|
"""Approve a pending guard request.
|
||||||
|
|
||||||
|
This method is called externally to approve a tool call
|
||||||
|
that is waiting for approval.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request_id: Optional request ID to verify (not yet implemented)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if a request was approved, False if no pending request
|
||||||
|
"""
|
||||||
|
if self._pending_approval is None:
|
||||||
|
logger.warning("No pending approval request to approve")
|
||||||
|
return False
|
||||||
|
|
||||||
|
TOOL_GUARD_STORE.set_status(
|
||||||
|
self._pending_approval.approval_id,
|
||||||
|
ApprovalStatus.APPROVED,
|
||||||
|
resolved_by="agent",
|
||||||
|
notify_request=False,
|
||||||
|
)
|
||||||
|
manager = get_global_runtime_manager()
|
||||||
|
if manager:
|
||||||
|
manager.resolve_pending_approval(
|
||||||
|
self._pending_approval.approval_id,
|
||||||
|
resolved_by="agent",
|
||||||
|
status=ApprovalStatus.APPROVED.value,
|
||||||
|
)
|
||||||
|
self._pending_approval.approve()
|
||||||
|
logger.info("Approved tool call: %s", self._pending_approval.tool_name)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def deny_guard_call(self, request_id: Optional[str] = None) -> bool:
|
||||||
|
"""Deny a pending guard request.
|
||||||
|
|
||||||
|
This method is called externally to deny a tool call
|
||||||
|
that is waiting for approval.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request_id: Optional request ID to verify (not yet implemented)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if a request was denied, False if no pending request
|
||||||
|
"""
|
||||||
|
if self._pending_approval is None:
|
||||||
|
logger.warning("No pending approval request to deny")
|
||||||
|
return False
|
||||||
|
|
||||||
|
TOOL_GUARD_STORE.set_status(
|
||||||
|
self._pending_approval.approval_id,
|
||||||
|
ApprovalStatus.DENIED,
|
||||||
|
resolved_by="agent",
|
||||||
|
notify_request=False,
|
||||||
|
)
|
||||||
|
manager = get_global_runtime_manager()
|
||||||
|
if manager:
|
||||||
|
manager.resolve_pending_approval(
|
||||||
|
self._pending_approval.approval_id,
|
||||||
|
resolved_by="agent",
|
||||||
|
status=ApprovalStatus.DENIED.value,
|
||||||
|
)
|
||||||
|
self._pending_approval.deny()
|
||||||
|
logger.info("Denied tool call: %s", self._pending_approval.tool_name)
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def _acting(self, tool_call) -> dict | None:
|
||||||
|
"""Intercept sensitive tool calls before execution.
|
||||||
|
|
||||||
|
1. If tool is in denied_tools, auto-deny unconditionally.
|
||||||
|
2. Check for a one-shot pre-approval.
|
||||||
|
3. If tool is in the guarded scope, request approval.
|
||||||
|
4. Otherwise, delegate to parent _acting.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tool_call: Tool call from the model
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tool result dict or None
|
||||||
|
"""
|
||||||
|
tool_name: str = tool_call.get("name", "")
|
||||||
|
tool_input: dict = tool_call.get("input", {})
|
||||||
|
tool_call_id: str = tool_call.get("id", "")
|
||||||
|
|
||||||
|
# Check if tool is denied
|
||||||
|
if tool_name and self._is_tool_denied(tool_name):
|
||||||
|
logger.warning("Tool '%s' is in the denied set, auto-denying", tool_name)
|
||||||
|
return await self._acting_auto_denied(tool_call, tool_name)
|
||||||
|
|
||||||
|
# Check if tool is guarded
|
||||||
|
if tool_name and self._is_tool_guarded(tool_name):
|
||||||
|
approved = await self._request_guard_approval(
|
||||||
|
tool_name=tool_name,
|
||||||
|
tool_input=tool_input,
|
||||||
|
tool_call_id=tool_call_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not approved:
|
||||||
|
return await self._acting_with_denial(tool_call, tool_name)
|
||||||
|
|
||||||
|
# Call parent _acting
|
||||||
|
return await super()._acting(tool_call) # type: ignore[misc]
|
||||||
|
|
||||||
|
async def _acting_auto_denied(
|
||||||
|
self,
|
||||||
|
tool_call: Dict[str, Any],
|
||||||
|
tool_name: str,
|
||||||
|
) -> dict | None:
|
||||||
|
"""Auto-deny a tool call without offering approval.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tool_call: Tool call from the model
|
||||||
|
tool_name: Name of the denied tool
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Denial result
|
||||||
|
"""
|
||||||
|
from agentscope.message import ToolResultBlock
|
||||||
|
|
||||||
|
denied_text = (
|
||||||
|
f"⛔ **Tool Blocked / 工具已拦截**\n\n"
|
||||||
|
f"- Tool / 工具: `{tool_name}`\n"
|
||||||
|
f"- Reason / 原因: This tool is blocked for security reasons\n\n"
|
||||||
|
f"This tool is blocked and cannot be approved.\n"
|
||||||
|
f"该工具已被禁止,无法批准执行。"
|
||||||
|
)
|
||||||
|
|
||||||
|
tool_res_msg = Msg(
|
||||||
|
"system",
|
||||||
|
[
|
||||||
|
ToolResultBlock(
|
||||||
|
type="tool_result",
|
||||||
|
id=tool_call.get("id", ""),
|
||||||
|
name=tool_name,
|
||||||
|
output=[{"type": "text", "text": denied_text}],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
"system",
|
||||||
|
)
|
||||||
|
|
||||||
|
await self.print(tool_res_msg, True)
|
||||||
|
await self.memory.add(tool_res_msg)
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def _acting_with_denial(
|
||||||
|
self,
|
||||||
|
tool_call: Dict[str, Any],
|
||||||
|
tool_name: str,
|
||||||
|
) -> dict | None:
|
||||||
|
"""Deny the tool call after approval was rejected.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tool_call: Tool call from the model
|
||||||
|
tool_name: Name of the tool
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Denial result
|
||||||
|
"""
|
||||||
|
from agentscope.message import ToolResultBlock
|
||||||
|
|
||||||
|
params_text = json.dumps(
|
||||||
|
tool_call.get("input", {}),
|
||||||
|
ensure_ascii=False,
|
||||||
|
indent=2,
|
||||||
|
)
|
||||||
|
|
||||||
|
denied_text = (
|
||||||
|
f"⚠️ **Tool Call Denied / 工具调用被拒绝**\n\n"
|
||||||
|
f"- Tool / 工具: `{tool_name}`\n"
|
||||||
|
f"- Parameters / 参数:\n"
|
||||||
|
f"```json\n{params_text}\n```\n\n"
|
||||||
|
f"The tool call was denied by the user or timed out.\n"
|
||||||
|
f"工具调用被用户拒绝或已超时。"
|
||||||
|
)
|
||||||
|
|
||||||
|
tool_res_msg = Msg(
|
||||||
|
"system",
|
||||||
|
[
|
||||||
|
ToolResultBlock(
|
||||||
|
type="tool_result",
|
||||||
|
id=tool_call.get("id", ""),
|
||||||
|
name=tool_name,
|
||||||
|
output=[{"type": "text", "text": denied_text}],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
"system",
|
||||||
|
)
|
||||||
|
|
||||||
|
await self.print(tool_res_msg, True)
|
||||||
|
await self.memory.add(tool_res_msg, marks=TOOL_GUARD_DENIED_MARK)
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def _reasoning(self, **kwargs) -> Msg:
|
||||||
|
"""Short-circuit reasoning when awaiting guard approval.
|
||||||
|
|
||||||
|
If the last message was a guard denial, return a waiting message
|
||||||
|
instead of continuing reasoning.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Response message
|
||||||
|
"""
|
||||||
|
if self._last_tool_response_is_denied():
|
||||||
|
msg = Msg(
|
||||||
|
self.name,
|
||||||
|
"⏳ Waiting for approval / 等待审批...\n\n"
|
||||||
|
"Type `/approve` to approve, or send any message to deny.\n"
|
||||||
|
"输入 `/approve` 批准执行,或发送任意消息拒绝。",
|
||||||
|
"assistant",
|
||||||
|
)
|
||||||
|
await self.print(msg, True)
|
||||||
|
await self.memory.add(msg)
|
||||||
|
return msg
|
||||||
|
|
||||||
|
return await super()._reasoning(**kwargs) # type: ignore[misc]
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"ToolGuardMixin",
|
||||||
|
"ToolApprovalRequest",
|
||||||
|
"DEFAULT_GUARDED_TOOLS",
|
||||||
|
"DEFAULT_DENIED_TOOLS",
|
||||||
|
"TOOL_GUARD_DENIED_MARK",
|
||||||
|
]
|
||||||
146
backend/agents/compat.py
Normal file
146
backend/agents/compat.py
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
Compatibility Layer - Adapters for legacy to EvoAgent migration.
|
||||||
|
|
||||||
|
Provides:
|
||||||
|
- LegacyAgentAdapter: Wraps old AnalystAgent to work with new interfaces
|
||||||
|
- Migration utilities for gradual adoption
|
||||||
|
"""
|
||||||
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
|
from agentscope.message import Msg
|
||||||
|
|
||||||
|
from .agent_core import EvoAgent
|
||||||
|
|
||||||
|
|
||||||
|
class LegacyAgentAdapter:
|
||||||
|
"""
|
||||||
|
Adapter to make legacy AnalystAgent compatible with EvoAgent interfaces.
|
||||||
|
|
||||||
|
This allows gradual migration by wrapping existing agents.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, legacy_agent: Any):
|
||||||
|
"""
|
||||||
|
Initialize adapter.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
legacy_agent: Legacy AnalystAgent instance
|
||||||
|
"""
|
||||||
|
self._agent = legacy_agent
|
||||||
|
self.agent_id = getattr(legacy_agent, 'agent_id', getattr(legacy_agent, 'name', 'unknown'))
|
||||||
|
self.analyst_type = getattr(legacy_agent, 'analyst_type_key', None)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def name(self) -> str:
|
||||||
|
"""Get agent name."""
|
||||||
|
return getattr(self._agent, 'name', self.agent_id)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def toolkit(self) -> Any:
|
||||||
|
"""Get agent toolkit."""
|
||||||
|
return getattr(self._agent, 'toolkit', None)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def model(self) -> Any:
|
||||||
|
"""Get agent model."""
|
||||||
|
return getattr(self._agent, 'model', None)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def memory(self) -> Any:
|
||||||
|
"""Get agent memory."""
|
||||||
|
return getattr(self._agent, 'memory', None)
|
||||||
|
|
||||||
|
async def reply(self, x: Msg = None) -> Msg:
|
||||||
|
"""
|
||||||
|
Delegate to legacy agent's reply method.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
x: Input message
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Response message
|
||||||
|
"""
|
||||||
|
return await self._agent.reply(x)
|
||||||
|
|
||||||
|
def reload_runtime_assets(self, active_skill_dirs: Optional[list] = None) -> None:
|
||||||
|
"""
|
||||||
|
Reload runtime assets if supported.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
active_skill_dirs: Optional list of active skill directories
|
||||||
|
"""
|
||||||
|
if hasattr(self._agent, 'reload_runtime_assets'):
|
||||||
|
self._agent.reload_runtime_assets(active_skill_dirs)
|
||||||
|
|
||||||
|
def to_evo_agent(
|
||||||
|
self,
|
||||||
|
workspace_manager: Optional[Any] = None,
|
||||||
|
enable_tool_guard: bool = False,
|
||||||
|
) -> EvoAgent:
|
||||||
|
"""
|
||||||
|
Convert legacy agent to EvoAgent.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
workspace_manager: Optional workspace manager
|
||||||
|
enable_tool_guard: Whether to enable tool guard
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
New EvoAgent instance with same configuration
|
||||||
|
"""
|
||||||
|
return EvoAgent(
|
||||||
|
agent_id=self.agent_id,
|
||||||
|
model=self.model,
|
||||||
|
formatter=getattr(self._agent, 'formatter', None),
|
||||||
|
toolkit=self.toolkit,
|
||||||
|
workspace_manager=workspace_manager,
|
||||||
|
config=getattr(self._agent, 'config', {}),
|
||||||
|
long_term_memory=getattr(self._agent, 'long_term_memory', None),
|
||||||
|
enable_tool_guard=enable_tool_guard,
|
||||||
|
sys_prompt=getattr(self._agent, '_sys_prompt', None),
|
||||||
|
)
|
||||||
|
|
||||||
|
def __getattr__(self, name: str) -> Any:
|
||||||
|
"""Delegate unknown attributes to wrapped agent."""
|
||||||
|
return getattr(self._agent, name)
|
||||||
|
|
||||||
|
|
||||||
|
def is_legacy_agent(agent: Any) -> bool:
|
||||||
|
"""
|
||||||
|
Check if an agent is a legacy agent.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
agent: Agent instance to check
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if legacy agent
|
||||||
|
"""
|
||||||
|
return hasattr(agent, 'analyst_type_key') and not isinstance(agent, EvoAgent)
|
||||||
|
|
||||||
|
|
||||||
|
def adapt_agent(agent: Any) -> Any:
|
||||||
|
"""
|
||||||
|
Wrap agent in adapter if it's a legacy agent.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
agent: Agent instance
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Adapted agent or original if already EvoAgent
|
||||||
|
"""
|
||||||
|
if is_legacy_agent(agent):
|
||||||
|
return LegacyAgentAdapter(agent)
|
||||||
|
return agent
|
||||||
|
|
||||||
|
|
||||||
|
def adapt_agents(agents: list) -> list:
|
||||||
|
"""
|
||||||
|
Wrap multiple agents in adapters.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
agents: List of agent instances
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of adapted agents
|
||||||
|
"""
|
||||||
|
return [adapt_agent(agent) for agent in agents]
|
||||||
495
backend/agents/factory.py
Normal file
495
backend/agents/factory.py
Normal file
@@ -0,0 +1,495 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Agent Factory - Dynamic creation and management of EvoAgents."""
|
||||||
|
|
||||||
|
import shutil
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ModelConfig:
|
||||||
|
"""Model configuration for an agent."""
|
||||||
|
|
||||||
|
model_name: str = "gpt-4o"
|
||||||
|
temperature: float = 0.7
|
||||||
|
max_tokens: int = 4096
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class RoleConfig:
|
||||||
|
"""Role configuration for an agent."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
description: str = ""
|
||||||
|
focus_areas: List[str] = None
|
||||||
|
constraints: List[str] = None
|
||||||
|
|
||||||
|
def __post_init__(self):
|
||||||
|
if self.focus_areas is None:
|
||||||
|
self.focus_areas = []
|
||||||
|
if self.constraints is None:
|
||||||
|
self.constraints = []
|
||||||
|
|
||||||
|
|
||||||
|
class EvoAgent:
|
||||||
|
"""Represents a configured agent instance."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
agent_id: str,
|
||||||
|
agent_type: str,
|
||||||
|
workspace_id: str,
|
||||||
|
config_path: Path,
|
||||||
|
model_config: Optional[ModelConfig] = None,
|
||||||
|
role_config: Optional[RoleConfig] = None,
|
||||||
|
):
|
||||||
|
self.agent_id = agent_id
|
||||||
|
self.agent_type = agent_type
|
||||||
|
self.workspace_id = workspace_id
|
||||||
|
self.config_path = config_path
|
||||||
|
self.model_config = model_config or ModelConfig()
|
||||||
|
self.role_config = role_config
|
||||||
|
self.agent_dir = config_path.parent
|
||||||
|
|
||||||
|
def to_dict(self) -> Dict[str, Any]:
|
||||||
|
"""Serialize agent to dictionary."""
|
||||||
|
return {
|
||||||
|
"agent_id": self.agent_id,
|
||||||
|
"agent_type": self.agent_type,
|
||||||
|
"workspace_id": self.workspace_id,
|
||||||
|
"config_path": str(self.config_path),
|
||||||
|
"agent_dir": str(self.agent_dir),
|
||||||
|
"model_config": {
|
||||||
|
"model_name": self.model_config.model_name,
|
||||||
|
"temperature": self.model_config.temperature,
|
||||||
|
"max_tokens": self.model_config.max_tokens,
|
||||||
|
},
|
||||||
|
"role_config": self.role_config.__dict__ if self.role_config else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class AgentFactory:
|
||||||
|
"""Factory for creating, cloning, and managing agents."""
|
||||||
|
|
||||||
|
# Default role templates by agent type
|
||||||
|
ROLE_TEMPLATES = {
|
||||||
|
"technical_analyst": {
|
||||||
|
"name": "Technical Analyst",
|
||||||
|
"description": "Analyze price patterns, trends, and technical indicators.",
|
||||||
|
"focus_areas": [
|
||||||
|
"Price action and chart patterns",
|
||||||
|
"Support and resistance levels",
|
||||||
|
"Technical indicators (RSI, MACD, Moving Averages)",
|
||||||
|
"Volume analysis",
|
||||||
|
],
|
||||||
|
"constraints": [
|
||||||
|
"State clear signal, confidence, and invalidation conditions",
|
||||||
|
"Use available technical analysis tools",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"fundamentals_analyst": {
|
||||||
|
"name": "Fundamentals Analyst",
|
||||||
|
"description": "Analyze company financials, earnings, and business metrics.",
|
||||||
|
"focus_areas": [
|
||||||
|
"Financial statements analysis",
|
||||||
|
"Earnings reports and guidance",
|
||||||
|
"Valuation metrics",
|
||||||
|
"Business model and competitive position",
|
||||||
|
],
|
||||||
|
"constraints": [
|
||||||
|
"State clear signal, confidence, and invalidation conditions",
|
||||||
|
"Use available fundamental analysis tools",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"sentiment_analyst": {
|
||||||
|
"name": "Sentiment Analyst",
|
||||||
|
"description": "Analyze market sentiment, news, and social signals.",
|
||||||
|
"focus_areas": [
|
||||||
|
"News sentiment analysis",
|
||||||
|
"Social media sentiment",
|
||||||
|
"Analyst ratings and price targets",
|
||||||
|
"Insider activity",
|
||||||
|
],
|
||||||
|
"constraints": [
|
||||||
|
"State clear signal, confidence, and invalidation conditions",
|
||||||
|
"Use available sentiment analysis tools",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"valuation_analyst": {
|
||||||
|
"name": "Valuation Analyst",
|
||||||
|
"description": "Perform valuation analysis and price target calculations.",
|
||||||
|
"focus_areas": [
|
||||||
|
"DCF and comparable valuation",
|
||||||
|
"Price target derivation",
|
||||||
|
"Margin of safety assessment",
|
||||||
|
"Risk-adjusted return expectations",
|
||||||
|
],
|
||||||
|
"constraints": [
|
||||||
|
"State clear signal, confidence, and invalidation conditions",
|
||||||
|
"Use available valuation tools",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"risk_manager": {
|
||||||
|
"name": "Risk Manager",
|
||||||
|
"description": "Quantify concentration, leverage, liquidity, and volatility risk.",
|
||||||
|
"focus_areas": [
|
||||||
|
"Portfolio concentration risk",
|
||||||
|
"Leverage and margin analysis",
|
||||||
|
"Liquidity assessment",
|
||||||
|
"Volatility and drawdown risk",
|
||||||
|
],
|
||||||
|
"constraints": [
|
||||||
|
"Prioritize highest-severity risk first",
|
||||||
|
"State concrete limits and recommendations",
|
||||||
|
"Use available risk tools before issuing final memo",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"portfolio_manager": {
|
||||||
|
"name": "Portfolio Manager",
|
||||||
|
"description": "Synthesize analyst and risk inputs into portfolio decisions.",
|
||||||
|
"focus_areas": [
|
||||||
|
"Position sizing and allocation",
|
||||||
|
"Risk-adjusted portfolio construction",
|
||||||
|
"Trade execution timing",
|
||||||
|
"Portfolio rebalancing",
|
||||||
|
],
|
||||||
|
"constraints": [
|
||||||
|
"Be concise, capital-aware, and explicit about sizing rationale",
|
||||||
|
"Respect cash, margin, and concentration constraints",
|
||||||
|
"Consider all analyst inputs before decisions",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(self, project_root: Optional[Path] = None):
|
||||||
|
"""Initialize the agent factory.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
project_root: Root directory of the project
|
||||||
|
"""
|
||||||
|
self.project_root = project_root or Path(__file__).parent.parent.parent
|
||||||
|
self.workspaces_root = self.project_root / "workspaces"
|
||||||
|
self.template_dir = self.project_root / "backend" / "workspaces" / ".template"
|
||||||
|
|
||||||
|
def create_agent(
|
||||||
|
self,
|
||||||
|
agent_id: str,
|
||||||
|
agent_type: str,
|
||||||
|
workspace_id: str,
|
||||||
|
model_config: Optional[ModelConfig] = None,
|
||||||
|
role_config: Optional[RoleConfig] = None,
|
||||||
|
clone_from: Optional[str] = None,
|
||||||
|
) -> EvoAgent:
|
||||||
|
"""Create a new agent.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
agent_id: Unique identifier for the agent
|
||||||
|
agent_type: Type of agent (e.g., "technical_analyst")
|
||||||
|
workspace_id: ID of the workspace to create agent in
|
||||||
|
model_config: Model configuration
|
||||||
|
role_config: Role configuration (auto-generated if None)
|
||||||
|
clone_from: Path to existing agent to clone from (optional)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
EvoAgent instance
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If agent already exists or workspace doesn't exist
|
||||||
|
"""
|
||||||
|
workspace_dir = self.workspaces_root / workspace_id
|
||||||
|
if not workspace_dir.exists():
|
||||||
|
raise ValueError(f"Workspace '{workspace_id}' does not exist")
|
||||||
|
|
||||||
|
agent_dir = workspace_dir / "agents" / agent_id
|
||||||
|
if agent_dir.exists():
|
||||||
|
raise ValueError(f"Agent '{agent_id}' already exists in workspace '{workspace_id}'")
|
||||||
|
|
||||||
|
# Create directory structure
|
||||||
|
agent_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
(agent_dir / "skills" / "active").mkdir(parents=True, exist_ok=True)
|
||||||
|
(agent_dir / "skills" / "local").mkdir(parents=True, exist_ok=True)
|
||||||
|
(agent_dir / "skills" / "installed").mkdir(parents=True, exist_ok=True)
|
||||||
|
(agent_dir / "skills" / "disabled").mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Copy template or clone existing agent
|
||||||
|
if clone_from:
|
||||||
|
self._clone_agent_files(clone_from, agent_dir, agent_id)
|
||||||
|
else:
|
||||||
|
self._copy_template(agent_dir, agent_id, agent_type)
|
||||||
|
|
||||||
|
# Generate role config if not provided
|
||||||
|
if role_config is None:
|
||||||
|
role_config = self._generate_role_config(agent_type)
|
||||||
|
|
||||||
|
# Generate ROLE.md
|
||||||
|
self._generate_role_md(agent_dir, role_config)
|
||||||
|
|
||||||
|
# Write agent.yaml
|
||||||
|
config_path = agent_dir / "agent.yaml"
|
||||||
|
self._write_agent_yaml(config_path, agent_id, agent_type, model_config)
|
||||||
|
|
||||||
|
return EvoAgent(
|
||||||
|
agent_id=agent_id,
|
||||||
|
agent_type=agent_type,
|
||||||
|
workspace_id=workspace_id,
|
||||||
|
config_path=config_path,
|
||||||
|
model_config=model_config,
|
||||||
|
role_config=role_config,
|
||||||
|
)
|
||||||
|
|
||||||
|
def delete_agent(self, agent_id: str, workspace_id: str) -> bool:
|
||||||
|
"""Delete an agent and its workspace.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
agent_id: ID of the agent to delete
|
||||||
|
workspace_id: ID of the workspace containing the agent
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if deleted, False if agent didn't exist
|
||||||
|
"""
|
||||||
|
agent_dir = self.workspaces_root / workspace_id / "agents" / agent_id
|
||||||
|
if not agent_dir.exists():
|
||||||
|
return False
|
||||||
|
|
||||||
|
shutil.rmtree(agent_dir)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def clone_agent(
|
||||||
|
self,
|
||||||
|
source_agent_id: str,
|
||||||
|
source_workspace_id: str,
|
||||||
|
new_agent_id: str,
|
||||||
|
target_workspace_id: Optional[str] = None,
|
||||||
|
model_config: Optional[ModelConfig] = None,
|
||||||
|
) -> EvoAgent:
|
||||||
|
"""Clone an existing agent.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
source_agent_id: ID of the agent to clone
|
||||||
|
source_workspace_id: Workspace containing the source agent
|
||||||
|
new_agent_id: ID for the new agent
|
||||||
|
target_workspace_id: Target workspace (defaults to source workspace)
|
||||||
|
model_config: Optional new model configuration
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
EvoAgent instance for the cloned agent
|
||||||
|
"""
|
||||||
|
target_workspace_id = target_workspace_id or source_workspace_id
|
||||||
|
source_dir = self.workspaces_root / source_workspace_id / "agents" / source_agent_id
|
||||||
|
|
||||||
|
if not source_dir.exists():
|
||||||
|
raise ValueError(f"Source agent '{source_agent_id}' not found")
|
||||||
|
|
||||||
|
# Load source agent config
|
||||||
|
source_config_path = source_dir / "agent.yaml"
|
||||||
|
source_config = {}
|
||||||
|
if source_config_path.exists():
|
||||||
|
with open(source_config_path, "r", encoding="utf-8") as f:
|
||||||
|
source_config = yaml.safe_load(f) or {}
|
||||||
|
|
||||||
|
agent_type = source_config.get("agent_type", "generic")
|
||||||
|
|
||||||
|
# Determine source path for cloning
|
||||||
|
clone_from = str(source_dir)
|
||||||
|
|
||||||
|
return self.create_agent(
|
||||||
|
agent_id=new_agent_id,
|
||||||
|
agent_type=agent_type,
|
||||||
|
workspace_id=target_workspace_id,
|
||||||
|
model_config=model_config,
|
||||||
|
clone_from=clone_from,
|
||||||
|
)
|
||||||
|
|
||||||
|
def list_agents(self, workspace_id: Optional[str] = None) -> List[Dict[str, Any]]:
|
||||||
|
"""List all agents.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
workspace_id: Optional workspace to filter by
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of agent information dictionaries
|
||||||
|
"""
|
||||||
|
agents = []
|
||||||
|
|
||||||
|
if workspace_id:
|
||||||
|
workspaces = [self.workspaces_root / workspace_id]
|
||||||
|
else:
|
||||||
|
if not self.workspaces_root.exists():
|
||||||
|
return agents
|
||||||
|
workspaces = [d for d in self.workspaces_root.iterdir() if d.is_dir()]
|
||||||
|
|
||||||
|
for workspace in workspaces:
|
||||||
|
agents_dir = workspace / "agents"
|
||||||
|
if not agents_dir.exists():
|
||||||
|
continue
|
||||||
|
|
||||||
|
for agent_dir in agents_dir.iterdir():
|
||||||
|
if not agent_dir.is_dir():
|
||||||
|
continue
|
||||||
|
|
||||||
|
config_path = agent_dir / "agent.yaml"
|
||||||
|
if config_path.exists():
|
||||||
|
try:
|
||||||
|
with open(config_path, "r", encoding="utf-8") as f:
|
||||||
|
config = yaml.safe_load(f) or {}
|
||||||
|
|
||||||
|
agents.append({
|
||||||
|
"agent_id": agent_dir.name,
|
||||||
|
"workspace_id": workspace.name,
|
||||||
|
"agent_type": config.get("agent_type", "unknown"),
|
||||||
|
"config_path": str(config_path),
|
||||||
|
})
|
||||||
|
except Exception:
|
||||||
|
# Skip invalid agent configs
|
||||||
|
pass
|
||||||
|
|
||||||
|
return agents
|
||||||
|
|
||||||
|
def _copy_template(
|
||||||
|
self,
|
||||||
|
agent_dir: Path,
|
||||||
|
agent_id: str,
|
||||||
|
agent_type: str,
|
||||||
|
) -> None:
|
||||||
|
"""Copy template files to agent directory.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
agent_dir: Target agent directory
|
||||||
|
agent_id: ID of the agent
|
||||||
|
agent_type: Type of the agent
|
||||||
|
"""
|
||||||
|
# Create default markdown files
|
||||||
|
default_files = {
|
||||||
|
"AGENTS.md": f"# Agent Guide\n\nDocument how {agent_id} should work, collaborate, and choose tools or skills.\n\n",
|
||||||
|
"SOUL.md": f"# Soul\n\nDescribe {agent_id}'s temperament, reasoning posture, and voice.\n\n",
|
||||||
|
"PROFILE.md": f"# Profile\n\nTrack {agent_id}'s long-lived investment style, preferences, and strengths.\n\n",
|
||||||
|
"MEMORY.md": f"# Memory\n\nStore durable lessons, heuristics, and reminders for {agent_id}.\n\n",
|
||||||
|
"HEARTBEAT.md": f"# Heartbeat\n\nOptional checklist for periodic review or self-reflection.\n\n",
|
||||||
|
"POLICY.md": f"# Policy\n\nOptional run-scoped constraints, limits, or strategy policy.\n\n",
|
||||||
|
"STYLE.md": f"# Style\n\nOptional run-scoped communication or reasoning style.\n\n",
|
||||||
|
}
|
||||||
|
|
||||||
|
for filename, content in default_files.items():
|
||||||
|
filepath = agent_dir / filename
|
||||||
|
if not filepath.exists():
|
||||||
|
filepath.write_text(content, encoding="utf-8")
|
||||||
|
|
||||||
|
def _clone_agent_files(self, source_path: str, target_dir: Path, new_agent_id: str) -> None:
|
||||||
|
"""Clone files from an existing agent.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
source_path: Path to source agent directory
|
||||||
|
target_dir: Target agent directory
|
||||||
|
new_agent_id: ID for the new agent
|
||||||
|
"""
|
||||||
|
source_dir = Path(source_path)
|
||||||
|
if not source_dir.exists():
|
||||||
|
raise ValueError(f"Source path '{source_path}' does not exist")
|
||||||
|
|
||||||
|
# Copy markdown files
|
||||||
|
for md_file in source_dir.glob("*.md"):
|
||||||
|
target_file = target_dir / md_file.name
|
||||||
|
content = md_file.read_text(encoding="utf-8")
|
||||||
|
# Update agent references in content
|
||||||
|
source_name = source_dir.name
|
||||||
|
content = content.replace(source_name, new_agent_id)
|
||||||
|
target_file.write_text(content, encoding="utf-8")
|
||||||
|
|
||||||
|
# Copy skills directory structure (but not contents)
|
||||||
|
for skill_subdir in ["active", "local", "installed", "disabled"]:
|
||||||
|
source_skills = source_dir / "skills" / skill_subdir
|
||||||
|
if source_skills.exists():
|
||||||
|
target_skills = target_dir / "skills" / skill_subdir
|
||||||
|
target_skills.mkdir(parents=True, exist_ok=True)
|
||||||
|
# Copy skill files
|
||||||
|
for skill_file in source_skills.iterdir():
|
||||||
|
if skill_file.is_file():
|
||||||
|
shutil.copy2(skill_file, target_skills / skill_file.name)
|
||||||
|
|
||||||
|
def _generate_role_config(self, agent_type: str) -> RoleConfig:
|
||||||
|
"""Generate role configuration for an agent type.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
agent_type: Type of agent
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
RoleConfig instance
|
||||||
|
"""
|
||||||
|
template = self.ROLE_TEMPLATES.get(agent_type, {})
|
||||||
|
return RoleConfig(
|
||||||
|
name=template.get("name", agent_type.replace("_", " ").title()),
|
||||||
|
description=template.get("description", ""),
|
||||||
|
focus_areas=template.get("focus_areas", []),
|
||||||
|
constraints=template.get("constraints", []),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _generate_role_md(self, agent_dir: Path, role_config: RoleConfig) -> None:
|
||||||
|
"""Generate ROLE.md file.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
agent_dir: Agent directory
|
||||||
|
role_config: Role configuration
|
||||||
|
"""
|
||||||
|
lines = [f"# {role_config.name}", ""]
|
||||||
|
|
||||||
|
if role_config.description:
|
||||||
|
lines.extend([role_config.description, ""])
|
||||||
|
|
||||||
|
if role_config.focus_areas:
|
||||||
|
lines.extend(["## Focus Areas", ""])
|
||||||
|
for area in role_config.focus_areas:
|
||||||
|
lines.append(f"- {area}")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
if role_config.constraints:
|
||||||
|
lines.extend(["## Constraints", ""])
|
||||||
|
for constraint in role_config.constraints:
|
||||||
|
lines.append(f"- {constraint}")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
content = "\n".join(lines)
|
||||||
|
(agent_dir / "ROLE.md").write_text(content, encoding="utf-8")
|
||||||
|
|
||||||
|
def _write_agent_yaml(
|
||||||
|
self,
|
||||||
|
config_path: Path,
|
||||||
|
agent_id: str,
|
||||||
|
agent_type: str,
|
||||||
|
model_config: Optional[ModelConfig] = None,
|
||||||
|
) -> None:
|
||||||
|
"""Write agent.yaml configuration file.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
config_path: Path to write configuration
|
||||||
|
agent_id: Agent ID
|
||||||
|
agent_type: Agent type
|
||||||
|
model_config: Optional model configuration
|
||||||
|
"""
|
||||||
|
config = {
|
||||||
|
"agent_id": agent_id,
|
||||||
|
"agent_type": agent_type,
|
||||||
|
"prompt_files": [
|
||||||
|
"SOUL.md",
|
||||||
|
"PROFILE.md",
|
||||||
|
"AGENTS.md",
|
||||||
|
"POLICY.md",
|
||||||
|
"MEMORY.md",
|
||||||
|
],
|
||||||
|
"enabled_skills": [],
|
||||||
|
"disabled_skills": [],
|
||||||
|
"active_tool_groups": [],
|
||||||
|
"disabled_tool_groups": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
if model_config:
|
||||||
|
config["model"] = {
|
||||||
|
"name": model_config.model_name,
|
||||||
|
"temperature": model_config.temperature,
|
||||||
|
"max_tokens": model_config.max_tokens,
|
||||||
|
}
|
||||||
|
|
||||||
|
with open(config_path, "w", encoding="utf-8") as f:
|
||||||
|
yaml.safe_dump(config, f, allow_unicode=True, sort_keys=False)
|
||||||
19
backend/agents/prompts/__init__.py
Normal file
19
backend/agents/prompts/__init__.py
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Prompt building utilities for EvoAgent.
|
||||||
|
|
||||||
|
This module provides prompt construction from workspace markdown files
|
||||||
|
with YAML frontmatter support.
|
||||||
|
"""
|
||||||
|
from .builder import (
|
||||||
|
PromptBuilder,
|
||||||
|
build_system_prompt_from_workspace,
|
||||||
|
build_bootstrap_guidance,
|
||||||
|
DEFAULT_SYS_PROMPT,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"PromptBuilder",
|
||||||
|
"build_system_prompt_from_workspace",
|
||||||
|
"build_bootstrap_guidance",
|
||||||
|
"DEFAULT_SYS_PROMPT",
|
||||||
|
]
|
||||||
305
backend/agents/prompts/builder.py
Normal file
305
backend/agents/prompts/builder.py
Normal file
@@ -0,0 +1,305 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""PromptBuilder for constructing system prompts from workspace markdown files.
|
||||||
|
|
||||||
|
Based on CoPaw design - loads AGENTS.md, SOUL.md, PROFILE.md, etc. from
|
||||||
|
agent workspace directories with YAML frontmatter support.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
DEFAULT_SYS_PROMPT = """You are a helpful trading analysis assistant."""
|
||||||
|
|
||||||
|
|
||||||
|
class PromptBuilder:
|
||||||
|
"""Builder for constructing system prompts from markdown files.
|
||||||
|
|
||||||
|
Loads markdown configuration files from agent workspace directories,
|
||||||
|
supporting YAML frontmatter for metadata extraction.
|
||||||
|
"""
|
||||||
|
|
||||||
|
DEFAULT_FILES = [
|
||||||
|
"AGENTS.md",
|
||||||
|
"SOUL.md",
|
||||||
|
"PROFILE.md",
|
||||||
|
"ROLE.md",
|
||||||
|
"POLICY.md",
|
||||||
|
"MEMORY.md",
|
||||||
|
"HEARTBEAT.md",
|
||||||
|
"STYLE.md",
|
||||||
|
]
|
||||||
|
|
||||||
|
TITLE_MAP: Dict[str, str] = {
|
||||||
|
"AGENTS.md": "Agent Guide",
|
||||||
|
"SOUL.md": "Soul",
|
||||||
|
"PROFILE.md": "Profile",
|
||||||
|
"ROLE.md": "Role",
|
||||||
|
"POLICY.md": "Policy",
|
||||||
|
"MEMORY.md": "Memory",
|
||||||
|
"HEARTBEAT.md": "Heartbeat",
|
||||||
|
"STYLE.md": "Style",
|
||||||
|
"BOOTSTRAP.md": "Bootstrap",
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
workspace_dir: Path,
|
||||||
|
enabled_files: Optional[List[str]] = None,
|
||||||
|
):
|
||||||
|
"""Initialize prompt builder.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
workspace_dir: Directory containing markdown configuration files
|
||||||
|
enabled_files: List of filenames to load (if None, uses defaults)
|
||||||
|
"""
|
||||||
|
self.workspace_dir = Path(workspace_dir)
|
||||||
|
self.enabled_files = enabled_files or self.DEFAULT_FILES.copy()
|
||||||
|
self._prompt_parts: List[str] = []
|
||||||
|
self._metadata: Dict[str, Any] = {}
|
||||||
|
self.loaded_count = 0
|
||||||
|
|
||||||
|
def _load_file(self, filename: str) -> tuple[str, Optional[Dict[str, Any]]]:
|
||||||
|
"""Load a single markdown file with YAML frontmatter support.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
filename: Name of the file to load
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (content, metadata dict or None)
|
||||||
|
"""
|
||||||
|
file_path = self.workspace_dir / filename
|
||||||
|
|
||||||
|
if not file_path.exists():
|
||||||
|
logger.debug("File %s not found in %s, skipping", filename, self.workspace_dir)
|
||||||
|
return "", None
|
||||||
|
|
||||||
|
try:
|
||||||
|
raw_content = file_path.read_text(encoding="utf-8").strip()
|
||||||
|
|
||||||
|
if not raw_content:
|
||||||
|
logger.debug("Skipped empty file: %s", filename)
|
||||||
|
return "", None
|
||||||
|
|
||||||
|
content, metadata = self._parse_frontmatter(raw_content)
|
||||||
|
|
||||||
|
if content:
|
||||||
|
self.loaded_count += 1
|
||||||
|
logger.debug("Loaded %s (metadata: %s)", filename, bool(metadata))
|
||||||
|
|
||||||
|
return content, metadata
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Failed to read file %s: %s, skipping", filename, e)
|
||||||
|
return "", None
|
||||||
|
|
||||||
|
def _parse_frontmatter(self, raw_content: str) -> tuple[str, Optional[Dict[str, Any]]]:
|
||||||
|
"""Parse YAML frontmatter from markdown content.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
raw_content: Raw file content
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (content without frontmatter, metadata dict or None)
|
||||||
|
"""
|
||||||
|
if not raw_content.startswith("---"):
|
||||||
|
return raw_content, None
|
||||||
|
|
||||||
|
parts = raw_content.split("---", 2)
|
||||||
|
if len(parts) < 3:
|
||||||
|
return raw_content, None
|
||||||
|
|
||||||
|
frontmatter = parts[1].strip()
|
||||||
|
content = parts[2].strip()
|
||||||
|
|
||||||
|
try:
|
||||||
|
metadata = yaml.safe_load(frontmatter) or {}
|
||||||
|
if not isinstance(metadata, dict):
|
||||||
|
metadata = {}
|
||||||
|
return content, metadata
|
||||||
|
except yaml.YAMLError as e:
|
||||||
|
logger.warning("Failed to parse YAML frontmatter: %s", e)
|
||||||
|
return content, None
|
||||||
|
|
||||||
|
def _append_section(self, title: str, content: str) -> None:
|
||||||
|
"""Append a section to the prompt parts.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
title: Section title
|
||||||
|
content: Section content
|
||||||
|
"""
|
||||||
|
content = content.strip()
|
||||||
|
if not content:
|
||||||
|
return
|
||||||
|
|
||||||
|
if self._prompt_parts:
|
||||||
|
self._prompt_parts.append("")
|
||||||
|
|
||||||
|
self._prompt_parts.append(f"## {title}")
|
||||||
|
self._prompt_parts.append("")
|
||||||
|
self._prompt_parts.append(content)
|
||||||
|
|
||||||
|
def build(self) -> str:
|
||||||
|
"""Build the system prompt from markdown files.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Constructed system prompt string
|
||||||
|
"""
|
||||||
|
self._prompt_parts = []
|
||||||
|
self._metadata = {}
|
||||||
|
self.loaded_count = 0
|
||||||
|
|
||||||
|
for filename in self.enabled_files:
|
||||||
|
content, metadata = self._load_file(filename)
|
||||||
|
|
||||||
|
if metadata:
|
||||||
|
self._metadata[filename] = metadata
|
||||||
|
|
||||||
|
if content:
|
||||||
|
title = self.TITLE_MAP.get(filename, filename.replace(".md", ""))
|
||||||
|
self._append_section(title, content)
|
||||||
|
|
||||||
|
if not self._prompt_parts:
|
||||||
|
logger.warning("No content loaded from workspace: %s", self.workspace_dir)
|
||||||
|
return DEFAULT_SYS_PROMPT
|
||||||
|
|
||||||
|
final_prompt = "\n".join(self._prompt_parts)
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
"System prompt built from %d file(s), total length: %d chars",
|
||||||
|
self.loaded_count,
|
||||||
|
len(final_prompt),
|
||||||
|
)
|
||||||
|
|
||||||
|
return final_prompt
|
||||||
|
|
||||||
|
def get_metadata(self) -> Dict[str, Any]:
|
||||||
|
"""Get metadata collected from YAML frontmatter.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary mapping filenames to their metadata
|
||||||
|
"""
|
||||||
|
return self._metadata.copy()
|
||||||
|
|
||||||
|
def get_agent_identity(self) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Extract agent identity from PROFILE.md metadata.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Identity dict with name, role, etc. or None
|
||||||
|
"""
|
||||||
|
profile_meta = self._metadata.get("PROFILE.md", {})
|
||||||
|
if not profile_meta:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return {
|
||||||
|
"name": profile_meta.get("name", "Unknown"),
|
||||||
|
"role": profile_meta.get("role", ""),
|
||||||
|
"expertise": profile_meta.get("expertise", []),
|
||||||
|
"style": profile_meta.get("style", ""),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_system_prompt_from_workspace(
|
||||||
|
workspace_dir: Path,
|
||||||
|
enabled_files: Optional[List[str]] = None,
|
||||||
|
agent_id: Optional[str] = None,
|
||||||
|
extra_context: Optional[str] = None,
|
||||||
|
) -> str:
|
||||||
|
"""Build system prompt from workspace markdown files.
|
||||||
|
|
||||||
|
This is the main entry point for building system prompts from
|
||||||
|
agent workspace directories.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
workspace_dir: Directory containing markdown configuration files
|
||||||
|
enabled_files: List of filenames to load (if None, uses defaults)
|
||||||
|
agent_id: Agent identifier to include in system prompt
|
||||||
|
extra_context: Additional context to append to the prompt
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Constructed system prompt string
|
||||||
|
"""
|
||||||
|
builder = PromptBuilder(
|
||||||
|
workspace_dir=workspace_dir,
|
||||||
|
enabled_files=enabled_files,
|
||||||
|
)
|
||||||
|
|
||||||
|
prompt = builder.build()
|
||||||
|
|
||||||
|
# Add agent identity header if agent_id provided
|
||||||
|
if agent_id and agent_id != "default":
|
||||||
|
identity_header = (
|
||||||
|
f"# Agent Identity\n\n"
|
||||||
|
f"Your agent ID is `{agent_id}`. "
|
||||||
|
f"This is your unique identifier in the multi-agent system.\n\n"
|
||||||
|
)
|
||||||
|
prompt = identity_header + prompt
|
||||||
|
|
||||||
|
# Append extra context if provided
|
||||||
|
if extra_context:
|
||||||
|
prompt = prompt + "\n\n" + extra_context
|
||||||
|
|
||||||
|
return prompt
|
||||||
|
|
||||||
|
|
||||||
|
def build_bootstrap_guidance(language: str = "zh") -> str:
|
||||||
|
"""Build bootstrap guidance message for first-time setup.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
language: Language code (zh/en)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Formatted bootstrap guidance message
|
||||||
|
"""
|
||||||
|
if language == "zh":
|
||||||
|
return (
|
||||||
|
"# 引导模式\n"
|
||||||
|
"\n"
|
||||||
|
"工作目录中存在 `BOOTSTRAP.md` — 首次设置。\n"
|
||||||
|
"\n"
|
||||||
|
"1. 阅读 BOOTSTRAP.md,友好地表示初次见面,"
|
||||||
|
"引导用户完成设置。\n"
|
||||||
|
"2. 按照 BOOTSTRAP.md 的指示,"
|
||||||
|
"帮助用户定义你的身份和偏好。\n"
|
||||||
|
"3. 按指南创建/更新必要文件"
|
||||||
|
"(PROFILE.md、MEMORY.md 等)。\n"
|
||||||
|
"4. 完成后删除 BOOTSTRAP.md。\n"
|
||||||
|
"\n"
|
||||||
|
"如果用户希望跳过,直接回答下面的问题即可。\n"
|
||||||
|
"\n"
|
||||||
|
"---\n"
|
||||||
|
"\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
"# BOOTSTRAP MODE\n"
|
||||||
|
"\n"
|
||||||
|
"`BOOTSTRAP.md` exists — first-time setup.\n"
|
||||||
|
"\n"
|
||||||
|
"1. Read BOOTSTRAP.md, greet the user, "
|
||||||
|
"and guide them through setup.\n"
|
||||||
|
"2. Follow BOOTSTRAP.md instructions "
|
||||||
|
"to define identity and preferences.\n"
|
||||||
|
"3. Create/update files "
|
||||||
|
"(PROFILE.md, MEMORY.md, etc.) as described.\n"
|
||||||
|
"4. Delete BOOTSTRAP.md when done.\n"
|
||||||
|
"\n"
|
||||||
|
"If the user wants to skip, answer their "
|
||||||
|
"question directly instead.\n"
|
||||||
|
"\n"
|
||||||
|
"---\n"
|
||||||
|
"\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"PromptBuilder",
|
||||||
|
"build_system_prompt_from_workspace",
|
||||||
|
"build_bootstrap_guidance",
|
||||||
|
"DEFAULT_SYS_PROMPT",
|
||||||
|
]
|
||||||
284
backend/agents/registry.py
Normal file
284
backend/agents/registry.py
Normal file
@@ -0,0 +1,284 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Agent Registry - In-memory registry for agent management."""
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AgentInfo:
|
||||||
|
"""Information about a registered agent."""
|
||||||
|
|
||||||
|
agent_id: str
|
||||||
|
agent_type: str
|
||||||
|
workspace_id: str
|
||||||
|
config_path: str
|
||||||
|
agent_dir: str
|
||||||
|
status: str = "inactive" # inactive, active, error
|
||||||
|
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
def to_dict(self) -> Dict[str, Any]:
|
||||||
|
"""Serialize to dictionary."""
|
||||||
|
return {
|
||||||
|
"agent_id": self.agent_id,
|
||||||
|
"agent_type": self.agent_type,
|
||||||
|
"workspace_id": self.workspace_id,
|
||||||
|
"config_path": self.config_path,
|
||||||
|
"agent_dir": self.agent_dir,
|
||||||
|
"status": self.status,
|
||||||
|
"metadata": self.metadata,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class AgentRegistry:
|
||||||
|
"""In-memory registry for agent instances."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
"""Initialize the agent registry."""
|
||||||
|
# Dictionary mapping agent_id -> AgentInfo
|
||||||
|
self._agents: Dict[str, AgentInfo] = {}
|
||||||
|
# Index mapping workspace_id -> set of agent_ids
|
||||||
|
self._workspace_index: Dict[str, set] = {}
|
||||||
|
|
||||||
|
def register(
|
||||||
|
self,
|
||||||
|
agent_id: str,
|
||||||
|
agent_type: str,
|
||||||
|
workspace_id: str,
|
||||||
|
config_path: str,
|
||||||
|
agent_dir: str,
|
||||||
|
status: str = "inactive",
|
||||||
|
metadata: Optional[Dict[str, Any]] = None,
|
||||||
|
) -> AgentInfo:
|
||||||
|
"""Register an agent in the registry.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
agent_id: Unique identifier for the agent
|
||||||
|
agent_type: Type of agent
|
||||||
|
workspace_id: ID of the workspace containing the agent
|
||||||
|
config_path: Path to agent configuration file
|
||||||
|
agent_dir: Path to agent directory
|
||||||
|
status: Initial status (default: inactive)
|
||||||
|
metadata: Optional metadata dictionary
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
AgentInfo instance
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If agent_id is already registered
|
||||||
|
"""
|
||||||
|
if agent_id in self._agents:
|
||||||
|
raise ValueError(f"Agent '{agent_id}' is already registered")
|
||||||
|
|
||||||
|
agent_info = AgentInfo(
|
||||||
|
agent_id=agent_id,
|
||||||
|
agent_type=agent_type,
|
||||||
|
workspace_id=workspace_id,
|
||||||
|
config_path=config_path,
|
||||||
|
agent_dir=agent_dir,
|
||||||
|
status=status,
|
||||||
|
metadata=metadata or {},
|
||||||
|
)
|
||||||
|
|
||||||
|
self._agents[agent_id] = agent_info
|
||||||
|
|
||||||
|
# Update workspace index
|
||||||
|
if workspace_id not in self._workspace_index:
|
||||||
|
self._workspace_index[workspace_id] = set()
|
||||||
|
self._workspace_index[workspace_id].add(agent_id)
|
||||||
|
|
||||||
|
return agent_info
|
||||||
|
|
||||||
|
def unregister(self, agent_id: str) -> bool:
|
||||||
|
"""Unregister an agent.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
agent_id: ID of the agent to unregister
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if unregistered, False if agent wasn't registered
|
||||||
|
"""
|
||||||
|
if agent_id not in self._agents:
|
||||||
|
return False
|
||||||
|
|
||||||
|
agent_info = self._agents[agent_id]
|
||||||
|
|
||||||
|
# Remove from workspace index
|
||||||
|
workspace_id = agent_info.workspace_id
|
||||||
|
if workspace_id in self._workspace_index:
|
||||||
|
self._workspace_index[workspace_id].discard(agent_id)
|
||||||
|
if not self._workspace_index[workspace_id]:
|
||||||
|
del self._workspace_index[workspace_id]
|
||||||
|
|
||||||
|
# Remove from agents dict
|
||||||
|
del self._agents[agent_id]
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
def get(self, agent_id: str) -> Optional[AgentInfo]:
|
||||||
|
"""Get agent information by ID.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
agent_id: ID of the agent
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
AgentInfo if found, None otherwise
|
||||||
|
"""
|
||||||
|
return self._agents.get(agent_id)
|
||||||
|
|
||||||
|
def list_all(
|
||||||
|
self,
|
||||||
|
workspace_id: Optional[str] = None,
|
||||||
|
agent_type: Optional[str] = None,
|
||||||
|
status: Optional[str] = None,
|
||||||
|
) -> List[AgentInfo]:
|
||||||
|
"""List all registered agents with optional filtering.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
workspace_id: Filter by workspace ID
|
||||||
|
agent_type: Filter by agent type
|
||||||
|
status: Filter by status
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of AgentInfo instances
|
||||||
|
"""
|
||||||
|
agents = list(self._agents.values())
|
||||||
|
|
||||||
|
if workspace_id:
|
||||||
|
agent_ids = self._workspace_index.get(workspace_id, set())
|
||||||
|
agents = [a for a in agents if a.agent_id in agent_ids]
|
||||||
|
|
||||||
|
if agent_type:
|
||||||
|
agents = [a for a in agents if a.agent_type == agent_type]
|
||||||
|
|
||||||
|
if status:
|
||||||
|
agents = [a for a in agents if a.status == status]
|
||||||
|
|
||||||
|
return agents
|
||||||
|
|
||||||
|
def update_status(self, agent_id: str, status: str) -> bool:
|
||||||
|
"""Update the status of an agent.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
agent_id: ID of the agent
|
||||||
|
status: New status value
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if updated, False if agent not found
|
||||||
|
"""
|
||||||
|
if agent_id not in self._agents:
|
||||||
|
return False
|
||||||
|
|
||||||
|
self._agents[agent_id].status = status
|
||||||
|
return True
|
||||||
|
|
||||||
|
def update_metadata(self, agent_id: str, metadata: Dict[str, Any]) -> bool:
|
||||||
|
"""Update the metadata of an agent.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
agent_id: ID of the agent
|
||||||
|
metadata: Metadata dictionary to merge
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if updated, False if agent not found
|
||||||
|
"""
|
||||||
|
if agent_id not in self._agents:
|
||||||
|
return False
|
||||||
|
|
||||||
|
self._agents[agent_id].metadata.update(metadata)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def is_registered(self, agent_id: str) -> bool:
|
||||||
|
"""Check if an agent is registered.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
agent_id: ID of the agent
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if registered, False otherwise
|
||||||
|
"""
|
||||||
|
return agent_id in self._agents
|
||||||
|
|
||||||
|
def get_workspace_agents(self, workspace_id: str) -> List[AgentInfo]:
|
||||||
|
"""Get all agents in a workspace.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
workspace_id: ID of the workspace
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of AgentInfo instances
|
||||||
|
"""
|
||||||
|
agent_ids = self._workspace_index.get(workspace_id, set())
|
||||||
|
return [self._agents[agent_id] for agent_id in agent_ids if agent_id in self._agents]
|
||||||
|
|
||||||
|
def get_agent_count(self, workspace_id: Optional[str] = None) -> int:
|
||||||
|
"""Get the count of registered agents.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
workspace_id: Optional workspace ID to filter by
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Number of agents
|
||||||
|
"""
|
||||||
|
if workspace_id:
|
||||||
|
return len(self._workspace_index.get(workspace_id, set()))
|
||||||
|
return len(self._agents)
|
||||||
|
|
||||||
|
def clear(self) -> None:
|
||||||
|
"""Clear all registered agents."""
|
||||||
|
self._agents.clear()
|
||||||
|
self._workspace_index.clear()
|
||||||
|
|
||||||
|
def get_stats(self) -> Dict[str, Any]:
|
||||||
|
"""Get registry statistics.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with registry statistics
|
||||||
|
"""
|
||||||
|
stats = {
|
||||||
|
"total_agents": len(self._agents),
|
||||||
|
"workspaces": len(self._workspace_index),
|
||||||
|
"agents_by_workspace": {
|
||||||
|
ws_id: len(agent_ids)
|
||||||
|
for ws_id, agent_ids in self._workspace_index.items()
|
||||||
|
},
|
||||||
|
"agents_by_type": {},
|
||||||
|
"agents_by_status": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
for agent in self._agents.values():
|
||||||
|
# Count by type
|
||||||
|
agent_type = agent.agent_type
|
||||||
|
stats["agents_by_type"][agent_type] = (
|
||||||
|
stats["agents_by_type"].get(agent_type, 0) + 1
|
||||||
|
)
|
||||||
|
|
||||||
|
# Count by status
|
||||||
|
status = agent.status
|
||||||
|
stats["agents_by_status"][status] = (
|
||||||
|
stats["agents_by_status"].get(status, 0) + 1
|
||||||
|
)
|
||||||
|
|
||||||
|
return stats
|
||||||
|
|
||||||
|
|
||||||
|
# Global registry instance
|
||||||
|
_global_registry: Optional[AgentRegistry] = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_registry() -> AgentRegistry:
|
||||||
|
"""Get the global agent registry instance.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
AgentRegistry instance
|
||||||
|
"""
|
||||||
|
global _global_registry
|
||||||
|
if _global_registry is None:
|
||||||
|
_global_registry = AgentRegistry()
|
||||||
|
return _global_registry
|
||||||
|
|
||||||
|
|
||||||
|
def reset_registry() -> None:
|
||||||
|
"""Reset the global registry (useful for testing)."""
|
||||||
|
global _global_registry
|
||||||
|
_global_registry = None
|
||||||
388
backend/agents/skill_loader.py
Normal file
388
backend/agents/skill_loader.py
Normal file
@@ -0,0 +1,388 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Skill loader for loading and validating skills from directories.
|
||||||
|
|
||||||
|
提供从目录加载技能、解析SKILL.md frontmatter、获取工具列表等功能。
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, List, Optional, Set
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
from backend.agents.skill_metadata import SkillMetadata, parse_skill_metadata
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SkillInfo:
|
||||||
|
"""完整的技能信息"""
|
||||||
|
name: str
|
||||||
|
description: str
|
||||||
|
version: str
|
||||||
|
source: str
|
||||||
|
path: Path
|
||||||
|
metadata: SkillMetadata
|
||||||
|
tools: List[str] = field(default_factory=list)
|
||||||
|
scripts: List[str] = field(default_factory=list)
|
||||||
|
references: List[str] = field(default_factory=list)
|
||||||
|
content: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
def load_skill_from_dir(skill_dir: Path, source: str = "unknown") -> Optional[Dict[str, Any]]:
|
||||||
|
"""从目录加载技能
|
||||||
|
|
||||||
|
Args:
|
||||||
|
skill_dir: 技能目录路径
|
||||||
|
source: 技能来源 (builtin/customized/local/installed/active)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
技能信息字典,加载失败返回None
|
||||||
|
"""
|
||||||
|
if not skill_dir.exists() or not skill_dir.is_dir():
|
||||||
|
logger.warning(f"Skill directory does not exist: {skill_dir}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
skill_md = skill_dir / "SKILL.md"
|
||||||
|
if not skill_md.exists():
|
||||||
|
logger.warning(f"SKILL.md not found in: {skill_dir}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 解析元数据
|
||||||
|
metadata = parse_skill_metadata(skill_dir, source=source)
|
||||||
|
|
||||||
|
# 读取完整内容
|
||||||
|
content = skill_md.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
# 提取body (去掉frontmatter)
|
||||||
|
body = content
|
||||||
|
if content.startswith("---"):
|
||||||
|
parts = content.split("---", 2)
|
||||||
|
if len(parts) >= 3:
|
||||||
|
body = parts[2].strip()
|
||||||
|
|
||||||
|
# 获取工具列表
|
||||||
|
tools = get_skill_tools(skill_dir)
|
||||||
|
|
||||||
|
# 获取脚本列表
|
||||||
|
scripts = _get_skill_scripts(skill_dir)
|
||||||
|
|
||||||
|
# 获取参考资料列表
|
||||||
|
references = _get_skill_references(skill_dir)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"name": metadata.name,
|
||||||
|
"skill_name": metadata.skill_name,
|
||||||
|
"description": metadata.description,
|
||||||
|
"version": metadata.version,
|
||||||
|
"source": source,
|
||||||
|
"path": str(skill_dir),
|
||||||
|
"content": body,
|
||||||
|
"tools": tools,
|
||||||
|
"scripts": scripts,
|
||||||
|
"references": references,
|
||||||
|
"metadata": metadata,
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to load skill from {skill_dir}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def parse_skill_metadata(skill_dir: Path, source: str = "unknown") -> SkillMetadata:
|
||||||
|
"""解析技能元数据 (兼容已有函数)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
skill_dir: 技能目录路径
|
||||||
|
source: 技能来源
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
SkillMetadata对象
|
||||||
|
"""
|
||||||
|
from backend.agents.skill_metadata import parse_skill_metadata as _parse
|
||||||
|
return _parse(skill_dir, source=source)
|
||||||
|
|
||||||
|
|
||||||
|
def get_skill_tools(skill_dir: Path) -> List[str]:
|
||||||
|
"""获取技能提供的工具列表
|
||||||
|
|
||||||
|
从SKILL.md frontmatter的tools字段和scripts目录解析工具。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
skill_dir: 技能目录路径
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
工具名称列表
|
||||||
|
"""
|
||||||
|
tools: Set[str] = set()
|
||||||
|
|
||||||
|
# 1. 从SKILL.md frontmatter读取tools字段
|
||||||
|
skill_md = skill_dir / "SKILL.md"
|
||||||
|
if skill_md.exists():
|
||||||
|
try:
|
||||||
|
raw = skill_md.read_text(encoding="utf-8").strip()
|
||||||
|
if raw.startswith("---"):
|
||||||
|
parts = raw.split("---", 2)
|
||||||
|
if len(parts) >= 3:
|
||||||
|
try:
|
||||||
|
frontmatter = yaml.safe_load(parts[1].strip()) or {}
|
||||||
|
if isinstance(frontmatter, dict):
|
||||||
|
tools_list = frontmatter.get("tools", [])
|
||||||
|
if isinstance(tools_list, str):
|
||||||
|
tools.add(tools_list.strip())
|
||||||
|
elif isinstance(tools_list, list):
|
||||||
|
for tool in tools_list:
|
||||||
|
if isinstance(tool, str):
|
||||||
|
tools.add(tool.strip())
|
||||||
|
except yaml.YAMLError:
|
||||||
|
pass
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to parse tools from SKILL.md: {e}")
|
||||||
|
|
||||||
|
# 2. 从scripts目录推断工具
|
||||||
|
scripts_dir = skill_dir / "scripts"
|
||||||
|
if scripts_dir.exists() and scripts_dir.is_dir():
|
||||||
|
for script in scripts_dir.iterdir():
|
||||||
|
if script.is_file() and not script.name.startswith("_"):
|
||||||
|
# 去掉扩展名作为工具名
|
||||||
|
tool_name = script.stem
|
||||||
|
tools.add(tool_name)
|
||||||
|
|
||||||
|
return sorted(list(tools))
|
||||||
|
|
||||||
|
|
||||||
|
def _get_skill_scripts(skill_dir: Path) -> List[str]:
|
||||||
|
"""获取技能脚本列表
|
||||||
|
|
||||||
|
Args:
|
||||||
|
skill_dir: 技能目录路径
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
脚本相对路径列表 (相对于scripts目录)
|
||||||
|
"""
|
||||||
|
scripts: List[str] = []
|
||||||
|
scripts_dir = skill_dir / "scripts"
|
||||||
|
|
||||||
|
if not scripts_dir.exists():
|
||||||
|
return scripts
|
||||||
|
|
||||||
|
try:
|
||||||
|
for item in scripts_dir.rglob("*"):
|
||||||
|
if item.is_file() and not item.name.startswith("_"):
|
||||||
|
rel_path = item.relative_to(scripts_dir)
|
||||||
|
scripts.append(str(rel_path))
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to list scripts in {skill_dir}: {e}")
|
||||||
|
|
||||||
|
return sorted(scripts)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_skill_references(skill_dir: Path) -> List[str]:
|
||||||
|
"""获取技能参考资料列表
|
||||||
|
|
||||||
|
Args:
|
||||||
|
skill_dir: 技能目录路径
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
参考资料相对路径列表 (相对于references目录)
|
||||||
|
"""
|
||||||
|
refs: List[str] = []
|
||||||
|
refs_dir = skill_dir / "references"
|
||||||
|
|
||||||
|
if not refs_dir.exists():
|
||||||
|
return refs
|
||||||
|
|
||||||
|
try:
|
||||||
|
for item in refs_dir.rglob("*"):
|
||||||
|
if item.is_file():
|
||||||
|
rel_path = item.relative_to(refs_dir)
|
||||||
|
refs.append(str(rel_path))
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to list references in {skill_dir}: {e}")
|
||||||
|
|
||||||
|
return sorted(refs)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_skill(skill_dir: Path) -> Dict[str, Any]:
|
||||||
|
"""验证技能格式
|
||||||
|
|
||||||
|
检查技能目录结构是否符合规范。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
skill_dir: 技能目录路径
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
验证结果字典,包含:
|
||||||
|
- valid: 是否有效
|
||||||
|
- errors: 错误列表
|
||||||
|
- warnings: 警告列表
|
||||||
|
"""
|
||||||
|
errors: List[str] = []
|
||||||
|
warnings: List[str] = []
|
||||||
|
|
||||||
|
# 检查目录存在
|
||||||
|
if not skill_dir.exists():
|
||||||
|
errors.append(f"Skill directory does not exist: {skill_dir}")
|
||||||
|
return {"valid": False, "errors": errors, "warnings": warnings}
|
||||||
|
|
||||||
|
if not skill_dir.is_dir():
|
||||||
|
errors.append(f"Path is not a directory: {skill_dir}")
|
||||||
|
return {"valid": False, "errors": errors, "warnings": warnings}
|
||||||
|
|
||||||
|
# 检查SKILL.md
|
||||||
|
skill_md = skill_dir / "SKILL.md"
|
||||||
|
if not skill_md.exists():
|
||||||
|
errors.append("SKILL.md is required but not found")
|
||||||
|
return {"valid": False, "errors": errors, "warnings": warnings}
|
||||||
|
|
||||||
|
# 解析frontmatter
|
||||||
|
try:
|
||||||
|
content = skill_md.read_text(encoding="utf-8").strip()
|
||||||
|
if not content.startswith("---"):
|
||||||
|
warnings.append("SKILL.md should have YAML frontmatter (starts with ---)")
|
||||||
|
else:
|
||||||
|
parts = content.split("---", 2)
|
||||||
|
if len(parts) < 3:
|
||||||
|
errors.append("Invalid YAML frontmatter format")
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
frontmatter = yaml.safe_load(parts[1].strip()) or {}
|
||||||
|
if not isinstance(frontmatter, dict):
|
||||||
|
errors.append("YAML frontmatter must be a dictionary")
|
||||||
|
else:
|
||||||
|
# 检查必需字段
|
||||||
|
if "name" not in frontmatter:
|
||||||
|
warnings.append("Frontmatter should have 'name' field")
|
||||||
|
if "description" not in frontmatter:
|
||||||
|
warnings.append("Frontmatter should have 'description' field")
|
||||||
|
|
||||||
|
# 检查version字段
|
||||||
|
version = frontmatter.get("version")
|
||||||
|
if version and not isinstance(version, str):
|
||||||
|
warnings.append("'version' should be a string")
|
||||||
|
|
||||||
|
# 检查tools字段
|
||||||
|
tools = frontmatter.get("tools")
|
||||||
|
if tools and not isinstance(tools, (str, list)):
|
||||||
|
warnings.append("'tools' should be a string or list")
|
||||||
|
|
||||||
|
except yaml.YAMLError as e:
|
||||||
|
errors.append(f"Invalid YAML in frontmatter: {e}")
|
||||||
|
except Exception as e:
|
||||||
|
errors.append(f"Failed to read SKILL.md: {e}")
|
||||||
|
|
||||||
|
# 检查body内容
|
||||||
|
try:
|
||||||
|
content = skill_md.read_text(encoding="utf-8")
|
||||||
|
body = content
|
||||||
|
if content.startswith("---"):
|
||||||
|
parts = content.split("---", 2)
|
||||||
|
if len(parts) >= 3:
|
||||||
|
body = parts[2].strip()
|
||||||
|
|
||||||
|
if not body:
|
||||||
|
warnings.append("SKILL.md body is empty")
|
||||||
|
elif len(body) < 50:
|
||||||
|
warnings.append("SKILL.md body is very short, consider adding more details")
|
||||||
|
except Exception as e:
|
||||||
|
errors.append(f"Failed to validate body: {e}")
|
||||||
|
|
||||||
|
# 检查scripts目录
|
||||||
|
scripts_dir = skill_dir / "scripts"
|
||||||
|
if scripts_dir.exists():
|
||||||
|
if not scripts_dir.is_dir():
|
||||||
|
errors.append("'scripts' exists but is not a directory")
|
||||||
|
else:
|
||||||
|
# 检查是否有可执行脚本
|
||||||
|
has_scripts = any(
|
||||||
|
f.is_file() and not f.name.startswith("_")
|
||||||
|
for f in scripts_dir.iterdir()
|
||||||
|
)
|
||||||
|
if not has_scripts:
|
||||||
|
warnings.append("scripts directory exists but contains no valid scripts")
|
||||||
|
|
||||||
|
# 检查references目录
|
||||||
|
refs_dir = skill_dir / "references"
|
||||||
|
if refs_dir.exists() and not refs_dir.is_dir():
|
||||||
|
errors.append("'references' exists but is not a directory")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"valid": len(errors) == 0,
|
||||||
|
"errors": errors,
|
||||||
|
"warnings": warnings,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def load_skills_from_directory(
|
||||||
|
directory: Path,
|
||||||
|
source: str = "unknown",
|
||||||
|
recursive: bool = False,
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
"""从目录加载所有技能
|
||||||
|
|
||||||
|
Args:
|
||||||
|
directory: 包含技能目录的父目录
|
||||||
|
source: 技能来源标识
|
||||||
|
recursive: 是否递归搜索子目录
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
技能信息列表
|
||||||
|
"""
|
||||||
|
skills: List[Dict[str, Any]] = []
|
||||||
|
|
||||||
|
if not directory.exists() or not directory.is_dir():
|
||||||
|
logger.warning(f"Directory does not exist: {directory}")
|
||||||
|
return skills
|
||||||
|
|
||||||
|
try:
|
||||||
|
for item in directory.iterdir():
|
||||||
|
if not item.is_dir():
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 检查是否是技能目录 (包含SKILL.md)
|
||||||
|
if (item / "SKILL.md").exists():
|
||||||
|
skill_info = load_skill_from_dir(item, source=source)
|
||||||
|
if skill_info:
|
||||||
|
skills.append(skill_info)
|
||||||
|
elif recursive:
|
||||||
|
# 递归搜索子目录
|
||||||
|
sub_skills = load_skills_from_directory(item, source, recursive)
|
||||||
|
skills.extend(sub_skills)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to load skills from {directory}: {e}")
|
||||||
|
|
||||||
|
return skills
|
||||||
|
|
||||||
|
|
||||||
|
def get_skill_manifest(skill_dir: Path) -> Dict[str, Any]:
|
||||||
|
"""获取技能清单
|
||||||
|
|
||||||
|
生成技能的详细清单,用于调试和展示。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
skill_dir: 技能目录路径
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
技能清单字典
|
||||||
|
"""
|
||||||
|
info = load_skill_from_dir(skill_dir)
|
||||||
|
if not info:
|
||||||
|
return {"error": "Failed to load skill"}
|
||||||
|
|
||||||
|
validation = validate_skill(skill_dir)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"name": info["name"],
|
||||||
|
"skill_name": info["skill_name"],
|
||||||
|
"version": info["version"],
|
||||||
|
"description": info["description"],
|
||||||
|
"source": info["source"],
|
||||||
|
"path": info["path"],
|
||||||
|
"tools": info["tools"],
|
||||||
|
"scripts": info["scripts"],
|
||||||
|
"references": info["references"],
|
||||||
|
"validation": validation,
|
||||||
|
"content_preview": info["content"][:500] + "..." if len(info["content"]) > 500 else info["content"],
|
||||||
|
}
|
||||||
286
backend/agents/templates.py
Normal file
286
backend/agents/templates.py
Normal file
@@ -0,0 +1,286 @@
|
|||||||
|
"""
|
||||||
|
Agent模板定义
|
||||||
|
|
||||||
|
包含各角色的ROLE.md内容字典,供程序生成Agent工作空间时使用。
|
||||||
|
"""
|
||||||
|
|
||||||
|
# 基础模板文件内容
|
||||||
|
BASE_TEMPLATES = {
|
||||||
|
"AGENTS.md": """# Agent Guide
|
||||||
|
|
||||||
|
## 工作流程
|
||||||
|
1. 接收分析任务
|
||||||
|
2. 调用相关工具/技能
|
||||||
|
3. 生成分析报告
|
||||||
|
4. 参与团队决策
|
||||||
|
|
||||||
|
## 工具使用规范
|
||||||
|
- 优先使用已激活的技能
|
||||||
|
- 不确定时询问Portfolio Manager
|
||||||
|
- 重要发现用 `/save` 记录
|
||||||
|
|
||||||
|
## 记忆管理
|
||||||
|
- 使用 `/compact` 定期压缩记忆
|
||||||
|
- 投资经验记录在MEMORY.md
|
||||||
|
""",
|
||||||
|
|
||||||
|
"SOUL.md": """# Soul
|
||||||
|
|
||||||
|
你是专业的金融分析师,语气冷静、客观、专业。
|
||||||
|
你的分析应该数据驱动,避免情绪化表达。
|
||||||
|
""",
|
||||||
|
|
||||||
|
"PROFILE.md": """# Profile
|
||||||
|
|
||||||
|
## 投资风格
|
||||||
|
- 风险承受能力:中等
|
||||||
|
- 投资期限:中期(3-12个月)
|
||||||
|
- 偏好行业:科技、医疗、消费
|
||||||
|
|
||||||
|
## 优势
|
||||||
|
- 财务分析
|
||||||
|
- 趋势识别
|
||||||
|
|
||||||
|
## 改进方向
|
||||||
|
- 市场情绪把握
|
||||||
|
""",
|
||||||
|
|
||||||
|
"MEMORY.md": """# Memory
|
||||||
|
|
||||||
|
<!-- 此文件用于记录Agent的学习经验和重要发现 -->
|
||||||
|
|
||||||
|
## 经验总结
|
||||||
|
|
||||||
|
## 重要事件
|
||||||
|
|
||||||
|
## 改进记录
|
||||||
|
""",
|
||||||
|
|
||||||
|
"HEARTBEAT.md": """# Heartbeat
|
||||||
|
|
||||||
|
## 定时任务
|
||||||
|
- 每日开盘前检查持仓
|
||||||
|
- 收盘后记录当日表现
|
||||||
|
""",
|
||||||
|
|
||||||
|
"POLICY.md": """# Policy
|
||||||
|
|
||||||
|
## 风控规则
|
||||||
|
- 单一持仓不超过20%
|
||||||
|
- 止损线:-15%
|
||||||
|
""",
|
||||||
|
|
||||||
|
"STYLE.md": """# Style
|
||||||
|
|
||||||
|
- 使用结构化输出(JSON/Markdown表格)
|
||||||
|
- 包含置信度评分
|
||||||
|
- 列出关键假设
|
||||||
|
""",
|
||||||
|
|
||||||
|
"agent.yaml": """agent_id: {agent_id}
|
||||||
|
agent_type: {agent_type}
|
||||||
|
name: {name}
|
||||||
|
model:
|
||||||
|
provider: openai
|
||||||
|
model_name: gpt-4o
|
||||||
|
temperature: 0.3
|
||||||
|
enabled_skills: []
|
||||||
|
disabled_skills: []
|
||||||
|
settings: {{}}
|
||||||
|
""",
|
||||||
|
}
|
||||||
|
|
||||||
|
# 角色专用模板
|
||||||
|
ROLE_TEMPLATES = {
|
||||||
|
"fundamental": {
|
||||||
|
"ROLE.md": """# Role: Fundamental Analyst
|
||||||
|
|
||||||
|
## 职责
|
||||||
|
分析公司财务报表、盈利能力、成长性、竞争优势等基本面因素。
|
||||||
|
|
||||||
|
## 分析维度
|
||||||
|
- 财务报表分析(资产负债表、利润表、现金流量表)
|
||||||
|
- 盈利能力指标(ROE、ROA、毛利率、净利率)
|
||||||
|
- 成长性指标(营收增长率、利润增长率)
|
||||||
|
- 估值指标(P/E、P/B、P/S)
|
||||||
|
- 行业地位和竞争优势
|
||||||
|
|
||||||
|
## 输出格式
|
||||||
|
- 财务健康度评分(1-10)
|
||||||
|
- 成长性评分(1-10)
|
||||||
|
- 关键财务亮点和风险
|
||||||
|
- 同业对比分析
|
||||||
|
""",
|
||||||
|
"SOUL.md": """# Soul
|
||||||
|
|
||||||
|
你是严谨的基本面分析师,像沃伦·巴菲特一样注重企业内在价值。
|
||||||
|
你的分析深入细致,关注长期价值而非短期波动。
|
||||||
|
语气沉稳、逻辑严密,善于发现财务数据背后的商业本质。
|
||||||
|
""",
|
||||||
|
},
|
||||||
|
|
||||||
|
"technical": {
|
||||||
|
"ROLE.md": """# Role: Technical Analyst
|
||||||
|
|
||||||
|
## 职责
|
||||||
|
分析价格走势、交易量、技术指标,识别买卖时机。
|
||||||
|
|
||||||
|
## 分析维度
|
||||||
|
- 趋势分析(长期/中期/短期趋势)
|
||||||
|
- 支撑阻力位识别
|
||||||
|
- 技术指标(MACD、RSI、KDJ、布林带等)
|
||||||
|
- 形态识别(头肩顶/底、双底、三角形等)
|
||||||
|
- 量价关系分析
|
||||||
|
|
||||||
|
## 输出格式
|
||||||
|
- 趋势方向(上涨/下跌/震荡)
|
||||||
|
- 关键价位(支撑/阻力)
|
||||||
|
- 技术信号(买入/卖出/观望)
|
||||||
|
- 置信度评分
|
||||||
|
""",
|
||||||
|
"SOUL.md": """# Soul
|
||||||
|
|
||||||
|
你是敏锐的技术分析师,相信价格包含一切信息。
|
||||||
|
你善于从图表中发现规律,像侦探一样寻找市场留下的痕迹。
|
||||||
|
语气果断、快速反应,善于捕捉稍纵即逝的交易机会。
|
||||||
|
""",
|
||||||
|
},
|
||||||
|
|
||||||
|
"sentiment": {
|
||||||
|
"ROLE.md": """# Role: Sentiment Analyst
|
||||||
|
|
||||||
|
## 职责
|
||||||
|
分析市场情绪、资金流向、新闻舆情,判断市场心理状态。
|
||||||
|
|
||||||
|
## 分析维度
|
||||||
|
- 市场情绪指标(恐慌/贪婪指数)
|
||||||
|
- 资金流向分析(主力/散户资金)
|
||||||
|
- 新闻舆情分析(正面/负面/中性)
|
||||||
|
- 社交媒体情绪
|
||||||
|
- 机构持仓变化
|
||||||
|
|
||||||
|
## 输出格式
|
||||||
|
- 情绪评分(-10到+10,极度恐慌到极度贪婪)
|
||||||
|
- 资金流向判断
|
||||||
|
- 舆情摘要
|
||||||
|
- 情绪拐点预警
|
||||||
|
""",
|
||||||
|
"SOUL.md": """# Soul
|
||||||
|
|
||||||
|
你是敏感的市场情绪捕手,善于感知市场的恐惧与贪婪。
|
||||||
|
你关注人性在金融市场中的表现,理解情绪如何驱动价格。
|
||||||
|
语气富有洞察力、善于捕捉微妙变化,像心理学家一样理解市场参与者。
|
||||||
|
""",
|
||||||
|
},
|
||||||
|
|
||||||
|
"valuation": {
|
||||||
|
"ROLE.md": """# Role: Valuation Analyst
|
||||||
|
|
||||||
|
## 职责
|
||||||
|
评估公司内在价值,计算合理价格区间,识别高估/低估机会。
|
||||||
|
|
||||||
|
## 分析维度
|
||||||
|
- DCF现金流折现模型
|
||||||
|
- 相对估值法(P/E、EV/EBITDA等)
|
||||||
|
- 资产重估法
|
||||||
|
- 分部估值(SOTP)
|
||||||
|
- 安全边际计算
|
||||||
|
|
||||||
|
## 输出格式
|
||||||
|
- 内在价值估算
|
||||||
|
- 合理价格区间
|
||||||
|
- 当前价格vs内在价值(高估/低估百分比)
|
||||||
|
- 估值假设和敏感性分析
|
||||||
|
""",
|
||||||
|
"SOUL.md": """# Soul
|
||||||
|
|
||||||
|
你是精确的估值分析师,追求计算内在价值的准确区间。
|
||||||
|
你像精算师一样严谨,注重假设的合理性和安全边际。
|
||||||
|
语气精确、注重数字,善于发现市场定价错误带来的机会。
|
||||||
|
""",
|
||||||
|
},
|
||||||
|
|
||||||
|
"portfolio": {
|
||||||
|
"ROLE.md": """# Role: Portfolio Manager
|
||||||
|
|
||||||
|
## 职责
|
||||||
|
统筹各分析师意见,制定投资决策,管理投资组合配置。
|
||||||
|
|
||||||
|
## 分析维度
|
||||||
|
- 资产配置策略(股债比例、行业分布)
|
||||||
|
- 风险收益平衡
|
||||||
|
- 仓位管理(建仓/加仓/减仓/清仓)
|
||||||
|
- 再平衡时机
|
||||||
|
- 组合相关性分析
|
||||||
|
|
||||||
|
## 输出格式
|
||||||
|
- 投资决策(买入/卖出/持有)
|
||||||
|
- 建议仓位比例
|
||||||
|
- 目标价位
|
||||||
|
- 止损止盈设置
|
||||||
|
- 组合调整建议
|
||||||
|
""",
|
||||||
|
"SOUL.md": """# Soul
|
||||||
|
|
||||||
|
你是睿智的投资组合经理,像将军一样统筹全局。
|
||||||
|
你善于权衡各方意见,做出果断而理性的投资决策。
|
||||||
|
语气权威、决策果断,对组合整体表现负有最终责任。
|
||||||
|
""",
|
||||||
|
},
|
||||||
|
|
||||||
|
"risk": {
|
||||||
|
"ROLE.md": """# Role: Risk Manager
|
||||||
|
|
||||||
|
## 职责
|
||||||
|
识别、评估和监控投资风险,确保组合风险在可控范围内。
|
||||||
|
|
||||||
|
## 分析维度
|
||||||
|
- 市场风险(Beta、波动率)
|
||||||
|
- 信用风险
|
||||||
|
- 流动性风险
|
||||||
|
- 集中度风险
|
||||||
|
- 尾部风险(VaR、CVaR)
|
||||||
|
- 压力测试
|
||||||
|
|
||||||
|
## 输出格式
|
||||||
|
- 风险等级(低/中/高/极高)
|
||||||
|
- 风险敞口分析
|
||||||
|
- 风险调整建议
|
||||||
|
- 预警阈值设置
|
||||||
|
- 应急预案
|
||||||
|
""",
|
||||||
|
"SOUL.md": """# Soul
|
||||||
|
|
||||||
|
你是谨慎的风险管理者,时刻警惕潜在的损失。
|
||||||
|
你像守门员一样守护组合安全,宁可错过机会也不冒无法承受的风险。
|
||||||
|
语气保守、风险意识强,善于发现隐藏的威胁和脆弱性。
|
||||||
|
""",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_base_template(filename: str) -> str | None:
|
||||||
|
"""获取基础模板内容"""
|
||||||
|
return BASE_TEMPLATES.get(filename)
|
||||||
|
|
||||||
|
|
||||||
|
def get_role_template(role_type: str, filename: str) -> str | None:
|
||||||
|
"""获取角色专用模板内容"""
|
||||||
|
role = ROLE_TEMPLATES.get(role_type)
|
||||||
|
if role:
|
||||||
|
return role.get(filename)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_all_role_types() -> list[str]:
|
||||||
|
"""获取所有角色类型列表"""
|
||||||
|
return list(ROLE_TEMPLATES.keys())
|
||||||
|
|
||||||
|
|
||||||
|
def render_agent_yaml(agent_id: str, agent_type: str, name: str) -> str:
|
||||||
|
"""渲染agent.yaml模板"""
|
||||||
|
return BASE_TEMPLATES["agent.yaml"].format(
|
||||||
|
agent_id=agent_id,
|
||||||
|
agent_type=agent_type,
|
||||||
|
name=name
|
||||||
|
)
|
||||||
@@ -1,22 +1,30 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
"""Toolkit factory following AgentScope's skill + tool group practices."""
|
"""Toolkit factory following AgentScope's skill + tool group practices.
|
||||||
|
|
||||||
from typing import Any, Dict, Iterable
|
支持从Agent工作空间动态创建工具集,加载builtin/customized技能,
|
||||||
|
以及合并Agent特定工具。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Any, Dict, Iterable, List, Optional
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from .agent_workspace import load_agent_workspace_config
|
|
||||||
from backend.config.bootstrap_config import get_bootstrap_config_for_run
|
|
||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
from .skills_manager import SkillsManager
|
from backend.agents.agent_workspace import load_agent_workspace_config
|
||||||
|
from backend.agents.skills_manager import SkillsManager
|
||||||
|
from backend.agents.skill_loader import load_skill_from_dir, get_skill_tools
|
||||||
|
from backend.config.bootstrap_config import get_bootstrap_config_for_run
|
||||||
|
|
||||||
|
|
||||||
def load_agent_profiles() -> Dict[str, Dict[str, Any]]:
|
def load_agent_profiles() -> Dict[str, Dict[str, Any]]:
|
||||||
|
"""加载Agent配置文件"""
|
||||||
config_path = SkillsManager().project_root / "backend" / "config" / "agent_profiles.yaml"
|
config_path = SkillsManager().project_root / "backend" / "config" / "agent_profiles.yaml"
|
||||||
with open(config_path, "r", encoding="utf-8") as file:
|
with open(config_path, "r", encoding="utf-8") as file:
|
||||||
return yaml.safe_load(file) or {}
|
return yaml.safe_load(file) or {}
|
||||||
|
|
||||||
|
|
||||||
def _register_analysis_tool_groups(toolkit: Any) -> None:
|
def _register_analysis_tool_groups(toolkit: Any) -> None:
|
||||||
|
"""注册分析工具组"""
|
||||||
from backend.tools.analysis_tools import TOOL_REGISTRY
|
from backend.tools.analysis_tools import TOOL_REGISTRY
|
||||||
|
|
||||||
tool_groups = {
|
tool_groups = {
|
||||||
@@ -95,6 +103,7 @@ def _register_analysis_tool_groups(toolkit: Any) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def _register_portfolio_tool_groups(toolkit: Any, pm_agent: Any) -> None:
|
def _register_portfolio_tool_groups(toolkit: Any, pm_agent: Any) -> None:
|
||||||
|
"""注册投资组合工具组"""
|
||||||
toolkit.create_tool_group(
|
toolkit.create_tool_group(
|
||||||
group_name="portfolio_ops",
|
group_name="portfolio_ops",
|
||||||
description="Portfolio decision recording tools.",
|
description="Portfolio decision recording tools.",
|
||||||
@@ -111,6 +120,7 @@ def _register_portfolio_tool_groups(toolkit: Any, pm_agent: Any) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def _register_risk_tool_groups(toolkit: Any) -> None:
|
def _register_risk_tool_groups(toolkit: Any) -> None:
|
||||||
|
"""注册风险工具组"""
|
||||||
from backend.tools.risk_tools import (
|
from backend.tools.risk_tools import (
|
||||||
assess_margin_and_liquidity,
|
assess_margin_and_liquidity,
|
||||||
assess_position_concentration,
|
assess_position_concentration,
|
||||||
@@ -146,7 +156,17 @@ def create_agent_toolkit(
|
|||||||
owner: Any = None,
|
owner: Any = None,
|
||||||
active_skill_dirs: Iterable[str] | None = None,
|
active_skill_dirs: Iterable[str] | None = None,
|
||||||
) -> Any:
|
) -> Any:
|
||||||
"""Create a Toolkit with agent skills and grouped tools."""
|
"""Create a Toolkit with agent skills and grouped tools.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
agent_id: Agent标识符
|
||||||
|
config_name: 运行配置名称
|
||||||
|
owner: Agent实例(用于注册特定方法)
|
||||||
|
active_skill_dirs: 显式指定的活动技能目录列表
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
配置好的Toolkit实例
|
||||||
|
"""
|
||||||
from agentscope.tool import Toolkit
|
from agentscope.tool import Toolkit
|
||||||
|
|
||||||
profiles = load_agent_profiles()
|
profiles = load_agent_profiles()
|
||||||
@@ -207,3 +227,173 @@ def create_agent_toolkit(
|
|||||||
toolkit.update_tool_groups(group_names=active_groups, active=True)
|
toolkit.update_tool_groups(group_names=active_groups, active=True)
|
||||||
|
|
||||||
return toolkit
|
return toolkit
|
||||||
|
|
||||||
|
|
||||||
|
def create_toolkit_from_workspace(
|
||||||
|
agent_id: str,
|
||||||
|
config_name: str,
|
||||||
|
owner: Any = None,
|
||||||
|
include_builtin: bool = True,
|
||||||
|
include_customized: bool = True,
|
||||||
|
include_local: bool = True,
|
||||||
|
active_groups: Optional[List[str]] = None,
|
||||||
|
) -> Any:
|
||||||
|
"""从Agent工作空间创建工具集
|
||||||
|
|
||||||
|
这是create_agent_toolkit的增强版本,支持更灵活的技能加载策略。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
agent_id: Agent标识符
|
||||||
|
config_name: 运行配置名称
|
||||||
|
owner: Agent实例
|
||||||
|
include_builtin: 是否包含builtin技能
|
||||||
|
include_customized: 是否包含customized技能
|
||||||
|
include_local: 是否包含agent-local技能
|
||||||
|
active_groups: 显式指定的活动工具组
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
配置好的Toolkit实例
|
||||||
|
"""
|
||||||
|
from agentscope.tool import Toolkit
|
||||||
|
|
||||||
|
skills_manager = SkillsManager()
|
||||||
|
agent_config = load_agent_workspace_config(
|
||||||
|
skills_manager.get_agent_asset_dir(config_name, agent_id) / "agent.yaml",
|
||||||
|
)
|
||||||
|
|
||||||
|
toolkit = Toolkit(
|
||||||
|
agent_skill_instruction=(
|
||||||
|
"<system-info>You have access to project skills. Each skill lives in a "
|
||||||
|
"directory and is described by SKILL.md. Follow the skill instructions "
|
||||||
|
"when they are relevant to the current task.</system-info>"
|
||||||
|
),
|
||||||
|
agent_skill_template="- {name} (dir: {dir}): {description}",
|
||||||
|
)
|
||||||
|
|
||||||
|
# 注册Agent类型的默认工具组
|
||||||
|
if agent_id.endswith("_analyst"):
|
||||||
|
_register_analysis_tool_groups(toolkit)
|
||||||
|
elif agent_id == "portfolio_manager" and owner is not None:
|
||||||
|
_register_portfolio_tool_groups(toolkit, owner)
|
||||||
|
elif agent_id == "risk_manager":
|
||||||
|
_register_risk_tool_groups(toolkit)
|
||||||
|
|
||||||
|
# 收集所有要加载的技能目录
|
||||||
|
skill_dirs: List[Path] = []
|
||||||
|
|
||||||
|
# 1. 从active目录加载已同步的技能
|
||||||
|
active_root = skills_manager.get_agent_active_root(config_name, agent_id)
|
||||||
|
if active_root.exists():
|
||||||
|
for skill_dir in sorted(active_root.iterdir()):
|
||||||
|
if skill_dir.is_dir() and (skill_dir / "SKILL.md").exists():
|
||||||
|
skill_dirs.append(skill_dir)
|
||||||
|
|
||||||
|
# 2. 从installed目录加载
|
||||||
|
installed_root = skills_manager.get_agent_installed_root(config_name, agent_id)
|
||||||
|
if installed_root.exists():
|
||||||
|
for skill_dir in sorted(installed_root.iterdir()):
|
||||||
|
if skill_dir.is_dir() and (skill_dir / "SKILL.md").exists():
|
||||||
|
if skill_dir not in skill_dirs:
|
||||||
|
skill_dirs.append(skill_dir)
|
||||||
|
|
||||||
|
# 3. 从local目录加载agent-local技能
|
||||||
|
if include_local:
|
||||||
|
local_root = skills_manager.get_agent_local_root(config_name, agent_id)
|
||||||
|
if local_root.exists():
|
||||||
|
for skill_dir in sorted(local_root.iterdir()):
|
||||||
|
if skill_dir.is_dir() and (skill_dir / "SKILL.md").exists():
|
||||||
|
if skill_dir not in skill_dirs:
|
||||||
|
skill_dirs.append(skill_dir)
|
||||||
|
|
||||||
|
# 注册技能到toolkit
|
||||||
|
for skill_dir in skill_dirs:
|
||||||
|
toolkit.register_agent_skill(str(skill_dir))
|
||||||
|
|
||||||
|
# 激活指定的工具组
|
||||||
|
if active_groups is None:
|
||||||
|
# 从配置中读取
|
||||||
|
profiles = load_agent_profiles()
|
||||||
|
profile = profiles.get(agent_id, {})
|
||||||
|
active_groups = agent_config.active_tool_groups or profile.get("active_tool_groups", [])
|
||||||
|
|
||||||
|
# 应用禁用列表
|
||||||
|
disabled_groups = set(agent_config.disabled_tool_groups)
|
||||||
|
if disabled_groups:
|
||||||
|
active_groups = [g for g in active_groups if g not in disabled_groups]
|
||||||
|
|
||||||
|
if active_groups:
|
||||||
|
toolkit.update_tool_groups(group_names=active_groups, active=True)
|
||||||
|
|
||||||
|
return toolkit
|
||||||
|
|
||||||
|
|
||||||
|
def get_toolkit_info(toolkit: Any) -> Dict[str, Any]:
|
||||||
|
"""获取工具集信息
|
||||||
|
|
||||||
|
Args:
|
||||||
|
toolkit: Toolkit实例
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
工具集信息字典
|
||||||
|
"""
|
||||||
|
info = {
|
||||||
|
"tool_groups": {},
|
||||||
|
"skills": [],
|
||||||
|
"tools_count": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
# 获取工具组信息
|
||||||
|
groups = getattr(toolkit, "tool_groups", {})
|
||||||
|
for name, group in groups.items():
|
||||||
|
info["tool_groups"][name] = {
|
||||||
|
"description": getattr(group, "description", ""),
|
||||||
|
"active": getattr(group, "active", False),
|
||||||
|
"tools": [t.name for t in getattr(group, "tools", [])],
|
||||||
|
}
|
||||||
|
info["tools_count"] += len(getattr(group, "tools", []))
|
||||||
|
|
||||||
|
# 获取技能信息
|
||||||
|
skills = getattr(toolkit, "agent_skills", [])
|
||||||
|
for skill in skills:
|
||||||
|
info["skills"].append({
|
||||||
|
"name": getattr(skill, "name", "unknown"),
|
||||||
|
"path": getattr(skill, "path", ""),
|
||||||
|
"description": getattr(skill, "description", ""),
|
||||||
|
})
|
||||||
|
|
||||||
|
return info
|
||||||
|
|
||||||
|
|
||||||
|
def refresh_toolkit_skills(
|
||||||
|
toolkit: Any,
|
||||||
|
agent_id: str,
|
||||||
|
config_name: str,
|
||||||
|
) -> None:
|
||||||
|
"""刷新工具集中的技能
|
||||||
|
|
||||||
|
重新从工作空间加载技能,用于运行时技能变更。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
toolkit: Toolkit实例
|
||||||
|
agent_id: Agent标识符
|
||||||
|
config_name: 运行配置名称
|
||||||
|
"""
|
||||||
|
skills_manager = SkillsManager()
|
||||||
|
|
||||||
|
# 清除现有技能
|
||||||
|
if hasattr(toolkit, "agent_skills"):
|
||||||
|
toolkit.agent_skills.clear()
|
||||||
|
|
||||||
|
# 重新加载active技能
|
||||||
|
active_root = skills_manager.get_agent_active_root(config_name, agent_id)
|
||||||
|
if active_root.exists():
|
||||||
|
for skill_dir in sorted(active_root.iterdir()):
|
||||||
|
if skill_dir.is_dir() and (skill_dir / "SKILL.md").exists():
|
||||||
|
toolkit.register_agent_skill(str(skill_dir))
|
||||||
|
|
||||||
|
# 重新加载local技能
|
||||||
|
local_root = skills_manager.get_agent_local_root(config_name, agent_id)
|
||||||
|
if local_root.exists():
|
||||||
|
for skill_dir in sorted(local_root.iterdir()):
|
||||||
|
if skill_dir.is_dir() and (skill_dir / "SKILL.md").exists():
|
||||||
|
toolkit.register_agent_skill(str(skill_dir))
|
||||||
|
|||||||
326
backend/agents/workspace.py
Normal file
326
backend/agents/workspace.py
Normal file
@@ -0,0 +1,326 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Workspace Manager - Create and manage agent workspaces."""
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class WorkspaceConfig:
|
||||||
|
"""Configuration for a workspace."""
|
||||||
|
|
||||||
|
workspace_id: str
|
||||||
|
name: str = ""
|
||||||
|
description: str = ""
|
||||||
|
created_at: str = ""
|
||||||
|
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
def to_dict(self) -> Dict[str, Any]:
|
||||||
|
"""Serialize to dictionary."""
|
||||||
|
return {
|
||||||
|
"workspace_id": self.workspace_id,
|
||||||
|
"name": self.name,
|
||||||
|
"description": self.description,
|
||||||
|
"created_at": self.created_at,
|
||||||
|
"metadata": self.metadata,
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data: Dict[str, Any]) -> "WorkspaceConfig":
|
||||||
|
"""Create from dictionary."""
|
||||||
|
return cls(
|
||||||
|
workspace_id=data.get("workspace_id", ""),
|
||||||
|
name=data.get("name", ""),
|
||||||
|
description=data.get("description", ""),
|
||||||
|
created_at=data.get("created_at", ""),
|
||||||
|
metadata=data.get("metadata", {}),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class WorkspaceRegistry:
|
||||||
|
"""Registry for persistent workspace definitions (design-time)."""
|
||||||
|
|
||||||
|
def __init__(self, project_root: Optional[Path] = None):
|
||||||
|
"""Initialize the workspace manager.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
project_root: Root directory of the project
|
||||||
|
"""
|
||||||
|
self.project_root = project_root or Path(__file__).parent.parent.parent
|
||||||
|
self.workspaces_root = self.project_root / "workspaces"
|
||||||
|
self.workspaces_root.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
def create_workspace(
|
||||||
|
self,
|
||||||
|
workspace_id: str,
|
||||||
|
name: Optional[str] = None,
|
||||||
|
description: Optional[str] = None,
|
||||||
|
metadata: Optional[Dict[str, Any]] = None,
|
||||||
|
) -> WorkspaceConfig:
|
||||||
|
"""Create a new workspace with directory structure.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
workspace_id: Unique identifier for the workspace
|
||||||
|
name: Display name for the workspace
|
||||||
|
description: Optional description
|
||||||
|
metadata: Optional metadata dictionary
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
WorkspaceConfig instance
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If workspace already exists
|
||||||
|
"""
|
||||||
|
workspace_dir = self.workspaces_root / workspace_id
|
||||||
|
|
||||||
|
if workspace_dir.exists():
|
||||||
|
raise ValueError(f"Workspace '{workspace_id}' already exists")
|
||||||
|
|
||||||
|
# Create directory structure
|
||||||
|
workspace_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Create subdirectories
|
||||||
|
(workspace_dir / "agents").mkdir(exist_ok=True)
|
||||||
|
(workspace_dir / "shared" / "market_data").mkdir(parents=True, exist_ok=True)
|
||||||
|
(workspace_dir / "shared" / "memories").mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Create workspace.yaml
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
config = WorkspaceConfig(
|
||||||
|
workspace_id=workspace_id,
|
||||||
|
name=name or workspace_id,
|
||||||
|
description=description or "",
|
||||||
|
created_at=datetime.now().isoformat(),
|
||||||
|
metadata=metadata or {},
|
||||||
|
)
|
||||||
|
|
||||||
|
self._write_workspace_config(workspace_dir, config)
|
||||||
|
|
||||||
|
return config
|
||||||
|
|
||||||
|
def list_workspaces(self) -> List[WorkspaceConfig]:
|
||||||
|
"""List all workspaces.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of WorkspaceConfig instances
|
||||||
|
"""
|
||||||
|
workspaces = []
|
||||||
|
|
||||||
|
if not self.workspaces_root.exists():
|
||||||
|
return workspaces
|
||||||
|
|
||||||
|
for workspace_dir in self.workspaces_root.iterdir():
|
||||||
|
if not workspace_dir.is_dir():
|
||||||
|
continue
|
||||||
|
|
||||||
|
config_path = workspace_dir / "workspace.yaml"
|
||||||
|
if config_path.exists():
|
||||||
|
try:
|
||||||
|
with open(config_path, "r", encoding="utf-8") as f:
|
||||||
|
data = yaml.safe_load(f) or {}
|
||||||
|
workspaces.append(WorkspaceConfig.from_dict(data))
|
||||||
|
except Exception:
|
||||||
|
# Skip invalid workspace configs
|
||||||
|
pass
|
||||||
|
|
||||||
|
return workspaces
|
||||||
|
|
||||||
|
def get_workspace_agents(self, workspace_id: str) -> List[Dict[str, Any]]:
|
||||||
|
"""Get all agents in a workspace.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
workspace_id: ID of the workspace
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of agent information dictionaries
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If workspace doesn't exist
|
||||||
|
"""
|
||||||
|
workspace_dir = self.workspaces_root / workspace_id
|
||||||
|
|
||||||
|
if not workspace_dir.exists():
|
||||||
|
raise ValueError(f"Workspace '{workspace_id}' does not exist")
|
||||||
|
|
||||||
|
agents = []
|
||||||
|
agents_dir = workspace_dir / "agents"
|
||||||
|
|
||||||
|
if not agents_dir.exists():
|
||||||
|
return agents
|
||||||
|
|
||||||
|
for agent_dir in agents_dir.iterdir():
|
||||||
|
if not agent_dir.is_dir():
|
||||||
|
continue
|
||||||
|
|
||||||
|
config_path = agent_dir / "agent.yaml"
|
||||||
|
if config_path.exists():
|
||||||
|
try:
|
||||||
|
with open(config_path, "r", encoding="utf-8") as f:
|
||||||
|
config = yaml.safe_load(f) or {}
|
||||||
|
|
||||||
|
agents.append({
|
||||||
|
"agent_id": agent_dir.name,
|
||||||
|
"agent_type": config.get("agent_type", "unknown"),
|
||||||
|
"config_path": str(config_path),
|
||||||
|
})
|
||||||
|
except Exception:
|
||||||
|
# Skip invalid agent configs
|
||||||
|
pass
|
||||||
|
|
||||||
|
return agents
|
||||||
|
|
||||||
|
def get_agent_workspace(self, agent_id: str, workspace_id: str) -> Optional[Path]:
|
||||||
|
"""Get the workspace path for an agent.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
agent_id: ID of the agent
|
||||||
|
workspace_id: ID of the workspace
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Path to agent directory, or None if not found
|
||||||
|
"""
|
||||||
|
agent_dir = self.workspaces_root / workspace_id / "agents" / agent_id
|
||||||
|
|
||||||
|
if agent_dir.exists():
|
||||||
|
return agent_dir
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def workspace_exists(self, workspace_id: str) -> bool:
|
||||||
|
"""Check if a workspace exists.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
workspace_id: ID of the workspace
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if workspace exists, False otherwise
|
||||||
|
"""
|
||||||
|
workspace_dir = self.workspaces_root / workspace_id
|
||||||
|
return workspace_dir.exists() and (workspace_dir / "workspace.yaml").exists()
|
||||||
|
|
||||||
|
def delete_workspace(self, workspace_id: str, force: bool = False) -> bool:
|
||||||
|
"""Delete a workspace and all its agents.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
workspace_id: ID of the workspace to delete
|
||||||
|
force: If True, delete even if workspace has agents
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if deleted, False if workspace didn't exist
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If workspace has agents and force is False
|
||||||
|
"""
|
||||||
|
import shutil
|
||||||
|
|
||||||
|
workspace_dir = self.workspaces_root / workspace_id
|
||||||
|
|
||||||
|
if not workspace_dir.exists():
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Check for agents
|
||||||
|
agents_dir = workspace_dir / "agents"
|
||||||
|
if agents_dir.exists() and any(agents_dir.iterdir()):
|
||||||
|
if not force:
|
||||||
|
raise ValueError(
|
||||||
|
f"Workspace '{workspace_id}' contains agents. "
|
||||||
|
"Use force=True to delete anyway."
|
||||||
|
)
|
||||||
|
|
||||||
|
shutil.rmtree(workspace_dir)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def get_workspace_path(self, workspace_id: str) -> Path:
|
||||||
|
"""Get the path to a workspace directory.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
workspace_id: ID of the workspace
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Path to workspace directory
|
||||||
|
"""
|
||||||
|
return self.workspaces_root / workspace_id
|
||||||
|
|
||||||
|
def get_shared_data_path(self, workspace_id: str) -> Optional[Path]:
|
||||||
|
"""Get the shared data directory for a workspace.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
workspace_id: ID of the workspace
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Path to shared data directory, or None if workspace doesn't exist
|
||||||
|
"""
|
||||||
|
workspace_dir = self.workspaces_root / workspace_id
|
||||||
|
|
||||||
|
if not workspace_dir.exists():
|
||||||
|
return None
|
||||||
|
|
||||||
|
return workspace_dir / "shared"
|
||||||
|
|
||||||
|
def update_workspace_config(
|
||||||
|
self,
|
||||||
|
workspace_id: str,
|
||||||
|
name: Optional[str] = None,
|
||||||
|
description: Optional[str] = None,
|
||||||
|
metadata: Optional[Dict[str, Any]] = None,
|
||||||
|
) -> WorkspaceConfig:
|
||||||
|
"""Update workspace configuration.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
workspace_id: ID of the workspace
|
||||||
|
name: New display name (optional)
|
||||||
|
description: New description (optional)
|
||||||
|
metadata: Metadata to merge (optional)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Updated WorkspaceConfig
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If workspace doesn't exist
|
||||||
|
"""
|
||||||
|
workspace_dir = self.workspaces_root / workspace_id
|
||||||
|
|
||||||
|
if not workspace_dir.exists():
|
||||||
|
raise ValueError(f"Workspace '{workspace_id}' does not exist")
|
||||||
|
|
||||||
|
config_path = workspace_dir / "workspace.yaml"
|
||||||
|
current_config = {}
|
||||||
|
|
||||||
|
if config_path.exists():
|
||||||
|
try:
|
||||||
|
with open(config_path, "r", encoding="utf-8") as f:
|
||||||
|
current_config = yaml.safe_load(f) or {}
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Update fields
|
||||||
|
if name is not None:
|
||||||
|
current_config["name"] = name
|
||||||
|
if description is not None:
|
||||||
|
current_config["description"] = description
|
||||||
|
if metadata is not None:
|
||||||
|
current_config["metadata"] = {**current_config.get("metadata", {}), **metadata}
|
||||||
|
|
||||||
|
config = WorkspaceConfig.from_dict(current_config)
|
||||||
|
self._write_workspace_config(workspace_dir, config)
|
||||||
|
|
||||||
|
return config
|
||||||
|
|
||||||
|
def _write_workspace_config(self, workspace_dir: Path, config: WorkspaceConfig) -> None:
|
||||||
|
"""Write workspace configuration to file.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
workspace_dir: Workspace directory
|
||||||
|
config: Workspace configuration
|
||||||
|
"""
|
||||||
|
config_path = workspace_dir / "workspace.yaml"
|
||||||
|
with open(config_path, "w", encoding="utf-8") as f:
|
||||||
|
yaml.safe_dump(config.to_dict(), f, allow_unicode=True, sort_keys=False)
|
||||||
|
|
||||||
|
|
||||||
|
# Backward-compatible alias: legacy imports expect WorkspaceManager.
|
||||||
|
WorkspaceManager = WorkspaceRegistry
|
||||||
@@ -9,7 +9,7 @@ import yaml
|
|||||||
from .skills_manager import SkillsManager
|
from .skills_manager import SkillsManager
|
||||||
|
|
||||||
|
|
||||||
class WorkspaceManager:
|
class RunWorkspaceManager:
|
||||||
"""Create and maintain run-level prompt asset files for each agent."""
|
"""Create and maintain run-level prompt asset files for each agent."""
|
||||||
|
|
||||||
def __init__(self, project_root: Optional[Path] = None):
|
def __init__(self, project_root: Optional[Path] = None):
|
||||||
@@ -197,3 +197,7 @@ class WorkspaceManager:
|
|||||||
yaml.safe_dump(payload, allow_unicode=True, sort_keys=False),
|
yaml.safe_dump(payload, allow_unicode=True, sort_keys=False),
|
||||||
encoding="utf-8",
|
encoding="utf-8",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Backward-compatible alias: code importing WorkspaceManager from this module should continue to work.
|
||||||
|
WorkspaceManager = RunWorkspaceManager
|
||||||
|
|||||||
21
backend/api/__init__.py
Normal file
21
backend/api/__init__.py
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
API Routes Package
|
||||||
|
|
||||||
|
Provides REST API endpoints for:
|
||||||
|
- Agent management
|
||||||
|
- Workspace management
|
||||||
|
- Tool guard operations
|
||||||
|
"""
|
||||||
|
|
||||||
|
from .agents import router as agents_router
|
||||||
|
from .workspaces import router as workspaces_router
|
||||||
|
from .guard import router as guard_router
|
||||||
|
from .runtime import router as runtime_router
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"agents_router",
|
||||||
|
"workspaces_router",
|
||||||
|
"guard_router",
|
||||||
|
"runtime_router",
|
||||||
|
]
|
||||||
405
backend/api/agents.py
Normal file
405
backend/api/agents.py
Normal file
@@ -0,0 +1,405 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
Agent API Routes
|
||||||
|
|
||||||
|
Provides REST API endpoints for agent management within workspaces.
|
||||||
|
"""
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException, Depends, Body
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from backend.agents import AgentFactory, WorkspaceManager, get_registry
|
||||||
|
from backend.agents.skills_manager import SkillsManager
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/workspaces/{workspace_id}/agents", tags=["agents"])
|
||||||
|
|
||||||
|
|
||||||
|
# Request/Response Models
|
||||||
|
class CreateAgentRequest(BaseModel):
|
||||||
|
"""Request to create a new agent."""
|
||||||
|
agent_id: str = Field(..., description="Unique agent identifier")
|
||||||
|
agent_type: str = Field(..., description="Type of agent (e.g., technical_analyst)")
|
||||||
|
name: Optional[str] = Field(None, description="Display name")
|
||||||
|
description: Optional[str] = Field(None, description="Agent description")
|
||||||
|
clone_from: Optional[str] = Field(None, description="Agent ID to clone from")
|
||||||
|
llm_model_config: Optional[Dict[str, Any]] = Field(None, description="LLM model configuration")
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateAgentRequest(BaseModel):
|
||||||
|
"""Request to update an agent."""
|
||||||
|
name: Optional[str] = None
|
||||||
|
description: Optional[str] = None
|
||||||
|
enabled_skills: Optional[List[str]] = None
|
||||||
|
disabled_skills: Optional[List[str]] = None
|
||||||
|
|
||||||
|
|
||||||
|
class AgentResponse(BaseModel):
|
||||||
|
"""Agent information response."""
|
||||||
|
agent_id: str
|
||||||
|
agent_type: str
|
||||||
|
workspace_id: str
|
||||||
|
config_path: str
|
||||||
|
agent_dir: str
|
||||||
|
status: str = "inactive"
|
||||||
|
|
||||||
|
|
||||||
|
class AgentFileResponse(BaseModel):
|
||||||
|
"""Agent file content response."""
|
||||||
|
filename: str
|
||||||
|
content: str
|
||||||
|
|
||||||
|
|
||||||
|
# Dependencies
|
||||||
|
def get_agent_factory():
|
||||||
|
"""Get AgentFactory instance."""
|
||||||
|
return AgentFactory()
|
||||||
|
|
||||||
|
|
||||||
|
def get_workspace_manager():
|
||||||
|
"""Get WorkspaceManager instance."""
|
||||||
|
return WorkspaceManager()
|
||||||
|
|
||||||
|
|
||||||
|
def get_skills_manager():
|
||||||
|
"""Get SkillsManager instance."""
|
||||||
|
return SkillsManager()
|
||||||
|
|
||||||
|
|
||||||
|
# Routes
|
||||||
|
@router.post("", response_model=AgentResponse)
|
||||||
|
async def create_agent(
|
||||||
|
workspace_id: str,
|
||||||
|
request: CreateAgentRequest,
|
||||||
|
factory: AgentFactory = Depends(get_agent_factory),
|
||||||
|
registry = Depends(get_registry),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Create a new agent in a workspace.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
workspace_id: Workspace identifier
|
||||||
|
request: Agent creation parameters
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Created agent information
|
||||||
|
"""
|
||||||
|
# Check workspace exists
|
||||||
|
if not factory.workspaces_root.exists():
|
||||||
|
raise HTTPException(status_code=404, detail="Workspaces root not found")
|
||||||
|
|
||||||
|
workspace_dir = factory.workspaces_root / workspace_id
|
||||||
|
if not workspace_dir.exists():
|
||||||
|
raise HTTPException(status_code=404, detail=f"Workspace '{workspace_id}' not found")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Create agent
|
||||||
|
agent = factory.create_agent(
|
||||||
|
agent_id=request.agent_id,
|
||||||
|
agent_type=request.agent_type,
|
||||||
|
workspace_id=workspace_id,
|
||||||
|
clone_from=request.clone_from,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Register in registry
|
||||||
|
registry.register(
|
||||||
|
agent_id=request.agent_id,
|
||||||
|
agent_type=request.agent_type,
|
||||||
|
workspace_id=workspace_id,
|
||||||
|
config_path=str(agent.config_path),
|
||||||
|
agent_dir=str(agent.agent_dir),
|
||||||
|
status="inactive",
|
||||||
|
)
|
||||||
|
|
||||||
|
return AgentResponse(
|
||||||
|
agent_id=agent.agent_id,
|
||||||
|
agent_type=agent.agent_type,
|
||||||
|
workspace_id=agent.workspace_id,
|
||||||
|
config_path=str(agent.config_path),
|
||||||
|
agent_dir=str(agent.agent_dir),
|
||||||
|
status="inactive",
|
||||||
|
)
|
||||||
|
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=List[AgentResponse])
|
||||||
|
async def list_agents(
|
||||||
|
workspace_id: str,
|
||||||
|
factory: AgentFactory = Depends(get_agent_factory),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
List all agents in a workspace.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
workspace_id: Workspace identifier
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of agents
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
agents_data = factory.list_agents(workspace_id=workspace_id)
|
||||||
|
return [
|
||||||
|
AgentResponse(
|
||||||
|
agent_id=agent["agent_id"],
|
||||||
|
agent_type=agent["agent_type"],
|
||||||
|
workspace_id=workspace_id,
|
||||||
|
config_path=agent["config_path"],
|
||||||
|
agent_dir=str(Path(agent["config_path"]).parent),
|
||||||
|
status="inactive",
|
||||||
|
)
|
||||||
|
for agent in agents_data
|
||||||
|
]
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{agent_id}", response_model=AgentResponse)
|
||||||
|
async def get_agent(
|
||||||
|
workspace_id: str,
|
||||||
|
agent_id: str,
|
||||||
|
registry = Depends(get_registry),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Get agent details.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
workspace_id: Workspace identifier
|
||||||
|
agent_id: Agent identifier
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Agent information
|
||||||
|
"""
|
||||||
|
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")
|
||||||
|
|
||||||
|
return AgentResponse(
|
||||||
|
agent_id=agent_info.agent_id,
|
||||||
|
agent_type=agent_info.agent_type,
|
||||||
|
workspace_id=agent_info.workspace_id,
|
||||||
|
config_path=agent_info.config_path,
|
||||||
|
agent_dir=agent_info.agent_dir,
|
||||||
|
status=agent_info.status,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{agent_id}")
|
||||||
|
async def delete_agent(
|
||||||
|
workspace_id: str,
|
||||||
|
agent_id: str,
|
||||||
|
factory: AgentFactory = Depends(get_agent_factory),
|
||||||
|
registry = Depends(get_registry),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Delete an agent.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
workspace_id: Workspace identifier
|
||||||
|
agent_id: Agent identifier
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Success message
|
||||||
|
"""
|
||||||
|
# Check agent exists in 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")
|
||||||
|
|
||||||
|
# Delete from factory
|
||||||
|
success = factory.delete_agent(agent_id, workspace_id)
|
||||||
|
if not success:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Agent '{agent_id}' not found")
|
||||||
|
|
||||||
|
# Unregister
|
||||||
|
registry.unregister(agent_id)
|
||||||
|
|
||||||
|
return {"message": f"Agent '{agent_id}' deleted successfully"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{agent_id}", response_model=AgentResponse)
|
||||||
|
async def update_agent(
|
||||||
|
workspace_id: str,
|
||||||
|
agent_id: str,
|
||||||
|
request: UpdateAgentRequest,
|
||||||
|
registry = Depends(get_registry),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Update agent configuration.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
workspace_id: Workspace identifier
|
||||||
|
agent_id: Agent identifier
|
||||||
|
request: Update parameters
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Updated agent information
|
||||||
|
"""
|
||||||
|
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")
|
||||||
|
|
||||||
|
# Update metadata in registry
|
||||||
|
metadata_updates = {}
|
||||||
|
if request.name:
|
||||||
|
metadata_updates["name"] = request.name
|
||||||
|
if request.description:
|
||||||
|
metadata_updates["description"] = request.description
|
||||||
|
|
||||||
|
if 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
|
||||||
|
agent_info = registry.get(agent_id)
|
||||||
|
return AgentResponse(
|
||||||
|
agent_id=agent_info.agent_id,
|
||||||
|
agent_type=agent_info.agent_type,
|
||||||
|
workspace_id=agent_info.workspace_id,
|
||||||
|
config_path=agent_info.config_path,
|
||||||
|
agent_dir=agent_info.agent_dir,
|
||||||
|
status=agent_info.status,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@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.get("/{agent_id}/files/{filename}", response_model=AgentFileResponse)
|
||||||
|
async def get_agent_file(
|
||||||
|
workspace_id: str,
|
||||||
|
agent_id: str,
|
||||||
|
filename: str,
|
||||||
|
workspace_manager: WorkspaceManager = Depends(get_workspace_manager),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Read an agent's workspace file.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
workspace_id: Workspace identifier
|
||||||
|
agent_id: Agent identifier
|
||||||
|
filename: File to read (e.g., SOUL.md, ROLE.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)
|
||||||
|
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: WorkspaceManager = Depends(get_workspace_manager),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Update an agent's workspace file.
|
||||||
|
|
||||||
|
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)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
257
backend/api/guard.py
Normal file
257
backend/api/guard.py
Normal file
@@ -0,0 +1,257 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
Tool Guard API Routes
|
||||||
|
|
||||||
|
Provides REST API endpoints for tool guard operations.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from backend.agents.base.tool_guard import (
|
||||||
|
ApprovalRecord,
|
||||||
|
ApprovalStatus,
|
||||||
|
SeverityLevel,
|
||||||
|
TOOL_GUARD_STORE,
|
||||||
|
default_findings_for_tool,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/guard", tags=["guard"])
|
||||||
|
|
||||||
|
|
||||||
|
# Request/Response Models
|
||||||
|
class ToolCallRequest(BaseModel):
|
||||||
|
"""Tool call request."""
|
||||||
|
tool_name: str = Field(..., description="Name of the tool")
|
||||||
|
tool_input: Dict[str, Any] = Field(default_factory=dict, description="Tool parameters")
|
||||||
|
agent_id: str = Field(..., description="Agent making the request")
|
||||||
|
workspace_id: str = Field(..., description="Workspace context")
|
||||||
|
session_id: Optional[str] = Field(None, description="Session identifier")
|
||||||
|
|
||||||
|
|
||||||
|
class ApprovalRequest(BaseModel):
|
||||||
|
"""Request to approve a tool call."""
|
||||||
|
approval_id: str = Field(..., description="Approval request ID")
|
||||||
|
one_time: bool = Field(True, description="Whether this is a one-time approval")
|
||||||
|
expires_in_minutes: Optional[int] = Field(30, description="Approval expiration time")
|
||||||
|
|
||||||
|
|
||||||
|
class DenyRequest(BaseModel):
|
||||||
|
"""Request to deny a tool call."""
|
||||||
|
approval_id: str = Field(..., description="Approval request ID")
|
||||||
|
reason: Optional[str] = Field(None, description="Reason for denial")
|
||||||
|
|
||||||
|
|
||||||
|
class ToolFinding(BaseModel):
|
||||||
|
"""Tool guard finding."""
|
||||||
|
severity: SeverityLevel
|
||||||
|
message: str
|
||||||
|
field: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class ApprovalResponse(BaseModel):
|
||||||
|
"""Tool approval response."""
|
||||||
|
approval_id: str
|
||||||
|
status: ApprovalStatus
|
||||||
|
tool_name: str
|
||||||
|
tool_input: Dict[str, Any]
|
||||||
|
agent_id: str
|
||||||
|
workspace_id: str
|
||||||
|
session_id: Optional[str] = None
|
||||||
|
findings: List[ToolFinding] = Field(default_factory=list)
|
||||||
|
created_at: str
|
||||||
|
resolved_at: Optional[str] = None
|
||||||
|
resolved_by: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class PendingApprovalsResponse(BaseModel):
|
||||||
|
"""List of pending approvals."""
|
||||||
|
approvals: List[ApprovalResponse]
|
||||||
|
total: int
|
||||||
|
|
||||||
|
|
||||||
|
STORE = TOOL_GUARD_STORE
|
||||||
|
SAFE_TOOLS = {
|
||||||
|
"get_price",
|
||||||
|
"get_fundamentals",
|
||||||
|
"get_news",
|
||||||
|
"analyze_technical",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _to_response(record: ApprovalRecord) -> ApprovalResponse:
|
||||||
|
return ApprovalResponse(
|
||||||
|
approval_id=record.approval_id,
|
||||||
|
status=record.status,
|
||||||
|
tool_name=record.tool_name,
|
||||||
|
tool_input=record.tool_input,
|
||||||
|
agent_id=record.agent_id,
|
||||||
|
workspace_id=record.workspace_id,
|
||||||
|
session_id=record.session_id,
|
||||||
|
findings=[ToolFinding(**f.to_dict()) for f in record.findings],
|
||||||
|
created_at=record.created_at.isoformat(),
|
||||||
|
resolved_at=record.resolved_at.isoformat() if record.resolved_at else None,
|
||||||
|
resolved_by=record.resolved_by,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Routes
|
||||||
|
@router.post("/check", response_model=ApprovalResponse)
|
||||||
|
async def check_tool_call(
|
||||||
|
request: ToolCallRequest,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Check if a tool call requires approval.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: Tool call details
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Approval status - may be auto-approved, auto-denied, or pending
|
||||||
|
"""
|
||||||
|
record = STORE.create_pending(
|
||||||
|
tool_name=request.tool_name,
|
||||||
|
tool_input=request.tool_input,
|
||||||
|
agent_id=request.agent_id,
|
||||||
|
workspace_id=request.workspace_id,
|
||||||
|
session_id=request.session_id,
|
||||||
|
findings=default_findings_for_tool(request.tool_name),
|
||||||
|
)
|
||||||
|
|
||||||
|
if request.tool_name in SAFE_TOOLS:
|
||||||
|
record.status = ApprovalStatus.APPROVED
|
||||||
|
record.resolved_at = datetime.utcnow()
|
||||||
|
record.resolved_by = "system"
|
||||||
|
STORE.set_status(
|
||||||
|
record.approval_id,
|
||||||
|
ApprovalStatus.APPROVED,
|
||||||
|
resolved_by="system",
|
||||||
|
notify_request=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
return _to_response(record)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/approve", response_model=ApprovalResponse)
|
||||||
|
async def approve_tool_call(
|
||||||
|
request: ApprovalRequest,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Approve a pending tool call.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: Approval parameters
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Updated approval status
|
||||||
|
"""
|
||||||
|
record = STORE.get(request.approval_id)
|
||||||
|
if not record:
|
||||||
|
raise HTTPException(status_code=404, detail="Approval request not found")
|
||||||
|
|
||||||
|
if record.status != ApprovalStatus.PENDING:
|
||||||
|
raise HTTPException(status_code=400, detail=f"Approval already {record.status}")
|
||||||
|
|
||||||
|
record.status = ApprovalStatus.APPROVED
|
||||||
|
record.resolved_at = datetime.utcnow()
|
||||||
|
record.resolved_by = "user"
|
||||||
|
|
||||||
|
return _to_response(record)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/deny", response_model=ApprovalResponse)
|
||||||
|
async def deny_tool_call(
|
||||||
|
request: DenyRequest,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Deny a pending tool call.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: Denial parameters
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Updated approval status
|
||||||
|
"""
|
||||||
|
record = STORE.get(request.approval_id)
|
||||||
|
if not record:
|
||||||
|
raise HTTPException(status_code=404, detail="Approval request not found")
|
||||||
|
|
||||||
|
if record.status != ApprovalStatus.PENDING:
|
||||||
|
raise HTTPException(status_code=400, detail=f"Approval already {record.status}")
|
||||||
|
|
||||||
|
record.status = ApprovalStatus.DENIED
|
||||||
|
record.resolved_at = datetime.utcnow()
|
||||||
|
record.resolved_by = "user"
|
||||||
|
record.metadata["denial_reason"] = request.reason
|
||||||
|
|
||||||
|
return _to_response(record)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/pending", response_model=PendingApprovalsResponse)
|
||||||
|
async def list_pending_approvals(
|
||||||
|
workspace_id: Optional[str] = None,
|
||||||
|
agent_id: Optional[str] = None,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
List pending tool approval requests.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
workspace_id: Filter by workspace
|
||||||
|
agent_id: Filter by agent
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of pending approvals
|
||||||
|
"""
|
||||||
|
pending = [
|
||||||
|
_to_response(record)
|
||||||
|
for record in STORE.list(
|
||||||
|
status=ApprovalStatus.PENDING,
|
||||||
|
workspace_id=workspace_id,
|
||||||
|
agent_id=agent_id,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
return PendingApprovalsResponse(approvals=pending, total=len(pending))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/approvals/{approval_id}", response_model=ApprovalResponse)
|
||||||
|
async def get_approval_status(
|
||||||
|
approval_id: str,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Get the status of a specific approval request.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
approval_id: Approval request ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Approval status
|
||||||
|
"""
|
||||||
|
record = STORE.get(approval_id)
|
||||||
|
if not record:
|
||||||
|
raise HTTPException(status_code=404, detail="Approval request not found")
|
||||||
|
return _to_response(record)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/approvals/{approval_id}")
|
||||||
|
async def cancel_approval(
|
||||||
|
approval_id: str,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Cancel/delete a pending approval request.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
approval_id: Approval request ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Success message
|
||||||
|
"""
|
||||||
|
record = STORE.get(approval_id)
|
||||||
|
if not record:
|
||||||
|
raise HTTPException(status_code=404, detail="Approval request not found")
|
||||||
|
|
||||||
|
STORE.cancel(approval_id)
|
||||||
|
return _to_response(record)
|
||||||
135
backend/api/runtime.py
Normal file
135
backend/api/runtime.py
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Runtime API routes exposing the latest trading run state."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from backend.runtime.agent_runtime import AgentRuntimeState
|
||||||
|
from backend.runtime.context import TradingRunContext
|
||||||
|
from backend.runtime.manager import TradingRuntimeManager
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/runtime", tags=["runtime"])
|
||||||
|
|
||||||
|
runtime_manager: Optional[TradingRuntimeManager] = None
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
|
||||||
|
|
||||||
|
class RunContextResponse(BaseModel):
|
||||||
|
config_name: str
|
||||||
|
run_dir: str
|
||||||
|
bootstrap_values: Dict[str, Any]
|
||||||
|
|
||||||
|
|
||||||
|
class RuntimeAgentState(BaseModel):
|
||||||
|
agent_id: str
|
||||||
|
status: str
|
||||||
|
last_session: Optional[str] = None
|
||||||
|
last_updated: str
|
||||||
|
|
||||||
|
|
||||||
|
class RuntimeAgentsResponse(BaseModel):
|
||||||
|
agents: List[RuntimeAgentState]
|
||||||
|
|
||||||
|
|
||||||
|
class RuntimeEvent(BaseModel):
|
||||||
|
timestamp: str
|
||||||
|
event: str
|
||||||
|
details: Dict[str, Any]
|
||||||
|
session: Optional[str]
|
||||||
|
|
||||||
|
|
||||||
|
class RuntimeEventsResponse(BaseModel):
|
||||||
|
events: List[RuntimeEvent]
|
||||||
|
|
||||||
|
|
||||||
|
def _latest_snapshot_path() -> Optional[Path]:
|
||||||
|
candidates = sorted(
|
||||||
|
PROJECT_ROOT.glob("runs/*/state/runtime_state.json"),
|
||||||
|
key=lambda path: path.stat().st_mtime,
|
||||||
|
reverse=True,
|
||||||
|
)
|
||||||
|
return candidates[0] if candidates else None
|
||||||
|
|
||||||
|
|
||||||
|
def _load_snapshot() -> Dict[str, Any]:
|
||||||
|
snapshot_path = _latest_snapshot_path()
|
||||||
|
if snapshot_path is None or not snapshot_path.exists():
|
||||||
|
raise HTTPException(status_code=503, detail="runtime manager is not initialized")
|
||||||
|
return json.loads(snapshot_path.read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
|
||||||
|
def _get_runtime_payload() -> Dict[str, Any]:
|
||||||
|
if runtime_manager is not None:
|
||||||
|
return runtime_manager.build_snapshot()
|
||||||
|
return _load_snapshot()
|
||||||
|
|
||||||
|
|
||||||
|
def _to_state_response(state: AgentRuntimeState) -> RuntimeAgentState:
|
||||||
|
return RuntimeAgentState(
|
||||||
|
agent_id=state.agent_id,
|
||||||
|
status=state.status,
|
||||||
|
last_session=state.last_session,
|
||||||
|
last_updated=state.last_updated.isoformat(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/context", response_model=RunContextResponse)
|
||||||
|
async def get_run_context() -> RunContextResponse:
|
||||||
|
"""Return the most recent run context."""
|
||||||
|
payload = _get_runtime_payload()
|
||||||
|
context = payload.get("context")
|
||||||
|
if context is None:
|
||||||
|
raise HTTPException(status_code=404, detail="run context is not ready")
|
||||||
|
|
||||||
|
return RunContextResponse(
|
||||||
|
config_name=context["config_name"],
|
||||||
|
run_dir=context["run_dir"],
|
||||||
|
bootstrap_values=context["bootstrap_values"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/agents", response_model=RuntimeAgentsResponse)
|
||||||
|
async def list_agent_states() -> RuntimeAgentsResponse:
|
||||||
|
"""List the current runtime state of every registered agent."""
|
||||||
|
payload = _get_runtime_payload()
|
||||||
|
agents = [RuntimeAgentState(**agent) for agent in payload.get("agents", [])]
|
||||||
|
return RuntimeAgentsResponse(agents=agents)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/events", response_model=RuntimeEventsResponse)
|
||||||
|
async def list_runtime_events() -> RuntimeEventsResponse:
|
||||||
|
"""Return the recent runtime events that TradingRuntimeManager emitted."""
|
||||||
|
payload = _get_runtime_payload()
|
||||||
|
events = [RuntimeEvent(**event) for event in payload.get("events", [])]
|
||||||
|
return RuntimeEventsResponse(events=events)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/agents/{agent_id}", response_model=RuntimeAgentState)
|
||||||
|
async def get_agent_state(agent_id: str) -> RuntimeAgentState:
|
||||||
|
"""Return the current runtime state for a single agent."""
|
||||||
|
payload = _get_runtime_payload()
|
||||||
|
state = next(
|
||||||
|
(agent for agent in payload.get("agents", []) if agent["agent_id"] == agent_id),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if state is None:
|
||||||
|
raise HTTPException(status_code=404, detail=f"agent '{agent_id}' not registered")
|
||||||
|
return RuntimeAgentState(**state)
|
||||||
|
|
||||||
|
|
||||||
|
def register_runtime_manager(manager: TradingRuntimeManager) -> None:
|
||||||
|
"""Allow other modules to expose the runtime manager to the API."""
|
||||||
|
global runtime_manager
|
||||||
|
runtime_manager = manager
|
||||||
|
|
||||||
|
|
||||||
|
def unregister_runtime_manager() -> None:
|
||||||
|
"""Drop the runtime manager reference (used for shutdown/testing)."""
|
||||||
|
global runtime_manager
|
||||||
|
runtime_manager = None
|
||||||
196
backend/api/workspaces.py
Normal file
196
backend/api/workspaces.py
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
Workspace API Routes
|
||||||
|
|
||||||
|
Provides REST API endpoints for workspace management.
|
||||||
|
"""
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException, Depends
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from backend.agents import WorkspaceManager
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/workspaces", tags=["workspaces"])
|
||||||
|
|
||||||
|
|
||||||
|
# Request/Response Models
|
||||||
|
class CreateWorkspaceRequest(BaseModel):
|
||||||
|
"""Request to create a new workspace."""
|
||||||
|
workspace_id: str = Field(..., description="Unique workspace identifier")
|
||||||
|
name: Optional[str] = Field(None, description="Display name")
|
||||||
|
description: Optional[str] = Field(None, description="Workspace description")
|
||||||
|
metadata: Optional[Dict[str, Any]] = Field(None, description="Additional metadata")
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateWorkspaceRequest(BaseModel):
|
||||||
|
"""Request to update a workspace."""
|
||||||
|
name: Optional[str] = None
|
||||||
|
description: Optional[str] = None
|
||||||
|
metadata: Optional[Dict[str, Any]] = None
|
||||||
|
|
||||||
|
|
||||||
|
class WorkspaceResponse(BaseModel):
|
||||||
|
"""Workspace information response."""
|
||||||
|
workspace_id: str
|
||||||
|
name: str
|
||||||
|
description: str
|
||||||
|
created_at: Optional[str] = None
|
||||||
|
metadata: Dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class WorkspaceListResponse(BaseModel):
|
||||||
|
"""List of workspaces response."""
|
||||||
|
workspaces: List[WorkspaceResponse]
|
||||||
|
total: int
|
||||||
|
|
||||||
|
|
||||||
|
# Dependencies
|
||||||
|
def get_workspace_manager():
|
||||||
|
"""Get WorkspaceManager instance."""
|
||||||
|
return WorkspaceManager()
|
||||||
|
|
||||||
|
|
||||||
|
# Routes
|
||||||
|
@router.post("", response_model=WorkspaceResponse)
|
||||||
|
async def create_workspace(
|
||||||
|
request: CreateWorkspaceRequest,
|
||||||
|
manager: WorkspaceManager = Depends(get_workspace_manager),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Create a new workspace.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: Workspace creation parameters
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Created workspace information
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
config = manager.create_workspace(
|
||||||
|
workspace_id=request.workspace_id,
|
||||||
|
name=request.name,
|
||||||
|
description=request.description,
|
||||||
|
metadata=request.metadata or {},
|
||||||
|
)
|
||||||
|
return WorkspaceResponse(
|
||||||
|
workspace_id=config.workspace_id,
|
||||||
|
name=config.name,
|
||||||
|
description=config.description,
|
||||||
|
created_at=config.created_at,
|
||||||
|
metadata=config.metadata,
|
||||||
|
)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=WorkspaceListResponse)
|
||||||
|
async def list_workspaces(
|
||||||
|
manager: WorkspaceManager = Depends(get_workspace_manager),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
List all workspaces.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of workspaces
|
||||||
|
"""
|
||||||
|
workspaces = manager.list_workspaces()
|
||||||
|
return WorkspaceListResponse(
|
||||||
|
workspaces=[
|
||||||
|
WorkspaceResponse(
|
||||||
|
workspace_id=ws.workspace_id,
|
||||||
|
name=ws.name,
|
||||||
|
description=ws.description,
|
||||||
|
created_at=ws.created_at,
|
||||||
|
metadata=ws.metadata,
|
||||||
|
)
|
||||||
|
for ws in workspaces
|
||||||
|
],
|
||||||
|
total=len(workspaces),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{workspace_id}", response_model=WorkspaceResponse)
|
||||||
|
async def get_workspace(
|
||||||
|
workspace_id: str,
|
||||||
|
manager: WorkspaceManager = Depends(get_workspace_manager),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Get workspace details.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
workspace_id: Workspace identifier
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Workspace information
|
||||||
|
"""
|
||||||
|
workspace = manager.get_workspace(workspace_id)
|
||||||
|
if not workspace:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Workspace '{workspace_id}' not found")
|
||||||
|
|
||||||
|
return WorkspaceResponse(
|
||||||
|
workspace_id=workspace["workspace_id"],
|
||||||
|
name=workspace.get("name", workspace_id),
|
||||||
|
description=workspace.get("description", ""),
|
||||||
|
created_at=workspace.get("created_at"),
|
||||||
|
metadata=workspace.get("metadata", {}),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{workspace_id}", response_model=WorkspaceResponse)
|
||||||
|
async def update_workspace(
|
||||||
|
workspace_id: str,
|
||||||
|
request: UpdateWorkspaceRequest,
|
||||||
|
manager: WorkspaceManager = Depends(get_workspace_manager),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Update workspace configuration.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
workspace_id: Workspace identifier
|
||||||
|
request: Update parameters
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Updated workspace information
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
config = manager.update_workspace_config(
|
||||||
|
workspace_id=workspace_id,
|
||||||
|
name=request.name,
|
||||||
|
description=request.description,
|
||||||
|
metadata=request.metadata,
|
||||||
|
)
|
||||||
|
return WorkspaceResponse(
|
||||||
|
workspace_id=config.workspace_id,
|
||||||
|
name=config.name,
|
||||||
|
description=config.description,
|
||||||
|
created_at=config.created_at,
|
||||||
|
metadata=config.metadata,
|
||||||
|
)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{workspace_id}")
|
||||||
|
async def delete_workspace(
|
||||||
|
workspace_id: str,
|
||||||
|
force: bool = False,
|
||||||
|
manager: WorkspaceManager = Depends(get_workspace_manager),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Delete a workspace.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
workspace_id: Workspace identifier
|
||||||
|
force: If True, delete even if workspace has agents
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Success message
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
success = manager.delete_workspace(workspace_id, force=force)
|
||||||
|
if not success:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Workspace '{workspace_id}' not found")
|
||||||
|
return {"message": f"Workspace '{workspace_id}' deleted successfully"}
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
115
backend/app.py
Normal file
115
backend/app.py
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
FastAPI Application - REST API for EvoTraders
|
||||||
|
|
||||||
|
Provides HTTP endpoints for:
|
||||||
|
- Agent management
|
||||||
|
- Workspace management
|
||||||
|
- Tool guard operations
|
||||||
|
- Health checks
|
||||||
|
"""
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import AsyncGenerator
|
||||||
|
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
|
from backend.api import agents_router, workspaces_router, guard_router, runtime_router
|
||||||
|
from backend.agents import AgentFactory, WorkspaceManager, get_registry
|
||||||
|
|
||||||
|
|
||||||
|
# Global instances (initialized on startup)
|
||||||
|
agent_factory: AgentFactory | None = None
|
||||||
|
workspace_manager: WorkspaceManager | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI) -> AsyncGenerator:
|
||||||
|
"""
|
||||||
|
Application lifespan manager.
|
||||||
|
|
||||||
|
Initializes global services on startup and cleans up on shutdown.
|
||||||
|
"""
|
||||||
|
global agent_factory, workspace_manager
|
||||||
|
|
||||||
|
# Startup: Initialize services
|
||||||
|
project_root = Path(__file__).parent.parent
|
||||||
|
|
||||||
|
# Initialize workspace manager
|
||||||
|
workspace_manager = WorkspaceManager(project_root=project_root)
|
||||||
|
|
||||||
|
# Initialize agent factory
|
||||||
|
agent_factory = AgentFactory(project_root=project_root)
|
||||||
|
|
||||||
|
# Ensure workspaces root exists
|
||||||
|
agent_factory.workspaces_root.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Get or create global registry
|
||||||
|
registry = get_registry()
|
||||||
|
|
||||||
|
print(f"✓ EvoTraders API started")
|
||||||
|
print(f" - Workspaces root: {agent_factory.workspaces_root}")
|
||||||
|
print(f" - Registered agents: {registry.get_agent_count()}")
|
||||||
|
|
||||||
|
yield
|
||||||
|
|
||||||
|
# Shutdown: Cleanup
|
||||||
|
print("✓ EvoTraders API shutting down")
|
||||||
|
|
||||||
|
|
||||||
|
# Create FastAPI application
|
||||||
|
app = FastAPI(
|
||||||
|
title="EvoTraders API",
|
||||||
|
description="REST API for the EvoTraders multi-agent trading system",
|
||||||
|
version="0.1.0",
|
||||||
|
lifespan=lifespan,
|
||||||
|
)
|
||||||
|
|
||||||
|
# CORS middleware
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=["*"], # Configure appropriately for production
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Health check endpoint
|
||||||
|
@app.get("/health")
|
||||||
|
async def health_check():
|
||||||
|
"""Health check endpoint."""
|
||||||
|
registry = get_registry()
|
||||||
|
return {
|
||||||
|
"status": "healthy",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"agents_registered": registry.get_agent_count(),
|
||||||
|
"workspaces_available": len(workspace_manager.list_workspaces()) if workspace_manager else 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# API status endpoint
|
||||||
|
@app.get("/api/status")
|
||||||
|
async def api_status():
|
||||||
|
"""Get API status and system information."""
|
||||||
|
registry = get_registry()
|
||||||
|
stats = registry.get_stats()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "operational",
|
||||||
|
"registry": stats,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Include routers
|
||||||
|
app.include_router(workspaces_router)
|
||||||
|
app.include_router(agents_router)
|
||||||
|
app.include_router(guard_router)
|
||||||
|
app.include_router(runtime_router)
|
||||||
|
|
||||||
|
|
||||||
|
# Main entry point for running with uvicorn
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import uvicorn
|
||||||
|
uvicorn.run(app, host="0.0.0.0", port=8000)
|
||||||
@@ -119,6 +119,9 @@ def resolve_runtime_config(
|
|||||||
project_root: Path,
|
project_root: Path,
|
||||||
config_name: str,
|
config_name: str,
|
||||||
enable_memory: bool = False,
|
enable_memory: bool = False,
|
||||||
|
schedule_mode: str = "daily",
|
||||||
|
interval_minutes: int = 60,
|
||||||
|
trigger_time: str = "09:30",
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""Merge env defaults with run-scoped bootstrap front matter."""
|
"""Merge env defaults with run-scoped bootstrap front matter."""
|
||||||
bootstrap = get_bootstrap_config_for_run(project_root, config_name)
|
bootstrap = get_bootstrap_config_for_run(project_root, config_name)
|
||||||
@@ -143,6 +146,18 @@ def resolve_runtime_config(
|
|||||||
get_env_int("MAX_COMM_CYCLES", 2),
|
get_env_int("MAX_COMM_CYCLES", 2),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
"schedule_mode": str(
|
||||||
|
bootstrap.get("schedule_mode", schedule_mode),
|
||||||
|
).strip().lower() or schedule_mode,
|
||||||
|
"interval_minutes": int(
|
||||||
|
bootstrap.get(
|
||||||
|
"interval_minutes",
|
||||||
|
interval_minutes or get_env_int("INTERVAL_MINUTES", 60),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
"trigger_time": str(
|
||||||
|
bootstrap.get("trigger_time", trigger_time),
|
||||||
|
).strip() or trigger_time,
|
||||||
"enable_memory": bool(enable_memory)
|
"enable_memory": bool(enable_memory)
|
||||||
or _coerce_bool(bootstrap.get("enable_memory", False)),
|
or _coerce_bool(bootstrap.get("enable_memory", False)),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ from backend.utils.settlement import SettlementCoordinator
|
|||||||
from backend.utils.terminal_dashboard import get_dashboard
|
from backend.utils.terminal_dashboard import get_dashboard
|
||||||
from backend.core.state_sync import StateSync
|
from backend.core.state_sync import StateSync
|
||||||
from backend.utils.trade_executor import PortfolioTradeExecutor
|
from backend.utils.trade_executor import PortfolioTradeExecutor
|
||||||
|
from backend.runtime.manager import TradingRuntimeManager
|
||||||
|
from backend.runtime.session import TradingSessionKey
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -46,6 +48,8 @@ class TradingPipeline:
|
|||||||
6. Reflection phase: broadcast closing P&L, agents record to long-term memory
|
6. Reflection phase: broadcast closing P&L, agents record to long-term memory
|
||||||
|
|
||||||
Real-time updates via StateSync after each agent completes.
|
Real-time updates via StateSync after each agent completes.
|
||||||
|
|
||||||
|
Supports both legacy agent lists and new workspace-based agent loading.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
@@ -56,6 +60,9 @@ class TradingPipeline:
|
|||||||
state_sync: Optional["StateSync"] = None,
|
state_sync: Optional["StateSync"] = None,
|
||||||
settlement_coordinator: Optional[SettlementCoordinator] = None,
|
settlement_coordinator: Optional[SettlementCoordinator] = None,
|
||||||
max_comm_cycles: Optional[int] = None,
|
max_comm_cycles: Optional[int] = None,
|
||||||
|
workspace_id: Optional[str] = None,
|
||||||
|
agent_factory: Optional[Any] = None,
|
||||||
|
runtime_manager: Optional[TradingRuntimeManager] = None,
|
||||||
):
|
):
|
||||||
self.analysts = analysts
|
self.analysts = analysts
|
||||||
self.risk_manager = risk_manager
|
self.risk_manager = risk_manager
|
||||||
@@ -66,6 +73,10 @@ class TradingPipeline:
|
|||||||
os.getenv("MAX_COMM_CYCLES", "2"),
|
os.getenv("MAX_COMM_CYCLES", "2"),
|
||||||
)
|
)
|
||||||
self.conference_summary = None # Store latest conference summary
|
self.conference_summary = None # Store latest conference summary
|
||||||
|
self.workspace_id = workspace_id
|
||||||
|
self.agent_factory = agent_factory
|
||||||
|
self.runtime_manager = runtime_manager
|
||||||
|
self._session_key: Optional[str] = None
|
||||||
|
|
||||||
async def run_cycle(
|
async def run_cycle(
|
||||||
self,
|
self,
|
||||||
@@ -80,6 +91,7 @@ class TradingPipeline:
|
|||||||
get_close_prices_fn: Optional[
|
get_close_prices_fn: Optional[
|
||||||
Callable[[], Awaitable[Dict[str, float]]]
|
Callable[[], Awaitable[Dict[str, float]]]
|
||||||
] = None,
|
] = None,
|
||||||
|
execute_decisions: bool = True,
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Run one complete trading cycle
|
Run one complete trading cycle
|
||||||
@@ -101,6 +113,12 @@ class TradingPipeline:
|
|||||||
Each agent's result is broadcast immediately via StateSync.
|
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()
|
||||||
|
self._session_key = session_key
|
||||||
|
if self.runtime_manager:
|
||||||
|
self.runtime_manager.set_session_key(session_key)
|
||||||
|
self._runtime_log_event("cycle:start", {"tickers": tickers, "date": date})
|
||||||
|
self._runtime_batch_status(self.analysts, "analysis_in_progress")
|
||||||
|
|
||||||
# Phase 0: Clear short-term memory to avoid cross-day context pollution
|
# Phase 0: Clear short-term memory to avoid cross-day context pollution
|
||||||
_log("Phase 0: Clearing memory")
|
_log("Phase 0: Clearing memory")
|
||||||
@@ -123,6 +141,7 @@ class TradingPipeline:
|
|||||||
|
|
||||||
# Phase 1.2: Risk Manager
|
# Phase 1.2: Risk Manager
|
||||||
_log("Phase 1.2: Risk assessment")
|
_log("Phase 1.2: Risk assessment")
|
||||||
|
self._runtime_update_status(self.risk_manager, "risk_assessment")
|
||||||
risk_assessment = await self._run_risk_manager_with_sync(
|
risk_assessment = await self._run_risk_manager_with_sync(
|
||||||
tickers,
|
tickers,
|
||||||
date,
|
date,
|
||||||
@@ -161,6 +180,7 @@ class TradingPipeline:
|
|||||||
|
|
||||||
# Phase 3: PM makes decisions
|
# Phase 3: PM makes decisions
|
||||||
_log("Phase 3.1: PM makes decisions")
|
_log("Phase 3.1: PM makes decisions")
|
||||||
|
self._runtime_update_status(self.pm, "decision_phase")
|
||||||
pm_result = await self._run_pm_with_sync(
|
pm_result = await self._run_pm_with_sync(
|
||||||
tickers,
|
tickers,
|
||||||
date,
|
date,
|
||||||
@@ -169,10 +189,17 @@ class TradingPipeline:
|
|||||||
risk_assessment,
|
risk_assessment,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Phase 4: Execute decisions
|
|
||||||
_log("Phase 4: Executing trades")
|
|
||||||
decisions = pm_result.get("decisions", {})
|
decisions = pm_result.get("decisions", {})
|
||||||
|
execution_result = {
|
||||||
|
"executed_trades": [],
|
||||||
|
"portfolio": self.pm.get_portfolio_state(),
|
||||||
|
}
|
||||||
|
if execute_decisions:
|
||||||
|
_log("Phase 4: Executing trades")
|
||||||
|
self._runtime_update_status(self.pm, "executing")
|
||||||
execution_result = self._execute_decisions(decisions, prices, date)
|
execution_result = self._execute_decisions(decisions, prices, date)
|
||||||
|
else:
|
||||||
|
_log("Phase 4: Skipping trade execution")
|
||||||
|
|
||||||
# Live mode: wait for market close before settlement
|
# Live mode: wait for market close before settlement
|
||||||
if get_close_prices_fn:
|
if get_close_prices_fn:
|
||||||
@@ -184,6 +211,10 @@ class TradingPipeline:
|
|||||||
settlement_result = None
|
settlement_result = None
|
||||||
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.risk_manager] + self.analysts + [self.pm],
|
||||||
|
"settlement",
|
||||||
|
)
|
||||||
|
|
||||||
agent_trajectories = await self._capture_agent_trajectories()
|
agent_trajectories = await self._capture_agent_trajectories()
|
||||||
|
|
||||||
@@ -214,8 +245,17 @@ class TradingPipeline:
|
|||||||
settlement_result=settlement_result,
|
settlement_result=settlement_result,
|
||||||
conference_summary=self.conference_summary,
|
conference_summary=self.conference_summary,
|
||||||
)
|
)
|
||||||
|
self._runtime_batch_status(
|
||||||
|
[self.risk_manager] + self.analysts + [self.pm],
|
||||||
|
"reflection",
|
||||||
|
)
|
||||||
|
|
||||||
_log(f"Cycle complete: {date}")
|
_log(f"Cycle complete: {date}")
|
||||||
|
self._runtime_batch_status(
|
||||||
|
self.analysts + [self.risk_manager, self.pm],
|
||||||
|
"idle",
|
||||||
|
)
|
||||||
|
self._runtime_log_event("cycle:end", {"tickers": tickers, "date": date})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"analyst_results": analyst_results,
|
"analyst_results": analyst_results,
|
||||||
@@ -1306,3 +1346,122 @@ class TradingPipeline:
|
|||||||
if decision_texts:
|
if decision_texts:
|
||||||
return "Decisions: " + "; ".join(decision_texts)
|
return "Decisions: " + "; ".join(decision_texts)
|
||||||
return "Portfolio analysis completed. No trades recommended."
|
return "Portfolio analysis completed. No trades recommended."
|
||||||
|
|
||||||
|
def load_agents_from_workspace(
|
||||||
|
self,
|
||||||
|
workspace_id: str,
|
||||||
|
agent_factory: Optional[Any] = None,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Load agents from workspace using AgentFactory.
|
||||||
|
|
||||||
|
This method supports the new EvoAgent architecture by loading
|
||||||
|
agents from a workspace instead of using hardcoded agents.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
workspace_id: Workspace identifier
|
||||||
|
agent_factory: Optional AgentFactory instance (uses self.agent_factory if None)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with loaded agents:
|
||||||
|
{
|
||||||
|
"analysts": List[EvoAgent],
|
||||||
|
"risk_manager": EvoAgent,
|
||||||
|
"portfolio_manager": EvoAgent,
|
||||||
|
}
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If workspace doesn't exist or no agents found
|
||||||
|
"""
|
||||||
|
factory = agent_factory or self.agent_factory
|
||||||
|
if factory is None:
|
||||||
|
from backend.agents import AgentFactory
|
||||||
|
factory = AgentFactory()
|
||||||
|
|
||||||
|
# Check workspace exists
|
||||||
|
if not factory.workspaces_root.exists():
|
||||||
|
raise ValueError(f"Workspaces root does not exist: {factory.workspaces_root}")
|
||||||
|
|
||||||
|
workspace_dir = factory.workspaces_root / workspace_id
|
||||||
|
if not workspace_dir.exists():
|
||||||
|
raise ValueError(f"Workspace '{workspace_id}' does not exist")
|
||||||
|
|
||||||
|
# Load agents from workspace
|
||||||
|
agents_data = factory.list_agents(workspace_id=workspace_id)
|
||||||
|
|
||||||
|
if not agents_data:
|
||||||
|
raise ValueError(f"No agents found in workspace '{workspace_id}'")
|
||||||
|
|
||||||
|
# Categorize agents by type
|
||||||
|
analysts = []
|
||||||
|
risk_manager = None
|
||||||
|
portfolio_manager = None
|
||||||
|
|
||||||
|
for agent_data in agents_data:
|
||||||
|
agent_type = agent_data.get("agent_type", "unknown")
|
||||||
|
agent_id = agent_data.get("agent_id")
|
||||||
|
|
||||||
|
# Load full agent configuration
|
||||||
|
config_path = Path(agent_data.get("config_path", ""))
|
||||||
|
if config_path.exists():
|
||||||
|
agent = factory.load_agent(agent_id, workspace_id)
|
||||||
|
|
||||||
|
if agent_type.endswith("_analyst"):
|
||||||
|
analysts.append(agent)
|
||||||
|
elif agent_type == "risk_manager":
|
||||||
|
risk_manager = agent
|
||||||
|
elif agent_type == "portfolio_manager":
|
||||||
|
portfolio_manager = agent
|
||||||
|
|
||||||
|
if not analysts:
|
||||||
|
raise ValueError(f"No analysts found in workspace '{workspace_id}'")
|
||||||
|
if risk_manager is None:
|
||||||
|
raise ValueError(f"No risk_manager found in workspace '{workspace_id}'")
|
||||||
|
if portfolio_manager is None:
|
||||||
|
raise ValueError(f"No portfolio_manager found in workspace '{workspace_id}'")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"analysts": analysts,
|
||||||
|
"risk_manager": risk_manager,
|
||||||
|
"portfolio_manager": portfolio_manager,
|
||||||
|
}
|
||||||
|
|
||||||
|
def reload_agents_from_workspace(self, workspace_id: Optional[str] = None) -> None:
|
||||||
|
"""
|
||||||
|
Reload all agents from workspace.
|
||||||
|
|
||||||
|
This updates self.analysts, self.risk_manager, and self.pm
|
||||||
|
with agents loaded from the specified workspace.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
workspace_id: Workspace ID (uses self.workspace_id if None)
|
||||||
|
"""
|
||||||
|
ws_id = workspace_id or self.workspace_id
|
||||||
|
if not ws_id:
|
||||||
|
raise ValueError("No workspace_id specified")
|
||||||
|
|
||||||
|
loaded = self.load_agents_from_workspace(ws_id)
|
||||||
|
|
||||||
|
self.analysts = loaded["analysts"]
|
||||||
|
self.risk_manager = loaded["risk_manager"]
|
||||||
|
self.pm = loaded["portfolio_manager"]
|
||||||
|
self.workspace_id = ws_id
|
||||||
|
|
||||||
|
logger.info(f"Reloaded {len(self.analysts)} analysts from workspace '{ws_id}'")
|
||||||
|
|
||||||
|
def _runtime_update_status(self, agent: Any, status: str) -> None:
|
||||||
|
if not self.runtime_manager:
|
||||||
|
return
|
||||||
|
agent_id = getattr(agent, "agent_id", None) or getattr(agent, "name", None)
|
||||||
|
if not agent_id:
|
||||||
|
return
|
||||||
|
self.runtime_manager.update_agent_status(agent_id, status, self._session_key)
|
||||||
|
|
||||||
|
def _runtime_batch_status(self, agents: List[Any], status: str) -> None:
|
||||||
|
for agent in agents:
|
||||||
|
self._runtime_update_status(agent, status)
|
||||||
|
|
||||||
|
def _runtime_log_event(self, event: str, details: Optional[Dict[str, Any]] = None) -> None:
|
||||||
|
if not self.runtime_manager:
|
||||||
|
return
|
||||||
|
self.runtime_manager.log_event(event, details)
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ class Scheduler:
|
|||||||
|
|
||||||
self.running = False
|
self.running = False
|
||||||
self._task: Optional[asyncio.Task] = None
|
self._task: Optional[asyncio.Task] = None
|
||||||
|
self._callback: Optional[Callable] = None
|
||||||
|
|
||||||
def _now_nyse(self) -> datetime:
|
def _now_nyse(self) -> datetime:
|
||||||
"""Get current time in NYSE timezone"""
|
"""Get current time in NYSE timezone"""
|
||||||
@@ -68,18 +69,69 @@ class Scheduler:
|
|||||||
return
|
return
|
||||||
|
|
||||||
self.running = True
|
self.running = True
|
||||||
|
self._callback = callback
|
||||||
if self.mode == "daily":
|
self._schedule_task()
|
||||||
self._task = asyncio.create_task(self._run_daily(callback))
|
|
||||||
elif self.mode == "intraday":
|
|
||||||
self._task = asyncio.create_task(self._run_intraday(callback))
|
|
||||||
else:
|
|
||||||
raise ValueError(f"Unknown scheduler mode: {self.mode}")
|
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Scheduler started: mode={self.mode}, timezone=America/New_York",
|
f"Scheduler started: mode={self.mode}, timezone=America/New_York",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _schedule_task(self):
|
||||||
|
"""Create the active scheduler task for the current mode."""
|
||||||
|
if not self._callback:
|
||||||
|
raise ValueError("Scheduler callback is not set")
|
||||||
|
|
||||||
|
if self._task:
|
||||||
|
self._task.cancel()
|
||||||
|
self._task = None
|
||||||
|
|
||||||
|
if self.mode == "daily":
|
||||||
|
self._task = asyncio.create_task(self._run_daily(self._callback))
|
||||||
|
elif self.mode == "intraday":
|
||||||
|
self._task = asyncio.create_task(
|
||||||
|
self._run_intraday(self._callback),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
raise ValueError(f"Unknown scheduler mode: {self.mode}")
|
||||||
|
|
||||||
|
def reconfigure(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
mode: Optional[str] = None,
|
||||||
|
trigger_time: Optional[str] = None,
|
||||||
|
interval_minutes: Optional[int] = None,
|
||||||
|
) -> bool:
|
||||||
|
"""Update scheduler parameters in-place and restart its timing loop."""
|
||||||
|
changed = False
|
||||||
|
|
||||||
|
if mode and mode != self.mode:
|
||||||
|
self.mode = mode
|
||||||
|
changed = True
|
||||||
|
|
||||||
|
if trigger_time and trigger_time != self.trigger_time:
|
||||||
|
self.trigger_time = trigger_time
|
||||||
|
self.trigger_now = self.trigger_time == "now"
|
||||||
|
changed = True
|
||||||
|
|
||||||
|
if (
|
||||||
|
interval_minutes is not None
|
||||||
|
and interval_minutes > 0
|
||||||
|
and interval_minutes != self.interval_minutes
|
||||||
|
):
|
||||||
|
self.interval_minutes = interval_minutes
|
||||||
|
changed = True
|
||||||
|
|
||||||
|
if changed and self.running and self._callback:
|
||||||
|
self._schedule_task()
|
||||||
|
logger.info(
|
||||||
|
"Scheduler reconfigured: mode=%s, trigger_time=%s, interval_minutes=%s",
|
||||||
|
self.mode,
|
||||||
|
self.trigger_time,
|
||||||
|
self.interval_minutes,
|
||||||
|
)
|
||||||
|
|
||||||
|
return changed
|
||||||
|
|
||||||
async def _run_daily(self, callback: Callable):
|
async def _run_daily(self, callback: Callable):
|
||||||
"""Run once per trading day at specified time (NYSE timezone)"""
|
"""Run once per trading day at specified time (NYSE timezone)"""
|
||||||
first_run = True
|
first_run = True
|
||||||
|
|||||||
@@ -22,11 +22,17 @@ from backend.config.bootstrap_config import resolve_runtime_config
|
|||||||
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
|
||||||
from backend.utils.settlement import SettlementCoordinator
|
|
||||||
from backend.llm.models import get_agent_formatter, get_agent_model
|
from backend.llm.models import get_agent_formatter, get_agent_model
|
||||||
|
from backend.api.runtime import register_runtime_manager, unregister_runtime_manager
|
||||||
|
from backend.runtime.manager import (
|
||||||
|
TradingRuntimeManager,
|
||||||
|
set_global_runtime_manager,
|
||||||
|
clear_global_runtime_manager,
|
||||||
|
)
|
||||||
from backend.services.gateway import Gateway
|
from backend.services.gateway import Gateway
|
||||||
from backend.services.market import MarketService
|
from backend.services.market import MarketService
|
||||||
from backend.services.storage import StorageService
|
from backend.services.storage import StorageService
|
||||||
|
from backend.utils.settlement import SettlementCoordinator
|
||||||
|
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -213,6 +219,15 @@ async def run_with_gateway(args):
|
|||||||
initial_cash = runtime_config["initial_cash"]
|
initial_cash = runtime_config["initial_cash"]
|
||||||
margin_requirement = runtime_config["margin_requirement"]
|
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)
|
||||||
|
register_runtime_manager(runtime_manager)
|
||||||
|
|
||||||
# Create market service
|
# Create market service
|
||||||
market_service = MarketService(
|
market_service = MarketService(
|
||||||
tickers=tickers,
|
tickers=tickers,
|
||||||
@@ -245,6 +260,10 @@ async def run_with_gateway(args):
|
|||||||
margin_requirement=margin_requirement,
|
margin_requirement=margin_requirement,
|
||||||
enable_long_term_memory=runtime_config["enable_memory"],
|
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()
|
portfolio_state = storage_service.load_portfolio_state()
|
||||||
pm.load_portfolio_state(portfolio_state)
|
pm.load_portfolio_state(portfolio_state)
|
||||||
|
|
||||||
@@ -259,6 +278,7 @@ async def run_with_gateway(args):
|
|||||||
portfolio_manager=pm,
|
portfolio_manager=pm,
|
||||||
settlement_coordinator=settlement_coordinator,
|
settlement_coordinator=settlement_coordinator,
|
||||||
max_comm_cycles=runtime_config["max_comm_cycles"],
|
max_comm_cycles=runtime_config["max_comm_cycles"],
|
||||||
|
runtime_manager=runtime_manager,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create scheduler callback
|
# Create scheduler callback
|
||||||
@@ -321,9 +341,13 @@ async def run_with_gateway(args):
|
|||||||
|
|
||||||
# Start long-term memory contexts and run gateway
|
# Start long-term memory contexts and run gateway
|
||||||
async with AsyncExitStack() as stack:
|
async with AsyncExitStack() as stack:
|
||||||
|
try:
|
||||||
for memory in long_term_memories:
|
for memory in long_term_memories:
|
||||||
await stack.enter_async_context(memory)
|
await stack.enter_async_context(memory)
|
||||||
await gateway.start(host=args.host, port=args.port)
|
await gateway.start(host=args.host, port=args.port)
|
||||||
|
finally:
|
||||||
|
unregister_runtime_manager()
|
||||||
|
clear_global_runtime_manager()
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
|
|||||||
41
backend/process/models.py
Normal file
41
backend/process/models.py
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Data models for lightweight process supervision."""
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime
|
||||||
|
from enum import Enum
|
||||||
|
from typing import Any, Dict
|
||||||
|
|
||||||
|
|
||||||
|
class ProcessRunState(str, Enum):
|
||||||
|
"""Execution state for supervised runs."""
|
||||||
|
|
||||||
|
PENDING = "pending"
|
||||||
|
RUNNING = "running"
|
||||||
|
COMPLETED = "completed"
|
||||||
|
FAILED = "failed"
|
||||||
|
CANCELLED = "cancelled"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ProcessRun:
|
||||||
|
"""Represents a supervised process run."""
|
||||||
|
|
||||||
|
run_id: str
|
||||||
|
command: str
|
||||||
|
scope_key: str
|
||||||
|
state: ProcessRunState = ProcessRunState.PENDING
|
||||||
|
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||||
|
created_at: datetime = field(default_factory=datetime.utcnow)
|
||||||
|
updated_at: datetime = field(default_factory=datetime.utcnow)
|
||||||
|
|
||||||
|
def to_dict(self) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"run_id": self.run_id,
|
||||||
|
"command": self.command,
|
||||||
|
"scope_key": self.scope_key,
|
||||||
|
"state": self.state.value,
|
||||||
|
"metadata": self.metadata,
|
||||||
|
"created_at": self.created_at.isoformat(),
|
||||||
|
"updated_at": self.updated_at.isoformat(),
|
||||||
|
}
|
||||||
35
backend/process/registry.py
Normal file
35
backend/process/registry.py
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Registry for managing supervised process metadata."""
|
||||||
|
|
||||||
|
from threading import Lock
|
||||||
|
from typing import Dict, Iterable, Optional
|
||||||
|
|
||||||
|
from .models import ProcessRun
|
||||||
|
|
||||||
|
|
||||||
|
class RunRegistry:
|
||||||
|
"""In-memory registry for tracked process runs."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._runs: Dict[str, ProcessRun] = {}
|
||||||
|
self._lock = Lock()
|
||||||
|
|
||||||
|
def add(self, run: ProcessRun) -> None:
|
||||||
|
with self._lock:
|
||||||
|
self._runs[run.run_id] = run
|
||||||
|
|
||||||
|
def get(self, run_id: str) -> Optional[ProcessRun]:
|
||||||
|
with self._lock:
|
||||||
|
return self._runs.get(run_id)
|
||||||
|
|
||||||
|
def list(self) -> Iterable[ProcessRun]:
|
||||||
|
with self._lock:
|
||||||
|
return list(self._runs.values())
|
||||||
|
|
||||||
|
def update(self, run: ProcessRun) -> None:
|
||||||
|
with self._lock:
|
||||||
|
self._runs[run.run_id] = run
|
||||||
|
|
||||||
|
def remove(self, run_id: str) -> None:
|
||||||
|
with self._lock:
|
||||||
|
self._runs.pop(run_id, None)
|
||||||
61
backend/process/supervisor.py
Normal file
61
backend/process/supervisor.py
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Minimal supervisor for scripted tasks and long-running utilities."""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any, Dict, Iterable, Optional
|
||||||
|
|
||||||
|
from .models import ProcessRun, ProcessRunState
|
||||||
|
from .registry import RunRegistry
|
||||||
|
|
||||||
|
|
||||||
|
class ProcessSupervisor:
|
||||||
|
"""Tracks supervised runs without executing real processes yet."""
|
||||||
|
|
||||||
|
def __init__(self, registry: Optional[RunRegistry] = None) -> None:
|
||||||
|
self.registry = registry or RunRegistry()
|
||||||
|
|
||||||
|
def spawn(
|
||||||
|
self,
|
||||||
|
run_id: str,
|
||||||
|
command: str,
|
||||||
|
scope_key: str,
|
||||||
|
metadata: Optional[Dict[str, Any]] = None,
|
||||||
|
) -> ProcessRun:
|
||||||
|
run = ProcessRun(
|
||||||
|
run_id=run_id,
|
||||||
|
command=command,
|
||||||
|
scope_key=scope_key,
|
||||||
|
metadata=metadata or {},
|
||||||
|
)
|
||||||
|
run.state = ProcessRunState.RUNNING
|
||||||
|
run.updated_at = datetime.utcnow()
|
||||||
|
self.registry.add(run)
|
||||||
|
return run
|
||||||
|
|
||||||
|
def update_state(
|
||||||
|
self,
|
||||||
|
run_id: str,
|
||||||
|
state: ProcessRunState,
|
||||||
|
metadata: Optional[Dict[str, Any]] = None,
|
||||||
|
) -> Optional[ProcessRun]:
|
||||||
|
run = self.registry.get(run_id)
|
||||||
|
if not run:
|
||||||
|
return None
|
||||||
|
run.state = state
|
||||||
|
run.metadata.update(metadata or {})
|
||||||
|
run.updated_at = datetime.utcnow()
|
||||||
|
self.registry.update(run)
|
||||||
|
return run
|
||||||
|
|
||||||
|
def cancel(self, run_id: str, reason: Optional[str] = None) -> Optional[ProcessRun]:
|
||||||
|
run = self.registry.get(run_id)
|
||||||
|
if not run:
|
||||||
|
return None
|
||||||
|
run.state = ProcessRunState.CANCELLED
|
||||||
|
run.metadata.setdefault("cancel_reason", reason or "manual")
|
||||||
|
run.updated_at = datetime.utcnow()
|
||||||
|
self.registry.update(run)
|
||||||
|
return run
|
||||||
|
|
||||||
|
def list_runs(self) -> Iterable[ProcessRun]:
|
||||||
|
return self.registry.list()
|
||||||
13
backend/runtime/__init__.py
Normal file
13
backend/runtime/__init__.py
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
from .agent_runtime import AgentRuntimeState
|
||||||
|
from .context import TradingRunContext
|
||||||
|
from .manager import TradingRuntimeManager
|
||||||
|
from .registry import RuntimeRegistry
|
||||||
|
from .session import TradingSessionKey
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"AgentRuntimeState",
|
||||||
|
"TradingRunContext",
|
||||||
|
"TradingRuntimeManager",
|
||||||
|
"RuntimeRegistry",
|
||||||
|
"TradingSessionKey",
|
||||||
|
]
|
||||||
26
backend/runtime/agent_runtime.py
Normal file
26
backend/runtime/agent_runtime.py
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime, UTC
|
||||||
|
from typing import Any, Dict
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AgentRuntimeState:
|
||||||
|
agent_id: str
|
||||||
|
status: str = "idle"
|
||||||
|
last_session: str | None = None
|
||||||
|
last_updated: datetime = field(default_factory=lambda: datetime.now(UTC))
|
||||||
|
|
||||||
|
def update(self, status: str, session_key: str | None = None) -> None:
|
||||||
|
self.status = status
|
||||||
|
self.last_session = session_key
|
||||||
|
self.last_updated = datetime.now(UTC)
|
||||||
|
|
||||||
|
def to_dict(self) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"agent_id": self.agent_id,
|
||||||
|
"status": self.status,
|
||||||
|
"last_session": self.last_session,
|
||||||
|
"last_updated": self.last_updated.isoformat(),
|
||||||
|
}
|
||||||
15
backend/runtime/context.py
Normal file
15
backend/runtime/context.py
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class TradingRunContext:
|
||||||
|
config_name: str
|
||||||
|
run_dir: Path
|
||||||
|
bootstrap_values: Dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
def describe(self) -> str:
|
||||||
|
return f"Run {self.config_name} @ {self.run_dir}"
|
||||||
134
backend/runtime/manager.py
Normal file
134
backend/runtime/manager.py
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from datetime import datetime, UTC
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
from .agent_runtime import AgentRuntimeState
|
||||||
|
from .context import TradingRunContext
|
||||||
|
from .registry import RuntimeRegistry
|
||||||
|
|
||||||
|
_global_runtime_manager: Optional["TradingRuntimeManager"] = None
|
||||||
|
|
||||||
|
|
||||||
|
def set_global_runtime_manager(manager: "TradingRuntimeManager") -> None:
|
||||||
|
global _global_runtime_manager
|
||||||
|
_global_runtime_manager = manager
|
||||||
|
|
||||||
|
|
||||||
|
def clear_global_runtime_manager() -> None:
|
||||||
|
global _global_runtime_manager
|
||||||
|
_global_runtime_manager = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_global_runtime_manager() -> Optional["TradingRuntimeManager"]:
|
||||||
|
return _global_runtime_manager
|
||||||
|
|
||||||
|
|
||||||
|
class TradingRuntimeManager:
|
||||||
|
def __init__(self, config_name: str, run_dir: Path, bootstrap: Optional[Dict[str, Any]] = None) -> None:
|
||||||
|
self.config_name = config_name
|
||||||
|
self.run_dir = run_dir
|
||||||
|
self.bootstrap = bootstrap or {}
|
||||||
|
self.context: Optional[TradingRunContext] = None
|
||||||
|
self.registry = RuntimeRegistry()
|
||||||
|
self.current_session_key: Optional[str] = None
|
||||||
|
self.events: List[Dict[str, Any]] = []
|
||||||
|
self.pending_approvals: Dict[str, Dict[str, Any]] = {}
|
||||||
|
self.snapshot_path = self.run_dir / "state" / "runtime_state.json"
|
||||||
|
|
||||||
|
def prepare_run(self) -> TradingRunContext:
|
||||||
|
self.run_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
self.context = TradingRunContext(
|
||||||
|
config_name=self.config_name,
|
||||||
|
run_dir=self.run_dir,
|
||||||
|
bootstrap_values=self.bootstrap,
|
||||||
|
)
|
||||||
|
self._persist_snapshot()
|
||||||
|
return self.context
|
||||||
|
|
||||||
|
def set_session_key(self, session_key: str) -> None:
|
||||||
|
self.current_session_key = session_key
|
||||||
|
self._persist_snapshot()
|
||||||
|
|
||||||
|
def log_event(self, event: str, details: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
||||||
|
entry = {
|
||||||
|
"timestamp": datetime.now(UTC).isoformat(),
|
||||||
|
"event": event,
|
||||||
|
"details": details or {},
|
||||||
|
"session": self.current_session_key,
|
||||||
|
}
|
||||||
|
self.events.append(entry)
|
||||||
|
self._persist_snapshot()
|
||||||
|
return entry
|
||||||
|
|
||||||
|
def register_agent(self, agent_id: str) -> AgentRuntimeState:
|
||||||
|
state = AgentRuntimeState(agent_id=agent_id)
|
||||||
|
self.registry.register(agent_id, state)
|
||||||
|
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())
|
||||||
|
self.pending_approvals[approval_id] = payload
|
||||||
|
self._persist_snapshot()
|
||||||
|
|
||||||
|
def update_agent_status(
|
||||||
|
self,
|
||||||
|
agent_id: str,
|
||||||
|
status: str,
|
||||||
|
session_key: Optional[str] = None,
|
||||||
|
) -> AgentRuntimeState:
|
||||||
|
state = self.registry.get(agent_id)
|
||||||
|
if state is None:
|
||||||
|
state = self.register_agent(agent_id)
|
||||||
|
effective_session = session_key or self.current_session_key
|
||||||
|
state.update(status, effective_session)
|
||||||
|
self._persist_snapshot()
|
||||||
|
return state
|
||||||
|
|
||||||
|
def get_agent_state(self, agent_id: str) -> Optional[AgentRuntimeState]:
|
||||||
|
return self.registry.get(agent_id)
|
||||||
|
|
||||||
|
def list_agents(self) -> list[str]:
|
||||||
|
return self.registry.list_agents()
|
||||||
|
|
||||||
|
def resolve_pending_approval(self, approval_id: str, resolved_by: str, status: str) -> None:
|
||||||
|
entry = self.pending_approvals.get(approval_id)
|
||||||
|
if not entry:
|
||||||
|
return
|
||||||
|
entry["status"] = status
|
||||||
|
entry["resolved_at"] = datetime.now(UTC).isoformat()
|
||||||
|
entry["resolved_by"] = resolved_by
|
||||||
|
self._persist_snapshot()
|
||||||
|
|
||||||
|
def list_pending_approvals(self) -> List[Dict[str, Any]]:
|
||||||
|
return list(self.pending_approvals.values())
|
||||||
|
|
||||||
|
def build_snapshot(self) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"context": {
|
||||||
|
"config_name": self.context.config_name,
|
||||||
|
"run_dir": str(self.context.run_dir),
|
||||||
|
"bootstrap_values": self.context.bootstrap_values,
|
||||||
|
}
|
||||||
|
if self.context
|
||||||
|
else None,
|
||||||
|
"current_session_key": self.current_session_key,
|
||||||
|
"agents": [
|
||||||
|
state.to_dict()
|
||||||
|
for agent_id in self.registry.list_agents()
|
||||||
|
if (state := self.registry.get(agent_id)) is not None
|
||||||
|
],
|
||||||
|
"events": self.events,
|
||||||
|
"pending_approvals": self.list_pending_approvals(),
|
||||||
|
}
|
||||||
|
|
||||||
|
def _persist_snapshot(self) -> None:
|
||||||
|
self.snapshot_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
self.snapshot_path.write_text(
|
||||||
|
json.dumps(self.build_snapshot(), ensure_ascii=False, indent=2),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
20
backend/runtime/registry.py
Normal file
20
backend/runtime/registry.py
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Dict, Optional
|
||||||
|
|
||||||
|
|
||||||
|
class RuntimeRegistry:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._states: Dict[str, "AgentRuntimeState"] = {}
|
||||||
|
|
||||||
|
def register(self, agent_id: str, state: "AgentRuntimeState") -> None:
|
||||||
|
self._states[agent_id] = state
|
||||||
|
|
||||||
|
def get(self, agent_id: str) -> Optional["AgentRuntimeState"]:
|
||||||
|
return self._states.get(agent_id)
|
||||||
|
|
||||||
|
def list_agents(self) -> list[str]:
|
||||||
|
return list(self._states.keys())
|
||||||
|
|
||||||
|
def clear(self) -> None:
|
||||||
|
self._states.clear()
|
||||||
14
backend/runtime/session.py
Normal file
14
backend/runtime/session.py
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class TradingSessionKey:
|
||||||
|
date: str
|
||||||
|
ticker: str | None = None
|
||||||
|
|
||||||
|
def __post_init__(self):
|
||||||
|
if not self.date:
|
||||||
|
raise ValueError("Session must have a date")
|
||||||
|
|
||||||
|
def key(self) -> str:
|
||||||
|
return f"{self.date}:{self.ticker or 'all'}"
|
||||||
16
backend/workspaces/.template/AGENTS.md
Normal file
16
backend/workspaces/.template/AGENTS.md
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
# Agent Guide
|
||||||
|
|
||||||
|
## 工作流程
|
||||||
|
1. 接收分析任务
|
||||||
|
2. 调用相关工具/技能
|
||||||
|
3. 生成分析报告
|
||||||
|
4. 参与团队决策
|
||||||
|
|
||||||
|
## 工具使用规范
|
||||||
|
- 优先使用已激活的技能
|
||||||
|
- 不确定时询问Portfolio Manager
|
||||||
|
- 重要发现用 `/save` 记录
|
||||||
|
|
||||||
|
## 记忆管理
|
||||||
|
- 使用 `/compact` 定期压缩记忆
|
||||||
|
- 投资经验记录在MEMORY.md
|
||||||
5
backend/workspaces/.template/HEARTBEAT.md
Normal file
5
backend/workspaces/.template/HEARTBEAT.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# Heartbeat
|
||||||
|
|
||||||
|
## 定时任务
|
||||||
|
- 每日开盘前检查持仓
|
||||||
|
- 收盘后记录当日表现
|
||||||
9
backend/workspaces/.template/MEMORY.md
Normal file
9
backend/workspaces/.template/MEMORY.md
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
# Memory
|
||||||
|
|
||||||
|
<!-- 此文件用于记录Agent的学习经验和重要发现 -->
|
||||||
|
|
||||||
|
## 经验总结
|
||||||
|
|
||||||
|
## 重要事件
|
||||||
|
|
||||||
|
## 改进记录
|
||||||
5
backend/workspaces/.template/POLICY.md
Normal file
5
backend/workspaces/.template/POLICY.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# Policy
|
||||||
|
|
||||||
|
## 风控规则
|
||||||
|
- 单一持仓不超过20%
|
||||||
|
- 止损线:-15%
|
||||||
13
backend/workspaces/.template/PROFILE.md
Normal file
13
backend/workspaces/.template/PROFILE.md
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
# Profile
|
||||||
|
|
||||||
|
## 投资风格
|
||||||
|
- 风险承受能力:中等
|
||||||
|
- 投资期限:中期(3-12个月)
|
||||||
|
- 偏好行业:科技、医疗、消费
|
||||||
|
|
||||||
|
## 优势
|
||||||
|
- 财务分析
|
||||||
|
- 趋势识别
|
||||||
|
|
||||||
|
## 改进方向
|
||||||
|
- 市场情绪把握
|
||||||
4
backend/workspaces/.template/SOUL.md
Normal file
4
backend/workspaces/.template/SOUL.md
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
# Soul
|
||||||
|
|
||||||
|
你是专业的金融分析师,语气冷静、客观、专业。
|
||||||
|
你的分析应该数据驱动,避免情绪化表达。
|
||||||
5
backend/workspaces/.template/STYLE.md
Normal file
5
backend/workspaces/.template/STYLE.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# Style
|
||||||
|
|
||||||
|
- 使用结构化输出(JSON/Markdown表格)
|
||||||
|
- 包含置信度评分
|
||||||
|
- 列出关键假设
|
||||||
10
backend/workspaces/.template/agent.yaml
Normal file
10
backend/workspaces/.template/agent.yaml
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
agent_id: {{agent_id}}
|
||||||
|
agent_type: {{agent_type}}
|
||||||
|
name: {{name}}
|
||||||
|
model:
|
||||||
|
provider: openai
|
||||||
|
model_name: gpt-4o
|
||||||
|
temperature: 0.3
|
||||||
|
enabled_skills: []
|
||||||
|
disabled_skills: []
|
||||||
|
settings: {}
|
||||||
0
backend/workspaces/.template/skills/.gitkeep
Normal file
0
backend/workspaces/.template/skills/.gitkeep
Normal file
17
backend/workspaces/.template/types/fundamental/ROLE.md
Normal file
17
backend/workspaces/.template/types/fundamental/ROLE.md
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
# Role: Fundamental Analyst
|
||||||
|
|
||||||
|
## 职责
|
||||||
|
分析公司财务报表、盈利能力、成长性、竞争优势等基本面因素。
|
||||||
|
|
||||||
|
## 分析维度
|
||||||
|
- 财务报表分析(资产负债表、利润表、现金流量表)
|
||||||
|
- 盈利能力指标(ROE、ROA、毛利率、净利率)
|
||||||
|
- 成长性指标(营收增长率、利润增长率)
|
||||||
|
- 估值指标(P/E、P/B、P/S)
|
||||||
|
- 行业地位和竞争优势
|
||||||
|
|
||||||
|
## 输出格式
|
||||||
|
- 财务健康度评分(1-10)
|
||||||
|
- 成长性评分(1-10)
|
||||||
|
- 关键财务亮点和风险
|
||||||
|
- 同业对比分析
|
||||||
5
backend/workspaces/.template/types/fundamental/SOUL.md
Normal file
5
backend/workspaces/.template/types/fundamental/SOUL.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# Soul
|
||||||
|
|
||||||
|
你是严谨的基本面分析师,像沃伦·巴菲特一样注重企业内在价值。
|
||||||
|
你的分析深入细致,关注长期价值而非短期波动。
|
||||||
|
语气沉稳、逻辑严密,善于发现财务数据背后的商业本质。
|
||||||
18
backend/workspaces/.template/types/portfolio/ROLE.md
Normal file
18
backend/workspaces/.template/types/portfolio/ROLE.md
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# Role: Portfolio Manager
|
||||||
|
|
||||||
|
## 职责
|
||||||
|
统筹各分析师意见,制定投资决策,管理投资组合配置。
|
||||||
|
|
||||||
|
## 分析维度
|
||||||
|
- 资产配置策略(股债比例、行业分布)
|
||||||
|
- 风险收益平衡
|
||||||
|
- 仓位管理(建仓/加仓/减仓/清仓)
|
||||||
|
- 再平衡时机
|
||||||
|
- 组合相关性分析
|
||||||
|
|
||||||
|
## 输出格式
|
||||||
|
- 投资决策(买入/卖出/持有)
|
||||||
|
- 建议仓位比例
|
||||||
|
- 目标价位
|
||||||
|
- 止损止盈设置
|
||||||
|
- 组合调整建议
|
||||||
5
backend/workspaces/.template/types/portfolio/SOUL.md
Normal file
5
backend/workspaces/.template/types/portfolio/SOUL.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# Soul
|
||||||
|
|
||||||
|
你是睿智的投资组合经理,像将军一样统筹全局。
|
||||||
|
你善于权衡各方意见,做出果断而理性的投资决策。
|
||||||
|
语气权威、决策果断,对组合整体表现负有最终责任。
|
||||||
19
backend/workspaces/.template/types/risk/ROLE.md
Normal file
19
backend/workspaces/.template/types/risk/ROLE.md
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
# Role: Risk Manager
|
||||||
|
|
||||||
|
## 职责
|
||||||
|
识别、评估和监控投资风险,确保组合风险在可控范围内。
|
||||||
|
|
||||||
|
## 分析维度
|
||||||
|
- 市场风险(Beta、波动率)
|
||||||
|
- 信用风险
|
||||||
|
- 流动性风险
|
||||||
|
- 集中度风险
|
||||||
|
- 尾部风险(VaR、CVaR)
|
||||||
|
- 压力测试
|
||||||
|
|
||||||
|
## 输出格式
|
||||||
|
- 风险等级(低/中/高/极高)
|
||||||
|
- 风险敞口分析
|
||||||
|
- 风险调整建议
|
||||||
|
- 预警阈值设置
|
||||||
|
- 应急预案
|
||||||
5
backend/workspaces/.template/types/risk/SOUL.md
Normal file
5
backend/workspaces/.template/types/risk/SOUL.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# Soul
|
||||||
|
|
||||||
|
你是谨慎的风险管理者,时刻警惕潜在的损失。
|
||||||
|
你像守门员一样守护组合安全,宁可错过机会也不冒无法承受的风险。
|
||||||
|
语气保守、风险意识强,善于发现隐藏的威胁和脆弱性。
|
||||||
17
backend/workspaces/.template/types/sentiment/ROLE.md
Normal file
17
backend/workspaces/.template/types/sentiment/ROLE.md
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
# Role: Sentiment Analyst
|
||||||
|
|
||||||
|
## 职责
|
||||||
|
分析市场情绪、资金流向、新闻舆情,判断市场心理状态。
|
||||||
|
|
||||||
|
## 分析维度
|
||||||
|
- 市场情绪指标(恐慌/贪婪指数)
|
||||||
|
- 资金流向分析(主力/散户资金)
|
||||||
|
- 新闻舆情分析(正面/负面/中性)
|
||||||
|
- 社交媒体情绪
|
||||||
|
- 机构持仓变化
|
||||||
|
|
||||||
|
## 输出格式
|
||||||
|
- 情绪评分(-10到+10,极度恐慌到极度贪婪)
|
||||||
|
- 资金流向判断
|
||||||
|
- 舆情摘要
|
||||||
|
- 情绪拐点预警
|
||||||
5
backend/workspaces/.template/types/sentiment/SOUL.md
Normal file
5
backend/workspaces/.template/types/sentiment/SOUL.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# Soul
|
||||||
|
|
||||||
|
你是敏感的市场情绪捕手,善于感知市场的恐惧与贪婪。
|
||||||
|
你关注人性在金融市场中的表现,理解情绪如何驱动价格。
|
||||||
|
语气富有洞察力、善于捕捉微妙变化,像心理学家一样理解市场参与者。
|
||||||
17
backend/workspaces/.template/types/technical/ROLE.md
Normal file
17
backend/workspaces/.template/types/technical/ROLE.md
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
# Role: Technical Analyst
|
||||||
|
|
||||||
|
## 职责
|
||||||
|
分析价格走势、交易量、技术指标,识别买卖时机。
|
||||||
|
|
||||||
|
## 分析维度
|
||||||
|
- 趋势分析(长期/中期/短期趋势)
|
||||||
|
- 支撑阻力位识别
|
||||||
|
- 技术指标(MACD、RSI、KDJ、布林带等)
|
||||||
|
- 形态识别(头肩顶/底、双底、三角形等)
|
||||||
|
- 量价关系分析
|
||||||
|
|
||||||
|
## 输出格式
|
||||||
|
- 趋势方向(上涨/下跌/震荡)
|
||||||
|
- 关键价位(支撑/阻力)
|
||||||
|
- 技术信号(买入/卖出/观望)
|
||||||
|
- 置信度评分
|
||||||
5
backend/workspaces/.template/types/technical/SOUL.md
Normal file
5
backend/workspaces/.template/types/technical/SOUL.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# Soul
|
||||||
|
|
||||||
|
你是敏锐的技术分析师,相信价格包含一切信息。
|
||||||
|
你善于从图表中发现规律,像侦探一样寻找市场留下的痕迹。
|
||||||
|
语气果断、快速反应,善于捕捉稍纵即逝的交易机会。
|
||||||
17
backend/workspaces/.template/types/valuation/ROLE.md
Normal file
17
backend/workspaces/.template/types/valuation/ROLE.md
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
# Role: Valuation Analyst
|
||||||
|
|
||||||
|
## 职责
|
||||||
|
评估公司内在价值,计算合理价格区间,识别高估/低估机会。
|
||||||
|
|
||||||
|
## 分析维度
|
||||||
|
- DCF现金流折现模型
|
||||||
|
- 相对估值法(P/E、EV/EBITDA等)
|
||||||
|
- 资产重估法
|
||||||
|
- 分部估值(SOTP)
|
||||||
|
- 安全边际计算
|
||||||
|
|
||||||
|
## 输出格式
|
||||||
|
- 内在价值估算
|
||||||
|
- 合理价格区间
|
||||||
|
- 当前价格vs内在价值(高估/低估百分比)
|
||||||
|
- 估值假设和敏感性分析
|
||||||
5
backend/workspaces/.template/types/valuation/SOUL.md
Normal file
5
backend/workspaces/.template/types/valuation/SOUL.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# Soul
|
||||||
|
|
||||||
|
你是精确的估值分析师,追求计算内在价值的准确区间。
|
||||||
|
你像精算师一样严谨,注重假设的合理性和安全边际。
|
||||||
|
语气精确、注重数字,善于发现市场定价错误带来的机会。
|
||||||
@@ -16,8 +16,8 @@ import GlobalStyles from './styles/GlobalStyles';
|
|||||||
import NetValueChart from './components/NetValueChart';
|
import NetValueChart from './components/NetValueChart';
|
||||||
import StockLogo from './components/StockLogo';
|
import StockLogo from './components/StockLogo';
|
||||||
import Header from './components/Header.jsx';
|
import Header from './components/Header.jsx';
|
||||||
import WatchlistPanel from './components/WatchlistPanel.jsx';
|
|
||||||
import RuntimeSettingsPanel from './components/RuntimeSettingsPanel.jsx';
|
import RuntimeSettingsPanel from './components/RuntimeSettingsPanel.jsx';
|
||||||
|
import RuntimeView from './components/RuntimeView.jsx';
|
||||||
|
|
||||||
// Utils
|
// Utils
|
||||||
import { formatNumber, formatTickerPrice } from './utils/formatters';
|
import { formatNumber, formatTickerPrice } from './utils/formatters';
|
||||||
@@ -64,7 +64,7 @@ export default function LiveTradingApp() {
|
|||||||
const [progress, setProgress] = useState({ current: 0, total: 0 });
|
const [progress, setProgress] = useState({ current: 0, total: 0 });
|
||||||
const [now, setNow] = useState(() => new Date());
|
const [now, setNow] = useState(() => new Date());
|
||||||
|
|
||||||
// View toggle: 'traders' | 'room' | 'explain' | 'chart' | 'statistics'
|
// View toggle: 'traders' | 'room' | 'explain' | 'chart' | 'statistics' | 'runtime'
|
||||||
const [currentView, setCurrentView] = useState('traders');
|
const [currentView, setCurrentView] = useState('traders');
|
||||||
const [isInitialAnimating, setIsInitialAnimating] = useState(true);
|
const [isInitialAnimating, setIsInitialAnimating] = useState(true);
|
||||||
const [lastUpdate, setLastUpdate] = useState(new Date());
|
const [lastUpdate, setLastUpdate] = useState(new Date());
|
||||||
@@ -124,6 +124,9 @@ export default function LiveTradingApp() {
|
|||||||
const [intervalMinutesDraft, setIntervalMinutesDraft] = useState('60');
|
const [intervalMinutesDraft, setIntervalMinutesDraft] = useState('60');
|
||||||
const [triggerTimeDraft, setTriggerTimeDraft] = useState('09:30');
|
const [triggerTimeDraft, setTriggerTimeDraft] = useState('09:30');
|
||||||
const [maxCommCyclesDraft, setMaxCommCyclesDraft] = useState('2');
|
const [maxCommCyclesDraft, setMaxCommCyclesDraft] = useState('2');
|
||||||
|
const [initialCashDraft, setInitialCashDraft] = useState('100000');
|
||||||
|
const [marginRequirementDraft, setMarginRequirementDraft] = useState('0');
|
||||||
|
const [enableMemoryDraft, setEnableMemoryDraft] = useState(false);
|
||||||
const [runtimeConfigFeedback, setRuntimeConfigFeedback] = useState(null);
|
const [runtimeConfigFeedback, setRuntimeConfigFeedback] = useState(null);
|
||||||
const [isRuntimeConfigSaving, setIsRuntimeConfigSaving] = useState(false);
|
const [isRuntimeConfigSaving, setIsRuntimeConfigSaving] = useState(false);
|
||||||
const [selectedSkillAgentId, setSelectedSkillAgentId] = useState(AGENTS[0]?.id || 'portfolio_manager');
|
const [selectedSkillAgentId, setSelectedSkillAgentId] = useState(AGENTS[0]?.id || 'portfolio_manager');
|
||||||
@@ -302,6 +305,9 @@ export default function LiveTradingApp() {
|
|||||||
setIntervalMinutesDraft(String(runtimeConfig.interval_minutes || 60));
|
setIntervalMinutesDraft(String(runtimeConfig.interval_minutes || 60));
|
||||||
setTriggerTimeDraft(String(runtimeConfig.trigger_time || '09:30'));
|
setTriggerTimeDraft(String(runtimeConfig.trigger_time || '09:30'));
|
||||||
setMaxCommCyclesDraft(String(runtimeConfig.max_comm_cycles || 2));
|
setMaxCommCyclesDraft(String(runtimeConfig.max_comm_cycles || 2));
|
||||||
|
setInitialCashDraft(String(runtimeConfig.initial_cash ?? 100000));
|
||||||
|
setMarginRequirementDraft(String(runtimeConfig.margin_requirement ?? 0));
|
||||||
|
setEnableMemoryDraft(Boolean(runtimeConfig.enable_memory ?? false));
|
||||||
}, [runtimeConfig]);
|
}, [runtimeConfig]);
|
||||||
|
|
||||||
const watchlistSuggestions = useMemo(
|
const watchlistSuggestions = useMemo(
|
||||||
@@ -537,20 +543,101 @@ export default function LiveTradingApp() {
|
|||||||
schedule_mode: scheduleModeDraft,
|
schedule_mode: scheduleModeDraft,
|
||||||
interval_minutes: interval,
|
interval_minutes: interval,
|
||||||
trigger_time: triggerTimeDraft,
|
trigger_time: triggerTimeDraft,
|
||||||
max_comm_cycles: maxCommCycles
|
max_comm_cycles: maxCommCycles,
|
||||||
|
initial_cash: Number(initialCashDraft),
|
||||||
|
margin_requirement: Number(marginRequirementDraft),
|
||||||
|
enable_memory: Boolean(enableMemoryDraft)
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!success) {
|
if (!success) {
|
||||||
setIsRuntimeConfigSaving(false);
|
setIsRuntimeConfigSaving(false);
|
||||||
setRuntimeConfigFeedback({ type: 'error', text: '发送失败,请检查连接状态' });
|
setRuntimeConfigFeedback({ type: 'error', text: '发送失败,请检查连接状态' });
|
||||||
}
|
}
|
||||||
}, [intervalMinutesDraft, maxCommCyclesDraft, scheduleModeDraft, triggerTimeDraft]);
|
}, [enableMemoryDraft, initialCashDraft, intervalMinutesDraft, marginRequirementDraft, maxCommCyclesDraft, scheduleModeDraft, triggerTimeDraft]);
|
||||||
|
|
||||||
|
const handleLaunchConfigSave = useCallback(() => {
|
||||||
|
const pendingTickers = parseWatchlistInput(watchlistInputValue);
|
||||||
|
const nextTickers = Array.from(new Set([...watchlistDraftSymbols, ...pendingTickers]));
|
||||||
|
if (nextTickers.length === 0) {
|
||||||
|
setRuntimeConfigFeedback({ type: 'error', text: '至少输入 1 个有效股票代码' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!clientRef.current) {
|
||||||
|
setRuntimeConfigFeedback({ type: 'error', text: '连接未就绪,稍后重试' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const interval = Number(intervalMinutesDraft);
|
||||||
|
const maxCommCycles = Number(maxCommCyclesDraft);
|
||||||
|
const initialCash = Number(initialCashDraft);
|
||||||
|
const marginRequirement = Number(marginRequirementDraft);
|
||||||
|
if (!Number.isInteger(interval) || interval <= 0) {
|
||||||
|
setRuntimeConfigFeedback({ type: 'error', text: '间隔必须是正整数分钟' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!Number.isInteger(maxCommCycles) || maxCommCycles <= 0) {
|
||||||
|
setRuntimeConfigFeedback({ type: 'error', text: '讨论轮数必须是正整数' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!Number.isFinite(initialCash) || initialCash <= 0) {
|
||||||
|
setRuntimeConfigFeedback({ type: 'error', text: '初始资金必须是正数' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!Number.isFinite(marginRequirement) || marginRequirement < 0) {
|
||||||
|
setRuntimeConfigFeedback({ type: 'error', text: '保证金要求不能为负数' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsRuntimeConfigSaving(true);
|
||||||
|
setIsWatchlistSaving(true);
|
||||||
|
setRuntimeConfigFeedback(null);
|
||||||
|
setWatchlistFeedback(null);
|
||||||
|
setWatchlistDraftSymbols(nextTickers);
|
||||||
|
setWatchlistInputValue('');
|
||||||
|
|
||||||
|
const watchlistSuccess = clientRef.current.send({
|
||||||
|
type: 'update_watchlist',
|
||||||
|
tickers: nextTickers
|
||||||
|
});
|
||||||
|
|
||||||
|
const runtimeSuccess = clientRef.current.send({
|
||||||
|
type: 'update_runtime_config',
|
||||||
|
schedule_mode: scheduleModeDraft,
|
||||||
|
interval_minutes: interval,
|
||||||
|
trigger_time: triggerTimeDraft,
|
||||||
|
max_comm_cycles: maxCommCycles,
|
||||||
|
initial_cash: initialCash,
|
||||||
|
margin_requirement: marginRequirement,
|
||||||
|
enable_memory: Boolean(enableMemoryDraft)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!watchlistSuccess || !runtimeSuccess) {
|
||||||
|
setIsRuntimeConfigSaving(false);
|
||||||
|
setIsWatchlistSaving(false);
|
||||||
|
setRuntimeConfigFeedback({ type: 'error', text: '发送失败,请检查连接状态' });
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
intervalMinutesDraft,
|
||||||
|
maxCommCyclesDraft,
|
||||||
|
parseWatchlistInput,
|
||||||
|
scheduleModeDraft,
|
||||||
|
triggerTimeDraft,
|
||||||
|
initialCashDraft,
|
||||||
|
marginRequirementDraft,
|
||||||
|
enableMemoryDraft,
|
||||||
|
watchlistDraftSymbols,
|
||||||
|
watchlistInputValue
|
||||||
|
]);
|
||||||
|
|
||||||
const handleRuntimeDefaultsRestore = useCallback(() => {
|
const handleRuntimeDefaultsRestore = useCallback(() => {
|
||||||
setScheduleModeDraft('daily');
|
setScheduleModeDraft('daily');
|
||||||
setIntervalMinutesDraft('60');
|
setIntervalMinutesDraft('60');
|
||||||
setTriggerTimeDraft('09:30');
|
setTriggerTimeDraft('09:30');
|
||||||
setMaxCommCyclesDraft('2');
|
setMaxCommCyclesDraft('2');
|
||||||
|
setInitialCashDraft('100000');
|
||||||
|
setMarginRequirementDraft('0');
|
||||||
|
setEnableMemoryDraft(false);
|
||||||
setRuntimeConfigFeedback(null);
|
setRuntimeConfigFeedback(null);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -2273,43 +2360,39 @@ export default function LiveTradingApp() {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<WatchlistPanel
|
|
||||||
isOpen={isWatchlistPanelOpen}
|
|
||||||
isConnected={isConnected}
|
|
||||||
isSaving={isWatchlistSaving}
|
|
||||||
draftSymbols={watchlistDraftSymbols}
|
|
||||||
inputValue={watchlistInputValue}
|
|
||||||
feedback={watchlistFeedback}
|
|
||||||
suggestions={watchlistSuggestions}
|
|
||||||
onToggle={handleWatchlistPanelToggle}
|
|
||||||
onClose={() => setIsWatchlistPanelOpen(false)}
|
|
||||||
onInputChange={handleWatchlistInputChange}
|
|
||||||
onInputKeyDown={handleWatchlistInputKeyDown}
|
|
||||||
onAdd={() => commitWatchlistInput(watchlistInputValue)}
|
|
||||||
onRemove={handleWatchlistRemove}
|
|
||||||
onRestoreCurrent={handleWatchlistRestoreCurrent}
|
|
||||||
onRestoreDefault={handleWatchlistRestoreDefault}
|
|
||||||
onSuggestionClick={handleWatchlistSuggestionClick}
|
|
||||||
onSave={handleWatchlistSave}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<RuntimeSettingsPanel
|
<RuntimeSettingsPanel
|
||||||
|
showTrigger={false}
|
||||||
isOpen={isRuntimeSettingsOpen}
|
isOpen={isRuntimeSettingsOpen}
|
||||||
isConnected={isConnected}
|
isConnected={isConnected}
|
||||||
isSaving={isRuntimeConfigSaving}
|
isSaving={isRuntimeConfigSaving || isWatchlistSaving}
|
||||||
feedback={runtimeConfigFeedback}
|
feedback={runtimeConfigFeedback || watchlistFeedback}
|
||||||
runtimeConfig={runtimeConfig}
|
|
||||||
scheduleMode={scheduleModeDraft}
|
scheduleMode={scheduleModeDraft}
|
||||||
intervalMinutes={intervalMinutesDraft}
|
intervalMinutes={intervalMinutesDraft}
|
||||||
triggerTime={triggerTimeDraft}
|
triggerTime={triggerTimeDraft}
|
||||||
maxCommCycles={maxCommCyclesDraft}
|
maxCommCycles={maxCommCyclesDraft}
|
||||||
|
initialCash={initialCashDraft}
|
||||||
|
marginRequirement={marginRequirementDraft}
|
||||||
|
enableMemory={enableMemoryDraft}
|
||||||
|
watchlistSymbols={watchlistDraftSymbols}
|
||||||
|
watchlistInputValue={watchlistInputValue}
|
||||||
|
watchlistSuggestions={watchlistSuggestions}
|
||||||
onToggle={handleRuntimeSettingsToggle}
|
onToggle={handleRuntimeSettingsToggle}
|
||||||
onClose={() => setIsRuntimeSettingsOpen(false)}
|
onClose={() => setIsRuntimeSettingsOpen(false)}
|
||||||
onScheduleModeChange={setScheduleModeDraft}
|
onScheduleModeChange={setScheduleModeDraft}
|
||||||
onIntervalMinutesChange={setIntervalMinutesDraft}
|
onIntervalMinutesChange={setIntervalMinutesDraft}
|
||||||
onTriggerTimeChange={setTriggerTimeDraft}
|
onTriggerTimeChange={setTriggerTimeDraft}
|
||||||
onMaxCommCyclesChange={setMaxCommCyclesDraft}
|
onMaxCommCyclesChange={setMaxCommCyclesDraft}
|
||||||
onSave={handleRuntimeConfigSave}
|
onInitialCashChange={setInitialCashDraft}
|
||||||
|
onMarginRequirementChange={setMarginRequirementDraft}
|
||||||
|
onEnableMemoryChange={setEnableMemoryDraft}
|
||||||
|
onWatchlistInputChange={handleWatchlistInputChange}
|
||||||
|
onWatchlistInputKeyDown={handleWatchlistInputKeyDown}
|
||||||
|
onWatchlistAdd={() => commitWatchlistInput(watchlistInputValue)}
|
||||||
|
onWatchlistRemove={handleWatchlistRemove}
|
||||||
|
onWatchlistRestoreCurrent={handleWatchlistRestoreCurrent}
|
||||||
|
onWatchlistRestoreDefault={handleWatchlistRestoreDefault}
|
||||||
|
onWatchlistSuggestionClick={handleWatchlistSuggestionClick}
|
||||||
|
onSave={handleLaunchConfigSave}
|
||||||
onRestoreDefaults={handleRuntimeDefaultsRestore}
|
onRestoreDefaults={handleRuntimeDefaultsRestore}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -2393,8 +2476,33 @@ export default function LiveTradingApp() {
|
|||||||
>
|
>
|
||||||
统计
|
统计
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className={`view-nav-btn ${currentView === 'runtime' ? 'active' : ''}`}
|
||||||
|
onClick={() => setCurrentView('runtime')}
|
||||||
|
>
|
||||||
|
运行态
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{currentView === 'runtime' ? (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 40,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
overflow: 'hidden',
|
||||||
|
minWidth: 0,
|
||||||
|
minHeight: 0
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<RuntimeView />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<div className={`view-slider-five ${
|
<div className={`view-slider-five ${
|
||||||
currentView === 'traders'
|
currentView === 'traders'
|
||||||
? 'show-traders'
|
? 'show-traders'
|
||||||
@@ -2454,6 +2562,7 @@ export default function LiveTradingApp() {
|
|||||||
leaderboard={leaderboard}
|
leaderboard={leaderboard}
|
||||||
feed={feed}
|
feed={feed}
|
||||||
onJumpToMessage={handleJumpToMessage}
|
onJumpToMessage={handleJumpToMessage}
|
||||||
|
onOpenLaunchConfig={() => setIsRuntimeSettingsOpen(true)}
|
||||||
/>
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
@@ -2535,6 +2644,7 @@ export default function LiveTradingApp() {
|
|||||||
</Suspense>
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ function getRankMedal(rank) {
|
|||||||
* Supports click and hover (1.5s) to show agent performance cards
|
* Supports click and hover (1.5s) to show agent performance cards
|
||||||
* Supports replay mode - completely independent from live mode
|
* Supports replay mode - completely independent from live mode
|
||||||
*/
|
*/
|
||||||
export default function RoomView({ bubbles, bubbleFor, leaderboard, feed, onJumpToMessage }) {
|
export default function RoomView({ bubbles, bubbleFor, leaderboard, feed, onJumpToMessage, onOpenLaunchConfig }) {
|
||||||
const canvasRef = useRef(null);
|
const canvasRef = useRef(null);
|
||||||
const containerRef = useRef(null);
|
const containerRef = useRef(null);
|
||||||
|
|
||||||
@@ -719,13 +719,24 @@ export default function RoomView({ bubbles, bubbleFor, leaderboard, feed, onJump
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Replay Button */}
|
{/* Room Controls */}
|
||||||
{showReplayButton && (
|
{(showReplayButton || onOpenLaunchConfig) && (
|
||||||
<div className="replay-button-container">
|
<div className="replay-button-container">
|
||||||
|
{onOpenLaunchConfig && (
|
||||||
|
<button
|
||||||
|
className="replay-button"
|
||||||
|
onClick={onOpenLaunchConfig}
|
||||||
|
title="打开启动配置"
|
||||||
|
style={{ background: '#FFFFFF', color: '#000000' }}
|
||||||
|
>
|
||||||
|
<span>启动</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
className="replay-button"
|
className="replay-button"
|
||||||
onClick={handleReplayClick}
|
onClick={handleReplayClick}
|
||||||
title="Replay feed history"
|
title="Replay feed history"
|
||||||
|
disabled={!showReplayButton}
|
||||||
>
|
>
|
||||||
<span className="replay-icon">▶▶</span>
|
<span className="replay-icon">▶▶</span>
|
||||||
<span>回放</span>
|
<span>回放</span>
|
||||||
@@ -767,4 +778,3 @@ export default function RoomView({ bubbles, bubbleFor, leaderboard, feed, onJump
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,26 +1,44 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
|
||||||
export default function RuntimeSettingsPanel({
|
export default function RuntimeSettingsPanel({
|
||||||
|
showTrigger = true,
|
||||||
isOpen,
|
isOpen,
|
||||||
isConnected,
|
isConnected,
|
||||||
isSaving,
|
isSaving,
|
||||||
feedback,
|
feedback,
|
||||||
runtimeConfig,
|
|
||||||
scheduleMode,
|
scheduleMode,
|
||||||
intervalMinutes,
|
intervalMinutes,
|
||||||
triggerTime,
|
triggerTime,
|
||||||
maxCommCycles,
|
maxCommCycles,
|
||||||
|
initialCash,
|
||||||
|
marginRequirement,
|
||||||
|
enableMemory,
|
||||||
|
watchlistSymbols,
|
||||||
|
watchlistInputValue,
|
||||||
|
watchlistSuggestions,
|
||||||
onToggle,
|
onToggle,
|
||||||
onClose,
|
onClose,
|
||||||
onScheduleModeChange,
|
onScheduleModeChange,
|
||||||
onIntervalMinutesChange,
|
onIntervalMinutesChange,
|
||||||
onTriggerTimeChange,
|
onTriggerTimeChange,
|
||||||
onMaxCommCyclesChange,
|
onMaxCommCyclesChange,
|
||||||
|
onInitialCashChange,
|
||||||
|
onMarginRequirementChange,
|
||||||
|
onEnableMemoryChange,
|
||||||
|
onWatchlistInputChange,
|
||||||
|
onWatchlistInputKeyDown,
|
||||||
|
onWatchlistAdd,
|
||||||
|
onWatchlistRemove,
|
||||||
|
onWatchlistRestoreCurrent,
|
||||||
|
onWatchlistRestoreDefault,
|
||||||
|
onWatchlistSuggestionClick,
|
||||||
onSave,
|
onSave,
|
||||||
onRestoreDefaults
|
onRestoreDefaults
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, minWidth: 0, position: 'relative' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, minWidth: 0 }}>
|
||||||
|
{showTrigger && (
|
||||||
<button
|
<button
|
||||||
onClick={onToggle}
|
onClick={onToggle}
|
||||||
style={{
|
style={{
|
||||||
@@ -36,49 +54,225 @@ export default function RuntimeSettingsPanel({
|
|||||||
whiteSpace: 'nowrap'
|
whiteSpace: 'nowrap'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
运行设置
|
启动配置
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
{isOpen && (
|
{isOpen && createPortal((
|
||||||
<div style={{
|
<div
|
||||||
position: 'absolute',
|
onClick={onClose}
|
||||||
top: 'calc(100% + 10px)',
|
style={{
|
||||||
right: 0,
|
position: 'fixed',
|
||||||
width: 320,
|
inset: 0,
|
||||||
maxWidth: 'min(320px, 92vw)',
|
background: 'rgba(15, 23, 42, 0.28)',
|
||||||
padding: '14px',
|
display: 'flex',
|
||||||
borderRadius: 8,
|
alignItems: 'center',
|
||||||
border: '1px solid #D9D9D9',
|
justifyContent: 'center',
|
||||||
|
padding: 24,
|
||||||
|
zIndex: 9998
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
style={{
|
||||||
|
width: 'min(760px, 92vw)',
|
||||||
|
maxHeight: '80vh',
|
||||||
|
overflowY: 'auto',
|
||||||
|
borderRadius: 16,
|
||||||
|
border: '1px solid #D9E0E7',
|
||||||
background: '#FFFFFF',
|
background: '#FFFFFF',
|
||||||
boxShadow: '0 12px 36px rgba(0, 0, 0, 0.14)',
|
boxShadow: '0 24px 60px rgba(15, 23, 42, 0.18)',
|
||||||
zIndex: 40,
|
padding: 18,
|
||||||
|
paddingTop: 22,
|
||||||
display: 'grid',
|
display: 'grid',
|
||||||
gap: 12
|
gap: 16,
|
||||||
}}>
|
position: 'relative',
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 12 }}>
|
zIndex: 9999
|
||||||
<div>
|
}}
|
||||||
<div style={{ fontSize: '12px', fontWeight: 700, color: '#111111', letterSpacing: '0.3px' }}>
|
>
|
||||||
运行设置
|
|
||||||
</div>
|
|
||||||
<div style={{ fontSize: '11px', color: '#666666', marginTop: 2 }}>
|
|
||||||
保存后立即热更新当前运行中的调度参数
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
style={{
|
style={{
|
||||||
border: 'none',
|
position: 'absolute',
|
||||||
background: 'transparent',
|
top: 16,
|
||||||
color: '#666666',
|
right: 16,
|
||||||
|
border: '1px solid #D0D7DE',
|
||||||
|
background: '#FFFFFF',
|
||||||
|
borderRadius: 999,
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
fontSize: 16,
|
||||||
|
lineHeight: 1,
|
||||||
|
color: '#111111',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
fontSize: '14px',
|
boxShadow: '0 4px 12px rgba(15, 23, 42, 0.08)'
|
||||||
lineHeight: 1
|
|
||||||
}}
|
}}
|
||||||
|
aria-label="关闭启动配置"
|
||||||
>
|
>
|
||||||
×
|
×
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12, alignItems: 'center', paddingRight: 56 }}>
|
||||||
|
<div style={{ display: 'grid', gap: 4 }}>
|
||||||
|
<div style={{ fontSize: 14, fontWeight: 800, color: '#111111' }}>启动配置</div>
|
||||||
|
<div style={{ fontSize: 11, color: '#6B7280' }}>
|
||||||
|
配置本次任务的启动参数与调度方式
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
border: '1px solid #E5EAF1',
|
||||||
|
borderRadius: 12,
|
||||||
|
background: '#FCFDFE',
|
||||||
|
padding: 14,
|
||||||
|
display: 'grid',
|
||||||
|
gap: 12
|
||||||
|
}}>
|
||||||
|
<div style={{ fontSize: 12, fontWeight: 800, color: '#111111' }}>自选股</div>
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
gap: 8,
|
||||||
|
minHeight: 40,
|
||||||
|
padding: '2px 0'
|
||||||
|
}}>
|
||||||
|
{watchlistSymbols.map((symbol) => (
|
||||||
|
<button
|
||||||
|
key={symbol}
|
||||||
|
onClick={() => onWatchlistRemove(symbol)}
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 6,
|
||||||
|
padding: '6px 10px',
|
||||||
|
borderRadius: 999,
|
||||||
|
border: '1px solid #D0D7DE',
|
||||||
|
background: '#FFFFFF',
|
||||||
|
color: '#111111',
|
||||||
|
fontSize: '11px',
|
||||||
|
fontWeight: 700,
|
||||||
|
cursor: 'pointer'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>{symbol}</span>
|
||||||
|
<span style={{ color: '#777777' }}>×</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{watchlistSymbols.length === 0 && (
|
||||||
|
<div style={{ fontSize: '11px', color: '#888888', padding: '8px 2px' }}>
|
||||||
|
还没有股票,输入代码后回车添加
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
|
<input
|
||||||
|
value={watchlistInputValue}
|
||||||
|
onChange={(e) => onWatchlistInputChange(e.target.value)}
|
||||||
|
onKeyDown={onWatchlistInputKeyDown}
|
||||||
|
placeholder="输入股票代码,回车添加"
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
padding: '9px 10px',
|
||||||
|
borderRadius: 8,
|
||||||
|
border: '1px solid #D0D7DE',
|
||||||
|
background: '#FFFFFF',
|
||||||
|
color: '#111111',
|
||||||
|
fontSize: '12px',
|
||||||
|
fontFamily: '"Courier New", monospace'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={onWatchlistAdd}
|
||||||
|
style={{
|
||||||
|
padding: '9px 12px',
|
||||||
|
borderRadius: 8,
|
||||||
|
border: '1px solid #D0D7DE',
|
||||||
|
background: '#FFFFFF',
|
||||||
|
color: '#111111',
|
||||||
|
fontSize: '11px',
|
||||||
|
fontWeight: 700,
|
||||||
|
cursor: 'pointer'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
添加
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
|
||||||
|
{watchlistSuggestions.map((symbol) => {
|
||||||
|
const active = watchlistSymbols.includes(symbol);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={symbol}
|
||||||
|
onClick={() => onWatchlistSuggestionClick(symbol)}
|
||||||
|
disabled={active}
|
||||||
|
style={{
|
||||||
|
padding: '5px 8px',
|
||||||
|
borderRadius: 999,
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: active ? '#B6E3C5' : '#D0D7DE',
|
||||||
|
background: active ? '#ECFDF3' : '#FFFFFF',
|
||||||
|
color: active ? '#157347' : '#4A5568',
|
||||||
|
fontSize: '10px',
|
||||||
|
fontWeight: 700,
|
||||||
|
cursor: active ? 'default' : 'pointer'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{symbol}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||||
|
<button
|
||||||
|
onClick={onWatchlistRestoreCurrent}
|
||||||
|
style={{
|
||||||
|
padding: '8px 10px',
|
||||||
|
borderRadius: 8,
|
||||||
|
border: '1px solid #D0D7DE',
|
||||||
|
background: '#FFFFFF',
|
||||||
|
color: '#111111',
|
||||||
|
fontSize: '11px',
|
||||||
|
fontWeight: 700,
|
||||||
|
cursor: 'pointer'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
恢复当前
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onWatchlistRestoreDefault}
|
||||||
|
style={{
|
||||||
|
padding: '8px 10px',
|
||||||
|
borderRadius: 8,
|
||||||
|
border: '1px solid #D0D7DE',
|
||||||
|
background: '#FFFFFF',
|
||||||
|
color: '#111111',
|
||||||
|
fontSize: '11px',
|
||||||
|
fontWeight: 700,
|
||||||
|
cursor: 'pointer'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
恢复默认
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
border: '1px solid #E5EAF1',
|
||||||
|
borderRadius: 12,
|
||||||
|
background: '#FCFDFE',
|
||||||
|
padding: 14,
|
||||||
|
display: 'grid',
|
||||||
|
gap: 12
|
||||||
|
}}>
|
||||||
|
<div style={{ fontSize: 12, fontWeight: 800, color: '#111111' }}>调度参数</div>
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
|
||||||
<label style={{ display: 'grid', gap: 4 }}>
|
<label style={{ display: 'grid', gap: 4 }}>
|
||||||
<span style={{ fontSize: '10px', color: '#4B5563', fontWeight: 700 }}>调度模式</span>
|
<span style={{ fontSize: '10px', color: '#4B5563', fontWeight: 700 }}>调度模式</span>
|
||||||
@@ -87,15 +281,15 @@ export default function RuntimeSettingsPanel({
|
|||||||
onChange={(e) => onScheduleModeChange(e.target.value)}
|
onChange={(e) => onScheduleModeChange(e.target.value)}
|
||||||
style={{
|
style={{
|
||||||
padding: '9px 10px',
|
padding: '9px 10px',
|
||||||
borderRadius: 6,
|
borderRadius: 8,
|
||||||
border: '1px solid #D0D7DE',
|
border: '1px solid #D0D7DE',
|
||||||
background: '#FFFFFF',
|
background: '#FFFFFF',
|
||||||
color: '#111111',
|
color: '#111111',
|
||||||
fontSize: '12px'
|
fontSize: '12px'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<option value="daily">daily</option>
|
<option value="daily">每日定时</option>
|
||||||
<option value="intraday">intraday</option>
|
<option value="intraday">盘中轮询</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
@@ -108,7 +302,7 @@ export default function RuntimeSettingsPanel({
|
|||||||
onChange={(e) => onIntervalMinutesChange(e.target.value)}
|
onChange={(e) => onIntervalMinutesChange(e.target.value)}
|
||||||
style={{
|
style={{
|
||||||
padding: '9px 10px',
|
padding: '9px 10px',
|
||||||
borderRadius: 6,
|
borderRadius: 8,
|
||||||
border: '1px solid #D0D7DE',
|
border: '1px solid #D0D7DE',
|
||||||
background: '#FFFFFF',
|
background: '#FFFFFF',
|
||||||
color: '#111111',
|
color: '#111111',
|
||||||
@@ -120,7 +314,7 @@ export default function RuntimeSettingsPanel({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<label style={{ display: 'grid', gap: 4 }}>
|
<label style={{ display: 'grid', gap: 4 }}>
|
||||||
<span style={{ fontSize: '10px', color: '#4B5563', fontWeight: 700 }}>Daily 时间 (NYSE)</span>
|
<span style={{ fontSize: '10px', color: '#4B5563', fontWeight: 700 }}>每日定时时间 (NYSE)</span>
|
||||||
<input
|
<input
|
||||||
type="time"
|
type="time"
|
||||||
value={triggerTime}
|
value={triggerTime}
|
||||||
@@ -128,7 +322,7 @@ export default function RuntimeSettingsPanel({
|
|||||||
disabled={scheduleMode !== 'daily'}
|
disabled={scheduleMode !== 'daily'}
|
||||||
style={{
|
style={{
|
||||||
padding: '9px 10px',
|
padding: '9px 10px',
|
||||||
borderRadius: 6,
|
borderRadius: 8,
|
||||||
border: '1px solid #D0D7DE',
|
border: '1px solid #D0D7DE',
|
||||||
background: scheduleMode === 'daily' ? '#FFFFFF' : '#F3F4F6',
|
background: scheduleMode === 'daily' ? '#FFFFFF' : '#F3F4F6',
|
||||||
color: '#111111',
|
color: '#111111',
|
||||||
@@ -147,7 +341,7 @@ export default function RuntimeSettingsPanel({
|
|||||||
onChange={(e) => onMaxCommCyclesChange(e.target.value)}
|
onChange={(e) => onMaxCommCyclesChange(e.target.value)}
|
||||||
style={{
|
style={{
|
||||||
padding: '9px 10px',
|
padding: '9px 10px',
|
||||||
borderRadius: 6,
|
borderRadius: 8,
|
||||||
border: '1px solid #D0D7DE',
|
border: '1px solid #D0D7DE',
|
||||||
background: '#FFFFFF',
|
background: '#FFFFFF',
|
||||||
color: '#111111',
|
color: '#111111',
|
||||||
@@ -157,12 +351,78 @@ export default function RuntimeSettingsPanel({
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 8 }}>
|
<label style={{ display: 'grid', gap: 4 }}>
|
||||||
|
<span style={{ fontSize: '10px', color: '#4B5563', fontWeight: 700 }}>初始资金</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
step="1000"
|
||||||
|
value={initialCash}
|
||||||
|
onChange={(e) => onInitialCashChange(e.target.value)}
|
||||||
|
style={{
|
||||||
|
padding: '9px 10px',
|
||||||
|
borderRadius: 8,
|
||||||
|
border: '1px solid #D0D7DE',
|
||||||
|
background: '#FFFFFF',
|
||||||
|
color: '#111111',
|
||||||
|
fontSize: '12px',
|
||||||
|
fontFamily: '"Courier New", monospace'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label style={{ display: 'grid', gap: 4 }}>
|
||||||
|
<span style={{ fontSize: '10px', color: '#4B5563', fontWeight: 700 }}>保证金要求</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="0.01"
|
||||||
|
value={marginRequirement}
|
||||||
|
onChange={(e) => onMarginRequirementChange(e.target.value)}
|
||||||
|
style={{
|
||||||
|
padding: '9px 10px',
|
||||||
|
borderRadius: 8,
|
||||||
|
border: '1px solid #D0D7DE',
|
||||||
|
background: '#FFFFFF',
|
||||||
|
color: '#111111',
|
||||||
|
fontSize: '12px',
|
||||||
|
fontFamily: '"Courier New", monospace'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label style={{ display: 'flex', alignItems: 'center', gap: 10, marginTop: 2 }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={enableMemory}
|
||||||
|
onChange={(e) => onEnableMemoryChange(e.target.checked)}
|
||||||
|
style={{
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
accentColor: '#0D47A1',
|
||||||
|
cursor: 'pointer'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span style={{ fontSize: '11px', color: '#111111', fontWeight: 700 }}>启用长期记忆</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
border: '1px solid #E5EAF1',
|
||||||
|
borderRadius: 12,
|
||||||
|
background: '#FCFDFE',
|
||||||
|
padding: 14,
|
||||||
|
display: 'grid',
|
||||||
|
gap: 10
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||||
|
<div style={{ fontSize: 12, fontWeight: 800, color: '#111111' }}>操作</div>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'flex-end', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||||
<button
|
<button
|
||||||
onClick={onRestoreDefaults}
|
onClick={onRestoreDefaults}
|
||||||
style={{
|
style={{
|
||||||
padding: '9px 12px',
|
padding: '9px 12px',
|
||||||
borderRadius: 6,
|
borderRadius: 8,
|
||||||
border: '1px solid #D0D7DE',
|
border: '1px solid #D0D7DE',
|
||||||
background: '#FFFFFF',
|
background: '#FFFFFF',
|
||||||
color: '#111111',
|
color: '#111111',
|
||||||
@@ -178,7 +438,7 @@ export default function RuntimeSettingsPanel({
|
|||||||
disabled={!isConnected || isSaving}
|
disabled={!isConnected || isSaving}
|
||||||
style={{
|
style={{
|
||||||
padding: '9px 14px',
|
padding: '9px 14px',
|
||||||
borderRadius: 6,
|
borderRadius: 8,
|
||||||
border: '1px solid #1565C0',
|
border: '1px solid #1565C0',
|
||||||
background: isConnected && !isSaving ? '#0D47A1' : '#94A3B8',
|
background: isConnected && !isSaving ? '#0D47A1' : '#94A3B8',
|
||||||
color: '#FFFFFF',
|
color: '#FFFFFF',
|
||||||
@@ -188,9 +448,10 @@ export default function RuntimeSettingsPanel({
|
|||||||
cursor: isConnected && !isSaving ? 'pointer' : 'not-allowed'
|
cursor: isConnected && !isSaving ? 'pointer' : 'not-allowed'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{isSaving ? '保存中' : '保存运行配置'}
|
{isSaving ? '启动中' : '启动任务'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{feedback && (
|
{feedback && (
|
||||||
<span style={{
|
<span style={{
|
||||||
@@ -201,47 +462,10 @@ export default function RuntimeSettingsPanel({
|
|||||||
{feedback.text}
|
{feedback.text}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{runtimeConfig && (
|
|
||||||
<div style={{
|
|
||||||
borderTop: '1px solid #E5E7EB',
|
|
||||||
paddingTop: 12,
|
|
||||||
display: 'grid',
|
|
||||||
gap: 8
|
|
||||||
}}>
|
|
||||||
<div>
|
|
||||||
<div style={{ fontSize: '12px', fontWeight: 700, color: '#111111', letterSpacing: '0.3px' }}>
|
|
||||||
当前生效配置
|
|
||||||
</div>
|
|
||||||
<div style={{ fontSize: '11px', color: '#666666', marginTop: 2 }}>
|
|
||||||
这里显示当前 run 已加载并生效的参数
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{
|
|
||||||
border: '1px solid #E5E7EB',
|
|
||||||
background: '#F8FAFC',
|
|
||||||
borderRadius: 6,
|
|
||||||
padding: '10px 12px',
|
|
||||||
display: 'grid',
|
|
||||||
gap: 6,
|
|
||||||
fontSize: '11px',
|
|
||||||
fontFamily: '"Courier New", monospace',
|
|
||||||
color: '#111111'
|
|
||||||
}}>
|
|
||||||
<div>tickers: {(runtimeConfig.tickers || []).join(', ') || '-'}</div>
|
|
||||||
<div>schedule_mode: {runtimeConfig.schedule_mode || '-'}</div>
|
|
||||||
<div>interval_minutes: {runtimeConfig.interval_minutes ?? '-'}</div>
|
|
||||||
<div>trigger_time: {runtimeConfig.trigger_time || '-'}</div>
|
|
||||||
<div>max_comm_cycles: {runtimeConfig.max_comm_cycles ?? '-'}</div>
|
|
||||||
<div>initial_cash: {runtimeConfig.initial_cash ?? '-'}</div>
|
|
||||||
<div>margin_requirement: {runtimeConfig.margin_requirement ?? '-'}</div>
|
|
||||||
<div>enable_memory: {String(runtimeConfig.enable_memory ?? false)}</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
), document.body)}
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
556
frontend/src/components/RuntimeView.jsx
Normal file
556
frontend/src/components/RuntimeView.jsx
Normal file
@@ -0,0 +1,556 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import {
|
||||||
|
approvePendingApproval,
|
||||||
|
denyPendingApproval,
|
||||||
|
loadAllRuntimeState
|
||||||
|
} from '../services/runtimeApi';
|
||||||
|
|
||||||
|
const AUTO_REFRESH_MS = 5000;
|
||||||
|
|
||||||
|
const STATUS_LABELS = {
|
||||||
|
idle: '空闲',
|
||||||
|
registered: '已注册',
|
||||||
|
initializing: '初始化中',
|
||||||
|
ready: '就绪',
|
||||||
|
running: '运行中',
|
||||||
|
analysis_in_progress: '分析中',
|
||||||
|
risk_review_in_progress: '风控处理中',
|
||||||
|
discussion_in_progress: '会商中',
|
||||||
|
decision_in_progress: '决策中',
|
||||||
|
execution_in_progress: '执行中',
|
||||||
|
settlement_in_progress: '结算中',
|
||||||
|
reflection_in_progress: '复盘中',
|
||||||
|
waiting_approval: '等待审批',
|
||||||
|
approved: '已批准',
|
||||||
|
denied: '已拒绝',
|
||||||
|
completed: '已完成',
|
||||||
|
error: '异常',
|
||||||
|
stopped: '已停止'
|
||||||
|
};
|
||||||
|
|
||||||
|
const EVENT_FILTER_OPTIONS = [
|
||||||
|
{ value: 'all', label: '全部事件' },
|
||||||
|
{ value: 'cycle', label: '运行周期' },
|
||||||
|
{ value: 'approval', label: '审批事件' }
|
||||||
|
];
|
||||||
|
|
||||||
|
function metricCard(label, value, accent, helper = null) {
|
||||||
|
return (
|
||||||
|
<div className="stat-card">
|
||||||
|
<div className="stat-card-label">
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
<div className="stat-card-value" style={{ color: accent }}>
|
||||||
|
{value}
|
||||||
|
</div>
|
||||||
|
{helper && (
|
||||||
|
<div style={{ marginTop: 8, fontSize: 11, color: '#666666', lineHeight: 1.5 }}>
|
||||||
|
{helper}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveApprovalTone(approval) {
|
||||||
|
const findings = Array.isArray(approval.findings) ? approval.findings : [];
|
||||||
|
const levels = findings.map((item) => item?.severity).filter(Boolean);
|
||||||
|
if (levels.includes('critical')) {
|
||||||
|
return { border: '#7F1D1D', bg: '#FEF2F2', text: '#991B1B', badgeBg: '#FECACA' };
|
||||||
|
}
|
||||||
|
if (levels.includes('high')) {
|
||||||
|
return { border: '#9A3412', bg: '#FFF7ED', text: '#C2410C', badgeBg: '#FED7AA' };
|
||||||
|
}
|
||||||
|
if (levels.includes('medium')) {
|
||||||
|
return { border: '#92400E', bg: '#FFFBEB', text: '#B45309', badgeBg: '#FDE68A' };
|
||||||
|
}
|
||||||
|
return { border: '#D1D5DB', bg: '#FCFCFC', text: '#374151', badgeBg: '#E5E7EB' };
|
||||||
|
}
|
||||||
|
|
||||||
|
function sectionTitle(label, action = null) {
|
||||||
|
return (
|
||||||
|
<div className="section-header" style={{ marginBottom: 0 }}>
|
||||||
|
<div className="section-title" style={{ fontSize: 14 }}>
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
{action}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatStatusLabel(status) {
|
||||||
|
if (!status) {
|
||||||
|
return '-';
|
||||||
|
}
|
||||||
|
return STATUS_LABELS[status] || status.replace(/_/g, ' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSessionLabel(sessionId) {
|
||||||
|
return sessionId || '无会话';
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatEventLabel(eventName) {
|
||||||
|
if (!eventName) {
|
||||||
|
return '-';
|
||||||
|
}
|
||||||
|
|
||||||
|
const [group, action] = String(eventName).split(':');
|
||||||
|
if (group === 'cycle') {
|
||||||
|
if (action === 'start') return '周期开始';
|
||||||
|
if (action === 'complete') return '周期完成';
|
||||||
|
if (action === 'error') return '周期异常';
|
||||||
|
return '运行周期';
|
||||||
|
}
|
||||||
|
if (group === 'approval') {
|
||||||
|
if (action === 'created') return '创建审批';
|
||||||
|
if (action === 'approved') return '审批通过';
|
||||||
|
if (action === 'denied') return '审批拒绝';
|
||||||
|
if (action === 'expired') return '审批超时';
|
||||||
|
return '审批事件';
|
||||||
|
}
|
||||||
|
if (group === 'agent') {
|
||||||
|
if (action === 'status') return '状态更新';
|
||||||
|
if (action === 'registered') return '注册 Agent';
|
||||||
|
return 'Agent 事件';
|
||||||
|
}
|
||||||
|
|
||||||
|
return String(eventName).replace(/_/g, ' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RuntimeView() {
|
||||||
|
const [runtimeState, setRuntimeState] = useState(null);
|
||||||
|
const [runtimeError, setRuntimeError] = useState(null);
|
||||||
|
const [isRuntimeLoading, setIsRuntimeLoading] = useState(false);
|
||||||
|
const [approvalActionId, setApprovalActionId] = useState(null);
|
||||||
|
const [autoRefreshEnabled, setAutoRefreshEnabled] = useState(true);
|
||||||
|
const [eventFilter, setEventFilter] = useState('all');
|
||||||
|
|
||||||
|
const refreshRuntimeState = () => {
|
||||||
|
setIsRuntimeLoading(true);
|
||||||
|
loadAllRuntimeState(
|
||||||
|
(state) => {
|
||||||
|
setRuntimeState(state);
|
||||||
|
setRuntimeError(null);
|
||||||
|
setIsRuntimeLoading(false);
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
setRuntimeError(error.message || '无法加载运行状态');
|
||||||
|
setIsRuntimeLoading(false);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
refreshRuntimeState();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!autoRefreshEnabled) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timer = window.setInterval(() => {
|
||||||
|
refreshRuntimeState();
|
||||||
|
}, AUTO_REFRESH_MS);
|
||||||
|
|
||||||
|
return () => window.clearInterval(timer);
|
||||||
|
}, [autoRefreshEnabled]);
|
||||||
|
|
||||||
|
const handleApprovalAction = async (approvalId, action) => {
|
||||||
|
setApprovalActionId(approvalId);
|
||||||
|
try {
|
||||||
|
if (action === 'approve') {
|
||||||
|
await approvePendingApproval(approvalId);
|
||||||
|
} else {
|
||||||
|
await denyPendingApproval(approvalId);
|
||||||
|
}
|
||||||
|
refreshRuntimeState();
|
||||||
|
} catch (error) {
|
||||||
|
setRuntimeError(error.message || '审批操作失败');
|
||||||
|
setIsRuntimeLoading(false);
|
||||||
|
} finally {
|
||||||
|
setApprovalActionId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const agents = runtimeState?.agents || [];
|
||||||
|
const approvals = runtimeState?.approvals || [];
|
||||||
|
const events = runtimeState?.events || [];
|
||||||
|
const activeAgentsCount = agents.filter((agent) => agent.status && agent.status !== 'idle').length;
|
||||||
|
const visibleEvents = events
|
||||||
|
.filter((event) => eventFilter === 'all' || event.event.startsWith(eventFilter))
|
||||||
|
.slice()
|
||||||
|
.reverse();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="performance-page" style={{ height: '100%', minHeight: 0 }}>
|
||||||
|
<div className="section">
|
||||||
|
<div className="section-header">
|
||||||
|
<div>
|
||||||
|
<div className="section-title" style={{ fontSize: 18 }}>
|
||||||
|
运行态控制台
|
||||||
|
</div>
|
||||||
|
<div style={{
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#666666',
|
||||||
|
marginTop: 4,
|
||||||
|
maxWidth: 760,
|
||||||
|
lineHeight: 1.5
|
||||||
|
}}>
|
||||||
|
查看当前运行上下文、分析师状态、待审批请求与近期事件。这里是监控面板,不再和运行设置挤在同一个小弹层里。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={refreshRuntimeState}
|
||||||
|
disabled={isRuntimeLoading}
|
||||||
|
style={{
|
||||||
|
padding: '10px 14px',
|
||||||
|
borderRadius: 6,
|
||||||
|
border: '1px solid #111111',
|
||||||
|
background: isRuntimeLoading ? '#8A8A8A' : '#111111',
|
||||||
|
color: '#FFFFFF',
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 700,
|
||||||
|
letterSpacing: '0.4px',
|
||||||
|
cursor: isRuntimeLoading ? 'not-allowed' : 'pointer',
|
||||||
|
whiteSpace: 'nowrap'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isRuntimeLoading ? '刷新中' : '刷新运行态'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="section">
|
||||||
|
<div className="stats-grid" style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))' }}>
|
||||||
|
{metricCard('活跃 Agent', activeAgentsCount, '#2563EB', `共 ${agents.length} 个 agent 已注册`)}
|
||||||
|
{metricCard('待审批', approvals.length, approvals.length > 0 ? '#C2410C' : '#059669', approvals.length > 0 ? '需要人工处理' : '当前无待处理审批')}
|
||||||
|
{metricCard('运行事件', events.length, '#111111', '最近运行阶段和状态变化')}
|
||||||
|
<div className="stat-card">
|
||||||
|
<div className="stat-card-label">
|
||||||
|
自动刷新
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setAutoRefreshEnabled((value) => !value)}
|
||||||
|
style={{
|
||||||
|
padding: '10px 12px',
|
||||||
|
border: '1px solid #000000',
|
||||||
|
background: autoRefreshEnabled ? '#000000' : '#FFFFFF',
|
||||||
|
color: autoRefreshEnabled ? '#FFFFFF' : '#000000',
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 700,
|
||||||
|
letterSpacing: '0.5px',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
cursor: 'pointer'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{autoRefreshEnabled ? `开启 / ${AUTO_REFRESH_MS / 1000}秒` : '关闭'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{runtimeError && (
|
||||||
|
<div className="section" style={{
|
||||||
|
borderColor: '#FF1744',
|
||||||
|
background: '#FFF5F7',
|
||||||
|
color: '#B91C1C',
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 700
|
||||||
|
}}>
|
||||||
|
{runtimeError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
display: 'grid',
|
||||||
|
gap: 20,
|
||||||
|
alignContent: 'start'
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'minmax(320px, 0.95fr) minmax(360px, 1.25fr)',
|
||||||
|
gap: 20,
|
||||||
|
alignItems: 'start'
|
||||||
|
}}>
|
||||||
|
<section className="section" style={{ display: 'grid', gap: 12, marginBottom: 0 }}>
|
||||||
|
{sectionTitle('运行上下文')}
|
||||||
|
{runtimeState?.context ? (
|
||||||
|
<div style={{
|
||||||
|
border: '1px solid #000000',
|
||||||
|
background: '#FAFAFA',
|
||||||
|
padding: 12,
|
||||||
|
display: 'grid',
|
||||||
|
gap: 10
|
||||||
|
}}>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 10, color: '#6B7280', textTransform: 'uppercase' }}>配置名</div>
|
||||||
|
<div style={{ fontSize: 18, color: '#111111', fontWeight: 800, marginTop: 3 }}>
|
||||||
|
{runtimeState.context.config_name}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 10, color: '#6B7280', textTransform: 'uppercase' }}>运行目录</div>
|
||||||
|
<div style={{ fontSize: 11, color: '#111111', lineHeight: 1.5, marginTop: 3, wordBreak: 'break-all' }}>
|
||||||
|
{runtimeState.context.run_dir}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 10, color: '#6B7280', textTransform: 'uppercase' }}>启动参数</div>
|
||||||
|
<pre style={{
|
||||||
|
margin: '6px 0 0',
|
||||||
|
whiteSpace: 'pre-wrap',
|
||||||
|
wordBreak: 'break-word',
|
||||||
|
fontSize: 11,
|
||||||
|
lineHeight: 1.7,
|
||||||
|
color: '#111111',
|
||||||
|
fontFamily: '"Courier New", monospace'
|
||||||
|
}}>
|
||||||
|
{JSON.stringify(runtimeState.context.bootstrap_values || {}, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ fontSize: 12, color: '#9CA3AF' }}>暂无运行上下文</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="section" style={{ display: 'grid', gap: 12, marginBottom: 0 }}>
|
||||||
|
{sectionTitle('待审批请求')}
|
||||||
|
<div style={{
|
||||||
|
display: 'grid',
|
||||||
|
gap: 10,
|
||||||
|
maxHeight: 640,
|
||||||
|
overflowY: 'auto',
|
||||||
|
paddingRight: 4
|
||||||
|
}}>
|
||||||
|
{approvals.length ? approvals.map((approval) => {
|
||||||
|
const tone = resolveApprovalTone(approval);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={approval.approval_id}
|
||||||
|
style={{
|
||||||
|
border: `1px solid ${tone.border}`,
|
||||||
|
background: '#FFFFFF',
|
||||||
|
padding: 12,
|
||||||
|
display: 'grid',
|
||||||
|
gap: 8
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
|
||||||
|
<div style={{ fontSize: 13, fontWeight: 800, color: '#111111' }}>
|
||||||
|
{approval.tool_name}
|
||||||
|
</div>
|
||||||
|
<div style={{
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: 700,
|
||||||
|
letterSpacing: '0.5px',
|
||||||
|
padding: '4px 6px',
|
||||||
|
background: tone.badgeBg,
|
||||||
|
color: tone.text,
|
||||||
|
border: `1px solid ${tone.border}`,
|
||||||
|
textTransform: 'uppercase'
|
||||||
|
}}>
|
||||||
|
{formatStatusLabel(approval.status)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 11, color: '#6B7280', lineHeight: 1.5 }}>
|
||||||
|
{approval.agent_id} · {approval.workspace_id} · {formatSessionLabel(approval.session_id)}
|
||||||
|
</div>
|
||||||
|
{approval.tool_input && (
|
||||||
|
<pre style={{
|
||||||
|
margin: 0,
|
||||||
|
padding: 10,
|
||||||
|
background: '#FAFAFA',
|
||||||
|
border: '1px solid #000000',
|
||||||
|
fontSize: 11,
|
||||||
|
lineHeight: 1.6,
|
||||||
|
color: '#111111',
|
||||||
|
whiteSpace: 'pre-wrap',
|
||||||
|
wordBreak: 'break-word',
|
||||||
|
fontFamily: '"Courier New", monospace'
|
||||||
|
}}>
|
||||||
|
{JSON.stringify(approval.tool_input, null, 2)}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
{approval.findings?.length > 0 && (
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
|
||||||
|
{approval.findings.map((finding, index) => (
|
||||||
|
<span
|
||||||
|
key={`${approval.approval_id}-finding-${index}`}
|
||||||
|
style={{
|
||||||
|
padding: '4px 6px',
|
||||||
|
background: '#FFFFFF',
|
||||||
|
border: `1px solid ${tone.border}`,
|
||||||
|
color: tone.text,
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: 700,
|
||||||
|
textTransform: 'uppercase'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{finding.severity}: {finding.message}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
|
||||||
|
<button
|
||||||
|
onClick={() => handleApprovalAction(approval.approval_id, 'deny')}
|
||||||
|
disabled={approvalActionId === approval.approval_id}
|
||||||
|
style={{
|
||||||
|
padding: '8px 10px',
|
||||||
|
border: '1px solid #000000',
|
||||||
|
background: '#FFFFFF',
|
||||||
|
color: '#000000',
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 700,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
cursor: approvalActionId === approval.approval_id ? 'not-allowed' : 'pointer'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
拒绝
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleApprovalAction(approval.approval_id, 'approve')}
|
||||||
|
disabled={approvalActionId === approval.approval_id}
|
||||||
|
style={{
|
||||||
|
padding: '8px 10px',
|
||||||
|
border: '1px solid #000000',
|
||||||
|
background: '#000000',
|
||||||
|
color: '#FFFFFF',
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 700,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
cursor: approvalActionId === approval.approval_id ? 'not-allowed' : 'pointer'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
批准
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}) : (
|
||||||
|
<div style={{
|
||||||
|
border: '1px dashed #999999',
|
||||||
|
padding: 16,
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#666666',
|
||||||
|
background: '#FAFAFA'
|
||||||
|
}}>
|
||||||
|
当前无待审批请求
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'minmax(320px, 1fr) minmax(360px, 1fr)',
|
||||||
|
gap: 20,
|
||||||
|
alignItems: 'start'
|
||||||
|
}}>
|
||||||
|
<section className="section" style={{ display: 'grid', gap: 12, marginBottom: 0 }}>
|
||||||
|
{sectionTitle('Agent 状态')}
|
||||||
|
<div style={{
|
||||||
|
display: 'grid',
|
||||||
|
gap: 8,
|
||||||
|
maxHeight: 420,
|
||||||
|
overflowY: 'auto',
|
||||||
|
paddingRight: 4
|
||||||
|
}}>
|
||||||
|
{runtimeState?.agents?.length ? runtimeState.agents.map((agent) => (
|
||||||
|
<div
|
||||||
|
key={agent.agent_id}
|
||||||
|
style={{
|
||||||
|
border: '1px solid #000000',
|
||||||
|
background: '#FAFAFA',
|
||||||
|
padding: 10,
|
||||||
|
display: 'grid',
|
||||||
|
gap: 4
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 8 }}>
|
||||||
|
<span style={{ fontSize: 12, fontWeight: 700, color: '#111111' }}>{agent.agent_id}</span>
|
||||||
|
<span style={{ fontSize: 11, color: '#2563EB', fontFamily: '"Courier New", monospace' }}>{formatStatusLabel(agent.status)}</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 10, color: '#6B7280', lineHeight: 1.5 }}>
|
||||||
|
会话: {formatSessionLabel(agent.last_session)}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 10, color: '#6B7280', lineHeight: 1.5 }}>
|
||||||
|
更新时间: {agent.last_updated}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)) : (
|
||||||
|
<div style={{ fontSize: 12, color: '#9CA3AF' }}>暂无 agent 状态</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="section" style={{ display: 'grid', gap: 12, marginBottom: 0 }}>
|
||||||
|
{sectionTitle(
|
||||||
|
'近期事件',
|
||||||
|
<select
|
||||||
|
value={eventFilter}
|
||||||
|
onChange={(event) => setEventFilter(event.target.value)}
|
||||||
|
style={{
|
||||||
|
padding: '8px 10px',
|
||||||
|
border: '1px solid #000000',
|
||||||
|
background: '#FFFFFF',
|
||||||
|
color: '#000000',
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 700,
|
||||||
|
textTransform: 'uppercase'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{EVENT_FILTER_OPTIONS.map((option) => (
|
||||||
|
<option key={option.value} value={option.value}>{option.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
<div style={{
|
||||||
|
display: 'grid',
|
||||||
|
gap: 8,
|
||||||
|
maxHeight: 420,
|
||||||
|
overflowY: 'auto',
|
||||||
|
paddingRight: 4
|
||||||
|
}}>
|
||||||
|
{visibleEvents.length ? visibleEvents.map((event, index) => (
|
||||||
|
<div
|
||||||
|
key={`${event.timestamp}-${event.event}-${index}`}
|
||||||
|
style={{
|
||||||
|
border: '1px solid #000000',
|
||||||
|
background: '#FAFAFA',
|
||||||
|
padding: 10,
|
||||||
|
display: 'grid',
|
||||||
|
gap: 4
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 8 }}>
|
||||||
|
<span style={{ fontSize: 12, fontWeight: 700, color: '#111111' }}>{formatEventLabel(event.event)}</span>
|
||||||
|
<span style={{ fontSize: 10, color: '#6B7280', fontFamily: '"Courier New", monospace' }}>{formatSessionLabel(event.session)}</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 10, color: '#6B7280' }}>{event.timestamp}</div>
|
||||||
|
{event.details && Object.keys(event.details).length > 0 && (
|
||||||
|
<pre style={{
|
||||||
|
margin: 0,
|
||||||
|
fontSize: 10,
|
||||||
|
lineHeight: 1.6,
|
||||||
|
color: '#374151',
|
||||||
|
whiteSpace: 'pre-wrap',
|
||||||
|
wordBreak: 'break-word',
|
||||||
|
fontFamily: '"Courier New", monospace'
|
||||||
|
}}>
|
||||||
|
{JSON.stringify(event.details, null, 2)}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)) : (
|
||||||
|
<div style={{ fontSize: 12, color: '#9CA3AF' }}>当前筛选条件下暂无运行事件</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
83
frontend/src/services/runtimeApi.js
Normal file
83
frontend/src/services/runtimeApi.js
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import { startTransition } from 'react';
|
||||||
|
|
||||||
|
const BASE_PATH = '/api';
|
||||||
|
|
||||||
|
async function safeFetch(endpoint) {
|
||||||
|
const response = await fetch(`${BASE_PATH}${endpoint}`);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(await response.text());
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function safeRequest(endpoint, options = {}) {
|
||||||
|
const response = await fetch(`${BASE_PATH}${endpoint}`, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...(options.headers || {})
|
||||||
|
},
|
||||||
|
...options
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(await response.text());
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchRuntimeContext() {
|
||||||
|
return safeFetch('/runtime/context');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchRuntimeAgents() {
|
||||||
|
return safeFetch('/runtime/agents');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchRuntimeEvents() {
|
||||||
|
return safeFetch('/runtime/events');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchPendingApprovals() {
|
||||||
|
return safeFetch('/guard/pending');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function approvePendingApproval(approvalId) {
|
||||||
|
return safeRequest('/guard/approve', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
approval_id: approvalId,
|
||||||
|
one_time: true,
|
||||||
|
expires_in_minutes: 30
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function denyPendingApproval(approvalId, reason = 'Rejected from runtime panel') {
|
||||||
|
return safeRequest('/guard/deny', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
approval_id: approvalId,
|
||||||
|
reason
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadAllRuntimeState(onSuccess, onError) {
|
||||||
|
startTransition(async () => {
|
||||||
|
try {
|
||||||
|
const [context, agents, approvals, events] = await Promise.all([
|
||||||
|
fetchRuntimeContext(),
|
||||||
|
fetchRuntimeAgents(),
|
||||||
|
fetchPendingApprovals(),
|
||||||
|
fetchRuntimeEvents()
|
||||||
|
]);
|
||||||
|
onSuccess({
|
||||||
|
context,
|
||||||
|
agents: agents.agents,
|
||||||
|
approvals: approvals.approvals,
|
||||||
|
events: events.events
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
onError(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -818,6 +818,9 @@ export default function GlobalStyles() {
|
|||||||
left: 50%;
|
left: 50%;
|
||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.replay-button {
|
.replay-button {
|
||||||
@@ -1053,34 +1056,38 @@ export default function GlobalStyles() {
|
|||||||
transform: translateX(-66.666%);
|
transform: translateX(-66.666%);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Four-view slider (Room / Explain / Chart / Statistics) */
|
/* Five-view slider (Traders / Room / Explain / Chart / Statistics) */
|
||||||
.view-slider-four {
|
.view-slider-five {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 40px;
|
top: 40px;
|
||||||
width: 400%;
|
width: 500%;
|
||||||
height: calc(100% - 40px);
|
height: calc(100% - 40px);
|
||||||
display: flex;
|
display: flex;
|
||||||
transition: transform 1.6s cubic-bezier(0.34, 1.56, 0.64, 1);
|
transition: transform 1.6s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.view-slider-four.normal-speed {
|
.view-slider-five.normal-speed {
|
||||||
transition: transform 0.8s cubic-bezier(0.34, 1.56, 0.64, 1);
|
transition: transform 0.8s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.view-slider-four.show-room {
|
.view-slider-five.show-traders {
|
||||||
transform: translateX(0);
|
transform: translateX(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
.view-slider-four.show-explain {
|
.view-slider-five.show-room {
|
||||||
transform: translateX(-25%);
|
transform: translateX(-20%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.view-slider-four.show-chart {
|
.view-slider-five.show-explain {
|
||||||
transform: translateX(-50%);
|
transform: translateX(-40%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.view-slider-four.show-statistics {
|
.view-slider-five.show-chart {
|
||||||
transform: translateX(-75%);
|
transform: translateX(-60%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-slider-five.show-statistics {
|
||||||
|
transform: translateX(-80%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.view-panel {
|
.view-panel {
|
||||||
@@ -1092,10 +1099,10 @@ export default function GlobalStyles() {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* View panel for four-view slider */
|
/* View panel for five-view slider */
|
||||||
.view-slider-four .view-panel {
|
.view-slider-five .view-panel {
|
||||||
flex: 0 0 25%;
|
flex: 0 0 20%;
|
||||||
width: 25%;
|
width: 20%;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Chart Tabs - Floating inside chart */
|
/* Chart Tabs - Floating inside chart */
|
||||||
|
|||||||
@@ -1,12 +1,20 @@
|
|||||||
---
|
---
|
||||||
tickers:
|
tickers:
|
||||||
- AAPL
|
- AAPL
|
||||||
- MSFT
|
- MSFT
|
||||||
|
- GOOGL
|
||||||
|
- AMZN
|
||||||
|
- NVDA
|
||||||
|
- META
|
||||||
|
- TSLA
|
||||||
initial_cash: 100000
|
initial_cash: 100000
|
||||||
margin_requirement: 0.0
|
margin_requirement: 0.0
|
||||||
enable_memory: false
|
enable_memory: false
|
||||||
max_comm_cycles: 2
|
max_comm_cycles: 2
|
||||||
agent_overrides: {}
|
agent_overrides: {}
|
||||||
|
schedule_mode: intraday
|
||||||
|
interval_minutes: 60
|
||||||
|
trigger_time: 09:30
|
||||||
---
|
---
|
||||||
|
|
||||||
# Bootstrap
|
# Bootstrap
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
# Agent Guide
|
||||||
|
|
||||||
|
Document how this agent should work, collaborate, and choose tools or skills.
|
||||||
|
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
# Heartbeat
|
||||||
|
|
||||||
|
Optional checklist for periodic review or self-reflection.
|
||||||
|
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
# Memory
|
||||||
|
|
||||||
|
Store durable lessons, heuristics, and reminders for this agent.
|
||||||
|
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
# Profile
|
||||||
|
|
||||||
|
Track this agent's long-lived investment style, preferences, and strengths.
|
||||||
|
|
||||||
4
runs/smoke_fullstack/agents/fundamentals_analyst/SOUL.md
Normal file
4
runs/smoke_fullstack/agents/fundamentals_analyst/SOUL.md
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
# Soul
|
||||||
|
|
||||||
|
Describe the agent's temperament, reasoning posture, and voice.
|
||||||
|
|
||||||
13
runs/smoke_fullstack/agents/fundamentals_analyst/agent.yaml
Normal file
13
runs/smoke_fullstack/agents/fundamentals_analyst/agent.yaml
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
agent_id: fundamentals_analyst
|
||||||
|
prompt_files:
|
||||||
|
- SOUL.md
|
||||||
|
- PROFILE.md
|
||||||
|
- AGENTS.md
|
||||||
|
- POLICY.md
|
||||||
|
- MEMORY.md
|
||||||
|
enabled_skills:
|
||||||
|
- fundamental_review
|
||||||
|
- portfolio_decisioning
|
||||||
|
disabled_skills: []
|
||||||
|
active_tool_groups: []
|
||||||
|
disabled_tool_groups: []
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
---
|
||||||
|
name: 基本面分析
|
||||||
|
description: 当用户要求“基本面分析”“看财务质量”“分析盈利能力”“判断公司质量”或“评估长期盈利韧性”时,应使用此技能。
|
||||||
|
version: 1.0.0
|
||||||
|
---
|
||||||
|
|
||||||
|
# 基本面分析
|
||||||
|
|
||||||
|
当用户希望从公司质量、资产负债表强度、盈利能力或长期盈利韧性出发判断标的时,使用这个技能。
|
||||||
|
|
||||||
|
## 工作流程
|
||||||
|
|
||||||
|
1. 在形成结论前,先检查盈利能力、成长性、财务健康度和经营效率。
|
||||||
|
2. 区分可持续的业务质量和短期噪音。
|
||||||
|
3. 明确指出会推翻当前判断的条件。
|
||||||
|
4. 最终给出清晰的信号、置信度和主要驱动因素。
|
||||||
|
|
||||||
|
## 约束
|
||||||
|
|
||||||
|
- 不要孤立依赖单一指标。
|
||||||
|
- 缺失数据要明确指出。
|
||||||
|
- 当财务质量优劣混杂时,优先给出保守结论。
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
---
|
||||||
|
name: 组合决策
|
||||||
|
description: 整合分析师观点与风险反馈,形成明确的组合层决策。
|
||||||
|
---
|
||||||
|
|
||||||
|
# 组合决策
|
||||||
|
|
||||||
|
当你负责把团队分析转化为最终交易决策时,使用这个技能。
|
||||||
|
|
||||||
|
## 工作流程
|
||||||
|
|
||||||
|
1. 行动前先阅读分析师结论和风险警示。
|
||||||
|
2. 评估当前组合、现金和保证金约束。
|
||||||
|
3. 使用决策工具为每个 ticker 记录一个明确决策。
|
||||||
|
4. 在全部决策记录完成后,总结组合层面的整体理由。
|
||||||
|
|
||||||
|
## 约束
|
||||||
|
|
||||||
|
- 仓位大小必须遵守资金和保证金限制。
|
||||||
|
- 当分析师信心与风险信号不一致时,优先采用更小仓位。
|
||||||
|
- 当任务要求完整决策清单时,不要让任何 ticker 处于未决状态。
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
---
|
||||||
|
name: 基本面分析
|
||||||
|
description: 当用户要求“基本面分析”“看财务质量”“分析盈利能力”“判断公司质量”或“评估长期盈利韧性”时,应使用此技能。
|
||||||
|
version: 1.0.0
|
||||||
|
---
|
||||||
|
|
||||||
|
# 基本面分析
|
||||||
|
|
||||||
|
当用户希望从公司质量、资产负债表强度、盈利能力或长期盈利韧性出发判断标的时,使用这个技能。
|
||||||
|
|
||||||
|
## 工作流程
|
||||||
|
|
||||||
|
1. 在形成结论前,先检查盈利能力、成长性、财务健康度和经营效率。
|
||||||
|
2. 区分可持续的业务质量和短期噪音。
|
||||||
|
3. 明确指出会推翻当前判断的条件。
|
||||||
|
4. 最终给出清晰的信号、置信度和主要驱动因素。
|
||||||
|
|
||||||
|
## 约束
|
||||||
|
|
||||||
|
- 不要孤立依赖单一指标。
|
||||||
|
- 缺失数据要明确指出。
|
||||||
|
- 当财务质量优劣混杂时,优先给出保守结论。
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
---
|
||||||
|
name: 组合决策
|
||||||
|
description: 整合分析师观点与风险反馈,形成明确的组合层决策。
|
||||||
|
---
|
||||||
|
|
||||||
|
# 组合决策
|
||||||
|
|
||||||
|
当你负责把团队分析转化为最终交易决策时,使用这个技能。
|
||||||
|
|
||||||
|
## 工作流程
|
||||||
|
|
||||||
|
1. 行动前先阅读分析师结论和风险警示。
|
||||||
|
2. 评估当前组合、现金和保证金约束。
|
||||||
|
3. 使用决策工具为每个 ticker 记录一个明确决策。
|
||||||
|
4. 在全部决策记录完成后,总结组合层面的整体理由。
|
||||||
|
|
||||||
|
## 约束
|
||||||
|
|
||||||
|
- 仓位大小必须遵守资金和保证金限制。
|
||||||
|
- 当分析师信心与风险信号不一致时,优先采用更小仓位。
|
||||||
|
- 当任务要求完整决策清单时,不要让任何 ticker 处于未决状态。
|
||||||
4
runs/smoke_fullstack/agents/portfolio_manager/AGENTS.md
Normal file
4
runs/smoke_fullstack/agents/portfolio_manager/AGENTS.md
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
# Agent Guide
|
||||||
|
|
||||||
|
Document how this agent should work, collaborate, and choose tools or skills.
|
||||||
|
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
# Heartbeat
|
||||||
|
|
||||||
|
Optional checklist for periodic review or self-reflection.
|
||||||
|
|
||||||
4
runs/smoke_fullstack/agents/portfolio_manager/MEMORY.md
Normal file
4
runs/smoke_fullstack/agents/portfolio_manager/MEMORY.md
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
# Memory
|
||||||
|
|
||||||
|
Store durable lessons, heuristics, and reminders for this agent.
|
||||||
|
|
||||||
4
runs/smoke_fullstack/agents/portfolio_manager/PROFILE.md
Normal file
4
runs/smoke_fullstack/agents/portfolio_manager/PROFILE.md
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
# Profile
|
||||||
|
|
||||||
|
Track this agent's long-lived investment style, preferences, and strengths.
|
||||||
|
|
||||||
4
runs/smoke_fullstack/agents/portfolio_manager/SOUL.md
Normal file
4
runs/smoke_fullstack/agents/portfolio_manager/SOUL.md
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
# Soul
|
||||||
|
|
||||||
|
Describe the agent's temperament, reasoning posture, and voice.
|
||||||
|
|
||||||
12
runs/smoke_fullstack/agents/portfolio_manager/agent.yaml
Normal file
12
runs/smoke_fullstack/agents/portfolio_manager/agent.yaml
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
agent_id: portfolio_manager
|
||||||
|
prompt_files:
|
||||||
|
- SOUL.md
|
||||||
|
- PROFILE.md
|
||||||
|
- AGENTS.md
|
||||||
|
- POLICY.md
|
||||||
|
- MEMORY.md
|
||||||
|
enabled_skills:
|
||||||
|
- portfolio_decisioning
|
||||||
|
disabled_skills: []
|
||||||
|
active_tool_groups: []
|
||||||
|
disabled_tool_groups: []
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
---
|
||||||
|
name: 组合决策
|
||||||
|
description: 整合分析师观点与风险反馈,形成明确的组合层决策。
|
||||||
|
---
|
||||||
|
|
||||||
|
# 组合决策
|
||||||
|
|
||||||
|
当你负责把团队分析转化为最终交易决策时,使用这个技能。
|
||||||
|
|
||||||
|
## 工作流程
|
||||||
|
|
||||||
|
1. 行动前先阅读分析师结论和风险警示。
|
||||||
|
2. 评估当前组合、现金和保证金约束。
|
||||||
|
3. 使用决策工具为每个 ticker 记录一个明确决策。
|
||||||
|
4. 在全部决策记录完成后,总结组合层面的整体理由。
|
||||||
|
|
||||||
|
## 约束
|
||||||
|
|
||||||
|
- 仓位大小必须遵守资金和保证金限制。
|
||||||
|
- 当分析师信心与风险信号不一致时,优先采用更小仓位。
|
||||||
|
- 当任务要求完整决策清单时,不要让任何 ticker 处于未决状态。
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
---
|
||||||
|
name: 组合决策
|
||||||
|
description: 整合分析师观点与风险反馈,形成明确的组合层决策。
|
||||||
|
---
|
||||||
|
|
||||||
|
# 组合决策
|
||||||
|
|
||||||
|
当你负责把团队分析转化为最终交易决策时,使用这个技能。
|
||||||
|
|
||||||
|
## 工作流程
|
||||||
|
|
||||||
|
1. 行动前先阅读分析师结论和风险警示。
|
||||||
|
2. 评估当前组合、现金和保证金约束。
|
||||||
|
3. 使用决策工具为每个 ticker 记录一个明确决策。
|
||||||
|
4. 在全部决策记录完成后,总结组合层面的整体理由。
|
||||||
|
|
||||||
|
## 约束
|
||||||
|
|
||||||
|
- 仓位大小必须遵守资金和保证金限制。
|
||||||
|
- 当分析师信心与风险信号不一致时,优先采用更小仓位。
|
||||||
|
- 当任务要求完整决策清单时,不要让任何 ticker 处于未决状态。
|
||||||
4
runs/smoke_fullstack/agents/risk_manager/AGENTS.md
Normal file
4
runs/smoke_fullstack/agents/risk_manager/AGENTS.md
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
# Agent Guide
|
||||||
|
|
||||||
|
Document how this agent should work, collaborate, and choose tools or skills.
|
||||||
|
|
||||||
4
runs/smoke_fullstack/agents/risk_manager/HEARTBEAT.md
Normal file
4
runs/smoke_fullstack/agents/risk_manager/HEARTBEAT.md
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
# Heartbeat
|
||||||
|
|
||||||
|
Optional checklist for periodic review or self-reflection.
|
||||||
|
|
||||||
4
runs/smoke_fullstack/agents/risk_manager/MEMORY.md
Normal file
4
runs/smoke_fullstack/agents/risk_manager/MEMORY.md
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
# Memory
|
||||||
|
|
||||||
|
Store durable lessons, heuristics, and reminders for this agent.
|
||||||
|
|
||||||
4
runs/smoke_fullstack/agents/risk_manager/PROFILE.md
Normal file
4
runs/smoke_fullstack/agents/risk_manager/PROFILE.md
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
# Profile
|
||||||
|
|
||||||
|
Track this agent's long-lived investment style, preferences, and strengths.
|
||||||
|
|
||||||
4
runs/smoke_fullstack/agents/risk_manager/SOUL.md
Normal file
4
runs/smoke_fullstack/agents/risk_manager/SOUL.md
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
# Soul
|
||||||
|
|
||||||
|
Describe the agent's temperament, reasoning posture, and voice.
|
||||||
|
|
||||||
12
runs/smoke_fullstack/agents/risk_manager/agent.yaml
Normal file
12
runs/smoke_fullstack/agents/risk_manager/agent.yaml
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
agent_id: risk_manager
|
||||||
|
prompt_files:
|
||||||
|
- SOUL.md
|
||||||
|
- PROFILE.md
|
||||||
|
- AGENTS.md
|
||||||
|
- POLICY.md
|
||||||
|
- MEMORY.md
|
||||||
|
enabled_skills:
|
||||||
|
- risk_review
|
||||||
|
disabled_skills: []
|
||||||
|
active_tool_groups: []
|
||||||
|
disabled_tool_groups: []
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
---
|
||||||
|
name: 风险审查
|
||||||
|
description: 在最终仓位和执行前,评估组合与市场风险。
|
||||||
|
---
|
||||||
|
|
||||||
|
# 风险审查
|
||||||
|
|
||||||
|
当你需要识别集中度、波动率、杠杆和情景风险时,使用这个技能。
|
||||||
|
|
||||||
|
## 工作流程
|
||||||
|
|
||||||
|
1. 按 ticker 和主题检查拟议敞口。
|
||||||
|
2. 识别集中度、波动率、流动性和杠杆方面的风险点。
|
||||||
|
3. 按严重程度排序风险警示。
|
||||||
|
4. 将风险结论转化为给投资经理的具体限制或注意事项。
|
||||||
|
|
||||||
|
## 约束
|
||||||
|
|
||||||
|
- 聚焦可执行的风险控制措施。
|
||||||
|
- 当数据支持时尽量量化限制。
|
||||||
|
- 明确区分致命阻断项和可管理风险。
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
---
|
||||||
|
name: 风险审查
|
||||||
|
description: 在最终仓位和执行前,评估组合与市场风险。
|
||||||
|
---
|
||||||
|
|
||||||
|
# 风险审查
|
||||||
|
|
||||||
|
当你需要识别集中度、波动率、杠杆和情景风险时,使用这个技能。
|
||||||
|
|
||||||
|
## 工作流程
|
||||||
|
|
||||||
|
1. 按 ticker 和主题检查拟议敞口。
|
||||||
|
2. 识别集中度、波动率、流动性和杠杆方面的风险点。
|
||||||
|
3. 按严重程度排序风险警示。
|
||||||
|
4. 将风险结论转化为给投资经理的具体限制或注意事项。
|
||||||
|
|
||||||
|
## 约束
|
||||||
|
|
||||||
|
- 聚焦可执行的风险控制措施。
|
||||||
|
- 当数据支持时尽量量化限制。
|
||||||
|
- 明确区分致命阻断项和可管理风险。
|
||||||
4
runs/smoke_fullstack/agents/sentiment_analyst/AGENTS.md
Normal file
4
runs/smoke_fullstack/agents/sentiment_analyst/AGENTS.md
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
# Agent Guide
|
||||||
|
|
||||||
|
Document how this agent should work, collaborate, and choose tools or skills.
|
||||||
|
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
# Heartbeat
|
||||||
|
|
||||||
|
Optional checklist for periodic review or self-reflection.
|
||||||
|
|
||||||
4
runs/smoke_fullstack/agents/sentiment_analyst/MEMORY.md
Normal file
4
runs/smoke_fullstack/agents/sentiment_analyst/MEMORY.md
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
# Memory
|
||||||
|
|
||||||
|
Store durable lessons, heuristics, and reminders for this agent.
|
||||||
|
|
||||||
4
runs/smoke_fullstack/agents/sentiment_analyst/PROFILE.md
Normal file
4
runs/smoke_fullstack/agents/sentiment_analyst/PROFILE.md
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
# Profile
|
||||||
|
|
||||||
|
Track this agent's long-lived investment style, preferences, and strengths.
|
||||||
|
|
||||||
4
runs/smoke_fullstack/agents/sentiment_analyst/SOUL.md
Normal file
4
runs/smoke_fullstack/agents/sentiment_analyst/SOUL.md
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
# Soul
|
||||||
|
|
||||||
|
Describe the agent's temperament, reasoning posture, and voice.
|
||||||
|
|
||||||
12
runs/smoke_fullstack/agents/sentiment_analyst/agent.yaml
Normal file
12
runs/smoke_fullstack/agents/sentiment_analyst/agent.yaml
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
agent_id: sentiment_analyst
|
||||||
|
prompt_files:
|
||||||
|
- SOUL.md
|
||||||
|
- PROFILE.md
|
||||||
|
- AGENTS.md
|
||||||
|
- POLICY.md
|
||||||
|
- MEMORY.md
|
||||||
|
enabled_skills:
|
||||||
|
- sentiment_review
|
||||||
|
disabled_skills: []
|
||||||
|
active_tool_groups: []
|
||||||
|
disabled_tool_groups: []
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
---
|
||||||
|
name: 情绪分析
|
||||||
|
description: 分析新闻流、市场心理和内幕行为,识别事件驱动型信号。
|
||||||
|
---
|
||||||
|
|
||||||
|
# 情绪分析
|
||||||
|
|
||||||
|
当任务依赖近期催化剂、新闻语气或行为层面的市场信号时,使用这个技能。
|
||||||
|
|
||||||
|
## 工作流程
|
||||||
|
|
||||||
|
1. 回顾近期新闻并识别主导叙事。
|
||||||
|
2. 检查内幕活动,寻找确认或冲突信号。
|
||||||
|
3. 区分可持续的情绪变化和短暂噪音。
|
||||||
|
4. 说明情绪如何改变短期交易展望。
|
||||||
|
|
||||||
|
## 约束
|
||||||
|
|
||||||
|
- 不要把注意力误判为真实信念。
|
||||||
|
- 当情绪很强但缺乏基本面支持时,要明确指出。
|
||||||
|
- 对催化剂时间窗口风险要说清楚。
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
---
|
||||||
|
name: 情绪分析
|
||||||
|
description: 分析新闻流、市场心理和内幕行为,识别事件驱动型信号。
|
||||||
|
---
|
||||||
|
|
||||||
|
# 情绪分析
|
||||||
|
|
||||||
|
当任务依赖近期催化剂、新闻语气或行为层面的市场信号时,使用这个技能。
|
||||||
|
|
||||||
|
## 工作流程
|
||||||
|
|
||||||
|
1. 回顾近期新闻并识别主导叙事。
|
||||||
|
2. 检查内幕活动,寻找确认或冲突信号。
|
||||||
|
3. 区分可持续的情绪变化和短暂噪音。
|
||||||
|
4. 说明情绪如何改变短期交易展望。
|
||||||
|
|
||||||
|
## 约束
|
||||||
|
|
||||||
|
- 不要把注意力误判为真实信念。
|
||||||
|
- 当情绪很强但缺乏基本面支持时,要明确指出。
|
||||||
|
- 对催化剂时间窗口风险要说清楚。
|
||||||
4
runs/smoke_fullstack/agents/technical_analyst/AGENTS.md
Normal file
4
runs/smoke_fullstack/agents/technical_analyst/AGENTS.md
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
# Agent Guide
|
||||||
|
|
||||||
|
Document how this agent should work, collaborate, and choose tools or skills.
|
||||||
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user