feat: Add agent workspace system and runtime management

- Add agent core modules (agent_core, factory, registry, skill_loader)
- Add runtime system for agent execution management
- Add REST API for agents, workspaces, and runtime control
- Add process supervisor for agent lifecycle management
- Add workspace template system with agent profiles
- Add frontend RuntimeView and runtime API integration
- Add per-agent skill workspaces for smoke_fullstack run
- Refactor skill system with active/installed separation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-17 16:43:29 +08:00
parent 2daf5717ba
commit 59b44545d0
121 changed files with 8384 additions and 358 deletions

View File

@@ -1,6 +1,57 @@
# -*- coding: utf-8 -*-
"""
Agents package - EvoAgent architecture for trading system.
Exports:
- EvoAgent: Next-generation agent with workspace support
- ToolGuardMixin: Tool call approval/denial flow
- CommandHandler: System command handling
- AgentFactory: Dynamic agent creation and management
- WorkspaceManager: Legacy name for the persistent workspace registry
- WorkspaceRegistry: Explicit run-time-agnostic workspace registry
- RunWorkspaceManager: Run-scoped workspace asset manager
- AgentRegistry: Central agent registry
- Legacy compatibility: AnalystAgent, PMAgent, RiskAgent
"""
# New EvoAgent architecture (from agent_core.py)
from .agent_core import EvoAgent, ToolGuardMixin, CommandHandler
from .factory import AgentFactory, ModelConfig, RoleConfig
from .workspace import WorkspaceManager, WorkspaceRegistry, WorkspaceConfig
from .workspace_manager import RunWorkspaceManager
from .registry import AgentRegistry, AgentInfo, get_registry, reset_registry
# Legacy agents (backward compatibility)
from .analyst import AnalystAgent
from .portfolio_manager import PMAgent
from .risk_manager import RiskAgent
__all__ = ["AnalystAgent", "PMAgent", "RiskAgent"]
# Compatibility layer
from .compat import LegacyAgentAdapter, adapt_agent, adapt_agents, is_legacy_agent
__all__ = [
# New architecture
"EvoAgent",
"ToolGuardMixin",
"CommandHandler",
"AgentFactory",
"ModelConfig",
"RoleConfig",
"WorkspaceManager",
"WorkspaceRegistry",
"WorkspaceConfig",
"RunWorkspaceManager",
"AgentRegistry",
"AgentInfo",
"get_registry",
"reset_registry",
# Legacy compatibility
"AnalystAgent",
"PMAgent",
"RiskAgent",
# Compatibility layer
"LegacyAgentAdapter",
"adapt_agent",
"adapt_agents",
"is_legacy_agent",
]

View File

@@ -0,0 +1,18 @@
# -*- coding: utf-8 -*-
"""
Compatibility layer for legacy imports.
This module re-exports the newer base implementations so existing import
paths (`from backend.agents.agent_core import EvoAgent`) continue to work while
centralizing the actual logic in `backend.agents.base.evo_agent`.
"""
from .base.command_handler import CommandHandler
from .base.evo_agent import EvoAgent
from .base.tool_guard import ToolGuardMixin
__all__ = [
"EvoAgent",
"ToolGuardMixin",
"CommandHandler",
]

View File

@@ -0,0 +1,23 @@
# -*- coding: utf-8 -*-
"""Base agent module for EvoTraders.
提供Agent基础类、命令处理、工具守卫和钩子管理等功能。
"""
# 命令处理器 (从command_handler.py导入)
from .command_handler import (
AgentCommandDispatcher,
CommandContext,
CommandHandler,
CommandResult,
create_command_dispatcher,
)
__all__ = [
# 命令处理
"AgentCommandDispatcher",
"CommandContext",
"CommandHandler",
"CommandResult",
"create_command_dispatcher",
]

View File

@@ -0,0 +1,543 @@
# -*- coding: utf-8 -*-
"""Agent command handler for system commands.
This module handles system commands like /save, /compact, /skills, /reload, etc.
参考CoPaw设计为EvoAgent提供命令处理能力。
"""
import logging
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from pathlib import Path
from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Protocol
if TYPE_CHECKING:
from .agent import EvoAgent
logger = logging.getLogger(__name__)
@dataclass
class CommandResult:
"""命令执行结果"""
success: bool
message: str
data: Dict[str, Any] = field(default_factory=dict)
class CommandContext:
"""命令执行上下文"""
def __init__(self, agent: "EvoAgent", raw_query: str, args: str = ""):
self.agent = agent
self.raw_query = raw_query
self.args = args
self.config_name = getattr(agent, "config_name", "default")
self.agent_id = getattr(agent, "agent_id", "unknown")
class CommandHandler(ABC):
"""命令处理器抽象基类"""
@abstractmethod
async def handle(self, ctx: CommandContext) -> CommandResult:
"""处理命令"""
pass
class SaveCommandHandler(CommandHandler):
"""处理 /save <message> 命令 - 保存内容到MEMORY.md"""
async def handle(self, ctx: CommandContext) -> CommandResult:
message = ctx.args.strip()
if not message:
return CommandResult(
success=False,
message="Usage: /save <message>\n请提供要保存的内容。"
)
try:
memory_path = self._get_memory_path(ctx)
memory_path.parent.mkdir(parents=True, exist_ok=True)
timestamp = self._get_timestamp()
entry = f"\n## {timestamp}\n\n{message}\n"
with open(memory_path, "a", encoding="utf-8") as f:
f.write(entry)
return CommandResult(
success=True,
message=f"✅ 内容已保存到 MEMORY.md\n- 路径: {memory_path}\n- 长度: {len(message)} 字符",
data={"path": str(memory_path), "length": len(message)}
)
except Exception as e:
logger.error(f"Failed to save to MEMORY.md: {e}")
return CommandResult(
success=False,
message=f"❌ 保存失败: {str(e)}"
)
def _get_memory_path(self, ctx: CommandContext) -> Path:
"""获取MEMORY.md路径"""
from backend.agents.skills_manager import SkillsManager
sm = SkillsManager()
asset_dir = sm.get_agent_asset_dir(ctx.config_name, ctx.agent_id)
return asset_dir / "MEMORY.md"
def _get_timestamp(self) -> str:
"""获取当前时间戳"""
from datetime import datetime
return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
class CompactCommandHandler(CommandHandler):
"""处理 /compact 命令 - 压缩记忆"""
async def handle(self, ctx: CommandContext) -> CommandResult:
try:
agent = ctx.agent
memory_manager = getattr(agent, "memory_manager", None)
if memory_manager is None:
return CommandResult(
success=False,
message="❌ Memory Manager 未启用\n\n- 记忆压缩功能不可用\n- 请在配置中启用 memory_manager"
)
messages = await self._get_messages(agent)
if not messages:
return CommandResult(
success=False,
message="⚠️ 没有可压缩的消息\n\n- 当前记忆为空\n- 无需执行压缩"
)
compact_content = await memory_manager.compact_memory(messages)
await self._update_compressed_summary(agent, compact_content)
return CommandResult(
success=True,
message=f"✅ 记忆压缩完成\n\n- 压缩了 {len(messages)} 条消息\n- 摘要长度: {len(compact_content)} 字符",
data={"message_count": len(messages), "summary_length": len(compact_content)}
)
except Exception as e:
logger.error(f"Failed to compact memory: {e}")
return CommandResult(
success=False,
message=f"❌ 压缩失败: {str(e)}"
)
async def _get_messages(self, agent: "EvoAgent") -> List[Any]:
"""获取Agent的记忆消息"""
memory = getattr(agent, "memory", None)
if memory is None:
return []
return await memory.get_memory() if hasattr(memory, "get_memory") else []
async def _update_compressed_summary(self, agent: "EvoAgent", content: str) -> None:
"""更新压缩摘要"""
memory = getattr(agent, "memory", None)
if memory and hasattr(memory, "update_compressed_summary"):
await memory.update_compressed_summary(content)
class SkillsListCommandHandler(CommandHandler):
"""处理 /skills list 命令 - 列出已激活技能"""
async def handle(self, ctx: CommandContext) -> CommandResult:
try:
from backend.agents.skills_manager import SkillsManager
sm = SkillsManager()
active_skills = sm.list_active_skill_metadata(ctx.config_name, ctx.agent_id)
catalog = sm.list_agent_skill_catalog(ctx.config_name, ctx.agent_id)
lines = ["📋 技能列表", ""]
if active_skills:
lines.append("✅ 已激活技能:")
for skill in active_skills:
lines.append(f"{skill.name} - {skill.description[:50]}...")
else:
lines.append("⚠️ 当前没有激活的技能")
lines.append("")
lines.append(f"📚 可用技能总数: {len(catalog)}")
lines.append("💡 使用 /skills enable <name> 启用技能")
return CommandResult(
success=True,
message="\n".join(lines),
data={
"active_count": len(active_skills),
"catalog_count": len(catalog),
"active": [s.skill_name for s in active_skills]
}
)
except Exception as e:
logger.error(f"Failed to list skills: {e}")
return CommandResult(
success=False,
message=f"❌ 获取技能列表失败: {str(e)}"
)
class SkillsEnableCommandHandler(CommandHandler):
"""处理 /skills enable <name> 命令 - 启用技能"""
async def handle(self, ctx: CommandContext) -> CommandResult:
skill_name = ctx.args.strip()
if not skill_name:
return CommandResult(
success=False,
message="Usage: /skills enable <skill_name>\n请提供技能名称。"
)
try:
from backend.agents.skills_manager import SkillsManager
sm = SkillsManager()
result = sm.update_agent_skill_overrides(
ctx.config_name,
ctx.agent_id,
enable=[skill_name]
)
return CommandResult(
success=True,
message=f"✅ 技能已启用: {skill_name}\n\n已启用技能: {', '.join(result['enabled_skills'])}",
data=result
)
except Exception as e:
logger.error(f"Failed to enable skill: {e}")
return CommandResult(
success=False,
message=f"❌ 启用技能失败: {str(e)}"
)
class SkillsDisableCommandHandler(CommandHandler):
"""处理 /skills disable <name> 命令 - 禁用技能"""
async def handle(self, ctx: CommandContext) -> CommandResult:
skill_name = ctx.args.strip()
if not skill_name:
return CommandResult(
success=False,
message="Usage: /skills disable <skill_name>\n请提供技能名称。"
)
try:
from backend.agents.skills_manager import SkillsManager
sm = SkillsManager()
result = sm.update_agent_skill_overrides(
ctx.config_name,
ctx.agent_id,
disable=[skill_name]
)
return CommandResult(
success=True,
message=f"✅ 技能已禁用: {skill_name}\n\n已禁用技能: {', '.join(result['disabled_skills'])}",
data=result
)
except Exception as e:
logger.error(f"Failed to disable skill: {e}")
return CommandResult(
success=False,
message=f"❌ 禁用技能失败: {str(e)}"
)
class SkillsInstallCommandHandler(CommandHandler):
"""处理 /skills install <name> 命令 - 安装技能"""
async def handle(self, ctx: CommandContext) -> CommandResult:
skill_name = ctx.args.strip()
if not skill_name:
return CommandResult(
success=False,
message="Usage: /skills install <skill_name>\n请提供技能名称。"
)
try:
from backend.agents.skills_manager import SkillsManager
from backend.agents.skill_loader import load_skill_from_dir
sm = SkillsManager()
# 查找技能源目录
source_dir = self._resolve_skill_source(sm, skill_name)
if not source_dir:
return CommandResult(
success=False,
message=f"❌ 技能未找到: {skill_name}\n\n请检查技能名称是否正确,或技能是否存在于 builtin/customized 目录。"
)
# 加载并验证技能
skill_info = load_skill_from_dir(source_dir)
if not skill_info:
return CommandResult(
success=False,
message=f"❌ 技能加载失败: {skill_name}\n\n技能格式可能不正确。"
)
# 安装到agent的installed目录
installed_root = sm.get_agent_installed_root(ctx.config_name, ctx.agent_id)
target_dir = installed_root / skill_name
import shutil
if target_dir.exists():
shutil.rmtree(target_dir)
shutil.copytree(source_dir, target_dir)
return CommandResult(
success=True,
message=f"✅ 技能已安装: {skill_name}\n\n- 名称: {skill_info.get('name', skill_name)}\n- 版本: {skill_info.get('version', 'unknown')}\n- 路径: {target_dir}",
data={"skill_name": skill_name, "target_dir": str(target_dir)}
)
except Exception as e:
logger.error(f"Failed to install skill: {e}")
return CommandResult(
success=False,
message=f"❌ 安装技能失败: {str(e)}"
)
def _resolve_skill_source(self, sm: "SkillsManager", skill_name: str) -> Optional[Path]:
"""解析技能源目录"""
for root in [sm.customized_root, sm.builtin_root]:
candidate = root / skill_name
if candidate.exists() and (candidate / "SKILL.md").exists():
return candidate
return None
class ReloadCommandHandler(CommandHandler):
"""处理 /reload 命令 - 重新加载配置"""
async def handle(self, ctx: CommandContext) -> CommandResult:
try:
agent = ctx.agent
# 重新加载配置
if hasattr(agent, "reload_config"):
await agent.reload_config()
# 重新加载技能
from backend.agents.skills_manager import SkillsManager
sm = SkillsManager()
# 刷新技能同步
active_root = sm.get_agent_active_root(ctx.config_name, ctx.agent_id)
if active_root.exists():
# 清除缓存,强制重新加载
import shutil
for item in active_root.iterdir():
if item.is_dir():
shutil.rmtree(item)
return CommandResult(
success=True,
message="✅ 配置已重新加载\n\n- Agent配置已刷新\n- 技能缓存已清除\n- 请重启对话以应用所有更改",
data={"config_name": ctx.config_name, "agent_id": ctx.agent_id}
)
except Exception as e:
logger.error(f"Failed to reload config: {e}")
return CommandResult(
success=False,
message=f"❌ 重新加载失败: {str(e)}"
)
class StatusCommandHandler(CommandHandler):
"""处理 /status 命令 - 显示Agent状态"""
async def handle(self, ctx: CommandContext) -> CommandResult:
try:
agent = ctx.agent
lines = ["📊 Agent 状态", ""]
lines.append(f"🆔 Agent ID: {ctx.agent_id}")
lines.append(f"⚙️ Config: {ctx.config_name}")
# 模型信息
model = getattr(agent, "model", None)
if model:
lines.append(f"🤖 Model: {model}")
# 记忆状态
memory = getattr(agent, "memory", None)
if memory:
msg_count = len(getattr(memory, "content", []))
lines.append(f"💾 Memory: {msg_count} messages")
# 技能状态
from backend.agents.skills_manager import SkillsManager
sm = SkillsManager()
active_skills = sm.list_active_skill_metadata(ctx.config_name, ctx.agent_id)
lines.append(f"🔧 Active Skills: {len(active_skills)}")
# 工具组状态
toolkit = getattr(agent, "toolkit", None)
if toolkit:
groups = getattr(toolkit, "tool_groups", {})
active_groups = [name for name, g in groups.items() if getattr(g, "active", False)]
lines.append(f"🛠️ Active Tool Groups: {', '.join(active_groups) if active_groups else 'None'}")
return CommandResult(
success=True,
message="\n".join(lines),
data={
"agent_id": ctx.agent_id,
"config_name": ctx.config_name,
"active_skills_count": len(active_skills)
}
)
except Exception as e:
logger.error(f"Failed to get status: {e}")
return CommandResult(
success=False,
message=f"❌ 获取状态失败: {str(e)}"
)
class HelpCommandHandler(CommandHandler):
"""处理 /help 命令 - 显示帮助"""
async def handle(self, ctx: CommandContext) -> CommandResult:
help_text = """📖 EvoAgent 命令帮助
可用命令:
/save <message> - 保存内容到 MEMORY.md
/compact - 压缩记忆
/skills list - 列出已激活技能
/skills enable <name> - 启用技能
/skills disable <name>- 禁用技能
/skills install <name>- 安装技能
/reload - 重新加载配置
/status - 显示Agent状态
/help - 显示此帮助信息
提示:
• 所有命令以 / 开头
• 命令不区分大小写
• 使用 Tab 键可自动补全命令
"""
return CommandResult(success=True, message=help_text)
class AgentCommandDispatcher:
"""Agent命令分发器
参考CoPaw的CommandHandler设计为EvoAgent提供统一的命令处理入口。
"""
# 支持的系统命令
SYSTEM_COMMANDS = frozenset({
"save", "compact",
"skills", "reload",
"status", "help"
})
def __init__(self):
self._handlers: Dict[str, CommandHandler] = {}
self._subcommands: Dict[str, Dict[str, CommandHandler]] = {}
self._register_default_handlers()
def _register_default_handlers(self) -> None:
"""注册默认命令处理器"""
self._handlers["save"] = SaveCommandHandler()
self._handlers["compact"] = CompactCommandHandler()
self._handlers["reload"] = ReloadCommandHandler()
self._handlers["status"] = StatusCommandHandler()
self._handlers["help"] = HelpCommandHandler()
# 子命令: /skills list/enable/disable/install
self._subcommands["skills"] = {
"list": SkillsListCommandHandler(),
"enable": SkillsEnableCommandHandler(),
"disable": SkillsDisableCommandHandler(),
"install": SkillsInstallCommandHandler(),
}
def is_command(self, query: str | None) -> bool:
"""检查是否为命令
Args:
query: 用户输入字符串
Returns:
True 如果是系统命令
"""
if not isinstance(query, str) or not query.startswith("/"):
return False
parts = query.strip().lstrip("/").split()
if not parts:
return False
cmd = parts[0].lower()
# 检查主命令
if cmd in self.SYSTEM_COMMANDS:
return True
return False
async def handle(self, agent: "EvoAgent", query: str) -> CommandResult:
"""处理命令
Args:
agent: EvoAgent实例
query: 命令字符串
Returns:
命令执行结果
"""
if not self.is_command(query):
return CommandResult(
success=False,
message=f"未知命令: {query}\n使用 /help 查看可用命令。"
)
# 解析命令和参数
parts = query.strip().lstrip("/").split(maxsplit=1)
cmd = parts[0].lower()
args = parts[1] if len(parts) > 1 else ""
logger.info(f"Processing command: {cmd}, args: {args}")
# 处理子命令 (e.g., /skills list)
if cmd in self._subcommands:
sub_parts = args.split(maxsplit=1)
sub_cmd = sub_parts[0].lower() if sub_parts else ""
sub_args = sub_parts[1] if len(sub_parts) > 1 else ""
handlers = self._subcommands[cmd]
handler = handlers.get(sub_cmd)
if handler is None:
available = ", ".join(handlers.keys())
return CommandResult(
success=False,
message=f"未知子命令: {sub_cmd}\n可用子命令: {available}"
)
ctx = CommandContext(agent, query, sub_args)
return await handler.handle(ctx)
# 处理主命令
handler = self._handlers.get(cmd)
if handler is None:
return CommandResult(
success=False,
message=f"命令未实现: {cmd}"
)
ctx = CommandContext(agent, query, args)
return await handler.handle(ctx)
# 便捷函数
def create_command_dispatcher() -> AgentCommandDispatcher:
"""创建命令分发器实例"""
return AgentCommandDispatcher()

View File

@@ -0,0 +1,411 @@
# -*- coding: utf-8 -*-
"""EvoAgent - Core agent implementation for EvoTraders.
This module provides the main EvoAgent class built on AgentScope's ReActAgent,
with integrated tools, skills, and memory management based on CoPaw design.
Key features:
- Workspace-driven configuration from Markdown files
- Dynamic skill loading from skills/active directories
- Tool-guard security interception
- Hook system for extensibility
- Runtime skill and prompt reloading
"""
from __future__ import annotations
import logging
from pathlib import Path
from typing import Any, Dict, List, Optional, Type, TYPE_CHECKING
from agentscope.agent import ReActAgent
from agentscope.memory import InMemoryMemory
from agentscope.message import Msg
from agentscope.tool import Toolkit
from .tool_guard import ToolGuardMixin
from .hooks import (
HookManager,
BootstrapHook,
MemoryCompactionHook,
HOOK_PRE_REASONING,
)
from ..prompts.builder import (
PromptBuilder,
build_system_prompt_from_workspace,
)
from ..agent_workspace import load_agent_workspace_config
from ..skills_manager import SkillsManager
if TYPE_CHECKING:
from agentscope.formatter import FormatterBase
from agentscope.model import ModelWrapperBase
logger = logging.getLogger(__name__)
class EvoAgent(ToolGuardMixin, ReActAgent):
"""EvoAgent with integrated tools, skills, and memory management.
This agent extends ReActAgent with:
- Workspace-driven configuration from AGENTS.md/SOUL.md/PROFILE.md/etc.
- Dynamic skill loading from skills/active directories
- Tool-guard security interception (via ToolGuardMixin)
- Hook system for extensibility (bootstrap, memory compaction)
- Runtime skill and prompt reloading
MRO note
~~~~~~~~
``ToolGuardMixin`` overrides ``_acting`` and ``_reasoning`` via
Python's MRO: EvoAgent → ToolGuardMixin → ReActAgent.
Example:
agent = EvoAgent(
agent_id="fundamentals_analyst",
config_name="smoke_fullstack",
workspace_dir=Path("runs/smoke_fullstack/agents/fundamentals_analyst"),
model=model_instance,
formatter=formatter_instance,
)
"""
def __init__(
self,
agent_id: str,
config_name: str,
workspace_dir: Path,
model: "ModelWrapperBase",
formatter: "FormatterBase",
skills_manager: Optional[SkillsManager] = None,
sys_prompt: Optional[str] = None,
max_iters: int = 10,
memory: Optional[Any] = None,
enable_tool_guard: bool = True,
enable_bootstrap_hook: bool = True,
enable_memory_compaction: bool = False,
memory_manager: Optional[Any] = None,
memory_compact_threshold: Optional[int] = None,
env_context: Optional[str] = None,
prompt_files: Optional[List[str]] = None,
):
"""Initialize EvoAgent.
Args:
agent_id: Unique identifier for this agent
config_name: Run configuration name (e.g., "smoke_fullstack")
workspace_dir: Agent workspace directory containing markdown files
model: LLM model instance
formatter: Message formatter instance
skills_manager: Optional SkillsManager instance
sys_prompt: Optional override for system prompt
max_iters: Maximum reasoning-acting iterations
memory: Optional memory instance (defaults to InMemoryMemory)
enable_tool_guard: Enable tool-guard security interception
enable_bootstrap_hook: Enable bootstrap guidance on first interaction
enable_memory_compaction: Enable automatic memory compaction
memory_manager: Optional memory manager for compaction
memory_compact_threshold: Token threshold for memory compaction
env_context: Optional environment context to prepend to system prompt
prompt_files: List of markdown files to load (defaults to standard set)
"""
self.agent_id = agent_id
self.config_name = config_name
self.workspace_dir = Path(workspace_dir)
self._skills_manager = skills_manager or SkillsManager()
self._env_context = env_context
self._prompt_files = prompt_files
# Initialize tool guard
if enable_tool_guard:
self._init_tool_guard()
# Load agent configuration from workspace
self._agent_config = self._load_agent_config()
# Build or use provided system prompt
if sys_prompt is not None:
self._sys_prompt = sys_prompt
else:
self._sys_prompt = self._build_system_prompt()
# Create toolkit with skills
toolkit = self._create_toolkit()
# Initialize hook manager
self._hook_manager = HookManager()
# Initialize parent ReActAgent
super().__init__(
name=agent_id,
model=model,
sys_prompt=self._sys_prompt,
toolkit=toolkit,
memory=memory or InMemoryMemory(),
formatter=formatter,
max_iters=max_iters,
)
# Register hooks
self._register_hooks(
enable_bootstrap=enable_bootstrap_hook,
enable_memory_compaction=enable_memory_compaction,
memory_manager=memory_manager,
memory_compact_threshold=memory_compact_threshold,
)
logger.info(
"EvoAgent initialized: %s (workspace: %s)",
agent_id,
workspace_dir,
)
def _load_agent_config(self) -> Dict[str, Any]:
"""Load agent configuration from workspace.
Returns:
Agent configuration dictionary
"""
config_path = self.workspace_dir / "agent.yaml"
if config_path.exists():
loaded = load_agent_workspace_config(config_path)
return dict(loaded.values)
return {}
def _build_system_prompt(self) -> str:
"""Build system prompt from workspace markdown files.
Uses PromptBuilder to load and combine AGENTS.md, SOUL.md,
PROFILE.md, and other configured files.
Returns:
Complete system prompt string
"""
prompt = build_system_prompt_from_workspace(
workspace_dir=self.workspace_dir,
enabled_files=self._prompt_files,
agent_id=self.agent_id,
extra_context=self._env_context,
)
return prompt
def _create_toolkit(self) -> Toolkit:
"""Create and populate toolkit with agent skills.
Loads skills from the agent's active skills directory and
registers them with the toolkit.
Returns:
Configured Toolkit instance
"""
toolkit = Toolkit(
agent_skill_instruction=(
"<system-info>You have access to specialized skills. "
"Each skill lives in a directory and is described by SKILL.md. "
"Follow the skill instructions when they are relevant to the current task."
"</system-info>"
),
agent_skill_template="- {name} (dir: {dir}): {description}",
)
# Register skills from active directory
active_skills_dir = self._skills_manager.get_agent_active_root(
self.config_name,
self.agent_id,
)
if active_skills_dir.exists():
for skill_dir in sorted(active_skills_dir.iterdir()):
if skill_dir.is_dir() and (skill_dir / "SKILL.md").exists():
try:
toolkit.register_agent_skill(str(skill_dir))
logger.debug("Registered skill: %s", skill_dir.name)
except Exception as e:
logger.error(
"Failed to register skill '%s': %s",
skill_dir.name,
e,
)
return toolkit
def _register_hooks(
self,
enable_bootstrap: bool,
enable_memory_compaction: bool,
memory_manager: Optional[Any],
memory_compact_threshold: Optional[int],
) -> None:
"""Register agent hooks.
Args:
enable_bootstrap: Enable bootstrap hook
enable_memory_compaction: Enable memory compaction hook
memory_manager: Memory manager instance
memory_compact_threshold: Token threshold for compaction
"""
# Bootstrap hook - checks BOOTSTRAP.md on first interaction
if enable_bootstrap:
bootstrap_hook = BootstrapHook(
workspace_dir=self.workspace_dir,
language="zh",
)
self._hook_manager.register(
hook_type=HOOK_PRE_REASONING,
hook_name="bootstrap",
hook=bootstrap_hook,
)
logger.debug("Registered bootstrap hook")
# Memory compaction hook
if enable_memory_compaction and memory_manager is not None:
compaction_hook = MemoryCompactionHook(
memory_manager=memory_manager,
memory_compact_threshold=memory_compact_threshold,
)
self._hook_manager.register(
hook_type=HOOK_PRE_REASONING,
hook_name="memory_compaction",
hook=compaction_hook,
)
logger.debug("Registered memory compaction hook")
async def _reasoning(self, **kwargs) -> Msg:
"""Override reasoning to execute pre-reasoning hooks.
Args:
**kwargs: Arguments for reasoning
Returns:
Response message
"""
# Execute pre-reasoning hooks
kwargs = await self._hook_manager.execute(
hook_type=HOOK_PRE_REASONING,
agent=self,
kwargs=kwargs,
)
# Call parent (which may be ToolGuardMixin's _reasoning)
return await super()._reasoning(**kwargs)
def reload_skills(self, active_skill_dirs: Optional[List[Path]] = None) -> None:
"""Reload skills at runtime.
Rebuilds the toolkit with current skills from the active directory.
Args:
active_skill_dirs: Optional list of specific skill directories to load
"""
logger.info("Reloading skills for agent: %s", self.agent_id)
# Create new toolkit
new_toolkit = Toolkit(
agent_skill_instruction=(
"<system-info>You have access to specialized skills. "
"Each skill lives in a directory and is described by SKILL.md. "
"Follow the skill instructions when they are relevant to the current task."
"</system-info>"
),
agent_skill_template="- {name} (dir: {dir}): {description}",
)
# Register skills
if active_skill_dirs is None:
active_skills_dir = self._skills_manager.get_agent_active_root(
self.config_name,
self.agent_id,
)
if active_skills_dir.exists():
active_skill_dirs = [
d for d in active_skills_dir.iterdir()
if d.is_dir() and (d / "SKILL.md").exists()
]
else:
active_skill_dirs = []
for skill_dir in active_skill_dirs:
if skill_dir.exists() and (skill_dir / "SKILL.md").exists():
try:
new_toolkit.register_agent_skill(str(skill_dir))
logger.debug("Reloaded skill: %s", skill_dir.name)
except Exception as e:
logger.error(
"Failed to reload skill '%s': %s",
skill_dir.name,
e,
)
# Replace toolkit
self.toolkit = new_toolkit
logger.info("Skills reloaded for agent: %s", self.agent_id)
def rebuild_sys_prompt(self) -> None:
"""Rebuild and replace the system prompt at runtime.
Useful after updating AGENTS.md, SOUL.md, PROFILE.md, etc.
to ensure the prompt reflects the latest configuration.
Updates both self._sys_prompt and the first system-role
message stored in self.memory.content.
"""
logger.info("Rebuilding system prompt for agent: %s", self.agent_id)
# Reload agent config in case it changed
self._agent_config = self._load_agent_config()
# Rebuild prompt
self._sys_prompt = self._build_system_prompt()
# Update memory if system message exists
if hasattr(self, "memory") and self.memory.content:
for msg, _marks in self.memory.content:
if getattr(msg, "role", None) == "system":
msg.content = self._sys_prompt
logger.debug("Updated system message in memory")
break
logger.info("System prompt rebuilt for agent: %s", self.agent_id)
async def reply(
self,
msg: Msg | List[Msg] | None = None,
structured_model: Optional[Type[Any]] = None,
) -> Msg:
"""Process a message and return a response.
Args:
msg: Input message(s) from user
structured_model: Optional pydantic model for structured output
Returns:
Response message
"""
# Handle list of messages
if isinstance(msg, list):
# Process each message in sequence
for m in msg[:-1]:
await self.memory.add(m)
msg = msg[-1] if msg else None
return await super().reply(msg=msg, structured_model=structured_model)
def get_agent_info(self) -> Dict[str, Any]:
"""Get agent information.
Returns:
Dictionary with agent metadata
"""
return {
"agent_id": self.agent_id,
"config_name": self.config_name,
"workspace_dir": str(self.workspace_dir),
"skills_count": len([
s for s in self._skills_manager.list_active_skill_metadata(
self.config_name,
self.agent_id,
)
]),
"registered_hooks": self._hook_manager.list_hooks(),
}
__all__ = ["EvoAgent"]

View File

@@ -0,0 +1,432 @@
# -*- coding: utf-8 -*-
"""Hook system for EvoAgent.
Provides pre_reasoning and post_acting hooks with built-in implementations:
- BootstrapHook: First-time setup guidance
- MemoryCompactionHook: Automatic memory compression
Based on CoPaw's hooks design.
"""
from __future__ import annotations
import logging
from abc import ABC, abstractmethod
from pathlib import Path
from typing import Any, Callable, Dict, List, Optional, TYPE_CHECKING
if TYPE_CHECKING:
from agentscope.agent import ReActAgent
logger = logging.getLogger(__name__)
# Hook types
HookType = str
HOOK_PRE_REASONING: HookType = "pre_reasoning"
HOOK_POST_ACTING: HookType = "post_acting"
class Hook(ABC):
"""Abstract base class for agent hooks."""
@abstractmethod
async def __call__(
self,
agent: "ReActAgent",
kwargs: Dict[str, Any],
) -> Optional[Dict[str, Any]]:
"""Execute the hook.
Args:
agent: The agent instance
kwargs: Input arguments to the method being hooked
Returns:
Modified kwargs or None to use original
"""
pass
class HookManager:
"""Manages agent hooks.
Provides registration and execution of hooks for different
lifecycle events in the agent's operation.
"""
def __init__(self):
self._hooks: Dict[HookType, List[tuple[str, Hook]]] = {
HOOK_PRE_REASONING: [],
HOOK_POST_ACTING: [],
}
def register(
self,
hook_type: HookType,
hook_name: str,
hook: Hook | Callable,
) -> None:
"""Register a hook.
Args:
hook_type: Type of hook (pre_reasoning, post_acting)
hook_name: Unique name for this hook
hook: Hook instance or callable
"""
# Remove existing hook with same name
self._hooks[hook_type] = [
(name, h) for name, h in self._hooks[hook_type] if name != hook_name
]
self._hooks[hook_type].append((hook_name, hook))
logger.debug("Registered hook '%s' for type '%s'", hook_name, hook_type)
def unregister(self, hook_type: HookType, hook_name: str) -> bool:
"""Unregister a hook.
Args:
hook_type: Type of hook
hook_name: Name of the hook to remove
Returns:
True if hook was found and removed
"""
original_len = len(self._hooks[hook_type])
self._hooks[hook_type] = [
(name, h) for name, h in self._hooks[hook_type] if name != hook_name
]
removed = len(self._hooks[hook_type]) < original_len
if removed:
logger.debug("Unregistered hook '%s' from type '%s'", hook_name, hook_type)
return removed
async def execute(
self,
hook_type: HookType,
agent: "ReActAgent",
kwargs: Dict[str, Any],
) -> Dict[str, Any]:
"""Execute all hooks of a given type.
Args:
hook_type: Type of hooks to execute
agent: The agent instance
kwargs: Input arguments
Returns:
Potentially modified kwargs
"""
for name, hook in self._hooks[hook_type]:
try:
result = await hook(agent, kwargs)
if result is not None:
kwargs = result
except Exception as e:
logger.error("Hook '%s' failed: %s", name, e, exc_info=True)
return kwargs
def list_hooks(self, hook_type: Optional[HookType] = None) -> List[str]:
"""List registered hook names.
Args:
hook_type: Optional type to filter by
Returns:
List of hook names
"""
if hook_type:
return [name for name, _ in self._hooks.get(hook_type, [])]
names = []
for hooks in self._hooks.values():
names.extend([name for name, _ in hooks])
return names
class BootstrapHook(Hook):
"""Hook for bootstrap guidance on first user interaction.
This hook looks for a BOOTSTRAP.md file in the working directory
and if found, prepends guidance to the first user message to help
establish the agent's identity and user preferences.
"""
def __init__(
self,
workspace_dir: Path,
language: str = "zh",
):
"""Initialize bootstrap hook.
Args:
workspace_dir: Working directory containing BOOTSTRAP.md
language: Language code for bootstrap guidance (en/zh)
"""
self.workspace_dir = Path(workspace_dir)
self.language = language
self._completed_flag = self.workspace_dir / ".bootstrap_completed"
def _is_first_user_interaction(self, agent: "ReActAgent") -> bool:
"""Check if this is the first user interaction.
Args:
agent: The agent instance
Returns:
True if first user interaction
"""
if not hasattr(agent, "memory") or not agent.memory.content:
return True
# Count user messages (excluding system)
user_count = sum(
1 for msg, _ in agent.memory.content if msg.role == "user"
)
return user_count <= 1
def _build_bootstrap_guidance(self) -> str:
"""Build bootstrap guidance message.
Returns:
Formatted bootstrap guidance
"""
if self.language == "zh":
return (
"# 引导模式\n"
"\n"
"工作目录中存在 `BOOTSTRAP.md` — 首次设置。\n"
"\n"
"1. 阅读 BOOTSTRAP.md友好地表示初次见面"
"引导用户完成设置。\n"
"2. 按照 BOOTSTRAP.md 的指示,"
"帮助用户定义你的身份和偏好。\n"
"3. 按指南创建/更新必要文件"
"PROFILE.md、MEMORY.md 等)。\n"
"4. 完成后删除 BOOTSTRAP.md。\n"
"\n"
"如果用户希望跳过,直接回答下面的问题即可。\n"
"\n"
"---\n"
"\n"
)
return (
"# BOOTSTRAP MODE\n"
"\n"
"`BOOTSTRAP.md` exists — first-time setup.\n"
"\n"
"1. Read BOOTSTRAP.md, greet the user, "
"and guide them through setup.\n"
"2. Follow BOOTSTRAP.md instructions "
"to define identity and preferences.\n"
"3. Create/update files "
"(PROFILE.md, MEMORY.md, etc.) as described.\n"
"4. Delete BOOTSTRAP.md when done.\n"
"\n"
"If the user wants to skip, answer their "
"question directly instead.\n"
"\n"
"---\n"
"\n"
)
async def __call__(
self,
agent: "ReActAgent",
kwargs: Dict[str, Any],
) -> Optional[Dict[str, Any]]:
"""Check and load BOOTSTRAP.md on first user interaction.
Args:
agent: The agent instance
kwargs: Input arguments to the _reasoning method
Returns:
None (hook doesn't modify kwargs)
"""
try:
bootstrap_path = self.workspace_dir / "BOOTSTRAP.md"
# Check if bootstrap has already been triggered
if self._completed_flag.exists():
return None
if not bootstrap_path.exists():
return None
if not self._is_first_user_interaction(agent):
return None
bootstrap_guidance = self._build_bootstrap_guidance()
logger.debug("Found BOOTSTRAP.md [%s], prepending guidance", self.language)
# Prepend to first user message in memory
if hasattr(agent, "memory") and agent.memory.content:
system_count = sum(
1 for msg, _ in agent.memory.content if msg.role == "system"
)
for msg, _ in agent.memory.content[system_count:]:
if msg.role == "user":
# Prepend guidance to message content
original_content = msg.content
msg.content = bootstrap_guidance + original_content
break
logger.debug("Bootstrap guidance prepended to first user message")
# Create completion flag to prevent repeated triggering
self._completed_flag.touch()
logger.debug("Created bootstrap completion flag")
except Exception as e:
logger.error("Failed to process bootstrap: %s", e, exc_info=True)
return None
class MemoryCompactionHook(Hook):
"""Hook for automatic memory compaction when context is full.
This hook monitors the token count of messages and triggers compaction
when it exceeds the threshold. It preserves the system prompt and recent
messages while summarizing older conversation history.
"""
def __init__(
self,
memory_manager: Any,
memory_compact_threshold: Optional[int] = None,
memory_compact_reserve: Optional[int] = None,
enable_tool_result_compact: bool = False,
tool_result_compact_keep_n: int = 5,
):
"""Initialize memory compaction hook.
Args:
memory_manager: Memory manager instance for compaction
memory_compact_threshold: Token threshold for compaction
memory_compact_reserve: Reserve tokens for recent messages
enable_tool_result_compact: Enable tool result compaction
tool_result_compact_keep_n: Number of tool results to keep
"""
self.memory_manager = memory_manager
self.memory_compact_threshold = memory_compact_threshold
self.memory_compact_reserve = memory_compact_reserve
self.enable_tool_result_compact = enable_tool_result_compact
self.tool_result_compact_keep_n = tool_result_compact_keep_n
async def __call__(
self,
agent: "ReActAgent",
kwargs: Dict[str, Any],
) -> Optional[Dict[str, Any]]:
"""Pre-reasoning hook to check and compact memory if needed.
Args:
agent: The agent instance
kwargs: Input arguments to the _reasoning method
Returns:
None (hook doesn't modify kwargs)
"""
try:
if not hasattr(agent, "memory") or not self.memory_manager:
return None
memory = agent.memory
# Get current token count estimate
messages = await memory.get_memory()
total_tokens = self._estimate_tokens(messages)
if self.memory_compact_threshold is None:
return None
if total_tokens < self.memory_compact_threshold:
return None
logger.info(
"Memory compaction triggered: %d tokens (threshold: %d)",
total_tokens,
self.memory_compact_threshold,
)
# Compact memory
await self._compact_memory(agent, messages)
except Exception as e:
logger.error("Failed to compact memory: %s", e, exc_info=True)
return None
def _estimate_tokens(self, messages: List[Any]) -> int:
"""Estimate token count for messages.
Args:
messages: List of messages
Returns:
Estimated token count
"""
# Simple estimation: ~4 chars per token
total_chars = sum(
len(str(getattr(msg, "content", "")))
for msg in messages
)
return total_chars // 4
async def _compact_memory(
self,
agent: "ReActAgent",
messages: List[Any],
) -> None:
"""Compact memory by summarizing older messages.
Args:
agent: The agent instance
messages: Current messages in memory
"""
if self.memory_compact_reserve is None:
return
# Keep recent messages
keep_count = min(
len(messages) // 4,
10, # Max 10 recent messages
)
keep_count = max(keep_count, 2) # At least 2
messages_to_compact = messages[:-keep_count] if keep_count < len(messages) else []
if not messages_to_compact:
return
# Use memory manager to compact if available
if hasattr(self.memory_manager, "compact_memory"):
try:
summary = await self.memory_manager.compact_memory(
messages=messages_to_compact,
)
logger.info("Memory compacted: %d messages summarized", len(messages_to_compact))
# Mark messages as compressed if supported
if hasattr(agent.memory, "update_messages_mark"):
from agentscope.agent._react_agent import _MemoryMark
await agent.memory.update_messages_mark(
new_mark=_MemoryMark.COMPRESSED,
msg_ids=[msg.id for msg in messages_to_compact],
)
except Exception as e:
logger.error("Memory manager compaction failed: %s", e)
__all__ = [
"Hook",
"HookManager",
"HookType",
"HOOK_PRE_REASONING",
"HOOK_POST_ACTING",
"BootstrapHook",
"MemoryCompactionHook",
]

View File

@@ -0,0 +1,674 @@
# -*- coding: utf-8 -*-
"""ToolGuardMixin - Security interception for dangerous tool calls.
Provides ``_acting`` and ``_reasoning`` overrides that intercept
sensitive tool calls before execution, implementing the deny /
guard / approve flow.
Based on CoPaw's tool_guard_mixin.py design.
"""
from __future__ import annotations
import asyncio
import json
import logging
from dataclasses import dataclass, field
from datetime import datetime
from enum import Enum
from typing import Any, Callable, Dict, Iterable, List, Optional, Set
from agentscope.message import Msg
from backend.runtime.manager import get_global_runtime_manager
logger = logging.getLogger(__name__)
class SeverityLevel(str, Enum):
"""Risk severity level."""
LOW = "low"
MEDIUM = "medium"
HIGH = "high"
CRITICAL = "critical"
class ApprovalStatus(str, Enum):
"""Approval lifecycle state."""
PENDING = "pending"
APPROVED = "approved"
DENIED = "denied"
EXPIRED = "expired"
class ToolFindingRecord:
"""Internal representation of a guard finding."""
def __init__(self, severity: SeverityLevel, message: str, field: Optional[str] = None) -> None:
self.severity = severity
self.message = message
self.field = field
def to_dict(self) -> Dict[str, Any]:
return {
"severity": self.severity.value,
"message": self.message,
"field": self.field,
}
class ApprovalRecord:
"""Stores the state of an approval request."""
def __init__(
self,
approval_id: str,
tool_name: str,
tool_input: Dict[str, Any],
agent_id: str,
workspace_id: str,
session_id: Optional[str] = None,
findings: Optional[List[ToolFindingRecord]] = None,
) -> None:
self.approval_id = approval_id
self.tool_name = tool_name
self.tool_input = tool_input
self.agent_id = agent_id
self.workspace_id = workspace_id
self.session_id = session_id
self.status = ApprovalStatus.PENDING
self.findings = findings or []
self.created_at = datetime.utcnow()
self.resolved_at: Optional[datetime] = None
self.resolved_by: Optional[str] = None
self.metadata: Dict[str, Any] = {}
self.pending_request: "ToolApprovalRequest" | None = None
def to_dict(self) -> Dict[str, Any]:
return {
"approval_id": self.approval_id,
"status": self.status.value,
"tool_name": self.tool_name,
"tool_input": self.tool_input,
"agent_id": self.agent_id,
"workspace_id": self.workspace_id,
"session_id": self.session_id,
"findings": [f.to_dict() for f in self.findings],
"created_at": self.created_at.isoformat(),
"resolved_at": self.resolved_at.isoformat() if self.resolved_at else None,
"resolved_by": self.resolved_by,
}
class ToolGuardStore:
"""Simple in-memory approval store for development/testing."""
def __init__(self) -> None:
self._records: Dict[str, ApprovalRecord] = {}
self._counter = 0
def next_id(self) -> str:
self._counter += 1
return f"approval_{self._counter:06d}"
def list(
self,
status: ApprovalStatus | None = None,
workspace_id: Optional[str] = None,
agent_id: Optional[str] = None,
) -> Iterable[ApprovalRecord]:
for record in self._records.values():
if status and record.status != status:
continue
if workspace_id and record.workspace_id != workspace_id:
continue
if agent_id and record.agent_id != agent_id:
continue
yield record
def get(self, approval_id: str) -> Optional[ApprovalRecord]:
return self._records.get(approval_id)
def create_pending(
self,
tool_name: str,
tool_input: Dict[str, Any],
agent_id: str,
workspace_id: str,
session_id: Optional[str] = None,
findings: Optional[List[ToolFindingRecord]] = None,
) -> ApprovalRecord:
record = ApprovalRecord(
approval_id=self.next_id(),
tool_name=tool_name,
tool_input=tool_input,
agent_id=agent_id,
workspace_id=workspace_id,
session_id=session_id,
findings=findings,
)
self._records[record.approval_id] = record
return record
def set_status(
self,
approval_id: str,
status: ApprovalStatus,
resolved_by: Optional[str] = None,
notify_request: bool = True,
) -> ApprovalRecord:
record = self._records[approval_id]
if record.status == status:
return record
record.status = status
record.resolved_at = datetime.utcnow()
record.resolved_by = resolved_by
if notify_request and record.pending_request:
if status == ApprovalStatus.APPROVED:
record.pending_request.approve()
elif status == ApprovalStatus.DENIED:
record.pending_request.deny()
return record
def cancel(self, approval_id: str) -> None:
self._records.pop(approval_id, None)
TOOL_GUARD_STORE = ToolGuardStore()
def get_tool_guard_store() -> ToolGuardStore:
return TOOL_GUARD_STORE
# Default tools that require approval
DEFAULT_GUARDED_TOOLS: Set[str] = {
"execute_shell_command",
"write_file",
"edit_file",
"place_order",
"modify_position",
"delete_file",
}
# Default denied tools (cannot be approved)
DEFAULT_DENIED_TOOLS: Set[str] = {
"execute_shell_command", # Shell execution is dangerous
}
# Mark for tool guard denied messages
TOOL_GUARD_DENIED_MARK = "tool_guard_denied"
def default_findings_for_tool(tool_name: str) -> List[ToolFindingRecord]:
findings: List[ToolFindingRecord] = []
if tool_name in {"execute_trade", "modify_portfolio"}:
findings.append(
ToolFindingRecord(
severity=SeverityLevel.HIGH,
message=f"Tool '{tool_name}' touches portfolio state",
)
)
return findings
class ToolApprovalRequest:
"""Represents a pending tool approval request."""
def __init__(
self,
approval_id: str,
tool_name: str,
tool_input: Dict[str, Any],
tool_call_id: str,
session_id: Optional[str] = None,
):
self.approval_id = approval_id
self.tool_name = tool_name
self.tool_input = tool_input
self.tool_call_id = tool_call_id
self.session_id = session_id
self.approved: Optional[bool] = None
self._event = asyncio.Event()
async def wait_for_approval(self, timeout: Optional[float] = None) -> bool:
"""Wait for approval decision.
Args:
timeout: Maximum time to wait in seconds
Returns:
True if approved, False otherwise
"""
try:
await asyncio.wait_for(self._event.wait(), timeout=timeout)
except asyncio.TimeoutError:
return False
return self.approved is True
def approve(self) -> None:
"""Approve this request."""
self.approved = True
self._event.set()
def deny(self) -> None:
"""Deny this request."""
self.approved = False
self._event.set()
class ToolGuardMixin:
"""Mixin that adds tool-guard interception to a ReActAgent.
At runtime this class is combined with ReActAgent via MRO,
so ``super()._acting`` and ``super()._reasoning`` resolve to
the concrete agent methods.
Usage:
class MyAgent(ToolGuardMixin, ReActAgent):
def __init__(self, ...):
super().__init__(...)
self._init_tool_guard()
"""
def _init_tool_guard(
self,
guarded_tools: Optional[Set[str]] = None,
denied_tools: Optional[Set[str]] = None,
approval_timeout: float = 300.0,
) -> None:
"""Initialize tool guard.
Args:
guarded_tools: Set of tool names requiring approval
denied_tools: Set of tool names that are always denied
approval_timeout: Timeout for approval requests in seconds
"""
self._guarded_tools = guarded_tools or DEFAULT_GUARDED_TOOLS.copy()
self._denied_tools = denied_tools or DEFAULT_DENIED_TOOLS.copy()
self._approval_timeout = approval_timeout
self._pending_approval: Optional[ToolApprovalRequest] = None
self._approval_callback: Optional[Callable[[ToolApprovalRequest], None]] = None
def set_approval_callback(
self,
callback: Callable[[ToolApprovalRequest], None],
) -> None:
"""Set callback for approval requests.
Args:
callback: Function called when approval is needed
"""
self._approval_callback = callback
def _is_tool_guarded(self, tool_name: str) -> bool:
"""Check if a tool requires approval.
Args:
tool_name: Name of the tool
Returns:
True if tool requires approval
"""
return tool_name in self._guarded_tools
def _is_tool_denied(self, tool_name: str) -> bool:
"""Check if a tool is always denied.
Args:
tool_name: Name of the tool
Returns:
True if tool is denied
"""
return tool_name in self._denied_tools
def _last_tool_response_is_denied(self) -> bool:
"""Check if the last message is a guard-denied tool result."""
if not hasattr(self, "memory") or not self.memory.content:
return False
msg, marks = self.memory.content[-1]
return TOOL_GUARD_DENIED_MARK in marks and msg.role == "system"
async def _cleanup_tool_guard_denied_messages(
self,
include_denial_response: bool = True,
) -> None:
"""Remove tool-guard denied messages from memory.
Args:
include_denial_response: Also remove the assistant's denial explanation
"""
if not hasattr(self, "memory"):
return
ids_to_delete: list[str] = []
last_marked_idx = -1
for i, (msg, marks) in enumerate(self.memory.content):
if TOOL_GUARD_DENIED_MARK in marks:
ids_to_delete.append(msg.id)
last_marked_idx = i
if (
include_denial_response
and last_marked_idx >= 0
and last_marked_idx + 1 < len(self.memory.content)
):
next_msg, _ = self.memory.content[last_marked_idx + 1]
if next_msg.role == "assistant":
ids_to_delete.append(next_msg.id)
if ids_to_delete:
removed = await self.memory.delete(ids_to_delete)
logger.info("Tool guard: cleaned up %d denied message(s)", removed)
async def _request_guard_approval(
self,
tool_name: str,
tool_input: Dict[str, Any],
tool_call_id: str,
) -> bool:
"""Request approval for a guarded tool call.
This method creates a ToolApprovalRequest and waits for
external approval via approve_guard_call() or deny_guard_call().
Args:
tool_name: Name of the tool
tool_input: Tool input parameters
tool_call_id: ID of the tool call
Returns:
True if approved, False otherwise
"""
record = TOOL_GUARD_STORE.create_pending(
tool_name=tool_name,
tool_input=tool_input,
agent_id=getattr(self, "agent_id", "unknown"),
workspace_id=getattr(self, "workspace_id", "default"),
session_id=getattr(self, "session_id", None),
findings=default_findings_for_tool(tool_name),
)
manager = get_global_runtime_manager()
if manager:
manager.register_pending_approval(
record.approval_id,
{
"tool_name": record.tool_name,
"agent_id": record.agent_id,
"workspace_id": record.workspace_id,
"session_id": record.session_id,
"tool_input": record.tool_input,
},
)
self._pending_approval = ToolApprovalRequest(
approval_id=record.approval_id,
tool_name=tool_name,
tool_input=tool_input,
tool_call_id=tool_call_id,
session_id=getattr(self, "session_id", None),
)
record.pending_request = self._pending_approval
# Notify via callback if set
if self._approval_callback:
self._approval_callback(self._pending_approval)
# Wait for approval
approval_request = self._pending_approval
approved = await approval_request.wait_for_approval(
timeout=self._approval_timeout
)
if approval_request:
status = (
ApprovalStatus.APPROVED
if approval_request.approved is True
else ApprovalStatus.DENIED
if approval_request.approved is False
else ApprovalStatus.EXPIRED
)
TOOL_GUARD_STORE.set_status(
approval_request.approval_id,
status,
resolved_by="agent",
notify_request=False,
)
manager = get_global_runtime_manager()
if manager:
manager.resolve_pending_approval(
approval_request.approval_id,
resolved_by="agent",
status=status.value,
)
self._pending_approval = None
return approved
def approve_guard_call(self, request_id: Optional[str] = None) -> bool:
"""Approve a pending guard request.
This method is called externally to approve a tool call
that is waiting for approval.
Args:
request_id: Optional request ID to verify (not yet implemented)
Returns:
True if a request was approved, False if no pending request
"""
if self._pending_approval is None:
logger.warning("No pending approval request to approve")
return False
TOOL_GUARD_STORE.set_status(
self._pending_approval.approval_id,
ApprovalStatus.APPROVED,
resolved_by="agent",
notify_request=False,
)
manager = get_global_runtime_manager()
if manager:
manager.resolve_pending_approval(
self._pending_approval.approval_id,
resolved_by="agent",
status=ApprovalStatus.APPROVED.value,
)
self._pending_approval.approve()
logger.info("Approved tool call: %s", self._pending_approval.tool_name)
return True
def deny_guard_call(self, request_id: Optional[str] = None) -> bool:
"""Deny a pending guard request.
This method is called externally to deny a tool call
that is waiting for approval.
Args:
request_id: Optional request ID to verify (not yet implemented)
Returns:
True if a request was denied, False if no pending request
"""
if self._pending_approval is None:
logger.warning("No pending approval request to deny")
return False
TOOL_GUARD_STORE.set_status(
self._pending_approval.approval_id,
ApprovalStatus.DENIED,
resolved_by="agent",
notify_request=False,
)
manager = get_global_runtime_manager()
if manager:
manager.resolve_pending_approval(
self._pending_approval.approval_id,
resolved_by="agent",
status=ApprovalStatus.DENIED.value,
)
self._pending_approval.deny()
logger.info("Denied tool call: %s", self._pending_approval.tool_name)
return True
async def _acting(self, tool_call) -> dict | None:
"""Intercept sensitive tool calls before execution.
1. If tool is in denied_tools, auto-deny unconditionally.
2. Check for a one-shot pre-approval.
3. If tool is in the guarded scope, request approval.
4. Otherwise, delegate to parent _acting.
Args:
tool_call: Tool call from the model
Returns:
Tool result dict or None
"""
tool_name: str = tool_call.get("name", "")
tool_input: dict = tool_call.get("input", {})
tool_call_id: str = tool_call.get("id", "")
# Check if tool is denied
if tool_name and self._is_tool_denied(tool_name):
logger.warning("Tool '%s' is in the denied set, auto-denying", tool_name)
return await self._acting_auto_denied(tool_call, tool_name)
# Check if tool is guarded
if tool_name and self._is_tool_guarded(tool_name):
approved = await self._request_guard_approval(
tool_name=tool_name,
tool_input=tool_input,
tool_call_id=tool_call_id,
)
if not approved:
return await self._acting_with_denial(tool_call, tool_name)
# Call parent _acting
return await super()._acting(tool_call) # type: ignore[misc]
async def _acting_auto_denied(
self,
tool_call: Dict[str, Any],
tool_name: str,
) -> dict | None:
"""Auto-deny a tool call without offering approval.
Args:
tool_call: Tool call from the model
tool_name: Name of the denied tool
Returns:
Denial result
"""
from agentscope.message import ToolResultBlock
denied_text = (
f"⛔ **Tool Blocked / 工具已拦截**\n\n"
f"- Tool / 工具: `{tool_name}`\n"
f"- Reason / 原因: This tool is blocked for security reasons\n\n"
f"This tool is blocked and cannot be approved.\n"
f"该工具已被禁止,无法批准执行。"
)
tool_res_msg = Msg(
"system",
[
ToolResultBlock(
type="tool_result",
id=tool_call.get("id", ""),
name=tool_name,
output=[{"type": "text", "text": denied_text}],
),
],
"system",
)
await self.print(tool_res_msg, True)
await self.memory.add(tool_res_msg)
return None
async def _acting_with_denial(
self,
tool_call: Dict[str, Any],
tool_name: str,
) -> dict | None:
"""Deny the tool call after approval was rejected.
Args:
tool_call: Tool call from the model
tool_name: Name of the tool
Returns:
Denial result
"""
from agentscope.message import ToolResultBlock
params_text = json.dumps(
tool_call.get("input", {}),
ensure_ascii=False,
indent=2,
)
denied_text = (
f"⚠️ **Tool Call Denied / 工具调用被拒绝**\n\n"
f"- Tool / 工具: `{tool_name}`\n"
f"- Parameters / 参数:\n"
f"```json\n{params_text}\n```\n\n"
f"The tool call was denied by the user or timed out.\n"
f"工具调用被用户拒绝或已超时。"
)
tool_res_msg = Msg(
"system",
[
ToolResultBlock(
type="tool_result",
id=tool_call.get("id", ""),
name=tool_name,
output=[{"type": "text", "text": denied_text}],
),
],
"system",
)
await self.print(tool_res_msg, True)
await self.memory.add(tool_res_msg, marks=TOOL_GUARD_DENIED_MARK)
return None
async def _reasoning(self, **kwargs) -> Msg:
"""Short-circuit reasoning when awaiting guard approval.
If the last message was a guard denial, return a waiting message
instead of continuing reasoning.
Returns:
Response message
"""
if self._last_tool_response_is_denied():
msg = Msg(
self.name,
"⏳ Waiting for approval / 等待审批...\n\n"
"Type `/approve` to approve, or send any message to deny.\n"
"输入 `/approve` 批准执行,或发送任意消息拒绝。",
"assistant",
)
await self.print(msg, True)
await self.memory.add(msg)
return msg
return await super()._reasoning(**kwargs) # type: ignore[misc]
__all__ = [
"ToolGuardMixin",
"ToolApprovalRequest",
"DEFAULT_GUARDED_TOOLS",
"DEFAULT_DENIED_TOOLS",
"TOOL_GUARD_DENIED_MARK",
]

146
backend/agents/compat.py Normal file
View File

@@ -0,0 +1,146 @@
# -*- coding: utf-8 -*-
"""
Compatibility Layer - Adapters for legacy to EvoAgent migration.
Provides:
- LegacyAgentAdapter: Wraps old AnalystAgent to work with new interfaces
- Migration utilities for gradual adoption
"""
from typing import Any, Dict, Optional
from agentscope.message import Msg
from .agent_core import EvoAgent
class LegacyAgentAdapter:
"""
Adapter to make legacy AnalystAgent compatible with EvoAgent interfaces.
This allows gradual migration by wrapping existing agents.
"""
def __init__(self, legacy_agent: Any):
"""
Initialize adapter.
Args:
legacy_agent: Legacy AnalystAgent instance
"""
self._agent = legacy_agent
self.agent_id = getattr(legacy_agent, 'agent_id', getattr(legacy_agent, 'name', 'unknown'))
self.analyst_type = getattr(legacy_agent, 'analyst_type_key', None)
@property
def name(self) -> str:
"""Get agent name."""
return getattr(self._agent, 'name', self.agent_id)
@property
def toolkit(self) -> Any:
"""Get agent toolkit."""
return getattr(self._agent, 'toolkit', None)
@property
def model(self) -> Any:
"""Get agent model."""
return getattr(self._agent, 'model', None)
@property
def memory(self) -> Any:
"""Get agent memory."""
return getattr(self._agent, 'memory', None)
async def reply(self, x: Msg = None) -> Msg:
"""
Delegate to legacy agent's reply method.
Args:
x: Input message
Returns:
Response message
"""
return await self._agent.reply(x)
def reload_runtime_assets(self, active_skill_dirs: Optional[list] = None) -> None:
"""
Reload runtime assets if supported.
Args:
active_skill_dirs: Optional list of active skill directories
"""
if hasattr(self._agent, 'reload_runtime_assets'):
self._agent.reload_runtime_assets(active_skill_dirs)
def to_evo_agent(
self,
workspace_manager: Optional[Any] = None,
enable_tool_guard: bool = False,
) -> EvoAgent:
"""
Convert legacy agent to EvoAgent.
Args:
workspace_manager: Optional workspace manager
enable_tool_guard: Whether to enable tool guard
Returns:
New EvoAgent instance with same configuration
"""
return EvoAgent(
agent_id=self.agent_id,
model=self.model,
formatter=getattr(self._agent, 'formatter', None),
toolkit=self.toolkit,
workspace_manager=workspace_manager,
config=getattr(self._agent, 'config', {}),
long_term_memory=getattr(self._agent, 'long_term_memory', None),
enable_tool_guard=enable_tool_guard,
sys_prompt=getattr(self._agent, '_sys_prompt', None),
)
def __getattr__(self, name: str) -> Any:
"""Delegate unknown attributes to wrapped agent."""
return getattr(self._agent, name)
def is_legacy_agent(agent: Any) -> bool:
"""
Check if an agent is a legacy agent.
Args:
agent: Agent instance to check
Returns:
True if legacy agent
"""
return hasattr(agent, 'analyst_type_key') and not isinstance(agent, EvoAgent)
def adapt_agent(agent: Any) -> Any:
"""
Wrap agent in adapter if it's a legacy agent.
Args:
agent: Agent instance
Returns:
Adapted agent or original if already EvoAgent
"""
if is_legacy_agent(agent):
return LegacyAgentAdapter(agent)
return agent
def adapt_agents(agents: list) -> list:
"""
Wrap multiple agents in adapters.
Args:
agents: List of agent instances
Returns:
List of adapted agents
"""
return [adapt_agent(agent) for agent in agents]

495
backend/agents/factory.py Normal file
View File

@@ -0,0 +1,495 @@
# -*- coding: utf-8 -*-
"""Agent Factory - Dynamic creation and management of EvoAgents."""
import shutil
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Dict, List, Optional
import yaml
@dataclass
class ModelConfig:
"""Model configuration for an agent."""
model_name: str = "gpt-4o"
temperature: float = 0.7
max_tokens: int = 4096
@dataclass
class RoleConfig:
"""Role configuration for an agent."""
name: str
description: str = ""
focus_areas: List[str] = None
constraints: List[str] = None
def __post_init__(self):
if self.focus_areas is None:
self.focus_areas = []
if self.constraints is None:
self.constraints = []
class EvoAgent:
"""Represents a configured agent instance."""
def __init__(
self,
agent_id: str,
agent_type: str,
workspace_id: str,
config_path: Path,
model_config: Optional[ModelConfig] = None,
role_config: Optional[RoleConfig] = None,
):
self.agent_id = agent_id
self.agent_type = agent_type
self.workspace_id = workspace_id
self.config_path = config_path
self.model_config = model_config or ModelConfig()
self.role_config = role_config
self.agent_dir = config_path.parent
def to_dict(self) -> Dict[str, Any]:
"""Serialize agent to dictionary."""
return {
"agent_id": self.agent_id,
"agent_type": self.agent_type,
"workspace_id": self.workspace_id,
"config_path": str(self.config_path),
"agent_dir": str(self.agent_dir),
"model_config": {
"model_name": self.model_config.model_name,
"temperature": self.model_config.temperature,
"max_tokens": self.model_config.max_tokens,
},
"role_config": self.role_config.__dict__ if self.role_config else None,
}
class AgentFactory:
"""Factory for creating, cloning, and managing agents."""
# Default role templates by agent type
ROLE_TEMPLATES = {
"technical_analyst": {
"name": "Technical Analyst",
"description": "Analyze price patterns, trends, and technical indicators.",
"focus_areas": [
"Price action and chart patterns",
"Support and resistance levels",
"Technical indicators (RSI, MACD, Moving Averages)",
"Volume analysis",
],
"constraints": [
"State clear signal, confidence, and invalidation conditions",
"Use available technical analysis tools",
],
},
"fundamentals_analyst": {
"name": "Fundamentals Analyst",
"description": "Analyze company financials, earnings, and business metrics.",
"focus_areas": [
"Financial statements analysis",
"Earnings reports and guidance",
"Valuation metrics",
"Business model and competitive position",
],
"constraints": [
"State clear signal, confidence, and invalidation conditions",
"Use available fundamental analysis tools",
],
},
"sentiment_analyst": {
"name": "Sentiment Analyst",
"description": "Analyze market sentiment, news, and social signals.",
"focus_areas": [
"News sentiment analysis",
"Social media sentiment",
"Analyst ratings and price targets",
"Insider activity",
],
"constraints": [
"State clear signal, confidence, and invalidation conditions",
"Use available sentiment analysis tools",
],
},
"valuation_analyst": {
"name": "Valuation Analyst",
"description": "Perform valuation analysis and price target calculations.",
"focus_areas": [
"DCF and comparable valuation",
"Price target derivation",
"Margin of safety assessment",
"Risk-adjusted return expectations",
],
"constraints": [
"State clear signal, confidence, and invalidation conditions",
"Use available valuation tools",
],
},
"risk_manager": {
"name": "Risk Manager",
"description": "Quantify concentration, leverage, liquidity, and volatility risk.",
"focus_areas": [
"Portfolio concentration risk",
"Leverage and margin analysis",
"Liquidity assessment",
"Volatility and drawdown risk",
],
"constraints": [
"Prioritize highest-severity risk first",
"State concrete limits and recommendations",
"Use available risk tools before issuing final memo",
],
},
"portfolio_manager": {
"name": "Portfolio Manager",
"description": "Synthesize analyst and risk inputs into portfolio decisions.",
"focus_areas": [
"Position sizing and allocation",
"Risk-adjusted portfolio construction",
"Trade execution timing",
"Portfolio rebalancing",
],
"constraints": [
"Be concise, capital-aware, and explicit about sizing rationale",
"Respect cash, margin, and concentration constraints",
"Consider all analyst inputs before decisions",
],
},
}
def __init__(self, project_root: Optional[Path] = None):
"""Initialize the agent factory.
Args:
project_root: Root directory of the project
"""
self.project_root = project_root or Path(__file__).parent.parent.parent
self.workspaces_root = self.project_root / "workspaces"
self.template_dir = self.project_root / "backend" / "workspaces" / ".template"
def create_agent(
self,
agent_id: str,
agent_type: str,
workspace_id: str,
model_config: Optional[ModelConfig] = None,
role_config: Optional[RoleConfig] = None,
clone_from: Optional[str] = None,
) -> EvoAgent:
"""Create a new agent.
Args:
agent_id: Unique identifier for the agent
agent_type: Type of agent (e.g., "technical_analyst")
workspace_id: ID of the workspace to create agent in
model_config: Model configuration
role_config: Role configuration (auto-generated if None)
clone_from: Path to existing agent to clone from (optional)
Returns:
EvoAgent instance
Raises:
ValueError: If agent already exists or workspace doesn't exist
"""
workspace_dir = self.workspaces_root / workspace_id
if not workspace_dir.exists():
raise ValueError(f"Workspace '{workspace_id}' does not exist")
agent_dir = workspace_dir / "agents" / agent_id
if agent_dir.exists():
raise ValueError(f"Agent '{agent_id}' already exists in workspace '{workspace_id}'")
# Create directory structure
agent_dir.mkdir(parents=True, exist_ok=True)
(agent_dir / "skills" / "active").mkdir(parents=True, exist_ok=True)
(agent_dir / "skills" / "local").mkdir(parents=True, exist_ok=True)
(agent_dir / "skills" / "installed").mkdir(parents=True, exist_ok=True)
(agent_dir / "skills" / "disabled").mkdir(parents=True, exist_ok=True)
# Copy template or clone existing agent
if clone_from:
self._clone_agent_files(clone_from, agent_dir, agent_id)
else:
self._copy_template(agent_dir, agent_id, agent_type)
# Generate role config if not provided
if role_config is None:
role_config = self._generate_role_config(agent_type)
# Generate ROLE.md
self._generate_role_md(agent_dir, role_config)
# Write agent.yaml
config_path = agent_dir / "agent.yaml"
self._write_agent_yaml(config_path, agent_id, agent_type, model_config)
return EvoAgent(
agent_id=agent_id,
agent_type=agent_type,
workspace_id=workspace_id,
config_path=config_path,
model_config=model_config,
role_config=role_config,
)
def delete_agent(self, agent_id: str, workspace_id: str) -> bool:
"""Delete an agent and its workspace.
Args:
agent_id: ID of the agent to delete
workspace_id: ID of the workspace containing the agent
Returns:
True if deleted, False if agent didn't exist
"""
agent_dir = self.workspaces_root / workspace_id / "agents" / agent_id
if not agent_dir.exists():
return False
shutil.rmtree(agent_dir)
return True
def clone_agent(
self,
source_agent_id: str,
source_workspace_id: str,
new_agent_id: str,
target_workspace_id: Optional[str] = None,
model_config: Optional[ModelConfig] = None,
) -> EvoAgent:
"""Clone an existing agent.
Args:
source_agent_id: ID of the agent to clone
source_workspace_id: Workspace containing the source agent
new_agent_id: ID for the new agent
target_workspace_id: Target workspace (defaults to source workspace)
model_config: Optional new model configuration
Returns:
EvoAgent instance for the cloned agent
"""
target_workspace_id = target_workspace_id or source_workspace_id
source_dir = self.workspaces_root / source_workspace_id / "agents" / source_agent_id
if not source_dir.exists():
raise ValueError(f"Source agent '{source_agent_id}' not found")
# Load source agent config
source_config_path = source_dir / "agent.yaml"
source_config = {}
if source_config_path.exists():
with open(source_config_path, "r", encoding="utf-8") as f:
source_config = yaml.safe_load(f) or {}
agent_type = source_config.get("agent_type", "generic")
# Determine source path for cloning
clone_from = str(source_dir)
return self.create_agent(
agent_id=new_agent_id,
agent_type=agent_type,
workspace_id=target_workspace_id,
model_config=model_config,
clone_from=clone_from,
)
def list_agents(self, workspace_id: Optional[str] = None) -> List[Dict[str, Any]]:
"""List all agents.
Args:
workspace_id: Optional workspace to filter by
Returns:
List of agent information dictionaries
"""
agents = []
if workspace_id:
workspaces = [self.workspaces_root / workspace_id]
else:
if not self.workspaces_root.exists():
return agents
workspaces = [d for d in self.workspaces_root.iterdir() if d.is_dir()]
for workspace in workspaces:
agents_dir = workspace / "agents"
if not agents_dir.exists():
continue
for agent_dir in agents_dir.iterdir():
if not agent_dir.is_dir():
continue
config_path = agent_dir / "agent.yaml"
if config_path.exists():
try:
with open(config_path, "r", encoding="utf-8") as f:
config = yaml.safe_load(f) or {}
agents.append({
"agent_id": agent_dir.name,
"workspace_id": workspace.name,
"agent_type": config.get("agent_type", "unknown"),
"config_path": str(config_path),
})
except Exception:
# Skip invalid agent configs
pass
return agents
def _copy_template(
self,
agent_dir: Path,
agent_id: str,
agent_type: str,
) -> None:
"""Copy template files to agent directory.
Args:
agent_dir: Target agent directory
agent_id: ID of the agent
agent_type: Type of the agent
"""
# Create default markdown files
default_files = {
"AGENTS.md": f"# Agent Guide\n\nDocument how {agent_id} should work, collaborate, and choose tools or skills.\n\n",
"SOUL.md": f"# Soul\n\nDescribe {agent_id}'s temperament, reasoning posture, and voice.\n\n",
"PROFILE.md": f"# Profile\n\nTrack {agent_id}'s long-lived investment style, preferences, and strengths.\n\n",
"MEMORY.md": f"# Memory\n\nStore durable lessons, heuristics, and reminders for {agent_id}.\n\n",
"HEARTBEAT.md": f"# Heartbeat\n\nOptional checklist for periodic review or self-reflection.\n\n",
"POLICY.md": f"# Policy\n\nOptional run-scoped constraints, limits, or strategy policy.\n\n",
"STYLE.md": f"# Style\n\nOptional run-scoped communication or reasoning style.\n\n",
}
for filename, content in default_files.items():
filepath = agent_dir / filename
if not filepath.exists():
filepath.write_text(content, encoding="utf-8")
def _clone_agent_files(self, source_path: str, target_dir: Path, new_agent_id: str) -> None:
"""Clone files from an existing agent.
Args:
source_path: Path to source agent directory
target_dir: Target agent directory
new_agent_id: ID for the new agent
"""
source_dir = Path(source_path)
if not source_dir.exists():
raise ValueError(f"Source path '{source_path}' does not exist")
# Copy markdown files
for md_file in source_dir.glob("*.md"):
target_file = target_dir / md_file.name
content = md_file.read_text(encoding="utf-8")
# Update agent references in content
source_name = source_dir.name
content = content.replace(source_name, new_agent_id)
target_file.write_text(content, encoding="utf-8")
# Copy skills directory structure (but not contents)
for skill_subdir in ["active", "local", "installed", "disabled"]:
source_skills = source_dir / "skills" / skill_subdir
if source_skills.exists():
target_skills = target_dir / "skills" / skill_subdir
target_skills.mkdir(parents=True, exist_ok=True)
# Copy skill files
for skill_file in source_skills.iterdir():
if skill_file.is_file():
shutil.copy2(skill_file, target_skills / skill_file.name)
def _generate_role_config(self, agent_type: str) -> RoleConfig:
"""Generate role configuration for an agent type.
Args:
agent_type: Type of agent
Returns:
RoleConfig instance
"""
template = self.ROLE_TEMPLATES.get(agent_type, {})
return RoleConfig(
name=template.get("name", agent_type.replace("_", " ").title()),
description=template.get("description", ""),
focus_areas=template.get("focus_areas", []),
constraints=template.get("constraints", []),
)
def _generate_role_md(self, agent_dir: Path, role_config: RoleConfig) -> None:
"""Generate ROLE.md file.
Args:
agent_dir: Agent directory
role_config: Role configuration
"""
lines = [f"# {role_config.name}", ""]
if role_config.description:
lines.extend([role_config.description, ""])
if role_config.focus_areas:
lines.extend(["## Focus Areas", ""])
for area in role_config.focus_areas:
lines.append(f"- {area}")
lines.append("")
if role_config.constraints:
lines.extend(["## Constraints", ""])
for constraint in role_config.constraints:
lines.append(f"- {constraint}")
lines.append("")
content = "\n".join(lines)
(agent_dir / "ROLE.md").write_text(content, encoding="utf-8")
def _write_agent_yaml(
self,
config_path: Path,
agent_id: str,
agent_type: str,
model_config: Optional[ModelConfig] = None,
) -> None:
"""Write agent.yaml configuration file.
Args:
config_path: Path to write configuration
agent_id: Agent ID
agent_type: Agent type
model_config: Optional model configuration
"""
config = {
"agent_id": agent_id,
"agent_type": agent_type,
"prompt_files": [
"SOUL.md",
"PROFILE.md",
"AGENTS.md",
"POLICY.md",
"MEMORY.md",
],
"enabled_skills": [],
"disabled_skills": [],
"active_tool_groups": [],
"disabled_tool_groups": [],
}
if model_config:
config["model"] = {
"name": model_config.model_name,
"temperature": model_config.temperature,
"max_tokens": model_config.max_tokens,
}
with open(config_path, "w", encoding="utf-8") as f:
yaml.safe_dump(config, f, allow_unicode=True, sort_keys=False)

View File

@@ -0,0 +1,19 @@
# -*- coding: utf-8 -*-
"""Prompt building utilities for EvoAgent.
This module provides prompt construction from workspace markdown files
with YAML frontmatter support.
"""
from .builder import (
PromptBuilder,
build_system_prompt_from_workspace,
build_bootstrap_guidance,
DEFAULT_SYS_PROMPT,
)
__all__ = [
"PromptBuilder",
"build_system_prompt_from_workspace",
"build_bootstrap_guidance",
"DEFAULT_SYS_PROMPT",
]

View File

@@ -0,0 +1,305 @@
# -*- coding: utf-8 -*-
"""PromptBuilder for constructing system prompts from workspace markdown files.
Based on CoPaw design - loads AGENTS.md, SOUL.md, PROFILE.md, etc. from
agent workspace directories with YAML frontmatter support.
"""
from __future__ import annotations
import logging
from pathlib import Path
from typing import Any, Dict, List, Optional
import yaml
logger = logging.getLogger(__name__)
DEFAULT_SYS_PROMPT = """You are a helpful trading analysis assistant."""
class PromptBuilder:
"""Builder for constructing system prompts from markdown files.
Loads markdown configuration files from agent workspace directories,
supporting YAML frontmatter for metadata extraction.
"""
DEFAULT_FILES = [
"AGENTS.md",
"SOUL.md",
"PROFILE.md",
"ROLE.md",
"POLICY.md",
"MEMORY.md",
"HEARTBEAT.md",
"STYLE.md",
]
TITLE_MAP: Dict[str, str] = {
"AGENTS.md": "Agent Guide",
"SOUL.md": "Soul",
"PROFILE.md": "Profile",
"ROLE.md": "Role",
"POLICY.md": "Policy",
"MEMORY.md": "Memory",
"HEARTBEAT.md": "Heartbeat",
"STYLE.md": "Style",
"BOOTSTRAP.md": "Bootstrap",
}
def __init__(
self,
workspace_dir: Path,
enabled_files: Optional[List[str]] = None,
):
"""Initialize prompt builder.
Args:
workspace_dir: Directory containing markdown configuration files
enabled_files: List of filenames to load (if None, uses defaults)
"""
self.workspace_dir = Path(workspace_dir)
self.enabled_files = enabled_files or self.DEFAULT_FILES.copy()
self._prompt_parts: List[str] = []
self._metadata: Dict[str, Any] = {}
self.loaded_count = 0
def _load_file(self, filename: str) -> tuple[str, Optional[Dict[str, Any]]]:
"""Load a single markdown file with YAML frontmatter support.
Args:
filename: Name of the file to load
Returns:
Tuple of (content, metadata dict or None)
"""
file_path = self.workspace_dir / filename
if not file_path.exists():
logger.debug("File %s not found in %s, skipping", filename, self.workspace_dir)
return "", None
try:
raw_content = file_path.read_text(encoding="utf-8").strip()
if not raw_content:
logger.debug("Skipped empty file: %s", filename)
return "", None
content, metadata = self._parse_frontmatter(raw_content)
if content:
self.loaded_count += 1
logger.debug("Loaded %s (metadata: %s)", filename, bool(metadata))
return content, metadata
except Exception as e:
logger.warning("Failed to read file %s: %s, skipping", filename, e)
return "", None
def _parse_frontmatter(self, raw_content: str) -> tuple[str, Optional[Dict[str, Any]]]:
"""Parse YAML frontmatter from markdown content.
Args:
raw_content: Raw file content
Returns:
Tuple of (content without frontmatter, metadata dict or None)
"""
if not raw_content.startswith("---"):
return raw_content, None
parts = raw_content.split("---", 2)
if len(parts) < 3:
return raw_content, None
frontmatter = parts[1].strip()
content = parts[2].strip()
try:
metadata = yaml.safe_load(frontmatter) or {}
if not isinstance(metadata, dict):
metadata = {}
return content, metadata
except yaml.YAMLError as e:
logger.warning("Failed to parse YAML frontmatter: %s", e)
return content, None
def _append_section(self, title: str, content: str) -> None:
"""Append a section to the prompt parts.
Args:
title: Section title
content: Section content
"""
content = content.strip()
if not content:
return
if self._prompt_parts:
self._prompt_parts.append("")
self._prompt_parts.append(f"## {title}")
self._prompt_parts.append("")
self._prompt_parts.append(content)
def build(self) -> str:
"""Build the system prompt from markdown files.
Returns:
Constructed system prompt string
"""
self._prompt_parts = []
self._metadata = {}
self.loaded_count = 0
for filename in self.enabled_files:
content, metadata = self._load_file(filename)
if metadata:
self._metadata[filename] = metadata
if content:
title = self.TITLE_MAP.get(filename, filename.replace(".md", ""))
self._append_section(title, content)
if not self._prompt_parts:
logger.warning("No content loaded from workspace: %s", self.workspace_dir)
return DEFAULT_SYS_PROMPT
final_prompt = "\n".join(self._prompt_parts)
logger.debug(
"System prompt built from %d file(s), total length: %d chars",
self.loaded_count,
len(final_prompt),
)
return final_prompt
def get_metadata(self) -> Dict[str, Any]:
"""Get metadata collected from YAML frontmatter.
Returns:
Dictionary mapping filenames to their metadata
"""
return self._metadata.copy()
def get_agent_identity(self) -> Optional[Dict[str, Any]]:
"""Extract agent identity from PROFILE.md metadata.
Returns:
Identity dict with name, role, etc. or None
"""
profile_meta = self._metadata.get("PROFILE.md", {})
if not profile_meta:
return None
return {
"name": profile_meta.get("name", "Unknown"),
"role": profile_meta.get("role", ""),
"expertise": profile_meta.get("expertise", []),
"style": profile_meta.get("style", ""),
}
def build_system_prompt_from_workspace(
workspace_dir: Path,
enabled_files: Optional[List[str]] = None,
agent_id: Optional[str] = None,
extra_context: Optional[str] = None,
) -> str:
"""Build system prompt from workspace markdown files.
This is the main entry point for building system prompts from
agent workspace directories.
Args:
workspace_dir: Directory containing markdown configuration files
enabled_files: List of filenames to load (if None, uses defaults)
agent_id: Agent identifier to include in system prompt
extra_context: Additional context to append to the prompt
Returns:
Constructed system prompt string
"""
builder = PromptBuilder(
workspace_dir=workspace_dir,
enabled_files=enabled_files,
)
prompt = builder.build()
# Add agent identity header if agent_id provided
if agent_id and agent_id != "default":
identity_header = (
f"# Agent Identity\n\n"
f"Your agent ID is `{agent_id}`. "
f"This is your unique identifier in the multi-agent system.\n\n"
)
prompt = identity_header + prompt
# Append extra context if provided
if extra_context:
prompt = prompt + "\n\n" + extra_context
return prompt
def build_bootstrap_guidance(language: str = "zh") -> str:
"""Build bootstrap guidance message for first-time setup.
Args:
language: Language code (zh/en)
Returns:
Formatted bootstrap guidance message
"""
if language == "zh":
return (
"# 引导模式\n"
"\n"
"工作目录中存在 `BOOTSTRAP.md` — 首次设置。\n"
"\n"
"1. 阅读 BOOTSTRAP.md友好地表示初次见面"
"引导用户完成设置。\n"
"2. 按照 BOOTSTRAP.md 的指示,"
"帮助用户定义你的身份和偏好。\n"
"3. 按指南创建/更新必要文件"
"PROFILE.md、MEMORY.md 等)。\n"
"4. 完成后删除 BOOTSTRAP.md。\n"
"\n"
"如果用户希望跳过,直接回答下面的问题即可。\n"
"\n"
"---\n"
"\n"
)
return (
"# BOOTSTRAP MODE\n"
"\n"
"`BOOTSTRAP.md` exists — first-time setup.\n"
"\n"
"1. Read BOOTSTRAP.md, greet the user, "
"and guide them through setup.\n"
"2. Follow BOOTSTRAP.md instructions "
"to define identity and preferences.\n"
"3. Create/update files "
"(PROFILE.md, MEMORY.md, etc.) as described.\n"
"4. Delete BOOTSTRAP.md when done.\n"
"\n"
"If the user wants to skip, answer their "
"question directly instead.\n"
"\n"
"---\n"
"\n"
)
__all__ = [
"PromptBuilder",
"build_system_prompt_from_workspace",
"build_bootstrap_guidance",
"DEFAULT_SYS_PROMPT",
]

284
backend/agents/registry.py Normal file
View File

@@ -0,0 +1,284 @@
# -*- coding: utf-8 -*-
"""Agent Registry - In-memory registry for agent management."""
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional
@dataclass
class AgentInfo:
"""Information about a registered agent."""
agent_id: str
agent_type: str
workspace_id: str
config_path: str
agent_dir: str
status: str = "inactive" # inactive, active, error
metadata: Dict[str, Any] = field(default_factory=dict)
def to_dict(self) -> Dict[str, Any]:
"""Serialize to dictionary."""
return {
"agent_id": self.agent_id,
"agent_type": self.agent_type,
"workspace_id": self.workspace_id,
"config_path": self.config_path,
"agent_dir": self.agent_dir,
"status": self.status,
"metadata": self.metadata,
}
class AgentRegistry:
"""In-memory registry for agent instances."""
def __init__(self):
"""Initialize the agent registry."""
# Dictionary mapping agent_id -> AgentInfo
self._agents: Dict[str, AgentInfo] = {}
# Index mapping workspace_id -> set of agent_ids
self._workspace_index: Dict[str, set] = {}
def register(
self,
agent_id: str,
agent_type: str,
workspace_id: str,
config_path: str,
agent_dir: str,
status: str = "inactive",
metadata: Optional[Dict[str, Any]] = None,
) -> AgentInfo:
"""Register an agent in the registry.
Args:
agent_id: Unique identifier for the agent
agent_type: Type of agent
workspace_id: ID of the workspace containing the agent
config_path: Path to agent configuration file
agent_dir: Path to agent directory
status: Initial status (default: inactive)
metadata: Optional metadata dictionary
Returns:
AgentInfo instance
Raises:
ValueError: If agent_id is already registered
"""
if agent_id in self._agents:
raise ValueError(f"Agent '{agent_id}' is already registered")
agent_info = AgentInfo(
agent_id=agent_id,
agent_type=agent_type,
workspace_id=workspace_id,
config_path=config_path,
agent_dir=agent_dir,
status=status,
metadata=metadata or {},
)
self._agents[agent_id] = agent_info
# Update workspace index
if workspace_id not in self._workspace_index:
self._workspace_index[workspace_id] = set()
self._workspace_index[workspace_id].add(agent_id)
return agent_info
def unregister(self, agent_id: str) -> bool:
"""Unregister an agent.
Args:
agent_id: ID of the agent to unregister
Returns:
True if unregistered, False if agent wasn't registered
"""
if agent_id not in self._agents:
return False
agent_info = self._agents[agent_id]
# Remove from workspace index
workspace_id = agent_info.workspace_id
if workspace_id in self._workspace_index:
self._workspace_index[workspace_id].discard(agent_id)
if not self._workspace_index[workspace_id]:
del self._workspace_index[workspace_id]
# Remove from agents dict
del self._agents[agent_id]
return True
def get(self, agent_id: str) -> Optional[AgentInfo]:
"""Get agent information by ID.
Args:
agent_id: ID of the agent
Returns:
AgentInfo if found, None otherwise
"""
return self._agents.get(agent_id)
def list_all(
self,
workspace_id: Optional[str] = None,
agent_type: Optional[str] = None,
status: Optional[str] = None,
) -> List[AgentInfo]:
"""List all registered agents with optional filtering.
Args:
workspace_id: Filter by workspace ID
agent_type: Filter by agent type
status: Filter by status
Returns:
List of AgentInfo instances
"""
agents = list(self._agents.values())
if workspace_id:
agent_ids = self._workspace_index.get(workspace_id, set())
agents = [a for a in agents if a.agent_id in agent_ids]
if agent_type:
agents = [a for a in agents if a.agent_type == agent_type]
if status:
agents = [a for a in agents if a.status == status]
return agents
def update_status(self, agent_id: str, status: str) -> bool:
"""Update the status of an agent.
Args:
agent_id: ID of the agent
status: New status value
Returns:
True if updated, False if agent not found
"""
if agent_id not in self._agents:
return False
self._agents[agent_id].status = status
return True
def update_metadata(self, agent_id: str, metadata: Dict[str, Any]) -> bool:
"""Update the metadata of an agent.
Args:
agent_id: ID of the agent
metadata: Metadata dictionary to merge
Returns:
True if updated, False if agent not found
"""
if agent_id not in self._agents:
return False
self._agents[agent_id].metadata.update(metadata)
return True
def is_registered(self, agent_id: str) -> bool:
"""Check if an agent is registered.
Args:
agent_id: ID of the agent
Returns:
True if registered, False otherwise
"""
return agent_id in self._agents
def get_workspace_agents(self, workspace_id: str) -> List[AgentInfo]:
"""Get all agents in a workspace.
Args:
workspace_id: ID of the workspace
Returns:
List of AgentInfo instances
"""
agent_ids = self._workspace_index.get(workspace_id, set())
return [self._agents[agent_id] for agent_id in agent_ids if agent_id in self._agents]
def get_agent_count(self, workspace_id: Optional[str] = None) -> int:
"""Get the count of registered agents.
Args:
workspace_id: Optional workspace ID to filter by
Returns:
Number of agents
"""
if workspace_id:
return len(self._workspace_index.get(workspace_id, set()))
return len(self._agents)
def clear(self) -> None:
"""Clear all registered agents."""
self._agents.clear()
self._workspace_index.clear()
def get_stats(self) -> Dict[str, Any]:
"""Get registry statistics.
Returns:
Dictionary with registry statistics
"""
stats = {
"total_agents": len(self._agents),
"workspaces": len(self._workspace_index),
"agents_by_workspace": {
ws_id: len(agent_ids)
for ws_id, agent_ids in self._workspace_index.items()
},
"agents_by_type": {},
"agents_by_status": {},
}
for agent in self._agents.values():
# Count by type
agent_type = agent.agent_type
stats["agents_by_type"][agent_type] = (
stats["agents_by_type"].get(agent_type, 0) + 1
)
# Count by status
status = agent.status
stats["agents_by_status"][status] = (
stats["agents_by_status"].get(status, 0) + 1
)
return stats
# Global registry instance
_global_registry: Optional[AgentRegistry] = None
def get_registry() -> AgentRegistry:
"""Get the global agent registry instance.
Returns:
AgentRegistry instance
"""
global _global_registry
if _global_registry is None:
_global_registry = AgentRegistry()
return _global_registry
def reset_registry() -> None:
"""Reset the global registry (useful for testing)."""
global _global_registry
_global_registry = None

View File

@@ -0,0 +1,388 @@
# -*- coding: utf-8 -*-
"""Skill loader for loading and validating skills from directories.
提供从目录加载技能、解析SKILL.md frontmatter、获取工具列表等功能。
"""
import logging
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Dict, List, Optional, Set
import yaml
from backend.agents.skill_metadata import SkillMetadata, parse_skill_metadata
logger = logging.getLogger(__name__)
@dataclass
class SkillInfo:
"""完整的技能信息"""
name: str
description: str
version: str
source: str
path: Path
metadata: SkillMetadata
tools: List[str] = field(default_factory=list)
scripts: List[str] = field(default_factory=list)
references: List[str] = field(default_factory=list)
content: str = ""
def load_skill_from_dir(skill_dir: Path, source: str = "unknown") -> Optional[Dict[str, Any]]:
"""从目录加载技能
Args:
skill_dir: 技能目录路径
source: 技能来源 (builtin/customized/local/installed/active)
Returns:
技能信息字典加载失败返回None
"""
if not skill_dir.exists() or not skill_dir.is_dir():
logger.warning(f"Skill directory does not exist: {skill_dir}")
return None
skill_md = skill_dir / "SKILL.md"
if not skill_md.exists():
logger.warning(f"SKILL.md not found in: {skill_dir}")
return None
try:
# 解析元数据
metadata = parse_skill_metadata(skill_dir, source=source)
# 读取完整内容
content = skill_md.read_text(encoding="utf-8")
# 提取body (去掉frontmatter)
body = content
if content.startswith("---"):
parts = content.split("---", 2)
if len(parts) >= 3:
body = parts[2].strip()
# 获取工具列表
tools = get_skill_tools(skill_dir)
# 获取脚本列表
scripts = _get_skill_scripts(skill_dir)
# 获取参考资料列表
references = _get_skill_references(skill_dir)
return {
"name": metadata.name,
"skill_name": metadata.skill_name,
"description": metadata.description,
"version": metadata.version,
"source": source,
"path": str(skill_dir),
"content": body,
"tools": tools,
"scripts": scripts,
"references": references,
"metadata": metadata,
}
except Exception as e:
logger.error(f"Failed to load skill from {skill_dir}: {e}")
return None
def parse_skill_metadata(skill_dir: Path, source: str = "unknown") -> SkillMetadata:
"""解析技能元数据 (兼容已有函数)
Args:
skill_dir: 技能目录路径
source: 技能来源
Returns:
SkillMetadata对象
"""
from backend.agents.skill_metadata import parse_skill_metadata as _parse
return _parse(skill_dir, source=source)
def get_skill_tools(skill_dir: Path) -> List[str]:
"""获取技能提供的工具列表
从SKILL.md frontmatter的tools字段和scripts目录解析工具。
Args:
skill_dir: 技能目录路径
Returns:
工具名称列表
"""
tools: Set[str] = set()
# 1. 从SKILL.md frontmatter读取tools字段
skill_md = skill_dir / "SKILL.md"
if skill_md.exists():
try:
raw = skill_md.read_text(encoding="utf-8").strip()
if raw.startswith("---"):
parts = raw.split("---", 2)
if len(parts) >= 3:
try:
frontmatter = yaml.safe_load(parts[1].strip()) or {}
if isinstance(frontmatter, dict):
tools_list = frontmatter.get("tools", [])
if isinstance(tools_list, str):
tools.add(tools_list.strip())
elif isinstance(tools_list, list):
for tool in tools_list:
if isinstance(tool, str):
tools.add(tool.strip())
except yaml.YAMLError:
pass
except Exception as e:
logger.warning(f"Failed to parse tools from SKILL.md: {e}")
# 2. 从scripts目录推断工具
scripts_dir = skill_dir / "scripts"
if scripts_dir.exists() and scripts_dir.is_dir():
for script in scripts_dir.iterdir():
if script.is_file() and not script.name.startswith("_"):
# 去掉扩展名作为工具名
tool_name = script.stem
tools.add(tool_name)
return sorted(list(tools))
def _get_skill_scripts(skill_dir: Path) -> List[str]:
"""获取技能脚本列表
Args:
skill_dir: 技能目录路径
Returns:
脚本相对路径列表 (相对于scripts目录)
"""
scripts: List[str] = []
scripts_dir = skill_dir / "scripts"
if not scripts_dir.exists():
return scripts
try:
for item in scripts_dir.rglob("*"):
if item.is_file() and not item.name.startswith("_"):
rel_path = item.relative_to(scripts_dir)
scripts.append(str(rel_path))
except Exception as e:
logger.warning(f"Failed to list scripts in {skill_dir}: {e}")
return sorted(scripts)
def _get_skill_references(skill_dir: Path) -> List[str]:
"""获取技能参考资料列表
Args:
skill_dir: 技能目录路径
Returns:
参考资料相对路径列表 (相对于references目录)
"""
refs: List[str] = []
refs_dir = skill_dir / "references"
if not refs_dir.exists():
return refs
try:
for item in refs_dir.rglob("*"):
if item.is_file():
rel_path = item.relative_to(refs_dir)
refs.append(str(rel_path))
except Exception as e:
logger.warning(f"Failed to list references in {skill_dir}: {e}")
return sorted(refs)
def validate_skill(skill_dir: Path) -> Dict[str, Any]:
"""验证技能格式
检查技能目录结构是否符合规范。
Args:
skill_dir: 技能目录路径
Returns:
验证结果字典,包含:
- valid: 是否有效
- errors: 错误列表
- warnings: 警告列表
"""
errors: List[str] = []
warnings: List[str] = []
# 检查目录存在
if not skill_dir.exists():
errors.append(f"Skill directory does not exist: {skill_dir}")
return {"valid": False, "errors": errors, "warnings": warnings}
if not skill_dir.is_dir():
errors.append(f"Path is not a directory: {skill_dir}")
return {"valid": False, "errors": errors, "warnings": warnings}
# 检查SKILL.md
skill_md = skill_dir / "SKILL.md"
if not skill_md.exists():
errors.append("SKILL.md is required but not found")
return {"valid": False, "errors": errors, "warnings": warnings}
# 解析frontmatter
try:
content = skill_md.read_text(encoding="utf-8").strip()
if not content.startswith("---"):
warnings.append("SKILL.md should have YAML frontmatter (starts with ---)")
else:
parts = content.split("---", 2)
if len(parts) < 3:
errors.append("Invalid YAML frontmatter format")
else:
try:
frontmatter = yaml.safe_load(parts[1].strip()) or {}
if not isinstance(frontmatter, dict):
errors.append("YAML frontmatter must be a dictionary")
else:
# 检查必需字段
if "name" not in frontmatter:
warnings.append("Frontmatter should have 'name' field")
if "description" not in frontmatter:
warnings.append("Frontmatter should have 'description' field")
# 检查version字段
version = frontmatter.get("version")
if version and not isinstance(version, str):
warnings.append("'version' should be a string")
# 检查tools字段
tools = frontmatter.get("tools")
if tools and not isinstance(tools, (str, list)):
warnings.append("'tools' should be a string or list")
except yaml.YAMLError as e:
errors.append(f"Invalid YAML in frontmatter: {e}")
except Exception as e:
errors.append(f"Failed to read SKILL.md: {e}")
# 检查body内容
try:
content = skill_md.read_text(encoding="utf-8")
body = content
if content.startswith("---"):
parts = content.split("---", 2)
if len(parts) >= 3:
body = parts[2].strip()
if not body:
warnings.append("SKILL.md body is empty")
elif len(body) < 50:
warnings.append("SKILL.md body is very short, consider adding more details")
except Exception as e:
errors.append(f"Failed to validate body: {e}")
# 检查scripts目录
scripts_dir = skill_dir / "scripts"
if scripts_dir.exists():
if not scripts_dir.is_dir():
errors.append("'scripts' exists but is not a directory")
else:
# 检查是否有可执行脚本
has_scripts = any(
f.is_file() and not f.name.startswith("_")
for f in scripts_dir.iterdir()
)
if not has_scripts:
warnings.append("scripts directory exists but contains no valid scripts")
# 检查references目录
refs_dir = skill_dir / "references"
if refs_dir.exists() and not refs_dir.is_dir():
errors.append("'references' exists but is not a directory")
return {
"valid": len(errors) == 0,
"errors": errors,
"warnings": warnings,
}
def load_skills_from_directory(
directory: Path,
source: str = "unknown",
recursive: bool = False,
) -> List[Dict[str, Any]]:
"""从目录加载所有技能
Args:
directory: 包含技能目录的父目录
source: 技能来源标识
recursive: 是否递归搜索子目录
Returns:
技能信息列表
"""
skills: List[Dict[str, Any]] = []
if not directory.exists() or not directory.is_dir():
logger.warning(f"Directory does not exist: {directory}")
return skills
try:
for item in directory.iterdir():
if not item.is_dir():
continue
# 检查是否是技能目录 (包含SKILL.md)
if (item / "SKILL.md").exists():
skill_info = load_skill_from_dir(item, source=source)
if skill_info:
skills.append(skill_info)
elif recursive:
# 递归搜索子目录
sub_skills = load_skills_from_directory(item, source, recursive)
skills.extend(sub_skills)
except Exception as e:
logger.error(f"Failed to load skills from {directory}: {e}")
return skills
def get_skill_manifest(skill_dir: Path) -> Dict[str, Any]:
"""获取技能清单
生成技能的详细清单,用于调试和展示。
Args:
skill_dir: 技能目录路径
Returns:
技能清单字典
"""
info = load_skill_from_dir(skill_dir)
if not info:
return {"error": "Failed to load skill"}
validation = validate_skill(skill_dir)
return {
"name": info["name"],
"skill_name": info["skill_name"],
"version": info["version"],
"description": info["description"],
"source": info["source"],
"path": info["path"],
"tools": info["tools"],
"scripts": info["scripts"],
"references": info["references"],
"validation": validation,
"content_preview": info["content"][:500] + "..." if len(info["content"]) > 500 else info["content"],
}

286
backend/agents/templates.py Normal file
View File

@@ -0,0 +1,286 @@
"""
Agent模板定义
包含各角色的ROLE.md内容字典供程序生成Agent工作空间时使用。
"""
# 基础模板文件内容
BASE_TEMPLATES = {
"AGENTS.md": """# Agent Guide
## 工作流程
1. 接收分析任务
2. 调用相关工具/技能
3. 生成分析报告
4. 参与团队决策
## 工具使用规范
- 优先使用已激活的技能
- 不确定时询问Portfolio Manager
- 重要发现用 `/save` 记录
## 记忆管理
- 使用 `/compact` 定期压缩记忆
- 投资经验记录在MEMORY.md
""",
"SOUL.md": """# Soul
你是专业的金融分析师,语气冷静、客观、专业。
你的分析应该数据驱动,避免情绪化表达。
""",
"PROFILE.md": """# Profile
## 投资风格
- 风险承受能力:中等
- 投资期限中期3-12个月
- 偏好行业:科技、医疗、消费
## 优势
- 财务分析
- 趋势识别
## 改进方向
- 市场情绪把握
""",
"MEMORY.md": """# Memory
<!-- 此文件用于记录Agent的学习经验和重要发现 -->
## 经验总结
## 重要事件
## 改进记录
""",
"HEARTBEAT.md": """# Heartbeat
## 定时任务
- 每日开盘前检查持仓
- 收盘后记录当日表现
""",
"POLICY.md": """# Policy
## 风控规则
- 单一持仓不超过20%
- 止损线:-15%
""",
"STYLE.md": """# Style
- 使用结构化输出JSON/Markdown表格
- 包含置信度评分
- 列出关键假设
""",
"agent.yaml": """agent_id: {agent_id}
agent_type: {agent_type}
name: {name}
model:
provider: openai
model_name: gpt-4o
temperature: 0.3
enabled_skills: []
disabled_skills: []
settings: {{}}
""",
}
# 角色专用模板
ROLE_TEMPLATES = {
"fundamental": {
"ROLE.md": """# Role: Fundamental Analyst
## 职责
分析公司财务报表、盈利能力、成长性、竞争优势等基本面因素。
## 分析维度
- 财务报表分析(资产负债表、利润表、现金流量表)
- 盈利能力指标ROE、ROA、毛利率、净利率
- 成长性指标(营收增长率、利润增长率)
- 估值指标P/E、P/B、P/S
- 行业地位和竞争优势
## 输出格式
- 财务健康度评分1-10
- 成长性评分1-10
- 关键财务亮点和风险
- 同业对比分析
""",
"SOUL.md": """# Soul
你是严谨的基本面分析师,像沃伦·巴菲特一样注重企业内在价值。
你的分析深入细致,关注长期价值而非短期波动。
语气沉稳、逻辑严密,善于发现财务数据背后的商业本质。
""",
},
"technical": {
"ROLE.md": """# Role: Technical Analyst
## 职责
分析价格走势、交易量、技术指标,识别买卖时机。
## 分析维度
- 趋势分析(长期/中期/短期趋势)
- 支撑阻力位识别
- 技术指标MACD、RSI、KDJ、布林带等
- 形态识别(头肩顶/底、双底、三角形等)
- 量价关系分析
## 输出格式
- 趋势方向(上涨/下跌/震荡)
- 关键价位(支撑/阻力)
- 技术信号(买入/卖出/观望)
- 置信度评分
""",
"SOUL.md": """# Soul
你是敏锐的技术分析师,相信价格包含一切信息。
你善于从图表中发现规律,像侦探一样寻找市场留下的痕迹。
语气果断、快速反应,善于捕捉稍纵即逝的交易机会。
""",
},
"sentiment": {
"ROLE.md": """# Role: Sentiment Analyst
## 职责
分析市场情绪、资金流向、新闻舆情,判断市场心理状态。
## 分析维度
- 市场情绪指标(恐慌/贪婪指数)
- 资金流向分析(主力/散户资金)
- 新闻舆情分析(正面/负面/中性)
- 社交媒体情绪
- 机构持仓变化
## 输出格式
- 情绪评分(-10到+10极度恐慌到极度贪婪
- 资金流向判断
- 舆情摘要
- 情绪拐点预警
""",
"SOUL.md": """# Soul
你是敏感的市场情绪捕手,善于感知市场的恐惧与贪婪。
你关注人性在金融市场中的表现,理解情绪如何驱动价格。
语气富有洞察力、善于捕捉微妙变化,像心理学家一样理解市场参与者。
""",
},
"valuation": {
"ROLE.md": """# Role: Valuation Analyst
## 职责
评估公司内在价值,计算合理价格区间,识别高估/低估机会。
## 分析维度
- DCF现金流折现模型
- 相对估值法P/E、EV/EBITDA等
- 资产重估法
- 分部估值SOTP
- 安全边际计算
## 输出格式
- 内在价值估算
- 合理价格区间
- 当前价格vs内在价值高估/低估百分比)
- 估值假设和敏感性分析
""",
"SOUL.md": """# Soul
你是精确的估值分析师,追求计算内在价值的准确区间。
你像精算师一样严谨,注重假设的合理性和安全边际。
语气精确、注重数字,善于发现市场定价错误带来的机会。
""",
},
"portfolio": {
"ROLE.md": """# Role: Portfolio Manager
## 职责
统筹各分析师意见,制定投资决策,管理投资组合配置。
## 分析维度
- 资产配置策略(股债比例、行业分布)
- 风险收益平衡
- 仓位管理(建仓/加仓/减仓/清仓)
- 再平衡时机
- 组合相关性分析
## 输出格式
- 投资决策(买入/卖出/持有)
- 建议仓位比例
- 目标价位
- 止损止盈设置
- 组合调整建议
""",
"SOUL.md": """# Soul
你是睿智的投资组合经理,像将军一样统筹全局。
你善于权衡各方意见,做出果断而理性的投资决策。
语气权威、决策果断,对组合整体表现负有最终责任。
""",
},
"risk": {
"ROLE.md": """# Role: Risk Manager
## 职责
识别、评估和监控投资风险,确保组合风险在可控范围内。
## 分析维度
- 市场风险Beta、波动率
- 信用风险
- 流动性风险
- 集中度风险
- 尾部风险VaR、CVaR
- 压力测试
## 输出格式
- 风险等级(低/中/高/极高)
- 风险敞口分析
- 风险调整建议
- 预警阈值设置
- 应急预案
""",
"SOUL.md": """# Soul
你是谨慎的风险管理者,时刻警惕潜在的损失。
你像守门员一样守护组合安全,宁可错过机会也不冒无法承受的风险。
语气保守、风险意识强,善于发现隐藏的威胁和脆弱性。
""",
},
}
def get_base_template(filename: str) -> str | None:
"""获取基础模板内容"""
return BASE_TEMPLATES.get(filename)
def get_role_template(role_type: str, filename: str) -> str | None:
"""获取角色专用模板内容"""
role = ROLE_TEMPLATES.get(role_type)
if role:
return role.get(filename)
return None
def get_all_role_types() -> list[str]:
"""获取所有角色类型列表"""
return list(ROLE_TEMPLATES.keys())
def render_agent_yaml(agent_id: str, agent_type: str, name: str) -> str:
"""渲染agent.yaml模板"""
return BASE_TEMPLATES["agent.yaml"].format(
agent_id=agent_id,
agent_type=agent_type,
name=name
)

View File

@@ -1,22 +1,30 @@
# -*- coding: utf-8 -*-
"""Toolkit factory following AgentScope's skill + tool group practices."""
"""Toolkit factory following AgentScope's skill + tool group practices.
from typing import Any, Dict, Iterable
支持从Agent工作空间动态创建工具集加载builtin/customized技能
以及合并Agent特定工具。
"""
from typing import Any, Dict, Iterable, List, Optional
from pathlib import Path
from .agent_workspace import load_agent_workspace_config
from backend.config.bootstrap_config import get_bootstrap_config_for_run
import yaml
from .skills_manager import SkillsManager
from backend.agents.agent_workspace import load_agent_workspace_config
from backend.agents.skills_manager import SkillsManager
from backend.agents.skill_loader import load_skill_from_dir, get_skill_tools
from backend.config.bootstrap_config import get_bootstrap_config_for_run
def load_agent_profiles() -> Dict[str, Dict[str, Any]]:
"""加载Agent配置文件"""
config_path = SkillsManager().project_root / "backend" / "config" / "agent_profiles.yaml"
with open(config_path, "r", encoding="utf-8") as file:
return yaml.safe_load(file) or {}
def _register_analysis_tool_groups(toolkit: Any) -> None:
"""注册分析工具组"""
from backend.tools.analysis_tools import TOOL_REGISTRY
tool_groups = {
@@ -95,6 +103,7 @@ def _register_analysis_tool_groups(toolkit: Any) -> None:
def _register_portfolio_tool_groups(toolkit: Any, pm_agent: Any) -> None:
"""注册投资组合工具组"""
toolkit.create_tool_group(
group_name="portfolio_ops",
description="Portfolio decision recording tools.",
@@ -111,6 +120,7 @@ def _register_portfolio_tool_groups(toolkit: Any, pm_agent: Any) -> None:
def _register_risk_tool_groups(toolkit: Any) -> None:
"""注册风险工具组"""
from backend.tools.risk_tools import (
assess_margin_and_liquidity,
assess_position_concentration,
@@ -146,7 +156,17 @@ def create_agent_toolkit(
owner: Any = None,
active_skill_dirs: Iterable[str] | None = None,
) -> Any:
"""Create a Toolkit with agent skills and grouped tools."""
"""Create a Toolkit with agent skills and grouped tools.
Args:
agent_id: Agent标识符
config_name: 运行配置名称
owner: Agent实例用于注册特定方法
active_skill_dirs: 显式指定的活动技能目录列表
Returns:
配置好的Toolkit实例
"""
from agentscope.tool import Toolkit
profiles = load_agent_profiles()
@@ -207,3 +227,173 @@ def create_agent_toolkit(
toolkit.update_tool_groups(group_names=active_groups, active=True)
return toolkit
def create_toolkit_from_workspace(
agent_id: str,
config_name: str,
owner: Any = None,
include_builtin: bool = True,
include_customized: bool = True,
include_local: bool = True,
active_groups: Optional[List[str]] = None,
) -> Any:
"""从Agent工作空间创建工具集
这是create_agent_toolkit的增强版本支持更灵活的技能加载策略。
Args:
agent_id: Agent标识符
config_name: 运行配置名称
owner: Agent实例
include_builtin: 是否包含builtin技能
include_customized: 是否包含customized技能
include_local: 是否包含agent-local技能
active_groups: 显式指定的活动工具组
Returns:
配置好的Toolkit实例
"""
from agentscope.tool import Toolkit
skills_manager = SkillsManager()
agent_config = load_agent_workspace_config(
skills_manager.get_agent_asset_dir(config_name, agent_id) / "agent.yaml",
)
toolkit = Toolkit(
agent_skill_instruction=(
"<system-info>You have access to project skills. Each skill lives in a "
"directory and is described by SKILL.md. Follow the skill instructions "
"when they are relevant to the current task.</system-info>"
),
agent_skill_template="- {name} (dir: {dir}): {description}",
)
# 注册Agent类型的默认工具组
if agent_id.endswith("_analyst"):
_register_analysis_tool_groups(toolkit)
elif agent_id == "portfolio_manager" and owner is not None:
_register_portfolio_tool_groups(toolkit, owner)
elif agent_id == "risk_manager":
_register_risk_tool_groups(toolkit)
# 收集所有要加载的技能目录
skill_dirs: List[Path] = []
# 1. 从active目录加载已同步的技能
active_root = skills_manager.get_agent_active_root(config_name, agent_id)
if active_root.exists():
for skill_dir in sorted(active_root.iterdir()):
if skill_dir.is_dir() and (skill_dir / "SKILL.md").exists():
skill_dirs.append(skill_dir)
# 2. 从installed目录加载
installed_root = skills_manager.get_agent_installed_root(config_name, agent_id)
if installed_root.exists():
for skill_dir in sorted(installed_root.iterdir()):
if skill_dir.is_dir() and (skill_dir / "SKILL.md").exists():
if skill_dir not in skill_dirs:
skill_dirs.append(skill_dir)
# 3. 从local目录加载agent-local技能
if include_local:
local_root = skills_manager.get_agent_local_root(config_name, agent_id)
if local_root.exists():
for skill_dir in sorted(local_root.iterdir()):
if skill_dir.is_dir() and (skill_dir / "SKILL.md").exists():
if skill_dir not in skill_dirs:
skill_dirs.append(skill_dir)
# 注册技能到toolkit
for skill_dir in skill_dirs:
toolkit.register_agent_skill(str(skill_dir))
# 激活指定的工具组
if active_groups is None:
# 从配置中读取
profiles = load_agent_profiles()
profile = profiles.get(agent_id, {})
active_groups = agent_config.active_tool_groups or profile.get("active_tool_groups", [])
# 应用禁用列表
disabled_groups = set(agent_config.disabled_tool_groups)
if disabled_groups:
active_groups = [g for g in active_groups if g not in disabled_groups]
if active_groups:
toolkit.update_tool_groups(group_names=active_groups, active=True)
return toolkit
def get_toolkit_info(toolkit: Any) -> Dict[str, Any]:
"""获取工具集信息
Args:
toolkit: Toolkit实例
Returns:
工具集信息字典
"""
info = {
"tool_groups": {},
"skills": [],
"tools_count": 0,
}
# 获取工具组信息
groups = getattr(toolkit, "tool_groups", {})
for name, group in groups.items():
info["tool_groups"][name] = {
"description": getattr(group, "description", ""),
"active": getattr(group, "active", False),
"tools": [t.name for t in getattr(group, "tools", [])],
}
info["tools_count"] += len(getattr(group, "tools", []))
# 获取技能信息
skills = getattr(toolkit, "agent_skills", [])
for skill in skills:
info["skills"].append({
"name": getattr(skill, "name", "unknown"),
"path": getattr(skill, "path", ""),
"description": getattr(skill, "description", ""),
})
return info
def refresh_toolkit_skills(
toolkit: Any,
agent_id: str,
config_name: str,
) -> None:
"""刷新工具集中的技能
重新从工作空间加载技能,用于运行时技能变更。
Args:
toolkit: Toolkit实例
agent_id: Agent标识符
config_name: 运行配置名称
"""
skills_manager = SkillsManager()
# 清除现有技能
if hasattr(toolkit, "agent_skills"):
toolkit.agent_skills.clear()
# 重新加载active技能
active_root = skills_manager.get_agent_active_root(config_name, agent_id)
if active_root.exists():
for skill_dir in sorted(active_root.iterdir()):
if skill_dir.is_dir() and (skill_dir / "SKILL.md").exists():
toolkit.register_agent_skill(str(skill_dir))
# 重新加载local技能
local_root = skills_manager.get_agent_local_root(config_name, agent_id)
if local_root.exists():
for skill_dir in sorted(local_root.iterdir()):
if skill_dir.is_dir() and (skill_dir / "SKILL.md").exists():
toolkit.register_agent_skill(str(skill_dir))

326
backend/agents/workspace.py Normal file
View File

@@ -0,0 +1,326 @@
# -*- coding: utf-8 -*-
"""Workspace Manager - Create and manage agent workspaces."""
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Dict, List, Optional
import yaml
@dataclass
class WorkspaceConfig:
"""Configuration for a workspace."""
workspace_id: str
name: str = ""
description: str = ""
created_at: str = ""
metadata: Dict[str, Any] = field(default_factory=dict)
def to_dict(self) -> Dict[str, Any]:
"""Serialize to dictionary."""
return {
"workspace_id": self.workspace_id,
"name": self.name,
"description": self.description,
"created_at": self.created_at,
"metadata": self.metadata,
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "WorkspaceConfig":
"""Create from dictionary."""
return cls(
workspace_id=data.get("workspace_id", ""),
name=data.get("name", ""),
description=data.get("description", ""),
created_at=data.get("created_at", ""),
metadata=data.get("metadata", {}),
)
class WorkspaceRegistry:
"""Registry for persistent workspace definitions (design-time)."""
def __init__(self, project_root: Optional[Path] = None):
"""Initialize the workspace manager.
Args:
project_root: Root directory of the project
"""
self.project_root = project_root or Path(__file__).parent.parent.parent
self.workspaces_root = self.project_root / "workspaces"
self.workspaces_root.mkdir(parents=True, exist_ok=True)
def create_workspace(
self,
workspace_id: str,
name: Optional[str] = None,
description: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None,
) -> WorkspaceConfig:
"""Create a new workspace with directory structure.
Args:
workspace_id: Unique identifier for the workspace
name: Display name for the workspace
description: Optional description
metadata: Optional metadata dictionary
Returns:
WorkspaceConfig instance
Raises:
ValueError: If workspace already exists
"""
workspace_dir = self.workspaces_root / workspace_id
if workspace_dir.exists():
raise ValueError(f"Workspace '{workspace_id}' already exists")
# Create directory structure
workspace_dir.mkdir(parents=True, exist_ok=True)
# Create subdirectories
(workspace_dir / "agents").mkdir(exist_ok=True)
(workspace_dir / "shared" / "market_data").mkdir(parents=True, exist_ok=True)
(workspace_dir / "shared" / "memories").mkdir(parents=True, exist_ok=True)
# Create workspace.yaml
from datetime import datetime
config = WorkspaceConfig(
workspace_id=workspace_id,
name=name or workspace_id,
description=description or "",
created_at=datetime.now().isoformat(),
metadata=metadata or {},
)
self._write_workspace_config(workspace_dir, config)
return config
def list_workspaces(self) -> List[WorkspaceConfig]:
"""List all workspaces.
Returns:
List of WorkspaceConfig instances
"""
workspaces = []
if not self.workspaces_root.exists():
return workspaces
for workspace_dir in self.workspaces_root.iterdir():
if not workspace_dir.is_dir():
continue
config_path = workspace_dir / "workspace.yaml"
if config_path.exists():
try:
with open(config_path, "r", encoding="utf-8") as f:
data = yaml.safe_load(f) or {}
workspaces.append(WorkspaceConfig.from_dict(data))
except Exception:
# Skip invalid workspace configs
pass
return workspaces
def get_workspace_agents(self, workspace_id: str) -> List[Dict[str, Any]]:
"""Get all agents in a workspace.
Args:
workspace_id: ID of the workspace
Returns:
List of agent information dictionaries
Raises:
ValueError: If workspace doesn't exist
"""
workspace_dir = self.workspaces_root / workspace_id
if not workspace_dir.exists():
raise ValueError(f"Workspace '{workspace_id}' does not exist")
agents = []
agents_dir = workspace_dir / "agents"
if not agents_dir.exists():
return agents
for agent_dir in agents_dir.iterdir():
if not agent_dir.is_dir():
continue
config_path = agent_dir / "agent.yaml"
if config_path.exists():
try:
with open(config_path, "r", encoding="utf-8") as f:
config = yaml.safe_load(f) or {}
agents.append({
"agent_id": agent_dir.name,
"agent_type": config.get("agent_type", "unknown"),
"config_path": str(config_path),
})
except Exception:
# Skip invalid agent configs
pass
return agents
def get_agent_workspace(self, agent_id: str, workspace_id: str) -> Optional[Path]:
"""Get the workspace path for an agent.
Args:
agent_id: ID of the agent
workspace_id: ID of the workspace
Returns:
Path to agent directory, or None if not found
"""
agent_dir = self.workspaces_root / workspace_id / "agents" / agent_id
if agent_dir.exists():
return agent_dir
return None
def workspace_exists(self, workspace_id: str) -> bool:
"""Check if a workspace exists.
Args:
workspace_id: ID of the workspace
Returns:
True if workspace exists, False otherwise
"""
workspace_dir = self.workspaces_root / workspace_id
return workspace_dir.exists() and (workspace_dir / "workspace.yaml").exists()
def delete_workspace(self, workspace_id: str, force: bool = False) -> bool:
"""Delete a workspace and all its agents.
Args:
workspace_id: ID of the workspace to delete
force: If True, delete even if workspace has agents
Returns:
True if deleted, False if workspace didn't exist
Raises:
ValueError: If workspace has agents and force is False
"""
import shutil
workspace_dir = self.workspaces_root / workspace_id
if not workspace_dir.exists():
return False
# Check for agents
agents_dir = workspace_dir / "agents"
if agents_dir.exists() and any(agents_dir.iterdir()):
if not force:
raise ValueError(
f"Workspace '{workspace_id}' contains agents. "
"Use force=True to delete anyway."
)
shutil.rmtree(workspace_dir)
return True
def get_workspace_path(self, workspace_id: str) -> Path:
"""Get the path to a workspace directory.
Args:
workspace_id: ID of the workspace
Returns:
Path to workspace directory
"""
return self.workspaces_root / workspace_id
def get_shared_data_path(self, workspace_id: str) -> Optional[Path]:
"""Get the shared data directory for a workspace.
Args:
workspace_id: ID of the workspace
Returns:
Path to shared data directory, or None if workspace doesn't exist
"""
workspace_dir = self.workspaces_root / workspace_id
if not workspace_dir.exists():
return None
return workspace_dir / "shared"
def update_workspace_config(
self,
workspace_id: str,
name: Optional[str] = None,
description: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None,
) -> WorkspaceConfig:
"""Update workspace configuration.
Args:
workspace_id: ID of the workspace
name: New display name (optional)
description: New description (optional)
metadata: Metadata to merge (optional)
Returns:
Updated WorkspaceConfig
Raises:
ValueError: If workspace doesn't exist
"""
workspace_dir = self.workspaces_root / workspace_id
if not workspace_dir.exists():
raise ValueError(f"Workspace '{workspace_id}' does not exist")
config_path = workspace_dir / "workspace.yaml"
current_config = {}
if config_path.exists():
try:
with open(config_path, "r", encoding="utf-8") as f:
current_config = yaml.safe_load(f) or {}
except Exception:
pass
# Update fields
if name is not None:
current_config["name"] = name
if description is not None:
current_config["description"] = description
if metadata is not None:
current_config["metadata"] = {**current_config.get("metadata", {}), **metadata}
config = WorkspaceConfig.from_dict(current_config)
self._write_workspace_config(workspace_dir, config)
return config
def _write_workspace_config(self, workspace_dir: Path, config: WorkspaceConfig) -> None:
"""Write workspace configuration to file.
Args:
workspace_dir: Workspace directory
config: Workspace configuration
"""
config_path = workspace_dir / "workspace.yaml"
with open(config_path, "w", encoding="utf-8") as f:
yaml.safe_dump(config.to_dict(), f, allow_unicode=True, sort_keys=False)
# Backward-compatible alias: legacy imports expect WorkspaceManager.
WorkspaceManager = WorkspaceRegistry

View File

@@ -9,7 +9,7 @@ import yaml
from .skills_manager import SkillsManager
class WorkspaceManager:
class RunWorkspaceManager:
"""Create and maintain run-level prompt asset files for each agent."""
def __init__(self, project_root: Optional[Path] = None):
@@ -197,3 +197,7 @@ class WorkspaceManager:
yaml.safe_dump(payload, allow_unicode=True, sort_keys=False),
encoding="utf-8",
)
# Backward-compatible alias: code importing WorkspaceManager from this module should continue to work.
WorkspaceManager = RunWorkspaceManager