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:
2026-03-17 16:43:29 +08:00
parent 2daf5717ba
commit 59b44545d0
121 changed files with 8384 additions and 358 deletions

View File

@@ -96,8 +96,11 @@ evotraders live # 立即运行(默认)
evotraders live --enable-memory # 使用记忆
evotraders live --mock # Mock 模式(测试)
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
evotraders --help # 查看整体命令行帮助

View File

@@ -1,6 +1,57 @@
# -*- 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 .portfolio_manager import PMAgent
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",
]

View 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",
]

View 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",
]

View 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()

View 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"]

View 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",
]

View 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
View 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
View 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)

View 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",
]

View 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
View 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

View 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
View 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
)

View File

@@ -1,22 +1,30 @@
# -*- 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
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]]:
"""加载Agent配置文件"""
config_path = SkillsManager().project_root / "backend" / "config" / "agent_profiles.yaml"
with open(config_path, "r", encoding="utf-8") as file:
return yaml.safe_load(file) or {}
def _register_analysis_tool_groups(toolkit: Any) -> None:
"""注册分析工具组"""
from backend.tools.analysis_tools import TOOL_REGISTRY
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:
"""注册投资组合工具组"""
toolkit.create_tool_group(
group_name="portfolio_ops",
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:
"""注册风险工具组"""
from backend.tools.risk_tools import (
assess_margin_and_liquidity,
assess_position_concentration,
@@ -146,7 +156,17 @@ def create_agent_toolkit(
owner: Any = None,
active_skill_dirs: Iterable[str] | None = None,
) -> 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
profiles = load_agent_profiles()
@@ -207,3 +227,173 @@ def create_agent_toolkit(
toolkit.update_tool_groups(group_names=active_groups, active=True)
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
View 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

View File

@@ -9,7 +9,7 @@ import yaml
from .skills_manager import SkillsManager
class WorkspaceManager:
class RunWorkspaceManager:
"""Create and maintain run-level prompt asset files for each agent."""
def __init__(self, project_root: Optional[Path] = None):
@@ -197,3 +197,7 @@ class WorkspaceManager:
yaml.safe_dump(payload, allow_unicode=True, sort_keys=False),
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
View 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
View 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
View 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
View 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
View 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
View 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)

View File

@@ -119,6 +119,9 @@ def resolve_runtime_config(
project_root: Path,
config_name: str,
enable_memory: bool = False,
schedule_mode: str = "daily",
interval_minutes: int = 60,
trigger_time: str = "09:30",
) -> Dict[str, Any]:
"""Merge env defaults with run-scoped bootstrap front matter."""
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),
),
),
"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)
or _coerce_bool(bootstrap.get("enable_memory", False)),
}

View File

@@ -19,6 +19,8 @@ from backend.utils.settlement import SettlementCoordinator
from backend.utils.terminal_dashboard import get_dashboard
from backend.core.state_sync import StateSync
from backend.utils.trade_executor import PortfolioTradeExecutor
from backend.runtime.manager import TradingRuntimeManager
from backend.runtime.session import TradingSessionKey
logger = logging.getLogger(__name__)
@@ -46,6 +48,8 @@ class TradingPipeline:
6. Reflection phase: broadcast closing P&L, agents record to long-term memory
Real-time updates via StateSync after each agent completes.
Supports both legacy agent lists and new workspace-based agent loading.
"""
def __init__(
@@ -56,6 +60,9 @@ class TradingPipeline:
state_sync: Optional["StateSync"] = None,
settlement_coordinator: Optional[SettlementCoordinator] = 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.risk_manager = risk_manager
@@ -66,6 +73,10 @@ class TradingPipeline:
os.getenv("MAX_COMM_CYCLES", "2"),
)
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(
self,
@@ -80,6 +91,7 @@ class TradingPipeline:
get_close_prices_fn: Optional[
Callable[[], Awaitable[Dict[str, float]]]
] = None,
execute_decisions: bool = True,
) -> Dict[str, Any]:
"""
Run one complete trading cycle
@@ -101,6 +113,12 @@ class TradingPipeline:
Each agent's result is broadcast immediately via StateSync.
"""
_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
_log("Phase 0: Clearing memory")
@@ -123,6 +141,7 @@ class TradingPipeline:
# Phase 1.2: Risk Manager
_log("Phase 1.2: Risk assessment")
self._runtime_update_status(self.risk_manager, "risk_assessment")
risk_assessment = await self._run_risk_manager_with_sync(
tickers,
date,
@@ -161,6 +180,7 @@ class TradingPipeline:
# Phase 3: 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(
tickers,
date,
@@ -169,10 +189,17 @@ class TradingPipeline:
risk_assessment,
)
# Phase 4: Execute decisions
_log("Phase 4: Executing trades")
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)
else:
_log("Phase 4: Skipping trade execution")
# Live mode: wait for market close before settlement
if get_close_prices_fn:
@@ -184,6 +211,10 @@ class TradingPipeline:
settlement_result = None
if close_prices and self.settlement_coordinator:
_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()
@@ -214,8 +245,17 @@ class TradingPipeline:
settlement_result=settlement_result,
conference_summary=self.conference_summary,
)
self._runtime_batch_status(
[self.risk_manager] + self.analysts + [self.pm],
"reflection",
)
_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 {
"analyst_results": analyst_results,
@@ -1306,3 +1346,122 @@ class TradingPipeline:
if decision_texts:
return "Decisions: " + "; ".join(decision_texts)
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)

View File

@@ -38,6 +38,7 @@ class Scheduler:
self.running = False
self._task: Optional[asyncio.Task] = None
self._callback: Optional[Callable] = None
def _now_nyse(self) -> datetime:
"""Get current time in NYSE timezone"""
@@ -68,18 +69,69 @@ class Scheduler:
return
self.running = True
if self.mode == "daily":
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}")
self._callback = callback
self._schedule_task()
logger.info(
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):
"""Run once per trading day at specified time (NYSE timezone)"""
first_run = True

View File

@@ -22,11 +22,17 @@ from backend.config.bootstrap_config import resolve_runtime_config
from backend.config.constants import ANALYST_TYPES
from backend.core.pipeline import TradingPipeline
from backend.core.scheduler import BacktestScheduler, Scheduler
from backend.utils.settlement import SettlementCoordinator
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.market import MarketService
from backend.services.storage import StorageService
from backend.utils.settlement import SettlementCoordinator
load_dotenv()
logger = logging.getLogger(__name__)
@@ -213,6 +219,15 @@ async def run_with_gateway(args):
initial_cash = runtime_config["initial_cash"]
margin_requirement = runtime_config["margin_requirement"]
runtime_manager = TradingRuntimeManager(
config_name=config_name,
run_dir=_get_run_dir(config_name),
bootstrap=runtime_config,
)
runtime_manager.prepare_run()
set_global_runtime_manager(runtime_manager)
register_runtime_manager(runtime_manager)
# Create market service
market_service = MarketService(
tickers=tickers,
@@ -245,6 +260,10 @@ async def run_with_gateway(args):
margin_requirement=margin_requirement,
enable_long_term_memory=runtime_config["enable_memory"],
)
for agent in analysts + [risk_manager, pm]:
agent_id = getattr(agent, "agent_id", None) or getattr(agent, "name", None)
if agent_id:
runtime_manager.register_agent(agent_id)
portfolio_state = storage_service.load_portfolio_state()
pm.load_portfolio_state(portfolio_state)
@@ -259,6 +278,7 @@ async def run_with_gateway(args):
portfolio_manager=pm,
settlement_coordinator=settlement_coordinator,
max_comm_cycles=runtime_config["max_comm_cycles"],
runtime_manager=runtime_manager,
)
# Create scheduler callback
@@ -321,9 +341,13 @@ async def run_with_gateway(args):
# Start long-term memory contexts and run gateway
async with AsyncExitStack() as stack:
try:
for memory in long_term_memories:
await stack.enter_async_context(memory)
await gateway.start(host=args.host, port=args.port)
finally:
unregister_runtime_manager()
clear_global_runtime_manager()
def main():

41
backend/process/models.py Normal file
View 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(),
}

View 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)

View 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()

View 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",
]

View 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(),
}

View 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
View 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",
)

View 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()

View 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'}"

View File

@@ -0,0 +1,16 @@
# Agent Guide
## 工作流程
1. 接收分析任务
2. 调用相关工具/技能
3. 生成分析报告
4. 参与团队决策
## 工具使用规范
- 优先使用已激活的技能
- 不确定时询问Portfolio Manager
- 重要发现用 `/save` 记录
## 记忆管理
- 使用 `/compact` 定期压缩记忆
- 投资经验记录在MEMORY.md

View File

@@ -0,0 +1,5 @@
# Heartbeat
## 定时任务
- 每日开盘前检查持仓
- 收盘后记录当日表现

View File

@@ -0,0 +1,9 @@
# Memory
<!-- 此文件用于记录Agent的学习经验和重要发现 -->
## 经验总结
## 重要事件
## 改进记录

View File

@@ -0,0 +1,5 @@
# Policy
## 风控规则
- 单一持仓不超过20%
- 止损线:-15%

View File

@@ -0,0 +1,13 @@
# Profile
## 投资风格
- 风险承受能力:中等
- 投资期限中期3-12个月
- 偏好行业:科技、医疗、消费
## 优势
- 财务分析
- 趋势识别
## 改进方向
- 市场情绪把握

View File

@@ -0,0 +1,4 @@
# Soul
你是专业的金融分析师,语气冷静、客观、专业。
你的分析应该数据驱动,避免情绪化表达。

View File

@@ -0,0 +1,5 @@
# Style
- 使用结构化输出JSON/Markdown表格
- 包含置信度评分
- 列出关键假设

View 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: {}

View File

@@ -0,0 +1,17 @@
# Role: Fundamental Analyst
## 职责
分析公司财务报表、盈利能力、成长性、竞争优势等基本面因素。
## 分析维度
- 财务报表分析(资产负债表、利润表、现金流量表)
- 盈利能力指标ROE、ROA、毛利率、净利率
- 成长性指标(营收增长率、利润增长率)
- 估值指标P/E、P/B、P/S
- 行业地位和竞争优势
## 输出格式
- 财务健康度评分1-10
- 成长性评分1-10
- 关键财务亮点和风险
- 同业对比分析

View File

@@ -0,0 +1,5 @@
# Soul
你是严谨的基本面分析师,像沃伦·巴菲特一样注重企业内在价值。
你的分析深入细致,关注长期价值而非短期波动。
语气沉稳、逻辑严密,善于发现财务数据背后的商业本质。

View File

@@ -0,0 +1,18 @@
# Role: Portfolio Manager
## 职责
统筹各分析师意见,制定投资决策,管理投资组合配置。
## 分析维度
- 资产配置策略(股债比例、行业分布)
- 风险收益平衡
- 仓位管理(建仓/加仓/减仓/清仓)
- 再平衡时机
- 组合相关性分析
## 输出格式
- 投资决策(买入/卖出/持有)
- 建议仓位比例
- 目标价位
- 止损止盈设置
- 组合调整建议

View File

@@ -0,0 +1,5 @@
# Soul
你是睿智的投资组合经理,像将军一样统筹全局。
你善于权衡各方意见,做出果断而理性的投资决策。
语气权威、决策果断,对组合整体表现负有最终责任。

View File

@@ -0,0 +1,19 @@
# Role: Risk Manager
## 职责
识别、评估和监控投资风险,确保组合风险在可控范围内。
## 分析维度
- 市场风险Beta、波动率
- 信用风险
- 流动性风险
- 集中度风险
- 尾部风险VaR、CVaR
- 压力测试
## 输出格式
- 风险等级(低/中/高/极高)
- 风险敞口分析
- 风险调整建议
- 预警阈值设置
- 应急预案

View File

@@ -0,0 +1,5 @@
# Soul
你是谨慎的风险管理者,时刻警惕潜在的损失。
你像守门员一样守护组合安全,宁可错过机会也不冒无法承受的风险。
语气保守、风险意识强,善于发现隐藏的威胁和脆弱性。

View File

@@ -0,0 +1,17 @@
# Role: Sentiment Analyst
## 职责
分析市场情绪、资金流向、新闻舆情,判断市场心理状态。
## 分析维度
- 市场情绪指标(恐慌/贪婪指数)
- 资金流向分析(主力/散户资金)
- 新闻舆情分析(正面/负面/中性)
- 社交媒体情绪
- 机构持仓变化
## 输出格式
- 情绪评分(-10到+10极度恐慌到极度贪婪
- 资金流向判断
- 舆情摘要
- 情绪拐点预警

View File

@@ -0,0 +1,5 @@
# Soul
你是敏感的市场情绪捕手,善于感知市场的恐惧与贪婪。
你关注人性在金融市场中的表现,理解情绪如何驱动价格。
语气富有洞察力、善于捕捉微妙变化,像心理学家一样理解市场参与者。

View File

@@ -0,0 +1,17 @@
# Role: Technical Analyst
## 职责
分析价格走势、交易量、技术指标,识别买卖时机。
## 分析维度
- 趋势分析(长期/中期/短期趋势)
- 支撑阻力位识别
- 技术指标MACD、RSI、KDJ、布林带等
- 形态识别(头肩顶/底、双底、三角形等)
- 量价关系分析
## 输出格式
- 趋势方向(上涨/下跌/震荡)
- 关键价位(支撑/阻力)
- 技术信号(买入/卖出/观望)
- 置信度评分

View File

@@ -0,0 +1,5 @@
# Soul
你是敏锐的技术分析师,相信价格包含一切信息。
你善于从图表中发现规律,像侦探一样寻找市场留下的痕迹。
语气果断、快速反应,善于捕捉稍纵即逝的交易机会。

View File

@@ -0,0 +1,17 @@
# Role: Valuation Analyst
## 职责
评估公司内在价值,计算合理价格区间,识别高估/低估机会。
## 分析维度
- DCF现金流折现模型
- 相对估值法P/E、EV/EBITDA等
- 资产重估法
- 分部估值SOTP
- 安全边际计算
## 输出格式
- 内在价值估算
- 合理价格区间
- 当前价格vs内在价值高估/低估百分比)
- 估值假设和敏感性分析

View File

@@ -0,0 +1,5 @@
# Soul
你是精确的估值分析师,追求计算内在价值的准确区间。
你像精算师一样严谨,注重假设的合理性和安全边际。
语气精确、注重数字,善于发现市场定价错误带来的机会。

View File

@@ -16,8 +16,8 @@ import GlobalStyles from './styles/GlobalStyles';
import NetValueChart from './components/NetValueChart';
import StockLogo from './components/StockLogo';
import Header from './components/Header.jsx';
import WatchlistPanel from './components/WatchlistPanel.jsx';
import RuntimeSettingsPanel from './components/RuntimeSettingsPanel.jsx';
import RuntimeView from './components/RuntimeView.jsx';
// Utils
import { formatNumber, formatTickerPrice } from './utils/formatters';
@@ -64,7 +64,7 @@ export default function LiveTradingApp() {
const [progress, setProgress] = useState({ current: 0, total: 0 });
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 [isInitialAnimating, setIsInitialAnimating] = useState(true);
const [lastUpdate, setLastUpdate] = useState(new Date());
@@ -124,6 +124,9 @@ export default function LiveTradingApp() {
const [intervalMinutesDraft, setIntervalMinutesDraft] = useState('60');
const [triggerTimeDraft, setTriggerTimeDraft] = useState('09:30');
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 [isRuntimeConfigSaving, setIsRuntimeConfigSaving] = useState(false);
const [selectedSkillAgentId, setSelectedSkillAgentId] = useState(AGENTS[0]?.id || 'portfolio_manager');
@@ -302,6 +305,9 @@ export default function LiveTradingApp() {
setIntervalMinutesDraft(String(runtimeConfig.interval_minutes || 60));
setTriggerTimeDraft(String(runtimeConfig.trigger_time || '09:30'));
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]);
const watchlistSuggestions = useMemo(
@@ -537,20 +543,101 @@ export default function LiveTradingApp() {
schedule_mode: scheduleModeDraft,
interval_minutes: interval,
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) {
setIsRuntimeConfigSaving(false);
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(() => {
setScheduleModeDraft('daily');
setIntervalMinutesDraft('60');
setTriggerTimeDraft('09:30');
setMaxCommCyclesDraft('2');
setInitialCashDraft('100000');
setMarginRequirementDraft('0');
setEnableMemoryDraft(false);
setRuntimeConfigFeedback(null);
}, []);
@@ -2273,43 +2360,39 @@ export default function LiveTradingApp() {
</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
showTrigger={false}
isOpen={isRuntimeSettingsOpen}
isConnected={isConnected}
isSaving={isRuntimeConfigSaving}
feedback={runtimeConfigFeedback}
runtimeConfig={runtimeConfig}
isSaving={isRuntimeConfigSaving || isWatchlistSaving}
feedback={runtimeConfigFeedback || watchlistFeedback}
scheduleMode={scheduleModeDraft}
intervalMinutes={intervalMinutesDraft}
triggerTime={triggerTimeDraft}
maxCommCycles={maxCommCyclesDraft}
initialCash={initialCashDraft}
marginRequirement={marginRequirementDraft}
enableMemory={enableMemoryDraft}
watchlistSymbols={watchlistDraftSymbols}
watchlistInputValue={watchlistInputValue}
watchlistSuggestions={watchlistSuggestions}
onToggle={handleRuntimeSettingsToggle}
onClose={() => setIsRuntimeSettingsOpen(false)}
onScheduleModeChange={setScheduleModeDraft}
onIntervalMinutesChange={setIntervalMinutesDraft}
onTriggerTimeChange={setTriggerTimeDraft}
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}
/>
</div>
@@ -2393,8 +2476,33 @@ export default function LiveTradingApp() {
>
统计
</button>
<button
className={`view-nav-btn ${currentView === 'runtime' ? 'active' : ''}`}
onClick={() => setCurrentView('runtime')}
>
运行态
</button>
</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 ${
currentView === 'traders'
? 'show-traders'
@@ -2454,6 +2562,7 @@ export default function LiveTradingApp() {
leaderboard={leaderboard}
feed={feed}
onJumpToMessage={handleJumpToMessage}
onOpenLaunchConfig={() => setIsRuntimeSettingsOpen(true)}
/>
</Suspense>
</div>
@@ -2535,6 +2644,7 @@ export default function LiveTradingApp() {
</Suspense>
</div>
</div>
)}
</div>
</div>
</div>

View File

@@ -47,7 +47,7 @@ function getRankMedal(rank) {
* Supports click and hover (1.5s) to show agent performance cards
* 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 containerRef = useRef(null);
@@ -719,13 +719,24 @@ export default function RoomView({ bubbles, bubbleFor, leaderboard, feed, onJump
/>
)}
{/* Replay Button */}
{showReplayButton && (
{/* Room Controls */}
{(showReplayButton || onOpenLaunchConfig) && (
<div className="replay-button-container">
{onOpenLaunchConfig && (
<button
className="replay-button"
onClick={onOpenLaunchConfig}
title="打开启动配置"
style={{ background: '#FFFFFF', color: '#000000' }}
>
<span>启动</span>
</button>
)}
<button
className="replay-button"
onClick={handleReplayClick}
title="Replay feed history"
disabled={!showReplayButton}
>
<span className="replay-icon">&#9654;&#9654;</span>
<span>回放</span>
@@ -767,4 +778,3 @@ export default function RoomView({ bubbles, bubbleFor, leaderboard, feed, onJump
</div>
);
}

View File

@@ -1,26 +1,44 @@
import React from 'react';
import { createPortal } from 'react-dom';
export default function RuntimeSettingsPanel({
showTrigger = true,
isOpen,
isConnected,
isSaving,
feedback,
runtimeConfig,
scheduleMode,
intervalMinutes,
triggerTime,
maxCommCycles,
initialCash,
marginRequirement,
enableMemory,
watchlistSymbols,
watchlistInputValue,
watchlistSuggestions,
onToggle,
onClose,
onScheduleModeChange,
onIntervalMinutesChange,
onTriggerTimeChange,
onMaxCommCyclesChange,
onInitialCashChange,
onMarginRequirementChange,
onEnableMemoryChange,
onWatchlistInputChange,
onWatchlistInputKeyDown,
onWatchlistAdd,
onWatchlistRemove,
onWatchlistRestoreCurrent,
onWatchlistRestoreDefault,
onWatchlistSuggestionClick,
onSave,
onRestoreDefaults
}) {
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
onClick={onToggle}
style={{
@@ -36,49 +54,225 @@ export default function RuntimeSettingsPanel({
whiteSpace: 'nowrap'
}}
>
运行设
启动配
</button>
)}
{isOpen && (
<div style={{
position: 'absolute',
top: 'calc(100% + 10px)',
right: 0,
width: 320,
maxWidth: 'min(320px, 92vw)',
padding: '14px',
borderRadius: 8,
border: '1px solid #D9D9D9',
{isOpen && createPortal((
<div
onClick={onClose}
style={{
position: 'fixed',
inset: 0,
background: 'rgba(15, 23, 42, 0.28)',
display: 'flex',
alignItems: 'center',
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',
boxShadow: '0 12px 36px rgba(0, 0, 0, 0.14)',
zIndex: 40,
boxShadow: '0 24px 60px rgba(15, 23, 42, 0.18)',
padding: 18,
paddingTop: 22,
display: 'grid',
gap: 12
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 12 }}>
<div>
<div style={{ fontSize: '12px', fontWeight: 700, color: '#111111', letterSpacing: '0.3px' }}>
运行设置
</div>
<div style={{ fontSize: '11px', color: '#666666', marginTop: 2 }}>
保存后立即热更新当前运行中的调度参数
</div>
</div>
gap: 16,
position: 'relative',
zIndex: 9999
}}
>
<button
onClick={onClose}
style={{
border: 'none',
background: 'transparent',
color: '#666666',
position: 'absolute',
top: 16,
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',
fontSize: '14px',
lineHeight: 1
boxShadow: '0 4px 12px rgba(15, 23, 42, 0.08)'
}}
aria-label="关闭启动配置"
>
×
</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 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 }}>
<label style={{ display: 'grid', gap: 4 }}>
<span style={{ fontSize: '10px', color: '#4B5563', fontWeight: 700 }}>调度模式</span>
@@ -87,15 +281,15 @@ export default function RuntimeSettingsPanel({
onChange={(e) => onScheduleModeChange(e.target.value)}
style={{
padding: '9px 10px',
borderRadius: 6,
borderRadius: 8,
border: '1px solid #D0D7DE',
background: '#FFFFFF',
color: '#111111',
fontSize: '12px'
}}
>
<option value="daily">daily</option>
<option value="intraday">intraday</option>
<option value="daily">每日定时</option>
<option value="intraday">盘中轮询</option>
</select>
</label>
@@ -108,7 +302,7 @@ export default function RuntimeSettingsPanel({
onChange={(e) => onIntervalMinutesChange(e.target.value)}
style={{
padding: '9px 10px',
borderRadius: 6,
borderRadius: 8,
border: '1px solid #D0D7DE',
background: '#FFFFFF',
color: '#111111',
@@ -120,7 +314,7 @@ export default function RuntimeSettingsPanel({
</div>
<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
type="time"
value={triggerTime}
@@ -128,7 +322,7 @@ export default function RuntimeSettingsPanel({
disabled={scheduleMode !== 'daily'}
style={{
padding: '9px 10px',
borderRadius: 6,
borderRadius: 8,
border: '1px solid #D0D7DE',
background: scheduleMode === 'daily' ? '#FFFFFF' : '#F3F4F6',
color: '#111111',
@@ -147,7 +341,7 @@ export default function RuntimeSettingsPanel({
onChange={(e) => onMaxCommCyclesChange(e.target.value)}
style={{
padding: '9px 10px',
borderRadius: 6,
borderRadius: 8,
border: '1px solid #D0D7DE',
background: '#FFFFFF',
color: '#111111',
@@ -157,12 +351,78 @@ export default function RuntimeSettingsPanel({
/>
</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
onClick={onRestoreDefaults}
style={{
padding: '9px 12px',
borderRadius: 6,
borderRadius: 8,
border: '1px solid #D0D7DE',
background: '#FFFFFF',
color: '#111111',
@@ -178,7 +438,7 @@ export default function RuntimeSettingsPanel({
disabled={!isConnected || isSaving}
style={{
padding: '9px 14px',
borderRadius: 6,
borderRadius: 8,
border: '1px solid #1565C0',
background: isConnected && !isSaving ? '#0D47A1' : '#94A3B8',
color: '#FFFFFF',
@@ -188,9 +448,10 @@ export default function RuntimeSettingsPanel({
cursor: isConnected && !isSaving ? 'pointer' : 'not-allowed'
}}
>
{isSaving ? '保存中' : '保存运行配置'}
{isSaving ? '启动中' : '启动任务'}
</button>
</div>
</div>
{feedback && (
<span style={{
@@ -201,47 +462,10 @@ export default function RuntimeSettingsPanel({
{feedback.text}
</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 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>
);
}

View 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>
);
}

View 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);
}
});
}

View File

@@ -818,6 +818,9 @@ export default function GlobalStyles() {
left: 50%;
transform: translateX(-50%);
z-index: 100;
display: flex;
align-items: center;
gap: 10px;
}
.replay-button {
@@ -1053,34 +1056,38 @@ export default function GlobalStyles() {
transform: translateX(-66.666%);
}
/* Four-view slider (Room / Explain / Chart / Statistics) */
.view-slider-four {
/* Five-view slider (Traders / Room / Explain / Chart / Statistics) */
.view-slider-five {
position: absolute;
top: 40px;
width: 400%;
width: 500%;
height: calc(100% - 40px);
display: flex;
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);
}
.view-slider-four.show-room {
.view-slider-five.show-traders {
transform: translateX(0);
}
.view-slider-four.show-explain {
transform: translateX(-25%);
.view-slider-five.show-room {
transform: translateX(-20%);
}
.view-slider-four.show-chart {
transform: translateX(-50%);
.view-slider-five.show-explain {
transform: translateX(-40%);
}
.view-slider-four.show-statistics {
transform: translateX(-75%);
.view-slider-five.show-chart {
transform: translateX(-60%);
}
.view-slider-five.show-statistics {
transform: translateX(-80%);
}
.view-panel {
@@ -1092,10 +1099,10 @@ export default function GlobalStyles() {
overflow: hidden;
}
/* View panel for four-view slider */
.view-slider-four .view-panel {
flex: 0 0 25%;
width: 25%;
/* View panel for five-view slider */
.view-slider-five .view-panel {
flex: 0 0 20%;
width: 20%;
}
/* Chart Tabs - Floating inside chart */

View File

@@ -2,11 +2,19 @@
tickers:
- AAPL
- MSFT
- GOOGL
- AMZN
- NVDA
- META
- TSLA
initial_cash: 100000
margin_requirement: 0.0
enable_memory: false
max_comm_cycles: 2
agent_overrides: {}
schedule_mode: intraday
interval_minutes: 60
trigger_time: 09:30
---
# Bootstrap

View File

@@ -0,0 +1,4 @@
# Agent Guide
Document how this agent should work, collaborate, and choose tools or skills.

View File

@@ -0,0 +1,4 @@
# Heartbeat
Optional checklist for periodic review or self-reflection.

View File

@@ -0,0 +1,4 @@
# Memory
Store durable lessons, heuristics, and reminders for this agent.

View File

@@ -0,0 +1,4 @@
# Profile
Track this agent's long-lived investment style, preferences, and strengths.

View File

@@ -0,0 +1,4 @@
# Soul
Describe the agent's temperament, reasoning posture, and voice.

View 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: []

View File

@@ -0,0 +1,22 @@
---
name: 基本面分析
description: 当用户要求“基本面分析”“看财务质量”“分析盈利能力”“判断公司质量”或“评估长期盈利韧性”时,应使用此技能。
version: 1.0.0
---
# 基本面分析
当用户希望从公司质量、资产负债表强度、盈利能力或长期盈利韧性出发判断标的时,使用这个技能。
## 工作流程
1. 在形成结论前,先检查盈利能力、成长性、财务健康度和经营效率。
2. 区分可持续的业务质量和短期噪音。
3. 明确指出会推翻当前判断的条件。
4. 最终给出清晰的信号、置信度和主要驱动因素。
## 约束
- 不要孤立依赖单一指标。
- 缺失数据要明确指出。
- 当财务质量优劣混杂时,优先给出保守结论。

View File

@@ -0,0 +1,21 @@
---
name: 组合决策
description: 整合分析师观点与风险反馈,形成明确的组合层决策。
---
# 组合决策
当你负责把团队分析转化为最终交易决策时,使用这个技能。
## 工作流程
1. 行动前先阅读分析师结论和风险警示。
2. 评估当前组合、现金和保证金约束。
3. 使用决策工具为每个 ticker 记录一个明确决策。
4. 在全部决策记录完成后,总结组合层面的整体理由。
## 约束
- 仓位大小必须遵守资金和保证金限制。
- 当分析师信心与风险信号不一致时,优先采用更小仓位。
- 当任务要求完整决策清单时,不要让任何 ticker 处于未决状态。

View File

@@ -0,0 +1,22 @@
---
name: 基本面分析
description: 当用户要求“基本面分析”“看财务质量”“分析盈利能力”“判断公司质量”或“评估长期盈利韧性”时,应使用此技能。
version: 1.0.0
---
# 基本面分析
当用户希望从公司质量、资产负债表强度、盈利能力或长期盈利韧性出发判断标的时,使用这个技能。
## 工作流程
1. 在形成结论前,先检查盈利能力、成长性、财务健康度和经营效率。
2. 区分可持续的业务质量和短期噪音。
3. 明确指出会推翻当前判断的条件。
4. 最终给出清晰的信号、置信度和主要驱动因素。
## 约束
- 不要孤立依赖单一指标。
- 缺失数据要明确指出。
- 当财务质量优劣混杂时,优先给出保守结论。

View File

@@ -0,0 +1,21 @@
---
name: 组合决策
description: 整合分析师观点与风险反馈,形成明确的组合层决策。
---
# 组合决策
当你负责把团队分析转化为最终交易决策时,使用这个技能。
## 工作流程
1. 行动前先阅读分析师结论和风险警示。
2. 评估当前组合、现金和保证金约束。
3. 使用决策工具为每个 ticker 记录一个明确决策。
4. 在全部决策记录完成后,总结组合层面的整体理由。
## 约束
- 仓位大小必须遵守资金和保证金限制。
- 当分析师信心与风险信号不一致时,优先采用更小仓位。
- 当任务要求完整决策清单时,不要让任何 ticker 处于未决状态。

View File

@@ -0,0 +1,4 @@
# Agent Guide
Document how this agent should work, collaborate, and choose tools or skills.

View File

@@ -0,0 +1,4 @@
# Heartbeat
Optional checklist for periodic review or self-reflection.

View File

@@ -0,0 +1,4 @@
# Memory
Store durable lessons, heuristics, and reminders for this agent.

View File

@@ -0,0 +1,4 @@
# Profile
Track this agent's long-lived investment style, preferences, and strengths.

View File

@@ -0,0 +1,4 @@
# Soul
Describe the agent's temperament, reasoning posture, and voice.

View 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: []

View File

@@ -0,0 +1,21 @@
---
name: 组合决策
description: 整合分析师观点与风险反馈,形成明确的组合层决策。
---
# 组合决策
当你负责把团队分析转化为最终交易决策时,使用这个技能。
## 工作流程
1. 行动前先阅读分析师结论和风险警示。
2. 评估当前组合、现金和保证金约束。
3. 使用决策工具为每个 ticker 记录一个明确决策。
4. 在全部决策记录完成后,总结组合层面的整体理由。
## 约束
- 仓位大小必须遵守资金和保证金限制。
- 当分析师信心与风险信号不一致时,优先采用更小仓位。
- 当任务要求完整决策清单时,不要让任何 ticker 处于未决状态。

View File

@@ -0,0 +1,21 @@
---
name: 组合决策
description: 整合分析师观点与风险反馈,形成明确的组合层决策。
---
# 组合决策
当你负责把团队分析转化为最终交易决策时,使用这个技能。
## 工作流程
1. 行动前先阅读分析师结论和风险警示。
2. 评估当前组合、现金和保证金约束。
3. 使用决策工具为每个 ticker 记录一个明确决策。
4. 在全部决策记录完成后,总结组合层面的整体理由。
## 约束
- 仓位大小必须遵守资金和保证金限制。
- 当分析师信心与风险信号不一致时,优先采用更小仓位。
- 当任务要求完整决策清单时,不要让任何 ticker 处于未决状态。

View File

@@ -0,0 +1,4 @@
# Agent Guide
Document how this agent should work, collaborate, and choose tools or skills.

View File

@@ -0,0 +1,4 @@
# Heartbeat
Optional checklist for periodic review or self-reflection.

View File

@@ -0,0 +1,4 @@
# Memory
Store durable lessons, heuristics, and reminders for this agent.

View File

@@ -0,0 +1,4 @@
# Profile
Track this agent's long-lived investment style, preferences, and strengths.

View File

@@ -0,0 +1,4 @@
# Soul
Describe the agent's temperament, reasoning posture, and voice.

View 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: []

View File

@@ -0,0 +1,21 @@
---
name: 风险审查
description: 在最终仓位和执行前,评估组合与市场风险。
---
# 风险审查
当你需要识别集中度、波动率、杠杆和情景风险时,使用这个技能。
## 工作流程
1. 按 ticker 和主题检查拟议敞口。
2. 识别集中度、波动率、流动性和杠杆方面的风险点。
3. 按严重程度排序风险警示。
4. 将风险结论转化为给投资经理的具体限制或注意事项。
## 约束
- 聚焦可执行的风险控制措施。
- 当数据支持时尽量量化限制。
- 明确区分致命阻断项和可管理风险。

View File

@@ -0,0 +1,21 @@
---
name: 风险审查
description: 在最终仓位和执行前,评估组合与市场风险。
---
# 风险审查
当你需要识别集中度、波动率、杠杆和情景风险时,使用这个技能。
## 工作流程
1. 按 ticker 和主题检查拟议敞口。
2. 识别集中度、波动率、流动性和杠杆方面的风险点。
3. 按严重程度排序风险警示。
4. 将风险结论转化为给投资经理的具体限制或注意事项。
## 约束
- 聚焦可执行的风险控制措施。
- 当数据支持时尽量量化限制。
- 明确区分致命阻断项和可管理风险。

View File

@@ -0,0 +1,4 @@
# Agent Guide
Document how this agent should work, collaborate, and choose tools or skills.

View File

@@ -0,0 +1,4 @@
# Heartbeat
Optional checklist for periodic review or self-reflection.

View File

@@ -0,0 +1,4 @@
# Memory
Store durable lessons, heuristics, and reminders for this agent.

View File

@@ -0,0 +1,4 @@
# Profile
Track this agent's long-lived investment style, preferences, and strengths.

View File

@@ -0,0 +1,4 @@
# Soul
Describe the agent's temperament, reasoning posture, and voice.

View 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: []

View File

@@ -0,0 +1,21 @@
---
name: 情绪分析
description: 分析新闻流、市场心理和内幕行为,识别事件驱动型信号。
---
# 情绪分析
当任务依赖近期催化剂、新闻语气或行为层面的市场信号时,使用这个技能。
## 工作流程
1. 回顾近期新闻并识别主导叙事。
2. 检查内幕活动,寻找确认或冲突信号。
3. 区分可持续的情绪变化和短暂噪音。
4. 说明情绪如何改变短期交易展望。
## 约束
- 不要把注意力误判为真实信念。
- 当情绪很强但缺乏基本面支持时,要明确指出。
- 对催化剂时间窗口风险要说清楚。

View File

@@ -0,0 +1,21 @@
---
name: 情绪分析
description: 分析新闻流、市场心理和内幕行为,识别事件驱动型信号。
---
# 情绪分析
当任务依赖近期催化剂、新闻语气或行为层面的市场信号时,使用这个技能。
## 工作流程
1. 回顾近期新闻并识别主导叙事。
2. 检查内幕活动,寻找确认或冲突信号。
3. 区分可持续的情绪变化和短暂噪音。
4. 说明情绪如何改变短期交易展望。
## 约束
- 不要把注意力误判为真实信念。
- 当情绪很强但缺乏基本面支持时,要明确指出。
- 对催化剂时间窗口风险要说清楚。

View 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