diff --git a/README_zh.md b/README_zh.md index 6faf72c..9fecf15 100644 --- a/README_zh.md +++ b/README_zh.md @@ -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 # 查看整体命令行帮助 diff --git a/backend/agents/__init__.py b/backend/agents/__init__.py index 9347bf6..c21f299 100644 --- a/backend/agents/__init__.py +++ b/backend/agents/__init__.py @@ -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", +] diff --git a/backend/agents/agent_core.py b/backend/agents/agent_core.py new file mode 100644 index 0000000..ed8b1ae --- /dev/null +++ b/backend/agents/agent_core.py @@ -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", +] diff --git a/backend/agents/base/__init__.py b/backend/agents/base/__init__.py new file mode 100644 index 0000000..85d83a2 --- /dev/null +++ b/backend/agents/base/__init__.py @@ -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", +] diff --git a/backend/agents/base/command_handler.py b/backend/agents/base/command_handler.py new file mode 100644 index 0000000..e3e3b75 --- /dev/null +++ b/backend/agents/base/command_handler.py @@ -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 命令 - 保存内容到MEMORY.md""" + + async def handle(self, ctx: CommandContext) -> CommandResult: + message = ctx.args.strip() + if not message: + return CommandResult( + success=False, + message="Usage: /save \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 启用技能") + + 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 命令 - 启用技能""" + + async def handle(self, ctx: CommandContext) -> CommandResult: + skill_name = ctx.args.strip() + if not skill_name: + return CommandResult( + success=False, + message="Usage: /skills enable \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 命令 - 禁用技能""" + + async def handle(self, ctx: CommandContext) -> CommandResult: + skill_name = ctx.args.strip() + if not skill_name: + return CommandResult( + success=False, + message="Usage: /skills disable \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 命令 - 安装技能""" + + async def handle(self, ctx: CommandContext) -> CommandResult: + skill_name = ctx.args.strip() + if not skill_name: + return CommandResult( + success=False, + message="Usage: /skills install \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 - 保存内容到 MEMORY.md + /compact - 压缩记忆 + /skills list - 列出已激活技能 + /skills enable - 启用技能 + /skills disable - 禁用技能 + /skills install - 安装技能 + /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() diff --git a/backend/agents/base/evo_agent.py b/backend/agents/base/evo_agent.py new file mode 100644 index 0000000..bb2cc87 --- /dev/null +++ b/backend/agents/base/evo_agent.py @@ -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=( + "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." + "" + ), + 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=( + "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." + "" + ), + 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"] diff --git a/backend/agents/base/hooks.py b/backend/agents/base/hooks.py new file mode 100644 index 0000000..97ab7bd --- /dev/null +++ b/backend/agents/base/hooks.py @@ -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", +] diff --git a/backend/agents/base/tool_guard.py b/backend/agents/base/tool_guard.py new file mode 100644 index 0000000..7391073 --- /dev/null +++ b/backend/agents/base/tool_guard.py @@ -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", +] diff --git a/backend/agents/compat.py b/backend/agents/compat.py new file mode 100644 index 0000000..e008dfe --- /dev/null +++ b/backend/agents/compat.py @@ -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] diff --git a/backend/agents/factory.py b/backend/agents/factory.py new file mode 100644 index 0000000..ac3db82 --- /dev/null +++ b/backend/agents/factory.py @@ -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) diff --git a/backend/agents/prompts/__init__.py b/backend/agents/prompts/__init__.py new file mode 100644 index 0000000..9231593 --- /dev/null +++ b/backend/agents/prompts/__init__.py @@ -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", +] diff --git a/backend/agents/prompts/builder.py b/backend/agents/prompts/builder.py new file mode 100644 index 0000000..f43c8fc --- /dev/null +++ b/backend/agents/prompts/builder.py @@ -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", +] diff --git a/backend/agents/registry.py b/backend/agents/registry.py new file mode 100644 index 0000000..9e2e325 --- /dev/null +++ b/backend/agents/registry.py @@ -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 diff --git a/backend/agents/skill_loader.py b/backend/agents/skill_loader.py new file mode 100644 index 0000000..4716c17 --- /dev/null +++ b/backend/agents/skill_loader.py @@ -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"], + } diff --git a/backend/agents/templates.py b/backend/agents/templates.py new file mode 100644 index 0000000..c87d623 --- /dev/null +++ b/backend/agents/templates.py @@ -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 + + + +## 经验总结 + +## 重要事件 + +## 改进记录 +""", + + "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 + ) diff --git a/backend/agents/toolkit_factory.py b/backend/agents/toolkit_factory.py index 92fbb3d..9feb442 100644 --- a/backend/agents/toolkit_factory.py +++ b/backend/agents/toolkit_factory.py @@ -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=( + "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." + ), + 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)) diff --git a/backend/agents/workspace.py b/backend/agents/workspace.py new file mode 100644 index 0000000..df89677 --- /dev/null +++ b/backend/agents/workspace.py @@ -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 diff --git a/backend/agents/workspace_manager.py b/backend/agents/workspace_manager.py index c2775e9..043fe36 100644 --- a/backend/agents/workspace_manager.py +++ b/backend/agents/workspace_manager.py @@ -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 diff --git a/backend/api/__init__.py b/backend/api/__init__.py new file mode 100644 index 0000000..9e8c0c5 --- /dev/null +++ b/backend/api/__init__.py @@ -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", +] diff --git a/backend/api/agents.py b/backend/api/agents.py new file mode 100644 index 0000000..26ae94a --- /dev/null +++ b/backend/api/agents.py @@ -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)) diff --git a/backend/api/guard.py b/backend/api/guard.py new file mode 100644 index 0000000..166b839 --- /dev/null +++ b/backend/api/guard.py @@ -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) diff --git a/backend/api/runtime.py b/backend/api/runtime.py new file mode 100644 index 0000000..2cf2b50 --- /dev/null +++ b/backend/api/runtime.py @@ -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 diff --git a/backend/api/workspaces.py b/backend/api/workspaces.py new file mode 100644 index 0000000..acbf105 --- /dev/null +++ b/backend/api/workspaces.py @@ -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)) diff --git a/backend/app.py b/backend/app.py new file mode 100644 index 0000000..940941d --- /dev/null +++ b/backend/app.py @@ -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) diff --git a/backend/config/bootstrap_config.py b/backend/config/bootstrap_config.py index d59dcb4..c043d32 100644 --- a/backend/config/bootstrap_config.py +++ b/backend/config/bootstrap_config.py @@ -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)), } diff --git a/backend/core/pipeline.py b/backend/core/pipeline.py index f567246..4390214 100644 --- a/backend/core/pipeline.py +++ b/backend/core/pipeline.py @@ -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 = self._execute_decisions(decisions, prices, date) + 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) diff --git a/backend/core/scheduler.py b/backend/core/scheduler.py index fd11ded..983759b 100644 --- a/backend/core/scheduler.py +++ b/backend/core/scheduler.py @@ -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 diff --git a/backend/main.py b/backend/main.py index 41e8354..16ddb1e 100644 --- a/backend/main.py +++ b/backend/main.py @@ -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: - for memory in long_term_memories: - await stack.enter_async_context(memory) - await gateway.start(host=args.host, port=args.port) + 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(): diff --git a/backend/process/models.py b/backend/process/models.py new file mode 100644 index 0000000..fb2bc1c --- /dev/null +++ b/backend/process/models.py @@ -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(), + } diff --git a/backend/process/registry.py b/backend/process/registry.py new file mode 100644 index 0000000..f64a6a8 --- /dev/null +++ b/backend/process/registry.py @@ -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) diff --git a/backend/process/supervisor.py b/backend/process/supervisor.py new file mode 100644 index 0000000..11f684d --- /dev/null +++ b/backend/process/supervisor.py @@ -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() diff --git a/backend/runtime/__init__.py b/backend/runtime/__init__.py new file mode 100644 index 0000000..5534605 --- /dev/null +++ b/backend/runtime/__init__.py @@ -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", +] diff --git a/backend/runtime/agent_runtime.py b/backend/runtime/agent_runtime.py new file mode 100644 index 0000000..3fd28c2 --- /dev/null +++ b/backend/runtime/agent_runtime.py @@ -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(), + } diff --git a/backend/runtime/context.py b/backend/runtime/context.py new file mode 100644 index 0000000..936a536 --- /dev/null +++ b/backend/runtime/context.py @@ -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}" diff --git a/backend/runtime/manager.py b/backend/runtime/manager.py new file mode 100644 index 0000000..ae60156 --- /dev/null +++ b/backend/runtime/manager.py @@ -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", + ) diff --git a/backend/runtime/registry.py b/backend/runtime/registry.py new file mode 100644 index 0000000..f2c2868 --- /dev/null +++ b/backend/runtime/registry.py @@ -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() diff --git a/backend/runtime/session.py b/backend/runtime/session.py new file mode 100644 index 0000000..61c7e08 --- /dev/null +++ b/backend/runtime/session.py @@ -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'}" diff --git a/backend/workspaces/.template/AGENTS.md b/backend/workspaces/.template/AGENTS.md new file mode 100644 index 0000000..13d0c64 --- /dev/null +++ b/backend/workspaces/.template/AGENTS.md @@ -0,0 +1,16 @@ +# Agent Guide + +## 工作流程 +1. 接收分析任务 +2. 调用相关工具/技能 +3. 生成分析报告 +4. 参与团队决策 + +## 工具使用规范 +- 优先使用已激活的技能 +- 不确定时询问Portfolio Manager +- 重要发现用 `/save` 记录 + +## 记忆管理 +- 使用 `/compact` 定期压缩记忆 +- 投资经验记录在MEMORY.md diff --git a/backend/workspaces/.template/HEARTBEAT.md b/backend/workspaces/.template/HEARTBEAT.md new file mode 100644 index 0000000..40aef36 --- /dev/null +++ b/backend/workspaces/.template/HEARTBEAT.md @@ -0,0 +1,5 @@ +# Heartbeat + +## 定时任务 +- 每日开盘前检查持仓 +- 收盘后记录当日表现 diff --git a/backend/workspaces/.template/MEMORY.md b/backend/workspaces/.template/MEMORY.md new file mode 100644 index 0000000..d1a9d3e --- /dev/null +++ b/backend/workspaces/.template/MEMORY.md @@ -0,0 +1,9 @@ +# Memory + + + +## 经验总结 + +## 重要事件 + +## 改进记录 diff --git a/backend/workspaces/.template/POLICY.md b/backend/workspaces/.template/POLICY.md new file mode 100644 index 0000000..8b18c3f --- /dev/null +++ b/backend/workspaces/.template/POLICY.md @@ -0,0 +1,5 @@ +# Policy + +## 风控规则 +- 单一持仓不超过20% +- 止损线:-15% diff --git a/backend/workspaces/.template/PROFILE.md b/backend/workspaces/.template/PROFILE.md new file mode 100644 index 0000000..193db5f --- /dev/null +++ b/backend/workspaces/.template/PROFILE.md @@ -0,0 +1,13 @@ +# Profile + +## 投资风格 +- 风险承受能力:中等 +- 投资期限:中期(3-12个月) +- 偏好行业:科技、医疗、消费 + +## 优势 +- 财务分析 +- 趋势识别 + +## 改进方向 +- 市场情绪把握 diff --git a/backend/workspaces/.template/SOUL.md b/backend/workspaces/.template/SOUL.md new file mode 100644 index 0000000..9e6a876 --- /dev/null +++ b/backend/workspaces/.template/SOUL.md @@ -0,0 +1,4 @@ +# Soul + +你是专业的金融分析师,语气冷静、客观、专业。 +你的分析应该数据驱动,避免情绪化表达。 diff --git a/backend/workspaces/.template/STYLE.md b/backend/workspaces/.template/STYLE.md new file mode 100644 index 0000000..4476af7 --- /dev/null +++ b/backend/workspaces/.template/STYLE.md @@ -0,0 +1,5 @@ +# Style + +- 使用结构化输出(JSON/Markdown表格) +- 包含置信度评分 +- 列出关键假设 diff --git a/backend/workspaces/.template/agent.yaml b/backend/workspaces/.template/agent.yaml new file mode 100644 index 0000000..d22ead0 --- /dev/null +++ b/backend/workspaces/.template/agent.yaml @@ -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: {} diff --git a/backend/workspaces/.template/skills/.gitkeep b/backend/workspaces/.template/skills/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/backend/workspaces/.template/types/fundamental/ROLE.md b/backend/workspaces/.template/types/fundamental/ROLE.md new file mode 100644 index 0000000..da705a9 --- /dev/null +++ b/backend/workspaces/.template/types/fundamental/ROLE.md @@ -0,0 +1,17 @@ +# Role: Fundamental Analyst + +## 职责 +分析公司财务报表、盈利能力、成长性、竞争优势等基本面因素。 + +## 分析维度 +- 财务报表分析(资产负债表、利润表、现金流量表) +- 盈利能力指标(ROE、ROA、毛利率、净利率) +- 成长性指标(营收增长率、利润增长率) +- 估值指标(P/E、P/B、P/S) +- 行业地位和竞争优势 + +## 输出格式 +- 财务健康度评分(1-10) +- 成长性评分(1-10) +- 关键财务亮点和风险 +- 同业对比分析 diff --git a/backend/workspaces/.template/types/fundamental/SOUL.md b/backend/workspaces/.template/types/fundamental/SOUL.md new file mode 100644 index 0000000..b8140c4 --- /dev/null +++ b/backend/workspaces/.template/types/fundamental/SOUL.md @@ -0,0 +1,5 @@ +# Soul + +你是严谨的基本面分析师,像沃伦·巴菲特一样注重企业内在价值。 +你的分析深入细致,关注长期价值而非短期波动。 +语气沉稳、逻辑严密,善于发现财务数据背后的商业本质。 diff --git a/backend/workspaces/.template/types/portfolio/ROLE.md b/backend/workspaces/.template/types/portfolio/ROLE.md new file mode 100644 index 0000000..e3f6a12 --- /dev/null +++ b/backend/workspaces/.template/types/portfolio/ROLE.md @@ -0,0 +1,18 @@ +# Role: Portfolio Manager + +## 职责 +统筹各分析师意见,制定投资决策,管理投资组合配置。 + +## 分析维度 +- 资产配置策略(股债比例、行业分布) +- 风险收益平衡 +- 仓位管理(建仓/加仓/减仓/清仓) +- 再平衡时机 +- 组合相关性分析 + +## 输出格式 +- 投资决策(买入/卖出/持有) +- 建议仓位比例 +- 目标价位 +- 止损止盈设置 +- 组合调整建议 diff --git a/backend/workspaces/.template/types/portfolio/SOUL.md b/backend/workspaces/.template/types/portfolio/SOUL.md new file mode 100644 index 0000000..e595d5f --- /dev/null +++ b/backend/workspaces/.template/types/portfolio/SOUL.md @@ -0,0 +1,5 @@ +# Soul + +你是睿智的投资组合经理,像将军一样统筹全局。 +你善于权衡各方意见,做出果断而理性的投资决策。 +语气权威、决策果断,对组合整体表现负有最终责任。 diff --git a/backend/workspaces/.template/types/risk/ROLE.md b/backend/workspaces/.template/types/risk/ROLE.md new file mode 100644 index 0000000..b582a21 --- /dev/null +++ b/backend/workspaces/.template/types/risk/ROLE.md @@ -0,0 +1,19 @@ +# Role: Risk Manager + +## 职责 +识别、评估和监控投资风险,确保组合风险在可控范围内。 + +## 分析维度 +- 市场风险(Beta、波动率) +- 信用风险 +- 流动性风险 +- 集中度风险 +- 尾部风险(VaR、CVaR) +- 压力测试 + +## 输出格式 +- 风险等级(低/中/高/极高) +- 风险敞口分析 +- 风险调整建议 +- 预警阈值设置 +- 应急预案 diff --git a/backend/workspaces/.template/types/risk/SOUL.md b/backend/workspaces/.template/types/risk/SOUL.md new file mode 100644 index 0000000..6e277e2 --- /dev/null +++ b/backend/workspaces/.template/types/risk/SOUL.md @@ -0,0 +1,5 @@ +# Soul + +你是谨慎的风险管理者,时刻警惕潜在的损失。 +你像守门员一样守护组合安全,宁可错过机会也不冒无法承受的风险。 +语气保守、风险意识强,善于发现隐藏的威胁和脆弱性。 diff --git a/backend/workspaces/.template/types/sentiment/ROLE.md b/backend/workspaces/.template/types/sentiment/ROLE.md new file mode 100644 index 0000000..dd8f516 --- /dev/null +++ b/backend/workspaces/.template/types/sentiment/ROLE.md @@ -0,0 +1,17 @@ +# Role: Sentiment Analyst + +## 职责 +分析市场情绪、资金流向、新闻舆情,判断市场心理状态。 + +## 分析维度 +- 市场情绪指标(恐慌/贪婪指数) +- 资金流向分析(主力/散户资金) +- 新闻舆情分析(正面/负面/中性) +- 社交媒体情绪 +- 机构持仓变化 + +## 输出格式 +- 情绪评分(-10到+10,极度恐慌到极度贪婪) +- 资金流向判断 +- 舆情摘要 +- 情绪拐点预警 diff --git a/backend/workspaces/.template/types/sentiment/SOUL.md b/backend/workspaces/.template/types/sentiment/SOUL.md new file mode 100644 index 0000000..65b5134 --- /dev/null +++ b/backend/workspaces/.template/types/sentiment/SOUL.md @@ -0,0 +1,5 @@ +# Soul + +你是敏感的市场情绪捕手,善于感知市场的恐惧与贪婪。 +你关注人性在金融市场中的表现,理解情绪如何驱动价格。 +语气富有洞察力、善于捕捉微妙变化,像心理学家一样理解市场参与者。 diff --git a/backend/workspaces/.template/types/technical/ROLE.md b/backend/workspaces/.template/types/technical/ROLE.md new file mode 100644 index 0000000..9982933 --- /dev/null +++ b/backend/workspaces/.template/types/technical/ROLE.md @@ -0,0 +1,17 @@ +# Role: Technical Analyst + +## 职责 +分析价格走势、交易量、技术指标,识别买卖时机。 + +## 分析维度 +- 趋势分析(长期/中期/短期趋势) +- 支撑阻力位识别 +- 技术指标(MACD、RSI、KDJ、布林带等) +- 形态识别(头肩顶/底、双底、三角形等) +- 量价关系分析 + +## 输出格式 +- 趋势方向(上涨/下跌/震荡) +- 关键价位(支撑/阻力) +- 技术信号(买入/卖出/观望) +- 置信度评分 diff --git a/backend/workspaces/.template/types/technical/SOUL.md b/backend/workspaces/.template/types/technical/SOUL.md new file mode 100644 index 0000000..62576e7 --- /dev/null +++ b/backend/workspaces/.template/types/technical/SOUL.md @@ -0,0 +1,5 @@ +# Soul + +你是敏锐的技术分析师,相信价格包含一切信息。 +你善于从图表中发现规律,像侦探一样寻找市场留下的痕迹。 +语气果断、快速反应,善于捕捉稍纵即逝的交易机会。 diff --git a/backend/workspaces/.template/types/valuation/ROLE.md b/backend/workspaces/.template/types/valuation/ROLE.md new file mode 100644 index 0000000..dd90a24 --- /dev/null +++ b/backend/workspaces/.template/types/valuation/ROLE.md @@ -0,0 +1,17 @@ +# Role: Valuation Analyst + +## 职责 +评估公司内在价值,计算合理价格区间,识别高估/低估机会。 + +## 分析维度 +- DCF现金流折现模型 +- 相对估值法(P/E、EV/EBITDA等) +- 资产重估法 +- 分部估值(SOTP) +- 安全边际计算 + +## 输出格式 +- 内在价值估算 +- 合理价格区间 +- 当前价格vs内在价值(高估/低估百分比) +- 估值假设和敏感性分析 diff --git a/backend/workspaces/.template/types/valuation/SOUL.md b/backend/workspaces/.template/types/valuation/SOUL.md new file mode 100644 index 0000000..3e935fd --- /dev/null +++ b/backend/workspaces/.template/types/valuation/SOUL.md @@ -0,0 +1,5 @@ +# Soul + +你是精确的估值分析师,追求计算内在价值的准确区间。 +你像精算师一样严谨,注重假设的合理性和安全边际。 +语气精确、注重数字,善于发现市场定价错误带来的机会。 diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index f1bf340..5cdc1a3 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -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() { )} - setIsWatchlistPanelOpen(false)} - onInputChange={handleWatchlistInputChange} - onInputKeyDown={handleWatchlistInputKeyDown} - onAdd={() => commitWatchlistInput(watchlistInputValue)} - onRemove={handleWatchlistRemove} - onRestoreCurrent={handleWatchlistRestoreCurrent} - onRestoreDefault={handleWatchlistRestoreDefault} - onSuggestionClick={handleWatchlistSuggestionClick} - onSave={handleWatchlistSave} - /> - 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} /> @@ -2393,8 +2476,33 @@ export default function LiveTradingApp() { > 统计 + + + {currentView === 'runtime' ? ( +
+ +
+ ) : (
setIsRuntimeSettingsOpen(true)} />
@@ -2535,6 +2644,7 @@ export default function LiveTradingApp() { + )} diff --git a/frontend/src/components/RoomView.jsx b/frontend/src/components/RoomView.jsx index 7bded68..04ffafb 100644 --- a/frontend/src/components/RoomView.jsx +++ b/frontend/src/components/RoomView.jsx @@ -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) && (
+ {onOpenLaunchConfig && ( + + )}
); } - diff --git a/frontend/src/components/RuntimeSettingsPanel.jsx b/frontend/src/components/RuntimeSettingsPanel.jsx index f5e2fdb..b8c50bd 100644 --- a/frontend/src/components/RuntimeSettingsPanel.jsx +++ b/frontend/src/components/RuntimeSettingsPanel.jsx @@ -1,247 +1,471 @@ 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 ( -
- +
+ {showTrigger && ( + + )} - {isOpen && ( -
-
-
-
- 运行设置 -
-
- 保存后立即热更新当前运行中的调度参数 -
-
+ {isOpen && createPortal(( +
+
event.stopPropagation()} + style={{ + width: 'min(760px, 92vw)', + maxHeight: '80vh', + overflowY: 'auto', + borderRadius: 16, + border: '1px solid #D9E0E7', + background: '#FFFFFF', + boxShadow: '0 24px 60px rgba(15, 23, 42, 0.18)', + padding: 18, + paddingTop: 22, + display: 'grid', + gap: 16, + position: 'relative', + zIndex: 9999 + }} + > -
-
- - - -
- - - - - -
- - -
- - {feedback && ( - - {feedback.text} - - )} - - {runtimeConfig && ( -
-
-
- 当前生效配置 +
+
+
启动配置
+
+ 配置本次任务的启动参数与调度方式
-
- 这里显示当前 run 已加载并生效的参数 -
-
- -
-
tickers: {(runtimeConfig.tickers || []).join(', ') || '-'}
-
schedule_mode: {runtimeConfig.schedule_mode || '-'}
-
interval_minutes: {runtimeConfig.interval_minutes ?? '-'}
-
trigger_time: {runtimeConfig.trigger_time || '-'}
-
max_comm_cycles: {runtimeConfig.max_comm_cycles ?? '-'}
-
initial_cash: {runtimeConfig.initial_cash ?? '-'}
-
margin_requirement: {runtimeConfig.margin_requirement ?? '-'}
-
enable_memory: {String(runtimeConfig.enable_memory ?? false)}
- )} + +
+
自选股
+ +
+ {watchlistSymbols.map((symbol) => ( + + ))} + {watchlistSymbols.length === 0 && ( +
+ 还没有股票,输入代码后回车添加 +
+ )} +
+ +
+ 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' + }} + /> + +
+ +
+ {watchlistSuggestions.map((symbol) => { + const active = watchlistSymbols.includes(symbol); + return ( + + ); + })} +
+ +
+ + +
+
+ +
+
调度参数
+
+ + + +
+ + + + + + + + + + +
+ +
+
+
操作
+
+ + +
+
+ + {feedback && ( + + {feedback.text} + + )} +
+
- )} + ), document.body)}
); } diff --git a/frontend/src/components/RuntimeView.jsx b/frontend/src/components/RuntimeView.jsx new file mode 100644 index 0000000..e72923c --- /dev/null +++ b/frontend/src/components/RuntimeView.jsx @@ -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 ( +
+
+ {label} +
+
+ {value} +
+ {helper && ( +
+ {helper} +
+ )} +
+ ); +} + +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 ( +
+
+ {label} +
+ {action} +
+ ); +} + +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 ( +
+
+
+
+
+ 运行态控制台 +
+
+ 查看当前运行上下文、分析师状态、待审批请求与近期事件。这里是监控面板,不再和运行设置挤在同一个小弹层里。 +
+
+ +
+
+ +
+
+ {metricCard('活跃 Agent', activeAgentsCount, '#2563EB', `共 ${agents.length} 个 agent 已注册`)} + {metricCard('待审批', approvals.length, approvals.length > 0 ? '#C2410C' : '#059669', approvals.length > 0 ? '需要人工处理' : '当前无待处理审批')} + {metricCard('运行事件', events.length, '#111111', '最近运行阶段和状态变化')} +
+
+ 自动刷新 +
+ +
+
+
+ + {runtimeError && ( +
+ {runtimeError} +
+ )} + +
+
+
+ {sectionTitle('运行上下文')} + {runtimeState?.context ? ( +
+
+
配置名
+
+ {runtimeState.context.config_name} +
+
+
+
运行目录
+
+ {runtimeState.context.run_dir} +
+
+
+
启动参数
+
+                  {JSON.stringify(runtimeState.context.bootstrap_values || {}, null, 2)}
+                
+
+
+ ) : ( +
暂无运行上下文
+ )} +
+ +
+ {sectionTitle('待审批请求')} +
+ {approvals.length ? approvals.map((approval) => { + const tone = resolveApprovalTone(approval); + return ( +
+
+
+ {approval.tool_name} +
+
+ {formatStatusLabel(approval.status)} +
+
+
+ {approval.agent_id} · {approval.workspace_id} · {formatSessionLabel(approval.session_id)} +
+ {approval.tool_input && ( +
+                    {JSON.stringify(approval.tool_input, null, 2)}
+                  
+ )} + {approval.findings?.length > 0 && ( +
+ {approval.findings.map((finding, index) => ( + + {finding.severity}: {finding.message} + + ))} +
+ )} +
+ + +
+
+ )}) : ( +
+ 当前无待审批请求 +
+ )} +
+
+
+ +
+
+ {sectionTitle('Agent 状态')} +
+ {runtimeState?.agents?.length ? runtimeState.agents.map((agent) => ( +
+
+ {agent.agent_id} + {formatStatusLabel(agent.status)} +
+
+ 会话: {formatSessionLabel(agent.last_session)} +
+
+ 更新时间: {agent.last_updated} +
+
+ )) : ( +
暂无 agent 状态
+ )} +
+
+ +
+ {sectionTitle( + '近期事件', + + )} +
+ {visibleEvents.length ? visibleEvents.map((event, index) => ( +
+
+ {formatEventLabel(event.event)} + {formatSessionLabel(event.session)} +
+
{event.timestamp}
+ {event.details && Object.keys(event.details).length > 0 && ( +
+                    {JSON.stringify(event.details, null, 2)}
+                  
+ )} +
+ )) : ( +
当前筛选条件下暂无运行事件
+ )} +
+
+
+
+
+ ); +} diff --git a/frontend/src/services/runtimeApi.js b/frontend/src/services/runtimeApi.js new file mode 100644 index 0000000..7de1163 --- /dev/null +++ b/frontend/src/services/runtimeApi.js @@ -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); + } + }); +} diff --git a/frontend/src/styles/GlobalStyles.jsx b/frontend/src/styles/GlobalStyles.jsx index 9130eff..ecea6e9 100644 --- a/frontend/src/styles/GlobalStyles.jsx +++ b/frontend/src/styles/GlobalStyles.jsx @@ -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 */ diff --git a/runs/smoke_fullstack/BOOTSTRAP.md b/runs/smoke_fullstack/BOOTSTRAP.md index cbea4b8..05c2208 100644 --- a/runs/smoke_fullstack/BOOTSTRAP.md +++ b/runs/smoke_fullstack/BOOTSTRAP.md @@ -1,12 +1,20 @@ --- tickers: - - AAPL - - MSFT +- 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 diff --git a/runs/smoke_fullstack/agents/fundamentals_analyst/AGENTS.md b/runs/smoke_fullstack/agents/fundamentals_analyst/AGENTS.md new file mode 100644 index 0000000..f620aea --- /dev/null +++ b/runs/smoke_fullstack/agents/fundamentals_analyst/AGENTS.md @@ -0,0 +1,4 @@ +# Agent Guide + +Document how this agent should work, collaborate, and choose tools or skills. + diff --git a/runs/smoke_fullstack/agents/fundamentals_analyst/HEARTBEAT.md b/runs/smoke_fullstack/agents/fundamentals_analyst/HEARTBEAT.md new file mode 100644 index 0000000..dc892f7 --- /dev/null +++ b/runs/smoke_fullstack/agents/fundamentals_analyst/HEARTBEAT.md @@ -0,0 +1,4 @@ +# Heartbeat + +Optional checklist for periodic review or self-reflection. + diff --git a/runs/smoke_fullstack/agents/fundamentals_analyst/MEMORY.md b/runs/smoke_fullstack/agents/fundamentals_analyst/MEMORY.md new file mode 100644 index 0000000..701fc0c --- /dev/null +++ b/runs/smoke_fullstack/agents/fundamentals_analyst/MEMORY.md @@ -0,0 +1,4 @@ +# Memory + +Store durable lessons, heuristics, and reminders for this agent. + diff --git a/runs/smoke_fullstack/agents/fundamentals_analyst/PROFILE.md b/runs/smoke_fullstack/agents/fundamentals_analyst/PROFILE.md new file mode 100644 index 0000000..d7412de --- /dev/null +++ b/runs/smoke_fullstack/agents/fundamentals_analyst/PROFILE.md @@ -0,0 +1,4 @@ +# Profile + +Track this agent's long-lived investment style, preferences, and strengths. + diff --git a/runs/smoke_fullstack/agents/fundamentals_analyst/SOUL.md b/runs/smoke_fullstack/agents/fundamentals_analyst/SOUL.md new file mode 100644 index 0000000..85402e4 --- /dev/null +++ b/runs/smoke_fullstack/agents/fundamentals_analyst/SOUL.md @@ -0,0 +1,4 @@ +# Soul + +Describe the agent's temperament, reasoning posture, and voice. + diff --git a/runs/smoke_fullstack/agents/fundamentals_analyst/agent.yaml b/runs/smoke_fullstack/agents/fundamentals_analyst/agent.yaml new file mode 100644 index 0000000..3d8b74d --- /dev/null +++ b/runs/smoke_fullstack/agents/fundamentals_analyst/agent.yaml @@ -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: [] diff --git a/runs/smoke_fullstack/agents/fundamentals_analyst/skills/active/fundamental_review/SKILL.md b/runs/smoke_fullstack/agents/fundamentals_analyst/skills/active/fundamental_review/SKILL.md new file mode 100644 index 0000000..936de49 --- /dev/null +++ b/runs/smoke_fullstack/agents/fundamentals_analyst/skills/active/fundamental_review/SKILL.md @@ -0,0 +1,22 @@ +--- +name: 基本面分析 +description: 当用户要求“基本面分析”“看财务质量”“分析盈利能力”“判断公司质量”或“评估长期盈利韧性”时,应使用此技能。 +version: 1.0.0 +--- + +# 基本面分析 + +当用户希望从公司质量、资产负债表强度、盈利能力或长期盈利韧性出发判断标的时,使用这个技能。 + +## 工作流程 + +1. 在形成结论前,先检查盈利能力、成长性、财务健康度和经营效率。 +2. 区分可持续的业务质量和短期噪音。 +3. 明确指出会推翻当前判断的条件。 +4. 最终给出清晰的信号、置信度和主要驱动因素。 + +## 约束 + +- 不要孤立依赖单一指标。 +- 缺失数据要明确指出。 +- 当财务质量优劣混杂时,优先给出保守结论。 diff --git a/runs/smoke_fullstack/agents/fundamentals_analyst/skills/active/portfolio_decisioning/SKILL.md b/runs/smoke_fullstack/agents/fundamentals_analyst/skills/active/portfolio_decisioning/SKILL.md new file mode 100644 index 0000000..444cc21 --- /dev/null +++ b/runs/smoke_fullstack/agents/fundamentals_analyst/skills/active/portfolio_decisioning/SKILL.md @@ -0,0 +1,21 @@ +--- +name: 组合决策 +description: 整合分析师观点与风险反馈,形成明确的组合层决策。 +--- + +# 组合决策 + +当你负责把团队分析转化为最终交易决策时,使用这个技能。 + +## 工作流程 + +1. 行动前先阅读分析师结论和风险警示。 +2. 评估当前组合、现金和保证金约束。 +3. 使用决策工具为每个 ticker 记录一个明确决策。 +4. 在全部决策记录完成后,总结组合层面的整体理由。 + +## 约束 + +- 仓位大小必须遵守资金和保证金限制。 +- 当分析师信心与风险信号不一致时,优先采用更小仓位。 +- 当任务要求完整决策清单时,不要让任何 ticker 处于未决状态。 diff --git a/runs/smoke_fullstack/agents/fundamentals_analyst/skills/installed/fundamental_review/SKILL.md b/runs/smoke_fullstack/agents/fundamentals_analyst/skills/installed/fundamental_review/SKILL.md new file mode 100644 index 0000000..936de49 --- /dev/null +++ b/runs/smoke_fullstack/agents/fundamentals_analyst/skills/installed/fundamental_review/SKILL.md @@ -0,0 +1,22 @@ +--- +name: 基本面分析 +description: 当用户要求“基本面分析”“看财务质量”“分析盈利能力”“判断公司质量”或“评估长期盈利韧性”时,应使用此技能。 +version: 1.0.0 +--- + +# 基本面分析 + +当用户希望从公司质量、资产负债表强度、盈利能力或长期盈利韧性出发判断标的时,使用这个技能。 + +## 工作流程 + +1. 在形成结论前,先检查盈利能力、成长性、财务健康度和经营效率。 +2. 区分可持续的业务质量和短期噪音。 +3. 明确指出会推翻当前判断的条件。 +4. 最终给出清晰的信号、置信度和主要驱动因素。 + +## 约束 + +- 不要孤立依赖单一指标。 +- 缺失数据要明确指出。 +- 当财务质量优劣混杂时,优先给出保守结论。 diff --git a/runs/smoke_fullstack/agents/fundamentals_analyst/skills/installed/portfolio_decisioning/SKILL.md b/runs/smoke_fullstack/agents/fundamentals_analyst/skills/installed/portfolio_decisioning/SKILL.md new file mode 100644 index 0000000..444cc21 --- /dev/null +++ b/runs/smoke_fullstack/agents/fundamentals_analyst/skills/installed/portfolio_decisioning/SKILL.md @@ -0,0 +1,21 @@ +--- +name: 组合决策 +description: 整合分析师观点与风险反馈,形成明确的组合层决策。 +--- + +# 组合决策 + +当你负责把团队分析转化为最终交易决策时,使用这个技能。 + +## 工作流程 + +1. 行动前先阅读分析师结论和风险警示。 +2. 评估当前组合、现金和保证金约束。 +3. 使用决策工具为每个 ticker 记录一个明确决策。 +4. 在全部决策记录完成后,总结组合层面的整体理由。 + +## 约束 + +- 仓位大小必须遵守资金和保证金限制。 +- 当分析师信心与风险信号不一致时,优先采用更小仓位。 +- 当任务要求完整决策清单时,不要让任何 ticker 处于未决状态。 diff --git a/runs/smoke_fullstack/agents/portfolio_manager/AGENTS.md b/runs/smoke_fullstack/agents/portfolio_manager/AGENTS.md new file mode 100644 index 0000000..f620aea --- /dev/null +++ b/runs/smoke_fullstack/agents/portfolio_manager/AGENTS.md @@ -0,0 +1,4 @@ +# Agent Guide + +Document how this agent should work, collaborate, and choose tools or skills. + diff --git a/runs/smoke_fullstack/agents/portfolio_manager/HEARTBEAT.md b/runs/smoke_fullstack/agents/portfolio_manager/HEARTBEAT.md new file mode 100644 index 0000000..dc892f7 --- /dev/null +++ b/runs/smoke_fullstack/agents/portfolio_manager/HEARTBEAT.md @@ -0,0 +1,4 @@ +# Heartbeat + +Optional checklist for periodic review or self-reflection. + diff --git a/runs/smoke_fullstack/agents/portfolio_manager/MEMORY.md b/runs/smoke_fullstack/agents/portfolio_manager/MEMORY.md new file mode 100644 index 0000000..701fc0c --- /dev/null +++ b/runs/smoke_fullstack/agents/portfolio_manager/MEMORY.md @@ -0,0 +1,4 @@ +# Memory + +Store durable lessons, heuristics, and reminders for this agent. + diff --git a/runs/smoke_fullstack/agents/portfolio_manager/PROFILE.md b/runs/smoke_fullstack/agents/portfolio_manager/PROFILE.md new file mode 100644 index 0000000..d7412de --- /dev/null +++ b/runs/smoke_fullstack/agents/portfolio_manager/PROFILE.md @@ -0,0 +1,4 @@ +# Profile + +Track this agent's long-lived investment style, preferences, and strengths. + diff --git a/runs/smoke_fullstack/agents/portfolio_manager/SOUL.md b/runs/smoke_fullstack/agents/portfolio_manager/SOUL.md new file mode 100644 index 0000000..85402e4 --- /dev/null +++ b/runs/smoke_fullstack/agents/portfolio_manager/SOUL.md @@ -0,0 +1,4 @@ +# Soul + +Describe the agent's temperament, reasoning posture, and voice. + diff --git a/runs/smoke_fullstack/agents/portfolio_manager/agent.yaml b/runs/smoke_fullstack/agents/portfolio_manager/agent.yaml new file mode 100644 index 0000000..6c6b8cb --- /dev/null +++ b/runs/smoke_fullstack/agents/portfolio_manager/agent.yaml @@ -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: [] diff --git a/runs/smoke_fullstack/agents/portfolio_manager/skills/active/portfolio_decisioning/SKILL.md b/runs/smoke_fullstack/agents/portfolio_manager/skills/active/portfolio_decisioning/SKILL.md new file mode 100644 index 0000000..444cc21 --- /dev/null +++ b/runs/smoke_fullstack/agents/portfolio_manager/skills/active/portfolio_decisioning/SKILL.md @@ -0,0 +1,21 @@ +--- +name: 组合决策 +description: 整合分析师观点与风险反馈,形成明确的组合层决策。 +--- + +# 组合决策 + +当你负责把团队分析转化为最终交易决策时,使用这个技能。 + +## 工作流程 + +1. 行动前先阅读分析师结论和风险警示。 +2. 评估当前组合、现金和保证金约束。 +3. 使用决策工具为每个 ticker 记录一个明确决策。 +4. 在全部决策记录完成后,总结组合层面的整体理由。 + +## 约束 + +- 仓位大小必须遵守资金和保证金限制。 +- 当分析师信心与风险信号不一致时,优先采用更小仓位。 +- 当任务要求完整决策清单时,不要让任何 ticker 处于未决状态。 diff --git a/runs/smoke_fullstack/agents/portfolio_manager/skills/installed/portfolio_decisioning/SKILL.md b/runs/smoke_fullstack/agents/portfolio_manager/skills/installed/portfolio_decisioning/SKILL.md new file mode 100644 index 0000000..444cc21 --- /dev/null +++ b/runs/smoke_fullstack/agents/portfolio_manager/skills/installed/portfolio_decisioning/SKILL.md @@ -0,0 +1,21 @@ +--- +name: 组合决策 +description: 整合分析师观点与风险反馈,形成明确的组合层决策。 +--- + +# 组合决策 + +当你负责把团队分析转化为最终交易决策时,使用这个技能。 + +## 工作流程 + +1. 行动前先阅读分析师结论和风险警示。 +2. 评估当前组合、现金和保证金约束。 +3. 使用决策工具为每个 ticker 记录一个明确决策。 +4. 在全部决策记录完成后,总结组合层面的整体理由。 + +## 约束 + +- 仓位大小必须遵守资金和保证金限制。 +- 当分析师信心与风险信号不一致时,优先采用更小仓位。 +- 当任务要求完整决策清单时,不要让任何 ticker 处于未决状态。 diff --git a/runs/smoke_fullstack/agents/risk_manager/AGENTS.md b/runs/smoke_fullstack/agents/risk_manager/AGENTS.md new file mode 100644 index 0000000..f620aea --- /dev/null +++ b/runs/smoke_fullstack/agents/risk_manager/AGENTS.md @@ -0,0 +1,4 @@ +# Agent Guide + +Document how this agent should work, collaborate, and choose tools or skills. + diff --git a/runs/smoke_fullstack/agents/risk_manager/HEARTBEAT.md b/runs/smoke_fullstack/agents/risk_manager/HEARTBEAT.md new file mode 100644 index 0000000..dc892f7 --- /dev/null +++ b/runs/smoke_fullstack/agents/risk_manager/HEARTBEAT.md @@ -0,0 +1,4 @@ +# Heartbeat + +Optional checklist for periodic review or self-reflection. + diff --git a/runs/smoke_fullstack/agents/risk_manager/MEMORY.md b/runs/smoke_fullstack/agents/risk_manager/MEMORY.md new file mode 100644 index 0000000..701fc0c --- /dev/null +++ b/runs/smoke_fullstack/agents/risk_manager/MEMORY.md @@ -0,0 +1,4 @@ +# Memory + +Store durable lessons, heuristics, and reminders for this agent. + diff --git a/runs/smoke_fullstack/agents/risk_manager/PROFILE.md b/runs/smoke_fullstack/agents/risk_manager/PROFILE.md new file mode 100644 index 0000000..d7412de --- /dev/null +++ b/runs/smoke_fullstack/agents/risk_manager/PROFILE.md @@ -0,0 +1,4 @@ +# Profile + +Track this agent's long-lived investment style, preferences, and strengths. + diff --git a/runs/smoke_fullstack/agents/risk_manager/SOUL.md b/runs/smoke_fullstack/agents/risk_manager/SOUL.md new file mode 100644 index 0000000..85402e4 --- /dev/null +++ b/runs/smoke_fullstack/agents/risk_manager/SOUL.md @@ -0,0 +1,4 @@ +# Soul + +Describe the agent's temperament, reasoning posture, and voice. + diff --git a/runs/smoke_fullstack/agents/risk_manager/agent.yaml b/runs/smoke_fullstack/agents/risk_manager/agent.yaml new file mode 100644 index 0000000..7ca4e5e --- /dev/null +++ b/runs/smoke_fullstack/agents/risk_manager/agent.yaml @@ -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: [] diff --git a/runs/smoke_fullstack/agents/risk_manager/skills/active/risk_review/SKILL.md b/runs/smoke_fullstack/agents/risk_manager/skills/active/risk_review/SKILL.md new file mode 100644 index 0000000..11f0b87 --- /dev/null +++ b/runs/smoke_fullstack/agents/risk_manager/skills/active/risk_review/SKILL.md @@ -0,0 +1,21 @@ +--- +name: 风险审查 +description: 在最终仓位和执行前,评估组合与市场风险。 +--- + +# 风险审查 + +当你需要识别集中度、波动率、杠杆和情景风险时,使用这个技能。 + +## 工作流程 + +1. 按 ticker 和主题检查拟议敞口。 +2. 识别集中度、波动率、流动性和杠杆方面的风险点。 +3. 按严重程度排序风险警示。 +4. 将风险结论转化为给投资经理的具体限制或注意事项。 + +## 约束 + +- 聚焦可执行的风险控制措施。 +- 当数据支持时尽量量化限制。 +- 明确区分致命阻断项和可管理风险。 diff --git a/runs/smoke_fullstack/agents/risk_manager/skills/installed/risk_review/SKILL.md b/runs/smoke_fullstack/agents/risk_manager/skills/installed/risk_review/SKILL.md new file mode 100644 index 0000000..11f0b87 --- /dev/null +++ b/runs/smoke_fullstack/agents/risk_manager/skills/installed/risk_review/SKILL.md @@ -0,0 +1,21 @@ +--- +name: 风险审查 +description: 在最终仓位和执行前,评估组合与市场风险。 +--- + +# 风险审查 + +当你需要识别集中度、波动率、杠杆和情景风险时,使用这个技能。 + +## 工作流程 + +1. 按 ticker 和主题检查拟议敞口。 +2. 识别集中度、波动率、流动性和杠杆方面的风险点。 +3. 按严重程度排序风险警示。 +4. 将风险结论转化为给投资经理的具体限制或注意事项。 + +## 约束 + +- 聚焦可执行的风险控制措施。 +- 当数据支持时尽量量化限制。 +- 明确区分致命阻断项和可管理风险。 diff --git a/runs/smoke_fullstack/agents/sentiment_analyst/AGENTS.md b/runs/smoke_fullstack/agents/sentiment_analyst/AGENTS.md new file mode 100644 index 0000000..f620aea --- /dev/null +++ b/runs/smoke_fullstack/agents/sentiment_analyst/AGENTS.md @@ -0,0 +1,4 @@ +# Agent Guide + +Document how this agent should work, collaborate, and choose tools or skills. + diff --git a/runs/smoke_fullstack/agents/sentiment_analyst/HEARTBEAT.md b/runs/smoke_fullstack/agents/sentiment_analyst/HEARTBEAT.md new file mode 100644 index 0000000..dc892f7 --- /dev/null +++ b/runs/smoke_fullstack/agents/sentiment_analyst/HEARTBEAT.md @@ -0,0 +1,4 @@ +# Heartbeat + +Optional checklist for periodic review or self-reflection. + diff --git a/runs/smoke_fullstack/agents/sentiment_analyst/MEMORY.md b/runs/smoke_fullstack/agents/sentiment_analyst/MEMORY.md new file mode 100644 index 0000000..701fc0c --- /dev/null +++ b/runs/smoke_fullstack/agents/sentiment_analyst/MEMORY.md @@ -0,0 +1,4 @@ +# Memory + +Store durable lessons, heuristics, and reminders for this agent. + diff --git a/runs/smoke_fullstack/agents/sentiment_analyst/PROFILE.md b/runs/smoke_fullstack/agents/sentiment_analyst/PROFILE.md new file mode 100644 index 0000000..d7412de --- /dev/null +++ b/runs/smoke_fullstack/agents/sentiment_analyst/PROFILE.md @@ -0,0 +1,4 @@ +# Profile + +Track this agent's long-lived investment style, preferences, and strengths. + diff --git a/runs/smoke_fullstack/agents/sentiment_analyst/SOUL.md b/runs/smoke_fullstack/agents/sentiment_analyst/SOUL.md new file mode 100644 index 0000000..85402e4 --- /dev/null +++ b/runs/smoke_fullstack/agents/sentiment_analyst/SOUL.md @@ -0,0 +1,4 @@ +# Soul + +Describe the agent's temperament, reasoning posture, and voice. + diff --git a/runs/smoke_fullstack/agents/sentiment_analyst/agent.yaml b/runs/smoke_fullstack/agents/sentiment_analyst/agent.yaml new file mode 100644 index 0000000..8fb67b8 --- /dev/null +++ b/runs/smoke_fullstack/agents/sentiment_analyst/agent.yaml @@ -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: [] diff --git a/runs/smoke_fullstack/agents/sentiment_analyst/skills/active/sentiment_review/SKILL.md b/runs/smoke_fullstack/agents/sentiment_analyst/skills/active/sentiment_review/SKILL.md new file mode 100644 index 0000000..2604497 --- /dev/null +++ b/runs/smoke_fullstack/agents/sentiment_analyst/skills/active/sentiment_review/SKILL.md @@ -0,0 +1,21 @@ +--- +name: 情绪分析 +description: 分析新闻流、市场心理和内幕行为,识别事件驱动型信号。 +--- + +# 情绪分析 + +当任务依赖近期催化剂、新闻语气或行为层面的市场信号时,使用这个技能。 + +## 工作流程 + +1. 回顾近期新闻并识别主导叙事。 +2. 检查内幕活动,寻找确认或冲突信号。 +3. 区分可持续的情绪变化和短暂噪音。 +4. 说明情绪如何改变短期交易展望。 + +## 约束 + +- 不要把注意力误判为真实信念。 +- 当情绪很强但缺乏基本面支持时,要明确指出。 +- 对催化剂时间窗口风险要说清楚。 diff --git a/runs/smoke_fullstack/agents/sentiment_analyst/skills/installed/sentiment_review/SKILL.md b/runs/smoke_fullstack/agents/sentiment_analyst/skills/installed/sentiment_review/SKILL.md new file mode 100644 index 0000000..2604497 --- /dev/null +++ b/runs/smoke_fullstack/agents/sentiment_analyst/skills/installed/sentiment_review/SKILL.md @@ -0,0 +1,21 @@ +--- +name: 情绪分析 +description: 分析新闻流、市场心理和内幕行为,识别事件驱动型信号。 +--- + +# 情绪分析 + +当任务依赖近期催化剂、新闻语气或行为层面的市场信号时,使用这个技能。 + +## 工作流程 + +1. 回顾近期新闻并识别主导叙事。 +2. 检查内幕活动,寻找确认或冲突信号。 +3. 区分可持续的情绪变化和短暂噪音。 +4. 说明情绪如何改变短期交易展望。 + +## 约束 + +- 不要把注意力误判为真实信念。 +- 当情绪很强但缺乏基本面支持时,要明确指出。 +- 对催化剂时间窗口风险要说清楚。 diff --git a/runs/smoke_fullstack/agents/technical_analyst/AGENTS.md b/runs/smoke_fullstack/agents/technical_analyst/AGENTS.md new file mode 100644 index 0000000..f620aea --- /dev/null +++ b/runs/smoke_fullstack/agents/technical_analyst/AGENTS.md @@ -0,0 +1,4 @@ +# Agent Guide + +Document how this agent should work, collaborate, and choose tools or skills. + diff --git a/runs/smoke_fullstack/agents/technical_analyst/HEARTBEAT.md b/runs/smoke_fullstack/agents/technical_analyst/HEARTBEAT.md new file mode 100644 index 0000000..dc892f7 --- /dev/null +++ b/runs/smoke_fullstack/agents/technical_analyst/HEARTBEAT.md @@ -0,0 +1,4 @@ +# Heartbeat + +Optional checklist for periodic review or self-reflection. + diff --git a/runs/smoke_fullstack/agents/technical_analyst/MEMORY.md b/runs/smoke_fullstack/agents/technical_analyst/MEMORY.md new file mode 100644 index 0000000..701fc0c --- /dev/null +++ b/runs/smoke_fullstack/agents/technical_analyst/MEMORY.md @@ -0,0 +1,4 @@ +# Memory + +Store durable lessons, heuristics, and reminders for this agent. + diff --git a/runs/smoke_fullstack/agents/technical_analyst/PROFILE.md b/runs/smoke_fullstack/agents/technical_analyst/PROFILE.md new file mode 100644 index 0000000..d7412de --- /dev/null +++ b/runs/smoke_fullstack/agents/technical_analyst/PROFILE.md @@ -0,0 +1,4 @@ +# Profile + +Track this agent's long-lived investment style, preferences, and strengths. + diff --git a/runs/smoke_fullstack/agents/technical_analyst/SOUL.md b/runs/smoke_fullstack/agents/technical_analyst/SOUL.md new file mode 100644 index 0000000..85402e4 --- /dev/null +++ b/runs/smoke_fullstack/agents/technical_analyst/SOUL.md @@ -0,0 +1,4 @@ +# Soul + +Describe the agent's temperament, reasoning posture, and voice. + diff --git a/runs/smoke_fullstack/agents/technical_analyst/agent.yaml b/runs/smoke_fullstack/agents/technical_analyst/agent.yaml new file mode 100644 index 0000000..b165b42 --- /dev/null +++ b/runs/smoke_fullstack/agents/technical_analyst/agent.yaml @@ -0,0 +1,12 @@ +agent_id: technical_analyst +prompt_files: +- SOUL.md +- PROFILE.md +- AGENTS.md +- POLICY.md +- MEMORY.md +enabled_skills: +- technical_review +disabled_skills: [] +active_tool_groups: [] +disabled_tool_groups: [] diff --git a/runs/smoke_fullstack/agents/technical_analyst/skills/active/technical_review/SKILL.md b/runs/smoke_fullstack/agents/technical_analyst/skills/active/technical_review/SKILL.md new file mode 100644 index 0000000..e783c2a --- /dev/null +++ b/runs/smoke_fullstack/agents/technical_analyst/skills/active/technical_review/SKILL.md @@ -0,0 +1,21 @@ +--- +name: 技术分析 +description: 评估价格行为、动量和波动率,用于判断时机和市场状态。 +--- + +# 技术分析 + +当任务对入场时机、趋势质量或短期市场结构敏感时,使用这个技能。 + +## 工作流程 + +1. 评估趋势方向和强度。 +2. 检查动量与均值回归条件。 +3. 在给出激进建议前先审视波动率。 +4. 将当前形态转化为带有明确风险意识的交易观点。 + +## 约束 + +- 区分趋势延续和过度透支。 +- 当信号冲突时避免给出高确定性判断。 +- 将波动率视为仓位输入,而不仅仅是方向输入。 diff --git a/runs/smoke_fullstack/agents/technical_analyst/skills/installed/technical_review/SKILL.md b/runs/smoke_fullstack/agents/technical_analyst/skills/installed/technical_review/SKILL.md new file mode 100644 index 0000000..e783c2a --- /dev/null +++ b/runs/smoke_fullstack/agents/technical_analyst/skills/installed/technical_review/SKILL.md @@ -0,0 +1,21 @@ +--- +name: 技术分析 +description: 评估价格行为、动量和波动率,用于判断时机和市场状态。 +--- + +# 技术分析 + +当任务对入场时机、趋势质量或短期市场结构敏感时,使用这个技能。 + +## 工作流程 + +1. 评估趋势方向和强度。 +2. 检查动量与均值回归条件。 +3. 在给出激进建议前先审视波动率。 +4. 将当前形态转化为带有明确风险意识的交易观点。 + +## 约束 + +- 区分趋势延续和过度透支。 +- 当信号冲突时避免给出高确定性判断。 +- 将波动率视为仓位输入,而不仅仅是方向输入。 diff --git a/runs/smoke_fullstack/agents/valuation_analyst/AGENTS.md b/runs/smoke_fullstack/agents/valuation_analyst/AGENTS.md new file mode 100644 index 0000000..f620aea --- /dev/null +++ b/runs/smoke_fullstack/agents/valuation_analyst/AGENTS.md @@ -0,0 +1,4 @@ +# Agent Guide + +Document how this agent should work, collaborate, and choose tools or skills. + diff --git a/runs/smoke_fullstack/agents/valuation_analyst/HEARTBEAT.md b/runs/smoke_fullstack/agents/valuation_analyst/HEARTBEAT.md new file mode 100644 index 0000000..dc892f7 --- /dev/null +++ b/runs/smoke_fullstack/agents/valuation_analyst/HEARTBEAT.md @@ -0,0 +1,4 @@ +# Heartbeat + +Optional checklist for periodic review or self-reflection. + diff --git a/runs/smoke_fullstack/agents/valuation_analyst/MEMORY.md b/runs/smoke_fullstack/agents/valuation_analyst/MEMORY.md new file mode 100644 index 0000000..701fc0c --- /dev/null +++ b/runs/smoke_fullstack/agents/valuation_analyst/MEMORY.md @@ -0,0 +1,4 @@ +# Memory + +Store durable lessons, heuristics, and reminders for this agent. + diff --git a/runs/smoke_fullstack/agents/valuation_analyst/PROFILE.md b/runs/smoke_fullstack/agents/valuation_analyst/PROFILE.md new file mode 100644 index 0000000..d7412de --- /dev/null +++ b/runs/smoke_fullstack/agents/valuation_analyst/PROFILE.md @@ -0,0 +1,4 @@ +# Profile + +Track this agent's long-lived investment style, preferences, and strengths. + diff --git a/runs/smoke_fullstack/agents/valuation_analyst/SOUL.md b/runs/smoke_fullstack/agents/valuation_analyst/SOUL.md new file mode 100644 index 0000000..85402e4 --- /dev/null +++ b/runs/smoke_fullstack/agents/valuation_analyst/SOUL.md @@ -0,0 +1,4 @@ +# Soul + +Describe the agent's temperament, reasoning posture, and voice. + diff --git a/runs/smoke_fullstack/agents/valuation_analyst/agent.yaml b/runs/smoke_fullstack/agents/valuation_analyst/agent.yaml new file mode 100644 index 0000000..7512b59 --- /dev/null +++ b/runs/smoke_fullstack/agents/valuation_analyst/agent.yaml @@ -0,0 +1,12 @@ +agent_id: valuation_analyst +prompt_files: +- SOUL.md +- PROFILE.md +- AGENTS.md +- POLICY.md +- MEMORY.md +enabled_skills: +- valuation_review +disabled_skills: [] +active_tool_groups: [] +disabled_tool_groups: [] diff --git a/runs/smoke_fullstack/agents/valuation_analyst/skills/active/valuation_review/SKILL.md b/runs/smoke_fullstack/agents/valuation_analyst/skills/active/valuation_review/SKILL.md new file mode 100644 index 0000000..7eea59e --- /dev/null +++ b/runs/smoke_fullstack/agents/valuation_analyst/skills/active/valuation_review/SKILL.md @@ -0,0 +1,21 @@ +--- +name: 估值分析 +description: 使用多种估值视角评估合理价值和安全边际。 +--- + +# 估值分析 + +当任务需要判断一只股票是低估、高估还是定价合理时,使用这个技能。 + +## 工作流程 + +1. 条件允许时,使用不止一种估值方法。 +2. 对比内在价值估计与当前市场价格。 +3. 解释估值判断背后的关键假设。 +4. 明确安全边际,以及哪些因素会压缩或扩大它。 + +## 约束 + +- 将估值视为区间,而不是一个精确点值。 +- 明确说明假设敏感性。 +- 当输入稀疏或不稳定时,避免给出高置信度判断。 diff --git a/runs/smoke_fullstack/agents/valuation_analyst/skills/installed/valuation_review/SKILL.md b/runs/smoke_fullstack/agents/valuation_analyst/skills/installed/valuation_review/SKILL.md new file mode 100644 index 0000000..7eea59e --- /dev/null +++ b/runs/smoke_fullstack/agents/valuation_analyst/skills/installed/valuation_review/SKILL.md @@ -0,0 +1,21 @@ +--- +name: 估值分析 +description: 使用多种估值视角评估合理价值和安全边际。 +--- + +# 估值分析 + +当任务需要判断一只股票是低估、高估还是定价合理时,使用这个技能。 + +## 工作流程 + +1. 条件允许时,使用不止一种估值方法。 +2. 对比内在价值估计与当前市场价格。 +3. 解释估值判断背后的关键假设。 +4. 明确安全边际,以及哪些因素会压缩或扩大它。 + +## 约束 + +- 将估值视为区间,而不是一个精确点值。 +- 明确说明假设敏感性。 +- 当输入稀疏或不稳定时,避免给出高置信度判断。 diff --git a/runs/smoke_fullstack/skills/active/fundamental_review/SKILL.md b/runs/smoke_fullstack/skills/active/fundamental_review/SKILL.md index 95aab74..936de49 100644 --- a/runs/smoke_fullstack/skills/active/fundamental_review/SKILL.md +++ b/runs/smoke_fullstack/skills/active/fundamental_review/SKILL.md @@ -1,21 +1,22 @@ --- -name: fundamental_review -description: Review a company from a fundamentals-first perspective before issuing a trading signal. +name: 基本面分析 +description: 当用户要求“基本面分析”“看财务质量”“分析盈利能力”“判断公司质量”或“评估长期盈利韧性”时,应使用此技能。 +version: 1.0.0 --- -# Fundamental Review +# 基本面分析 -Use this skill when the task requires judging business quality, balance-sheet strength, profitability, or long-term earnings durability. +当用户希望从公司质量、资产负债表强度、盈利能力或长期盈利韧性出发判断标的时,使用这个技能。 -## Workflow +## 工作流程 -1. Check profitability, growth, financial health, and efficiency before forming a conclusion. -2. Separate durable business quality from short-term noise. -3. State what would invalidate the thesis. -4. End with a clear signal, confidence, and the main drivers behind that signal. +1. 在形成结论前,先检查盈利能力、成长性、财务健康度和经营效率。 +2. 区分可持续的业务质量和短期噪音。 +3. 明确指出会推翻当前判断的条件。 +4. 最终给出清晰的信号、置信度和主要驱动因素。 -## Guardrails +## 约束 -- Do not rely on one metric in isolation. -- Call out missing data explicitly. -- Prefer conservative conclusions when financial quality is mixed. +- 不要孤立依赖单一指标。 +- 缺失数据要明确指出。 +- 当财务质量优劣混杂时,优先给出保守结论。 diff --git a/runs/smoke_fullstack/skills/active/portfolio_decisioning/SKILL.md b/runs/smoke_fullstack/skills/active/portfolio_decisioning/SKILL.md index f4ad73e..444cc21 100644 --- a/runs/smoke_fullstack/skills/active/portfolio_decisioning/SKILL.md +++ b/runs/smoke_fullstack/skills/active/portfolio_decisioning/SKILL.md @@ -1,21 +1,21 @@ --- -name: portfolio_decisioning -description: Synthesize analyst inputs and risk feedback into explicit portfolio decisions. +name: 组合决策 +description: 整合分析师观点与风险反馈,形成明确的组合层决策。 --- -# Portfolio Decisioning +# 组合决策 -Use this skill when you are responsible for converting team analysis into final trades. +当你负责把团队分析转化为最终交易决策时,使用这个技能。 -## Workflow +## 工作流程 -1. Read analyst conclusions and risk warnings before acting. -2. Evaluate the current portfolio, cash, and margin constraints. -3. Record one explicit decision per ticker using the decision tool. -4. Summarize the portfolio-level rationale after all decisions are recorded. +1. 行动前先阅读分析师结论和风险警示。 +2. 评估当前组合、现金和保证金约束。 +3. 使用决策工具为每个 ticker 记录一个明确决策。 +4. 在全部决策记录完成后,总结组合层面的整体理由。 -## Guardrails +## 约束 -- Position sizing must respect capital and margin limits. -- Prefer smaller size when analyst conviction and risk signals disagree. -- Do not leave a ticker undecided when the task expects a full slate of decisions. +- 仓位大小必须遵守资金和保证金限制。 +- 当分析师信心与风险信号不一致时,优先采用更小仓位。 +- 当任务要求完整决策清单时,不要让任何 ticker 处于未决状态。 diff --git a/runs/smoke_fullstack/skills/active/risk_review/SKILL.md b/runs/smoke_fullstack/skills/active/risk_review/SKILL.md index da4f93a..11f0b87 100644 --- a/runs/smoke_fullstack/skills/active/risk_review/SKILL.md +++ b/runs/smoke_fullstack/skills/active/risk_review/SKILL.md @@ -1,21 +1,21 @@ --- -name: risk_review -description: Assess portfolio and market risks before final position sizing and execution. +name: 风险审查 +description: 在最终仓位和执行前,评估组合与市场风险。 --- -# Risk Review +# 风险审查 -Use this skill when you must identify concentration, volatility, leverage, and scenario risks. +当你需要识别集中度、波动率、杠杆和情景风险时,使用这个技能。 -## Workflow +## 工作流程 -1. Review the proposed exposure by ticker and theme. -2. Identify concentration, volatility, liquidity, and leverage concerns. -3. Rank warnings by severity. -4. Translate risk findings into concrete limits or cautions for the portfolio manager. +1. 按 ticker 和主题检查拟议敞口。 +2. 识别集中度、波动率、流动性和杠杆方面的风险点。 +3. 按严重程度排序风险警示。 +4. 将风险结论转化为给投资经理的具体限制或注意事项。 -## Guardrails +## 约束 -- Focus on actionable risk controls. -- Quantify limits when the available data supports it. -- Distinguish fatal blockers from manageable risks. +- 聚焦可执行的风险控制措施。 +- 当数据支持时尽量量化限制。 +- 明确区分致命阻断项和可管理风险。 diff --git a/runs/smoke_fullstack/skills/active/sentiment_review/SKILL.md b/runs/smoke_fullstack/skills/active/sentiment_review/SKILL.md index 71db6d5..2604497 100644 --- a/runs/smoke_fullstack/skills/active/sentiment_review/SKILL.md +++ b/runs/smoke_fullstack/skills/active/sentiment_review/SKILL.md @@ -1,21 +1,21 @@ --- -name: sentiment_review -description: Analyze news flow, market psychology, and insider behavior for catalyst-driven signals. +name: 情绪分析 +description: 分析新闻流、市场心理和内幕行为,识别事件驱动型信号。 --- -# Sentiment Review +# 情绪分析 -Use this skill when the task depends on recent catalysts, news tone, or behavioral market signals. +当任务依赖近期催化剂、新闻语气或行为层面的市场信号时,使用这个技能。 -## Workflow +## 工作流程 -1. Review recent news and identify the dominant narrative. -2. Check insider activity for confirming or conflicting signals. -3. Separate durable sentiment shifts from transient noise. -4. Explain how sentiment changes the near-term trade outlook. +1. 回顾近期新闻并识别主导叙事。 +2. 检查内幕活动,寻找确认或冲突信号。 +3. 区分可持续的情绪变化和短暂噪音。 +4. 说明情绪如何改变短期交易展望。 -## Guardrails +## 约束 -- Do not confuse attention with conviction. -- Highlight when sentiment is strong but unsupported by fundamentals. -- Be explicit about catalyst timing risk. +- 不要把注意力误判为真实信念。 +- 当情绪很强但缺乏基本面支持时,要明确指出。 +- 对催化剂时间窗口风险要说清楚。 diff --git a/runs/smoke_fullstack/skills/active/technical_review/SKILL.md b/runs/smoke_fullstack/skills/active/technical_review/SKILL.md index 329c0a8..e783c2a 100644 --- a/runs/smoke_fullstack/skills/active/technical_review/SKILL.md +++ b/runs/smoke_fullstack/skills/active/technical_review/SKILL.md @@ -1,21 +1,21 @@ --- -name: technical_review -description: Evaluate price action, momentum, and volatility to judge timing and market regime. +name: 技术分析 +description: 评估价格行为、动量和波动率,用于判断时机和市场状态。 --- -# Technical Review +# 技术分析 -Use this skill when the task is sensitive to entry timing, trend quality, or short-term market structure. +当任务对入场时机、趋势质量或短期市场结构敏感时,使用这个技能。 -## Workflow +## 工作流程 -1. Assess trend direction and strength. -2. Check momentum and mean-reversion conditions. -3. Review volatility before making aggressive recommendations. -4. Convert the setup into a trading view with explicit risk awareness. +1. 评估趋势方向和强度。 +2. 检查动量与均值回归条件。 +3. 在给出激进建议前先审视波动率。 +4. 将当前形态转化为带有明确风险意识的交易观点。 -## Guardrails +## 约束 -- Distinguish trend continuation from overshoot. -- Avoid strong conviction when signals conflict. -- Treat volatility as a sizing input, not only a directional input. +- 区分趋势延续和过度透支。 +- 当信号冲突时避免给出高确定性判断。 +- 将波动率视为仓位输入,而不仅仅是方向输入。 diff --git a/runs/smoke_fullstack/skills/active/valuation_review/SKILL.md b/runs/smoke_fullstack/skills/active/valuation_review/SKILL.md index 9cfa1ff..7eea59e 100644 --- a/runs/smoke_fullstack/skills/active/valuation_review/SKILL.md +++ b/runs/smoke_fullstack/skills/active/valuation_review/SKILL.md @@ -1,21 +1,21 @@ --- -name: valuation_review -description: Estimate fair value and margin of safety using multiple valuation lenses. +name: 估值分析 +description: 使用多种估值视角评估合理价值和安全边际。 --- -# Valuation Review +# 估值分析 -Use this skill when the task requires determining whether a stock is cheap, expensive, or fairly priced. +当任务需要判断一只股票是低估、高估还是定价合理时,使用这个技能。 -## Workflow +## 工作流程 -1. Use more than one valuation method when possible. -2. Compare intrinsic value estimates with current market pricing. -3. Explain the key assumptions behind the valuation view. -4. State the margin of safety and what could compress or expand it. +1. 条件允许时,使用不止一种估值方法。 +2. 对比内在价值估计与当前市场价格。 +3. 解释估值判断背后的关键假设。 +4. 明确安全边际,以及哪些因素会压缩或扩大它。 -## Guardrails +## 约束 -- Treat valuation as a range, not a single precise number. -- Call out assumption sensitivity. -- Avoid high-confidence calls when inputs are sparse or unstable. +- 将估值视为区间,而不是一个精确点值。 +- 明确说明假设敏感性。 +- 当输入稀疏或不稳定时,避免给出高置信度判断。