From f05c0073e16e3d89a4db64baf4072faa17befe0c Mon Sep 17 00:00:00 2001 From: lintsinghua Date: Fri, 12 Dec 2025 15:27:12 +0800 Subject: [PATCH] feat(agent): implement comprehensive agent architecture with knowledge base and persistence layer - Add database migrations for agent checkpoints and tree node tracking - Implement core agent execution framework with executor, state management, and message handling - Create knowledge base system with framework-specific modules (Django, FastAPI, Flask, Express, React, Supabase) - Add vulnerability knowledge modules covering authentication, cryptography, injection, XSS, XXE, SSRF, path traversal, deserialization, and race conditions - Introduce new agent tools: thinking tool, reporting tool, and agent-specific utilities - Implement LLM memory compression and prompt caching for improved performance - Add agent registry and persistence layer for checkpoint management - Refactor agent implementations (analysis, recon, verification, orchestrator) with enhanced capabilities - Remove legacy agent implementations (analysis_v2, react_agent) - Update API endpoints for agent task creation and project management - Add frontend components for agent task creation and enhanced audit UI - Consolidate agent service architecture with improved separation of concerns - This refactoring provides a scalable foundation for multi-agent collaboration with knowledge-driven decision making and state persistence --- .../007_add_agent_checkpoint_tables.py | 68 + .../versions/4c280754c680_merge_heads.py | 29 + backend/app/api/v1/endpoints/agent_tasks.py | 917 +++++++++++++- backend/app/api/v1/endpoints/projects.py | 14 +- backend/app/models/agent_task.py | 138 ++ backend/app/services/agent/__init__.py | 70 +- backend/app/services/agent/agents/analysis.py | 131 +- .../app/services/agent/agents/analysis_v2.py | 0 backend/app/services/agent/agents/base.py | 234 +++- .../app/services/agent/agents/orchestrator.py | 255 +++- .../app/services/agent/agents/react_agent.py | 380 ------ backend/app/services/agent/agents/recon.py | 191 ++- .../app/services/agent/agents/verification.py | 18 +- backend/app/services/agent/core/__init__.py | 53 + backend/app/services/agent/core/executor.py | 491 ++++++++ backend/app/services/agent/core/message.py | 290 +++++ .../app/services/agent/core/persistence.py | 413 ++++++ backend/app/services/agent/core/registry.py | 309 +++++ backend/app/services/agent/core/state.py | 297 +++++ backend/app/services/agent/event_manager.py | 2 +- backend/app/services/agent/graph/runner.py | 39 +- .../app/services/agent/knowledge/__init__.py | 59 + backend/app/services/agent/knowledge/base.py | 61 + .../agent/knowledge/frameworks/__init__.py | 32 + .../agent/knowledge/frameworks/django.py | 117 ++ .../agent/knowledge/frameworks/express.py | 148 +++ .../agent/knowledge/frameworks/fastapi.py | 109 ++ .../agent/knowledge/frameworks/flask.py | 139 ++ .../agent/knowledge/frameworks/react.py | 137 ++ .../agent/knowledge/frameworks/supabase.py | 148 +++ .../app/services/agent/knowledge/loader.py | 207 +++ .../services/agent/knowledge/rag_knowledge.py | 322 +++++ backend/app/services/agent/knowledge/tools.py | 257 ++++ .../knowledge/vulnerabilities/__init__.py | 62 + .../agent/knowledge/vulnerabilities/auth.py | 231 ++++ .../agent/knowledge/vulnerabilities/crypto.py | 163 +++ .../vulnerabilities/deserialization.py | 119 ++ .../knowledge/vulnerabilities/injection.py | 273 ++++ .../vulnerabilities/path_traversal.py | 129 ++ .../vulnerabilities/race_condition.py | 134 ++ .../agent/knowledge/vulnerabilities/ssrf.py | 118 ++ .../agent/knowledge/vulnerabilities/xss.py | 205 +++ .../agent/knowledge/vulnerabilities/xxe.py | 129 ++ .../services/agent/prompts/system_prompts.py | 3 +- backend/app/services/agent/tools/__init__.py | 42 +- .../app/services/agent/tools/agent_tools.py | 785 ++++++++++++ .../agent/tools/code_analysis_tool.py | 9 + backend/app/services/agent/tools/file_tool.py | 189 ++- .../services/agent/tools/reporting_tool.py | 235 ++++ .../app/services/agent/tools/thinking_tool.py | 167 +++ backend/app/services/llm/__init__.py | 52 + .../services/llm/adapters/litellm_adapter.py | 38 +- backend/app/services/llm/memory_compressor.py | 349 +++++ backend/app/services/llm/prompt_cache.py | 333 +++++ .../data_level0.bin | Bin 628400 -> 628400 bytes backend/test_msg.md | 809 ++++++++++++ backend/架构升级方案.md | 527 ++++++++ frontend/src/app/routes.tsx | 6 + .../agent/CreateAgentTaskDialog.tsx | 593 +++++++++ .../src/components/audit/CreateTaskDialog.tsx | 96 +- frontend/src/components/layout/Sidebar.tsx | 4 +- frontend/src/pages/AgentAudit.tsx | 1121 +++++++++++++---- frontend/src/shared/api/agentTasks.ts | 100 +- 63 files changed, 12304 insertions(+), 792 deletions(-) create mode 100644 backend/alembic/versions/007_add_agent_checkpoint_tables.py create mode 100644 backend/alembic/versions/4c280754c680_merge_heads.py delete mode 100644 backend/app/services/agent/agents/analysis_v2.py delete mode 100644 backend/app/services/agent/agents/react_agent.py create mode 100644 backend/app/services/agent/core/__init__.py create mode 100644 backend/app/services/agent/core/executor.py create mode 100644 backend/app/services/agent/core/message.py create mode 100644 backend/app/services/agent/core/persistence.py create mode 100644 backend/app/services/agent/core/registry.py create mode 100644 backend/app/services/agent/core/state.py create mode 100644 backend/app/services/agent/knowledge/__init__.py create mode 100644 backend/app/services/agent/knowledge/base.py create mode 100644 backend/app/services/agent/knowledge/frameworks/__init__.py create mode 100644 backend/app/services/agent/knowledge/frameworks/django.py create mode 100644 backend/app/services/agent/knowledge/frameworks/express.py create mode 100644 backend/app/services/agent/knowledge/frameworks/fastapi.py create mode 100644 backend/app/services/agent/knowledge/frameworks/flask.py create mode 100644 backend/app/services/agent/knowledge/frameworks/react.py create mode 100644 backend/app/services/agent/knowledge/frameworks/supabase.py create mode 100644 backend/app/services/agent/knowledge/loader.py create mode 100644 backend/app/services/agent/knowledge/rag_knowledge.py create mode 100644 backend/app/services/agent/knowledge/tools.py create mode 100644 backend/app/services/agent/knowledge/vulnerabilities/__init__.py create mode 100644 backend/app/services/agent/knowledge/vulnerabilities/auth.py create mode 100644 backend/app/services/agent/knowledge/vulnerabilities/crypto.py create mode 100644 backend/app/services/agent/knowledge/vulnerabilities/deserialization.py create mode 100644 backend/app/services/agent/knowledge/vulnerabilities/injection.py create mode 100644 backend/app/services/agent/knowledge/vulnerabilities/path_traversal.py create mode 100644 backend/app/services/agent/knowledge/vulnerabilities/race_condition.py create mode 100644 backend/app/services/agent/knowledge/vulnerabilities/ssrf.py create mode 100644 backend/app/services/agent/knowledge/vulnerabilities/xss.py create mode 100644 backend/app/services/agent/knowledge/vulnerabilities/xxe.py create mode 100644 backend/app/services/agent/tools/agent_tools.py create mode 100644 backend/app/services/agent/tools/reporting_tool.py create mode 100644 backend/app/services/agent/tools/thinking_tool.py create mode 100644 backend/app/services/llm/memory_compressor.py create mode 100644 backend/app/services/llm/prompt_cache.py create mode 100644 backend/test_msg.md create mode 100644 backend/架构升级方案.md create mode 100644 frontend/src/components/agent/CreateAgentTaskDialog.tsx diff --git a/backend/alembic/versions/007_add_agent_checkpoint_tables.py b/backend/alembic/versions/007_add_agent_checkpoint_tables.py new file mode 100644 index 0000000..b2d39ed --- /dev/null +++ b/backend/alembic/versions/007_add_agent_checkpoint_tables.py @@ -0,0 +1,68 @@ +"""Add agent checkpoint and tree node tables + +Revision ID: 007_add_agent_checkpoint_tables +Revises: 006_add_agent_tables +Create Date: 2024-12-12 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '007_add_agent_checkpoint_tables' +down_revision = '006_add_agent_tables' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # Create agent_checkpoints table + op.create_table( + 'agent_checkpoints', + sa.Column('id', sa.String(36), primary_key=True), + sa.Column('task_id', sa.String(36), sa.ForeignKey('agent_tasks.id', ondelete='CASCADE'), nullable=False, index=True), + sa.Column('agent_id', sa.String(50), nullable=False, index=True), + sa.Column('agent_name', sa.String(255), nullable=False), + sa.Column('agent_type', sa.String(50), nullable=False), + sa.Column('parent_agent_id', sa.String(50), nullable=True), + sa.Column('state_data', sa.Text, nullable=False), + sa.Column('iteration', sa.Integer, default=0), + sa.Column('status', sa.String(30), nullable=False), + sa.Column('total_tokens', sa.Integer, default=0), + sa.Column('tool_calls', sa.Integer, default=0), + sa.Column('findings_count', sa.Integer, default=0), + sa.Column('checkpoint_type', sa.String(30), default='auto'), + sa.Column('checkpoint_name', sa.String(255), nullable=True), + sa.Column('checkpoint_metadata', sa.JSON, nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now(), index=True), + ) + + # Create agent_tree_nodes table + op.create_table( + 'agent_tree_nodes', + sa.Column('id', sa.String(36), primary_key=True), + sa.Column('task_id', sa.String(36), sa.ForeignKey('agent_tasks.id', ondelete='CASCADE'), nullable=False, index=True), + sa.Column('agent_id', sa.String(50), nullable=False, unique=True, index=True), + sa.Column('agent_name', sa.String(255), nullable=False), + sa.Column('agent_type', sa.String(50), nullable=False), + sa.Column('parent_agent_id', sa.String(50), nullable=True, index=True), + sa.Column('depth', sa.Integer, default=0), + sa.Column('task_description', sa.Text, nullable=True), + sa.Column('knowledge_modules', sa.JSON, nullable=True), + sa.Column('status', sa.String(30), default='created'), + sa.Column('result_summary', sa.Text, nullable=True), + sa.Column('findings_count', sa.Integer, default=0), + sa.Column('iterations', sa.Integer, default=0), + sa.Column('tokens_used', sa.Integer, default=0), + sa.Column('tool_calls', sa.Integer, default=0), + sa.Column('duration_ms', sa.Integer, nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now()), + sa.Column('started_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('finished_at', sa.DateTime(timezone=True), nullable=True), + ) + + +def downgrade() -> None: + op.drop_table('agent_tree_nodes') + op.drop_table('agent_checkpoints') diff --git a/backend/alembic/versions/4c280754c680_merge_heads.py b/backend/alembic/versions/4c280754c680_merge_heads.py new file mode 100644 index 0000000..268d25e --- /dev/null +++ b/backend/alembic/versions/4c280754c680_merge_heads.py @@ -0,0 +1,29 @@ +"""merge_heads + +Revision ID: 4c280754c680 +Revises: 004_add_prompts_and_rules, 007_add_agent_checkpoint_tables +Create Date: 2025-12-12 12:07:42.238185 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '4c280754c680' +down_revision = ('004_add_prompts_and_rules', '007_add_agent_checkpoint_tables') +branch_labels = None +depends_on = None + + +def upgrade() -> None: + pass + + +def downgrade() -> None: + pass + + + + + diff --git a/backend/app/api/v1/endpoints/agent_tasks.py b/backend/app/api/v1/endpoints/agent_tasks.py index 79fbb33..f3316ae 100644 --- a/backend/app/api/v1/endpoints/agent_tasks.py +++ b/backend/app/api/v1/endpoints/agent_tasks.py @@ -29,14 +29,17 @@ from app.models.agent_task import ( from app.models.project import Project from app.models.user import User from app.models.user_config import UserConfig -from app.services.agent import AgentRunner, EventManager, run_agent_task +from app.services.agent.event_manager import EventManager from app.services.agent.streaming import StreamHandler, StreamEvent, StreamEventType logger = logging.getLogger(__name__) router = APIRouter() -# 运行中的任务 -_running_tasks: Dict[str, AgentRunner] = {} +# 运行中的任务(兼容旧接口) +_running_tasks: Dict[str, Any] = {} + +# 🔥 运行中的 asyncio Tasks(用于强制取消) +_running_asyncio_tasks: Dict[str, asyncio.Task] = {} # ============ Schemas ============ @@ -71,7 +74,7 @@ class AgentTaskCreate(BaseModel): target_files: Optional[List[str]] = Field(None, description="指定扫描的文件") # Agent 配置 - max_iterations: int = Field(3, ge=1, le=10, description="最大分析迭代次数") + max_iterations: int = Field(50, ge=1, le=200, description="最大迭代次数") timeout_seconds: int = Field(1800, ge=60, le=7200, description="超时时间(秒)") @@ -200,9 +203,29 @@ class TaskSummaryResponse(BaseModel): # ============ 后台任务执行 ============ +# 运行中的动态执行器 +_running_orchestrators: Dict[str, Any] = {} +# 运行中的事件管理器(用于 SSE 流) +_running_event_managers: Dict[str, EventManager] = {} + + async def _execute_agent_task(task_id: str): - """在后台执行 Agent 任务""" + """ + 在后台执行 Agent 任务 - 使用动态 Agent 树架构 + + 架构:OrchestratorAgent 作为大脑,动态调度子 Agent + """ + from app.services.agent.agents import OrchestratorAgent, ReconAgent, AnalysisAgent, VerificationAgent + from app.services.agent.event_manager import EventManager, AgentEventEmitter + from app.services.llm.service import LLMService + from app.services.agent.core import agent_registry + from app.core.config import settings + import time + async with async_session_factory() as db: + orchestrator = None + start_time = time.time() + try: # 获取任务 task = await db.get(AgentTask, task_id, options=[selectinload(AgentTask.project)]) @@ -216,78 +239,203 @@ async def _execute_agent_task(task_id: str): logger.error(f"Project not found for task {task_id}") return - # 🔥 获取项目根目录(解压 ZIP 或克隆仓库) + # 获取项目根目录 project_root = await _get_project_root(project, task_id) - # 🔥 获取用户配置(从系统配置页面) - # 优先级:1. 数据库用户配置 > 2. 环境变量配置 - user_config = None - if task.created_by: - from app.api.v1.endpoints.config import ( - decrypt_config, - SENSITIVE_LLM_FIELDS, SENSITIVE_OTHER_FIELDS - ) - import json - - result = await db.execute( - select(UserConfig).where(UserConfig.user_id == task.created_by) - ) - config = result.scalar_one_or_none() - - if config and config.llm_config: - # 🔥 有数据库配置:使用数据库配置(优先) - user_llm_config = json.loads(config.llm_config) if config.llm_config else {} - user_other_config = json.loads(config.other_config) if config.other_config else {} - - # 解密敏感字段 - user_llm_config = decrypt_config(user_llm_config, SENSITIVE_LLM_FIELDS) - user_other_config = decrypt_config(user_other_config, SENSITIVE_OTHER_FIELDS) - - user_config = { - "llmConfig": user_llm_config, # 直接使用数据库配置,不合并默认值 - "otherConfig": user_other_config, - } - logger.info(f"✅ Using database user config for task {task_id}, LLM provider: {user_llm_config.get('llmProvider', 'N/A')}") - else: - # 🔥 无数据库配置:传递 None,让 LLMService 使用环境变量 - user_config = None - logger.info(f"⚠️ No database config found for user {task.created_by}, will use environment variables for task {task_id}") + # 获取用户配置 + user_config = await _get_user_config(db, task.created_by) # 更新状态为运行中 task.status = AgentTaskStatus.RUNNING task.started_at = datetime.now(timezone.utc) + task.current_phase = AgentTaskPhase.PLANNING await db.commit() - logger.info(f"Task {task_id} started") + logger.info(f"🚀 Task {task_id} started with Dynamic Agent Tree architecture") - # 创建 Runner(传入用户配置) - runner = AgentRunner(db, task, project_root, user_config=user_config) - _running_tasks[task_id] = runner + # 创建事件管理器 + event_manager = EventManager(db_session_factory=async_session_factory) + event_manager.create_queue(task_id) + event_emitter = AgentEventEmitter(task_id, event_manager) - # 执行 - result = await runner.run() + # 创建 LLM 服务 + llm_service = LLMService(user_config=user_config) + + # 初始化工具集 - 传递排除模式和目标文件 + tools = await _initialize_tools( + project_root, + llm_service, + user_config, + exclude_patterns=task.exclude_patterns, + target_files=task.target_files, + ) + + # 创建子 Agent + recon_agent = ReconAgent( + llm_service=llm_service, + tools=tools.get("recon", {}), + event_emitter=event_emitter, + ) + + analysis_agent = AnalysisAgent( + llm_service=llm_service, + tools=tools.get("analysis", {}), + event_emitter=event_emitter, + ) + + verification_agent = VerificationAgent( + llm_service=llm_service, + tools=tools.get("verification", {}), + event_emitter=event_emitter, + ) + + # 创建 Orchestrator Agent + orchestrator = OrchestratorAgent( + llm_service=llm_service, + tools=tools.get("orchestrator", {}), + event_emitter=event_emitter, + sub_agents={ + "recon": recon_agent, + "analysis": analysis_agent, + "verification": verification_agent, + }, + ) + + # 注册到全局 + _running_orchestrators[task_id] = orchestrator + _running_tasks[task_id] = orchestrator # 兼容旧的取消逻辑 + _running_event_managers[task_id] = event_manager # 用于 SSE 流 + + # 🔥 清理旧的 Agent 注册表,避免显示多个树 + from app.services.agent.core import agent_registry + agent_registry.clear() + + # 注册 Orchestrator 到 Agent Registry(使用其内置方法) + orchestrator._register_to_registry(task="Root orchestrator for security audit") + + await event_emitter.emit_info("🧠 动态 Agent 树架构启动") + await event_emitter.emit_info(f"📁 项目路径: {project_root}") + + # 收集项目信息 - 传递排除模式和目标文件 + project_info = await _collect_project_info( + project_root, + project.name, + exclude_patterns=task.exclude_patterns, + target_files=task.target_files, + ) + + # 更新任务文件统计 + task.total_files = project_info.get("file_count", 0) + await db.commit() + + # 构建输入数据 + input_data = { + "project_info": project_info, + "config": { + "target_vulnerabilities": task.target_vulnerabilities or [], + "verification_level": task.verification_level or "sandbox", + "exclude_patterns": task.exclude_patterns or [], + "target_files": task.target_files or [], + "max_iterations": task.max_iterations or 50, + }, + "project_root": project_root, + "task_id": task_id, + } + + # 执行 Orchestrator + await event_emitter.emit_phase_start("orchestration", "🎯 Orchestrator 开始编排审计流程") + task.current_phase = AgentTaskPhase.ANALYSIS + await db.commit() + + # 🔥 将 orchestrator.run() 包装在 asyncio.Task 中,以便可以强制取消 + run_task = asyncio.create_task(orchestrator.run(input_data)) + _running_asyncio_tasks[task_id] = run_task + + try: + result = await run_task + finally: + _running_asyncio_tasks.pop(task_id, None) + + # 处理结果 + duration_ms = int((time.time() - start_time) * 1000) - # 更新任务状态 await db.refresh(task) - if result.get('success', True): # 默认成功,除非明确失败 + + if result.success: + # 保存发现 + findings = result.data.get("findings", []) + await _save_findings(db, task_id, findings) + + # 更新任务统计 task.status = AgentTaskStatus.COMPLETED task.completed_at = datetime.now(timezone.utc) + task.current_phase = AgentTaskPhase.COMPLETED + task.findings_count = len(findings) + task.total_iterations = result.iterations + task.tool_calls_count = result.tool_calls + task.tokens_used = result.tokens_used + + # 统计严重程度 + for f in findings: + if isinstance(f, dict): + sev = f.get("severity", "low") + if sev == "critical": + task.critical_count += 1 + elif sev == "high": + task.high_count += 1 + elif sev == "medium": + task.medium_count += 1 + elif sev == "low": + task.low_count += 1 + + # 计算安全评分 + task.security_score = _calculate_security_score(findings) + task.progress_percentage = 100.0 + + await db.commit() + + await event_emitter.emit_task_complete( + findings_count=len(findings), + duration_ms=duration_ms, + ) + + logger.info(f"✅ Task {task_id} completed: {len(findings)} findings, {duration_ms}ms") else: - task.status = AgentTaskStatus.FAILED - task.error_message = result.get('error', 'Unknown error') - task.completed_at = datetime.now(timezone.utc) - - await db.commit() - logger.info(f"Task {task_id} completed with status: {task.status}") + # 🔥 检查是否是取消导致的失败 + if result.error == "任务已取消": + # 状态可能已经被 cancel API 更新,只需确保一致性 + if task.status != AgentTaskStatus.CANCELLED: + task.status = AgentTaskStatus.CANCELLED + task.completed_at = datetime.now(timezone.utc) + await db.commit() + logger.info(f"🛑 Task {task_id} cancelled") + else: + task.status = AgentTaskStatus.FAILED + task.error_message = result.error or "Unknown error" + task.completed_at = datetime.now(timezone.utc) + await db.commit() + + await event_emitter.emit_error(result.error or "Unknown error") + logger.error(f"❌ Task {task_id} failed: {result.error}") + except asyncio.CancelledError: + logger.info(f"Task {task_id} cancelled") + try: + task = await db.get(AgentTask, task_id) + if task: + task.status = AgentTaskStatus.CANCELLED + task.completed_at = datetime.now(timezone.utc) + await db.commit() + except Exception: + pass + except Exception as e: logger.error(f"Task {task_id} failed: {e}", exc_info=True) - # 更新任务状态 try: task = await db.get(AgentTask, task_id) if task: task.status = AgentTaskStatus.FAILED - task.error_message = str(e)[:1000] # 限制错误消息长度 + task.error_message = str(e)[:1000] task.completed_at = datetime.now(timezone.utc) await db.commit() except Exception as db_error: @@ -295,10 +443,308 @@ async def _execute_agent_task(task_id: str): finally: # 清理 + _running_orchestrators.pop(task_id, None) _running_tasks.pop(task_id, None) + _running_event_managers.pop(task_id, None) + _running_asyncio_tasks.pop(task_id, None) # 🔥 清理 asyncio task + + # 从 Registry 注销 + if orchestrator: + agent_registry.unregister_agent(orchestrator.agent_id) + logger.debug(f"Task {task_id} cleaned up") +async def _get_user_config(db: AsyncSession, user_id: Optional[str]) -> Optional[Dict[str, Any]]: + """获取用户配置""" + if not user_id: + return None + + try: + from app.api.v1.endpoints.config import ( + decrypt_config, + SENSITIVE_LLM_FIELDS, SENSITIVE_OTHER_FIELDS + ) + + result = await db.execute( + select(UserConfig).where(UserConfig.user_id == user_id) + ) + config = result.scalar_one_or_none() + + if config and config.llm_config: + user_llm_config = json.loads(config.llm_config) if config.llm_config else {} + user_other_config = json.loads(config.other_config) if config.other_config else {} + + user_llm_config = decrypt_config(user_llm_config, SENSITIVE_LLM_FIELDS) + user_other_config = decrypt_config(user_other_config, SENSITIVE_OTHER_FIELDS) + + return { + "llmConfig": user_llm_config, + "otherConfig": user_other_config, + } + except Exception as e: + logger.warning(f"Failed to get user config: {e}") + + return None + + +async def _initialize_tools( + project_root: str, + llm_service, + user_config: Optional[Dict[str, Any]], + exclude_patterns: Optional[List[str]] = None, + target_files: Optional[List[str]] = None, +) -> Dict[str, Dict[str, Any]]: + """初始化工具集 + + Args: + project_root: 项目根目录 + llm_service: LLM 服务 + user_config: 用户配置 + exclude_patterns: 排除模式列表 + target_files: 目标文件列表 + """ + from app.services.agent.tools import ( + FileReadTool, FileSearchTool, ListFilesTool, + PatternMatchTool, CodeAnalysisTool, DataFlowAnalysisTool, + SemgrepTool, BanditTool, GitleaksTool, + ThinkTool, ReflectTool, + CreateVulnerabilityReportTool, + VulnerabilityValidationTool, + ) + from app.services.agent.knowledge import ( + SecurityKnowledgeQueryTool, + GetVulnerabilityKnowledgeTool, + ) + + # 基础工具 - 传递排除模式和目标文件 + base_tools = { + "read_file": FileReadTool(project_root, exclude_patterns, target_files), + "list_files": ListFilesTool(project_root, exclude_patterns, target_files), + "search_code": FileSearchTool(project_root, exclude_patterns, target_files), + "think": ThinkTool(), + "reflect": ReflectTool(), + } + + # Recon 工具 + recon_tools = { + **base_tools, + } + + # Analysis 工具 + analysis_tools = { + **base_tools, + "pattern_match": PatternMatchTool(project_root), + # TODO: code_analysis 工具暂时禁用,因为 LLM 调用经常失败 + # "code_analysis": CodeAnalysisTool(llm_service), + "dataflow_analysis": DataFlowAnalysisTool(llm_service), + "semgrep_scan": SemgrepTool(project_root), + "bandit_scan": BanditTool(project_root), + "gitleaks_scan": GitleaksTool(project_root), + "query_security_knowledge": SecurityKnowledgeQueryTool(), + "get_vulnerability_knowledge": GetVulnerabilityKnowledgeTool(), + } + + # Verification 工具 + verification_tools = { + **base_tools, + "vulnerability_validation": VulnerabilityValidationTool(llm_service), + "dataflow_analysis": DataFlowAnalysisTool(llm_service), + "create_vulnerability_report": CreateVulnerabilityReportTool(), + } + + # Orchestrator 工具(主要是思考工具) + orchestrator_tools = { + "think": ThinkTool(), + "reflect": ReflectTool(), + } + + return { + "recon": recon_tools, + "analysis": analysis_tools, + "verification": verification_tools, + "orchestrator": orchestrator_tools, + } + + +async def _collect_project_info( + project_root: str, + project_name: str, + exclude_patterns: Optional[List[str]] = None, + target_files: Optional[List[str]] = None, +) -> Dict[str, Any]: + """收集项目信息 + + Args: + project_root: 项目根目录 + project_name: 项目名称 + exclude_patterns: 排除模式列表 + target_files: 目标文件列表 + """ + import fnmatch + + info = { + "name": project_name, + "root": project_root, + "languages": [], + "file_count": 0, + "structure": {}, + } + + try: + # 默认排除目录 + exclude_dirs = { + "node_modules", "__pycache__", ".git", "venv", ".venv", + "build", "dist", "target", ".idea", ".vscode", + } + + # 从用户配置的排除模式中提取目录 + if exclude_patterns: + for pattern in exclude_patterns: + if pattern.endswith("/**"): + exclude_dirs.add(pattern[:-3]) + elif "/" not in pattern and "*" not in pattern: + exclude_dirs.add(pattern) + + # 目标文件集合 + target_files_set = set(target_files) if target_files else None + + lang_map = { + ".py": "Python", ".js": "JavaScript", ".ts": "TypeScript", + ".java": "Java", ".go": "Go", ".php": "PHP", + ".rb": "Ruby", ".rs": "Rust", ".c": "C", ".cpp": "C++", + } + + for root, dirs, files in os.walk(project_root): + dirs[:] = [d for d in dirs if d not in exclude_dirs] + + for f in files: + relative_path = os.path.relpath(os.path.join(root, f), project_root) + + # 检查是否在目标文件列表中 + if target_files_set and relative_path not in target_files_set: + continue + + # 检查排除模式 + should_skip = False + if exclude_patterns: + for pattern in exclude_patterns: + if fnmatch.fnmatch(relative_path, pattern) or fnmatch.fnmatch(f, pattern): + should_skip = True + break + if should_skip: + continue + + info["file_count"] += 1 + + ext = os.path.splitext(f)[1].lower() + if ext in lang_map and lang_map[ext] not in info["languages"]: + info["languages"].append(lang_map[ext]) + + # 收集顶层目录结构 + try: + top_items = os.listdir(project_root) + info["structure"] = { + "directories": [d for d in top_items if os.path.isdir(os.path.join(project_root, d)) and d not in exclude_dirs], + "files": [f for f in top_items if os.path.isfile(os.path.join(project_root, f))][:20], + } + except Exception: + pass + + except Exception as e: + logger.warning(f"Failed to collect project info: {e}") + + return info + + +async def _save_findings(db: AsyncSession, task_id: str, findings: List[Dict]) -> None: + """保存发现到数据库""" + from app.models.agent_task import VulnerabilityType + + severity_map = { + "critical": VulnerabilitySeverity.CRITICAL, + "high": VulnerabilitySeverity.HIGH, + "medium": VulnerabilitySeverity.MEDIUM, + "low": VulnerabilitySeverity.LOW, + "info": VulnerabilitySeverity.INFO, + } + + type_map = { + "sql_injection": VulnerabilityType.SQL_INJECTION, + "nosql_injection": VulnerabilityType.NOSQL_INJECTION, + "xss": VulnerabilityType.XSS, + "command_injection": VulnerabilityType.COMMAND_INJECTION, + "code_injection": VulnerabilityType.CODE_INJECTION, + "path_traversal": VulnerabilityType.PATH_TRAVERSAL, + "ssrf": VulnerabilityType.SSRF, + "xxe": VulnerabilityType.XXE, + "auth_bypass": VulnerabilityType.AUTH_BYPASS, + "idor": VulnerabilityType.IDOR, + "sensitive_data_exposure": VulnerabilityType.SENSITIVE_DATA_EXPOSURE, + "hardcoded_secret": VulnerabilityType.HARDCODED_SECRET, + } + + for finding in findings: + if not isinstance(finding, dict): + continue + + try: + db_finding = AgentFinding( + id=str(uuid4()), + task_id=task_id, + vulnerability_type=type_map.get( + finding.get("vulnerability_type", "other"), + VulnerabilityType.OTHER + ), + severity=severity_map.get( + finding.get("severity", "medium"), + VulnerabilitySeverity.MEDIUM + ), + title=finding.get("title", "Unknown"), + description=finding.get("description", ""), + file_path=finding.get("file_path"), + line_start=finding.get("line_start"), + line_end=finding.get("line_end"), + code_snippet=finding.get("code_snippet"), + suggestion=finding.get("suggestion") or finding.get("recommendation"), + is_verified=finding.get("is_verified", False), + confidence=finding.get("confidence", 0.5), + status=FindingStatus.VERIFIED if finding.get("is_verified") else FindingStatus.NEW, + ) + db.add(db_finding) + except Exception as e: + logger.warning(f"Failed to save finding: {e}") + + try: + await db.commit() + except Exception as e: + logger.error(f"Failed to commit findings: {e}") + + +def _calculate_security_score(findings: List[Dict]) -> float: + """计算安全评分""" + if not findings: + return 100.0 + + # 基于发现的严重程度计算扣分 + deductions = { + "critical": 25, + "high": 15, + "medium": 8, + "low": 3, + "info": 1, + } + + total_deduction = 0 + for f in findings: + if isinstance(f, dict): + sev = f.get("severity", "low") + total_deduction += deductions.get(sev, 3) + + score = max(0, 100 - total_deduction) + return float(score) + + # ============ API Endpoints ============ @router.post("/", response_model=AgentTaskResponse) @@ -420,6 +866,28 @@ async def get_agent_task( elif task.status in [AgentTaskStatus.FAILED, AgentTaskStatus.CANCELLED]: progress = 0.0 + # 🔥 从运行中的 Orchestrator 获取实时统计 + total_iterations = task.total_iterations or 0 + tool_calls_count = task.tool_calls_count or 0 + tokens_used = task.tokens_used or 0 + + orchestrator = _running_orchestrators.get(task_id) + if orchestrator and task.status == AgentTaskStatus.RUNNING: + # 从 Orchestrator 获取统计 + stats = orchestrator.get_stats() + total_iterations = stats.get("iterations", 0) + tool_calls_count = stats.get("tool_calls", 0) + tokens_used = stats.get("tokens_used", 0) + + # 累加子 Agent 的统计 + if hasattr(orchestrator, 'sub_agents'): + for agent in orchestrator.sub_agents.values(): + if hasattr(agent, 'get_stats'): + sub_stats = agent.get_stats() + total_iterations += sub_stats.get("iterations", 0) + tool_calls_count += sub_stats.get("tool_calls", 0) + tokens_used += sub_stats.get("tokens_used", 0) + # 手动构建响应数据 response_data = { "id": task.id, @@ -434,9 +902,9 @@ async def get_agent_task( "indexed_files": task.indexed_files or 0, "analyzed_files": task.analyzed_files or 0, "total_chunks": task.total_chunks or 0, - "total_iterations": task.total_iterations or 0, - "tool_calls_count": task.tool_calls_count or 0, - "tokens_used": task.tokens_used or 0, + "total_iterations": total_iterations, + "tool_calls_count": tool_calls_count, + "tokens_used": tokens_used, "findings_count": task.findings_count or 0, "total_findings": task.findings_count or 0, # 兼容字段 "verified_count": task.verified_count or 0, @@ -486,16 +954,24 @@ async def cancel_agent_task( if task.status in [AgentTaskStatus.COMPLETED, AgentTaskStatus.FAILED, AgentTaskStatus.CANCELLED]: raise HTTPException(status_code=400, detail="任务已结束,无法取消") - # 取消运行中的任务 + # 🔥 1. 设置 Agent 的取消标志 runner = _running_tasks.get(task_id) if runner: runner.cancel() + logger.info(f"[Cancel] Set cancel flag for task {task_id}") + + # 🔥 2. 强制取消 asyncio Task(立即中断 LLM 调用) + asyncio_task = _running_asyncio_tasks.get(task_id) + if asyncio_task and not asyncio_task.done(): + asyncio_task.cancel() + logger.info(f"[Cancel] Cancelled asyncio task for {task_id}") # 更新状态 task.status = AgentTaskStatus.CANCELLED task.completed_at = datetime.now(timezone.utc) await db.commit() + logger.info(f"[Cancel] Task {task_id} cancelled successfully") return {"message": "任务已取消", "task_id": task_id} @@ -631,9 +1107,9 @@ async def stream_agent_with_thinking( async def enhanced_event_generator(): """生成增强版 SSE 事件流""" # 1. 检查任务是否在运行中 (内存) - runner = _running_tasks.get(task_id) + event_manager = _running_event_managers.get(task_id) - if runner: + if event_manager: logger.info(f"Stream {task_id}: Using in-memory event manager") try: # 使用 EventManager 的流式接口 @@ -644,7 +1120,7 @@ async def stream_agent_with_thinking( if not include_tool_calls: skip_types.update(["tool_call_start", "tool_call_input", "tool_call_output", "tool_call_end"]) - async for event in runner.event_manager.stream_events(task_id, after_sequence=after_sequence): + async for event in event_manager.stream_events(task_id, after_sequence=after_sequence): event_type = event.get("event_type") if event_type in skip_types: @@ -1033,3 +1509,320 @@ async def _get_project_root(project: Project, task_id: str) -> str: return base_path + +# ============ Agent Tree API ============ + +class AgentTreeNodeResponse(BaseModel): + """Agent 树节点响应""" + id: str + agent_id: str + agent_name: str + agent_type: str + parent_agent_id: Optional[str] = None + depth: int = 0 + task_description: Optional[str] = None + knowledge_modules: Optional[List[str]] = None + status: str = "created" + result_summary: Optional[str] = None + findings_count: int = 0 + iterations: int = 0 + tokens_used: int = 0 + tool_calls: int = 0 + duration_ms: Optional[int] = None + children: List["AgentTreeNodeResponse"] = [] + + class Config: + from_attributes = True + + +class AgentTreeResponse(BaseModel): + """Agent 树响应""" + task_id: str + root_agent_id: Optional[str] = None + total_agents: int = 0 + running_agents: int = 0 + completed_agents: int = 0 + failed_agents: int = 0 + total_findings: int = 0 + nodes: List[AgentTreeNodeResponse] = [] + + +@router.get("/{task_id}/agent-tree", response_model=AgentTreeResponse) +async def get_agent_tree( + task_id: str, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(deps.get_current_user), +) -> Any: + """ + 获取任务的 Agent 树结构 + + 返回动态 Agent 树的完整结构,包括: + - 所有 Agent 节点 + - 父子关系 + - 执行状态 + - 发现统计 + """ + task = await db.get(AgentTask, task_id) + if not task: + raise HTTPException(status_code=404, detail="任务不存在") + + project = await db.get(Project, task.project_id) + if not project or project.owner_id != current_user.id: + raise HTTPException(status_code=403, detail="无权访问此任务") + + # 尝试从内存中获取 Agent 树(运行中的任务) + runner = _running_tasks.get(task_id) + logger.info(f"[AgentTree API] task_id={task_id}, runner exists={runner is not None}") + + if runner: + from app.services.agent.core import agent_registry + + tree = agent_registry.get_agent_tree() + stats = agent_registry.get_statistics() + logger.info(f"[AgentTree API] tree nodes={len(tree.get('nodes', {}))}, root={tree.get('root_agent_id')}") + logger.info(f"[AgentTree API] 节点详情: {list(tree.get('nodes', {}).keys())}") + + # 构建节点列表 + nodes = [] + for agent_id, node_data in tree.get("nodes", {}).items(): + # 🔥 从 Agent 实例获取实时统计数据 + iterations = 0 + tool_calls = 0 + tokens_used = 0 + findings_count = 0 + + agent_instance = agent_registry.get_agent(agent_id) + if agent_instance and hasattr(agent_instance, 'get_stats'): + agent_stats = agent_instance.get_stats() + iterations = agent_stats.get("iterations", 0) + tool_calls = agent_stats.get("tool_calls", 0) + tokens_used = agent_stats.get("tokens_used", 0) + + # 从结果中获取发现数量 + if node_data.get("result"): + result = node_data.get("result", {}) + findings_count = len(result.get("findings", [])) + + nodes.append(AgentTreeNodeResponse( + id=node_data.get("id", agent_id), + agent_id=agent_id, + agent_name=node_data.get("name", "Unknown"), + agent_type=node_data.get("type", "unknown"), + parent_agent_id=node_data.get("parent_id"), + task_description=node_data.get("task"), + knowledge_modules=node_data.get("knowledge_modules", []), + status=node_data.get("status", "unknown"), + findings_count=findings_count, + iterations=iterations, + tool_calls=tool_calls, + tokens_used=tokens_used, + children=[], + )) + + return AgentTreeResponse( + task_id=task_id, + root_agent_id=tree.get("root_agent_id"), + total_agents=stats.get("total", 0), + running_agents=stats.get("running", 0), + completed_agents=stats.get("completed", 0), + failed_agents=stats.get("failed", 0), + total_findings=sum(n.findings_count for n in nodes), + nodes=nodes, + ) + + # 从数据库获取(已完成的任务) + from app.models.agent_task import AgentTreeNode + + result = await db.execute( + select(AgentTreeNode) + .where(AgentTreeNode.task_id == task_id) + .order_by(AgentTreeNode.depth, AgentTreeNode.created_at) + ) + db_nodes = result.scalars().all() + + if not db_nodes: + return AgentTreeResponse( + task_id=task_id, + nodes=[], + ) + + # 构建响应 + nodes = [] + root_id = None + running = 0 + completed = 0 + failed = 0 + total_findings = 0 + + for node in db_nodes: + if node.parent_agent_id is None: + root_id = node.agent_id + + if node.status == "running": + running += 1 + elif node.status == "completed": + completed += 1 + elif node.status == "failed": + failed += 1 + + total_findings += node.findings_count or 0 + + nodes.append(AgentTreeNodeResponse( + id=node.id, + agent_id=node.agent_id, + agent_name=node.agent_name, + agent_type=node.agent_type, + parent_agent_id=node.parent_agent_id, + depth=node.depth, + task_description=node.task_description, + knowledge_modules=node.knowledge_modules, + status=node.status, + result_summary=node.result_summary, + findings_count=node.findings_count or 0, + iterations=node.iterations or 0, + tokens_used=node.tokens_used or 0, + tool_calls=node.tool_calls or 0, + duration_ms=node.duration_ms, + children=[], + )) + + return AgentTreeResponse( + task_id=task_id, + root_agent_id=root_id, + total_agents=len(nodes), + running_agents=running, + completed_agents=completed, + failed_agents=failed, + total_findings=total_findings, + nodes=nodes, + ) + + +# ============ Checkpoint API ============ + +class CheckpointResponse(BaseModel): + """检查点响应""" + id: str + agent_id: str + agent_name: str + agent_type: str + iteration: int + status: str + total_tokens: int = 0 + tool_calls: int = 0 + findings_count: int = 0 + checkpoint_type: str = "auto" + checkpoint_name: Optional[str] = None + created_at: Optional[str] = None + + class Config: + from_attributes = True + + +@router.get("/{task_id}/checkpoints", response_model=List[CheckpointResponse]) +async def list_checkpoints( + task_id: str, + agent_id: Optional[str] = None, + limit: int = Query(20, ge=1, le=100), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(deps.get_current_user), +) -> Any: + """ + 获取任务的检查点列表 + + 用于: + - 查看执行历史 + - 状态恢复 + - 调试分析 + """ + task = await db.get(AgentTask, task_id) + if not task: + raise HTTPException(status_code=404, detail="任务不存在") + + project = await db.get(Project, task.project_id) + if not project or project.owner_id != current_user.id: + raise HTTPException(status_code=403, detail="无权访问此任务") + + from app.models.agent_task import AgentCheckpoint + + query = select(AgentCheckpoint).where(AgentCheckpoint.task_id == task_id) + + if agent_id: + query = query.where(AgentCheckpoint.agent_id == agent_id) + + query = query.order_by(AgentCheckpoint.created_at.desc()).limit(limit) + + result = await db.execute(query) + checkpoints = result.scalars().all() + + return [ + CheckpointResponse( + id=cp.id, + agent_id=cp.agent_id, + agent_name=cp.agent_name, + agent_type=cp.agent_type, + iteration=cp.iteration, + status=cp.status, + total_tokens=cp.total_tokens or 0, + tool_calls=cp.tool_calls or 0, + findings_count=cp.findings_count or 0, + checkpoint_type=cp.checkpoint_type or "auto", + checkpoint_name=cp.checkpoint_name, + created_at=cp.created_at.isoformat() if cp.created_at else None, + ) + for cp in checkpoints + ] + + +@router.get("/{task_id}/checkpoints/{checkpoint_id}") +async def get_checkpoint_detail( + task_id: str, + checkpoint_id: str, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(deps.get_current_user), +) -> Any: + """ + 获取检查点详情 + + 返回完整的 Agent 状态数据 + """ + task = await db.get(AgentTask, task_id) + if not task: + raise HTTPException(status_code=404, detail="任务不存在") + + project = await db.get(Project, task.project_id) + if not project or project.owner_id != current_user.id: + raise HTTPException(status_code=403, detail="无权访问此任务") + + from app.models.agent_task import AgentCheckpoint + + checkpoint = await db.get(AgentCheckpoint, checkpoint_id) + if not checkpoint or checkpoint.task_id != task_id: + raise HTTPException(status_code=404, detail="检查点不存在") + + # 解析状态数据 + state_data = {} + if checkpoint.state_data: + try: + state_data = json.loads(checkpoint.state_data) + except json.JSONDecodeError: + pass + + return { + "id": checkpoint.id, + "task_id": checkpoint.task_id, + "agent_id": checkpoint.agent_id, + "agent_name": checkpoint.agent_name, + "agent_type": checkpoint.agent_type, + "parent_agent_id": checkpoint.parent_agent_id, + "iteration": checkpoint.iteration, + "status": checkpoint.status, + "total_tokens": checkpoint.total_tokens, + "tool_calls": checkpoint.tool_calls, + "findings_count": checkpoint.findings_count, + "checkpoint_type": checkpoint.checkpoint_type, + "checkpoint_name": checkpoint.checkpoint_name, + "state_data": state_data, + "metadata": checkpoint.checkpoint_metadata, + "created_at": checkpoint.created_at.isoformat() if checkpoint.created_at else None, + } diff --git a/backend/app/api/v1/endpoints/projects.py b/backend/app/api/v1/endpoints/projects.py index e573a18..cbc34c1 100644 --- a/backend/app/api/v1/endpoints/projects.py +++ b/backend/app/api/v1/endpoints/projects.py @@ -676,15 +676,26 @@ async def get_project_branches( repo_type = project.repository_type or "other" + # 详细日志 + print(f"[Branch] 项目: {project.name}, 类型: {repo_type}, URL: {project.repository_url}") + print(f"[Branch] GitHub Token: {'已配置' if github_token else '未配置'}, GitLab Token: {'已配置' if gitlab_token else '未配置'}") + try: if repo_type == "github": + if not github_token: + print("[Branch] 警告: GitHub Token 未配置,可能会遇到 API 限制") branches = await get_github_branches(project.repository_url, github_token) elif repo_type == "gitlab": + if not gitlab_token: + print("[Branch] 警告: GitLab Token 未配置,可能无法访问私有仓库") branches = await get_gitlab_branches(project.repository_url, gitlab_token) else: # 对于其他类型,返回默认分支 + print(f"[Branch] 仓库类型 '{repo_type}' 不支持获取分支,返回默认分支") branches = [project.default_branch or "main"] + print(f"[Branch] 成功获取 {len(branches)} 个分支") + # 将默认分支放在第一位 default_branch = project.default_branch or "main" if default_branch in branches: @@ -694,7 +705,8 @@ async def get_project_branches( return {"branches": branches, "default_branch": default_branch} except Exception as e: - print(f"获取分支列表失败: {e}") + error_msg = str(e) + print(f"[Branch] 获取分支列表失败: {error_msg}") # 返回默认分支作为后备 return { "branches": [project.default_branch or "main"], diff --git a/backend/app/models/agent_task.py b/backend/app/models/agent_task.py index 435e2a1..0bc1a1a 100644 --- a/backend/app/models/agent_task.py +++ b/backend/app/models/agent_task.py @@ -442,3 +442,141 @@ class AgentFinding(Base): "ai_confidence": self.ai_confidence, "created_at": self.created_at.isoformat() if self.created_at else None, } + + +class AgentCheckpoint(Base): + """ + Agent 检查点 + + 用于持久化 Agent 状态,支持: + - 任务恢复 + - 状态回滚 + - 执行历史追踪 + """ + __tablename__ = "agent_checkpoints" + + id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4())) + task_id = Column(String(36), ForeignKey("agent_tasks.id", ondelete="CASCADE"), nullable=False, index=True) + + # Agent 信息 + agent_id = Column(String(50), nullable=False, index=True) + agent_name = Column(String(255), nullable=False) + agent_type = Column(String(50), nullable=False) + parent_agent_id = Column(String(50), nullable=True) + + # 状态数据(JSON 序列化的 AgentState) + state_data = Column(Text, nullable=False) + + # 执行状态 + iteration = Column(Integer, default=0) + status = Column(String(30), nullable=False) + + # 统计信息 + total_tokens = Column(Integer, default=0) + tool_calls = Column(Integer, default=0) + findings_count = Column(Integer, default=0) + + # 检查点类型 + checkpoint_type = Column(String(30), default="auto") # auto, manual, error, final + checkpoint_name = Column(String(255), nullable=True) + + # 元数据 + checkpoint_metadata = Column(JSON, nullable=True) + + # 时间戳 + created_at = Column(DateTime(timezone=True), server_default=func.now(), index=True) + + def __repr__(self): + return f"" + + def to_dict(self) -> dict: + """转换为字典""" + return { + "id": self.id, + "task_id": self.task_id, + "agent_id": self.agent_id, + "agent_name": self.agent_name, + "agent_type": self.agent_type, + "parent_agent_id": self.parent_agent_id, + "iteration": self.iteration, + "status": self.status, + "total_tokens": self.total_tokens, + "tool_calls": self.tool_calls, + "findings_count": self.findings_count, + "checkpoint_type": self.checkpoint_type, + "checkpoint_name": self.checkpoint_name, + "created_at": self.created_at.isoformat() if self.created_at else None, + } + + +class AgentTreeNode(Base): + """ + Agent 树节点 + + 记录动态 Agent 树的结构,用于: + - 可视化 Agent 树 + - 追踪 Agent 间关系 + - 分析执行流程 + """ + __tablename__ = "agent_tree_nodes" + + id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4())) + task_id = Column(String(36), ForeignKey("agent_tasks.id", ondelete="CASCADE"), nullable=False, index=True) + + # Agent 信息 + agent_id = Column(String(50), nullable=False, unique=True, index=True) + agent_name = Column(String(255), nullable=False) + agent_type = Column(String(50), nullable=False) + + # 树结构 + parent_agent_id = Column(String(50), nullable=True, index=True) + depth = Column(Integer, default=0) # 树深度 + + # 任务信息 + task_description = Column(Text, nullable=True) + knowledge_modules = Column(JSON, nullable=True) + + # 执行状态 + status = Column(String(30), default="created") + + # 执行结果 + result_summary = Column(Text, nullable=True) + findings_count = Column(Integer, default=0) + + # 统计 + iterations = Column(Integer, default=0) + tokens_used = Column(Integer, default=0) + tool_calls = Column(Integer, default=0) + duration_ms = Column(Integer, nullable=True) + + # 时间戳 + created_at = Column(DateTime(timezone=True), server_default=func.now()) + started_at = Column(DateTime(timezone=True), nullable=True) + finished_at = Column(DateTime(timezone=True), nullable=True) + + def __repr__(self): + return f"" + + def to_dict(self) -> dict: + """转换为字典""" + return { + "id": self.id, + "task_id": self.task_id, + "agent_id": self.agent_id, + "agent_name": self.agent_name, + "agent_type": self.agent_type, + "parent_agent_id": self.parent_agent_id, + "depth": self.depth, + "task_description": self.task_description, + "knowledge_modules": self.knowledge_modules, + "status": self.status, + "result_summary": self.result_summary, + "findings_count": self.findings_count, + "iterations": self.iterations, + "tokens_used": self.tokens_used, + "tool_calls": self.tool_calls, + "duration_ms": self.duration_ms, + "created_at": self.created_at.isoformat() if self.created_at else None, + "started_at": self.started_at.isoformat() if self.started_at else None, + "finished_at": self.finished_at.isoformat() if self.finished_at else None, + } diff --git a/backend/app/services/agent/__init__.py b/backend/app/services/agent/__init__.py index a82b83b..b3cbbe5 100644 --- a/backend/app/services/agent/__init__.py +++ b/backend/app/services/agent/__init__.py @@ -2,16 +2,17 @@ DeepAudit Agent 服务模块 基于 LangGraph 的 AI Agent 代码安全审计 -架构: - LangGraph 状态图工作流 - +架构升级版本 - 支持: +- 动态Agent树结构 +- 专业知识模块系统 +- Agent间通信机制 +- 完整状态管理 +- Think工具和漏洞报告工具 + +工作流: START → Recon → Analysis ⟲ → Verification → Report → END -节点: - - Recon: 信息收集 (项目结构、技术栈、入口点) - - Analysis: 漏洞分析 (静态分析、RAG、模式匹配) - - Verification: 漏洞验证 (LLM 验证、沙箱测试) - - Report: 报告生成 + 支持动态创建子Agent进行专业化分析 """ # 从 graph 模块导入主要组件 @@ -32,6 +33,29 @@ from .agents import ( OrchestratorAgent, ReconAgent, AnalysisAgent, VerificationAgent, ) +# 🔥 新增:核心模块(状态管理、注册表、消息) +from .core import ( + AgentState, AgentStatus, + AgentRegistry, agent_registry, + AgentMessage, MessageType, MessagePriority, MessageBus, +) + +# 🔥 新增:知识模块系统(基于RAG) +from .knowledge import ( + KnowledgeLoader, knowledge_loader, + get_available_modules, get_module_content, + SecurityKnowledgeRAG, security_knowledge_rag, + SecurityKnowledgeQueryTool, GetVulnerabilityKnowledgeTool, +) + +# 🔥 新增:协作工具 +from .tools import ( + ThinkTool, ReflectTool, + CreateVulnerabilityReportTool, + CreateSubAgentTool, SendMessageTool, ViewAgentGraphTool, + WaitForMessageTool, AgentFinishTool, +) + __all__ = [ # 核心 Runner "AgentRunner", @@ -54,5 +78,35 @@ __all__ = [ "ReconAgent", "AnalysisAgent", "VerificationAgent", + + # 🔥 核心模块 + "AgentState", + "AgentStatus", + "AgentRegistry", + "agent_registry", + "AgentMessage", + "MessageType", + "MessagePriority", + "MessageBus", + + # 🔥 知识模块(基于RAG) + "KnowledgeLoader", + "knowledge_loader", + "get_available_modules", + "get_module_content", + "SecurityKnowledgeRAG", + "security_knowledge_rag", + "SecurityKnowledgeQueryTool", + "GetVulnerabilityKnowledgeTool", + + # 🔥 协作工具 + "ThinkTool", + "ReflectTool", + "CreateVulnerabilityReportTool", + "CreateSubAgentTool", + "SendMessageTool", + "ViewAgentGraphTool", + "WaitForMessageTool", + "AgentFinishTool", ] diff --git a/backend/app/services/agent/agents/analysis.py b/backend/app/services/agent/agents/analysis.py index 2e6a2d3..508c66e 100644 --- a/backend/app/services/agent/agents/analysis.py +++ b/backend/app/services/agent/agents/analysis.py @@ -46,8 +46,6 @@ ANALYSIS_SYSTEM_PROMPT = """你是 DeepAudit 的漏洞分析 Agent,一个**自 ### 深度分析 - **pattern_match**: 危险模式匹配 参数: pattern (str), file_types (list) -- **code_analysis**: LLM 深度代码分析 ⭐ - 参数: code (str), file_path (str), focus (str) - **dataflow_analysis**: 数据流追踪 参数: source (str), sink (str) @@ -114,7 +112,7 @@ Final Answer: [JSON 格式的漏洞报告] ## 分析策略建议 1. **快速扫描**: 先用 semgrep_scan 获得概览 -2. **重点深入**: 对可疑文件使用 read_file + code_analysis +2. **重点深入**: 对可疑文件使用 read_file + pattern_match 3. **模式搜索**: 用 search_code 找危险模式 (eval, exec, query 等) 4. **语义搜索**: 用 RAG 找相似的漏洞模式 5. **数据流**: 用 dataflow_analysis 追踪用户输入 @@ -268,6 +266,9 @@ class AnalysisAgent(BaseAgent): # 🔥 构建包含交接上下文的初始消息 handoff_context = self.get_handoff_context() + # 🔥 获取目标文件列表 + target_files = config.get("target_files", []) + initial_message = f"""请开始对项目进行安全漏洞分析。 ## 项目信息 @@ -275,7 +276,22 @@ class AnalysisAgent(BaseAgent): - 语言: {tech_stack.get('languages', [])} - 框架: {tech_stack.get('frameworks', [])} -{handoff_context if handoff_context else f'''## 上下文信息 +""" + # 🔥 如果指定了目标文件,明确告知 Agent + if target_files: + initial_message += f"""## ⚠️ 审计范围 +用户指定了 {len(target_files)} 个目标文件进行审计: +""" + for tf in target_files[:10]: + initial_message += f"- {tf}\n" + if len(target_files) > 10: + initial_message += f"- ... 还有 {len(target_files) - 10} 个文件\n" + initial_message += """ +请直接分析这些指定的文件,不要分析其他文件。 + +""" + + initial_message += f"""{handoff_context if handoff_context else f'''## 上下文信息 ### 高风险区域 {json.dumps(high_risk_areas[:20], ensure_ascii=False)} @@ -307,6 +323,7 @@ class AnalysisAgent(BaseAgent): self._steps = [] all_findings = [] + error_message = None # 🔥 跟踪错误信息 await self.emit_thinking("🔬 Analysis Agent 启动,LLM 开始自主安全分析...") @@ -323,11 +340,12 @@ class AnalysisAgent(BaseAgent): break # 调用 LLM 进行思考和决策(流式输出) + # 🔥 增加 max_tokens 到 4096,避免长输出被截断 try: llm_output, tokens_this_round = await self.stream_llm_call( self._conversation_history, temperature=0.1, - max_tokens=2048, + max_tokens=4096, ) except asyncio.CancelledError: logger.info(f"[{self.name}] LLM call cancelled") @@ -338,12 +356,21 @@ class AnalysisAgent(BaseAgent): # 🔥 Handle empty LLM response to prevent loops if not llm_output or not llm_output.strip(): logger.warning(f"[{self.name}] Empty LLM response in iteration {self._iteration}") - await self.emit_llm_decision("收到空响应", "LLM 返回内容为空,尝试重试通过提示") + empty_retry_count = getattr(self, '_empty_retry_count', 0) + 1 + self._empty_retry_count = empty_retry_count + if empty_retry_count >= 3: + logger.error(f"[{self.name}] Too many empty responses, stopping") + error_message = "连续收到空响应,停止分析" + await self.emit_event("error", error_message) + break self._conversation_history.append({ "role": "user", "content": "Received empty response. Please output your Thought and Action.", }) continue + + # 重置空响应计数器 + self._empty_retry_count = 0 # 解析 LLM 响应 step = self._parse_llm_response(llm_output) @@ -396,6 +423,11 @@ class AnalysisAgent(BaseAgent): step.action_input or {} ) + # 🔥 工具执行后检查取消状态 + if self.is_cancelled: + logger.info(f"[{self.name}] Cancelled after tool execution") + break + step.observation = observation # 🔥 发射 LLM 观察事件 @@ -414,9 +446,96 @@ class AnalysisAgent(BaseAgent): "content": "请继续分析。选择一个工具执行,或者如果分析完成,输出 Final Answer 汇总所有发现。", }) + # 🔥 如果循环结束但没有发现,强制 LLM 总结 + if not all_findings and not self.is_cancelled and not error_message: + await self.emit_thinking("📝 分析阶段结束,正在生成漏洞总结...") + + # 添加强制总结的提示 + self._conversation_history.append({ + "role": "user", + "content": """分析阶段已结束。请立即输出 Final Answer,总结你发现的所有安全问题。 + +即使没有发现严重漏洞,也请总结你的分析过程和观察到的潜在风险点。 + +请按以下 JSON 格式输出: +```json +{ + "findings": [ + { + "vulnerability_type": "sql_injection|xss|command_injection|path_traversal|ssrf|hardcoded_secret|other", + "severity": "critical|high|medium|low", + "title": "漏洞标题", + "description": "详细描述", + "file_path": "文件路径", + "line_start": 行号, + "code_snippet": "相关代码片段", + "suggestion": "修复建议" + } + ], + "summary": "分析总结" +} +``` + +Final Answer:""", + }) + + try: + summary_output, _ = await self.stream_llm_call( + self._conversation_history, + temperature=0.1, + max_tokens=4096, + ) + + if summary_output and summary_output.strip(): + # 解析总结输出 + import re + summary_text = summary_output.strip() + summary_text = re.sub(r'```json\s*', '', summary_text) + summary_text = re.sub(r'```\s*', '', summary_text) + parsed_result = AgentJsonParser.parse( + summary_text, + default={"findings": [], "summary": ""} + ) + if "findings" in parsed_result: + all_findings = parsed_result["findings"] + except Exception as e: + logger.warning(f"[{self.name}] Failed to generate summary: {e}") + # 处理结果 duration_ms = int((time.time() - start_time) * 1000) + # 🔥 如果被取消,返回取消结果 + if self.is_cancelled: + await self.emit_event( + "info", + f"🛑 Analysis Agent 已取消: {len(all_findings)} 个发现, {self._iteration} 轮迭代" + ) + return AgentResult( + success=False, + error="任务已取消", + data={"findings": all_findings}, + iterations=self._iteration, + tool_calls=self._tool_calls, + tokens_used=self._total_tokens, + duration_ms=duration_ms, + ) + + # 🔥 如果有错误,返回失败结果 + if error_message: + await self.emit_event( + "error", + f"❌ Analysis Agent 失败: {error_message}" + ) + return AgentResult( + success=False, + error=error_message, + data={"findings": all_findings}, + iterations=self._iteration, + tool_calls=self._tool_calls, + tokens_used=self._total_tokens, + duration_ms=duration_ms, + ) + # 标准化发现 standardized_findings = [] for finding in all_findings: diff --git a/backend/app/services/agent/agents/analysis_v2.py b/backend/app/services/agent/agents/analysis_v2.py deleted file mode 100644 index e69de29..0000000 diff --git a/backend/app/services/agent/agents/base.py b/backend/app/services/agent/agents/base.py index 84b0878..2564d3f 100644 --- a/backend/app/services/agent/agents/base.py +++ b/backend/app/services/agent/agents/base.py @@ -6,6 +6,8 @@ Agent 基类 1. LLM 是 Agent 的大脑,全程参与决策 2. Agent 之间通过 TaskHandoff 传递结构化上下文 3. 事件分为流式事件(前端展示)和持久化事件(数据库记录) +4. 支持动态Agent树和专业知识模块 +5. 完整的状态管理和Agent间通信 """ from abc import ABC, abstractmethod @@ -17,6 +19,10 @@ import asyncio import logging import uuid +from ..core.state import AgentState, AgentStatus +from ..core.registry import agent_registry +from ..core.message import message_bus, MessageType, AgentMessage + logger = logging.getLogger(__name__) @@ -238,6 +244,11 @@ class BaseAgent(ABC): 1. 通过 TaskHandoff 接收前序 Agent 的上下文 2. 执行完成后生成 TaskHandoff 传递给下一个 Agent 3. 洞察和发现应该结构化记录 + + 动态Agent树: + 1. 支持动态创建子Agent + 2. Agent间通过消息总线通信 + 3. 完整的状态管理和生命周期 """ def __init__( @@ -246,6 +257,8 @@ class BaseAgent(ABC): llm_service, tools: Dict[str, Any], event_emitter=None, + parent_id: Optional[str] = None, + knowledge_modules: Optional[List[str]] = None, ): """ 初始化 Agent @@ -255,13 +268,30 @@ class BaseAgent(ABC): llm_service: LLM 服务 tools: 可用工具字典 event_emitter: 事件发射器 + parent_id: 父Agent ID(用于动态Agent树) + knowledge_modules: 要加载的知识模块 """ self.config = config self.llm_service = llm_service self.tools = tools self.event_emitter = event_emitter + self.parent_id = parent_id + self.knowledge_modules = knowledge_modules or [] - # 运行状态 + # 🔥 生成唯一ID + self._agent_id = f"agent_{uuid.uuid4().hex[:8]}" + + # 🔥 增强的状态管理 + self._state = AgentState( + agent_id=self._agent_id, + agent_name=config.name, + agent_type=config.agent_type.value, + parent_id=parent_id, + max_iterations=config.max_iterations, + knowledge_modules=self.knowledge_modules, + ) + + # 运行状态(保持向后兼容) self._iteration = 0 self._total_tokens = 0 self._tool_calls = 0 @@ -271,15 +301,171 @@ class BaseAgent(ABC): self._incoming_handoff: Optional[TaskHandoff] = None self._insights: List[str] = [] # 收集的洞察 self._work_completed: List[str] = [] # 完成的工作记录 + + # 🔥 是否已注册到注册表 + self._registered = False + + # 🔥 加载知识模块到系统提示词 + if self.knowledge_modules: + self._load_knowledge_modules() + + def _register_to_registry(self, task: Optional[str] = None) -> None: + """注册到Agent注册表(延迟注册,在run时调用)""" + logger.info(f"[AgentTree] _register_to_registry 被调用: {self.config.name} (id={self._agent_id}, parent={self.parent_id}, _registered={self._registered})") + + if self._registered: + logger.warning(f"[AgentTree] {self.config.name} 已注册,跳过 (id={self._agent_id})") + return + + logger.info(f"[AgentTree] 正在注册 Agent: {self.config.name} (id={self._agent_id}, parent={self.parent_id})") + + agent_registry.register_agent( + agent_id=self._agent_id, + agent_name=self.config.name, + agent_type=self.config.agent_type.value, + task=task or self._state.task or "Initializing", + parent_id=self.parent_id, + agent_instance=self, + state=self._state, + knowledge_modules=self.knowledge_modules, + ) + + # 创建消息队列 + message_bus.create_queue(self._agent_id) + self._registered = True + + tree = agent_registry.get_agent_tree() + logger.info(f"[AgentTree] Agent 注册完成: {self.config.name}, 当前树节点数: {len(tree['nodes'])}") + + def set_parent_id(self, parent_id: str) -> None: + """设置父Agent ID(在调度时调用)""" + self.parent_id = parent_id + self._state.parent_id = parent_id + + def _load_knowledge_modules(self) -> None: + """加载知识模块到系统提示词""" + if not self.knowledge_modules: + return + + try: + from ..knowledge import knowledge_loader + + enhanced_prompt = knowledge_loader.build_system_prompt_with_modules( + self.config.system_prompt or "", + self.knowledge_modules, + ) + self.config.system_prompt = enhanced_prompt + + logger.info(f"[{self.name}] Loaded knowledge modules: {self.knowledge_modules}") + except Exception as e: + logger.warning(f"Failed to load knowledge modules: {e}") @property def name(self) -> str: return self.config.name + @property + def agent_id(self) -> str: + return self._agent_id + + @property + def state(self) -> AgentState: + return self._state + @property def agent_type(self) -> AgentType: return self.config.agent_type + # ============ Agent间消息处理 ============ + + def check_messages(self) -> List[AgentMessage]: + """ + 检查并处理收到的消息 + + Returns: + 未读消息列表 + """ + messages = message_bus.get_messages( + self._agent_id, + unread_only=True, + mark_as_read=True, + ) + + for msg in messages: + # 处理消息 + if msg.from_agent == "user": + # 用户消息直接添加到对话历史 + self._state.add_message("user", msg.content) + else: + # Agent间消息使用XML格式 + self._state.add_message("user", msg.to_xml()) + + # 如果在等待状态,恢复执行 + if self._state.is_waiting_for_input(): + self._state.resume_from_waiting() + agent_registry.update_agent_status(self._agent_id, "running") + + return messages + + def has_pending_messages(self) -> bool: + """检查是否有待处理的消息""" + return message_bus.has_unread_messages(self._agent_id) + + def send_message_to_parent( + self, + content: str, + message_type: MessageType = MessageType.INFORMATION, + ) -> None: + """向父Agent发送消息""" + if self.parent_id: + message_bus.send_message( + from_agent=self._agent_id, + to_agent=self.parent_id, + content=content, + message_type=message_type, + ) + + def send_message_to_agent( + self, + target_id: str, + content: str, + message_type: MessageType = MessageType.INFORMATION, + ) -> None: + """向指定Agent发送消息""" + message_bus.send_message( + from_agent=self._agent_id, + to_agent=target_id, + content=content, + message_type=message_type, + ) + + # ============ 生命周期管理 ============ + + def on_start(self) -> None: + """Agent开始执行时调用""" + self._state.start() + agent_registry.update_agent_status(self._agent_id, "running") + + def on_complete(self, result: Dict[str, Any]) -> None: + """Agent完成时调用""" + self._state.set_completed(result) + agent_registry.update_agent_status(self._agent_id, "completed", result) + + # 向父Agent报告完成 + if self.parent_id: + message_bus.send_completion_report( + from_agent=self._agent_id, + to_agent=self.parent_id, + summary=result.get("summary", "Task completed"), + findings=result.get("findings", []), + success=True, + ) + + def on_error(self, error: str) -> None: + """Agent出错时调用""" + self._state.set_failed(error) + agent_registry.update_agent_status(self._agent_id, "failed", {"error": error}) + @abstractmethod async def run(self, input_data: Dict[str, Any]) -> AgentResult: """ @@ -296,6 +482,7 @@ class BaseAgent(ABC): def cancel(self): """取消执行""" self._cancelled = True + logger.info(f"[{self.name}] Cancel requested") @property def is_cancelled(self) -> bool: @@ -671,6 +858,35 @@ class BaseAgent(ABC): "tokens_used": self._total_tokens, } + # ============ Memory Compression ============ + + def compress_messages_if_needed( + self, + messages: List[Dict[str, str]], + max_tokens: int = 100000, + ) -> List[Dict[str, str]]: + """ + 如果消息历史过长,自动压缩 + + Args: + messages: 消息列表 + max_tokens: 最大token数 + + Returns: + 压缩后的消息列表 + """ + from ...llm.memory_compressor import MemoryCompressor + + compressor = MemoryCompressor(max_total_tokens=max_tokens) + + if compressor.should_compress(messages): + logger.info(f"[{self.name}] Compressing conversation history...") + compressed = compressor.compress_history(messages) + logger.info(f"[{self.name}] Compressed {len(messages)} -> {len(compressed)} messages") + return compressed + + return messages + # ============ 统一的流式 LLM 调用 ============ async def stream_llm_call( @@ -678,6 +894,7 @@ class BaseAgent(ABC): messages: List[Dict[str, str]], temperature: float = 0.1, max_tokens: int = 2048, + auto_compress: bool = True, ) -> Tuple[str, int]: """ 统一的流式 LLM 调用方法 @@ -688,13 +905,23 @@ class BaseAgent(ABC): messages: 消息列表 temperature: 温度 max_tokens: 最大 token 数 + auto_compress: 是否自动压缩过长的消息历史 Returns: (完整响应内容, token数量) """ + # 🔥 自动压缩过长的消息历史 + if auto_compress: + messages = self.compress_messages_if_needed(messages) + accumulated = "" total_tokens = 0 + # 🔥 在开始 LLM 调用前检查取消 + if self.is_cancelled: + logger.info(f"[{self.name}] Cancelled before LLM call") + return "", 0 + await self.emit_thinking_start() try: @@ -705,6 +932,7 @@ class BaseAgent(ABC): ): # 检查取消 if self.is_cancelled: + logger.info(f"[{self.name}] Cancelled during LLM streaming") break if chunk["type"] == "token": @@ -745,6 +973,10 @@ class BaseAgent(ABC): Returns: 工具执行结果字符串 """ + # 🔥 在执行工具前检查取消 + if self.is_cancelled: + return "任务已取消" + tool = self.tools.get(tool_name) if not tool: diff --git a/backend/app/services/agent/agents/orchestrator.py b/backend/app/services/agent/agents/orchestrator.py index f6c1030..4f1aae0 100644 --- a/backend/app/services/agent/agents/orchestrator.py +++ b/backend/app/services/agent/agents/orchestrator.py @@ -79,7 +79,7 @@ Action Input: [JSON 参数] ``` ## 审计策略建议 -- 先用 recon Agent 了解项目全貌 +- 先用 recon Agent 了解项目全貌(只需调度一次) - 根据 recon 结果,让 analysis Agent 重点审计高风险区域 - 发现可疑漏洞后,用 verification Agent 验证 - 随时根据新发现调整策略,不要机械执行 @@ -90,6 +90,15 @@ Action Input: [JSON 参数] 2. **动态调整** - 根据发现调整策略 3. **主动决策** - 不要等待,主动推进 4. **质量优先** - 宁可深入分析几个真实漏洞,不要浅尝辄止 +5. **避免重复** - 每个 Agent 通常只需要调度一次,如果结果不理想,尝试其他 Agent 或直接完成审计 + +## 处理子 Agent 结果 +- 子 Agent 返回的 Observation 包含它们的分析结果 +- 即使结果看起来不完整,也要基于已有信息继续推进 +- 不要反复调度同一个 Agent 期望得到不同结果 +- 如果 recon 完成后,应该调度 analysis 进行深度分析 +- 如果 analysis 完成后有发现,可以调度 verification 验证 +- 如果没有更多工作要做,使用 finish 结束审计 现在,基于项目信息开始你的审计工作!""" @@ -136,11 +145,32 @@ class OrchestratorAgent(BaseAgent): self._conversation_history: List[Dict[str, str]] = [] self._steps: List[AgentStep] = [] self._all_findings: List[Dict] = [] + + # 🔥 存储运行时上下文,用于传递给子 Agent + self._runtime_context: Dict[str, Any] = {} + + # 🔥 跟踪已调度的 Agent 任务,避免重复调度 + self._dispatched_tasks: Dict[str, int] = {} # agent_name -> dispatch_count def register_sub_agent(self, name: str, agent: BaseAgent): """注册子 Agent""" self.sub_agents[name] = agent + def cancel(self): + """ + 取消执行 - 同时取消所有子 Agent + + 重写父类方法,确保取消信号传播到所有子 Agent + """ + self._cancelled = True + logger.info(f"[{self.name}] Cancel requested, propagating to {len(self.sub_agents)} sub-agents") + + # 🔥 传播取消信号到所有子 Agent + for name, agent in self.sub_agents.items(): + if hasattr(agent, 'cancel'): + agent.cancel() + logger.info(f"[{self.name}] Cancelled sub-agent: {name}") + async def run(self, input_data: Dict[str, Any]) -> AgentResult: """ 执行编排任务 - LLM 全程参与! @@ -149,6 +179,8 @@ class OrchestratorAgent(BaseAgent): input_data: { "project_info": 项目信息, "config": 审计配置, + "project_root": 项目根目录, + "task_id": 任务ID, } """ import time @@ -157,6 +189,14 @@ class OrchestratorAgent(BaseAgent): project_info = input_data.get("project_info", {}) config = input_data.get("config", {}) + # 🔥 保存运行时上下文,用于传递给子 Agent + self._runtime_context = { + "project_info": project_info, + "config": config, + "project_root": input_data.get("project_root", project_info.get("root", ".")), + "task_id": input_data.get("task_id"), + } + # 构建初始消息 initial_message = self._build_initial_message(project_info, config) @@ -169,6 +209,7 @@ class OrchestratorAgent(BaseAgent): self._steps = [] self._all_findings = [] final_result = None + error_message = None # 🔥 跟踪错误信息 await self.emit_thinking("🧠 Orchestrator Agent 启动,LLM 开始自主编排决策...") @@ -189,7 +230,7 @@ class OrchestratorAgent(BaseAgent): llm_output, tokens_this_round = await self.stream_llm_call( self._conversation_history, temperature=0.1, - max_tokens=2048, + max_tokens=4096, # 🔥 增加到 4096,避免截断 ) except asyncio.CancelledError: logger.info(f"[{self.name}] LLM call cancelled") @@ -197,11 +238,37 @@ class OrchestratorAgent(BaseAgent): self._total_tokens += tokens_this_round + # 🔥 检测空响应 + if not llm_output or not llm_output.strip(): + logger.warning(f"[{self.name}] Empty LLM response") + empty_retry_count = getattr(self, '_empty_retry_count', 0) + 1 + self._empty_retry_count = empty_retry_count + if empty_retry_count >= 3: + logger.error(f"[{self.name}] Too many empty responses, stopping") + error_message = "连续收到空响应,停止编排" + await self.emit_event("error", error_message) + break + self._conversation_history.append({ + "role": "user", + "content": "Received empty response. Please output Thought + Action + Action Input.", + }) + continue + + # 重置空响应计数器 + self._empty_retry_count = 0 + # 解析 LLM 的决策 step = self._parse_llm_response(llm_output) if not step: # LLM 输出格式不正确,提示重试 + format_retry_count = getattr(self, '_format_retry_count', 0) + 1 + self._format_retry_count = format_retry_count + if format_retry_count >= 3: + logger.error(f"[{self.name}] Too many format errors, stopping") + error_message = "连续格式错误,停止编排" + await self.emit_event("error", error_message) + break await self.emit_llm_decision("格式错误", "需要重新输出") self._conversation_history.append({ "role": "assistant", @@ -213,6 +280,9 @@ class OrchestratorAgent(BaseAgent): }) continue + # 重置格式重试计数器 + self._format_retry_count = 0 + self._steps.append(step) # 🔥 发射 LLM 思考内容事件 - 展示编排决策的思考过程 @@ -249,6 +319,11 @@ class OrchestratorAgent(BaseAgent): observation = await self._dispatch_agent(step.action_input) step.observation = observation + # 🔥 子 Agent 执行完成后检查取消状态 + if self.is_cancelled: + logger.info(f"[{self.name}] Cancelled after sub-agent dispatch") + break + # 🔥 发射观察事件 await self.emit_llm_observation(observation) @@ -272,6 +347,60 @@ class OrchestratorAgent(BaseAgent): # 生成最终结果 duration_ms = int((time.time() - start_time) * 1000) + # 🔥 如果被取消,返回取消结果 + if self.is_cancelled: + await self.emit_event( + "info", + f"🛑 Orchestrator 已取消: {len(self._all_findings)} 个发现, {self._iteration} 轮决策" + ) + return AgentResult( + success=False, + error="任务已取消", + data={ + "findings": self._all_findings, + "steps": [ + { + "thought": s.thought, + "action": s.action, + "action_input": s.action_input, + "observation": s.observation[:500] if s.observation else None, + } + for s in self._steps + ], + }, + iterations=self._iteration, + tool_calls=self._tool_calls, + tokens_used=self._total_tokens, + duration_ms=duration_ms, + ) + + # 🔥 如果有错误,返回失败结果 + if error_message: + await self.emit_event( + "error", + f"❌ Orchestrator 失败: {error_message}" + ) + return AgentResult( + success=False, + error=error_message, + data={ + "findings": self._all_findings, + "steps": [ + { + "thought": s.thought, + "action": s.action, + "action_input": s.action_input, + "observation": s.observation[:500] if s.observation else None, + } + for s in self._steps + ], + }, + iterations=self._iteration, + tool_calls=self._tool_calls, + tokens_used=self._total_tokens, + duration_ms=duration_ms, + ) + await self.emit_event( "info", f"🎯 Orchestrator 完成: {len(self._all_findings)} 个发现, {self._iteration} 轮决策" @@ -377,6 +506,30 @@ class OrchestratorAgent(BaseAgent): available = list(self.sub_agents.keys()) return f"错误: Agent '{agent_name}' 不存在。可用的 Agent: {available}" + # 🔥 检查是否重复调度同一个 Agent + dispatch_count = self._dispatched_tasks.get(agent_name, 0) + if dispatch_count >= 2: + return f"""## ⚠️ 重复调度警告 + +你已经调度 {agent_name} Agent {dispatch_count} 次了。 + +如果之前的调度没有返回有用的结果,请考虑: +1. 尝试调度其他 Agent(如 analysis 或 verification) +2. 使用 finish 操作结束审计并汇总已有发现 +3. 提供更具体的任务描述 + +当前已收集的发现数量: {len(self._all_findings)} +""" + + self._dispatched_tasks[agent_name] = dispatch_count + 1 + + # 🔥 设置父 Agent ID 并注册到注册表(动态 Agent 树) + logger.info(f"[Orchestrator] 准备调度 {agent_name} Agent, agent._registered={agent._registered}") + agent.set_parent_id(self._agent_id) + logger.info(f"[Orchestrator] 设置 parent_id 完成,准备注册 {agent_name}") + agent._register_to_registry(task=task) + logger.info(f"[Orchestrator] {agent_name} 注册完成,agent._registered={agent._registered}") + await self.emit_event( "dispatch", f"📤 调度 {agent_name} Agent: {task[:100]}...", @@ -387,31 +540,92 @@ class OrchestratorAgent(BaseAgent): self._tool_calls += 1 try: - # 构建子 Agent 输入 + # 🔥 构建子 Agent 输入 - 传递完整的运行时上下文 + project_info = self._runtime_context.get("project_info", {}).copy() + # 确保 project_info 包含 root 路径 + if "root" not in project_info: + project_info["root"] = self._runtime_context.get("project_root", ".") + sub_input = { "task": task, "task_context": context, - "project_info": {}, # 从上下文获取 - "config": {}, + "project_info": project_info, + "config": self._runtime_context.get("config", {}), + "project_root": self._runtime_context.get("project_root", "."), + "previous_results": { + "findings": self._all_findings, # 传递已收集的发现 + }, } + # 🔥 执行子 Agent 前检查取消状态 + if self.is_cancelled: + return f"## {agent_name} Agent 执行取消\n\n任务已被用户取消" + # 执行子 Agent result = await agent.run(sub_input) - # 收集发现 + # 🔥 执行后再次检查取消状态 + if self.is_cancelled: + return f"## {agent_name} Agent 执行中断\n\n任务已被用户取消" + + # 🔥 处理子 Agent 结果 - 不同 Agent 返回不同的数据结构 if result.success and result.data: - findings = result.data.get("findings", []) - self._all_findings.extend(findings) + data = result.data + + # 🔥 收集发现 - 只收集格式正确的漏洞对象 + # findings 字段通常来自 Analysis/Verification Agent,是漏洞对象数组 + # initial_findings 来自 Recon Agent,可能是字符串数组(观察)或对象数组 + findings = data.get("findings", []) + if findings: + # 只添加字典格式的发现 + valid_findings = [f for f in findings if isinstance(f, dict)] + self._all_findings.extend(valid_findings) await self.emit_event( "dispatch_complete", - f"✅ {agent_name} Agent 完成: {len(findings)} 个发现", + f"✅ {agent_name} Agent 完成", agent=agent_name, findings_count=len(findings), ) - # 构建观察结果 - observation = f"""## {agent_name} Agent 执行结果 + # 🔥 根据 Agent 类型构建不同的观察结果 + if agent_name == "recon": + # Recon Agent 返回项目信息 + observation = f"""## Recon Agent 执行结果 + +**状态**: 成功 +**迭代次数**: {result.iterations} +**耗时**: {result.duration_ms}ms + +### 项目结构 +{json.dumps(data.get('project_structure', {}), ensure_ascii=False, indent=2)} + +### 技术栈 +- 语言: {data.get('tech_stack', {}).get('languages', [])} +- 框架: {data.get('tech_stack', {}).get('frameworks', [])} +- 数据库: {data.get('tech_stack', {}).get('databases', [])} + +### 入口点 ({len(data.get('entry_points', []))} 个) +""" + for i, ep in enumerate(data.get('entry_points', [])[:10]): + if isinstance(ep, dict): + observation += f"{i+1}. [{ep.get('type', 'unknown')}] {ep.get('file', '')}:{ep.get('line', '')}\n" + + observation += f""" +### 高风险区域 +{data.get('high_risk_areas', [])} + +### 初步发现 ({len(data.get('initial_findings', []))} 个) +""" + for finding in data.get('initial_findings', [])[:5]: + if isinstance(finding, str): + observation += f"- {finding}\n" + elif isinstance(finding, dict): + observation += f"- {finding.get('title', finding)}\n" + + else: + # Analysis/Verification Agent 返回漏洞发现 + observation = f"""## {agent_name} Agent 执行结果 **状态**: 成功 **发现数量**: {len(findings)} @@ -420,22 +634,21 @@ class OrchestratorAgent(BaseAgent): ### 发现摘要 """ - for i, f in enumerate(findings[:10]): # 最多显示 10 个 - if not isinstance(f, dict): - continue - - observation += f""" + for i, f in enumerate(findings[:10]): + if not isinstance(f, dict): + continue + observation += f""" {i+1}. [{f.get('severity', 'unknown')}] {f.get('title', 'Unknown')} - 类型: {f.get('vulnerability_type', 'unknown')} - 文件: {f.get('file_path', 'unknown')} - 描述: {f.get('description', '')[:200]}... """ + + if len(findings) > 10: + observation += f"\n... 还有 {len(findings) - 10} 个发现" - if len(findings) > 10: - observation += f"\n... 还有 {len(findings) - 10} 个发现" - - if result.data.get("summary"): - observation += f"\n\n### Agent 总结\n{result.data['summary']}" + if data.get("summary"): + observation += f"\n\n### Agent 总结\n{data['summary']}" return observation else: diff --git a/backend/app/services/agent/agents/react_agent.py b/backend/app/services/agent/agents/react_agent.py deleted file mode 100644 index 37e8e0e..0000000 --- a/backend/app/services/agent/agents/react_agent.py +++ /dev/null @@ -1,380 +0,0 @@ -""" -真正的 ReAct Agent 实现 -LLM 是大脑,全程参与决策! - -ReAct 循环: -1. Thought: LLM 思考当前状态和下一步 -2. Action: LLM 决定调用哪个工具 -3. Observation: 执行工具,获取结果 -4. 重复直到 LLM 决定完成 -""" - -import json -import logging -import re -from typing import List, Dict, Any, Optional, Tuple -from dataclasses import dataclass - -from .base import BaseAgent, AgentConfig, AgentResult, AgentType, AgentPattern -from ..json_parser import AgentJsonParser - -logger = logging.getLogger(__name__) - - -REACT_SYSTEM_PROMPT = """你是 DeepAudit 安全审计 Agent,一个专业的代码安全分析专家。 - -## 你的任务 -对目标项目进行全面的安全审计,发现潜在的安全漏洞。 - -## 你的工具 -{tools_description} - -## 工作方式 -你需要通过 **思考-行动-观察** 循环来完成任务: - -1. **Thought**: 分析当前情况,思考下一步应该做什么 -2. **Action**: 选择一个工具并执行 -3. **Observation**: 观察工具返回的结果 -4. 重复上述过程直到你认为审计完成 - -## 输出格式 -每一步必须严格按照以下格式输出: - -``` -Thought: [你的思考过程,分析当前状态,决定下一步] -Action: [工具名称] -Action Input: [工具参数,JSON 格式] -``` - -当你完成分析后,输出: -``` -Thought: [总结分析结果] -Final Answer: [JSON 格式的最终发现] -``` - -## Final Answer 格式 -```json -{{ - "findings": [ - {{ - "vulnerability_type": "sql_injection", - "severity": "high", - "title": "SQL 注入漏洞", - "description": "详细描述", - "file_path": "path/to/file.py", - "line_start": 42, - "code_snippet": "危险代码片段", - "suggestion": "修复建议" - }} - ], - "summary": "审计总结" -}} -``` - -## 审计策略建议 -1. 先用 list_files 了解项目结构 -2. 识别关键文件(路由、控制器、数据库操作) -3. 使用 search_code 搜索危险模式(eval, exec, query, innerHTML 等) -4. 读取可疑文件进行深度分析 -5. 如果有 semgrep,用它进行全面扫描 - -## 重点关注的漏洞类型 -- SQL 注入 (query, execute, raw SQL) -- XSS (innerHTML, document.write, v-html) -- 命令注入 (exec, system, subprocess, child_process) -- 路径遍历 (open, readFile, path concatenation) -- SSRF (requests, fetch, http client) -- 硬编码密钥 (password, secret, api_key, token) -- 不安全的反序列化 (pickle, yaml.load, eval) - -现在开始审计!""" - - -@dataclass -class AgentStep: - """Agent 执行步骤""" - thought: str - action: Optional[str] = None - action_input: Optional[Dict] = None - observation: Optional[str] = None - is_final: bool = False - final_answer: Optional[Dict] = None - - -class ReActAgent(BaseAgent): - """ - 真正的 ReAct Agent - - LLM 全程参与决策,自主选择工具和分析策略 - """ - - def __init__( - self, - llm_service, - tools: Dict[str, Any], - event_emitter=None, - agent_type: AgentType = AgentType.ANALYSIS, - max_iterations: int = 30, - ): - config = AgentConfig( - name="ReActAgent", - agent_type=agent_type, - pattern=AgentPattern.REACT, - max_iterations=max_iterations, - system_prompt=REACT_SYSTEM_PROMPT, - ) - super().__init__(config, llm_service, tools, event_emitter) - - self._conversation_history: List[Dict[str, str]] = [] - self._steps: List[AgentStep] = [] - - def _get_tools_description(self) -> str: - """生成工具描述""" - descriptions = [] - - for name, tool in self.tools.items(): - if name.startswith("_"): - continue - - desc = f"### {name}\n" - desc += f"{tool.description}\n" - - # 添加参数说明 - if hasattr(tool, 'args_schema') and tool.args_schema: - schema = tool.args_schema.schema() - properties = schema.get("properties", {}) - if properties: - desc += "参数:\n" - for param_name, param_info in properties.items(): - param_desc = param_info.get("description", "") - param_type = param_info.get("type", "string") - desc += f" - {param_name} ({param_type}): {param_desc}\n" - - descriptions.append(desc) - - return "\n".join(descriptions) - - def _build_system_prompt(self, project_info: Dict, task_context: str = "") -> str: - """构建系统提示词""" - tools_desc = self._get_tools_description() - prompt = self.config.system_prompt.format(tools_description=tools_desc) - - if project_info: - prompt += f"\n\n## 项目信息\n" - prompt += f"- 名称: {project_info.get('name', 'unknown')}\n" - prompt += f"- 语言: {', '.join(project_info.get('languages', ['unknown']))}\n" - prompt += f"- 文件数: {project_info.get('file_count', 'unknown')}\n" - - if task_context: - prompt += f"\n\n## 任务上下文\n{task_context}" - - return prompt - - def _parse_llm_response(self, response: str) -> AgentStep: - """解析 LLM 响应""" - step = AgentStep(thought="") - - # 提取 Thought - thought_match = re.search(r'Thought:\s*(.*?)(?=Action:|Final Answer:|$)', response, re.DOTALL) - if thought_match: - step.thought = thought_match.group(1).strip() - - # 检查是否是最终答案 - final_match = re.search(r'Final Answer:\s*(.*?)$', response, re.DOTALL) - if final_match: - step.is_final = True - answer_text = final_match.group(1).strip() - answer_text = re.sub(r'```json\s*', '', answer_text) - answer_text = re.sub(r'```\s*', '', answer_text) - # 使用增强的 JSON 解析器 - step.final_answer = AgentJsonParser.parse( - answer_text, - default={"raw_answer": answer_text} - ) - # 确保 findings 格式正确 - if "findings" in step.final_answer: - step.final_answer["findings"] = [ - f for f in step.final_answer["findings"] - if isinstance(f, dict) - ] - return step - - # 提取 Action - action_match = re.search(r'Action:\s*(\w+)', response) - if action_match: - step.action = action_match.group(1).strip() - - # 提取 Action Input - input_match = re.search(r'Action Input:\s*(.*?)(?=Thought:|Action:|Observation:|$)', response, re.DOTALL) - if input_match: - input_text = input_match.group(1).strip() - input_text = re.sub(r'```json\s*', '', input_text) - input_text = re.sub(r'```\s*', '', input_text) - # 使用增强的 JSON 解析器 - step.action_input = AgentJsonParser.parse( - input_text, - default={"raw_input": input_text} - ) - - return step - - async def _execute_tool(self, tool_name: str, tool_input: Dict) -> str: - """执行工具""" - tool = self.tools.get(tool_name) - - if not tool: - return f"错误: 工具 '{tool_name}' 不存在。可用工具: {list(self.tools.keys())}" - - try: - self._tool_calls += 1 - await self.emit_tool_call(tool_name, tool_input) - - import time - start = time.time() - - result = await tool.execute(**tool_input) - - duration_ms = int((time.time() - start) * 1000) - await self.emit_tool_result(tool_name, str(result.data)[:200], duration_ms) - - if result.success: - # 截断过长的输出 - output = str(result.data) - if len(output) > 4000: - output = output[:4000] + "\n\n... [输出已截断,共 {} 字符]".format(len(str(result.data))) - return output - else: - return f"工具执行失败: {result.error}" - - except Exception as e: - logger.error(f"Tool execution error: {e}") - return f"工具执行错误: {str(e)}" - - async def run(self, input_data: Dict[str, Any]) -> AgentResult: - """ - 执行 ReAct Agent - - LLM 全程参与,自主决策! - """ - import time - start_time = time.time() - - project_info = input_data.get("project_info", {}) - task_context = input_data.get("task_context", "") - config = input_data.get("config", {}) - - # 构建系统提示词 - system_prompt = self._build_system_prompt(project_info, task_context) - - # 初始化对话历史 - self._conversation_history = [ - {"role": "system", "content": system_prompt}, - {"role": "user", "content": "请开始对项目进行安全审计。首先了解项目结构,然后系统性地搜索和分析潜在的安全漏洞。"}, - ] - - self._steps = [] - all_findings = [] - - await self.emit_thinking("🤖 ReAct Agent 启动,LLM 开始自主分析...") - - try: - for iteration in range(self.config.max_iterations): - if self.is_cancelled: - break - - self._iteration = iteration + 1 - - await self.emit_thinking(f"💭 第 {iteration + 1} 轮思考...") - - # 🔥 调用 LLM 进行思考和决策 - response = await self.llm_service.chat_completion_raw( - messages=self._conversation_history, - temperature=0.1, - max_tokens=2048, - ) - - llm_output = response.get("content", "") - self._total_tokens += response.get("usage", {}).get("total_tokens", 0) - - # 发射思考事件 - await self.emit_event("thinking", f"LLM: {llm_output[:500]}...") - - # 解析 LLM 响应 - step = self._parse_llm_response(llm_output) - self._steps.append(step) - - # 添加 LLM 响应到历史 - self._conversation_history.append({ - "role": "assistant", - "content": llm_output, - }) - - # 检查是否完成 - if step.is_final: - await self.emit_thinking("✅ LLM 完成分析,生成最终报告") - - if step.final_answer and "findings" in step.final_answer: - all_findings = step.final_answer["findings"] - break - - # 执行工具 - if step.action: - await self.emit_thinking(f"🔧 LLM 决定调用工具: {step.action}") - - observation = await self._execute_tool( - step.action, - step.action_input or {} - ) - - step.observation = observation - - # 添加观察结果到历史 - self._conversation_history.append({ - "role": "user", - "content": f"Observation: {observation}", - }) - else: - # LLM 没有选择工具,提示它继续 - self._conversation_history.append({ - "role": "user", - "content": "请继续分析,选择一个工具执行,或者如果分析完成,输出 Final Answer。", - }) - - duration_ms = int((time.time() - start_time) * 1000) - - await self.emit_event( - "info", - f"🎯 ReAct Agent 完成: {len(all_findings)} 个发现, {self._iteration} 轮迭代, {self._tool_calls} 次工具调用" - ) - - return AgentResult( - success=True, - data={ - "findings": all_findings, - "steps": [ - { - "thought": s.thought, - "action": s.action, - "action_input": s.action_input, - "observation": s.observation[:500] if s.observation else None, - } - for s in self._steps - ], - }, - iterations=self._iteration, - tool_calls=self._tool_calls, - tokens_used=self._total_tokens, - duration_ms=duration_ms, - ) - - except Exception as e: - logger.error(f"ReAct Agent failed: {e}", exc_info=True) - return AgentResult(success=False, error=str(e)) - - def get_conversation_history(self) -> List[Dict[str, str]]: - """获取对话历史""" - return self._conversation_history - - def get_steps(self) -> List[AgentStep]: - """获取执行步骤""" - return self._steps diff --git a/backend/app/services/agent/agents/recon.py b/backend/app/services/agent/agents/recon.py index 1850c52..e1dcd9c 100644 --- a/backend/app/services/agent/agents/recon.py +++ b/backend/app/services/agent/agents/recon.py @@ -99,6 +99,12 @@ Final Answer: [JSON 格式的收集结果] 4. 运行安全扫描发现初步问题 5. 根据发现继续深入 +## 重要提示 +- 用户可能指定了特定的目标文件进行审计 +- 如果 list_files 显示"审计范围限定为 X 个指定文件",说明只需要分析这些文件 +- 在这种情况下,直接读取和分析指定的文件,不要浪费时间遍历其他目录 +- 如果目录显示为空,可能是因为该目录不包含目标文件 + ## 重要原则 1. **你是大脑** - 每一步都要思考,不要机械执行 2. **动态调整** - 根据发现调整策略 @@ -216,13 +222,38 @@ class ReconAgent(BaseAgent): task = input_data.get("task", "") task_context = input_data.get("task_context", "") + # 🔥 获取目标文件列表 + target_files = config.get("target_files", []) + exclude_patterns = config.get("exclude_patterns", []) + # 构建初始消息 initial_message = f"""请开始收集项目信息。 ## 项目基本信息 - 名称: {project_info.get('name', 'unknown')} - 根目录: {project_info.get('root', '.')} +- 文件数量: {project_info.get('file_count', 'unknown')} +## 审计范围 +""" + # 🔥 如果指定了目标文件,明确告知 Agent + if target_files: + initial_message += f"""⚠️ **重要**: 用户指定了 {len(target_files)} 个目标文件进行审计: +""" + for tf in target_files[:10]: + initial_message += f"- {tf}\n" + if len(target_files) > 10: + initial_message += f"- ... 还有 {len(target_files) - 10} 个文件\n" + initial_message += """ +请直接读取和分析这些指定的文件,不要浪费时间遍历其他目录。 +""" + else: + initial_message += "全项目审计(无特定文件限制)\n" + + if exclude_patterns: + initial_message += f"\n排除模式: {', '.join(exclude_patterns[:5])}\n" + + initial_message += f""" ## 任务上下文 {task_context or task or '进行全面的信息收集,为安全审计做准备。'} @@ -239,6 +270,7 @@ class ReconAgent(BaseAgent): self._steps = [] final_result = None + error_message = None # 🔥 跟踪错误信息 await self.emit_thinking("Recon Agent 启动,LLM 开始自主收集信息...") @@ -259,7 +291,7 @@ class ReconAgent(BaseAgent): llm_output, tokens_this_round = await self.stream_llm_call( self._conversation_history, temperature=0.1, - max_tokens=2048, + max_tokens=4096, # 🔥 增加到 4096,避免截断 ) except asyncio.CancelledError: logger.info(f"[{self.name}] LLM call cancelled") @@ -270,12 +302,21 @@ class ReconAgent(BaseAgent): # 🔥 Handle empty LLM response to prevent loops if not llm_output or not llm_output.strip(): logger.warning(f"[{self.name}] Empty LLM response in iteration {self._iteration}") - await self.emit_llm_decision("收到空响应", "LLM 返回内容为空,尝试重试通过提示") + empty_retry_count = getattr(self, '_empty_retry_count', 0) + 1 + self._empty_retry_count = empty_retry_count + if empty_retry_count >= 3: + logger.error(f"[{self.name}] Too many empty responses, stopping") + error_message = "连续收到空响应,停止信息收集" + await self.emit_event("error", error_message) + break self._conversation_history.append({ "role": "user", "content": "Received empty response. Please output your Thought and Action.", }) continue + + # 重置空响应计数器 + self._empty_retry_count = 0 # 解析 LLM 响应 step = self._parse_llm_response(llm_output) @@ -311,6 +352,11 @@ class ReconAgent(BaseAgent): step.action_input or {} ) + # 🔥 工具执行后检查取消状态 + if self.is_cancelled: + logger.info(f"[{self.name}] Cancelled after tool execution") + break + step.observation = observation # 🔥 发射 LLM 观察事件 @@ -329,9 +375,84 @@ class ReconAgent(BaseAgent): "content": "请继续,选择一个工具执行,或者如果信息收集完成,输出 Final Answer。", }) + # 🔥 如果循环结束但没有 final_result,强制 LLM 总结 + if not final_result and not self.is_cancelled and not error_message: + await self.emit_thinking("📝 信息收集阶段结束,正在生成总结...") + + # 添加强制总结的提示 + self._conversation_history.append({ + "role": "user", + "content": """信息收集阶段已结束。请立即输出 Final Answer,总结你收集到的所有信息。 + +请按以下 JSON 格式输出: +```json +{ + "project_structure": {"directories": [...], "key_files": [...]}, + "tech_stack": {"languages": [...], "frameworks": [...], "databases": [...]}, + "entry_points": [{"type": "...", "file": "...", "description": "..."}], + "high_risk_areas": ["file1.py", "file2.js"], + "initial_findings": [{"title": "...", "description": "...", "file_path": "..."}], + "summary": "项目总结描述" +} +``` + +Final Answer:""", + }) + + try: + summary_output, _ = await self.stream_llm_call( + self._conversation_history, + temperature=0.1, + max_tokens=2048, + ) + + if summary_output and summary_output.strip(): + # 解析总结输出 + summary_text = summary_output.strip() + summary_text = re.sub(r'```json\s*', '', summary_text) + summary_text = re.sub(r'```\s*', '', summary_text) + final_result = AgentJsonParser.parse( + summary_text, + default=self._summarize_from_steps() + ) + except Exception as e: + logger.warning(f"[{self.name}] Failed to generate summary: {e}") + # 处理结果 duration_ms = int((time.time() - start_time) * 1000) + # 🔥 如果被取消,返回取消结果 + if self.is_cancelled: + await self.emit_event( + "info", + f"🛑 Recon Agent 已取消: {self._iteration} 轮迭代" + ) + return AgentResult( + success=False, + error="任务已取消", + data=self._summarize_from_steps(), + iterations=self._iteration, + tool_calls=self._tool_calls, + tokens_used=self._total_tokens, + duration_ms=duration_ms, + ) + + # 🔥 如果有错误,返回失败结果 + if error_message: + await self.emit_event( + "error", + f"❌ Recon Agent 失败: {error_message}" + ) + return AgentResult( + success=False, + error=error_message, + data=self._summarize_from_steps(), + iterations=self._iteration, + tool_calls=self._tool_calls, + tokens_used=self._total_tokens, + duration_ms=duration_ms, + ) + # 如果没有最终结果,从历史中汇总 if not final_result: final_result = self._summarize_from_steps() @@ -364,7 +485,7 @@ class ReconAgent(BaseAgent): return AgentResult(success=False, error=str(e)) def _summarize_from_steps(self) -> Dict[str, Any]: - """从步骤中汇总结果""" + """从步骤中汇总结果 - 增强版,从 LLM 思考过程中提取更多信息""" # 默认结果结构 result = { "project_structure": {}, @@ -377,34 +498,90 @@ class ReconAgent(BaseAgent): "high_risk_areas": [], "dependencies": {}, "initial_findings": [], + "summary": "", # 🔥 新增:汇总 LLM 的思考 } - # 从步骤的观察结果中提取信息 + # 🔥 收集所有 LLM 的思考内容 + thoughts = [] + + # 从步骤的观察结果和思考中提取信息 for step in self._steps: + # 收集思考内容 + if step.thought: + thoughts.append(step.thought) + if step.observation: # 尝试从观察中识别技术栈等信息 obs_lower = step.observation.lower() - if "package.json" in obs_lower: + # 识别语言 + if "package.json" in obs_lower or ".js" in obs_lower or ".ts" in obs_lower: result["tech_stack"]["languages"].append("JavaScript/TypeScript") - if "requirements.txt" in obs_lower or "setup.py" in obs_lower: + if "requirements.txt" in obs_lower or "setup.py" in obs_lower or ".py" in obs_lower: result["tech_stack"]["languages"].append("Python") - if "go.mod" in obs_lower: + if "go.mod" in obs_lower or ".go" in obs_lower: result["tech_stack"]["languages"].append("Go") + if "pom.xml" in obs_lower or ".java" in obs_lower: + result["tech_stack"]["languages"].append("Java") + if ".php" in obs_lower: + result["tech_stack"]["languages"].append("PHP") + if ".rb" in obs_lower or "gemfile" in obs_lower: + result["tech_stack"]["languages"].append("Ruby") # 识别框架 if "react" in obs_lower: result["tech_stack"]["frameworks"].append("React") + if "vue" in obs_lower: + result["tech_stack"]["frameworks"].append("Vue") + if "angular" in obs_lower: + result["tech_stack"]["frameworks"].append("Angular") if "django" in obs_lower: result["tech_stack"]["frameworks"].append("Django") + if "flask" in obs_lower: + result["tech_stack"]["frameworks"].append("Flask") if "fastapi" in obs_lower: result["tech_stack"]["frameworks"].append("FastAPI") if "express" in obs_lower: result["tech_stack"]["frameworks"].append("Express") + if "spring" in obs_lower: + result["tech_stack"]["frameworks"].append("Spring") + if "streamlit" in obs_lower: + result["tech_stack"]["frameworks"].append("Streamlit") + + # 识别数据库 + if "mysql" in obs_lower or "pymysql" in obs_lower: + result["tech_stack"]["databases"].append("MySQL") + if "postgres" in obs_lower or "asyncpg" in obs_lower: + result["tech_stack"]["databases"].append("PostgreSQL") + if "mongodb" in obs_lower or "pymongo" in obs_lower: + result["tech_stack"]["databases"].append("MongoDB") + if "redis" in obs_lower: + result["tech_stack"]["databases"].append("Redis") + if "sqlite" in obs_lower: + result["tech_stack"]["databases"].append("SQLite") + + # 🔥 识别高风险区域(从观察中提取) + risk_keywords = ["api", "auth", "login", "password", "secret", "key", "token", + "admin", "upload", "download", "exec", "eval", "sql", "query"] + for keyword in risk_keywords: + if keyword in obs_lower: + # 尝试从观察中提取文件路径 + import re + file_matches = re.findall(r'[\w/]+\.(?:py|js|ts|java|php|go|rb)', step.observation) + for file_path in file_matches[:3]: # 限制数量 + if file_path not in result["high_risk_areas"]: + result["high_risk_areas"].append(file_path) # 去重 result["tech_stack"]["languages"] = list(set(result["tech_stack"]["languages"])) result["tech_stack"]["frameworks"] = list(set(result["tech_stack"]["frameworks"])) + result["tech_stack"]["databases"] = list(set(result["tech_stack"]["databases"])) + result["high_risk_areas"] = list(set(result["high_risk_areas"]))[:20] # 限制数量 + + # 🔥 汇总 LLM 的思考作为 summary + if thoughts: + # 取最后几个思考作为总结 + result["summary"] = "\n".join(thoughts[-3:]) return result diff --git a/backend/app/services/agent/agents/verification.py b/backend/app/services/agent/agents/verification.py index 919618b..3752d91 100644 --- a/backend/app/services/agent/agents/verification.py +++ b/backend/app/services/agent/agents/verification.py @@ -334,7 +334,7 @@ class VerificationAgent(BaseAgent): llm_output, tokens_this_round = await self.stream_llm_call( self._conversation_history, temperature=0.1, - max_tokens=3000, + max_tokens=4096, # 🔥 增加到 4096,避免截断 ) except asyncio.CancelledError: logger.info(f"[{self.name}] LLM call cancelled") @@ -415,6 +415,22 @@ class VerificationAgent(BaseAgent): # 处理结果 duration_ms = int((time.time() - start_time) * 1000) + # 🔥 如果被取消,返回取消结果 + if self.is_cancelled: + await self.emit_event( + "info", + f"🛑 Verification Agent 已取消: {self._iteration} 轮迭代" + ) + return AgentResult( + success=False, + error="任务已取消", + data={"findings": findings_to_verify}, + iterations=self._iteration, + tool_calls=self._tool_calls, + tokens_used=self._total_tokens, + duration_ms=duration_ms, + ) + # 处理最终结果 verified_findings = [] if final_result and "findings" in final_result: diff --git a/backend/app/services/agent/core/__init__.py b/backend/app/services/agent/core/__init__.py new file mode 100644 index 0000000..a72f0dd --- /dev/null +++ b/backend/app/services/agent/core/__init__.py @@ -0,0 +1,53 @@ +""" +DeepAudit Agent 核心模块 + +包含Agent系统的基础组件: +- state: 增强的Agent状态管理 +- registry: Agent注册表和动态Agent树管理 +- message: Agent间通信机制 +- executor: 动态Agent树执行器 +- persistence: Agent状态持久化 +""" + +from .state import AgentState, AgentStatus +from .registry import AgentRegistry, agent_registry +from .message import AgentMessage, MessageType, MessagePriority, MessageBus, message_bus +from .executor import ( + DynamicAgentExecutor, + SubAgentExecutor, + ExecutionTask, + ExecutionResult, + ExecutionMode, +) +from .persistence import ( + AgentStatePersistence, + CheckpointManager, + agent_persistence, + checkpoint_manager, +) + +__all__ = [ + # State + "AgentState", + "AgentStatus", + # Registry + "AgentRegistry", + "agent_registry", + # Message + "AgentMessage", + "MessageType", + "MessagePriority", + "MessageBus", + "message_bus", + # Executor + "DynamicAgentExecutor", + "SubAgentExecutor", + "ExecutionTask", + "ExecutionResult", + "ExecutionMode", + # Persistence + "AgentStatePersistence", + "CheckpointManager", + "agent_persistence", + "checkpoint_manager", +] diff --git a/backend/app/services/agent/core/executor.py b/backend/app/services/agent/core/executor.py new file mode 100644 index 0000000..d76ec45 --- /dev/null +++ b/backend/app/services/agent/core/executor.py @@ -0,0 +1,491 @@ +""" +动态 Agent 树执行器 + +实现完整的动态 Agent 树执行逻辑: +- 子 Agent 实际执行 +- 并行 Agent 执行 +- 结果汇总 +- 执行状态追踪 +""" + +import asyncio +import logging +import time +from typing import Dict, Any, List, Optional, Callable, Awaitable +from dataclasses import dataclass, field +from enum import Enum +from datetime import datetime, timezone + +from .state import AgentState, AgentStatus +from .registry import agent_registry +from .message import message_bus, MessageType + +logger = logging.getLogger(__name__) + + +class ExecutionMode(str, Enum): + """执行模式""" + SEQUENTIAL = "sequential" # 顺序执行 + PARALLEL = "parallel" # 并行执行 + ADAPTIVE = "adaptive" # 自适应(根据任务类型决定) + + +@dataclass +class ExecutionTask: + """执行任务""" + agent_id: str + agent_type: str + task: str + context: Dict[str, Any] = field(default_factory=dict) + priority: int = 0 # 优先级,数字越大优先级越高 + dependencies: List[str] = field(default_factory=list) # 依赖的其他任务 ID + + # 执行状态 + status: str = "pending" # pending, running, completed, failed + result: Optional[Dict[str, Any]] = None + error: Optional[str] = None + started_at: Optional[datetime] = None + finished_at: Optional[datetime] = None + + +@dataclass +class ExecutionResult: + """执行结果""" + success: bool + total_agents: int = 0 + completed_agents: int = 0 + failed_agents: int = 0 + + # 汇总的发现 + all_findings: List[Dict[str, Any]] = field(default_factory=list) + + # 各 Agent 的结果 + agent_results: Dict[str, Dict[str, Any]] = field(default_factory=dict) + + # 执行统计 + total_duration_ms: int = 0 + total_tokens: int = 0 + total_tool_calls: int = 0 + + # 错误信息 + errors: List[str] = field(default_factory=list) + + +class DynamicAgentExecutor: + """ + 动态 Agent 树执行器 + + 负责: + 1. 管理 Agent 的创建和执行 + 2. 处理并行执行和依赖关系 + 3. 汇总执行结果 + 4. 处理错误和超时 + """ + + def __init__( + self, + llm_service, + tools: Dict[str, Any], + event_emitter=None, + max_parallel: int = 5, + default_timeout: int = 600, + ): + """ + 初始化执行器 + + Args: + llm_service: LLM 服务 + tools: 可用工具 + event_emitter: 事件发射器 + max_parallel: 最大并行 Agent 数 + default_timeout: 默认超时时间(秒) + """ + self.llm_service = llm_service + self.tools = tools + self.event_emitter = event_emitter + self.max_parallel = max_parallel + self.default_timeout = default_timeout + + # 执行状态 + self._tasks: Dict[str, ExecutionTask] = {} + self._running_tasks: Dict[str, asyncio.Task] = {} + self._semaphore = asyncio.Semaphore(max_parallel) + + # 取消标志 + self._cancelled = False + + def cancel(self): + """取消所有执行""" + self._cancelled = True + + # 取消所有运行中的任务 + for task_id, task in self._running_tasks.items(): + if not task.done(): + task.cancel() + logger.info(f"Cancelled task: {task_id}") + + @property + def is_cancelled(self) -> bool: + return self._cancelled + + async def execute_agent( + self, + agent_class, + agent_config: Dict[str, Any], + input_data: Dict[str, Any], + parent_id: Optional[str] = None, + knowledge_modules: Optional[List[str]] = None, + ) -> Dict[str, Any]: + """ + 执行单个 Agent + + Args: + agent_class: Agent 类 + agent_config: Agent 配置 + input_data: 输入数据 + parent_id: 父 Agent ID + knowledge_modules: 知识模块列表 + + Returns: + Agent 执行结果 + """ + if self._cancelled: + return {"success": False, "error": "Execution cancelled"} + + async with self._semaphore: + try: + # 创建 Agent 实例 + agent = agent_class( + llm_service=self.llm_service, + tools=self.tools, + event_emitter=self.event_emitter, + parent_id=parent_id, + knowledge_modules=knowledge_modules, + **agent_config, + ) + + # 执行 Agent + start_time = time.time() + result = await asyncio.wait_for( + agent.run(input_data), + timeout=self.default_timeout, + ) + duration_ms = int((time.time() - start_time) * 1000) + + return { + "success": result.success, + "data": result.data, + "error": result.error, + "agent_id": agent.agent_id, + "iterations": result.iterations, + "tokens_used": result.tokens_used, + "tool_calls": result.tool_calls, + "duration_ms": duration_ms, + "handoff": result.handoff.to_dict() if result.handoff else None, + } + + except asyncio.TimeoutError: + logger.error(f"Agent execution timed out") + return {"success": False, "error": "Execution timed out"} + except asyncio.CancelledError: + logger.info(f"Agent execution cancelled") + return {"success": False, "error": "Execution cancelled"} + except Exception as e: + logger.error(f"Agent execution failed: {e}", exc_info=True) + return {"success": False, "error": str(e)} + + async def execute_parallel( + self, + tasks: List[ExecutionTask], + agent_factory: Callable[[ExecutionTask], Awaitable[Dict[str, Any]]], + ) -> ExecutionResult: + """ + 并行执行多个 Agent 任务 + + Args: + tasks: 任务列表 + agent_factory: Agent 工厂函数,接收任务返回执行结果 + + Returns: + 汇总的执行结果 + """ + if not tasks: + return ExecutionResult(success=True) + + start_time = time.time() + + # 按优先级排序 + sorted_tasks = sorted(tasks, key=lambda t: t.priority, reverse=True) + + # 分离有依赖和无依赖的任务 + independent_tasks = [t for t in sorted_tasks if not t.dependencies] + dependent_tasks = [t for t in sorted_tasks if t.dependencies] + + # 存储任务 + for task in sorted_tasks: + self._tasks[task.agent_id] = task + + result = ExecutionResult( + success=True, + total_agents=len(tasks), + ) + + # 先执行无依赖的任务 + if independent_tasks: + await self._execute_task_batch(independent_tasks, agent_factory, result) + + # 然后执行有依赖的任务 + for task in dependent_tasks: + if self._cancelled: + break + + # 等待依赖完成 + await self._wait_for_dependencies(task) + + # 执行任务 + await self._execute_single_task(task, agent_factory, result) + + # 计算总时长 + result.total_duration_ms = int((time.time() - start_time) * 1000) + + # 判断整体成功状态 + result.success = result.failed_agents == 0 + + return result + + async def _execute_task_batch( + self, + tasks: List[ExecutionTask], + agent_factory: Callable[[ExecutionTask], Awaitable[Dict[str, Any]]], + result: ExecutionResult, + ): + """执行一批任务""" + async_tasks = [] + + for task in tasks: + if self._cancelled: + break + + async_task = asyncio.create_task( + self._execute_single_task(task, agent_factory, result) + ) + self._running_tasks[task.agent_id] = async_task + async_tasks.append(async_task) + + # 等待所有任务完成 + if async_tasks: + await asyncio.gather(*async_tasks, return_exceptions=True) + + async def _execute_single_task( + self, + task: ExecutionTask, + agent_factory: Callable[[ExecutionTask], Awaitable[Dict[str, Any]]], + result: ExecutionResult, + ): + """执行单个任务""" + task.status = "running" + task.started_at = datetime.now(timezone.utc) + + try: + # 调用工厂函数执行 Agent + agent_result = await agent_factory(task) + + task.finished_at = datetime.now(timezone.utc) + task.result = agent_result + + if agent_result.get("success"): + task.status = "completed" + result.completed_agents += 1 + + # 收集发现 + findings = agent_result.get("data", {}).get("findings", []) + result.all_findings.extend(findings) + + # 统计 + result.total_tokens += agent_result.get("tokens_used", 0) + result.total_tool_calls += agent_result.get("tool_calls", 0) + else: + task.status = "failed" + task.error = agent_result.get("error") + result.failed_agents += 1 + result.errors.append(f"{task.agent_id}: {task.error}") + + # 保存结果 + result.agent_results[task.agent_id] = agent_result + + except Exception as e: + task.status = "failed" + task.error = str(e) + task.finished_at = datetime.now(timezone.utc) + result.failed_agents += 1 + result.errors.append(f"{task.agent_id}: {str(e)}") + logger.error(f"Task {task.agent_id} failed: {e}", exc_info=True) + + finally: + # 清理运行中的任务 + self._running_tasks.pop(task.agent_id, None) + + async def _wait_for_dependencies(self, task: ExecutionTask): + """等待任务的依赖完成""" + for dep_id in task.dependencies: + dep_task = self._tasks.get(dep_id) + if not dep_task: + continue + + # 等待依赖任务完成 + while dep_task.status in ["pending", "running"]: + if self._cancelled: + return + await asyncio.sleep(0.1) + + def get_execution_summary(self) -> Dict[str, Any]: + """获取执行摘要""" + return { + "total_tasks": len(self._tasks), + "completed": sum(1 for t in self._tasks.values() if t.status == "completed"), + "failed": sum(1 for t in self._tasks.values() if t.status == "failed"), + "pending": sum(1 for t in self._tasks.values() if t.status == "pending"), + "running": sum(1 for t in self._tasks.values() if t.status == "running"), + "tasks": { + tid: { + "status": t.status, + "agent_type": t.agent_type, + "error": t.error, + } + for tid, t in self._tasks.items() + }, + } + + +class SubAgentExecutor: + """ + 子 Agent 执行器 + + 专门用于从父 Agent 创建和执行子 Agent + """ + + def __init__( + self, + parent_agent, + llm_service, + tools: Dict[str, Any], + event_emitter=None, + ): + self.parent_agent = parent_agent + self.llm_service = llm_service + self.tools = tools + self.event_emitter = event_emitter + + self._child_agents: Dict[str, Any] = {} + self._executor = DynamicAgentExecutor( + llm_service=llm_service, + tools=tools, + event_emitter=event_emitter, + ) + + async def create_and_run_sub_agent( + self, + agent_type: str, + task: str, + context: Dict[str, Any] = None, + knowledge_modules: List[str] = None, + ) -> Dict[str, Any]: + """ + 创建并运行子 Agent + + Args: + agent_type: Agent 类型 (analysis, verification, specialist) + task: 任务描述 + context: 任务上下文 + knowledge_modules: 知识模块 + + Returns: + 子 Agent 执行结果 + """ + from ..agents import AnalysisAgent, VerificationAgent + + # 根据类型选择 Agent 类 + agent_class_map = { + "analysis": AnalysisAgent, + "verification": VerificationAgent, + } + + agent_class = agent_class_map.get(agent_type) + if not agent_class: + return {"success": False, "error": f"Unknown agent type: {agent_type}"} + + # 准备输入数据 + input_data = { + "task": task, + "task_context": context or {}, + "project_info": context.get("project_info", {}) if context else {}, + "config": context.get("config", {}) if context else {}, + } + + # 如果父 Agent 有 handoff,传递给子 Agent + if hasattr(self.parent_agent, "_incoming_handoff") and self.parent_agent._incoming_handoff: + input_data["parent_handoff"] = self.parent_agent._incoming_handoff.to_dict() + + # 执行子 Agent + result = await self._executor.execute_agent( + agent_class=agent_class, + agent_config={}, + input_data=input_data, + parent_id=self.parent_agent.agent_id, + knowledge_modules=knowledge_modules, + ) + + # 记录子 Agent + if result.get("agent_id"): + self._child_agents[result["agent_id"]] = result + + return result + + async def run_parallel_sub_agents( + self, + sub_agent_configs: List[Dict[str, Any]], + ) -> ExecutionResult: + """ + 并行运行多个子 Agent + + Args: + sub_agent_configs: 子 Agent 配置列表 + [{"agent_type": "analysis", "task": "...", "context": {...}, "knowledge_modules": [...]}] + + Returns: + 汇总的执行结果 + """ + tasks = [] + + for i, config in enumerate(sub_agent_configs): + task = ExecutionTask( + agent_id=f"sub_{self.parent_agent.agent_id}_{i}", + agent_type=config.get("agent_type", "analysis"), + task=config.get("task", ""), + context=config.get("context", {}), + priority=config.get("priority", 0), + dependencies=config.get("dependencies", []), + ) + tasks.append(task) + + async def agent_factory(task: ExecutionTask) -> Dict[str, Any]: + return await self.create_and_run_sub_agent( + agent_type=task.agent_type, + task=task.task, + context=task.context, + knowledge_modules=task.context.get("knowledge_modules"), + ) + + return await self._executor.execute_parallel(tasks, agent_factory) + + def get_child_results(self) -> Dict[str, Dict[str, Any]]: + """获取所有子 Agent 的结果""" + return self._child_agents.copy() + + def get_all_findings(self) -> List[Dict[str, Any]]: + """获取所有子 Agent 发现的漏洞""" + findings = [] + for result in self._child_agents.values(): + if result.get("success") and result.get("data"): + findings.extend(result["data"].get("findings", [])) + return findings diff --git a/backend/app/services/agent/core/message.py b/backend/app/services/agent/core/message.py new file mode 100644 index 0000000..8e6d4f5 --- /dev/null +++ b/backend/app/services/agent/core/message.py @@ -0,0 +1,290 @@ +""" +Agent 间通信机制 + +提供: +- 消息类型定义 +- 消息队列管理 +- Agent间消息传递 +""" + +import logging +import uuid +from datetime import datetime, timezone +from enum import Enum +from typing import Any, Dict, List, Optional +from dataclasses import dataclass, field + +logger = logging.getLogger(__name__) + + +class MessageType(str, Enum): + """消息类型""" + QUERY = "query" # 查询消息(请求信息) + INSTRUCTION = "instruction" # 指令消息(要求执行操作) + INFORMATION = "information" # 信息消息(分享发现或状态) + RESULT = "result" # 结果消息(任务完成报告) + ERROR = "error" # 错误消息 + + +class MessagePriority(str, Enum): + """消息优先级""" + LOW = "low" + NORMAL = "normal" + HIGH = "high" + URGENT = "urgent" + + +@dataclass +class AgentMessage: + """ + Agent 消息 + + 用于Agent间通信的消息结构 + """ + id: str = field(default_factory=lambda: f"msg_{uuid.uuid4().hex[:8]}") + from_agent: str = "" + to_agent: str = "" + content: str = "" + message_type: MessageType = MessageType.INFORMATION + priority: MessagePriority = MessagePriority.NORMAL + timestamp: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat()) + + # 状态 + delivered: bool = False + read: bool = False + + # 附加数据 + metadata: Dict[str, Any] = field(default_factory=dict) + + def to_dict(self) -> Dict[str, Any]: + """转换为字典""" + return { + "id": self.id, + "from": self.from_agent, + "to": self.to_agent, + "content": self.content, + "message_type": self.message_type.value if isinstance(self.message_type, MessageType) else self.message_type, + "priority": self.priority.value if isinstance(self.priority, MessagePriority) else self.priority, + "timestamp": self.timestamp, + "delivered": self.delivered, + "read": self.read, + "metadata": self.metadata, + } + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "AgentMessage": + """从字典创建""" + return cls( + id=data.get("id", f"msg_{uuid.uuid4().hex[:8]}"), + from_agent=data.get("from", ""), + to_agent=data.get("to", ""), + content=data.get("content", ""), + message_type=MessageType(data.get("message_type", "information")), + priority=MessagePriority(data.get("priority", "normal")), + timestamp=data.get("timestamp", datetime.now(timezone.utc).isoformat()), + delivered=data.get("delivered", False), + read=data.get("read", False), + metadata=data.get("metadata", {}), + ) + + def to_xml(self) -> str: + """转换为XML格式(用于LLM理解)""" + return f""" + + {self.from_agent} + + + {self.message_type.value if isinstance(self.message_type, MessageType) else self.message_type} + {self.priority.value if isinstance(self.priority, MessagePriority) else self.priority} + {self.timestamp} + + +{self.content} + +""" + + +class MessageBus: + """ + 消息总线 + + 管理Agent间的消息传递 + """ + + def __init__(self): + self._queues: Dict[str, List[AgentMessage]] = {} + self._message_history: List[AgentMessage] = [] + + def create_queue(self, agent_id: str) -> None: + """为Agent创建消息队列""" + if agent_id not in self._queues: + self._queues[agent_id] = [] + logger.debug(f"Created message queue for agent: {agent_id}") + + def delete_queue(self, agent_id: str) -> None: + """删除Agent的消息队列""" + if agent_id in self._queues: + del self._queues[agent_id] + logger.debug(f"Deleted message queue for agent: {agent_id}") + + def send_message( + self, + from_agent: str, + to_agent: str, + content: str, + message_type: MessageType = MessageType.INFORMATION, + priority: MessagePriority = MessagePriority.NORMAL, + metadata: Optional[Dict[str, Any]] = None, + ) -> AgentMessage: + """ + 发送消息 + + Args: + from_agent: 发送者Agent ID + to_agent: 接收者Agent ID + content: 消息内容 + message_type: 消息类型 + priority: 优先级 + metadata: 附加数据 + + Returns: + 发送的消息 + """ + message = AgentMessage( + from_agent=from_agent, + to_agent=to_agent, + content=content, + message_type=message_type, + priority=priority, + metadata=metadata or {}, + ) + + # 确保目标队列存在 + if to_agent not in self._queues: + self.create_queue(to_agent) + + # 添加到队列 + self._queues[to_agent].append(message) + message.delivered = True + + # 记录历史 + self._message_history.append(message) + + logger.debug(f"Message sent from {from_agent} to {to_agent}: {content[:50]}...") + return message + + def get_messages( + self, + agent_id: str, + unread_only: bool = True, + mark_as_read: bool = True, + ) -> List[AgentMessage]: + """ + 获取Agent的消息 + + Args: + agent_id: Agent ID + unread_only: 是否只获取未读消息 + mark_as_read: 是否标记为已读 + + Returns: + 消息列表 + """ + if agent_id not in self._queues: + return [] + + messages = self._queues[agent_id] + + if unread_only: + messages = [m for m in messages if not m.read] + + if mark_as_read: + for m in messages: + m.read = True + + return messages + + def has_unread_messages(self, agent_id: str) -> bool: + """检查是否有未读消息""" + if agent_id not in self._queues: + return False + return any(not m.read for m in self._queues[agent_id]) + + def get_unread_count(self, agent_id: str) -> int: + """获取未读消息数量""" + if agent_id not in self._queues: + return 0 + return sum(1 for m in self._queues[agent_id] if not m.read) + + def send_user_message( + self, + to_agent: str, + content: str, + priority: MessagePriority = MessagePriority.HIGH, + ) -> AgentMessage: + """发送用户消息到Agent""" + return self.send_message( + from_agent="user", + to_agent=to_agent, + content=content, + message_type=MessageType.INSTRUCTION, + priority=priority, + ) + + def send_completion_report( + self, + from_agent: str, + to_agent: str, + summary: str, + findings: List[Dict[str, Any]], + success: bool = True, + ) -> AgentMessage: + """发送任务完成报告""" + content = f""" + {"SUCCESS" if success else "FAILED"} + {summary} + {len(findings)} +""" + + return self.send_message( + from_agent=from_agent, + to_agent=to_agent, + content=content, + message_type=MessageType.RESULT, + priority=MessagePriority.HIGH, + metadata={ + "summary": summary, + "findings": findings, + "success": success, + }, + ) + + def clear_queue(self, agent_id: str) -> None: + """清空Agent的消息队列""" + if agent_id in self._queues: + self._queues[agent_id] = [] + + def clear_all(self) -> None: + """清空所有消息""" + self._queues.clear() + self._message_history.clear() + + def get_message_history( + self, + agent_id: Optional[str] = None, + limit: int = 100, + ) -> List[AgentMessage]: + """获取消息历史""" + history = self._message_history + + if agent_id: + history = [ + m for m in history + if m.from_agent == agent_id or m.to_agent == agent_id + ] + + return history[-limit:] + + +# 全局消息总线实例 +message_bus = MessageBus() diff --git a/backend/app/services/agent/core/persistence.py b/backend/app/services/agent/core/persistence.py new file mode 100644 index 0000000..5e5bd6a --- /dev/null +++ b/backend/app/services/agent/core/persistence.py @@ -0,0 +1,413 @@ +""" +Agent 状态持久化模块 + +提供 Agent 状态的持久化和恢复功能: +- Agent 状态序列化和反序列化 +- 检查点保存和恢复 +- 消息历史持久化 +- 执行记录持久化 +""" + +import json +import logging +import os +from datetime import datetime, timezone +from typing import Dict, Any, List, Optional +from pathlib import Path + +from .state import AgentState, AgentStatus +from .registry import agent_registry + +logger = logging.getLogger(__name__) + + +class AgentStatePersistence: + """ + Agent 状态持久化管理器 + + 支持: + - 文件系统持久化 + - 数据库持久化(可选) + - 检查点机制 + """ + + def __init__( + self, + persist_dir: str = "./agent_checkpoints", + use_database: bool = False, + db_session_factory=None, + ): + """ + 初始化持久化管理器 + + Args: + persist_dir: 持久化目录 + use_database: 是否使用数据库持久化 + db_session_factory: 数据库会话工厂 + """ + self.persist_dir = Path(persist_dir) + self.use_database = use_database + self.db_session_factory = db_session_factory + + # 确保目录存在 + self.persist_dir.mkdir(parents=True, exist_ok=True) + + # ============ 文件系统持久化 ============ + + def save_state(self, state: AgentState, checkpoint_name: Optional[str] = None) -> str: + """ + 保存 Agent 状态到文件 + + Args: + state: Agent 状态 + checkpoint_name: 检查点名称(可选) + + Returns: + 保存的文件路径 + """ + # 生成文件名 + if checkpoint_name: + filename = f"{state.agent_id}_{checkpoint_name}.json" + else: + timestamp = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S") + filename = f"{state.agent_id}_{timestamp}.json" + + filepath = self.persist_dir / filename + + # 序列化状态 + state_dict = self._serialize_state(state) + + # 保存到文件 + with open(filepath, "w", encoding="utf-8") as f: + json.dump(state_dict, f, ensure_ascii=False, indent=2) + + logger.info(f"Saved agent state to {filepath}") + return str(filepath) + + def load_state(self, filepath: str) -> Optional[AgentState]: + """ + 从文件加载 Agent 状态 + + Args: + filepath: 文件路径 + + Returns: + Agent 状态,如果加载失败返回 None + """ + try: + with open(filepath, "r", encoding="utf-8") as f: + state_dict = json.load(f) + + state = self._deserialize_state(state_dict) + logger.info(f"Loaded agent state from {filepath}") + return state + + except Exception as e: + logger.error(f"Failed to load agent state from {filepath}: {e}") + return None + + def load_latest_checkpoint(self, agent_id: str) -> Optional[AgentState]: + """ + 加载指定 Agent 的最新检查点 + + Args: + agent_id: Agent ID + + Returns: + Agent 状态 + """ + # 查找所有匹配的检查点文件 + pattern = f"{agent_id}_*.json" + checkpoints = list(self.persist_dir.glob(pattern)) + + if not checkpoints: + logger.warning(f"No checkpoints found for agent {agent_id}") + return None + + # 按修改时间排序,取最新的 + latest = max(checkpoints, key=lambda p: p.stat().st_mtime) + return self.load_state(str(latest)) + + def list_checkpoints(self, agent_id: Optional[str] = None) -> List[Dict[str, Any]]: + """ + 列出检查点 + + Args: + agent_id: Agent ID(可选,不指定则列出所有) + + Returns: + 检查点信息列表 + """ + if agent_id: + pattern = f"{agent_id}_*.json" + else: + pattern = "*.json" + + checkpoints = [] + for filepath in self.persist_dir.glob(pattern): + stat = filepath.stat() + checkpoints.append({ + "filepath": str(filepath), + "filename": filepath.name, + "size_bytes": stat.st_size, + "created_at": datetime.fromtimestamp(stat.st_ctime, tz=timezone.utc).isoformat(), + "modified_at": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat(), + }) + + # 按修改时间排序 + checkpoints.sort(key=lambda x: x["modified_at"], reverse=True) + return checkpoints + + def delete_checkpoint(self, filepath: str) -> bool: + """ + 删除检查点 + + Args: + filepath: 文件路径 + + Returns: + 是否删除成功 + """ + try: + os.remove(filepath) + logger.info(f"Deleted checkpoint: {filepath}") + return True + except Exception as e: + logger.error(f"Failed to delete checkpoint {filepath}: {e}") + return False + + def cleanup_old_checkpoints( + self, + agent_id: str, + keep_count: int = 5, + ) -> int: + """ + 清理旧的检查点,只保留最新的几个 + + Args: + agent_id: Agent ID + keep_count: 保留的检查点数量 + + Returns: + 删除的检查点数量 + """ + checkpoints = self.list_checkpoints(agent_id) + + if len(checkpoints) <= keep_count: + return 0 + + # 删除旧的检查点 + to_delete = checkpoints[keep_count:] + deleted = 0 + + for cp in to_delete: + if self.delete_checkpoint(cp["filepath"]): + deleted += 1 + + return deleted + + # ============ 序列化/反序列化 ============ + + def _serialize_state(self, state: AgentState) -> Dict[str, Any]: + """序列化 Agent 状态""" + return { + "version": "1.0", + "serialized_at": datetime.now(timezone.utc).isoformat(), + "state": state.model_dump(), + } + + def _deserialize_state(self, data: Dict[str, Any]) -> AgentState: + """反序列化 Agent 状态""" + version = data.get("version", "1.0") + state_data = data.get("state", data) + + # 处理版本兼容性 + if version == "1.0": + return AgentState(**state_data) + else: + logger.warning(f"Unknown state version: {version}, attempting to load anyway") + return AgentState(**state_data) + + # ============ 数据库持久化 ============ + + async def save_state_to_db( + self, + state: AgentState, + task_id: str, + ) -> bool: + """ + 保存 Agent 状态到数据库 + + Args: + state: Agent 状态 + task_id: 关联的任务 ID + + Returns: + 是否保存成功 + """ + if not self.use_database or not self.db_session_factory: + logger.warning("Database persistence not configured") + return False + + try: + async with self.db_session_factory() as session: + from app.models.agent_task import AgentCheckpoint + + checkpoint = AgentCheckpoint( + task_id=task_id, + agent_id=state.agent_id, + agent_name=state.agent_name, + agent_type=state.agent_type, + state_data=state.model_dump_json(), + iteration=state.iteration, + status=state.status, + created_at=datetime.now(timezone.utc), + ) + + session.add(checkpoint) + await session.commit() + + logger.info(f"Saved agent state to database: {state.agent_id}") + return True + + except Exception as e: + logger.error(f"Failed to save agent state to database: {e}") + return False + + async def load_state_from_db( + self, + task_id: str, + agent_id: Optional[str] = None, + ) -> Optional[AgentState]: + """ + 从数据库加载 Agent 状态 + + Args: + task_id: 任务 ID + agent_id: Agent ID(可选) + + Returns: + Agent 状态 + """ + if not self.use_database or not self.db_session_factory: + logger.warning("Database persistence not configured") + return None + + try: + async with self.db_session_factory() as session: + from sqlalchemy import select + from app.models.agent_task import AgentCheckpoint + + query = select(AgentCheckpoint).where( + AgentCheckpoint.task_id == task_id + ) + + if agent_id: + query = query.where(AgentCheckpoint.agent_id == agent_id) + + query = query.order_by(AgentCheckpoint.created_at.desc()).limit(1) + + result = await session.execute(query) + checkpoint = result.scalar_one_or_none() + + if checkpoint: + state_data = json.loads(checkpoint.state_data) + return AgentState(**state_data) + + return None + + except Exception as e: + logger.error(f"Failed to load agent state from database: {e}") + return None + + +class CheckpointManager: + """ + 检查点管理器 + + 提供自动检查点功能: + - 定期保存检查点 + - 错误恢复 + - 状态回滚 + """ + + def __init__( + self, + persistence: AgentStatePersistence, + auto_checkpoint_interval: int = 5, # 每 N 次迭代自动保存 + ): + self.persistence = persistence + self.auto_checkpoint_interval = auto_checkpoint_interval + + self._last_checkpoint_iteration: Dict[str, int] = {} + + def should_checkpoint(self, state: AgentState) -> bool: + """ + 判断是否应该创建检查点 + + Args: + state: Agent 状态 + + Returns: + 是否应该创建检查点 + """ + last_iteration = self._last_checkpoint_iteration.get(state.agent_id, 0) + return state.iteration - last_iteration >= self.auto_checkpoint_interval + + def create_checkpoint( + self, + state: AgentState, + checkpoint_name: Optional[str] = None, + ) -> str: + """ + 创建检查点 + + Args: + state: Agent 状态 + checkpoint_name: 检查点名称 + + Returns: + 检查点文件路径 + """ + filepath = self.persistence.save_state(state, checkpoint_name) + self._last_checkpoint_iteration[state.agent_id] = state.iteration + return filepath + + def auto_checkpoint(self, state: AgentState) -> Optional[str]: + """ + 自动检查点(如果需要) + + Args: + state: Agent 状态 + + Returns: + 检查点文件路径,如果没有创建则返回 None + """ + if self.should_checkpoint(state): + return self.create_checkpoint(state) + return None + + def restore_from_checkpoint( + self, + agent_id: str, + checkpoint_filepath: Optional[str] = None, + ) -> Optional[AgentState]: + """ + 从检查点恢复 + + Args: + agent_id: Agent ID + checkpoint_filepath: 检查点文件路径(可选,不指定则使用最新的) + + Returns: + 恢复的 Agent 状态 + """ + if checkpoint_filepath: + return self.persistence.load_state(checkpoint_filepath) + else: + return self.persistence.load_latest_checkpoint(agent_id) + + +# 全局持久化管理器 +agent_persistence = AgentStatePersistence() +checkpoint_manager = CheckpointManager(agent_persistence) diff --git a/backend/app/services/agent/core/registry.py b/backend/app/services/agent/core/registry.py new file mode 100644 index 0000000..0e5ea38 --- /dev/null +++ b/backend/app/services/agent/core/registry.py @@ -0,0 +1,309 @@ +""" +Agent 注册表和动态Agent树管理 + +提供: +- Agent实例注册和管理 +- 动态Agent树结构 +- Agent状态追踪 +- 子Agent创建和销毁 +""" + +import logging +import threading +from datetime import datetime, timezone +from typing import Any, Dict, List, Optional, TYPE_CHECKING + +if TYPE_CHECKING: + from .state import AgentState + +logger = logging.getLogger(__name__) + + +class AgentRegistry: + """ + Agent 注册表 + + 管理所有Agent实例,维护动态Agent树结构 + """ + + def __init__(self): + self._lock = threading.RLock() + + # Agent图结构 + self._agent_graph: Dict[str, Any] = { + "nodes": {}, # agent_id -> node_info + "edges": [], # {from, to, type} + } + + # Agent实例和状态 + self._agent_instances: Dict[str, Any] = {} # agent_id -> agent_instance + self._agent_states: Dict[str, "AgentState"] = {} # agent_id -> state + + # 消息队列 + self._agent_messages: Dict[str, List[Dict[str, Any]]] = {} # agent_id -> messages + + # 根Agent + self._root_agent_id: Optional[str] = None + + # 运行中的Agent线程 + self._running_agents: Dict[str, threading.Thread] = {} + + # ============ Agent 注册 ============ + + def register_agent( + self, + agent_id: str, + agent_name: str, + agent_type: str, + task: str, + parent_id: Optional[str] = None, + agent_instance: Any = None, + state: Optional["AgentState"] = None, + knowledge_modules: Optional[List[str]] = None, + ) -> Dict[str, Any]: + """ + 注册Agent到注册表 + + Args: + agent_id: Agent唯一标识 + agent_name: Agent名称 + agent_type: Agent类型 + task: 任务描述 + parent_id: 父Agent ID + agent_instance: Agent实例 + state: Agent状态 + knowledge_modules: 加载的知识模块 + + Returns: + 注册的节点信息 + """ + logger.info(f"[AgentRegistry] register_agent 被调用: {agent_name} (id={agent_id}, parent={parent_id})") + logger.info(f"[AgentRegistry] 当前节点数: {len(self._agent_graph['nodes'])}, 节点列表: {list(self._agent_graph['nodes'].keys())}") + + with self._lock: + node = { + "id": agent_id, + "name": agent_name, + "type": agent_type, + "task": task, + "status": "running", + "parent_id": parent_id, + "created_at": datetime.now(timezone.utc).isoformat(), + "finished_at": None, + "result": None, + "knowledge_modules": knowledge_modules or [], + "children": [], + } + + self._agent_graph["nodes"][agent_id] = node + + if agent_instance: + self._agent_instances[agent_id] = agent_instance + + if state: + self._agent_states[agent_id] = state + + # 初始化消息队列 + if agent_id not in self._agent_messages: + self._agent_messages[agent_id] = [] + + # 添加边(父子关系) + if parent_id: + self._agent_graph["edges"].append({ + "from": parent_id, + "to": agent_id, + "type": "delegation", + "created_at": datetime.now(timezone.utc).isoformat(), + }) + + # 更新父节点的children列表 + if parent_id in self._agent_graph["nodes"]: + self._agent_graph["nodes"][parent_id]["children"].append(agent_id) + + # 设置根Agent + if parent_id is None and self._root_agent_id is None: + self._root_agent_id = agent_id + + logger.info(f"[AgentRegistry] 注册完成: {agent_name} ({agent_id}), parent: {parent_id}") + logger.info(f"[AgentRegistry] 注册后节点数: {len(self._agent_graph['nodes'])}, 节点列表: {list(self._agent_graph['nodes'].keys())}") + return node + + def unregister_agent(self, agent_id: str) -> None: + """注销Agent""" + with self._lock: + if agent_id in self._agent_graph["nodes"]: + del self._agent_graph["nodes"][agent_id] + + self._agent_instances.pop(agent_id, None) + self._agent_states.pop(agent_id, None) + self._agent_messages.pop(agent_id, None) + self._running_agents.pop(agent_id, None) + + # 移除相关边 + self._agent_graph["edges"] = [ + e for e in self._agent_graph["edges"] + if e["from"] != agent_id and e["to"] != agent_id + ] + + logger.info(f"Unregistered agent: {agent_id}") + + # ============ Agent 状态更新 ============ + + def update_agent_status( + self, + agent_id: str, + status: str, + result: Optional[Dict[str, Any]] = None, + ) -> None: + """更新Agent状态""" + with self._lock: + if agent_id in self._agent_graph["nodes"]: + node = self._agent_graph["nodes"][agent_id] + node["status"] = status + + if status in ["completed", "failed", "stopped"]: + node["finished_at"] = datetime.now(timezone.utc).isoformat() + + if result: + node["result"] = result + + logger.debug(f"Updated agent {agent_id} status to {status}") + + def get_agent_status(self, agent_id: str) -> Optional[str]: + """获取Agent状态""" + with self._lock: + if agent_id in self._agent_graph["nodes"]: + return self._agent_graph["nodes"][agent_id]["status"] + return None + + # ============ Agent 查询 ============ + + def get_agent(self, agent_id: str) -> Optional[Any]: + """获取Agent实例""" + return self._agent_instances.get(agent_id) + + def get_agent_state(self, agent_id: str) -> Optional["AgentState"]: + """获取Agent状态""" + return self._agent_states.get(agent_id) + + def get_agent_node(self, agent_id: str) -> Optional[Dict[str, Any]]: + """获取Agent节点信息""" + return self._agent_graph["nodes"].get(agent_id) + + def get_root_agent_id(self) -> Optional[str]: + """获取根Agent ID""" + return self._root_agent_id + + def get_children(self, agent_id: str) -> List[str]: + """获取子Agent ID列表""" + with self._lock: + node = self._agent_graph["nodes"].get(agent_id) + if node: + return node.get("children", []) + return [] + + def get_parent(self, agent_id: str) -> Optional[str]: + """获取父Agent ID""" + with self._lock: + node = self._agent_graph["nodes"].get(agent_id) + if node: + return node.get("parent_id") + return None + + # ============ Agent 树操作 ============ + + def get_agent_tree(self) -> Dict[str, Any]: + """获取完整的Agent树结构""" + with self._lock: + return { + "nodes": dict(self._agent_graph["nodes"]), + "edges": list(self._agent_graph["edges"]), + "root_agent_id": self._root_agent_id, + } + + def get_agent_tree_view(self, agent_id: Optional[str] = None) -> str: + """获取Agent树的文本视图""" + with self._lock: + lines = ["=== AGENT TREE ==="] + + root_id = agent_id or self._root_agent_id + if not root_id or root_id not in self._agent_graph["nodes"]: + return "No agents in the tree" + + def _build_tree(aid: str, depth: int = 0) -> None: + node = self._agent_graph["nodes"].get(aid) + if not node: + return + + indent = " " * depth + status_emoji = { + "running": "🔄", + "waiting": "⏳", + "completed": "✅", + "failed": "❌", + "stopped": "🛑", + }.get(node["status"], "❓") + + lines.append(f"{indent}{status_emoji} {node['name']} ({aid})") + lines.append(f"{indent} Task: {node['task'][:50]}...") + lines.append(f"{indent} Status: {node['status']}") + + if node.get("knowledge_modules"): + lines.append(f"{indent} Modules: {', '.join(node['knowledge_modules'])}") + + for child_id in node.get("children", []): + _build_tree(child_id, depth + 1) + + _build_tree(root_id) + return "\n".join(lines) + + def get_statistics(self) -> Dict[str, int]: + """获取统计信息""" + with self._lock: + stats = { + "total": len(self._agent_graph["nodes"]), + "running": 0, + "waiting": 0, + "completed": 0, + "failed": 0, + "stopped": 0, + } + + for node in self._agent_graph["nodes"].values(): + status = node.get("status", "unknown") + if status in stats: + stats[status] += 1 + + return stats + + # ============ 清理 ============ + + def clear(self) -> None: + """清空注册表""" + with self._lock: + self._agent_graph = {"nodes": {}, "edges": []} + self._agent_instances.clear() + self._agent_states.clear() + self._agent_messages.clear() + self._running_agents.clear() + self._root_agent_id = None + logger.info("Agent registry cleared") + + def cleanup_finished_agents(self) -> int: + """清理已完成的Agent""" + with self._lock: + finished_ids = [ + aid for aid, node in self._agent_graph["nodes"].items() + if node["status"] in ["completed", "failed", "stopped"] + ] + + for aid in finished_ids: + # 保留节点信息,但清理实例 + self._agent_instances.pop(aid, None) + self._running_agents.pop(aid, None) + + return len(finished_ids) + + +# 全局注册表实例 +agent_registry = AgentRegistry() diff --git a/backend/app/services/agent/core/state.py b/backend/app/services/agent/core/state.py new file mode 100644 index 0000000..2f1ac40 --- /dev/null +++ b/backend/app/services/agent/core/state.py @@ -0,0 +1,297 @@ +""" +Agent 状态管理模块 + +提供完整的Agent状态管理,支持: +- 完整的生命周期管理 +- 状态序列化和持久化 +- 暂停和恢复 +- 动态Agent树结构 +""" + +import uuid +from datetime import datetime, timezone +from enum import Enum +from typing import Any, Dict, List, Optional +from pydantic import BaseModel, Field + + +def _generate_agent_id() -> str: + """生成唯一的Agent ID""" + return f"agent_{uuid.uuid4().hex[:8]}" + + +class AgentStatus(str, Enum): + """Agent 运行状态""" + CREATED = "created" # 已创建,未开始 + RUNNING = "running" # 运行中 + WAITING = "waiting" # 等待中(等待消息或输入) + PAUSED = "paused" # 已暂停 + COMPLETED = "completed" # 已完成 + FAILED = "failed" # 失败 + STOPPED = "stopped" # 被停止 + STOPPING = "stopping" # 正在停止 + + +class AgentState(BaseModel): + """ + Agent 状态模型 + + 包含Agent执行所需的所有状态信息,支持: + - 完整的生命周期管理 + - 状态序列化和持久化 + - 暂停和恢复 + - 动态Agent树结构 + """ + + # ============ 基本信息 ============ + agent_id: str = Field(default_factory=_generate_agent_id) + agent_name: str = "DeepAudit Agent" + agent_type: str = "generic" # recon, analysis, verification, specialist + parent_id: Optional[str] = None # 父Agent ID(用于动态Agent树) + + # ============ 任务信息 ============ + task: str = "" + task_context: Dict[str, Any] = Field(default_factory=dict) + inherited_context: Dict[str, Any] = Field(default_factory=dict) # 从父Agent继承的上下文 + + # ============ 知识模块 ============ + knowledge_modules: List[str] = Field(default_factory=list) # 加载的知识模块名称 + + # ============ 执行状态 ============ + status: AgentStatus = AgentStatus.CREATED + iteration: int = 0 + max_iterations: int = 50 + + # ============ 对话历史 ============ + messages: List[Dict[str, Any]] = Field(default_factory=list) + system_prompt: str = "" + + # ============ 执行记录 ============ + actions_taken: List[Dict[str, Any]] = Field(default_factory=list) + observations: List[Dict[str, Any]] = Field(default_factory=list) + errors: List[str] = Field(default_factory=list) + + # ============ 发现列表 ============ + findings: List[Dict[str, Any]] = Field(default_factory=list) + + # ============ 时间戳 ============ + created_at: str = Field(default_factory=lambda: datetime.now(timezone.utc).isoformat()) + started_at: Optional[str] = None + last_updated: str = Field(default_factory=lambda: datetime.now(timezone.utc).isoformat()) + finished_at: Optional[str] = None + + # ============ 等待状态 ============ + waiting_for_input: bool = False + waiting_start_time: Optional[datetime] = None + waiting_reason: str = "" + waiting_timeout_seconds: int = 600 # 10分钟超时 + + # ============ 最终结果 ============ + final_result: Optional[Dict[str, Any]] = None + + # ============ 统计信息 ============ + total_tokens: int = 0 + tool_calls: int = 0 + + # ============ 标志位 ============ + stop_requested: bool = False + max_iterations_warning_sent: bool = False + + class Config: + use_enum_values = True + + # ============ 状态管理方法 ============ + + def start(self) -> None: + """开始执行""" + self.status = AgentStatus.RUNNING + self.started_at = datetime.now(timezone.utc).isoformat() + self._update_timestamp() + + def increment_iteration(self) -> None: + """增加迭代次数""" + self.iteration += 1 + self._update_timestamp() + + def set_completed(self, final_result: Optional[Dict[str, Any]] = None) -> None: + """标记为完成""" + self.status = AgentStatus.COMPLETED + self.final_result = final_result + self.finished_at = datetime.now(timezone.utc).isoformat() + self._update_timestamp() + + def set_failed(self, error: str) -> None: + """标记为失败""" + self.status = AgentStatus.FAILED + self.add_error(error) + self.finished_at = datetime.now(timezone.utc).isoformat() + self._update_timestamp() + + def request_stop(self) -> None: + """请求停止""" + self.stop_requested = True + self.status = AgentStatus.STOPPING + self._update_timestamp() + + def set_stopped(self) -> None: + """标记为已停止""" + self.status = AgentStatus.STOPPED + self.finished_at = datetime.now(timezone.utc).isoformat() + self._update_timestamp() + + # ============ 等待状态管理 ============ + + def enter_waiting_state(self, reason: str = "等待消息") -> None: + """进入等待状态""" + self.waiting_for_input = True + self.waiting_start_time = datetime.now(timezone.utc) + self.waiting_reason = reason + self.status = AgentStatus.WAITING + self._update_timestamp() + + def resume_from_waiting(self, new_task: Optional[str] = None) -> None: + """从等待状态恢复""" + self.waiting_for_input = False + self.waiting_start_time = None + self.waiting_reason = "" + self.stop_requested = False + self.status = AgentStatus.RUNNING + if new_task: + self.task = new_task + self._update_timestamp() + + def has_waiting_timeout(self) -> bool: + """检查等待是否超时""" + if not self.waiting_for_input or not self.waiting_start_time: + return False + + if self.stop_requested or self.status in [AgentStatus.COMPLETED, AgentStatus.FAILED]: + return False + + elapsed = (datetime.now(timezone.utc) - self.waiting_start_time).total_seconds() + return elapsed > self.waiting_timeout_seconds + + def is_waiting_for_input(self) -> bool: + """是否在等待输入""" + return self.waiting_for_input + + # ============ 执行控制 ============ + + def should_stop(self) -> bool: + """是否应该停止""" + return ( + self.stop_requested or + self.status in [AgentStatus.COMPLETED, AgentStatus.FAILED, AgentStatus.STOPPED] or + self.has_reached_max_iterations() + ) + + def has_reached_max_iterations(self) -> bool: + """是否达到最大迭代次数""" + return self.iteration >= self.max_iterations + + def is_approaching_max_iterations(self, threshold: float = 0.85) -> bool: + """是否接近最大迭代次数""" + return self.iteration >= int(self.max_iterations * threshold) + + # ============ 消息管理 ============ + + def add_message(self, role: str, content: Any) -> None: + """添加消息""" + self.messages.append({ + "role": role, + "content": content, + "timestamp": datetime.now(timezone.utc).isoformat(), + }) + self._update_timestamp() + + def get_conversation_history(self) -> List[Dict[str, Any]]: + """获取对话历史(不含时间戳,用于LLM调用)""" + return [{"role": m["role"], "content": m["content"]} for m in self.messages] + + # ============ 执行记录 ============ + + def add_action(self, action: Dict[str, Any]) -> None: + """记录执行的动作""" + self.actions_taken.append({ + "iteration": self.iteration, + "timestamp": datetime.now(timezone.utc).isoformat(), + "action": action, + }) + self.tool_calls += 1 + self._update_timestamp() + + def add_observation(self, observation: Dict[str, Any]) -> None: + """记录观察结果""" + self.observations.append({ + "iteration": self.iteration, + "timestamp": datetime.now(timezone.utc).isoformat(), + "observation": observation, + }) + self._update_timestamp() + + def add_error(self, error: str) -> None: + """记录错误""" + self.errors.append(f"Iteration {self.iteration}: {error}") + self._update_timestamp() + + def add_finding(self, finding: Dict[str, Any]) -> None: + """添加发现""" + finding["discovered_at"] = datetime.now(timezone.utc).isoformat() + finding["discovered_by"] = self.agent_id + self.findings.append(finding) + self._update_timestamp() + + # ============ 上下文管理 ============ + + def update_context(self, key: str, value: Any) -> None: + """更新任务上下文""" + self.task_context[key] = value + self._update_timestamp() + + def inherit_context(self, parent_context: Dict[str, Any]) -> None: + """继承父Agent的上下文""" + self.inherited_context = parent_context.copy() + self._update_timestamp() + + # ============ 统计和摘要 ============ + + def add_tokens(self, tokens: int) -> None: + """添加token使用量""" + self.total_tokens += tokens + self._update_timestamp() + + def get_execution_summary(self) -> Dict[str, Any]: + """获取执行摘要""" + return { + "agent_id": self.agent_id, + "agent_name": self.agent_name, + "agent_type": self.agent_type, + "parent_id": self.parent_id, + "task": self.task, + "status": self.status, + "iteration": self.iteration, + "max_iterations": self.max_iterations, + "total_tokens": self.total_tokens, + "tool_calls": self.tool_calls, + "findings_count": len(self.findings), + "errors_count": len(self.errors), + "created_at": self.created_at, + "started_at": self.started_at, + "finished_at": self.finished_at, + "duration_seconds": self._calculate_duration(), + "knowledge_modules": self.knowledge_modules, + } + + def _calculate_duration(self) -> Optional[float]: + """计算执行时长""" + if not self.started_at: + return None + + end_time = self.finished_at or datetime.now(timezone.utc).isoformat() + start = datetime.fromisoformat(self.started_at.replace('Z', '+00:00')) + end = datetime.fromisoformat(end_time.replace('Z', '+00:00')) + return (end - start).total_seconds() + + def _update_timestamp(self) -> None: + """更新最后修改时间""" + self.last_updated = datetime.now(timezone.utc).isoformat() diff --git a/backend/app/services/agent/event_manager.py b/backend/app/services/agent/event_manager.py index d7f5d85..f40b032 100644 --- a/backend/app/services/agent/event_manager.py +++ b/backend/app/services/agent/event_manager.py @@ -354,7 +354,7 @@ class EventManager: """创建或获取事件队列""" if task_id not in self._event_queues: # 🔥 使用较大的队列容量,缓存更多 token 事件 - self._event_queues[task_id] = asyncio.Queue(maxsize=1000) + self._event_queues[task_id] = asyncio.Queue(maxsize=5000) return self._event_queues[task_id] def remove_queue(self, task_id: str): diff --git a/backend/app/services/agent/graph/runner.py b/backend/app/services/agent/graph/runner.py index 563678a..1514e8d 100644 --- a/backend/app/services/agent/graph/runner.py +++ b/backend/app/services/agent/graph/runner.py @@ -193,17 +193,36 @@ class AgentRunner: """初始化工具集""" await self.event_emitter.emit_info("初始化 Agent 工具集...") + # 🔥 导入新工具 + from app.services.agent.tools import ( + ThinkTool, ReflectTool, + CreateVulnerabilityReportTool, + ) + # 🔥 导入知识查询工具 + from app.services.agent.knowledge import ( + SecurityKnowledgeQueryTool, + GetVulnerabilityKnowledgeTool, + ) + + # 🔥 获取排除模式和目标文件 + exclude_patterns = self.task.exclude_patterns or [] + target_files = self.task.target_files or None + # ============ 基础工具(所有 Agent 共享)============ base_tools = { - "read_file": FileReadTool(self.project_root), - "list_files": ListFilesTool(self.project_root), + "read_file": FileReadTool(self.project_root, exclude_patterns, target_files), + "list_files": ListFilesTool(self.project_root, exclude_patterns, target_files), + # 🔥 新增:思考工具(所有Agent可用) + "think": ThinkTool(), } # ============ Recon Agent 专属工具 ============ # 职责:信息收集、项目结构分析、技术栈识别 self.recon_tools = { **base_tools, - "search_code": FileSearchTool(self.project_root), + "search_code": FileSearchTool(self.project_root, exclude_patterns, target_files), + # 🔥 新增:反思工具 + "reflect": ReflectTool(), } # RAG 工具(Recon 用于语义搜索) @@ -214,10 +233,11 @@ class AgentRunner: # 职责:漏洞分析、代码审计、模式匹配 self.analysis_tools = { **base_tools, - "search_code": FileSearchTool(self.project_root), + "search_code": FileSearchTool(self.project_root, exclude_patterns, target_files), # 模式匹配和代码分析 "pattern_match": PatternMatchTool(self.project_root), - "code_analysis": CodeAnalysisTool(self.llm_service), + # TODO: code_analysis 工具暂时禁用,因为 LLM 调用经常失败 + # "code_analysis": CodeAnalysisTool(self.llm_service), "dataflow_analysis": DataFlowAnalysisTool(self.llm_service), # 外部静态分析工具 "semgrep_scan": SemgrepTool(self.project_root), @@ -227,6 +247,11 @@ class AgentRunner: "npm_audit": NpmAuditTool(self.project_root), "safety_scan": SafetyTool(self.project_root), "osv_scan": OSVScannerTool(self.project_root), + # 🔥 新增:反思工具 + "reflect": ReflectTool(), + # 🔥 新增:安全知识查询工具(基于RAG) + "query_security_knowledge": SecurityKnowledgeQueryTool(), + "get_vulnerability_knowledge": GetVulnerabilityKnowledgeTool(), } # RAG 工具(Analysis 用于安全相关代码搜索) @@ -241,6 +266,10 @@ class AgentRunner: # 验证工具 "vulnerability_validation": VulnerabilityValidationTool(self.llm_service), "dataflow_analysis": DataFlowAnalysisTool(self.llm_service), + # 🔥 新增:漏洞报告工具(仅Verification可用) + "create_vulnerability_report": CreateVulnerabilityReportTool(), + # 🔥 新增:反思工具 + "reflect": ReflectTool(), } # 沙箱工具(仅 Verification Agent 可用) diff --git a/backend/app/services/agent/knowledge/__init__.py b/backend/app/services/agent/knowledge/__init__.py new file mode 100644 index 0000000..207d031 --- /dev/null +++ b/backend/app/services/agent/knowledge/__init__.py @@ -0,0 +1,59 @@ +""" +知识模块系统 - 基于RAG的安全知识检索 + +提供专业的安全知识检索能力,支持: +- 漏洞类型知识(SQL注入、XSS、命令注入等) +- 框架安全知识(FastAPI、Django、Flask、Express等) +- 安全最佳实践 +- 修复建议 +- 代码模式识别 + +知识库采用模块化组织: +- vulnerabilities/: 漏洞类型知识 +- frameworks/: 框架安全知识 +""" + +# 基础定义 +from .base import KnowledgeDocument, KnowledgeCategory + +# 知识加载器 +from .loader import ( + KnowledgeLoader, + knowledge_loader, + get_available_modules, + get_module_content, +) + +# RAG知识检索 +from .rag_knowledge import ( + SecurityKnowledgeRAG, + security_knowledge_rag, +) + +# 知识查询工具 +from .tools import ( + SecurityKnowledgeQueryTool, + GetVulnerabilityKnowledgeTool, + ListKnowledgeModulesTool, +) + +__all__ = [ + # 基础定义 + "KnowledgeDocument", + "KnowledgeCategory", + + # 知识加载器 + "KnowledgeLoader", + "knowledge_loader", + "get_available_modules", + "get_module_content", + + # RAG知识检索 + "SecurityKnowledgeRAG", + "security_knowledge_rag", + + # 知识查询工具 + "SecurityKnowledgeQueryTool", + "GetVulnerabilityKnowledgeTool", + "ListKnowledgeModulesTool", +] diff --git a/backend/app/services/agent/knowledge/base.py b/backend/app/services/agent/knowledge/base.py new file mode 100644 index 0000000..7bddb02 --- /dev/null +++ b/backend/app/services/agent/knowledge/base.py @@ -0,0 +1,61 @@ +""" +知识模块基础定义 + +定义知识文档的数据结构和类别 +""" + +from dataclasses import dataclass, field +from enum import Enum +from typing import Any, Dict, List, Optional + + +class KnowledgeCategory(Enum): + """知识类别""" + VULNERABILITY = "vulnerability" # 漏洞类型 + FRAMEWORK = "framework" # 框架安全 + BEST_PRACTICE = "best_practice" # 最佳实践 + REMEDIATION = "remediation" # 修复建议 + CODE_PATTERN = "code_pattern" # 代码模式 + COMPLIANCE = "compliance" # 合规要求 + + +@dataclass +class KnowledgeDocument: + """知识文档""" + id: str + title: str + content: str + category: KnowledgeCategory + tags: List[str] = field(default_factory=list) + severity: Optional[str] = None + cwe_ids: List[str] = field(default_factory=list) + owasp_ids: List[str] = field(default_factory=list) + metadata: Dict[str, Any] = field(default_factory=dict) + + def to_dict(self) -> Dict[str, Any]: + return { + "id": self.id, + "title": self.title, + "content": self.content, + "category": self.category.value, + "tags": self.tags, + "severity": self.severity, + "cwe_ids": self.cwe_ids, + "owasp_ids": self.owasp_ids, + "metadata": self.metadata, + } + + def to_embedding_text(self) -> str: + """生成用于嵌入的文本""" + parts = [ + f"Title: {self.title}", + f"Category: {self.category.value}", + ] + if self.tags: + parts.append(f"Tags: {', '.join(self.tags)}") + if self.cwe_ids: + parts.append(f"CWE: {', '.join(self.cwe_ids)}") + if self.owasp_ids: + parts.append(f"OWASP: {', '.join(self.owasp_ids)}") + parts.append(f"Content: {self.content}") + return "\n".join(parts) diff --git a/backend/app/services/agent/knowledge/frameworks/__init__.py b/backend/app/services/agent/knowledge/frameworks/__init__.py new file mode 100644 index 0000000..63b7586 --- /dev/null +++ b/backend/app/services/agent/knowledge/frameworks/__init__.py @@ -0,0 +1,32 @@ +""" +框架安全知识模块 + +包含各种框架的安全特性和常见漏洞模式 +""" + +from .fastapi import FASTAPI_SECURITY +from .django import DJANGO_SECURITY +from .flask import FLASK_SECURITY +from .express import EXPRESS_SECURITY +from .react import REACT_SECURITY +from .supabase import SUPABASE_SECURITY + +# 所有框架知识文档 +ALL_FRAMEWORK_DOCS = [ + FASTAPI_SECURITY, + DJANGO_SECURITY, + FLASK_SECURITY, + EXPRESS_SECURITY, + REACT_SECURITY, + SUPABASE_SECURITY, +] + +__all__ = [ + "ALL_FRAMEWORK_DOCS", + "FASTAPI_SECURITY", + "DJANGO_SECURITY", + "FLASK_SECURITY", + "EXPRESS_SECURITY", + "REACT_SECURITY", + "SUPABASE_SECURITY", +] diff --git a/backend/app/services/agent/knowledge/frameworks/django.py b/backend/app/services/agent/knowledge/frameworks/django.py new file mode 100644 index 0000000..3a09718 --- /dev/null +++ b/backend/app/services/agent/knowledge/frameworks/django.py @@ -0,0 +1,117 @@ +""" +Django 框架安全知识 +""" + +from ..base import KnowledgeDocument, KnowledgeCategory + + +DJANGO_SECURITY = KnowledgeDocument( + id="framework_django", + title="Django Security", + category=KnowledgeCategory.FRAMEWORK, + tags=["django", "python", "web", "orm"], + content=""" +Django 内置了许多安全保护,但不当使用仍可能引入漏洞。 + +## 内置安全特性 +1. CSRF保护 +2. XSS防护(模板自动转义) +3. SQL注入防护(ORM) +4. 点击劫持防护 +5. 安全的密码哈希 + +## 常见漏洞模式 + +### SQL注入 +```python +# 危险 - raw()和extra() +User.objects.raw(f"SELECT * FROM users WHERE name = '{name}'") +User.objects.extra(where=[f"name = '{name}'"]) + +# 危险 - RawSQL +from django.db.models.expressions import RawSQL +User.objects.annotate(val=RawSQL(f"SELECT {user_input}")) + +# 安全 - 使用ORM +User.objects.filter(name=name) +User.objects.raw("SELECT * FROM users WHERE name = %s", [name]) +``` + +### XSS +```python +# 危险 - 禁用自动转义 +{{ user_input|safe }} +{% autoescape off %}{{ user_input }}{% endautoescape %} +mark_safe(user_input) + +# 安全 - 默认转义 +{{ user_input }} +``` + +### CSRF绕过 +```python +# 危险 - 禁用CSRF +@csrf_exempt +def my_view(request): + pass + +# 危险 - 全局禁用 +MIDDLEWARE = [ + # 'django.middleware.csrf.CsrfViewMiddleware', # 被注释 +] +``` + +### 不安全的反序列化 +```python +# 危险 - 签名数据可被篡改 +from django.core import signing +data = signing.loads(user_input) # 如果SECRET_KEY泄露 + +# 危险 - pickle +import pickle +data = pickle.loads(request.body) +``` + +### 敏感信息泄露 +```python +# 危险 - DEBUG模式在生产环境 +DEBUG = True # settings.py + +# 危险 - 详细错误信息 +ALLOWED_HOSTS = [] # 空列表在DEBUG=False时会报错 +``` + +### 文件上传 +```python +# 危险 - 不验证文件类型 +def upload(request): + file = request.FILES['file'] + with open(f'/uploads/{file.name}', 'wb') as f: + f.write(file.read()) + +# 安全 - 验证和重命名 +import uuid +def upload(request): + file = request.FILES['file'] + ext = os.path.splitext(file.name)[1].lower() + if ext not in ['.jpg', '.png', '.pdf']: + raise ValidationError("Invalid file type") + safe_name = f"{uuid.uuid4()}{ext}" + # 使用Django的文件存储 + default_storage.save(safe_name, file) +``` + +## 安全配置检查 +```python +# settings.py 安全配置 +DEBUG = False +SECRET_KEY = os.environ.get('DJANGO_SECRET_KEY') +ALLOWED_HOSTS = ['example.com'] +SECURE_SSL_REDIRECT = True +SESSION_COOKIE_SECURE = True +CSRF_COOKIE_SECURE = True +SECURE_HSTS_SECONDS = 31536000 +X_FRAME_OPTIONS = 'DENY' +``` +""", +) diff --git a/backend/app/services/agent/knowledge/frameworks/express.py b/backend/app/services/agent/knowledge/frameworks/express.py new file mode 100644 index 0000000..b55cf76 --- /dev/null +++ b/backend/app/services/agent/knowledge/frameworks/express.py @@ -0,0 +1,148 @@ +""" +Express.js 框架安全知识 +""" + +from ..base import KnowledgeDocument, KnowledgeCategory + + +EXPRESS_SECURITY = KnowledgeDocument( + id="framework_express", + title="Express.js Security", + category=KnowledgeCategory.FRAMEWORK, + tags=["express", "nodejs", "javascript", "api"], + content=""" +Express.js 是Node.js最流行的Web框架,需要注意多种安全问题。 + +## 常见漏洞模式 + +### NoSQL注入 +```javascript +// 危险 - MongoDB查询注入 +app.post('/login', async (req, res) => { + const user = await User.findOne({ + username: req.body.username, + password: req.body.password + }); + // 攻击: {"username": {"$ne": ""}, "password": {"$ne": ""}} +}); + +// 安全 - 类型验证 +app.post('/login', async (req, res) => { + const { username, password } = req.body; + if (typeof username !== 'string' || typeof password !== 'string') { + return res.status(400).json({ error: 'Invalid input' }); + } + const user = await User.findOne({ username, password }); +}); +``` + +### 原型污染 +```javascript +// 危险 - 合并用户输入 +const merge = require('lodash.merge'); +app.post('/config', (req, res) => { + merge(config, req.body); + // 攻击: {"__proto__": {"isAdmin": true}} +}); + +// 安全 - 使用Object.assign或白名单 +app.post('/config', (req, res) => { + const allowed = ['theme', 'language']; + allowed.forEach(key => { + if (req.body[key]) config[key] = req.body[key]; + }); +}); +``` + +### 命令注入 +```javascript +// 危险 +const { exec } = require('child_process'); +app.get('/ping', (req, res) => { + exec(`ping ${req.query.host}`, (err, stdout) => { + res.send(stdout); + }); +}); + +// 安全 - 使用execFile和参数数组 +const { execFile } = require('child_process'); +app.get('/ping', (req, res) => { + execFile('ping', ['-c', '4', req.query.host], (err, stdout) => { + res.send(stdout); + }); +}); +``` + +### XSS +```javascript +// 危险 - 直接输出用户输入 +app.get('/search', (req, res) => { + res.send(`

Results for: ${req.query.q}

`); +}); + +// 安全 - 使用模板引擎或转义 +const escape = require('escape-html'); +app.get('/search', (req, res) => { + res.send(`

Results for: ${escape(req.query.q)}

`); +}); +``` + +### 路径遍历 +```javascript +// 危险 +app.get('/files/:name', (req, res) => { + res.sendFile(`/uploads/${req.params.name}`); +}); + +// 安全 - 验证路径 +const path = require('path'); +app.get('/files/:name', (req, res) => { + const safePath = path.join('/uploads', req.params.name); + if (!safePath.startsWith('/uploads/')) { + return res.status(400).send('Invalid path'); + } + res.sendFile(safePath); +}); +``` + +### 不安全的依赖 +```javascript +// 危险 - 使用有漏洞的包 +const serialize = require('node-serialize'); +const obj = serialize.unserialize(userInput); // RCE! + +// 安全 - 使用JSON +const obj = JSON.parse(userInput); +``` + +## 安全中间件 +```javascript +const helmet = require('helmet'); +const rateLimit = require('express-rate-limit'); + +// 安全头 +app.use(helmet()); + +// 速率限制 +app.use(rateLimit({ + windowMs: 15 * 60 * 1000, + max: 100 +})); + +// CORS +const cors = require('cors'); +app.use(cors({ + origin: 'https://example.com', + credentials: true +})); +``` + +## 安全检查清单 +1. 使用helmet设置安全头 +2. 实现速率限制 +3. 验证所有用户输入类型 +4. 使用参数化查询 +5. 定期更新依赖 (npm audit) +6. 不要在错误中暴露堆栈信息 +""", +) diff --git a/backend/app/services/agent/knowledge/frameworks/fastapi.py b/backend/app/services/agent/knowledge/frameworks/fastapi.py new file mode 100644 index 0000000..2aac577 --- /dev/null +++ b/backend/app/services/agent/knowledge/frameworks/fastapi.py @@ -0,0 +1,109 @@ +""" +FastAPI 框架安全知识 +""" + +from ..base import KnowledgeDocument, KnowledgeCategory + + +FASTAPI_SECURITY = KnowledgeDocument( + id="framework_fastapi", + title="FastAPI Security", + category=KnowledgeCategory.FRAMEWORK, + tags=["fastapi", "python", "api", "async", "pydantic"], + content=""" +FastAPI 是一个现代Python Web框架,内置了许多安全特性,但仍需注意一些常见问题。 + +## 安全特性 +1. Pydantic自动数据验证 +2. 自动生成OpenAPI文档 +3. 内置OAuth2/JWT支持 +4. 依赖注入系统 + +## 常见漏洞模式 + +### SQL注入 +```python +# 危险 - 原始SQL +@app.get("/users") +async def get_users(name: str): + query = f"SELECT * FROM users WHERE name = '{name}'" + return await database.fetch_all(query) + +# 安全 - 参数化查询 +@app.get("/users") +async def get_users(name: str): + query = "SELECT * FROM users WHERE name = :name" + return await database.fetch_all(query, {"name": name}) +``` + +### IDOR +```python +# 危险 - 无权限检查 +@app.get("/users/{user_id}") +async def get_user(user_id: int): + return await User.get(user_id) + +# 安全 - 验证权限 +@app.get("/users/{user_id}") +async def get_user(user_id: int, current_user: User = Depends(get_current_user)): + if user_id != current_user.id and not current_user.is_admin: + raise HTTPException(status_code=403) + return await User.get(user_id) +``` + +### 路径遍历 +```python +# 危险 +@app.get("/files/{filename}") +async def get_file(filename: str): + return FileResponse(f"/uploads/{filename}") + +# 安全 - 验证路径 +@app.get("/files/{filename}") +async def get_file(filename: str): + safe_path = Path("/uploads").resolve() / filename + if not str(safe_path.resolve()).startswith(str(Path("/uploads").resolve())): + raise HTTPException(status_code=400) + return FileResponse(safe_path) +``` + +### JWT配置问题 +```python +# 危险 - 弱密钥 +SECRET_KEY = "secret" + +# 危险 - 不验证签名 +jwt.decode(token, options={"verify_signature": False}) + +# 安全 +SECRET_KEY = os.environ.get("JWT_SECRET_KEY") +jwt.decode(token, SECRET_KEY, algorithms=["HS256"]) +``` + +### CORS配置 +```python +# 危险 - 允许所有来源 +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, # 危险组合! +) + +# 安全 - 指定来源 +app.add_middleware( + CORSMiddleware, + allow_origins=["https://example.com"], + allow_credentials=True, +) +``` + +## 安全检查清单 +1. 所有端点是否有适当的认证 +2. 是否使用Depends进行权限检查 +3. 文件操作是否验证路径 +4. SQL查询是否参数化 +5. CORS配置是否合理 +6. JWT密钥是否安全存储 +7. 敏感数据是否在响应中暴露 +""", +) diff --git a/backend/app/services/agent/knowledge/frameworks/flask.py b/backend/app/services/agent/knowledge/frameworks/flask.py new file mode 100644 index 0000000..e96a511 --- /dev/null +++ b/backend/app/services/agent/knowledge/frameworks/flask.py @@ -0,0 +1,139 @@ +""" +Flask 框架安全知识 +""" + +from ..base import KnowledgeDocument, KnowledgeCategory + + +FLASK_SECURITY = KnowledgeDocument( + id="framework_flask", + title="Flask Security", + category=KnowledgeCategory.FRAMEWORK, + tags=["flask", "python", "web", "jinja2"], + content=""" +Flask 是一个轻量级框架,安全性很大程度上取决于开发者的实现。 + +## 常见漏洞模式 + +### 模板注入 (SSTI) +```python +# 危险 - 用户输入作为模板 +from flask import render_template_string +@app.route('/hello') +def hello(): + name = request.args.get('name') + return render_template_string(f'Hello {name}!') + # 攻击: ?name={{config}} + +# 安全 - 使用参数 +@app.route('/hello') +def hello(): + name = request.args.get('name') + return render_template_string('Hello {{ name }}!', name=name) +``` + +### XSS +```python +# 危险 - 禁用转义 +from markupsafe import Markup +return Markup(user_input) + +# 模板中 +{{ user_input|safe }} + +# 安全 - 默认转义 +return render_template('page.html', content=user_input) +``` + +### SQL注入 +```python +# 危险 - 字符串拼接 +@app.route('/user/') +def get_user(name): + cursor.execute(f"SELECT * FROM users WHERE name = '{name}'") + +# 安全 - 参数化 +@app.route('/user/') +def get_user(name): + cursor.execute("SELECT * FROM users WHERE name = ?", (name,)) +``` + +### 会话安全 +```python +# 危险 - 弱密钥 +app.secret_key = 'dev' + +# 危险 - 硬编码密钥 +app.secret_key = 'super-secret-key-12345' + +# 安全 +app.secret_key = os.environ.get('FLASK_SECRET_KEY') +``` + +### 文件上传 +```python +# 危险 - 不验证文件 +@app.route('/upload', methods=['POST']) +def upload(): + file = request.files['file'] + file.save(f'/uploads/{file.filename}') + +# 安全 - 验证和安全文件名 +from werkzeug.utils import secure_filename +ALLOWED_EXTENSIONS = {'png', 'jpg', 'pdf'} + +def allowed_file(filename): + return '.' in filename and \\ + filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS + +@app.route('/upload', methods=['POST']) +def upload(): + file = request.files['file'] + if file and allowed_file(file.filename): + filename = secure_filename(file.filename) + file.save(os.path.join(app.config['UPLOAD_FOLDER'], filename)) +``` + +### 开放重定向 +```python +# 危险 - 未验证的重定向 +@app.route('/redirect') +def redirect_url(): + url = request.args.get('url') + return redirect(url) + +# 安全 - 验证URL +from urllib.parse import urlparse + +@app.route('/redirect') +def redirect_url(): + url = request.args.get('url', '/') + # 只允许相对路径或同域名 + parsed = urlparse(url) + if parsed.netloc and parsed.netloc != request.host: + return redirect('/') + return redirect(url) +``` + +### Debug模式 +```python +# 危险 - 生产环境开启debug +if __name__ == '__main__': + app.run(debug=True) # 可能导致RCE + +# 安全 +if __name__ == '__main__': + app.run(debug=os.environ.get('FLASK_DEBUG', 'False') == 'True') +``` + +## 安全配置 +```python +app.config.update( + SECRET_KEY=os.environ.get('SECRET_KEY'), + SESSION_COOKIE_SECURE=True, + SESSION_COOKIE_HTTPONLY=True, + SESSION_COOKIE_SAMESITE='Lax', +) +``` +""", +) diff --git a/backend/app/services/agent/knowledge/frameworks/react.py b/backend/app/services/agent/knowledge/frameworks/react.py new file mode 100644 index 0000000..403b5b6 --- /dev/null +++ b/backend/app/services/agent/knowledge/frameworks/react.py @@ -0,0 +1,137 @@ +""" +React 框架安全知识 +""" + +from ..base import KnowledgeDocument, KnowledgeCategory + + +REACT_SECURITY = KnowledgeDocument( + id="framework_react", + title="React Security", + category=KnowledgeCategory.FRAMEWORK, + tags=["react", "javascript", "frontend", "jsx"], + content=""" +React 默认对XSS有较好的防护,但仍有一些需要注意的安全问题。 + +## 安全特性 +1. JSX自动转义 +2. 虚拟DOM隔离 + +## 常见漏洞模式 + +### dangerouslySetInnerHTML +```jsx +// 危险 - 直接渲染HTML +function Comment({ content }) { + return
; +} + +// 安全 - 使用DOMPurify +import DOMPurify from 'dompurify'; +function Comment({ content }) { + return
; +} +``` + +### href/src注入 +```jsx +// 危险 - javascript:协议 +function Link({ url }) { + return Click; + // 攻击: url = "javascript:alert('XSS')" +} + +// 安全 - 验证协议 +function Link({ url }) { + const safeUrl = url.startsWith('http') ? url : '#'; + return Click; +} +``` + +### eval和Function +```jsx +// 危险 +function Calculator({ expression }) { + const result = eval(expression); // RCE风险 + return
{result}
; +} + +// 安全 - 使用安全的表达式解析器 +import { evaluate } from 'mathjs'; +function Calculator({ expression }) { + const result = evaluate(expression); + return
{result}
; +} +``` + +### 服务端渲染(SSR) XSS +```jsx +// 危险 - Next.js中 +export async function getServerSideProps({ query }) { + return { + props: { + search: query.q // 未转义 + } + }; +} + +// 页面中 +function Page({ search }) { + return + + +javascript:alert('XSS') + +``` + +## 安全实践 +1. 输出编码/HTML转义 +2. 使用模板引擎的自动转义 +3. Content-Type设置正确 +4. 使用CSP头 + +## 修复示例 +```python +# 安全 - 使用escape +from markupsafe import escape +return f"

搜索结果: {escape(query)}

" + +# 安全 - 使用模板(自动转义) +return render_template('search.html', query=query) +``` +""", +) + + +XSS_STORED = KnowledgeDocument( + id="vuln_xss_stored", + title="Stored XSS", + category=KnowledgeCategory.VULNERABILITY, + tags=["xss", "stored", "persistent", "javascript", "database"], + severity="high", + cwe_ids=["CWE-79"], + owasp_ids=["A03:2021"], + content=""" +存储型XSS:恶意脚本被存储在服务器(数据库、文件等),当其他用户访问时执行。 + +## 危险场景 +- 用户评论/留言板 +- 用户个人资料 +- 论坛帖子 +- 文件名/描述 +- 日志查看器 + +## 危险模式 +```python +# 危险 - 存储未过滤的用户输入 +comment = request.form['comment'] +db.save_comment(comment) # 存储 + +# 危险 - 显示未转义的内容 +comments = db.get_comments() +return render_template_string(f"
{comments}
") +``` + +## 检测要点 +1. 追踪用户输入到数据库的流程 +2. 检查从数据库读取后的输出处理 +3. 关注富文本编辑器的处理 +4. 检查管理后台的数据展示 + +## 安全实践 +1. 输入时过滤/存储时转义 +2. 输出时始终转义 +3. 使用白名单HTML标签(如需富文本) +4. 使用DOMPurify等库清理HTML + +## 修复示例 +```python +# 安全 - 使用bleach清理HTML +import bleach +clean_comment = bleach.clean(comment, tags=['p', 'b', 'i']) +db.save_comment(clean_comment) +``` +""", +) + + +XSS_DOM = KnowledgeDocument( + id="vuln_xss_dom", + title="DOM-based XSS", + category=KnowledgeCategory.VULNERABILITY, + tags=["xss", "dom", "javascript", "client-side"], + severity="high", + cwe_ids=["CWE-79"], + owasp_ids=["A03:2021"], + content=""" +DOM型XSS:漏洞存在于客户端JavaScript代码,通过修改DOM环境执行恶意脚本。 + +## 危险源 (Sources) +```javascript +// URL相关 +location.href +location.search +location.hash +document.URL +document.referrer + +// 存储相关 +localStorage.getItem() +sessionStorage.getItem() + +// 消息相关 +window.postMessage +``` + +## 危险汇 (Sinks) +```javascript +// 危险 - HTML注入 +element.innerHTML = userInput; +element.outerHTML = userInput; +document.write(userInput); +document.writeln(userInput); + +// 危险 - JavaScript执行 +eval(userInput); +setTimeout(userInput, 1000); +setInterval(userInput, 1000); +new Function(userInput); + +// 危险 - URL跳转 +location.href = userInput; +location.assign(userInput); +window.open(userInput); +``` + +## 危险模式 +```javascript +// 危险 - 从URL获取并直接使用 +const name = new URLSearchParams(location.search).get('name'); +document.getElementById('greeting').innerHTML = 'Hello ' + name; + +// 危险 - hash注入 +const hash = location.hash.substring(1); +document.getElementById('content').innerHTML = decodeURIComponent(hash); +``` + +## 安全实践 +1. 使用textContent代替innerHTML +2. 使用安全的DOM API +3. 对URL参数进行验证 +4. 使用DOMPurify清理HTML + +## 修复示例 +```javascript +// 安全 - 使用textContent +element.textContent = userInput; + +// 安全 - 使用DOMPurify +element.innerHTML = DOMPurify.sanitize(userInput); + +// 安全 - 创建文本节点 +element.appendChild(document.createTextNode(userInput)); +``` +""", +) diff --git a/backend/app/services/agent/knowledge/vulnerabilities/xxe.py b/backend/app/services/agent/knowledge/vulnerabilities/xxe.py new file mode 100644 index 0000000..1566686 --- /dev/null +++ b/backend/app/services/agent/knowledge/vulnerabilities/xxe.py @@ -0,0 +1,129 @@ +""" +XXE (XML外部实体注入) 漏洞知识 +""" + +from ..base import KnowledgeDocument, KnowledgeCategory + + +XXE = KnowledgeDocument( + id="vuln_xxe", + title="XML External Entity (XXE) Injection", + category=KnowledgeCategory.VULNERABILITY, + tags=["xxe", "xml", "entity", "injection", "ssrf"], + severity="high", + cwe_ids=["CWE-611"], + owasp_ids=["A05:2021"], + content=""" +XXE允许攻击者通过XML外部实体读取服务器文件、执行SSRF攻击或导致拒绝服务。 + +## 危险模式 + +### Python +```python +# 危险 - lxml默认配置 +from lxml import etree +doc = etree.parse(user_xml) +doc = etree.fromstring(user_xml) + +# 危险 - xml.etree (Python < 3.7.1) +import xml.etree.ElementTree as ET +ET.parse(user_xml) + +# 危险 - xml.dom +from xml.dom import minidom +minidom.parseString(user_xml) +``` + +### Java +```java +// 危险 - DocumentBuilder默认配置 +DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance(); +DocumentBuilder db = dbf.newDocumentBuilder(); +Document doc = db.parse(userInput); + +// 危险 - SAXParser +SAXParserFactory spf = SAXParserFactory.newInstance(); +SAXParser parser = spf.newSAXParser(); +parser.parse(userInput, handler); +``` + +### PHP +```php +// 危险 +$doc = simplexml_load_string($xml); +$doc = new DOMDocument(); +$doc->loadXML($xml); +``` + +## 攻击载荷 + +### 文件读取 +```xml + + +]> +&xxe; +``` + +### SSRF +```xml + + +]> +&xxe; +``` + +### 拒绝服务 (Billion Laughs) +```xml + + + + +]> +&lol3; +``` + +## 检测要点 +1. 所有XML解析代码 +2. 是否禁用了外部实体 +3. 是否禁用了DTD处理 +4. 用户输入是否直接解析 + +## 安全实践 +1. 禁用外部实体 +2. 禁用DTD处理 +3. 使用JSON代替XML +4. 输入验证 + +## 修复示例 + +### Python +```python +# 安全 - lxml禁用实体 +from lxml import etree +parser = etree.XMLParser( + resolve_entities=False, + no_network=True, + dtd_validation=False, + load_dtd=False +) +doc = etree.parse(user_xml, parser) + +# 安全 - defusedxml +import defusedxml.ElementTree as ET +doc = ET.parse(user_xml) +``` + +### Java +```java +// 安全 - 禁用外部实体 +DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance(); +dbf.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true); +dbf.setFeature("http://xml.org/sax/features/external-general-entities", false); +dbf.setFeature("http://xml.org/sax/features/external-parameter-entities", false); +``` +""", +) diff --git a/backend/app/services/agent/prompts/system_prompts.py b/backend/app/services/agent/prompts/system_prompts.py index f44321b..2f02161 100644 --- a/backend/app/services/agent/prompts/system_prompts.py +++ b/backend/app/services/agent/prompts/system_prompts.py @@ -34,7 +34,7 @@ ORCHESTRATOR_SYSTEM_PROMPT = """你是一个专业的代码安全审计 Agent, ## 分析方法 1. **快速扫描**: 首先使用 pattern_match 快速发现可疑代码 2. **语义搜索**: 使用 rag_query 查找相关上下文 -3. **深度分析**: 对可疑代码使用 code_analysis 深入分析 +3. **深度分析**: 对可疑代码使用 read_file 读取并分析 4. **数据流追踪**: 追踪用户输入到危险函数的路径 5. **漏洞验证**: 在沙箱中验证发现的漏洞 @@ -67,7 +67,6 @@ ANALYSIS_SYSTEM_PROMPT = """你是一个专注于代码漏洞分析的安全专 ## 可用工具 - rag_query: 语义搜索相关代码 - pattern_match: 快速模式匹配 -- code_analysis: LLM 深度分析 - read_file: 读取文件内容 - search_code: 关键字搜索 - dataflow_analysis: 数据流分析 diff --git a/backend/app/services/agent/tools/__init__.py b/backend/app/services/agent/tools/__init__.py index 1ebf28f..444172f 100644 --- a/backend/app/services/agent/tools/__init__.py +++ b/backend/app/services/agent/tools/__init__.py @@ -1,7 +1,12 @@ """ Agent 工具集 -提供 LangChain Agent 使用的各种工具 -包括内置工具和外部安全工具 + +提供 Agent 使用的各种工具,包括: +- 基础工具(文件操作、代码搜索) +- 分析工具(模式匹配、数据流分析) +- 外部安全工具(Semgrep、Bandit等) +- 协作工具(Think、Agent通信) +- 报告工具(漏洞报告) """ from .base import AgentTool, ToolResult @@ -22,6 +27,23 @@ from .external_tools import ( OSVScannerTool, ) +# 🔥 新增:思考和推理工具 +from .thinking_tool import ThinkTool, ReflectTool + +# 🔥 新增:漏洞报告工具 +from .reporting_tool import CreateVulnerabilityReportTool + +# 🔥 新增:Agent协作工具 +from .agent_tools import ( + CreateSubAgentTool, + SendMessageTool, + ViewAgentGraphTool, + WaitForMessageTool, + AgentFinishTool, + RunSubAgentsTool, + CollectSubAgentResultsTool, +) + __all__ = [ # 基础 "AgentTool", @@ -57,5 +79,21 @@ __all__ = [ "SafetyTool", "TruffleHogTool", "OSVScannerTool", + + # 🔥 思考和推理工具 + "ThinkTool", + "ReflectTool", + + # 🔥 漏洞报告工具 + "CreateVulnerabilityReportTool", + + # 🔥 Agent协作工具 + "CreateSubAgentTool", + "SendMessageTool", + "ViewAgentGraphTool", + "WaitForMessageTool", + "AgentFinishTool", + "RunSubAgentsTool", + "CollectSubAgentResultsTool", ] diff --git a/backend/app/services/agent/tools/agent_tools.py b/backend/app/services/agent/tools/agent_tools.py new file mode 100644 index 0000000..a9a21c3 --- /dev/null +++ b/backend/app/services/agent/tools/agent_tools.py @@ -0,0 +1,785 @@ +""" +Agent 协作工具 + +提供动态Agent创建、通信和管理功能 +""" + +import logging +from typing import Optional, List, Dict, Any +from pydantic import BaseModel, Field + +from .base import AgentTool, ToolResult +from ..core.registry import agent_registry +from ..core.message import message_bus, MessageType, MessagePriority + +logger = logging.getLogger(__name__) + + +class CreateAgentInput(BaseModel): + """创建Agent输入参数""" + name: str = Field(..., description="Agent名称") + task: str = Field(..., description="任务描述") + agent_type: str = Field( + default="specialist", + description="Agent类型: analysis(分析), verification(验证), specialist(专家)" + ) + knowledge_modules: Optional[str] = Field( + default=None, + description="知识模块,逗号分隔,最多5个。如: sql_injection,xss,authentication" + ) + inherit_context: bool = Field( + default=True, + description="是否继承父Agent的上下文" + ) + execute_immediately: bool = Field( + default=False, + description="是否立即执行子Agent(否则只创建不执行)" + ) + context: Optional[Dict[str, Any]] = Field( + default=None, + description="传递给子Agent的上下文数据" + ) + + +class CreateSubAgentTool(AgentTool): + """ + 创建子Agent工具 + + 允许Agent动态创建专业化的子Agent来处理特定任务。 + 子Agent可以加载特定的知识模块,专注于特定领域。 + + 支持两种模式: + 1. 仅创建:创建Agent但不执行,后续可以批量执行 + 2. 立即执行:创建并立即执行Agent,等待结果返回 + """ + + def __init__( + self, + parent_agent_id: str, + llm_service=None, + tools: Dict[str, Any] = None, + event_emitter=None, + ): + super().__init__() + self.parent_agent_id = parent_agent_id + self.llm_service = llm_service + self.tools = tools or {} + self.event_emitter = event_emitter + + # 子Agent执行器(延迟初始化) + self._sub_executor = None + + def _get_executor(self): + """获取子Agent执行器""" + if self._sub_executor is None and self.llm_service: + from ..core.executor import SubAgentExecutor + # 需要获取父Agent实例 + parent_agent = agent_registry.get_agent(self.parent_agent_id) + if parent_agent: + self._sub_executor = SubAgentExecutor( + parent_agent=parent_agent, + llm_service=self.llm_service, + tools=self.tools, + event_emitter=self.event_emitter, + ) + return self._sub_executor + + @property + def name(self) -> str: + return "create_sub_agent" + + @property + def description(self) -> str: + return """创建专业化的子Agent来处理特定任务。 + +使用场景: +1. 发现需要深入分析的特定漏洞类型 +2. 需要专业知识来验证某个发现 +3. 任务过于复杂需要分解 + +参数: +- name: Agent名称(如 "SQL注入专家") +- task: 具体任务描述 +- agent_type: Agent类型 (analysis/verification/specialist) +- knowledge_modules: 知识模块,逗号分隔(如 "sql_injection,database_security") +- inherit_context: 是否继承当前上下文 +- execute_immediately: 是否立即执行(默认false,仅创建) +- context: 传递给子Agent的上下文数据 + +注意:每个Agent最多加载5个知识模块。""" + + @property + def args_schema(self): + return CreateAgentInput + + async def _execute( + self, + name: str, + task: str, + agent_type: str = "specialist", + knowledge_modules: Optional[str] = None, + inherit_context: bool = True, + execute_immediately: bool = False, + context: Optional[Dict[str, Any]] = None, + **kwargs + ) -> ToolResult: + """创建子Agent""" + + if not name or not name.strip(): + return ToolResult(success=False, error="Agent名称不能为空") + + if not task or not task.strip(): + return ToolResult(success=False, error="任务描述不能为空") + + # 解析知识模块 + modules = [] + if knowledge_modules: + modules = [m.strip() for m in knowledge_modules.split(",") if m.strip()] + if len(modules) > 5: + return ToolResult( + success=False, + error="知识模块数量不能超过5个" + ) + + # 验证知识模块(如果有) + if modules: + from ..knowledge import knowledge_loader + validation = knowledge_loader.validate_modules(modules) + if validation["invalid"]: + available = knowledge_loader.get_all_module_names() + return ToolResult( + success=False, + error=f"无效的知识模块: {validation['invalid']}。可用模块: {', '.join(available)}" + ) + + # 生成Agent ID + from ..core.state import _generate_agent_id + agent_id = _generate_agent_id() + + # 注册到注册表 + node = agent_registry.register_agent( + agent_id=agent_id, + agent_name=name.strip(), + agent_type=agent_type, + task=task.strip(), + parent_id=self.parent_agent_id, + knowledge_modules=modules, + ) + + # 创建消息队列 + message_bus.create_queue(agent_id) + + logger.info(f"Created sub-agent: {name} ({agent_id}), parent: {self.parent_agent_id}") + + # 如果需要立即执行 + if execute_immediately: + executor = self._get_executor() + if executor: + # 准备上下文 + exec_context = context or {} + exec_context["knowledge_modules"] = modules + + # 执行子Agent + exec_result = await executor.create_and_run_sub_agent( + agent_type=agent_type if agent_type in ["analysis", "verification"] else "analysis", + task=task.strip(), + context=exec_context, + knowledge_modules=modules, + ) + + # 更新注册表状态 + if exec_result.get("success"): + agent_registry.update_agent_status(agent_id, "completed", exec_result) + else: + agent_registry.update_agent_status(agent_id, "failed", {"error": exec_result.get("error")}) + + return ToolResult( + success=exec_result.get("success", False), + data={ + "message": f"子Agent '{name}' 已执行完成" if exec_result.get("success") else f"子Agent '{name}' 执行失败", + "agent_id": agent_id, + "execution_result": exec_result, + "findings": exec_result.get("data", {}).get("findings", []) if exec_result.get("success") else [], + }, + error=exec_result.get("error"), + metadata=node, + ) + else: + logger.warning("SubAgentExecutor not available, agent created but not executed") + + return ToolResult( + success=True, + data={ + "message": f"子Agent '{name}' 已创建", + "agent_id": agent_id, + "agent_info": { + "id": agent_id, + "name": name, + "type": agent_type, + "task": task[:100], + "knowledge_modules": modules, + "parent_id": self.parent_agent_id, + "status": "created", + } + }, + metadata=node, + ) + + +class SendMessageInput(BaseModel): + """发送消息输入参数""" + target_agent_id: str = Field(..., description="目标Agent ID") + message: str = Field(..., description="消息内容") + message_type: str = Field( + default="information", + description="消息类型: query(查询), instruction(指令), information(信息)" + ) + priority: str = Field( + default="normal", + description="优先级: low, normal, high, urgent" + ) + + +class SendMessageTool(AgentTool): + """ + 发送消息工具 + + 向其他Agent发送消息,实现Agent间通信 + """ + + def __init__(self, sender_agent_id: str): + super().__init__() + self.sender_agent_id = sender_agent_id + + @property + def name(self) -> str: + return "send_message" + + @property + def description(self) -> str: + return """向其他Agent发送消息。 + +使用场景: +1. 向子Agent发送指令 +2. 向父Agent报告进展 +3. 请求其他Agent提供信息 + +参数: +- target_agent_id: 目标Agent的ID +- message: 消息内容 +- message_type: 消息类型 (query/instruction/information) +- priority: 优先级 (low/normal/high/urgent)""" + + @property + def args_schema(self): + return SendMessageInput + + async def _execute( + self, + target_agent_id: str, + message: str, + message_type: str = "information", + priority: str = "normal", + **kwargs + ) -> ToolResult: + """发送消息""" + + if not target_agent_id: + return ToolResult(success=False, error="目标Agent ID不能为空") + + if not message or not message.strip(): + return ToolResult(success=False, error="消息内容不能为空") + + # 检查目标Agent是否存在 + target_node = agent_registry.get_agent_node(target_agent_id) + if not target_node: + return ToolResult( + success=False, + error=f"目标Agent '{target_agent_id}' 不存在" + ) + + # 转换消息类型 + try: + msg_type = MessageType(message_type) + except ValueError: + msg_type = MessageType.INFORMATION + + try: + msg_priority = MessagePriority(priority) + except ValueError: + msg_priority = MessagePriority.NORMAL + + # 发送消息 + sent_message = message_bus.send_message( + from_agent=self.sender_agent_id, + to_agent=target_agent_id, + content=message.strip(), + message_type=msg_type, + priority=msg_priority, + ) + + return ToolResult( + success=True, + data={ + "message": f"消息已发送到 '{target_node['name']}'", + "message_id": sent_message.id, + "target_agent": { + "id": target_agent_id, + "name": target_node["name"], + "status": target_node["status"], + } + }, + metadata=sent_message.to_dict(), + ) + + +class ViewAgentGraphTool(AgentTool): + """ + 查看Agent图工具 + + 查看当前的Agent树结构和状态 + """ + + def __init__(self, current_agent_id: str): + super().__init__() + self.current_agent_id = current_agent_id + + @property + def name(self) -> str: + return "view_agent_graph" + + @property + def description(self) -> str: + return """查看当前的Agent树结构和状态。 + +显示: +- 所有Agent及其层级关系 +- 每个Agent的状态和任务 +- 加载的知识模块""" + + @property + def args_schema(self): + return None + + async def _execute(self, **kwargs) -> ToolResult: + """查看Agent图""" + + tree_view = agent_registry.get_agent_tree_view() + stats = agent_registry.get_statistics() + + return ToolResult( + success=True, + data={ + "graph_structure": tree_view, + "summary": stats, + "current_agent_id": self.current_agent_id, + }, + ) + + +class WaitForMessageTool(AgentTool): + """ + 等待消息工具 + + 让Agent进入等待状态,等待其他Agent的消息 + """ + + def __init__(self, agent_id: str, agent_state=None): + super().__init__() + self.agent_id = agent_id + self.agent_state = agent_state + + @property + def name(self) -> str: + return "wait_for_message" + + @property + def description(self) -> str: + return """进入等待状态,等待其他Agent或用户的消息。 + +使用场景: +1. 等待子Agent完成任务并报告 +2. 等待用户提供更多信息 +3. 等待其他Agent的协作响应 + +参数: +- reason: 等待原因""" + + @property + def args_schema(self): + return None + + async def _execute( + self, + reason: str = "等待消息", + **kwargs + ) -> ToolResult: + """进入等待状态""" + + # 更新Agent状态 + if self.agent_state: + self.agent_state.enter_waiting_state(reason) + + # 更新注册表 + agent_registry.update_agent_status(self.agent_id, "waiting") + + return ToolResult( + success=True, + data={ + "status": "waiting", + "message": f"Agent正在等待: {reason}", + "agent_id": self.agent_id, + "resume_conditions": [ + "收到其他Agent的消息", + "收到用户消息", + "等待超时", + ], + }, + ) + + +class AgentFinishInput(BaseModel): + """Agent完成输入参数""" + result_summary: str = Field(..., description="结果摘要") + findings: Optional[List[str]] = Field(default=None, description="发现列表") + success: bool = Field(default=True, description="是否成功") + recommendations: Optional[List[str]] = Field(default=None, description="建议列表") + + +class AgentFinishTool(AgentTool): + """ + Agent完成工具 + + 子Agent完成任务后调用,向父Agent报告结果 + """ + + def __init__(self, agent_id: str, agent_state=None): + super().__init__() + self.agent_id = agent_id + self.agent_state = agent_state + + @property + def name(self) -> str: + return "agent_finish" + + @property + def description(self) -> str: + return """完成当前Agent的任务并向父Agent报告。 + +只有子Agent才能使用此工具。根Agent应使用finish_scan。 + +参数: +- result_summary: 结果摘要 +- findings: 发现列表 +- success: 是否成功完成 +- recommendations: 建议列表""" + + @property + def args_schema(self): + return AgentFinishInput + + async def _execute( + self, + result_summary: str, + findings: Optional[List[str]] = None, + success: bool = True, + recommendations: Optional[List[str]] = None, + **kwargs + ) -> ToolResult: + """完成Agent任务""" + + # 获取父Agent ID + parent_id = agent_registry.get_parent(self.agent_id) + + if not parent_id: + return ToolResult( + success=False, + error="此工具只能由子Agent使用。根Agent请使用finish_scan。" + ) + + # 更新状态 + result = { + "summary": result_summary, + "findings": findings or [], + "success": success, + "recommendations": recommendations or [], + } + + agent_registry.update_agent_status( + self.agent_id, + "completed" if success else "failed", + result, + ) + + if self.agent_state: + self.agent_state.set_completed(result) + + # 向父Agent发送完成报告 + message_bus.send_completion_report( + from_agent=self.agent_id, + to_agent=parent_id, + summary=result_summary, + findings=[{"description": f} for f in (findings or [])], + success=success, + ) + + agent_node = agent_registry.get_agent_node(self.agent_id) + + return ToolResult( + success=True, + data={ + "agent_completed": True, + "parent_notified": True, + "completion_summary": { + "agent_id": self.agent_id, + "agent_name": agent_node["name"] if agent_node else "Unknown", + "success": success, + "findings_count": len(findings or []), + } + }, + ) + + +class RunSubAgentsInput(BaseModel): + """批量执行子Agent输入参数""" + agent_ids: List[str] = Field(..., description="要执行的Agent ID列表") + parallel: bool = Field(default=True, description="是否并行执行") + + +class RunSubAgentsTool(AgentTool): + """ + 批量执行子Agent工具 + + 执行已创建的子Agent,支持并行执行 + """ + + def __init__( + self, + parent_agent_id: str, + llm_service=None, + tools: Dict[str, Any] = None, + event_emitter=None, + ): + super().__init__() + self.parent_agent_id = parent_agent_id + self.llm_service = llm_service + self.tools = tools or {} + self.event_emitter = event_emitter + + @property + def name(self) -> str: + return "run_sub_agents" + + @property + def description(self) -> str: + return """批量执行已创建的子Agent。 + +使用场景: +1. 创建多个子Agent后批量执行 +2. 并行执行多个分析任务 + +参数: +- agent_ids: 要执行的Agent ID列表 +- parallel: 是否并行执行(默认true)""" + + @property + def args_schema(self): + return RunSubAgentsInput + + async def _execute( + self, + agent_ids: List[str], + parallel: bool = True, + **kwargs + ) -> ToolResult: + """批量执行子Agent""" + + if not agent_ids: + return ToolResult(success=False, error="Agent ID列表不能为空") + + # 验证所有Agent存在且是当前Agent的子Agent + valid_agents = [] + for aid in agent_ids: + node = agent_registry.get_agent_node(aid) + if not node: + continue + if node.get("parent_id") != self.parent_agent_id: + continue + if node.get("status") not in ["created", "pending"]: + continue + valid_agents.append(node) + + if not valid_agents: + return ToolResult( + success=False, + error="没有找到可执行的子Agent" + ) + + # 构建执行任务 + from ..core.executor import DynamicAgentExecutor, ExecutionTask + + executor = DynamicAgentExecutor( + llm_service=self.llm_service, + tools=self.tools, + event_emitter=self.event_emitter, + ) + + tasks = [] + for node in valid_agents: + task = ExecutionTask( + agent_id=node["id"], + agent_type=node["type"], + task=node["task"], + context={ + "knowledge_modules": node.get("knowledge_modules", []), + }, + ) + tasks.append(task) + + # 定义Agent工厂函数 + async def agent_factory(task: ExecutionTask) -> Dict[str, Any]: + from ..agents import AnalysisAgent, VerificationAgent + + agent_class_map = { + "analysis": AnalysisAgent, + "verification": VerificationAgent, + "specialist": AnalysisAgent, # 默认使用分析Agent + } + + agent_class = agent_class_map.get(task.agent_type, AnalysisAgent) + + return await executor.execute_agent( + agent_class=agent_class, + agent_config={}, + input_data={ + "task": task.task, + "task_context": task.context, + }, + parent_id=self.parent_agent_id, + knowledge_modules=task.context.get("knowledge_modules"), + ) + + # 执行 + if parallel: + result = await executor.execute_parallel(tasks, agent_factory) + else: + # 顺序执行 + result = await executor.execute_parallel(tasks, agent_factory) + + return ToolResult( + success=result.success, + data={ + "message": f"执行完成: {result.completed_agents}/{result.total_agents} 成功", + "total_agents": result.total_agents, + "completed": result.completed_agents, + "failed": result.failed_agents, + "findings_count": len(result.all_findings), + "findings": result.all_findings[:20], # 限制返回数量 + "duration_ms": result.total_duration_ms, + "tokens_used": result.total_tokens, + }, + error="; ".join(result.errors) if result.errors else None, + metadata={ + "agent_results": { + aid: { + "success": r.get("success"), + "findings_count": len(r.get("data", {}).get("findings", [])) if r.get("success") else 0, + } + for aid, r in result.agent_results.items() + } + }, + ) + + +class CollectSubAgentResultsTool(AgentTool): + """ + 收集子Agent结果工具 + + 收集所有子Agent的执行结果和发现 + """ + + def __init__(self, parent_agent_id: str): + super().__init__() + self.parent_agent_id = parent_agent_id + + @property + def name(self) -> str: + return "collect_sub_agent_results" + + @property + def description(self) -> str: + return """收集所有子Agent的执行结果。 + +返回: +- 所有子Agent的状态 +- 汇总的发现列表 +- 执行统计""" + + @property + def args_schema(self): + return None + + async def _execute(self, **kwargs) -> ToolResult: + """收集子Agent结果""" + + # 获取所有子Agent + children = agent_registry.get_children(self.parent_agent_id) + + if not children: + return ToolResult( + success=True, + data={ + "message": "没有子Agent", + "children_count": 0, + "findings": [], + } + ) + + all_findings = [] + completed = 0 + failed = 0 + running = 0 + + child_summaries = [] + + for child_id in children: + node = agent_registry.get_agent_node(child_id) + if not node: + continue + + status = node.get("status", "unknown") + + if status == "completed": + completed += 1 + # 收集发现 + result = node.get("result", {}) + if isinstance(result, dict): + findings = result.get("findings", []) + if isinstance(findings, list): + all_findings.extend(findings) + elif status == "failed": + failed += 1 + elif status == "running": + running += 1 + + child_summaries.append({ + "id": child_id, + "name": node.get("name"), + "type": node.get("type"), + "status": status, + "findings_count": len(node.get("result", {}).get("findings", [])) if node.get("result") else 0, + }) + + return ToolResult( + success=True, + data={ + "message": f"收集完成: {completed} 完成, {failed} 失败, {running} 运行中", + "children_count": len(children), + "completed": completed, + "failed": failed, + "running": running, + "total_findings": len(all_findings), + "findings": all_findings, + "children": child_summaries, + }, + ) diff --git a/backend/app/services/agent/tools/code_analysis_tool.py b/backend/app/services/agent/tools/code_analysis_tool.py index 6d50391..819d51a 100644 --- a/backend/app/services/agent/tools/code_analysis_tool.py +++ b/backend/app/services/agent/tools/code_analysis_tool.py @@ -4,11 +4,14 @@ """ import json +import logging from typing import Optional, List, Dict, Any from pydantic import BaseModel, Field from .base import AgentTool, ToolResult +logger = logging.getLogger(__name__) + class CodeAnalysisInput(BaseModel): """代码分析输入""" @@ -155,6 +158,12 @@ class CodeAnalysisTool(AgentTool): ) except Exception as e: + import traceback + logger.error(f"代码分析失败: {e}") + logger.error(f"LLM Provider: {self.llm_service.config.provider.value if self.llm_service.config else 'N/A'}") + logger.error(f"LLM Model: {self.llm_service.config.model if self.llm_service.config else 'N/A'}") + logger.error(f"API Key 前缀: {self.llm_service.config.api_key[:10] + '...' if self.llm_service.config and self.llm_service.config.api_key else 'N/A'}") + logger.error(traceback.format_exc()) return ToolResult( success=False, error=f"代码分析失败: {str(e)}", diff --git a/backend/app/services/agent/tools/file_tool.py b/backend/app/services/agent/tools/file_tool.py index 4a6ae0c..172f5b3 100644 --- a/backend/app/services/agent/tools/file_tool.py +++ b/backend/app/services/agent/tools/file_tool.py @@ -26,15 +26,24 @@ class FileReadTool(AgentTool): 读取项目中的文件内容 """ - def __init__(self, project_root: str): + def __init__( + self, + project_root: str, + exclude_patterns: Optional[List[str]] = None, + target_files: Optional[List[str]] = None, + ): """ 初始化文件读取工具 Args: project_root: 项目根目录 + exclude_patterns: 排除模式列表 + target_files: 目标文件列表(如果指定,只允许读取这些文件) """ super().__init__() self.project_root = project_root + self.exclude_patterns = exclude_patterns or [] + self.target_files = set(target_files) if target_files else None @property def name(self) -> str: @@ -61,6 +70,22 @@ class FileReadTool(AgentTool): def args_schema(self): return FileReadInput + def _should_exclude(self, file_path: str) -> bool: + """检查文件是否应该被排除""" + # 如果指定了目标文件,只允许读取这些文件 + if self.target_files and file_path not in self.target_files: + return True + + # 检查排除模式 + for pattern in self.exclude_patterns: + if fnmatch.fnmatch(file_path, pattern): + return True + # 也检查文件名 + if fnmatch.fnmatch(os.path.basename(file_path), pattern): + return True + + return False + async def _execute( self, file_path: str, @@ -71,6 +96,13 @@ class FileReadTool(AgentTool): ) -> ToolResult: """执行文件读取""" try: + # 检查是否被排除 + if self._should_exclude(file_path): + return ToolResult( + success=False, + error=f"文件被排除或不在目标文件列表中: {file_path}", + ) + # 安全检查:防止路径遍历 full_path = os.path.normpath(os.path.join(self.project_root, file_path)) if not full_path.startswith(os.path.normpath(self.project_root)): @@ -178,15 +210,30 @@ class FileSearchTool(AgentTool): """ # 排除的目录 - EXCLUDE_DIRS = { + DEFAULT_EXCLUDE_DIRS = { "node_modules", "vendor", "dist", "build", ".git", "__pycache__", ".pytest_cache", "coverage", ".nyc_output", ".vscode", ".idea", ".vs", "target", "venv", "env", } - def __init__(self, project_root: str): + def __init__( + self, + project_root: str, + exclude_patterns: Optional[List[str]] = None, + target_files: Optional[List[str]] = None, + ): super().__init__() self.project_root = project_root + self.exclude_patterns = exclude_patterns or [] + self.target_files = set(target_files) if target_files else None + + # 从 exclude_patterns 中提取目录排除 + self.exclude_dirs = set(self.DEFAULT_EXCLUDE_DIRS) + for pattern in self.exclude_patterns: + if pattern.endswith("/**"): + self.exclude_dirs.add(pattern[:-3]) + elif "/" not in pattern and "*" not in pattern: + self.exclude_dirs.add(pattern) @property def name(self) -> str: @@ -256,7 +303,7 @@ class FileSearchTool(AgentTool): # 遍历文件 for root, dirs, files in os.walk(search_dir): # 排除目录 - dirs[:] = [d for d in dirs if d not in self.EXCLUDE_DIRS] + dirs[:] = [d for d in dirs if d not in self.exclude_dirs] for filename in files: # 检查文件模式 @@ -266,6 +313,19 @@ class FileSearchTool(AgentTool): file_path = os.path.join(root, filename) relative_path = os.path.relpath(file_path, self.project_root) + # 检查是否在目标文件列表中 + if self.target_files and relative_path not in self.target_files: + continue + + # 检查排除模式 + should_skip = False + for excl_pattern in self.exclude_patterns: + if fnmatch.fnmatch(relative_path, excl_pattern) or fnmatch.fnmatch(filename, excl_pattern): + should_skip = True + break + if should_skip: + continue + try: with open(file_path, 'r', encoding='utf-8', errors='ignore') as f: lines = f.readlines() @@ -351,14 +411,30 @@ class ListFilesTool(AgentTool): 列出目录中的文件 """ - EXCLUDE_DIRS = { + DEFAULT_EXCLUDE_DIRS = { "node_modules", "vendor", "dist", "build", ".git", "__pycache__", ".pytest_cache", "coverage", } - def __init__(self, project_root: str): + def __init__( + self, + project_root: str, + exclude_patterns: Optional[List[str]] = None, + target_files: Optional[List[str]] = None, + ): super().__init__() self.project_root = project_root + self.exclude_patterns = exclude_patterns or [] + self.target_files = set(target_files) if target_files else None + + # 从 exclude_patterns 中提取目录排除 + self.exclude_dirs = set(self.DEFAULT_EXCLUDE_DIRS) + for pattern in self.exclude_patterns: + # 如果是目录模式(如 node_modules/**),提取目录名 + if pattern.endswith("/**"): + self.exclude_dirs.add(pattern[:-3]) + elif "/" not in pattern and "*" not in pattern: + self.exclude_dirs.add(pattern) @property def name(self) -> str: @@ -412,7 +488,7 @@ class ListFilesTool(AgentTool): if recursive: for root, dirnames, filenames in os.walk(target_dir): # 排除目录 - dirnames[:] = [d for d in dirnames if d not in self.EXCLUDE_DIRS] + dirnames[:] = [d for d in dirnames if d not in self.exclude_dirs] for filename in filenames: if pattern and not fnmatch.fnmatch(filename, pattern): @@ -420,6 +496,20 @@ class ListFilesTool(AgentTool): full_path = os.path.join(root, filename) relative_path = os.path.relpath(full_path, self.project_root) + + # 检查是否在目标文件列表中 + if self.target_files and relative_path not in self.target_files: + continue + + # 检查排除模式 + should_skip = False + for excl_pattern in self.exclude_patterns: + if fnmatch.fnmatch(relative_path, excl_pattern) or fnmatch.fnmatch(filename, excl_pattern): + should_skip = True + break + if should_skip: + continue + files.append(relative_path) if len(files) >= max_files: @@ -428,26 +518,78 @@ class ListFilesTool(AgentTool): if len(files) >= max_files: break else: - for item in os.listdir(target_dir): - if item in self.EXCLUDE_DIRS: - continue + # 🔥 如果设置了 target_files,只显示目标文件和包含目标文件的目录 + if self.target_files: + # 计算哪些目录包含目标文件 + dirs_with_targets = set() + for tf in self.target_files: + # 获取目标文件的目录部分 + tf_dir = os.path.dirname(tf) + while tf_dir: + dirs_with_targets.add(tf_dir) + tf_dir = os.path.dirname(tf_dir) - full_path = os.path.join(target_dir, item) - relative_path = os.path.relpath(full_path, self.project_root) - - if os.path.isdir(full_path): - dirs.append(relative_path + "/") - else: - if pattern and not fnmatch.fnmatch(item, pattern): + for item in os.listdir(target_dir): + if item in self.exclude_dirs: continue - files.append(relative_path) - if len(files) >= max_files: - break + full_path = os.path.join(target_dir, item) + relative_path = os.path.relpath(full_path, self.project_root) + + if os.path.isdir(full_path): + # 只显示包含目标文件的目录 + if relative_path in dirs_with_targets or any( + tf.startswith(relative_path + "/") for tf in self.target_files + ): + dirs.append(relative_path + "/") + else: + if pattern and not fnmatch.fnmatch(item, pattern): + continue + + # 检查是否在目标文件列表中 + if relative_path not in self.target_files: + continue + + files.append(relative_path) + + if len(files) >= max_files: + break + else: + # 没有设置 target_files,正常列出 + for item in os.listdir(target_dir): + if item in self.exclude_dirs: + continue + + full_path = os.path.join(target_dir, item) + relative_path = os.path.relpath(full_path, self.project_root) + + if os.path.isdir(full_path): + dirs.append(relative_path + "/") + else: + if pattern and not fnmatch.fnmatch(item, pattern): + continue + + # 检查排除模式 + should_skip = False + for excl_pattern in self.exclude_patterns: + if fnmatch.fnmatch(relative_path, excl_pattern) or fnmatch.fnmatch(item, excl_pattern): + should_skip = True + break + if should_skip: + continue + + files.append(relative_path) + + if len(files) >= max_files: + break # 格式化输出 output_parts = [f"📁 目录: {directory}\n"] + # 🔥 如果设置了 target_files,显示提示信息 + if self.target_files: + output_parts.append(f"⚠️ 注意: 审计范围限定为 {len(self.target_files)} 个指定文件\n") + if dirs: output_parts.append("目录:") for d in sorted(dirs)[:20]: @@ -459,6 +601,13 @@ class ListFilesTool(AgentTool): output_parts.append(f"\n文件 ({len(files)}):") for f in sorted(files): output_parts.append(f" 📄 {f}") + elif self.target_files: + # 如果没有文件但设置了 target_files,显示目标文件列表 + output_parts.append(f"\n指定的目标文件 ({len(self.target_files)}):") + for f in sorted(self.target_files)[:20]: + output_parts.append(f" 📄 {f}") + if len(self.target_files) > 20: + output_parts.append(f" ... 还有 {len(self.target_files) - 20} 个文件") if len(files) >= max_files: output_parts.append(f"\n... 结果已截断(最大 {max_files} 个文件)") diff --git a/backend/app/services/agent/tools/reporting_tool.py b/backend/app/services/agent/tools/reporting_tool.py new file mode 100644 index 0000000..d04f72e --- /dev/null +++ b/backend/app/services/agent/tools/reporting_tool.py @@ -0,0 +1,235 @@ +""" +漏洞报告工具 + +正式记录漏洞的唯一方式,确保漏洞报告的规范性和完整性。 +""" + +import logging +import uuid +from datetime import datetime, timezone +from typing import Optional, List, Dict, Any +from pydantic import BaseModel, Field + +from .base import AgentTool, ToolResult + +logger = logging.getLogger(__name__) + + +class VulnerabilityReportInput(BaseModel): + """漏洞报告输入参数""" + title: str = Field(..., description="漏洞标题") + vulnerability_type: str = Field( + ..., + description="漏洞类型: sql_injection, xss, ssrf, command_injection, path_traversal, idor, auth_bypass, etc." + ) + severity: str = Field( + ..., + description="严重程度: critical, high, medium, low, info" + ) + description: str = Field(..., description="漏洞详细描述") + file_path: str = Field(..., description="漏洞所在文件路径") + line_start: Optional[int] = Field(default=None, description="起始行号") + line_end: Optional[int] = Field(default=None, description="结束行号") + code_snippet: Optional[str] = Field(default=None, description="相关代码片段") + source: Optional[str] = Field(default=None, description="污点来源(用户输入点)") + sink: Optional[str] = Field(default=None, description="危险函数(漏洞触发点)") + poc: Optional[str] = Field(default=None, description="概念验证/利用方法") + impact: Optional[str] = Field(default=None, description="影响分析") + recommendation: Optional[str] = Field(default=None, description="修复建议") + confidence: float = Field(default=0.8, description="置信度 0.0-1.0") + cwe_id: Optional[str] = Field(default=None, description="CWE编号") + cvss_score: Optional[float] = Field(default=None, description="CVSS评分") + + +class CreateVulnerabilityReportTool(AgentTool): + """ + 创建漏洞报告工具 + + 这是正式记录漏洞的唯一方式。只有通过这个工具创建的漏洞才会被计入最终报告。 + 这个设计确保了漏洞报告的规范性和完整性。 + + 通常只有专门的报告Agent或验证Agent才会调用这个工具, + 确保漏洞在被正式报告之前已经经过了充分的验证。 + """ + + # 存储所有报告的漏洞 + _vulnerability_reports: List[Dict[str, Any]] = [] + + def __init__(self): + super().__init__() + self._reports: List[Dict[str, Any]] = [] + + @property + def name(self) -> str: + return "create_vulnerability_report" + + @property + def description(self) -> str: + return """创建正式的漏洞报告。这是记录已确认漏洞的唯一方式。 + +只有在以下情况下才应该使用此工具: +1. 漏洞已经过充分分析和验证 +2. 有明确的证据支持漏洞存在 +3. 已经评估了漏洞的影响 + +必需参数: +- title: 漏洞标题 +- vulnerability_type: 漏洞类型 +- severity: 严重程度 (critical/high/medium/low/info) +- description: 详细描述 +- file_path: 文件路径 + +可选参数: +- line_start/line_end: 行号范围 +- code_snippet: 代码片段 +- source/sink: 数据流信息 +- poc: 概念验证 +- impact: 影响分析 +- recommendation: 修复建议 +- confidence: 置信度 +- cwe_id: CWE编号 +- cvss_score: CVSS评分""" + + @property + def args_schema(self): + return VulnerabilityReportInput + + async def _execute( + self, + title: str, + vulnerability_type: str, + severity: str, + description: str, + file_path: str, + line_start: Optional[int] = None, + line_end: Optional[int] = None, + code_snippet: Optional[str] = None, + source: Optional[str] = None, + sink: Optional[str] = None, + poc: Optional[str] = None, + impact: Optional[str] = None, + recommendation: Optional[str] = None, + confidence: float = 0.8, + cwe_id: Optional[str] = None, + cvss_score: Optional[float] = None, + **kwargs + ) -> ToolResult: + """创建漏洞报告""" + + # 验证必需字段 + if not title or not title.strip(): + return ToolResult(success=False, error="标题不能为空") + + if not description or not description.strip(): + return ToolResult(success=False, error="描述不能为空") + + if not file_path or not file_path.strip(): + return ToolResult(success=False, error="文件路径不能为空") + + # 验证严重程度 + valid_severities = ["critical", "high", "medium", "low", "info"] + severity = severity.lower() + if severity not in valid_severities: + return ToolResult( + success=False, + error=f"无效的严重程度 '{severity}',必须是: {', '.join(valid_severities)}" + ) + + # 验证漏洞类型 + valid_types = [ + "sql_injection", "nosql_injection", "xss", "ssrf", + "command_injection", "code_injection", "path_traversal", + "file_inclusion", "idor", "auth_bypass", "broken_auth", + "sensitive_data_exposure", "hardcoded_secret", "weak_crypto", + "xxe", "deserialization", "race_condition", "business_logic", + "csrf", "open_redirect", "mass_assignment", "other" + ] + vulnerability_type = vulnerability_type.lower() + if vulnerability_type not in valid_types: + # 允许未知类型,但记录警告 + logger.warning(f"Unknown vulnerability type: {vulnerability_type}") + + # 验证置信度 + confidence = max(0.0, min(1.0, confidence)) + + # 生成报告ID + report_id = f"vuln_{uuid.uuid4().hex[:8]}" + + # 构建报告 + report = { + "id": report_id, + "title": title.strip(), + "vulnerability_type": vulnerability_type, + "severity": severity, + "description": description.strip(), + "file_path": file_path.strip(), + "line_start": line_start, + "line_end": line_end, + "code_snippet": code_snippet, + "source": source, + "sink": sink, + "poc": poc, + "impact": impact, + "recommendation": recommendation or self._get_default_recommendation(vulnerability_type), + "confidence": confidence, + "cwe_id": cwe_id, + "cvss_score": cvss_score, + "created_at": datetime.now(timezone.utc).isoformat(), + "is_verified": True, # 通过此工具创建的都视为已验证 + } + + # 存储报告 + self._reports.append(report) + CreateVulnerabilityReportTool._vulnerability_reports.append(report) + + logger.info(f"Created vulnerability report: [{severity.upper()}] {title}") + + # 返回结果 + severity_emoji = { + "critical": "🔴", + "high": "🟠", + "medium": "🟡", + "low": "🟢", + "info": "🔵", + }.get(severity, "⚪") + + return ToolResult( + success=True, + data={ + "message": f"漏洞报告已创建: {severity_emoji} [{severity.upper()}] {title}", + "report_id": report_id, + "severity": severity, + }, + metadata=report, + ) + + def _get_default_recommendation(self, vuln_type: str) -> str: + """获取默认修复建议""" + recommendations = { + "sql_injection": "使用参数化查询或ORM,避免字符串拼接构造SQL语句", + "xss": "对用户输入进行HTML实体编码,使用CSP策略,避免innerHTML", + "ssrf": "验证和限制目标URL,使用白名单,禁止访问内网地址", + "command_injection": "避免使用shell执行,使用参数列表传递命令,严格验证输入", + "path_traversal": "规范化路径后验证,使用白名单,限制访问目录", + "idor": "实现细粒度访问控制,验证资源所有权,使用UUID替代自增ID", + "auth_bypass": "加强认证逻辑,实现多因素认证,定期审计认证代码", + "hardcoded_secret": "使用环境变量或密钥管理服务存储敏感信息", + "weak_crypto": "使用强加密算法(AES-256, SHA-256+),避免MD5/SHA1", + "xxe": "禁用外部实体解析,使用安全的XML解析器配置", + "deserialization": "避免反序列化不可信数据,使用JSON替代pickle/yaml", + } + return recommendations.get(vuln_type, "请根据具体情况修复此安全问题") + + def get_reports(self) -> List[Dict[str, Any]]: + """获取所有报告""" + return self._reports.copy() + + @classmethod + def get_all_reports(cls) -> List[Dict[str, Any]]: + """获取所有实例的报告""" + return cls._vulnerability_reports.copy() + + @classmethod + def clear_all_reports(cls) -> None: + """清空所有报告""" + cls._vulnerability_reports.clear() diff --git a/backend/app/services/agent/tools/thinking_tool.py b/backend/app/services/agent/tools/thinking_tool.py new file mode 100644 index 0000000..8224840 --- /dev/null +++ b/backend/app/services/agent/tools/thinking_tool.py @@ -0,0 +1,167 @@ +""" +Think 工具 - 深度推理工具 + +让Agent进行深度思考和推理,用于: +- 分析复杂情况 +- 规划下一步行动 +- 评估发现的严重性 +- 决定是否需要创建子Agent +""" + +import logging +from typing import Optional +from pydantic import BaseModel, Field + +from .base import AgentTool, ToolResult + +logger = logging.getLogger(__name__) + + +class ThinkInput(BaseModel): + """Think工具输入参数""" + thought: str = Field( + ..., + description="思考内容,可以是分析、规划、评估等" + ) + category: Optional[str] = Field( + default="general", + description="思考类别: analysis(分析), planning(规划), evaluation(评估), decision(决策)" + ) + + +class ThinkTool(AgentTool): + """ + Think 工具 + + 这是一个让Agent进行深度推理的工具。Agent可以用它来: + - 分析复杂情况:当面对复杂的代码逻辑或不确定的漏洞线索时 + - 规划下一步行动:在执行具体操作之前先规划策略 + - 评估发现的严重性:发现可疑点后评估其真实性和影响 + - 决定是否需要分解任务:当任务变得复杂时分析是否需要创建子Agent + + Think工具的输出会被记录到Agent的对话历史中,帮助LLM保持思路的连贯性。 + """ + + @property + def name(self) -> str: + return "think" + + @property + def description(self) -> str: + return """深度思考工具。用于: +1. 分析复杂的代码逻辑或安全问题 +2. 规划下一步的分析策略 +3. 评估发现的漏洞是否真实存在 +4. 决定是否需要深入调查某个方向 + +使用此工具记录你的推理过程,这有助于保持分析的连贯性。 + +参数: +- thought: 你的思考内容 +- category: 思考类别 (analysis/planning/evaluation/decision)""" + + @property + def args_schema(self): + return ThinkInput + + async def _execute( + self, + thought: str, + category: str = "general", + **kwargs + ) -> ToolResult: + """ + 执行思考 + + 实际上这个工具不执行任何操作,只是记录思考内容。 + 但它的存在让Agent有一个"思考"的动作,有助于推理。 + """ + if not thought or not thought.strip(): + return ToolResult( + success=False, + error="思考内容不能为空", + ) + + thought = thought.strip() + + # 根据类别添加标记 + category_labels = { + "analysis": "🔍 分析", + "planning": "📋 规划", + "evaluation": "⚖️ 评估", + "decision": "🎯 决策", + "general": "💭 思考", + } + + label = category_labels.get(category, "💭 思考") + + logger.debug(f"Think tool called: [{label}] {thought[:100]}...") + + return ToolResult( + success=True, + data={ + "message": f"思考已记录 ({len(thought)} 字符)", + "category": category, + "label": label, + }, + metadata={ + "thought": thought, + "category": category, + "char_count": len(thought), + } + ) + + +class ReflectTool(AgentTool): + """ + 反思工具 + + 让Agent回顾和总结当前的分析进展 + """ + + @property + def name(self) -> str: + return "reflect" + + @property + def description(self) -> str: + return """反思工具。用于回顾当前的分析进展: +1. 总结已经发现的问题 +2. 评估当前分析的覆盖度 +3. 识别可能遗漏的方向 +4. 决定是否需要调整策略 + +参数: +- summary: 当前进展总结 +- findings_so_far: 目前发现的问题数量 +- coverage: 分析覆盖度评估 (low/medium/high) +- next_steps: 建议的下一步行动""" + + @property + def args_schema(self): + return None + + async def _execute( + self, + summary: str = "", + findings_so_far: int = 0, + coverage: str = "medium", + next_steps: str = "", + **kwargs + ) -> ToolResult: + """执行反思""" + reflection = { + "summary": summary, + "findings_count": findings_so_far, + "coverage": coverage, + "next_steps": next_steps, + } + + return ToolResult( + success=True, + data={ + "message": "反思已记录", + "reflection": reflection, + }, + metadata=reflection, + ) diff --git a/backend/app/services/llm/__init__.py b/backend/app/services/llm/__init__.py index e69de29..40e5e0a 100644 --- a/backend/app/services/llm/__init__.py +++ b/backend/app/services/llm/__init__.py @@ -0,0 +1,52 @@ +""" +LLM 服务模块 + +提供统一的 LLM 调用接口,支持: +- 多提供商支持(OpenAI, Claude, Gemini, DeepSeek 等) +- Prompt Caching(减少 Token 消耗) +- Memory Compression(对话历史压缩) +- 流式输出 +- 智能重试 +""" + +from .service import LLMService +from .types import ( + LLMConfig, + LLMProvider, + LLMMessage, + LLMRequest, + LLMResponse, + LLMUsage, + LLMError, +) +from .prompt_cache import ( + PromptCacheManager, + CacheConfig, + CacheStrategy, + CacheStats, + prompt_cache_manager, + estimate_tokens, +) +from .memory_compressor import MemoryCompressor + +__all__ = [ + # Service + "LLMService", + # Types + "LLMConfig", + "LLMProvider", + "LLMMessage", + "LLMRequest", + "LLMResponse", + "LLMUsage", + "LLMError", + # Prompt Cache + "PromptCacheManager", + "CacheConfig", + "CacheStrategy", + "CacheStats", + "prompt_cache_manager", + "estimate_tokens", + # Memory Compression + "MemoryCompressor", +] diff --git a/backend/app/services/llm/adapters/litellm_adapter.py b/backend/app/services/llm/adapters/litellm_adapter.py index ea104e6..b42cdca 100644 --- a/backend/app/services/llm/adapters/litellm_adapter.py +++ b/backend/app/services/llm/adapters/litellm_adapter.py @@ -1,9 +1,15 @@ """ LiteLLM 统一适配器 支持通过 LiteLLM 调用多个 LLM 提供商,使用统一的 OpenAI 兼容格式 + +增强功能: +- Prompt Caching: 为支持的 LLM(如 Claude)添加缓存标记 +- 智能重试: 指数退避重试策略 +- 流式输出: 支持逐 token 返回 """ -from typing import Dict, Any, Optional +import logging +from typing import Dict, Any, Optional, List from ..base_adapter import BaseLLMAdapter from ..types import ( LLMConfig, @@ -14,6 +20,9 @@ from ..types import ( LLMError, DEFAULT_BASE_URLS, ) +from ..prompt_cache import prompt_cache_manager, estimate_tokens + +logger = logging.getLogger(__name__) class LiteLLMAdapter(BaseLLMAdapter): @@ -107,6 +116,25 @@ class LiteLLMAdapter(BaseLLMAdapter): # 构建消息 messages = [{"role": msg.role, "content": msg.content} for msg in request.messages] + + # 🔥 Prompt Caching: 为支持的 LLM 添加缓存标记 + cache_enabled = False + if self.config.provider == LLMProvider.CLAUDE: + # 估算系统提示词 token 数 + system_tokens = 0 + for msg in messages: + if msg.get("role") == "system": + system_tokens += estimate_tokens(msg.get("content", "")) + + messages, cache_enabled = prompt_cache_manager.process_messages( + messages=messages, + model=self.config.model, + provider=self.config.provider.value, + system_prompt_tokens=system_tokens, + ) + + if cache_enabled: + logger.debug(f"🔥 Prompt Caching enabled for {self.config.model}") # 构建请求参数 kwargs: Dict[str, Any] = { @@ -169,6 +197,14 @@ class LiteLLMAdapter(BaseLLMAdapter): completion_tokens=response.usage.completion_tokens or 0, total_tokens=response.usage.total_tokens or 0, ) + + # 🔥 更新 Prompt Cache 统计 + if cache_enabled and hasattr(response.usage, "cache_creation_input_tokens"): + prompt_cache_manager.update_stats( + cache_creation_input_tokens=getattr(response.usage, "cache_creation_input_tokens", 0), + cache_read_input_tokens=getattr(response.usage, "cache_read_input_tokens", 0), + total_input_tokens=response.usage.prompt_tokens or 0, + ) return LLMResponse( content=choice.message.content or "", diff --git a/backend/app/services/llm/memory_compressor.py b/backend/app/services/llm/memory_compressor.py new file mode 100644 index 0000000..bd2c4e9 --- /dev/null +++ b/backend/app/services/llm/memory_compressor.py @@ -0,0 +1,349 @@ +""" +Memory Compressor - 对话历史压缩器 + +当对话历史变得很长时,自动进行压缩,保持语义完整性的同时降低Token消耗。 + +压缩策略: +1. 保留所有系统消息 +2. 保留最近的N条消息 +3. 对较早的消息进行摘要压缩 +4. 保留关键信息(发现、决策点、错误) +""" + +import logging +from typing import Any, Dict, List, Optional + +logger = logging.getLogger(__name__) + + +# 配置常量 +MAX_TOTAL_TOKENS = 100_000 # 最大总token数 +MIN_RECENT_MESSAGES = 15 # 最少保留的最近消息数 +COMPRESSION_THRESHOLD = 0.9 # 触发压缩的阈值(90%) + + +def estimate_tokens(text: str) -> int: + """ + 估算文本的token数量 + + 简单估算:英文约4字符/token,中文约2字符/token + """ + if not text: + return 0 + + # 简单估算 + ascii_chars = sum(1 for c in text if ord(c) < 128) + non_ascii_chars = len(text) - ascii_chars + + return (ascii_chars // 4) + (non_ascii_chars // 2) + 1 + + +def get_message_tokens(msg: Dict[str, Any]) -> int: + """获取单条消息的token数""" + content = msg.get("content", "") + + if isinstance(content, str): + return estimate_tokens(content) + + if isinstance(content, list): + total = 0 + for item in content: + if isinstance(item, dict) and item.get("type") == "text": + total += estimate_tokens(item.get("text", "")) + return total + + return 0 + + +def extract_message_text(msg: Dict[str, Any]) -> str: + """提取消息文本内容""" + content = msg.get("content", "") + + if isinstance(content, str): + return content + + if isinstance(content, list): + parts = [] + for item in content: + if isinstance(item, dict): + if item.get("type") == "text": + parts.append(item.get("text", "")) + elif item.get("type") == "image_url": + parts.append("[IMAGE]") + return " ".join(parts) + + return str(content) + + +class MemoryCompressor: + """ + 对话历史压缩器 + + 当对话历史超过token限制时,自动压缩较早的消息, + 同时保留关键的安全审计上下文。 + """ + + def __init__( + self, + max_total_tokens: int = MAX_TOTAL_TOKENS, + min_recent_messages: int = MIN_RECENT_MESSAGES, + llm_service=None, + ): + """ + 初始化压缩器 + + Args: + max_total_tokens: 最大总token数 + min_recent_messages: 最少保留的最近消息数 + llm_service: LLM服务(用于生成摘要,可选) + """ + self.max_total_tokens = max_total_tokens + self.min_recent_messages = min_recent_messages + self.llm_service = llm_service + + def compress_history( + self, + messages: List[Dict[str, Any]], + ) -> List[Dict[str, Any]]: + """ + 压缩对话历史 + + 策略: + 1. 保留所有系统消息 + 2. 保留最近的N条消息 + 3. 对较早的消息进行摘要压缩 + 4. 保留关键信息 + + Args: + messages: 原始消息列表 + + Returns: + 压缩后的消息列表 + """ + if not messages: + return messages + + # 分离系统消息和普通消息 + system_msgs = [] + regular_msgs = [] + + for msg in messages: + if msg.get("role") == "system": + system_msgs.append(msg) + else: + regular_msgs.append(msg) + + # 计算当前总token数 + total_tokens = sum(get_message_tokens(msg) for msg in messages) + + # 如果未超过阈值,不需要压缩 + if total_tokens <= self.max_total_tokens * COMPRESSION_THRESHOLD: + return messages + + logger.info(f"Compressing conversation history: {total_tokens} tokens -> target: {int(self.max_total_tokens * 0.7)}") + + # 分离最近消息和较早消息 + recent_msgs = regular_msgs[-self.min_recent_messages:] + old_msgs = regular_msgs[:-self.min_recent_messages] if len(regular_msgs) > self.min_recent_messages else [] + + if not old_msgs: + return messages + + # 压缩较早的消息 + compressed = self._compress_messages(old_msgs) + + # 重新组合 + result = system_msgs + compressed + recent_msgs + + new_total = sum(get_message_tokens(msg) for msg in result) + logger.info(f"Compression complete: {total_tokens} -> {new_total} tokens ({100 - new_total * 100 // total_tokens}% reduction)") + + return result + + def _compress_messages( + self, + messages: List[Dict[str, Any]], + chunk_size: int = 10, + ) -> List[Dict[str, Any]]: + """ + 压缩消息列表 + + Args: + messages: 要压缩的消息 + chunk_size: 每次压缩的消息数量 + + Returns: + 压缩后的消息列表 + """ + if not messages: + return [] + + compressed = [] + + # 按chunk分组压缩 + for i in range(0, len(messages), chunk_size): + chunk = messages[i:i + chunk_size] + summary = self._summarize_chunk(chunk) + if summary: + compressed.append(summary) + + return compressed + + def _summarize_chunk(self, messages: List[Dict[str, Any]]) -> Optional[Dict[str, Any]]: + """ + 摘要一组消息 + + Args: + messages: 要摘要的消息 + + Returns: + 摘要消息 + """ + if not messages: + return None + + # 提取关键信息 + key_info = self._extract_key_info(messages) + + # 构建摘要 + summary_parts = [] + + if key_info["findings"]: + summary_parts.append(f"发现: {', '.join(key_info['findings'][:5])}") + + if key_info["tools_used"]: + summary_parts.append(f"使用工具: {', '.join(key_info['tools_used'][:5])}") + + if key_info["decisions"]: + summary_parts.append(f"决策: {', '.join(key_info['decisions'][:3])}") + + if key_info["errors"]: + summary_parts.append(f"错误: {', '.join(key_info['errors'][:2])}") + + if not summary_parts: + # 如果没有提取到关键信息,生成简单摘要 + summary_parts.append(f"[已压缩 {len(messages)} 条历史消息]") + + summary_text = " | ".join(summary_parts) + + return { + "role": "assistant", + "content": f"{summary_text}", + } + + def _extract_key_info(self, messages: List[Dict[str, Any]]) -> Dict[str, List[str]]: + """ + 从消息中提取关键信息 + + Args: + messages: 消息列表 + + Returns: + 关键信息字典 + """ + import re + + key_info = { + "findings": [], + "tools_used": [], + "decisions": [], + "errors": [], + "files_analyzed": [], + } + + for msg in messages: + text = extract_message_text(msg).lower() + + # 提取发现的漏洞类型 + vuln_patterns = { + "sql": "SQL注入", + "xss": "XSS", + "ssrf": "SSRF", + "idor": "IDOR", + "auth": "认证问题", + "injection": "注入漏洞", + "traversal": "路径遍历", + "deserialization": "反序列化", + "hardcoded": "硬编码凭证", + "secret": "密钥泄露", + } + + for pattern, label in vuln_patterns.items(): + if pattern in text and ("发现" in text or "漏洞" in text or "finding" in text or "vulnerability" in text): + if label not in key_info["findings"]: + key_info["findings"].append(label) + + # 提取工具使用 + tool_match = re.search(r'action:\s*(\w+)', text, re.IGNORECASE) + if tool_match: + tool = tool_match.group(1) + if tool not in key_info["tools_used"]: + key_info["tools_used"].append(tool) + + # 提取分析的文件 + file_patterns = [ + r'读取文件[::]\s*([^\s\n]+)', + r'分析文件[::]\s*([^\s\n]+)', + r'file[_\s]?path[::]\s*["\']?([^\s\n"\']+)', + r'\.py|\.js|\.ts|\.java|\.go|\.php', + ] + for pattern in file_patterns[:3]: + matches = re.findall(pattern, text) + for match in matches: + if match not in key_info["files_analyzed"]: + key_info["files_analyzed"].append(match) + + # 提取决策 + if any(kw in text for kw in ["决定", "决策", "decision", "选择", "采用"]): + # 尝试提取决策内容 + decision_match = re.search(r'(决定|决策|decision)[::\s]*([^\n。.]{10,50})', text) + if decision_match: + key_info["decisions"].append(decision_match.group(2)[:50]) + else: + key_info["decisions"].append("做出决策") + + # 提取错误 + if any(kw in text for kw in ["错误", "失败", "error", "failed", "exception"]): + error_match = re.search(r'(错误|error|failed)[::\s]*([^\n]{10,50})', text, re.IGNORECASE) + if error_match: + key_info["errors"].append(error_match.group(2)[:50]) + else: + key_info["errors"].append("遇到错误") + + # 去重并限制数量 + for key in key_info: + key_info[key] = list(set(key_info[key]))[:5] + + return key_info + + def should_compress(self, messages: List[Dict[str, Any]]) -> bool: + """ + 检查是否需要压缩 + + Args: + messages: 消息列表 + + Returns: + 是否需要压缩 + """ + total_tokens = sum(get_message_tokens(msg) for msg in messages) + return total_tokens > self.max_total_tokens * COMPRESSION_THRESHOLD + + +# 便捷函数 +def compress_conversation( + messages: List[Dict[str, Any]], + max_tokens: int = MAX_TOTAL_TOKENS, +) -> List[Dict[str, Any]]: + """ + 压缩对话历史的便捷函数 + + Args: + messages: 消息列表 + max_tokens: 最大token数 + + Returns: + 压缩后的消息列表 + """ + compressor = MemoryCompressor(max_total_tokens=max_tokens) + return compressor.compress_history(messages) diff --git a/backend/app/services/llm/prompt_cache.py b/backend/app/services/llm/prompt_cache.py new file mode 100644 index 0000000..aec391f --- /dev/null +++ b/backend/app/services/llm/prompt_cache.py @@ -0,0 +1,333 @@ +""" +Prompt Caching 模块 + +为支持缓存的 LLM(如 Anthropic Claude)提供 Prompt 缓存功能。 +通过在系统提示词和早期对话中添加缓存标记,减少重复处理, +显著降低 Token 消耗和响应延迟。 + +支持的 LLM: +- Anthropic Claude (claude-3-5-sonnet, claude-3-opus, claude-3-haiku) +- OpenAI (部分模型支持) + +缓存策略: +- 短对话(<10轮): 仅缓存系统提示词 +- 中等对话(10-30轮): 缓存系统提示词 + 前5轮对话 +- 长对话(>30轮): 多个缓存点,动态调整 +""" + +import logging +from typing import Dict, Any, List, Optional, Tuple +from dataclasses import dataclass, field +from enum import Enum + +logger = logging.getLogger(__name__) + + +class CacheStrategy(str, Enum): + """缓存策略""" + NONE = "none" # 不缓存 + SYSTEM_ONLY = "system_only" # 仅缓存系统提示词 + SYSTEM_AND_EARLY = "system_early" # 缓存系统提示词和早期对话 + MULTI_POINT = "multi_point" # 多缓存点 + + +@dataclass +class CacheConfig: + """缓存配置""" + enabled: bool = True + strategy: CacheStrategy = CacheStrategy.SYSTEM_AND_EARLY + + # 缓存阈值 + min_system_prompt_tokens: int = 1000 # 系统提示词最小 token 数才启用缓存 + early_messages_count: int = 5 # 早期对话缓存的消息数 + + # 多缓存点配置 + multi_point_interval: int = 10 # 多缓存点间隔(消息数) + max_cache_points: int = 4 # 最大缓存点数量 + + +@dataclass +class CacheStats: + """缓存统计""" + cache_hits: int = 0 + cache_misses: int = 0 + cached_tokens: int = 0 + total_tokens: int = 0 + + @property + def hit_rate(self) -> float: + total = self.cache_hits + self.cache_misses + return self.cache_hits / total if total > 0 else 0.0 + + @property + def token_savings(self) -> float: + return self.cached_tokens / self.total_tokens if self.total_tokens > 0 else 0.0 + + +class PromptCacheManager: + """ + Prompt 缓存管理器 + + 负责: + 1. 检测 LLM 是否支持缓存 + 2. 根据对话长度选择缓存策略 + 3. 为消息添加缓存标记 + 4. 统计缓存效果 + """ + + # 支持缓存的模型 + CACHEABLE_MODELS = { + # Anthropic Claude + "claude-3-5-sonnet": True, + "claude-3-5-sonnet-20241022": True, + "claude-3-opus": True, + "claude-3-opus-20240229": True, + "claude-3-haiku": True, + "claude-3-haiku-20240307": True, + "claude-3-sonnet": True, + "claude-3-sonnet-20240229": True, + # OpenAI (部分支持) + "gpt-4-turbo": False, # 暂不支持 + "gpt-4o": False, + "gpt-4o-mini": False, + } + + # Anthropic 缓存标记 + ANTHROPIC_CACHE_CONTROL = {"type": "ephemeral"} + + def __init__(self, config: Optional[CacheConfig] = None): + self.config = config or CacheConfig() + self.stats = CacheStats() + self._cache_enabled_for_session = True + + def supports_caching(self, model: str, provider: str) -> bool: + """ + 检查模型是否支持缓存 + + Args: + model: 模型名称 + provider: 提供商名称 + + Returns: + 是否支持缓存 + """ + if not self.config.enabled: + return False + + # Anthropic Claude 支持缓存 + if provider.lower() in ["anthropic", "claude"]: + # 检查模型名称 + for cacheable_model in self.CACHEABLE_MODELS: + if cacheable_model in model.lower(): + return self.CACHEABLE_MODELS.get(cacheable_model, False) + + return False + + def determine_strategy( + self, + messages: List[Dict[str, Any]], + system_prompt_tokens: int = 0, + ) -> CacheStrategy: + """ + 根据对话状态确定缓存策略 + + Args: + messages: 消息列表 + system_prompt_tokens: 系统提示词的 token 数 + + Returns: + 缓存策略 + """ + if not self.config.enabled: + return CacheStrategy.NONE + + # 系统提示词太短,不值得缓存 + if system_prompt_tokens < self.config.min_system_prompt_tokens: + return CacheStrategy.NONE + + message_count = len(messages) + + # 短对话:仅缓存系统提示词 + if message_count < 10: + return CacheStrategy.SYSTEM_ONLY + + # 中等对话:缓存系统提示词和早期对话 + if message_count < 30: + return CacheStrategy.SYSTEM_AND_EARLY + + # 长对话:多缓存点 + return CacheStrategy.MULTI_POINT + + def add_cache_markers_anthropic( + self, + messages: List[Dict[str, Any]], + strategy: CacheStrategy, + ) -> List[Dict[str, Any]]: + """ + 为 Anthropic Claude 消息添加缓存标记 + + Anthropic 的缓存格式: + - 在 content 中使用 cache_control 字段 + - 支持 text 类型的 content block + + Args: + messages: 原始消息列表 + strategy: 缓存策略 + + Returns: + 添加了缓存标记的消息列表 + """ + if strategy == CacheStrategy.NONE: + return messages + + cached_messages = [] + + for i, msg in enumerate(messages): + new_msg = msg.copy() + + # 系统提示词缓存 + if msg.get("role") == "system": + new_msg = self._add_cache_to_message(new_msg) + cached_messages.append(new_msg) + continue + + # 早期对话缓存 + if strategy in [CacheStrategy.SYSTEM_AND_EARLY, CacheStrategy.MULTI_POINT]: + if i <= self.config.early_messages_count: + new_msg = self._add_cache_to_message(new_msg) + + # 多缓存点 + if strategy == CacheStrategy.MULTI_POINT: + if i > 0 and i % self.config.multi_point_interval == 0: + cache_point_count = i // self.config.multi_point_interval + if cache_point_count <= self.config.max_cache_points: + new_msg = self._add_cache_to_message(new_msg) + + cached_messages.append(new_msg) + + return cached_messages + + def _add_cache_to_message(self, msg: Dict[str, Any]) -> Dict[str, Any]: + """ + 为单条消息添加缓存标记 + + Args: + msg: 原始消息 + + Returns: + 添加了缓存标记的消息 + """ + content = msg.get("content", "") + + # 如果 content 是字符串,转换为 content block 格式 + if isinstance(content, str): + msg["content"] = [ + { + "type": "text", + "text": content, + "cache_control": self.ANTHROPIC_CACHE_CONTROL, + } + ] + elif isinstance(content, list): + # 已经是 content block 格式,为最后一个 block 添加缓存 + if content: + last_block = content[-1] + if isinstance(last_block, dict): + last_block["cache_control"] = self.ANTHROPIC_CACHE_CONTROL + + return msg + + def process_messages( + self, + messages: List[Dict[str, Any]], + model: str, + provider: str, + system_prompt_tokens: int = 0, + ) -> Tuple[List[Dict[str, Any]], bool]: + """ + 处理消息,添加缓存标记 + + Args: + messages: 原始消息列表 + model: 模型名称 + provider: 提供商名称 + system_prompt_tokens: 系统提示词 token 数 + + Returns: + (处理后的消息列表, 是否启用了缓存) + """ + if not self.supports_caching(model, provider): + return messages, False + + strategy = self.determine_strategy(messages, system_prompt_tokens) + + if strategy == CacheStrategy.NONE: + return messages, False + + # 根据提供商选择缓存方法 + if provider.lower() in ["anthropic", "claude"]: + cached_messages = self.add_cache_markers_anthropic(messages, strategy) + logger.debug(f"Applied {strategy.value} caching strategy for Anthropic") + return cached_messages, True + + return messages, False + + def update_stats( + self, + cache_creation_input_tokens: int = 0, + cache_read_input_tokens: int = 0, + total_input_tokens: int = 0, + ): + """ + 更新缓存统计 + + Args: + cache_creation_input_tokens: 缓存创建的 token 数 + cache_read_input_tokens: 缓存读取的 token 数 + total_input_tokens: 总输入 token 数 + """ + if cache_read_input_tokens > 0: + self.stats.cache_hits += 1 + self.stats.cached_tokens += cache_read_input_tokens + else: + self.stats.cache_misses += 1 + + self.stats.total_tokens += total_input_tokens + + def get_stats_summary(self) -> Dict[str, Any]: + """获取缓存统计摘要""" + return { + "cache_hits": self.stats.cache_hits, + "cache_misses": self.stats.cache_misses, + "hit_rate": f"{self.stats.hit_rate:.2%}", + "cached_tokens": self.stats.cached_tokens, + "total_tokens": self.stats.total_tokens, + "token_savings": f"{self.stats.token_savings:.2%}", + } + + +# 全局缓存管理器实例 +prompt_cache_manager = PromptCacheManager() + + +def estimate_tokens(text: str) -> int: + """ + 估算文本的 token 数量 + + 简单估算:英文约 4 字符/token,中文约 2 字符/token + + Args: + text: 文本内容 + + Returns: + 估算的 token 数 + """ + if not text: + return 0 + + # 统计中文字符 + chinese_chars = sum(1 for c in text if '\u4e00' <= c <= '\u9fff') + other_chars = len(text) - chinese_chars + + # 中文约 2 字符/token,其他约 4 字符/token + return int(chinese_chars / 2 + other_chars / 4) diff --git a/backend/data/vector_db/ef6dc788-cc23-4a4d-b1a9-5ce4b32248b8/data_level0.bin b/backend/data/vector_db/ef6dc788-cc23-4a4d-b1a9-5ce4b32248b8/data_level0.bin index e89c95a8a1e8f6caaefa663c67b2e9212eb6c04f..dd3082eb38a7eef6982bbec3c4bf5fa5637db5fa 100644 GIT binary patch literal 628400 zcmeIu0Sy2E0K%a6Pi+o2h(KY$fB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM z7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b* z1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd z0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwA zz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEj zFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r z3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@ z0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VK zfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5 zV8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM z7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b* z1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd z0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwA zz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEj zFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r z3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@ z0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VK zfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5 zV8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM z7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b* z1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd z0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwA zz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEj zFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r z3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@ z0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VK zfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5 zV8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM z7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b* z1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd z0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwA zz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEj zFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r z3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@ z0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VK zfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5 zV8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM z7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b* z1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd z0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwA zz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEj zFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r z3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@ z0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VK zfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5 zV8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM z7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b* z1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd z0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwA zz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEj zFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r z3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@ z0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VK Hz{|h@mp1?b literal 628400 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%0A)<()_0v@?%iuikj-)=QuN=D@e6L}a&4 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

VpHYswEJ`=9)?(?s8ZUzFcQ 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

QbYbO&U=TVB0-50RYR8E|=%cT%AL=G_ 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 diff --git a/backend/test_msg.md b/backend/test_msg.md new file mode 100644 index 0000000..50f4b83 --- /dev/null +++ b/backend/test_msg.md @@ -0,0 +1,809 @@ +14:22:48 +🔗 Connected to audit stream +14:23:04 +Orchestrator +Thought: 我需要对这个复杂的多组件项目进行安全审计。项目包含多个引擎(MediaEngine, MindSpider, QueryEngine, ReportEngine, InsightEngine, ForumEngine)和相关的 Streamlit 报告应用,使用 Python 和 JavaScript 开发。首先,我需要全面了解项目的结构、技术栈和入口点,以便确定审计的重点区域。我将从 recon Agent 开始,收集项目的基本信息。 +14:23:08 +📤 调度 recon Agent: 分析项目结构、技术栈、入口点和主要组件... +Orchestrator +14:23:18 +Recon +Thought: 我需要开始收集项目信息。首先,我应该了解项目的整体结构,包括目录布局和文件组织。这将帮助我识别项目的基本架构和组件。我将从根目录开始,使用list_files工具查看项目结构。 +14:23:22 +Completed: list_files +Recon +4ms +14:23:26 +Recon +Thought: 从项目结构来看,这是一个包含多个组件的复杂项目,主要包括: +1. MediaEngine - 一个Python模块,似乎处理媒体相关功能 +2. MindSpider - 包含爬虫功能,特别是DeepSentimentCrawling +3. 根目录有一些Python应用文件,如app.py、config.py等 +4. 有Docker相关文件,表明这是一个容器化项目 + +我需要进一步了解技术栈和项目配置。首先,我应该查看根目录的配置文件,如requirements.txt、config.py和app.py,以了解主要的技术栈和入口点。 +14:23:34 +Completed: read_file +Recon +3ms +Input: +{ + "file_path": "requirements.txt" +} + +Output: +null +14:23:35 +Recon +Thought: 从requirements.txt可以看出这是一个复杂的项目,使用了多种技术栈: +1. Web框架:Flask、Streamlit、FastAPI +2. 实时通信:Flask-SocketIO、python-socketio +3. 数据库:MySQL、PostgreSQL、SQLite、Redis +4. 爬虫工具:Playwright、BeautifulSoup等 +5. 机器学习:PyTorch、Transformers等 +6. 其他工具:OpenAI API、Tavily搜索等 + +现在我需要查看主要的入口点文件,如app.py,了解应用程序的主要结构和功能。 +14:23:38 +Completed: read_file +Recon +1ms +14:23:39 +Recon +Thought: 从app.py文件的前500行可以看出,这是一个Flask应用,用于统一管理三个Streamlit应用。它包含以下关键信息: + +1. 使用Flask作为主框架,Flask-SocketIO用于实时通信 +2. 配置了多个API密钥和引擎(Insight Engine、Media Engine、Query Engine等) +3. 使用MindSpider模块进行爬虫功能 +4. 尝试导入ReportEngine作为蓝图 +5. 配置了多个数据库连接参数 + +我需要继续查看app.py的其余部分,以了解完整的应用结构和入口点。同时,我也应该查看config.py文件来了解配置结构。 +14:23:44 +Completed: read_file +Recon +0ms +14:23:44 +Recon +Thought: 从app.py的501-1000行可以看出,这个应用管理多个Streamlit子进程: + +1. 有四个主要进程:insight、media、query和forum +2. 每个进程都有独立的端口(insight: 8501, media: 8502, query: 8503) +3. 使用队列进行进程间通信 +4. 有日志记录功能,将输出写入文件并通过WebSocket发送到前端 +5. 有启动Streamlit应用的函数 + +我需要继续查看app.py的剩余部分,以了解完整的API路由和功能。同时,我也应该查看config.py文件来了解配置结构。 +14:23:48 +Completed: read_file +Recon +2ms +14:23:49 +Recon +Thought: 从app.py的最后部分可以看出,这个应用提供了多个API端点: + +1. 日志相关API:读取和测试日志 +2. ForumEngine相关API:启动/停止论坛监控,获取论坛日志 +3. 搜索API:统一搜索接口,可以向运行中的不同引擎发送搜索请求 +4. 每个Streamlit应用都有对应的API端口(insight: 8601, media: 8602, query: 8603) + +现在我需要查看config.py文件来了解配置结构,以及查看SingleEngineApp目录来了解各个引擎的实现。 +14:23:55 +Completed: read_file +Recon +1ms +14:23:56 +Recon +Thought: 从config.py文件可以看出,这是一个使用Pydantic Settings进行配置管理的项目,包含多个API密钥和配置项: + +1. 数据库配置:支持MySQL和PostgreSQL +2. 多个LLM引擎配置:Insight Engine、Media Engine、Query Engine、Report Engine等 +3. 网络工具配置:Tavily API、Bocha API等 +4. 每个引擎都有对应的API密钥、基础URL和模型名称 + +现在我需要查看SingleEngineApp目录,了解各个引擎的实现,以及查看MindSpider目录,了解爬虫功能的实现。 +14:24:02 +Completed: list_files +Recon +0ms +14:24:03 +Recon +Thought: SingleEngineApp目录包含三个Streamlit应用文件,分别对应三个不同的引擎: +1. insight_engine_streamlit_app.py - 洞察引擎 +2. media_engine_streamlit_app.py - 媒体引擎 +3. query_engine_streamlit_app.py - 查询引擎 + +我需要查看这些文件来了解各个引擎的具体实现和功能。首先查看洞察引擎的实现。 +14:24:07 +Completed: read_file +Recon +0ms +14:24:07 +Recon +Thought: 从insight_engine_streamlit_app.py可以看出,这是一个Streamlit应用,为Insight Agent提供Web界面。主要功能包括: + +1. 接收URL参数中的查询内容 +2. 使用DeepSearchAgent执行深度搜索和分析 +3. 生成报告结构并处理段落 +4. 执行反思循环以改进分析结果 +5. 生成最终报告 + +现在我需要查看媒体引擎和查询引擎的实现,以及MindSpider目录,了解爬虫功能的实现。 +14:24:12 +Completed: read_file +Recon +1ms +14:26:02 +Recon +Thought: 从media_engine_streamlit_app.py可以看出,这是Media Agent的Streamlit界面,主要功能包括: + +1. 支持多模态能力,可以处理视频、图文、直播等内容 +2. 支持多种搜索引擎:BochaAPI和AnspireAPI +3. 使用Gemini模型作为默认LLM +4. 接收URL参数中的查询内容并自动执行搜索 + +现在我需要查看查询引擎的实现,以及MindSpider目录,了解爬虫功能的实现。 +14:26:07 +Completed: read_file +Recon +2ms +14:26:10 +Recon +Thought: 从query_engine_streamlit_app.py可以看出,这是Query Agent的Streamlit界面,主要功能包括: + +1. 具备强大的网页搜索能力 +2. 注重国内外资源相结合理解舆情 +3. 使用DeepSeek模型作为默认LLM +4. 使用Tavily API进行网络搜索 +5. 与Insight Agent类似,也执行报告结构生成、段落处理和反思循环 + +现在我需要查看MindSpider目录,了解爬虫功能的实现。 +14:26:14 +Completed: list_files +Recon +2ms +14:26:14 +Recon +Thought: MindSpider目录包含一个复杂的爬虫系统,主要结构包括: + +1. DeepSentimentCrawling/MediaCrawler - 媒体爬虫模块 + - 包含缓存系统 + - 数据库模块 + - 代理系统 + - 各种工具函数 + - 主程序入口 + +2. DeepSentimentCrawling - 深度情感爬虫 + - 关键词管理器 + - 平台爬虫 + - 主程序 + +3. 根目录的MindSpider文件 + - 配置文件 + - 主程序 + +我需要查看MindSpider的主程序和配置文件,了解爬虫系统的整体架构和功能。 +14:26:18 +Completed: read_file +Recon +2ms +14:26:20 +Recon +Thought: 从MindSpider/main.py可以看出,这是一个AI爬虫项目的主程序,集成了两个核心模块: + +1. BroadTopicExtraction - 广泛主题提取 +2. DeepSentimentCrawling - 深度情感爬虫 + +主要功能包括: +- 检查配置和数据库连接 +- 初始化数据库 +- 运行爬虫任务 +- 支持多种数据库(MySQL、PostgreSQL) + +现在我需要查看MindSpider的配置文件,了解具体的配置项。 +14:26:24 +Completed: read_file +Recon +1ms +14:26:25 +Recon +Thought: 从MindSpider/config.py可以看出,这是一个使用Pydantic Settings进行配置管理的模块,主要包含: + +1. 数据库配置:支持MySQL和PostgreSQL +2. MindSpider API配置:包括API密钥、基础URL和模型名称 +3. 使用.env文件进行环境变量管理 + +现在我需要查看DeepSentimentCrawling模块,了解爬虫的具体实现。 +14:26:29 +Completed: read_file +Recon +2ms +14:27:09 +Recon +Thought: 从DeepSentimentCrawling/main.py可以看出,这是一个深度情感爬取模块,主要功能包括: + +1. 支持多个平台:小红书(xhs)、抖音(dy)、快手(ks)、哔哩哔哩(bili)、微博(wb)、贴吧(tieba)、知乎(zhihu) +2. 基于关键词进行爬取 +3. 支持每日爬取任务和单个平台爬取 +4. 使用关键词管理器(KeywordManager)和平台爬虫(PlatformCrawler) + +现在我需要查看平台爬虫的实现,了解具体的爬取逻辑。 +14:27:19 +Completed: read_file +Recon +3ms +14:27:19 +✅ recon Agent 完成 +Orchestrator +14:27:39 +Orchestrator +Thought: Recon Agent 的执行结果没有返回任何有用的信息,项目结构、技术栈、入口点等都是空的。这可能是因为 Agent 需要更具体的指导。我将尝试一个更具体的 recon 任务,重点关注配置文件和主要代码文件,因为这些通常包含安全敏感信息。 +14:27:43 +📤 调度 recon Agent: 重点分析配置文件(config.py, .env.example)、主要入口文件(app.py, report_engine_only.py)和依赖文件(requirements.txt)... +Orchestrator +14:28:44 +Recon +Thought: 我需要开始收集项目信息。首先,我应该了解项目的整体结构,包括目录布局和文件组织。这将帮助我识别项目类型、技术栈和关键组件。我将从根目录开始,递归列出所有文件和目录。 +14:28:51 +Completed: list_files +Recon +4ms +14:28:51 +Recon +Thought: 从文件列表中,我可以看到这是一个复杂的项目,包含多个组件: +1. MediaEngine - 一个Python模块,似乎用于媒体处理和报告生成 +2. MindSpider/DeepSentimentCrawling - 一个网络爬虫项目,用于情感分析爬取 +3. 根目录有一些配置文件和主程序 + +我需要进一步了解项目的技术栈和依赖关系。首先,我应该查看根目录的配置文件,如requirements.txt、package.json等,以了解项目使用的依赖和技术栈。 +14:28:56 +Completed: read_file +Recon +1ms +14:29:05 +Recon +Thought: 从requirements.txt文件中,我可以看到这是一个复杂的项目,使用了多种技术栈: + +1. Web框架:Flask、Streamlit、FastAPI +2. 数据库:MySQL (pymysql, aiomysql)、SQLite (aiosqlite)、Redis、PostgreSQL (asyncpg) +3. 爬虫工具:Playwright、BeautifulSoup、lxml等 +4. 机器学习:PyTorch、Transformers、scikit-learn等 +5. 其他工具:OpenAI API、数据处理、可视化等 + +现在我应该查看MindSpider/DeepSentimentCrawling目录下的package.json文件,以了解前端或Node.js相关的依赖。 +14:29:28 +Completed: read_file +Recon +0ms +14:29:29 +Recon +Thought: package.json文件显示这是一个使用VitePress的文档项目,没有前端应用依赖。现在我应该查看MindSpider/DeepSentimentCrawler目录下的requirements.txt文件,以了解爬虫组件的具体依赖。 +14:29:32 +Completed: read_file +Recon +1ms +14:29:32 +Recon +Thought: 现在我已经了解了项目的主要依赖。接下来,我应该查看主要的入口点文件,如app.py、main.py等,以了解应用程序的启动点和主要功能。首先查看根目录的app.py文件。 +14:29:35 +Completed: read_file +Recon +2ms +14:29:35 +Recon +Thought: app.py文件很长,我需要查看更多内容来了解应用程序的主要功能和入口点。让我继续读取文件的剩余部分。 +14:29:38 +Completed: read_file +Recon +2ms +14:29:39 +Recon +Thought: app.py文件非常长,我需要继续查看剩余部分以了解完整的应用程序结构。让我继续读取文件的最后部分。 +14:29:41 +Completed: read_file +Recon +1ms +14:29:42 +Recon +Thought: app.py文件很长,我已经看到了大部分内容,但还需要查看最后部分以了解完整的应用程序结构。让我继续读取文件的最后部分。 +14:29:43 +Completed: read_file +Recon +1ms +14:29:46 +Recon +Thought: 现在我已经了解了app.py文件的主要内容。这是一个Flask应用,用于管理多个Streamlit应用。接下来,我应该查看MindSpider/main.py文件,以了解爬虫组件的主要功能。 +14:29:51 +Completed: read_file +Recon +4ms +14:29:53 +Recon +Thought: MindSpider/main.py文件也很长,我需要查看更多内容来了解爬虫组件的主要功能。让我继续读取文件的剩余部分。 +14:29:55 +Completed: read_file +Recon +2ms +14:29:56 +Recon +Thought: 我已经了解了MindSpider/main.py文件的主要内容。这是一个爬虫项目的主程序,集成了BroadTopicExtraction和DeepSentimentCrawling两个核心模块。接下来,我应该查看配置文件,以了解项目的配置结构和敏感信息。 +14:29:59 +Completed: read_file +Recon +6ms +Input: +{ + "file_path": "config.py" +} + +Output: +null +14:30:03 +Recon +Thought: config.py文件很长,我需要查看更多内容以了解完整的配置结构。让我继续读取文件的剩余部分。 +14:30:04 +Completed: read_file +Recon +1ms +14:30:05 +Recon +Thought: 现在我已经了解了config.py文件的主要内容。这是一个使用pydantic-settings管理的全局配置文件,包含了数据库配置、LLM API配置、网络工具配置等。接下来,我应该查看.env.example文件,以了解环境变量的配置示例。 +14:30:09 +Completed: read_file +Recon +1ms +14:30:10 +Recon +Thought: 现在我已经了解了项目的主要配置文件。接下来,我应该查看一些关键的入口点文件,如SingleEngineApp目录下的文件,以了解各个引擎的应用程序结构。首先查看SingleEngineApp目录结构。 +14:30:13 +Completed: list_files +Recon +1ms +14:30:14 +Recon +Thought: SingleEngineApp目录包含三个Streamlit应用程序文件。我应该查看其中一个文件以了解应用程序的结构和功能。让我先查看insight_engine_streamlit_app.py文件。 +14:30:17 +Completed: read_file +Recon +0ms +14:30:17 +✅ recon Agent 完成 +Orchestrator +14:30:17 +Orchestrator +尝试直接分析项目中的关键文件,看看能否获取更多信息。我将尝试使用 analysis Agent 来分析项目中的主要文件,特别是配置文件和入口文件。 +14:30:20 +📤 调度 analysis Agent: 分析项目中的关键文件,包括config.py, app.py, report_engine_only.py等主要入口文件... +Orchestrator +14:30:26 +Analysis +Thought: 我需要首先了解这个项目的结构和内容,以便制定合适的分析策略。由于项目信息有限,我不知道项目的具体语言和框架,所以我应该先探索项目结构,然后根据文件类型选择合适的分析工具。 +14:30:28 +Completed: list_files +Analysis +2ms +14:30:50 +Analysis +Thought: 从项目结构来看,这是一个Python项目,主要包含两个主要部分:MediaEngine和MindSpider。项目中有多个Python文件,包括配置文件、主程序文件和工具文件。我注意到有一些敏感文件如.env.example,可能包含配置信息。接下来我应该先查看主要的入口文件和配置文件,了解项目的基本功能,然后进行安全分析。 +14:30:57 +Completed: read_file +Analysis +4ms +14:30:58 +Analysis +Thought: 我已经看到了app.py文件的部分内容,这是一个Flask应用,用于管理三个Streamlit应用。我注意到一些安全相关信息: +1. Flask应用的SECRET_KEY是硬编码的 +2. 有多个API密钥配置 +3. 使用了subprocess模块 +4. 有socketio通信 + +我需要继续查看app.py的其余部分,然后检查配置文件和其他关键文件。让我先读取app.py的剩余部分。 +14:31:02 +Completed: read_file +Analysis +2ms +14:31:02 +Analysis +Thought: 我已经看到了app.py的部分内容,这是一个Flask应用,用于管理多个Streamlit应用。我注意到一些潜在的安全问题: +1. 硬编码的SECRET_KEY +2. 使用subprocess模块启动子进程 +3. 文件操作可能存在路径遍历风险 + +让我继续查看配置文件,了解项目的配置情况,然后使用semgrep进行静态分析,这样可以更系统地发现潜在的安全问题。 +14:31:05 +Completed: read_file +Analysis +1ms +14:31:18 +Analysis +Thought: 我已经看到了config.py文件的部分内容,这是一个使用pydantic-settings的配置文件,包含多个API密钥和配置项。我注意到配置文件中有很多API密钥字段,这些如果被泄露会很危险。 + +现在我已经对项目有了基本了解,接下来我应该使用semgrep进行静态分析,这样可以系统地发现潜在的安全问题。semgrep可以检测多种漏洞类型,包括SQL注入、XSS、命令注入等。 +14:31:22 +Completed: semgrep_scan +Analysis +2406ms +Input: +{ + "rules": "auto", + "max_results": 50 +} + +Output: +null +14:31:27 +Analysis +Thought: semgrep工具未安装,我需要使用其他工具进行安全分析。我可以使用pattern_match工具来检测常见的危险模式,或者使用bandit_scan(如果项目是Python的)。从项目结构来看,这是一个Python项目,所以我可以尝试使用bandit_scan。 +14:31:29 +Completed: bandit_scan +Analysis +4ms +14:31:31 +Analysis +Thought: 看来bandit工具也未安装。我需要使用其他方法进行安全分析。我可以使用pattern_match工具来检测常见的危险模式,或者使用search_code工具搜索特定的关键字。让我先使用pattern_match来检测一些常见的危险模式。 + +首先,我需要读取一些关键文件的内容,然后使用pattern_match进行分析。让我先读取MindSpider/main.py,因为从项目结构看,这似乎是一个主要组件。 +14:31:35 +Completed: read_file +Analysis +5ms +14:31:57 +Analysis +Thought: 我已经看到了MindSpider/main.py文件的部分内容,这是一个AI爬虫项目的主程序。我注意到一些潜在的安全问题: +1. 使用了subprocess模块 +2. 数据库连接字符串构建 +3. 使用了pymysql和sqlalchemy进行数据库操作 + +让我继续读取这个文件的剩余部分,然后使用pattern_match工具来检测常见的危险模式。 +14:32:01 +Completed: read_file +Analysis +1ms +14:32:03 +Analysis +Thought: 我已经看到了MindSpider/main.py文件的更多内容,这个文件使用了subprocess模块来执行其他Python脚本,这可能是命令注入的风险点。现在让我使用pattern_match工具来检测常见的危险模式,包括SQL注入、命令注入、路径遍历等。 +14:32:41 +Completed: pattern_match +Analysis +4ms +14:32:42 +Analysis +Thought: pattern_match工具没有检测到已知的危险模式,但我注意到代码中使用了subprocess.run来执行命令,这仍然是一个潜在的安全风险。让我继续检查其他关键文件,特别是那些处理用户输入或执行外部命令的文件。 + +让我先检查MindSpider/DeepSentimentCrawling/main.py文件,因为从项目结构看,这似乎是另一个主要组件。 +14:32:46 +Completed: read_file +Analysis +2ms +14:32:47 +Analysis +Thought: 我已经看到了MindSpider/DeepSentimentCrawling/main.py文件的部分内容,这是一个爬虫模块。我注意到它使用了PlatformCrawler来执行爬取任务,这可能涉及网络请求。让我继续查看这个文件的剩余部分,然后检查platform_crawler.py文件,因为它可能包含更多的安全敏感代码。 +14:32:51 +Completed: read_file +Analysis +0ms +14:32:52 +Analysis +Thought: 我已经看到了MindSpider/DeepSentimentCrawling/main.py文件的完整内容,这是一个爬虫模块,它使用了PlatformCrawler来执行爬取任务。现在我需要检查platform_crawler.py文件,因为它可能包含更多的安全敏感代码,特别是网络请求和用户输入处理部分。 +14:32:55 +Completed: read_file +Analysis +1ms +14:32:57 +Analysis +Thought: 我已经看到了platform_crawler.py文件的部分内容,这个文件负责配置和调用MediaCrawler进行多平台爬取。我注意到一些潜在的安全问题: +1. 使用subprocess模块执行外部命令 +2. 文件操作可能存在路径遍历风险 +3. 数据库配置信息被硬编码写入文件 + +让我继续查看这个文件的剩余部分,然后使用search_code工具搜索一些特定的危险模式,如eval、exec、subprocess等。 +14:33:01 +Completed: read_file +Analysis +20ms +14:33:03 +Analysis +Thought: 我已经看到了platform_crawler.py文件的更多内容,这个文件使用了subprocess.run来执行MediaCrawler的main.py脚本,这是一个潜在的安全风险。现在让我使用search_code工具搜索一些特定的危险模式,如eval、exec、subprocess等,以便更全面地发现潜在的安全问题。 +14:33:06 +Completed: search_code +Analysis +287ms +14:33:07 +Analysis +Thought: 我发现了多个使用subprocess的地方,这些是潜在的安全风险点。现在让我搜索其他危险模式,如eval、exec、query等,以便更全面地发现潜在的安全问题。 +14:33:10 +Completed: search_code +Analysis +200ms +14:33:11 +Analysis +Thought: 我发现了一些使用eval的地方,大多数是Playwright的page.evaluate方法,用于在浏览器中执行JavaScript代码。这些通常是安全的,因为它们是在浏览器上下文中执行的。现在让我搜索exec和其他潜在的危险模式。 +14:33:14 +Completed: search_code +Analysis +157ms +14:33:16 +Analysis +Thought: 我发现了一些使用exec的地方,大多数是数据库操作或执行外部命令。现在让我搜索query和其他潜在的危险模式,特别是SQL查询相关的代码。 +14:33:18 +Completed: search_code +Analysis +5ms +14:33:20 +Analysis +Thought: 我已经搜索了subprocess、eval、exec和query等关键字,发现了一些潜在的安全风险点。现在让我使用dataflow_analysis工具来分析一些关键的数据流,特别是用户输入如何流向可能危险的函数。 +14:33:27 +Completed: dataflow_analysis +Analysis +65427ms +14:34:34 +Analysis +让我继续分析其他潜在的安全风险点。我将检查一些关键的Python文件,看看是否有明显的安全问题。 +14:34:36 +Completed: read_file +Analysis +6ms +14:34:38 +Analysis +Thought: 我已经看到了keyword_manager.py文件的部分内容,这个文件负责管理关键词和数据库操作。我注意到它使用了SQLAlchemy进行数据库操作,并且使用了参数化查询,这有助于防止SQL注入。让我继续查看这个文件的剩余部分,然后检查其他可能存在安全问题的文件。 +14:34:42 +Completed: read_file +Analysis +2ms +14:35:04 +Analysis +Thought: 我已经看到了keyword_manager.py文件的完整内容,这个文件使用了SQLAlchemy进行数据库操作,并且使用了参数化查询,这有助于防止SQL注入。现在让我检查.env.example文件,看看是否有敏感信息泄露的风险。 +14:35:07 +Completed: read_file +Analysis +5ms +14:35:09 +Analysis +Thought: 我已经看到了.env.example文件,这是一个环境变量配置示例文件,包含了各种API密钥和配置项。这个文件本身不包含敏感信息,因为它只是示例,但需要注意在实际使用时,.env文件可能包含敏感信息。 + +现在让我使用gitleaks_scan工具来检查代码中是否有密钥泄露或其他敏感信息。 +14:35:12 +Completed: gitleaks_scan +Analysis +73ms +14:35:18 +Analysis +Thought: 由于gitleaks工具未安装,我无法使用它来检测密钥泄露。让我使用search_code工具搜索一些常见的敏感信息模式,如API密钥、密码等。 +14:35:20 +Completed: search_code +Analysis +31ms +14:35:22 +Analysis +Thought: 我已经搜索了api_key关键字,发现了很多API密钥的配置。这些API密钥是通过环境变量或配置文件管理的,这是好的做法。现在让我搜索一些其他可能的敏感信息,如密码、密钥等。 +14:35:25 +Completed: search_code +Analysis +460ms +14:35:27 +Analysis +Thought: 我已经搜索了password关键字,发现了很多数据库密码的配置。这些密码是通过环境变量或配置文件管理的,这是好的做法。现在让我总结一下我的分析结果,并提供一个安全漏洞报告。 + +基于我的分析,我发现了以下潜在的安全问题: + +1. 使用subprocess执行外部命令 - 在多个文件中发现 +2. 硬编码的SECRET_KEY - 在app.py中 +3. 数据库配置信息被硬编码写入文件 - 在platform_crawler.py中 +4. 使用Playwright的page.evaluate执行JavaScript代码 - 在多个爬虫客户端中 + +让我使用think工具来总结我的分析结果。 +14:35:44 +Completed: think +Analysis +0ms +14:35:54 +Analysis +Thought: 基于我的分析,我现在可以提供一个完整的安全漏洞报告。我将使用Final Answer格式来总结我的发现。 +14:36:51 +Analysis +Thought: 我需要使用正确的工具格式来输出我的分析结果。让我使用think工具来总结我的分析,然后提供最终的安全漏洞报告。 +14:37:02 +Completed: think +Analysis +0ms +14:37:05 +Analysis +Thought: 我已经完成了对项目的安全分析,并记录了我的评估结果。现在我将使用reflect工具来总结我的分析进展和发现。 +14:37:26 +Completed: reflect +Analysis +165ms +14:37:35 +Analysis +Thought: 我已经完成了对项目的安全分析,并记录了我的发现和评估。现在我将总结我的分析结果,并提供一个完整的安全漏洞报告。 + +基于我的分析,我发现了以下潜在的安全问题: + +1. **命令注入风险**:在多个文件中使用subprocess.run执行外部命令,包括app.py、MindSpider/main.py和platform_crawler.py。虽然当前命令参数是硬编码的,但如果未来修改为接受用户输入,可能存在命令注入风险。 + +2. **硬编码的SECRET_KEY**:在app.py第36行,Flask应用的SECRET_KEY是硬编码的,这降低了密钥的安全性。 + +3. **数据库配置信息硬编码写入文件**:在platform_crawler.py中,数据库配置信息被硬编码写入MediaCrawler的配置文件中,这可能导致敏感信息泄露。 + +4. **JavaScript代码执行**:在多个爬虫客户端中使用Playwright的page.evaluate方法执行JavaScript代码,虽然这些通常是在浏览器上下文中执行的,但如果用户输入被不当处理,可能存在XSS风险。 + +总体而言,项目在敏感信息管理方面做得较好,使用了参数化查询防止SQL注入,但需要注意subprocess使用和硬编码密钥的问题。 +14:37:51 +Analysis +Action +14:37:59 +Completed: think +Analysis +19ms +14:37:59 +✅ analysis Agent 完成 +Orchestrator +14:38:00 +Orchestrator +Thought: analysis Agent 也没有返回任何有用的信息。这可能是因为项目文件结构复杂,或者 Agent 需要更具体的指导。我将尝试一个更直接的方法,先查看项目的根目录文件,特别是配置文件和主要入口文件,然后再进行深入分析。 +14:38:05 +📤 调度 recon Agent: 查看项目根目录下的所有文件,特别关注config.py, app.py, report_engine_only.py, requirements.txt等关键文件... +Orchestrator +14:38:15 +Recon +Thought: 我需要开始收集项目信息。首先,我应该了解项目的整体结构,包括目录布局和主要文件。这将帮助我识别项目类型、技术栈和可能的入口点。我将从列出根目录开始。 +14:38:38 +Completed: list_files +Recon +15ms +14:38:39 +Recon +Thought: 从文件列表中,我可以看到这是一个包含多个组件的项目,主要有: +1. MediaEngine - 一个Python模块,包含LLM相关功能 +2. MindSpider - 一个爬虫项目,包含DeepSentimentCrawling组件 +3. 一些根级别的Python文件如app.py, config.py等 + +我需要进一步了解项目的技术栈和结构。首先,我应该查看根目录下的配置文件,如requirements.txt、package.json等,以了解项目依赖和使用的框架。 +14:38:44 +Completed: read_file +Recon +0ms +14:38:45 +Recon +Thought: 从requirements.txt文件中,我可以看到这是一个舆情分析系统,使用了多种技术栈: +1. Web框架:Flask, FastAPI, Streamlit +2. 数据库:MySQL, PostgreSQL, SQLite, Redis +3. 爬虫技术:Playwright, BeautifulSoup等 +4. LLM集成:OpenAI API +5. 数据处理:pandas, numpy等 + +现在我需要查看MindSpider/DeepSentimentCrawling/MediaCrawler目录下的package.json文件,以了解前端依赖。 +14:38:51 +Completed: read_file +Recon +8ms +14:38:52 +Recon +Thought: package.json文件显示这是一个使用VitePress的文档项目,没有前端应用依赖。现在我需要查看主要的入口点文件,如app.py、MindSpider/main.py等,以了解应用程序的入口点和主要功能。 +14:38:55 +Completed: read_file +Recon +3ms +14:38:57 +Recon +Thought: app.py文件很长,我需要继续查看更多内容来了解应用程序的主要功能和入口点。让我继续读取文件的剩余部分。 +14:39:20 +Completed: read_file +Recon +2ms +14:39:21 +Recon +Thought: 我需要继续查看app.py文件的更多内容,特别是路由定义部分,以了解应用程序的API端点。让我继续读取文件的后续部分。 +14:39:24 +Completed: read_file +Recon +2ms +14:39:45 +Recon +Thought: 我需要继续查看app.py文件,特别是路由定义部分,以了解应用程序的API端点。让我继续读取文件的后续部分。 +14:39:50 +Completed: read_file +Recon +6ms +14:40:57 +Recon +Thought: 我需要继续查看app.py文件的最后部分,以了解所有的API路由。让我读取文件的剩余部分。 +14:41:04 +Completed: read_file +Recon +23ms +14:41:37 +Recon +Thought: 我需要查看app.py文件的最后部分,以了解所有的API路由。让我读取文件的最后部分。 +14:41:40 +Completed: read_file +Recon +5ms +14:42:03 +Recon +Thought: 现在我已经查看了app.py文件,了解了主要的API路由和应用程序结构。接下来,我需要查看MindSpider/main.py文件,以了解爬虫组件的主要功能。 +14:42:08 +Completed: read_file +Recon +3ms +14:42:16 +Recon +Thought: 我需要继续查看MindSpider/main.py文件的剩余部分,以了解爬虫组件的主要功能。 +14:42:18 +Completed: read_file +Recon +1ms +14:43:42 +Recon +Thought: 我需要继续查看MindSpider/main.py文件的剩余部分,以了解爬虫组件的主要功能。 +14:43:45 +Completed: read_file +Recon +4ms +14:43:48 +Recon +Thought: 现在我已经查看了MindSpider/main.py文件,了解了爬虫组件的主要功能。接下来,我需要查看配置文件,以了解项目的配置结构和敏感信息。 +14:43:51 +Completed: read_file +Recon +5ms +14:43:53 +Recon +Thought: 我需要继续查看config.py文件的剩余部分,以了解项目的完整配置结构。 +14:43:55 +Completed: read_file +Recon +0ms +14:43:57 +Recon +Thought: 现在我已经查看了config.py文件,了解了项目的配置结构。接下来,我需要查看.env.example文件,以了解环境变量的配置示例。 +14:44:00 +Completed: read_file +Recon +14ms +14:44:00 +✅ recon Agent 完成 +Orchestrator +14:44:00 +Orchestrator +Thought: 多次尝试使用 recon 和 analysis Agent 都没有返回任何有用的信息。这可能是因为项目结构复杂或者 Agent 无法正确访问文件。我将尝试一个更直接的方法,先查看项目的根目录文件,特别关注配置文件和主要入口文件,然后再进行深入分析。 +14:44:04 +📤 调度 recon Agent: 查看项目根目录下的所有文件,特别关注config.py, app.py, report_engine_only.py, requirements.txt等关键文件... +Orchestrator +14:45:23 +Recon +Thought: 我需要开始收集项目信息。首先,我应该了解项目的整体结构,包括目录布局和主要文件。这将帮助我识别项目类型、技术栈和关键组件。我将从列出根目录的内容开始。 +14:45:26 +Completed: list_files +Recon +2ms +14:46:04 +Recon +Thought: 我已经看到了项目的根目录结构。这是一个多引擎的项目,包含多个子模块如ForumEngine、InsightEngine、MediaEngine等。我注意到根目录下有app.py和config.py,这表明这可能是一个Python项目。接下来,我应该查看requirements.txt文件来了解项目的依赖和技术栈,同时也要查看config.py来了解项目配置。 +14:46:07 +Completed: read_file +Recon +0ms +14:46:08 +Recon +Thought: 从requirements.txt文件可以看出,这是一个基于Python的舆情分析系统,使用了多种技术栈: +1. Web框架:Flask、Streamlit、FastAPI +2. 数据库:MySQL (pymysql, aiomysql)、SQLite (aiosqlite)、Redis、PostgreSQL (asyncpg) +3. 数据处理:pandas, numpy, jieba (中文分词) +4. 机器学习:torch, transformers, sentence-transformers等 +5. 爬虫:playwright, beautifulsoup4等 +6. 可视化:plotly, matplotlib, wordcloud + +接下来,我应该查看config.py文件来了解项目的配置信息,然后查看app.py作为主入口点。 +14:46:14 +Completed: read_file +Recon +1ms +14:46:15 +Recon +Thought: 从config.py文件可以看出,这是一个复杂的舆情分析系统,包含多个AI代理引擎(Insight Engine、Media Engine、Query Engine、Report Engine、MindSpider等)。系统使用Flask作为Web框架,支持多种数据库(MySQL、PostgreSQL),并集成了多个LLM API(如Kimi、Gemini、DeepSeek等)。 + +接下来,我应该查看app.py文件,这是主入口点,可以了解系统的路由和主要功能。 +14:46:18 +Completed: read_file +Recon \ No newline at end of file diff --git a/backend/架构升级方案.md b/backend/架构升级方案.md new file mode 100644 index 0000000..9f96f57 --- /dev/null +++ b/backend/架构升级方案.md @@ -0,0 +1,527 @@ +DeepAudit Agent 架构重构升级方案 +一、现状分析 +当前 DeepAudit 架构特点 +DeepAudit 目前采用基于 LangGraph 的固定流程图架构。整个审计流程按照 Recon(信息收集)→ Analysis(漏洞分析)→ Verification(漏洞验证)→ Report(报告生成)的线性顺序执行。每个阶段由一个专门的 Agent 负责,Agent 之间通过 TaskHandoff 机制传递结构化的上下文信息。 + +这种架构的优点是流程清晰、易于理解和调试,但存在几个明显的局限性: + +第一,流程过于固定。无论面对什么类型的项目或漏洞,都必须走完整个流程,无法根据实际发现动态调整策略。比如发现了一个 SQL 注入线索,无法立即深入分析,必须等待 Analysis 阶段统一处理。 + +第二,Agent 专业化程度不足。Analysis Agent 需要同时处理所有类型的漏洞,从 SQL 注入到 XSS 到 SSRF,这导致系统提示词过于庞大,LLM 难以在每种漏洞类型上都表现出专家级水平。 + +第三,缺乏动态协作能力。Agent 之间只能按照预设的顺序传递信息,无法根据需要动态创建新的 Agent 或在 Agent 之间进行实时通信。 + +Strix 架构的启示 +Strix 是一个开源的 AI 安全测试 Agent 项目,它采用了完全不同的架构理念。通过深入分析 Strix 的设计,我们可以获得以下关键启示: + +Strix 的核心是动态 Agent 树结构。根 Agent 可以根据任务需要随时创建子 Agent,每个子 Agent 专注于特定的漏洞类型或任务。子 Agent 完成后向父 Agent 汇报结果,父 Agent 可以根据结果决定是否需要创建更多子 Agent 或进行其他操作。 + +Strix 的另一个亮点是模块化的专业知识系统。它为每种漏洞类型都准备了详细的 Jinja2 模板,包含该漏洞的检测方法、利用技术、绕过手段、验证步骤等专业知识。创建 Agent 时可以指定加载哪些知识模块,让 Agent 在特定领域具备专家级能力。 + +此外,Strix 还实现了 Agent 间的消息传递机制、完善的状态管理、工具的沙箱执行、LLM 调用优化等高级特性。 + +二、升级后的整体架构 +核心设计理念 +升级后的 DeepAudit 将采用"动态 Agent 协作 + 专业知识模块 + 智能编排"的三层架构。 + +最底层是专业知识模块层,包含各种漏洞类型、框架、技术栈的专业知识库。这些知识以模板形式存储,可以按需加载到 Agent 的系统提示词中。 + +中间层是 Agent 执行层,包含可动态创建和销毁的 Agent 实例。每个 Agent 都有完整的生命周期管理,可以执行任务、调用工具、与其他 Agent 通信。 + +最上层是智能编排层,负责根据审计目标和实时发现来协调整个审计流程,决定何时创建什么类型的 Agent,如何分配任务,何时结束审计。 + +动态 Agent 树 +与当前固定的四阶段流程不同,升级后的系统将采用动态 Agent 树结构。 + +审计开始时,系统创建一个根 Agent(Root Agent)。根 Agent 首先进行初步的信息收集,了解项目的技术栈、目录结构、入口点等基本信息。然后根据收集到的信息,根 Agent 决定需要创建哪些专业子 Agent。 + +例如,如果发现项目使用了 SQL 数据库,根 Agent 可能会创建一个专门的 SQL 注入检测 Agent;如果发现有用户输入直接渲染到页面的代码,可能会创建一个 XSS 检测 Agent;如果发现有 HTTP 请求的代码,可能会创建一个 SSRF 检测 Agent。 + +每个子 Agent 专注于自己的任务领域。当子 Agent 发现可疑的漏洞线索时,它可以进一步创建验证子 Agent 来确认漏洞是否真实存在。验证通过后,还可以创建报告子 Agent 来生成正式的漏洞报告。 + +这种树状结构的好处是:任务可以无限细分,每个 Agent 都能专注于自己擅长的领域;发现和验证可以并行进行,提高效率;根据实际情况动态调整策略,而不是机械地执行固定流程。 + +Agent 间通信机制 +升级后的系统将实现完善的 Agent 间通信机制。 + +每个 Agent 都有一个消息队列,其他 Agent 可以向这个队列发送消息。消息类型包括:查询消息(请求信息)、指令消息(要求执行某个操作)、信息消息(分享发现或状态)。 + +当 Agent 处于等待状态时,它会检查自己的消息队列。如果有新消息,Agent 会处理消息并可能恢复执行。这种机制使得 Agent 之间可以进行实时协作,而不仅仅是单向的结果传递。 + +例如,SQL 注入检测 Agent 在分析过程中发现某个函数可能存在问题,但需要了解这个函数的调用上下文。它可以向根 Agent 发送查询消息,请求提供相关信息。根 Agent 收到消息后,可以调用代码搜索工具获取信息,然后回复给 SQL 注入检测 Agent。 + +Agent 状态管理 +每个 Agent 都有完整的状态管理,状态信息包括: + +基本信息:Agent 的唯一标识、名称、父 Agent 标识、创建时间等。 + +任务信息:当前任务描述、任务上下文、从父 Agent 继承的信息等。 + +执行状态:当前迭代次数、最大迭代限制、运行状态(运行中、等待中、已完成、失败、已停止)等。 + +对话历史:与 LLM 的完整对话记录,包括系统提示词、用户消息、助手回复等。 + +执行记录:已执行的动作列表、观察结果列表、错误记录等。 + +发现列表:该 Agent 发现的所有漏洞和可疑点。 + +这种完整的状态管理使得 Agent 可以被暂停和恢复,可以被序列化和持久化,也便于调试和审计。 + +三、专业知识模块系统 +模块化设计 +专业知识模块是升级后架构的核心创新之一。我们将为不同的漏洞类型、框架、技术栈创建专门的知识模块。 + +漏洞类型模块包括:SQL 注入、XSS、SSRF、IDOR、认证绕过、远程代码执行、路径遍历、XXE、CSRF、竞态条件、反序列化、业务逻辑漏洞等。每个模块都包含该漏洞类型的完整知识体系。 + +框架知识模块包括:FastAPI、Django、Flask、Express、Next.js、Spring、Laravel 等主流框架。每个模块包含该框架的安全特性、常见漏洞模式、最佳实践等。 + +技术栈模块包括:Supabase、Firebase、GraphQL、gRPC、WebSocket 等。每个模块包含该技术的安全考量和常见问题。 + +模块内容结构 +以 SQL 注入模块为例,它应该包含以下内容: + +漏洞概述:SQL 注入的定义、危害、影响范围。 + +检测方法:错误型注入检测、布尔型注入检测、时间型注入检测、带外注入检测的具体技术和判断标准。 + +数据库特定知识:MySQL、PostgreSQL、MSSQL、Oracle 等不同数据库的特有语法、函数、利用技术。 + +绕过技术:WAF 绕过、过滤绕过、编码绕过等高级技术。 + +ORM 和查询构建器:各种 ORM 框架中容易出现 SQL 注入的 API 和模式。 + +验证步骤:如何确认漏洞真实存在,如何构造 PoC,如何评估影响。 + +误报识别:哪些情况容易被误判为 SQL 注入,如何排除误报。 + +修复建议:参数化查询、ORM 正确用法、输入验证等修复方案。 + +模块加载机制 +创建 Agent 时,可以指定该 Agent 需要加载哪些知识模块。系统会将这些模块的内容动态注入到 Agent 的系统提示词中。 + +为了控制提示词长度和保持 Agent 的专注度,每个 Agent 最多加载 5 个知识模块。这个限制迫使我们为每个 Agent 选择最相关的知识,而不是试图让一个 Agent 掌握所有知识。 + +模块之间可以有依赖关系。例如,FastAPI 框架模块可能依赖 Python 安全基础模块;GraphQL 模块可能依赖 API 安全基础模块。加载模块时会自动处理这些依赖。 + +四、工具系统升级 +统一的工具注册机制 +升级后的工具系统将采用装饰器模式进行统一注册。每个工具都需要提供:工具名称、功能描述、参数定义、返回值说明。 + +工具按类别组织,包括:文件操作类(读取文件、搜索文件、列出目录)、代码分析类(模式匹配、数据流分析、AST 分析)、外部扫描类(Semgrep、Bandit、Gitleaks 等)、验证执行类(沙箱命令执行、HTTP 请求)、协作类(创建子 Agent、发送消息、等待消息)、推理类(思考工具)、报告类(创建漏洞报告)。 + +Think 工具 +Think 工具是从 Strix 借鉴的关键创新。这是一个让 Agent 进行深度推理的工具,Agent 可以用它来: + +分析复杂情况:当面对复杂的代码逻辑或不确定的漏洞线索时,Agent 可以调用 Think 工具进行深入思考。 + +规划下一步行动:在执行具体操作之前,先用 Think 工具规划策略。 + +评估发现的严重性:发现可疑点后,用 Think 工具评估其真实性和影响。 + +决定是否需要创建子 Agent:当任务变得复杂时,用 Think 工具分析是否需要分解任务。 + +Think 工具的输出会被记录到 Agent 的对话历史中,帮助 LLM 保持思路的连贯性。 + +漏洞报告工具 +漏洞报告工具是正式记录漏洞的唯一方式。只有通过这个工具创建的漏洞才会被计入最终报告。这个设计确保了漏洞报告的规范性和完整性。 + +报告工具要求提供完整的漏洞信息:漏洞类型、严重程度、标题、详细描述、文件位置、代码片段、PoC、影响分析、修复建议等。 + +通常只有专门的报告 Agent 才会调用这个工具,确保漏洞在被正式报告之前已经经过了充分的验证。 + +沙箱执行 +涉及代码执行或网络请求的工具都在沙箱环境中运行。沙箱提供资源隔离(CPU、内存、网络限制)、文件系统隔离、超时控制等安全保障。 + +沙箱执行通过 Tool Server 机制实现。Agent 发送工具调用请求到 Tool Server,Tool Server 在沙箱中执行工具并返回结果。这种设计使得即使工具执行出现问题,也不会影响主系统的稳定性。 + +五、LLM 调用优化 +Prompt Caching +对于支持 Prompt Caching 的 LLM(如 Anthropic Claude),系统会自动为系统提示词和早期对话添加缓存标记。这样在多轮对话中,这些内容只需要处理一次,后续调用可以直接使用缓存,显著降低 Token 消耗和响应延迟。 + +缓存策略会根据对话长度动态调整。对于短对话,只缓存系统提示词;对于长对话,会在关键位置添加多个缓存点。 + +Memory Compression +当对话历史变得很长时,系统会自动进行压缩。压缩策略包括: + +移除冗余信息:重复的工具调用结果、过长的代码输出等会被截断或摘要。 + +合并相似消息:连续的同类型消息可能被合并。 + +保留关键信息:重要的发现、决策点、错误信息等会被优先保留。 + +压缩后的对话历史仍然保持语义完整性,LLM 可以理解之前发生了什么,但 Token 消耗大大降低。 + +智能重试 +LLM 调用可能因为各种原因失败:网络问题、速率限制、服务不可用等。系统实现了智能重试机制: + +对于可重试的错误(如速率限制),会等待适当时间后重试。 + +对于不可重试的错误(如认证失败),会立即报错并提供清晰的错误信息。 + +重试时会使用指数退避策略,避免对 LLM 服务造成过大压力。 + +六、审计流程重构 +启动阶段 +用户发起审计请求后,系统首先创建根 Agent。根 Agent 加载通用的安全审计知识模块和项目相关的框架知识模块。 + +根 Agent 的第一个任务是信息收集:扫描项目目录结构、识别技术栈、找出入口点、分析依赖关系。这个阶段类似于当前的 Recon 阶段,但更加灵活。 + +任务分解阶段 +根据信息收集的结果,根 Agent 决定如何分解审计任务。它会考虑: + +项目使用了哪些技术?需要创建哪些专业 Agent? + +有哪些高风险区域?应该优先分析哪些部分? + +项目规模如何?需要多少并行 Agent? + +根 Agent 会创建一批初始的子 Agent,每个子 Agent 负责特定的漏洞类型或代码区域。 + +并行分析阶段 +多个子 Agent 并行工作,各自在自己的专业领域进行深入分析。 + +每个子 Agent 都有自己的工作循环:思考当前状态、选择工具执行、观察结果、决定下一步。这个循环会持续进行,直到 Agent 认为任务完成或达到迭代限制。 + +子 Agent 在分析过程中可能会发现需要进一步调查的线索。这时它可以创建更专业的子 Agent 来处理,形成多层的 Agent 树。 + +验证阶段 +当分析 Agent 发现可疑的漏洞时,它会创建验证 Agent 来确认漏洞是否真实存在。 + +验证 Agent 会尝试构造 PoC、进行数据流追踪、在沙箱中测试等。验证通过后,验证 Agent 会创建报告 Agent 来生成正式的漏洞报告。 + +如果验证失败,验证 Agent 会将结果反馈给父 Agent,父 Agent 可以决定是否需要进一步调查或将其标记为误报。 + +汇总阶段 +当所有子 Agent 都完成工作后,根 Agent 会汇总所有发现,生成最终的审计报告。 + +报告包括:发现的所有漏洞(按严重程度排序)、安全评分、技术栈分析、高风险区域标注、修复建议优先级等。 + +七、可观测性和调试 +完整的事件追踪 +系统会记录所有重要事件:Agent 创建和销毁、工具调用和结果、LLM 请求和响应、Agent 间消息、状态变更等。 + +这些事件可以实时推送到前端,让用户看到审计的进展。也可以持久化到数据库,用于后续分析和审计。 + +Agent 树可视化 +前端可以展示当前的 Agent 树结构,显示每个 Agent 的状态、任务、发现数量等信息。用户可以点击任何 Agent 查看其详细信息和对话历史。 + +调试模式 +在调试模式下,系统会记录更详细的信息,包括完整的 LLM 提示词和响应、工具执行的详细日志、状态变更的完整历史等。这些信息对于排查问题和优化系统非常有价值。 + +八、与现有架构的兼容 +渐进式迁移 +升级不需要一次性完成,可以渐进式进行。 + +第一步,保持现有的 LangGraph 流程不变,但将 Agent 的状态管理升级为新的模型。 + +第二步,引入专业知识模块系统,让现有的 Analysis Agent 可以加载不同的知识模块。 + +第三步,在 Analysis 阶段内部引入子 Agent 机制,允许创建专业的漏洞检测子 Agent。 + +第四步,逐步放开流程限制,让 Agent 可以更灵活地决定下一步操作。 + +第五步,完全迁移到动态 Agent 树架构。 + +保留 LangGraph 的优势 +LangGraph 提供了很好的状态管理和检查点机制,这些在新架构中仍然有价值。我们可以将 LangGraph 用于根 Agent 的高层编排,而在子 Agent 层面使用更灵活的动态创建机制。 + +九、预期收益 +更深度的漏洞发现 +专业知识模块让每个 Agent 都具备安全专家级别的知识。专注于单一漏洞类型的 Agent 比通用 Agent 更容易发现深层次的问题。 + +更高的效率 +并行的 Agent 执行比串行流程更快。动态任务分解避免了在无关区域浪费时间。 + +更低的成本 +Prompt Caching 和 Memory Compression 显著降低 Token 消耗。专业化的 Agent 使用更短的提示词就能达到更好的效果。 + +更好的可扩展性 +添加新的漏洞类型只需要创建新的知识模块。支持新的框架只需要添加框架知识模块。整个系统的扩展不需要修改核心架构。 + +更强的可解释性 +完整的事件追踪和 Agent 树可视化让用户清楚地了解系统在做什么。Think 工具的输出展示了 Agent 的推理过程。 + +这个升级方案借鉴了 Strix 的核心设计理念,同时保留了 DeepAudit 的既有优势,通过渐进式迁移降低风险,最终实现一个更强大、更灵活、更专业的安全审计 Agent 系统。 + + +--- + +## 十、实施进度记录 + +### 已完成的工作 (2024-12) + +#### 1. 核心模块系统 ✅ +- `core/state.py`: 增强的Agent状态管理,支持完整生命周期 +- `core/registry.py`: Agent注册表和动态Agent树管理 +- `core/message.py`: Agent间通信机制(消息总线) + +#### 2. 专业知识模块系统 ✅ (基于RAG) +采用模块化文件组织,统一使用RAG进行知识检索: + +``` +knowledge/ +├── base.py # 基础定义(KnowledgeDocument, KnowledgeCategory) +├── loader.py # 知识加载器 +├── rag_knowledge.py # RAG检索系统 +├── tools.py # 知识查询工具 +├── vulnerabilities/ # 漏洞类型知识 +│ ├── injection.py # SQL注入、NoSQL注入、命令注入、代码注入 +│ ├── xss.py # 反射型XSS、存储型XSS、DOM型XSS +│ ├── auth.py # 认证绕过、IDOR、访问控制失效 +│ ├── crypto.py # 弱加密、硬编码凭证 +│ ├── ssrf.py # SSRF +│ ├── deserialization.py # 不安全的反序列化 +│ ├── path_traversal.py # 路径遍历 +│ ├── xxe.py # XXE +│ └── race_condition.py # 竞态条件 +└── frameworks/ # 框架安全知识 + ├── fastapi.py # FastAPI安全 + ├── django.py # Django安全 + ├── flask.py # Flask安全 + ├── express.py # Express.js安全 + ├── react.py # React安全 + └── supabase.py # Supabase安全 +``` + +#### 3. Agent基类增强 ✅ +- 支持动态Agent树(parent_id, 子Agent创建) +- Agent间消息通信 +- TaskHandoff协作机制 +- 知识模块加载 +- Memory Compression集成 + +#### 4. 工具系统 ✅ +- `thinking_tool.py`: Think和Reflect工具 +- `reporting_tool.py`: 漏洞报告工具 +- `agent_tools.py`: Agent协作工具 + - CreateSubAgentTool: 动态创建子Agent + - SendMessageTool: Agent间消息发送 + - ViewAgentGraphTool: 查看Agent树 + - WaitForMessageTool: 等待消息 + - AgentFinishTool: 子Agent完成报告 + +#### 5. LLM调用优化 ✅ +- `memory_compressor.py`: 对话历史压缩 + - 自动检测是否需要压缩 + - 保留关键信息(发现、工具使用、决策、错误) + - 智能摘要生成 +- Agent基类集成自动压缩 + +#### 6. Orchestrator Agent ✅ +- LLM驱动的编排决策 +- 动态调度子Agent +- ReAct模式执行 + +### 已完成的工作 (2024-12 续) + +#### 7. Prompt Caching ✅ +- `llm/prompt_cache.py`: Prompt 缓存管理器 + - 支持 Anthropic Claude 的 Prompt Caching + - 动态缓存策略(SYSTEM_ONLY, SYSTEM_AND_EARLY, MULTI_POINT) + - 缓存统计和效果追踪 + - Token 估算工具 +- LiteLLM 适配器集成缓存支持 + +#### 8. 动态Agent树执行器 ✅ +- `core/executor.py`: 完整的执行器实现 + - `DynamicAgentExecutor`: 动态Agent树执行器 + - 并行Agent执行(带信号量控制) + - 任务依赖管理 + - 执行结果汇总 + - 超时和取消处理 + - `SubAgentExecutor`: 子Agent执行器 + - 从父Agent创建和执行子Agent + - 并行子Agent执行 + - 结果收集和汇总 + - `ExecutionTask`: 执行任务数据结构 + - `ExecutionResult`: 执行结果数据结构 + +#### 9. Agent状态持久化 ✅ +- `core/persistence.py`: 持久化模块 + - `AgentStatePersistence`: 状态持久化管理器 + - 文件系统持久化 + - 数据库持久化(可选) + - 检查点列表和清理 + - `CheckpointManager`: 检查点管理器 + - 自动检查点(按迭代间隔) + - 检查点恢复 + - 状态回滚 + +#### 10. 增强的Agent协作工具 ✅ +- `CreateSubAgentTool`: 增强版 + - 支持立即执行模式 + - 集成SubAgentExecutor + - 上下文传递 +- `RunSubAgentsTool`: 批量执行子Agent + - 并行/顺序执行 + - 结果汇总 +- `CollectSubAgentResultsTool`: 收集子Agent结果 + +#### 11. 数据库模型扩展 ✅ +- `AgentCheckpoint`: Agent检查点模型 + - 状态数据存储 + - 执行统计 + - 检查点类型(auto/manual/error/final) +- `AgentTreeNode`: Agent树节点模型 + - 树结构记录 + - 执行状态追踪 + - 结果汇总 +- Alembic迁移脚本: `007_add_agent_checkpoint_tables.py` + +#### 12. API 端点 ✅ +- `GET /agent-tasks/{task_id}/agent-tree`: Agent树查询API + - 返回完整的Agent树结构 + - 支持运行时内存查询和数据库查询 + - 包含执行状态和发现统计 +- `GET /agent-tasks/{task_id}/checkpoints`: 检查点列表API + - 支持按Agent ID过滤 + - 分页支持 +- `GET /agent-tasks/{task_id}/checkpoints/{checkpoint_id}`: 检查点详情API + - 返回完整的Agent状态数据 + +### 已完成的工作 (2024-12 续2) + +#### 13. 前端 Agent 审计页面 ✅ (Strix-inspired Terminal UI) +- `frontend/src/shared/api/agentTasks.ts`: 扩展 API + - `AgentTreeNode`, `AgentTreeResponse` 类型定义 + - `AgentCheckpoint`, `CheckpointDetail` 类型定义 + - `getAgentTree()`: 获取 Agent 树结构 + - `getAgentCheckpoints()`: 获取检查点列表 + - `getCheckpointDetail()`: 获取检查点详情 + +- `frontend/src/pages/AgentAudit.tsx`: 统一的 Agent 审计页面 (参考 Strix TUI 设计) + - **布局**: 左侧活动日志 (75%) + 右侧 Agent 树和统计 (25%) + - **启动画面**: ASCII Art + 动画加载效果 + - **活动日志**: + - 实时流式显示 Agent 思考过程 + - 工具调用和结果展示 + - 漏洞发现高亮 + - 自动滚动控制 + - 可折叠的日志条目 + - **Agent 树可视化**: + - 树状结构展示 + - 节点状态图标(运行中/已完成/失败/等待) + - 发现数量徽章 + - 节点选择交互 + - **实时统计面板**: + - 进度百分比 + - 文件分析进度 + - Token 使用量 + - 发现数量 + - 严重程度分布 + - **创建任务对话框**: 选择项目后直接跳转到实时流页面 + - **任务控制**: 停止/取消任务 + +- `frontend/src/app/routes.tsx`: 路由配置 + - `/agent-audit`: 启动画面 + 创建任务 + - `/agent-audit/:taskId`: 任务实时流页面 + +- `frontend/src/components/layout/Sidebar.tsx`: 侧边栏导航 + - 新增 Agent 审计入口图标 + +### 已完成的工作 (2024-12 续3) + +#### 14. 执行架构切换 ✅ +- **移除旧的 LangGraph 固定流程架构** +- **启用新的动态 Agent 树架构** +- `backend/app/api/v1/endpoints/agent_tasks.py`: + - `_execute_agent_task()` 重写为使用 `OrchestratorAgent` + - OrchestratorAgent 作为大脑,动态调度子 Agent + - 子 Agent: ReconAgent, AnalysisAgent, VerificationAgent + - 新增辅助函数: `_get_user_config()`, `_initialize_tools()`, `_collect_project_info()`, `_save_findings()`, `_calculate_security_score()` + +### 待完成的工作 + +#### 1. 前端增强 +- 知识模块选择 UI(创建任务时) +- 检查点恢复功能 +- 导出报告功能 + +#### 2. 测试和优化 +- 单元测试 +- 集成测试 +- 性能优化 +- 并发执行压力测试 + +#### 3. 文档 +- API文档更新 +- 架构图更新 +- 使用指南 + +--- + +## 十一、架构升级总结 + +### 已实现的核心功能 + +1. **Prompt Caching** - 为 Claude 等 LLM 提供缓存支持,减少 Token 消耗 +2. **动态 Agent 树执行** - OrchestratorAgent 作为大脑,动态调度子 Agent +3. **Agent 状态持久化** - 文件系统和数据库双重持久化 +4. **检查点机制** - 自动检查点、状态恢复、执行历史追踪 +5. **增强的协作工具** - 子 Agent 创建、批量执行、结果收集 +6. **完整的 API 支持** - Agent 树查询、检查点管理 +7. **旧架构已移除** - 不再使用 LangGraph 固定流程,完全切换到动态 Agent 树 + +### 文件清单 + +``` +backend/app/services/ +├── llm/ +│ ├── __init__.py # 模块导出 +│ ├── prompt_cache.py # 🆕 Prompt Caching +│ ├── memory_compressor.py # Memory Compression +│ └── adapters/ +│ └── litellm_adapter.py # 集成 Prompt Caching +│ +├── agent/ +│ ├── core/ +│ │ ├── __init__.py # 模块导出 +│ │ ├── state.py # Agent 状态管理 +│ │ ├── registry.py # Agent 注册表 +│ │ ├── message.py # Agent 间通信 +│ │ ├── executor.py # 🆕 动态 Agent 树执行器 +│ │ └── persistence.py # 🆕 状态持久化 +│ │ +│ ├── tools/ +│ │ ├── __init__.py # 模块导出 +│ │ ├── agent_tools.py # 🔄 增强的协作工具 +│ │ ├── thinking_tool.py # Think/Reflect 工具 +│ │ └── reporting_tool.py # 漏洞报告工具 +│ │ +│ ├── knowledge/ # 专业知识模块 +│ │ ├── vulnerabilities/ # 漏洞类型知识 +│ │ └── frameworks/ # 框架安全知识 +│ │ +│ └── agents/ +│ ├── base.py # Agent 基类 +│ ├── orchestrator.py # 编排 Agent +│ ├── analysis.py # 分析 Agent +│ └── verification.py # 验证 Agent + +backend/app/models/ +└── agent_task.py # 🔄 新增 AgentCheckpoint, AgentTreeNode + +backend/app/api/v1/endpoints/ +└── agent_tasks.py # 🔄 新增 Agent 树和检查点 API + +backend/alembic/versions/ +└── 007_add_agent_checkpoint_tables.py # 🆕 数据库迁移 + +frontend/src/shared/api/ +└── agentTasks.ts # 🔄 扩展 Agent 树和检查点 API + +frontend/src/pages/ +└── AgentAudit.tsx # 🆕 统一的 Agent 审计页面 (Strix-inspired) + +frontend/src/app/ +└── routes.tsx # 🔄 新增 Agent 审计路由 + +frontend/src/components/layout/ +└── Sidebar.tsx # 🔄 新增 Agent 审计导航图标 +``` + +### 下一步计划 + +1. 测试前端页面渲染和流式事件 +2. 知识模块选择 UI +3. 检查点恢复功能 diff --git a/frontend/src/app/routes.tsx b/frontend/src/app/routes.tsx index cfd4072..a2c52c0 100644 --- a/frontend/src/app/routes.tsx +++ b/frontend/src/app/routes.tsx @@ -59,6 +59,12 @@ const routes: RouteConfig[] = [ }, { name: "Agent审计", + path: "/agent-audit", + element: , + visible: true, + }, + { + name: "Agent审计任务", path: "/agent-audit/:taskId", element: , visible: false, diff --git a/frontend/src/components/agent/CreateAgentTaskDialog.tsx b/frontend/src/components/agent/CreateAgentTaskDialog.tsx new file mode 100644 index 0000000..d1858a4 --- /dev/null +++ b/frontend/src/components/agent/CreateAgentTaskDialog.tsx @@ -0,0 +1,593 @@ +/** + * Agent 审计任务创建对话框 + * 专门用于 Agent Audit 页面,UI 风格与终端界面保持一致 + */ + +import { useState, useEffect, useMemo } from "react"; +import { useNavigate } from "react-router-dom"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Badge } from "@/components/ui/badge"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Search, + ChevronRight, + GitBranch, + Package, + Globe, + Loader2, + Bot, + Settings2, + Play, + Upload, + FolderOpen, +} from "lucide-react"; +import { toast } from "sonner"; +import { api } from "@/shared/config/database"; +import { createAgentTask } from "@/shared/api/agentTasks"; +import { isRepositoryProject, isZipProject } from "@/shared/utils/projectUtils"; +import { getZipFileInfo, type ZipFileMeta } from "@/shared/utils/zipStorage"; +import { validateZipFile } from "@/features/projects/services/repoZipScan"; +import type { Project } from "@/shared/types"; +import FileSelectionDialog from "@/components/audit/FileSelectionDialog"; + +interface CreateAgentTaskDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +const DEFAULT_EXCLUDES = [ + "node_modules/**", + ".git/**", + "dist/**", + "build/**", + "*.log", +]; + +export default function CreateAgentTaskDialog({ + open, + onOpenChange, +}: CreateAgentTaskDialogProps) { + const navigate = useNavigate(); + + // 状态 + const [projects, setProjects] = useState([]); + const [loadingProjects, setLoadingProjects] = useState(true); + const [selectedProjectId, setSelectedProjectId] = useState(""); + const [searchTerm, setSearchTerm] = useState(""); + const [branch, setBranch] = useState("main"); + const [branches, setBranches] = useState([]); + const [loadingBranches, setLoadingBranches] = useState(false); + const [excludePatterns, setExcludePatterns] = useState(DEFAULT_EXCLUDES); + const [showAdvanced, setShowAdvanced] = useState(false); + const [creating, setCreating] = useState(false); + + // ZIP 文件状态 + const [zipFile, setZipFile] = useState(null); + const [storedZipInfo, setStoredZipInfo] = useState(null); + const [useStoredZip, setUseStoredZip] = useState(true); + + // 文件选择状态 + const [selectedFiles, setSelectedFiles] = useState(); + const [showFileSelection, setShowFileSelection] = useState(false); + + const selectedProject = projects.find((p) => p.id === selectedProjectId); + + // 加载项目列表 + useEffect(() => { + if (open) { + setLoadingProjects(true); + api.getProjects() + .then((data) => { + setProjects(data.filter((p: Project) => p.is_active)); + }) + .catch(() => { + toast.error("加载项目列表失败"); + }) + .finally(() => setLoadingProjects(false)); + + // 重置状态 + setSelectedProjectId(""); + setSearchTerm(""); + setBranch("main"); + setExcludePatterns(DEFAULT_EXCLUDES); + setShowAdvanced(false); + setZipFile(null); + setStoredZipInfo(null); + setSelectedFiles(undefined); + } + }, [open]); + + // 加载分支列表 + useEffect(() => { + const loadBranches = async () => { + // 使用 selectedProjectId 从 projects 中获取最新的 project 对象 + const project = projects.find((p) => p.id === selectedProjectId); + if (!project || !isRepositoryProject(project)) { + setBranches([]); + return; + } + + setLoadingBranches(true); + try { + const result = await api.getProjectBranches(project.id); + console.log("[Branch] 加载分支结果:", result); + + if (result.error) { + console.warn("[Branch] 加载分支警告:", result.error); + toast.error(`加载分支失败: ${result.error}`); + } + + setBranches(result.branches); + if (result.default_branch) { + setBranch(result.default_branch); + } + } catch (err) { + const msg = err instanceof Error ? err.message : "未知错误"; + console.error("[Branch] 加载分支失败:", msg); + toast.error(`加载分支失败: ${msg}`); + setBranches([project.default_branch || "main"]); + } finally { + setLoadingBranches(false); + } + }; + + loadBranches(); + }, [selectedProjectId, projects]); + + // 加载 ZIP 文件信息 + useEffect(() => { + const loadZipInfo = async () => { + if (!selectedProject || !isZipProject(selectedProject)) { + setStoredZipInfo(null); + return; + } + + try { + const info = await getZipFileInfo(selectedProject.id); + setStoredZipInfo(info); + setUseStoredZip(info.has_file); + } catch { + setStoredZipInfo(null); + } + }; + + loadZipInfo(); + }, [selectedProject?.id]); + + // 过滤项目 + const filteredProjects = useMemo(() => { + if (!searchTerm) return projects; + const term = searchTerm.toLowerCase(); + return projects.filter( + (p) => + p.name.toLowerCase().includes(term) || + p.description?.toLowerCase().includes(term) + ); + }, [projects, searchTerm]); + + // 是否可以开始 + const canStart = useMemo(() => { + if (!selectedProject) return false; + if (isZipProject(selectedProject)) { + return (useStoredZip && storedZipInfo?.has_file) || !!zipFile; + } + return !!selectedProject.repository_url && !!branch.trim(); + }, [selectedProject, useStoredZip, storedZipInfo, zipFile, branch]); + + // 创建任务 + const handleCreate = async () => { + if (!selectedProject) return; + + setCreating(true); + try { + const agentTask = await createAgentTask({ + project_id: selectedProject.id, + name: `Agent审计-${selectedProject.name}`, + branch_name: isRepositoryProject(selectedProject) ? branch : undefined, + exclude_patterns: excludePatterns, + target_files: selectedFiles, + verification_level: "sandbox", + }); + + onOpenChange(false); + toast.success("Agent 审计任务已创建"); + navigate(`/agent-audit/${agentTask.id}`); + } catch (err) { + const msg = err instanceof Error ? err.message : "创建失败"; + toast.error(msg); + } finally { + setCreating(false); + } + }; + + // 处理文件上传 + const handleFileChange = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (file) { + const validation = validateZipFile(file); + if (!validation.valid) { + toast.error(validation.error || "文件无效"); + e.target.value = ""; + return; + } + setZipFile(file); + setUseStoredZip(false); + } + }; + + return ( +

+ + {/* Header */} + + +
+ +
+
+ New Agent Audit +

+ AI-Powered Security Analysis +

+
+
+
+ +
+ {/* 项目选择 */} +
+
+ + Select Project + + + {filteredProjects.length} available + +
+ + {/* 搜索框 */} +
+ + setSearchTerm(e.target.value)} + className="pl-9 h-10 bg-gray-900/50 border-gray-800 text-white font-mono placeholder:text-gray-600 focus:border-primary focus:ring-0" + /> +
+ + {/* 项目列表 */} + + {loadingProjects ? ( +
+ +
+ ) : filteredProjects.length === 0 ? ( +
+ + {searchTerm ? "No matches" : "No projects"} +
+ ) : ( +
+ {filteredProjects.map((project) => ( + setSelectedProjectId(project.id)} + /> + ))} +
+ )} +
+
+ + {/* 配置区域 */} + {selectedProject && ( +
+ {/* 仓库项目:分支选择 */} + {isRepositoryProject(selectedProject) && ( +
+ + Branch + {loadingBranches ? ( +
+ + Loading... +
+ ) : ( + + )} +
+ )} + + {/* ZIP 项目:文件选择 */} + {isZipProject(selectedProject) && ( +
+
+ + ZIP File +
+ + {storedZipInfo?.has_file && ( +
setUseStoredZip(true)} + > +
+
+ + {storedZipInfo.original_filename} + + + Stored + +
+
+ )} + +
+
+ )} + + {/* 高级选项 */} + + + + + Advanced Options + + + {/* 文件选择 */} + {(() => { + const isRepo = isRepositoryProject(selectedProject); + const isZip = isZipProject(selectedProject); + const hasStoredZip = storedZipInfo?.has_file; + // 可以选择文件的条件:仓库项目 或 ZIP项目使用已存储文件 + const canSelectFiles = isRepo || (isZip && useStoredZip && hasStoredZip); + + return ( +
+
+

+ Scan Scope +

+

+ {selectedFiles + ? `${selectedFiles.length} files selected` + : "All files"} +

+
+
+ {selectedFiles && canSelectFiles && ( + + )} + +
+
+ ); + })()} + + {/* 排除模式 */} +
+
+ + Exclude Patterns + + +
+ +
+ {excludePatterns.map((p) => ( + setExcludePatterns((prev) => prev.filter((x) => x !== p))} + > + {p} × + + ))} +
+ + { + if (e.key === "Enter" && e.currentTarget.value) { + const val = e.currentTarget.value.trim(); + if (val && !excludePatterns.includes(val)) { + setExcludePatterns((prev) => [...prev, val]); + } + e.currentTarget.value = ""; + } + }} + /> +
+
+
+
+ )} +
+ + {/* Footer */} +
+ + +
+ + + {/* 文件选择对话框 */} + +
+ ); +} + +// 项目列表项 +function ProjectItem({ + project, + selected, + onSelect, +}: { + project: Project; + selected: boolean; + onSelect: () => void; +}) { + const isRepo = isRepositoryProject(project); + + return ( +
+
+ {isRepo ? ( + + ) : ( + + )} +
+ +
+
+ + {project.name} + + + {isRepo ? "REPO" : "ZIP"} + +
+ {project.description && ( +

+ {project.description} +

+ )} +
+ + {selected && ( +
+ )} +
+ ); +} diff --git a/frontend/src/components/audit/CreateTaskDialog.tsx b/frontend/src/components/audit/CreateTaskDialog.tsx index 3e89caa..9ed1d78 100644 --- a/frontend/src/components/audit/CreateTaskDialog.tsx +++ b/frontend/src/components/audit/CreateTaskDialog.tsx @@ -30,7 +30,6 @@ import { Upload, FolderOpen, Settings2, - Play, Package, Globe, Shield, @@ -111,28 +110,39 @@ export default function CreateTaskDialog({ // 加载分支列表 useEffect(() => { const loadBranches = async () => { - if (!selectedProject || !isRepositoryProject(selectedProject)) { + // 使用 selectedProjectId 从 projects 中获取最新的 project 对象 + const project = projects.find((p) => p.id === selectedProjectId); + if (!project || !isRepositoryProject(project)) { setBranches([]); return; } setLoadingBranches(true); try { - const result = await api.getProjectBranches(selectedProject.id); + const result = await api.getProjectBranches(project.id); + console.log("[Branch] 加载分支结果:", result); + + if (result.error) { + console.warn("[Branch] 加载分支警告:", result.error); + toast.error(`加载分支失败: ${result.error}`); + } + setBranches(result.branches); if (result.default_branch) { setBranch(result.default_branch); } } catch (error) { - console.error("加载分支失败:", error); - setBranches([selectedProject.default_branch || "main"]); + const msg = error instanceof Error ? error.message : "未知错误"; + console.error("[Branch] 加载分支失败:", msg); + toast.error(`加载分支失败: ${msg}`); + setBranches([project.default_branch || "main"]); } finally { setLoadingBranches(false); } }; loadBranches(); - }, [selectedProject?.id]); + }, [selectedProjectId, projects]); const filteredProjects = useMemo(() => { if (!searchTerm) return projects; @@ -437,45 +447,47 @@ export default function CreateTaskDialog({ )} {/* 高级选项 */} - {/* 规则集和提示词选择 */} -
-
- - 审计配置 -
-
-
- - + {/* 规则集和提示词选择 - 仅快速扫描模式显示 */} + {auditMode !== "agent" && ( +
+
+ + 审计配置
-
- - +
+
+ + +
+
+ + +
-
+ )} diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index abb2d97..88a752c 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -16,7 +16,8 @@ import { Github, UserCircle, Shield, - MessageSquare + MessageSquare, + Bot } from "lucide-react"; import routes from "@/app/routes"; import { version } from "../../../package.json"; @@ -27,6 +28,7 @@ const routeIcons: Record = { "/projects": , "/instant-analysis": , "/audit-tasks": , + "/agent-audit": , "/audit-rules": , "/prompts": , "/admin": , diff --git a/frontend/src/pages/AgentAudit.tsx b/frontend/src/pages/AgentAudit.tsx index 8195a77..8cfb283 100644 --- a/frontend/src/pages/AgentAudit.tsx +++ b/frontend/src/pages/AgentAudit.tsx @@ -1,13 +1,16 @@ /** - * Agent Audit Page - Simplified Professional Version + * Agent Audit Page - Strix-inspired Terminal UI + * 参考 Strix 的 TUI 设计:左侧活动日志 + 右侧 Agent 树和统计 */ import { useState, useEffect, useRef, useCallback, useMemo } from "react"; -import { useParams, useNavigate } from "react-router-dom"; +import { useParams } from "react-router-dom"; import { Terminal, Bot, CheckCircle2, Loader2, XCircle, - Bug, Square, ArrowLeft, Brain, Wrench, - ChevronDown, ChevronUp, Clock, Eye, EyeOff, Target + Bug, Square, Brain, Wrench, Play, + ChevronDown, ChevronUp, Clock, Target, Zap, + Shield, Activity, ChevronRight, + FileCode, AlertTriangle, Search } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; @@ -19,14 +22,17 @@ import { getAgentTask, getAgentFindings, cancelAgentTask, + getAgentTree, + type AgentTreeResponse, + type AgentTreeNode, } from "@/shared/api/agentTasks"; +import CreateAgentTaskDialog from "@/components/agent/CreateAgentTaskDialog"; // ============ Types ============ - interface LogItem { id: string; time: string; - type: 'thinking' | 'tool' | 'phase' | 'finding' | 'info' | 'error'; + type: 'thinking' | 'tool' | 'phase' | 'finding' | 'info' | 'error' | 'user' | 'dispatch'; title: string; content?: string; isStreaming?: boolean; @@ -35,105 +41,334 @@ interface LogItem { agentName?: string; } +// ============ Utilities ============ + +/** + * 将扁平的 Agent 节点列表转换为树结构 + * 后端返回的是扁平列表,需要根据 parent_agent_id 构建树 + */ +function buildAgentTree(flatNodes: AgentTreeNode[]): AgentTreeNode[] { + if (!flatNodes || flatNodes.length === 0) return []; + + // 创建节点映射(使用 agent_id 作为 key) + const nodeMap = new Map(); + + // 首先克隆所有节点并重置 children + flatNodes.forEach(node => { + nodeMap.set(node.agent_id, { ...node, children: [] }); + }); + + // 构建树结构 + const rootNodes: AgentTreeNode[] = []; + + flatNodes.forEach(node => { + const currentNode = nodeMap.get(node.agent_id)!; + + if (node.parent_agent_id && nodeMap.has(node.parent_agent_id)) { + // 有父节点,添加到父节点的 children + const parentNode = nodeMap.get(node.parent_agent_id)!; + parentNode.children.push(currentNode); + } else { + // 没有父节点或父节点不存在,作为根节点 + rootNodes.push(currentNode); + } + }); + + return rootNodes; +} + // ============ Constants ============ - const SEVERITY_COLORS: Record = { - critical: "text-red-400 bg-red-950/50", - high: "text-orange-400 bg-orange-950/50", - medium: "text-yellow-400 bg-yellow-950/50", - low: "text-blue-400 bg-blue-950/50", - info: "text-gray-400 bg-gray-900/50", + critical: "text-red-400 bg-red-950/50 border-red-500", + high: "text-orange-400 bg-orange-950/50 border-orange-500", + medium: "text-yellow-400 bg-yellow-950/50 border-yellow-500", + low: "text-blue-400 bg-blue-950/50 border-blue-500", + info: "text-gray-400 bg-gray-900/50 border-gray-500", }; -const AGENT_COLORS: Record = { - Orchestrator: "text-purple-400 border-purple-500/30 bg-purple-950/20", - Recon: "text-green-400 border-green-500/30 bg-green-950/20", - Analysis: "text-blue-400 border-blue-500/30 bg-blue-950/20", - Verification: "text-red-400 border-red-500/30 bg-red-950/20", - default: "text-gray-400 border-gray-500/30 bg-gray-950/20", -}; +const ACTION_VERBS = [ + "Analyzing", "Scanning", "Probing", "Investigating", + "Examining", "Auditing", "Testing", "Exploring" +]; -// ============ Components ============ +// ============ Sub Components ============ + +// 启动画面 - Strix 风格 +function SplashScreen({ onComplete }: { onComplete: () => void }) { + const [dots, setDots] = useState(0); + const [verb, setVerb] = useState(ACTION_VERBS[0]); + + useEffect(() => { + const timer = setTimeout(onComplete, 2500); + return () => clearTimeout(timer); + }, [onComplete]); + + useEffect(() => { + const dotTimer = setInterval(() => setDots(d => (d + 1) % 4), 400); + const verbTimer = setInterval(() => { + setVerb(ACTION_VERBS[Math.floor(Math.random() * ACTION_VERBS.length)]); + }, 2000); + return () => { + clearInterval(dotTimer); + clearInterval(verbTimer); + }; + }, []); -function StatusBadge({ status }: { status: string }) { - const config: Record = { - pending: { bg: "bg-gray-700", icon: }, - running: { bg: "bg-blue-700", icon: }, - completed: { bg: "bg-green-700", icon: }, - failed: { bg: "bg-red-700", icon: }, - cancelled: { bg: "bg-yellow-700", icon: }, - }; - const c = config[status] || config.pending; return ( - - {c.icon} - {status.toUpperCase()} - +
+
+
+{`
+ ██████╗ ███████╗███████╗██████╗  █████╗ ██╗   ██╗██████╗ ██╗████████╗
+ ██╔══██╗██╔════╝██╔════╝██╔══██╗██╔══██╗██║   ██║██╔══██╗██║╚══██╔══╝
+ ██║  ██║█████╗  █████╗  ██████╔╝███████║██║   ██║██║  ██║██║   ██║   
+ ██║  ██║██╔══╝  ██╔══╝  ██╔═══╝ ██╔══██║██║   ██║██║  ██║██║   ██║   
+ ██████╔╝███████╗███████╗██║     ██║  ██║╚██████╔╝██████╔╝██║   ██║   
+ ╚═════╝ ╚══════╝╚══════╝╚═╝     ╚═╝  ╚═╝ ╚═════╝ ╚═════╝ ╚═╝   ╚═╝   
+`}
+        
+
+

+ Welcome to DeepAudit! +

+

AI-Powered Security Audit Agent

+
+
+ + + {verb}{'.'.repeat(dots)} + +
+
+
); } + +// Agent 树节点 - 增强版 +function AgentTreeNodeItem({ + node, + depth = 0, + selectedId, + onSelect +}: { + node: AgentTreeNode; + depth?: number; + selectedId: string | null; + onSelect: (id: string) => void; +}) { + const [expanded, setExpanded] = useState(true); + const hasChildren = node.children && node.children.length > 0; + const isSelected = selectedId === node.agent_id; + + // 状态图标和颜色 + const statusConfig: Record = { + running: { + icon:
, + color: "text-green-400", + animate: true + }, + completed: { + icon: , + color: "text-green-400" + }, + failed: { + icon: , + color: "text-red-400" + }, + waiting: { + icon: , + color: "text-yellow-400" + }, + created: { + icon:
, + color: "text-gray-400" + }, + }; + + const config = statusConfig[node.status] || statusConfig.created; + + // Agent 类型图标 + const typeIcons: Record = { + orchestrator: , + recon: , + analysis: , + verification: , + }; + + return ( +
+
onSelect(node.agent_id)} + > + {hasChildren ? ( + + ) : } + + {/* 状态指示器 */} + + {config.icon} + + + {/* Agent 类型图标 */} + {typeIcons[node.agent_type] || } + + {/* Agent 名称 */} + + {node.agent_name} + + + {/* 发现数量 */} + {node.findings_count > 0 && ( + + {node.findings_count} + + )} +
+ + {expanded && hasChildren && ( +
+ {node.children.map(child => ( + + ))} +
+ )} +
+ ); +} + +// 日志条目组件 - 增强版 function LogEntry({ item, isExpanded, onToggle }: { item: LogItem; isExpanded: boolean; onToggle: () => void; }) { - const icons: Record = { - thinking: , - tool: , - phase: , - finding: , - info: , - error: , + const config: Record = { + thinking: { + icon: , + borderColor: "border-l-purple-500", + bgColor: "bg-purple-950/20" + }, + tool: { + icon: , + borderColor: "border-l-amber-500", + bgColor: "bg-amber-950/20" + }, + phase: { + icon: , + borderColor: "border-l-cyan-500", + bgColor: "bg-cyan-950/20" + }, + finding: { + icon: , + borderColor: "border-l-red-500", + bgColor: "bg-red-950/20" + }, + dispatch: { + icon: , + borderColor: "border-l-blue-500", + bgColor: "bg-blue-950/20" + }, + info: { + icon: , + borderColor: "border-l-gray-600", + bgColor: "bg-gray-900/30" + }, + error: { + icon: , + borderColor: "border-l-red-600", + bgColor: "bg-red-950/30" + }, + user: { + icon: , + borderColor: "border-l-blue-500", + bgColor: "bg-blue-950/20" + }, }; - const borderColors: Record = { - thinking: "border-l-purple-500", - tool: "border-l-amber-500", - phase: "border-l-cyan-500", - finding: "border-l-red-500", - info: "border-l-gray-600", - error: "border-l-red-600", - }; - - // Thinking content is always shown, others are collapsible + const c = config[item.type] || config.info; const isThinking = item.type === 'thinking'; const showContent = isThinking || isExpanded; const isCollapsible = !isThinking && item.content; return (
-
- {icons[item.type]} - {item.time} - {!isThinking && {item.title}} - {item.isStreaming && } - {item.tool?.status === 'running' && } +
+ {c.icon} + {item.time} + + {!isThinking && ( + {item.title} + )} + + {item.isStreaming && ( + + )} + + {item.tool?.status === 'running' && ( + + )} + {item.agentName && ( - + {item.agentName} )}
+
{item.tool?.duration !== undefined && ( - {item.tool.duration}ms + {item.tool.duration}ms )} {item.severity && ( - + {item.severity.charAt(0).toUpperCase()} )} {isCollapsible && ( - isExpanded ? : + isExpanded ? + : + )}
- + {showContent && item.content && ( -
+
{item.content}
)} @@ -141,19 +376,251 @@ function LogEntry({ item, isExpanded, onToggle }: { ); } -// ============ Main Component ============ +// 选中 Agent 详情面板 +function AgentDetailPanel({ + agentId, + treeNodes, + onClose +}: { + agentId: string; + treeNodes: AgentTreeNode[]; + onClose: () => void; +}) { + // 递归查找 agent + const findAgent = (nodes: AgentTreeNode[], id: string): AgentTreeNode | null => { + for (const node of nodes) { + if (node.agent_id === id) return node; + const found = findAgent(node.children, id); + if (found) return found; + } + return null; + }; + const agent = findAgent(treeNodes, agentId); + if (!agent) return null; + + const statusConfig: Record = { + running: { color: "text-green-400", text: "Running" }, + completed: { color: "text-green-400", text: "Completed" }, + failed: { color: "text-red-400", text: "Failed" }, + waiting: { color: "text-yellow-400", text: "Waiting" }, + created: { color: "text-gray-400", text: "Created" }, + }; + + const typeIcons: Record = { + orchestrator: { icon: , label: "Orchestrator" }, + recon: { icon: , label: "Reconnaissance" }, + analysis: { icon: , label: "Analysis" }, + verification: { icon: , label: "Verification" }, + }; + + const config = statusConfig[agent.status] || statusConfig.created; + const typeInfo = typeIcons[agent.agent_type] || { icon: , label: "Agent" }; + + return ( +
+
+
+ {typeInfo.icon} + {agent.agent_name} +
+ +
+ +
+
+ Type + {typeInfo.label} +
+
+ Status + {config.text} +
+
+ Iterations + {agent.iterations || 0} +
+
+ Findings + 0 ? 'text-red-400' : 'text-white'}`}> + {agent.findings_count} + +
+
+ Tool Calls + {agent.tool_calls || 0} +
+
+ Tokens + {((agent.tokens_used || 0) / 1000).toFixed(1)}k +
+
+ + {agent.task_description && ( +
+ Task +

{agent.task_description}

+
+ )} + + {agent.children && agent.children.length > 0 && ( +
+ Sub-agents: {agent.children.length} +
+ )} +
+ ); +} + +// 实时统计面板 - 增强版 +function StatsPanel({ task, findings }: { task: AgentTask | null; findings: AgentFinding[] }) { + if (!task) return null; + + const severityCounts = { + critical: findings.filter(f => f.severity === 'critical').length, + high: findings.filter(f => f.severity === 'high').length, + medium: findings.filter(f => f.severity === 'medium').length, + low: findings.filter(f => f.severity === 'low').length, + }; + + const totalFindings = Object.values(severityCounts).reduce((a, b) => a + b, 0); + + return ( +
+
+ + Live Stats +
+ + {/* 进度条 */} +
+
+ Progress + {task.progress_percentage?.toFixed(0) || 0}% +
+
+
+
+
+ + {/* 统计数据 */} +
+
+ Files + {task.analyzed_files}/{task.total_files} +
+
+ Iterations + {task.total_iterations || 0} +
+
+ Tokens + {((task.tokens_used || 0) / 1000).toFixed(1)}k +
+
+ Tool Calls + {task.tool_calls_count || 0} +
+
+ + {/* 发现统计 */} + {totalFindings > 0 && ( +
+
+ Findings + {totalFindings} +
+
+ {severityCounts.critical > 0 && ( + + Critical: {severityCounts.critical} + + )} + {severityCounts.high > 0 && ( + + High: {severityCounts.high} + + )} + {severityCounts.medium > 0 && ( + + Medium: {severityCounts.medium} + + )} + {severityCounts.low > 0 && ( + + Low: {severityCounts.low} + + )} +
+
+ )} + + {/* 安全评分 */} + {task.security_score !== null && task.security_score !== undefined && ( +
+
+ Security Score + = 80 ? 'text-green-400' : + task.security_score >= 60 ? 'text-yellow-400' : + 'text-red-400' + }`}> + {task.security_score.toFixed(0)} + +
+
+ )} +
+ ); +} + + + +// 状态徽章 +function StatusBadge({ status }: { status: string }) { + const config: Record = { + pending: { bg: "bg-gray-700", icon: , text: "PENDING" }, + running: { bg: "bg-green-700", icon: , text: "RUNNING" }, + completed: { bg: "bg-green-600", icon: , text: "COMPLETED" }, + failed: { bg: "bg-red-700", icon: , text: "FAILED" }, + cancelled: { bg: "bg-yellow-700", icon: , text: "CANCELLED" }, + }; + const c = config[status] || config.pending; + return ( + + {c.icon} + {c.text} + + ); +} + + +// ============ Main Component ============ export default function AgentAuditPage() { const { taskId } = useParams<{ taskId: string }>(); - const navigate = useNavigate(); + // 状态 + const [showSplash, setShowSplash] = useState(!taskId); + const [showCreateDialog, setShowCreateDialog] = useState(false); const [task, setTask] = useState(null); - const [_findings, setFindings] = useState([]); // Loaded for future use - const [isLoading, setIsLoading] = useState(true); - + const [findings, setFindings] = useState([]); + const [agentTree, setAgentTree] = useState(null); + const [selectedAgentId, setSelectedAgentId] = useState(null); + const [isLoading, setIsLoading] = useState(!!taskId); const [logs, setLogs] = useState([]); const [expandedIds, setExpandedIds] = useState>(new Set()); const [isAutoScroll, setIsAutoScroll] = useState(true); + const [statusVerb, setStatusVerb] = useState(ACTION_VERBS[0]); + const [statusDots, setStatusDots] = useState(0); + const [showAllLogs, setShowAllLogs] = useState(true); // 是否显示所有日志 const logEndRef = useRef(null); const logIdCounter = useRef(0); @@ -163,7 +630,62 @@ export default function AgentAuditPage() { const isRunning = task?.status === "running"; const isComplete = task?.status === "completed" || task?.status === "failed" || task?.status === "cancelled"; - // Helper to add log + // 构建 Agent 树结构(将扁平列表转换为树) + const treeNodes = useMemo(() => { + if (!agentTree?.nodes) return []; + return buildAgentTree(agentTree.nodes); + }, [agentTree?.nodes]); + + // 根据选中的 Agent 过滤日志 + const filteredLogs = useMemo(() => { + if (showAllLogs || !selectedAgentId) { + return logs; + } + // 根据 agentName 或 agentId 过滤 + // 需要找到选中 agent 的名称(在树结构中递归查找) + const findAgentName = (nodes: AgentTreeNode[], id: string): string | null => { + for (const node of nodes) { + if (node.agent_id === id) return node.agent_name; + const found = findAgentName(node.children, id); + if (found) return found; + } + return null; + }; + const selectedAgentName = findAgentName(treeNodes, selectedAgentId); + if (!selectedAgentName) return logs; + + return logs.filter(log => + log.agentName?.toLowerCase() === selectedAgentName.toLowerCase() || + log.agentName?.toLowerCase().includes(selectedAgentName.toLowerCase().split('_')[0]) + ); + }, [logs, selectedAgentId, showAllLogs, treeNodes]); + + // 选中 Agent 时的处理 + const handleAgentSelect = useCallback((agentId: string) => { + if (selectedAgentId === agentId) { + // 再次点击同一个 agent,切换回显示全部 + setShowAllLogs(true); + setSelectedAgentId(null); + } else { + setSelectedAgentId(agentId); + setShowAllLogs(false); + } + }, [selectedAgentId]); + + // 动态状态动画 + useEffect(() => { + if (!isRunning) return; + const dotTimer = setInterval(() => setStatusDots(d => (d + 1) % 4), 500); + const verbTimer = setInterval(() => { + setStatusVerb(ACTION_VERBS[Math.floor(Math.random() * ACTION_VERBS.length)]); + }, 5000); + return () => { + clearInterval(dotTimer); + clearInterval(verbTimer); + }; + }, [isRunning]); + + // Helper: 添加日志 const addLog = useCallback((item: Omit) => { const newItem: LogItem = { ...item, @@ -174,13 +696,13 @@ export default function AgentAuditPage() { return newItem.id; }, []); - // Load functions + // 加载任务数据 const loadTask = useCallback(async () => { if (!taskId) return; try { const data = await getAgentTask(taskId); setTask(data); - } catch (err: unknown) { + } catch (err) { toast.error("Failed to load task"); } }, [taskId]); @@ -195,40 +717,52 @@ export default function AgentAuditPage() { } }, [taskId]); - // Stream options - SIMPLIFIED + const loadAgentTree = useCallback(async () => { + if (!taskId) return; + try { + const data = await getAgentTree(taskId); + setAgentTree(data); + } catch (err) { + console.error(err); + } + }, [taskId]); + + // 流式事件处理 const streamOptions = useMemo(() => ({ includeThinking: true, includeToolCalls: true, - onEvent: (event: any) => { - if (event.agent_name) { - currentAgentName.current = event.agent_name; + // 捕获 agent_name + if (event.metadata?.agent_name) { + currentAgentName.current = event.metadata.agent_name; + } + + // 处理 dispatch 事件 + if (event.type === 'dispatch' || event.type === 'dispatch_complete') { + addLog({ + type: 'dispatch', + title: event.message || `Agent dispatch: ${event.metadata?.agent || 'unknown'}`, + agentName: currentAgentName.current || undefined, + }); + // 🔥 刷新 Agent 树,显示新创建的子 Agent + loadAgentTree(); } }, - onThinkingStart: () => { - // Ensure previous thinking is finalized if (currentThinkingId.current) { setLogs(prev => prev.map(log => - log.id === currentThinkingId.current - ? { ...log, isStreaming: false } - : log + log.id === currentThinkingId.current ? { ...log, isStreaming: false } : log )); } currentThinkingId.current = null; }, - onThinkingToken: (_token: string, accumulated: string) => { - if (!accumulated || accumulated.trim() === '') return; // Skip empty content - - // User Request: Action and Action Input should not be in Thinking box - // Filter out "Action:" and everything after from the thinking log + if (!accumulated?.trim()) return; + // 清理 Action 部分,只显示 Thought const cleanContent = accumulated.replace(/\nAction\s*:[\s\S]*$/, "").trim(); - if (!cleanContent) return; if (!currentThinkingId.current) { - // Create new thinking entry on first non-empty token const id = addLog({ type: 'thinking', title: 'Thinking...', @@ -238,91 +772,68 @@ export default function AgentAuditPage() { }); currentThinkingId.current = id; } else { - // Update existing entry setLogs(prev => prev.map(log => - log.id === currentThinkingId.current - ? { ...log, content: cleanContent } - : log + log.id === currentThinkingId.current ? { ...log, content: cleanContent } : log )); } }, - onThinkingEnd: (response: string) => { const cleanResponse = (response || "").replace(/\nAction\s*:[\s\S]*$/, "").trim(); - - if (!cleanResponse || cleanResponse === '') { - // No content, remove the entry if it exists + if (!cleanResponse) { if (currentThinkingId.current) { setLogs(prev => prev.filter(log => log.id !== currentThinkingId.current)); } currentThinkingId.current = null; return; } - if (currentThinkingId.current) { setLogs(prev => prev.map(log => log.id === currentThinkingId.current - ? { - ...log, - title: cleanResponse.slice(0, 80) + (cleanResponse.length > 80 ? '...' : ''), - content: cleanResponse, - isStreaming: false - } + ? { + ...log, + title: cleanResponse.slice(0, 100) + (cleanResponse.length > 100 ? '...' : ''), + content: cleanResponse, + isStreaming: false + } : log )); currentThinkingId.current = null; - } else if (cleanResponse.trim()) { - // No existing entry but we have content - create one - addLog({ - type: 'thinking', - title: cleanResponse.slice(0, 80) + (cleanResponse.length > 80 ? '...' : ''), - content: cleanResponse, - agentName: currentAgentName.current || undefined, - }); } }, - onToolStart: (name: string, input: Record) => { - // Force finalize any pending thinking log when a tool starts if (currentThinkingId.current) { setLogs(prev => prev.map(log => - log.id === currentThinkingId.current - ? { ...log, isStreaming: false } - : log + log.id === currentThinkingId.current ? { ...log, isStreaming: false } : log )); currentThinkingId.current = null; } - addLog({ type: 'tool', - title: `Action: ${name}`, + title: `Tool: ${name}`, content: `Input:\n${JSON.stringify(input, null, 2)}`, tool: { name, status: 'running' }, agentName: currentAgentName.current || undefined, }); }, - onToolEnd: (name: string, output: unknown, duration: number) => { - // Update the last tool log with duration and output setLogs(prev => { - // Find last matching tool (reverse search for compatibility) let idx = -1; for (let i = prev.length - 1; i >= 0; i--) { - if (prev[i].type === 'tool' && prev[i].tool?.name === name) { - idx = i; - break; + if (prev[i].type === 'tool' && prev[i].tool?.name === name && prev[i].tool?.status === 'running') { + idx = i; + break; } } if (idx >= 0) { const newLogs = [...prev]; - // Preserve existing input content and append output const previousContent = newLogs[idx].content || ''; const outputStr = typeof output === 'string' ? output : JSON.stringify(output, null, 2); - + // 截断过长输出 + const truncatedOutput = outputStr.length > 1000 ? outputStr.slice(0, 1000) + '\n... (truncated)' : outputStr; newLogs[idx] = { ...newLogs[idx], title: `Completed: ${name}`, - content: `${previousContent}\n\nOutput:\n${outputStr}`, + content: `${previousContent}\n\nOutput:\n${truncatedOutput}`, tool: { name, duration, status: 'completed' }, }; return newLogs; @@ -330,57 +841,72 @@ export default function AgentAuditPage() { return prev; }); }, - onFinding: (finding: Record) => { addLog({ type: 'finding', title: (finding.title as string) || 'Vulnerability found', severity: (finding.severity as string) || 'medium', + agentName: currentAgentName.current || undefined, }); loadFindings(); }, - onComplete: () => { - addLog({ type: 'info', title: 'Audit completed' }); + addLog({ type: 'info', title: '✅ Audit completed' }); loadTask(); loadFindings(); + loadAgentTree(); }, - onError: (err: string) => { addLog({ type: 'error', title: `Error: ${err}` }); }, - }), [addLog, loadTask, loadFindings]); + }), [addLog, loadTask, loadFindings, loadAgentTree]); - const { - connect: connectStream, - disconnect: disconnectStream, - isConnected, - } = useAgentStream(taskId || null, streamOptions); + const { connect: connectStream, disconnect: disconnectStream, isConnected } = useAgentStream(taskId || null, streamOptions); - // Init + // 初始化 useEffect(() => { - const init = async () => { - setIsLoading(true); - await Promise.all([loadTask(), loadFindings()]); - setIsLoading(false); - }; - init(); - }, [loadTask, loadFindings]); + if (!taskId) { + setShowSplash(true); + return; + } + setShowSplash(false); + setIsLoading(true); + + Promise.all([loadTask(), loadFindings(), loadAgentTree()]) + .finally(() => setIsLoading(false)); + }, [taskId, loadTask, loadFindings, loadAgentTree]); - // Connect + // 连接流 useEffect(() => { - if (!taskId || isComplete) return; - connectStream(); + if (taskId && task?.status === 'running') { + connectStream(); + addLog({ type: 'info', title: '🔗 Connected to audit stream' }); + } return () => disconnectStream(); - }, [taskId, isComplete, connectStream, disconnectStream]); + }, [taskId, task?.status, connectStream, disconnectStream, addLog]); - // Auto-scroll + // 定期刷新 Agent 树 + useEffect(() => { + if (!taskId || !isRunning) return; + const interval = setInterval(loadAgentTree, 3000); + return () => clearInterval(interval); + }, [taskId, isRunning, loadAgentTree]); + + // 定期刷新 Task 统计数据(Files, Iterations, Tokens, Tool Calls) + useEffect(() => { + if (!taskId || !isRunning) return; + const interval = setInterval(loadTask, 2000); + return () => clearInterval(interval); + }, [taskId, isRunning, loadTask]); + + // 自动滚动 useEffect(() => { if (isAutoScroll && logEndRef.current) { logEndRef.current.scrollIntoView({ behavior: 'smooth' }); } }, [logs, isAutoScroll]); + // 取消任务 const handleCancel = async () => { if (!taskId) return; try { @@ -388,10 +914,11 @@ export default function AgentAuditPage() { toast.success("Task cancelled"); loadTask(); } catch { - toast.error("Failed to cancel"); + toast.error("Failed to cancel task"); } }; + // 切换日志展开 const toggleExpand = (id: string) => { setExpandedIds(prev => { const next = new Set(prev); @@ -401,112 +928,246 @@ export default function AgentAuditPage() { }); }; - if (isLoading || !task) { + // ============ Render ============ + + // Splash 画面 (无 taskId) + if (showSplash && !taskId) { return ( -
- + <> + setShowCreateDialog(true)} /> + + + ); + } + + // 加载中 + if (isLoading && !task) { + return ( +
+
+ + Loading audit task... +
); } return ( -
+
{/* Header */} -
-
- -
- - Security Audit - {taskId?.slice(0, 8)} -
-
- +
- {isConnected && ( - - - LIVE - - )} - - {isRunning && ( - + + DeepAudit + {task && ( + <> + / + + {task.name || task.id.slice(0, 8)} + + + )}
-
- - {/* Main */} -
- {/* Left: Activity Log */} -
- {/* Toolbar */} -
-
- - Activity Log - {logs.length} -
+
+ {isRunning && ( + )} + +
+
+ + {/* Main Content - Strix Layout: 75% left / 25% right */} +
+ {/* Left Panel - Activity Log (75%) */} +
+ {/* Log Header */} +
+
+ + Activity Log + {isConnected && ( + + + Live + + )} + {/* 显示日志数量 */} + + {filteredLogs.length}{!showAllLogs && logs.length !== filteredLogs.length ? `/${logs.length}` : ''} + +
+
- {/* Logs */} -
- {logs.length === 0 ? ( -
- -

Waiting for agent activity...

+ {/* Log Content */} +
+ {/* 过滤提示 */} + {selectedAgentId && !showAllLogs && ( +
+ + Filtering logs for selected agent + + +
+ )} + {filteredLogs.length === 0 ? ( +
+ {isRunning ? ( + + + {selectedAgentId && !showAllLogs + ? 'Waiting for activity from selected agent...' + : 'Waiting for agent activity...'} + + ) : selectedAgentId && !showAllLogs ? ( + 'No activity from selected agent' + ) : ( + 'No activity yet' + )}
) : ( - logs - .filter(item => { - // Filter out empty/placeholder entries - if (item.title === 'Thinking...' && (!item.content || item.content.trim() === '')) { - return false; - } - return true; - }) - .map(item => ( - toggleExpand(item.id)} - /> - )) + filteredLogs.map(item => ( + toggleExpand(item.id)} + /> + )) )} -
+
- {/* Progress */} - {isRunning && ( -
-
- Progress - {task.progress_percentage?.toFixed(0) || 0}% -
-
-
-
+ {/* Status Bar */} + {task && ( +
+ + {isRunning ? ( + + + {statusVerb}{'.'.repeat(statusDots)} + + ) : isComplete ? ( + Audit {task.status} + ) : ( + 'Ready' + )} + + + {task.progress_percentage?.toFixed(0) || 0}% • {task.analyzed_files}/{task.total_files} files • {task.tool_calls_count || 0} tools +
)}
+ + {/* Right Panel - Agent Tree + Stats (25%) */} +
+ {/* Agent Tree */} +
+
+
+ + Agent Tree + {agentTree && ( + + {agentTree.total_agents} + + )} +
+
+ {selectedAgentId && !showAllLogs && ( + + )} + {agentTree && agentTree.running_agents > 0 && ( + + + {agentTree.running_agents} active + + )} +
+
+
+ {treeNodes.length > 0 ? ( + treeNodes.map(node => ( + + )) + ) : ( +
+ {isRunning ? ( + + + Initializing agents... + + ) : ( + 'No agents yet' + )} +
+ )} +
+
+ + {/* Agent Detail + Stats Panel */} +
+ {/* 选中 Agent 详情 */} + {selectedAgentId && !showAllLogs && ( + { setShowAllLogs(true); setSelectedAgentId(null); }} + /> + )} + +
+
+ + {/* Create Agent Task Dialog */} +
); } diff --git a/frontend/src/shared/api/agentTasks.ts b/frontend/src/shared/api/agentTasks.ts index c6aecd4..20c1eb5 100644 --- a/frontend/src/shared/api/agentTasks.ts +++ b/frontend/src/shared/api/agentTasks.ts @@ -26,6 +26,11 @@ export interface AgentTask { verified_count: number; false_positive_count: number; + // Agent 统计 + total_iterations: number; + tool_calls_count: number; + tokens_used: number; + // 严重程度统计 critical_count: number; high_count: number; @@ -34,7 +39,7 @@ export interface AgentTask { // 评分 quality_score: number; - security_score: number; + security_score: number | null; // 时间 created_at: string; @@ -44,6 +49,13 @@ export interface AgentTask { // 进度 progress_percentage: number; + // 配置 + audit_scope: Record | null; + target_vulnerabilities: string[] | null; + verification_level: string | null; + exclude_patterns: string[] | null; + target_files: string[] | null; + // 错误信息 error_message: string | null; } @@ -307,3 +319,89 @@ export async function* streamAgentEvents( } } +// ============ Agent Tree Types ============ + +export interface AgentTreeNode { + id: string; + agent_id: string; + agent_name: string; + agent_type: string; + parent_agent_id: string | null; + depth: number; + task_description: string | null; + knowledge_modules: string[] | null; + status: "created" | "running" | "completed" | "failed" | "waiting"; + result_summary: string | null; + findings_count: number; + iterations: number; + tokens_used: number; + tool_calls: number; + duration_ms: number | null; + children: AgentTreeNode[]; +} + +export interface AgentTreeResponse { + task_id: string; + root_agent_id: string | null; + total_agents: number; + running_agents: number; + completed_agents: number; + failed_agents: number; + total_findings: number; + nodes: AgentTreeNode[]; +} + +export interface AgentCheckpoint { + id: string; + agent_id: string; + agent_name: string; + agent_type: string; + iteration: number; + status: string; + total_tokens: number; + tool_calls: number; + findings_count: number; + checkpoint_type: "auto" | "manual" | "error" | "final"; + checkpoint_name: string | null; + created_at: string | null; +} + +export interface CheckpointDetail extends AgentCheckpoint { + task_id: string; + parent_agent_id: string | null; + state_data: Record; + metadata: Record | null; +} + +// ============ Agent Tree API Functions ============ + +/** + * 获取任务的 Agent 树结构 + */ +export async function getAgentTree(taskId: string): Promise { + const response = await apiClient.get(`/agent-tasks/${taskId}/agent-tree`); + return response.data; +} + +/** + * 获取任务的检查点列表 + */ +export async function getAgentCheckpoints( + taskId: string, + params?: { agent_id?: string; limit?: number } +): Promise { + const response = await apiClient.get(`/agent-tasks/${taskId}/checkpoints`, { params }); + return response.data; +} + +/** + * 获取检查点详情 + */ +export async function getCheckpointDetail( + taskId: string, + checkpointId: string +): Promise { + const response = await apiClient.get(`/agent-tasks/${taskId}/checkpoints/${checkpointId}`); + return response.data; +} +