merge: feat/board-meeting-mode into main
Deploy to Production / deploy (push) Failing after 19s Details

This commit is contained in:
chiguyong 2026-06-17 23:53:10 +08:00
commit 086d77997c
49 changed files with 10935 additions and 1624 deletions

15
.env.example Normal file
View File

@ -0,0 +1,15 @@
# AgentKit 生产环境变量清单
# 此文件仅作为 Gitea Secrets 配置参考,不会自动加载
# 实际部署时由 Gitea Actions workflow 从 Secrets 注入到 /opt/agentkit/repo/.env
# ===== 数据库密码(必填,通过 Gitea Secrets 配置)=====
POSTGRES_PASSWORD=change-me-to-strong-password
REDIS_PASSWORD=change-me-to-strong-password
# ===== 应用密钥(必填,用于外部系统调用 API 的鉴权)=====
AGENTKIT_API_KEY=change-me-to-strong-api-key
# ===== LLM Provider API Keys =====
# 不在此配置!部署完成后通过 Web UI Settings 页面配置:
# http://8.153.107.96:8001 → Settings → LLM
# 配置后自动写入 agentkit.yaml 和 .env

View File

@ -0,0 +1,83 @@
name: Deploy to Production
# 触发条件:推送到主干分支 或 手动触发
on:
push:
branches: [main, master]
workflow_dispatch:
env:
DEPLOY_DIR: /opt/agentkit
REPO_DIR: /opt/agentkit/repo
COMPOSE_FILE: docker-compose.deploy.yaml
jobs:
deploy:
# 使用自托管 runner同机部署host 模式)
runs-on: self-hosted
timeout-minutes: 15
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Prepare deploy directory
run: |
sudo mkdir -p "$DEPLOY_DIR" "$REPO_DIR"
sudo chown -R "$(id -u):$(id -g)" "$DEPLOY_DIR"
- name: Sync code to deploy directory
run: |
rsync -a --delete \
--exclude='.git' \
--exclude='node_modules' \
--exclude='__pycache__' \
--exclude='.pytest_cache' \
--exclude='*.pyc' \
--exclude='.venv' \
--exclude='venv' \
--exclude='build/' \
--exclude='dist/' \
--exclude='test-results/' \
./ "$REPO_DIR/"
- name: Write .env from Gitea Secrets
# 仅写入基础设施密码LLM key 由用户部署后通过 Web UI onboarding 配置
# PUT /api/v1/settings/llm 会写入 agentkit.yaml 和 .env
run: |
umask 077
cat > "$REPO_DIR/.env" <<EOF
# 自动生成,请勿手动编辑
# LLM API key 请通过 Web UI Settings 页面配置
POSTGRES_PASSWORD=${{ secrets.POSTGRES_PASSWORD }}
REDIS_PASSWORD=${{ secrets.REDIS_PASSWORD }}
AGENTKIT_API_KEY=${{ secrets.AGENTKIT_API_KEY }}
EOF
- name: Run deploy script
working-directory: ${{ env.REPO_DIR }}
run: |
bash scripts/deploy.sh
- name: Health check
run: |
echo "等待服务启动..."
for i in $(seq 1 30); do
if curl -sf http://localhost:8001/api/v1/health > /dev/null 2>&1; then
echo "✅ 服务健康检查通过"
curl -s http://localhost:8001/api/v1/health
exit 0
fi
echo "尝试 $i/30: 服务未就绪,等待 5 秒..."
sleep 5
done
echo "❌ 健康检查失败"
docker compose -f "$REPO_DIR/$COMPOSE_FILE" logs --tail=100
exit 1
- name: Cleanup old images
if: always()
run: |
docker image prune -f --filter "until=24h" || true

View File

@ -1,862 +0,0 @@
#!/usr/bin/env python3
"""Knowledge Graph Builder for Fischer AgentKit
Scans all Python source files under src/agentkit/ and configs/,
extracts classes, functions, imports, and builds a comprehensive
knowledge graph JSON file.
"""
import ast
import json
import os
import sys
import uuid
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
# Project root
PROJECT_ROOT = Path("/Users/Chiguyong/Code/Fischer/fischer-agentkit")
OUTPUT_PATH = PROJECT_ROOT / ".understand-anything" / "knowledge-graph.json"
# Directories to scan
SCAN_DIRS = [
PROJECT_ROOT / "src" / "agentkit",
PROJECT_ROOT / "configs",
]
# Architecture layer mapping
LAYER_MAP = {
"server": "api",
"cli": "api",
"core": "service",
"orchestrator": "service",
"skills": "service",
"router": "service",
"memory": "data",
"session": "data",
"bus": "data",
"llm": "utility",
"mcp": "utility",
"tools": "utility",
"telemetry": "utility",
"prompts": "utility",
"quality": "utility",
"evaluation": "utility",
"evolution": "utility",
"configs": "utility",
}
# Chinese summaries for modules
MODULE_SUMMARIES = {
"core": "核心模块 - 定义Agent基类、通信协议、ReAct引擎、任务分发、注册中心等基础组件",
"core.base": "Agent基类 - 统一Agent生命周期管理包括启动、停止、任务执行、Handoff、进度上报",
"core.protocol": "通信协议定义 - 统一消息格式包括TaskMessage、TaskResult、TaskProgress、HandoffMessage等",
"core.react": "ReAct推理-行动循环引擎 - 实现Think→Act→Observe循环支持工具调用和文本解析模式",
"core.exceptions": "自定义异常体系 - 定义Agent框架所有异常类型",
"core.dispatcher": "任务分发器 - 通过Redis Queue将任务分发给Agent支持回调、重试、进度上报",
"core.registry": "Agent注册中心 - 管理Agent的注册、发现、状态、心跳和负载均衡",
"core.config_driven": "配置驱动Agent - 从YAML/Dict配置自动组装Agent支持llm_generate/tool_call/custom三种模式",
"core.compressor": "上下文压缩器 - 长会话自动压缩历史消息支持LLM摘要和简单截断策略",
"core.trace": "执行轨迹记录器 - 记录ReAct执行过程中的完整轨迹为反思和可观测性提供数据",
"core.shared_workspace": "共享工作空间 - 基于Redis的Agent间共享状态存储支持读写、锁操作",
"core.agent_pool": "Agent实例池 - 运行时管理Agent的创建、获取、删除",
"core.orchestrator": "多Agent协作编排器 - 实现Orchestrator-Worker模式支持任务分解、并行执行、自适应编排",
"core.headroom_compressor": "Headroom AI压缩器 - 基于Headroom AI的上下文压缩实现",
"core.logging": "日志配置 - 统一日志格式和配置",
"core.standalone": "独立运行模式 - 支持Agent脱离框架独立运行",
"core.goal_planner": "目标规划器 - 将复杂目标分解为可执行步骤",
"core.plan_checker": "计划检查器 - 验证执行计划的完整性和可行性",
"core.plan_exec_engine": "计划执行引擎 - 执行分解后的计划步骤",
"core.plan_executor": "计划执行器 - 管理计划执行的完整流程",
"core.plan_schema": "计划Schema - 执行计划的数据结构定义",
"core.reflexion": "Reflexion引擎 - 自反思推理,通过自我评估改进输出",
"core.rewoo": "ReWOO引擎 - 无观察推理,先规划后执行的高效模式",
"llm": "LLM网关模块 - 多Provider统一网关支持OpenAI/Anthropic/Gemini/文心/豆包/元宝等",
"llm.gateway": "LLM网关 - 统一多Provider调用接口支持路由、重试、流式输出",
"llm.protocol": "LLM协议定义 - 定义LLMProvider、LLMRequest、LLMResponse等接口",
"llm.config": "LLM配置 - 模型别名、Provider配置管理",
"llm.retry": "LLM重试策略 - 指数退避重试和错误处理",
"llm.providers": "LLM Provider实现 - 各大模型服务商的具体适配器",
"llm.providers.openai": "OpenAI Provider - 支持GPT-4/GPT-3.5等模型",
"llm.providers.anthropic": "Anthropic Provider - 支持Claude系列模型",
"llm.providers.gemini": "Gemini Provider - 支持Google Gemini模型",
"llm.providers.wenxin": "文心一言Provider - 支持百度文心大模型",
"llm.providers.doubao": "豆包Provider - 支持字节豆包大模型",
"llm.providers.yuanbao": "元宝Provider - 支持腾讯元宝大模型",
"llm.providers.tracker": "LLM调用追踪器 - 记录和统计LLM调用",
"llm.providers.usage_store": "LLM用量存储 - Token用量和成本追踪支持InMemory和Redis后端",
"llm.cache": "LLM响应缓存 - 基于语义相似度的LLM响应缓存减少重复调用",
"llm.cache_key": "缓存键生成 - LLM缓存键的计算和归一化",
"chat": "聊天路由模块 - CostAwareRouter三层意图路由和语义路由",
"chat.skill_routing": "三层意图路由 - CostAwareRouter正则→启发式→LLM分类逐层升级",
"chat.semantic_router": "语义路由 - 基于向量相似度的意图路由,支持语义匹配",
"quality.cascade_detector": "级联检测器 - 检测Agent输出中的级联失败模式",
"quality.cascade_state_store": "级联状态存储 - 级联检测状态持久化支持InMemory和Redis后端",
"quality.alignment": "对齐守卫 - 检测和修正Agent输出中的对齐偏差",
"tools": "工具模块 - 提供Agent可调用的各类工具",
"tools.base": "工具基类 - 定义Tool接口和标准执行流程",
"tools.registry": "工具注册中心 - 管理工具的注册、发现、获取",
"tools.shell": "Shell工具 - 执行系统命令",
"tools.web_search": "Web搜索工具 - 执行网络搜索",
"tools.web_crawl": "Web爬取工具 - 爬取网页内容",
"tools.memory_tool": "记忆工具 - Agent记忆读写操作",
"tools.ask_human": "人工介入工具 - 请求人类输入",
"tools.schema_tools": "Schema工具 - JSON Schema相关操作",
"tools.function_tool": "函数工具 - 将Python函数包装为Tool",
"tools.agent_tool": "Agent工具 - 将Agent包装为可调用Tool",
"tools.mcp_tool": "MCP工具 - MCP协议工具适配器",
"tools.composition": "工具组合 - 支持工具链式组合",
"tools.baidu_search": "百度搜索工具 - 百度搜索引擎集成",
"tools.headroom_retrieve": "Headroom检索工具 - Headroom AI知识检索",
"tools.computer_use": "计算机使用工具 - 桌面操控工具,支持截图、点击、输入等操作",
"tools.computer_use_session": "计算机使用会话 - 桌面操控会话管理,支持云端和本地(pyautogui)模式",
"tools.computer_use_recorder": "计算机使用录制器 - 记录桌面操控动作序列",
"tools.pty_session": "PTY会话 - 伪终端会话管理",
"tools.terminal_session": "终端会话 - 终端模拟器会话",
"tools.output_parser": "输出解析器 - 解析Agent输出为结构化数据",
"tools.skill_install": "技能安装器 - 动态安装技能包",
"memory": "记忆模块 - 多层记忆系统,支持工作记忆、情景记忆、语义记忆",
"memory.base": "记忆基类 - 定义Memory接口",
"memory.working": "工作记忆 - 基于Redis的短期工作记忆",
"memory.episodic": "情景记忆 - 基于向量数据库的长期情景记忆",
"memory.semantic": "语义记忆 - 基于RAG服务的语义知识检索",
"memory.profile": "用户画像 - 用户偏好和历史信息管理",
"memory.retriever": "记忆检索器 - 统一多层记忆检索接口",
"memory.embedder": "嵌入器 - 文本向量化支持OpenAI Embedding",
"memory.models": "记忆数据模型 - Pydantic模型定义",
"memory.rag_loop": "RAG循环 - 检索增强生成的迭代循环",
"memory.query_transformer": "查询转换器 - 优化检索查询",
"memory.relevance_scorer": "相关性评分器 - 评估检索结果相关性",
"memory.contextual_retrieval": "上下文检索 - 基于上下文的检索增强",
"memory.http_rag": "HTTP RAG服务 - 远程RAG API客户端",
"skills": "技能模块 - 定义可复用的Agent技能包含意图、工具和质量门控",
"skills.base": "技能基类 - 定义Skill、SkillConfig、IntentConfig等",
"skills.registry": "技能注册中心 - 管理技能的注册、发现、获取",
"skills.loader": "技能加载器 - 从YAML配置加载技能定义",
"skills.pipeline": "技能Pipeline - 技能编排流程",
"skills.skill_md": "Markdown技能 - 从Markdown文档生成技能",
"skills.geo_pipeline": "GEO Pipeline - 地理信息处理Pipeline",
"orchestrator": "编排模块 - Pipeline编排引擎支持DAG工作流",
"orchestrator.pipeline_engine": "Pipeline引擎 - 执行DAG定义的工作流",
"orchestrator.pipeline_schema": "Pipeline Schema - Pipeline配置模型定义",
"orchestrator.pipeline_state": "Pipeline状态 - Pipeline执行状态管理",
"orchestrator.pipeline_models": "Pipeline模型 - Pipeline数据模型",
"orchestrator.pipeline_loader": "Pipeline加载器 - 从YAML加载Pipeline定义",
"orchestrator.reflection": "反思模块 - 执行后反思和改进",
"orchestrator.retry": "重试策略 - Pipeline步骤重试机制",
"orchestrator.compensation": "补偿机制 - Pipeline失败时的补偿操作",
"orchestrator.handoff": "Handoff - Agent间任务转交",
"orchestrator.dynamic_pipeline": "动态Pipeline - 运行时动态构建Pipeline",
"router": "路由模块 - 意图路由,将用户输入匹配到对应技能",
"router.intent": "意图路由器 - 基于LLM的意图识别和路由",
"quality": "质量模块 - 输出质量门控和标准化",
"quality.gate": "质量门控 - 检查Agent输出是否满足质量要求",
"quality.output": "输出标准化 - 统一Agent输出格式",
"prompts": "Prompt模块 - Prompt模板和渲染",
"prompts.template": "Prompt模板 - 支持变量替换和Section组合",
"prompts.section": "Prompt Section - 定义Prompt的各组成部分",
"bus": "消息总线模块 - Agent间异步通信",
"bus.protocol": "总线协议 - 定义消息总线接口",
"bus.message": "消息定义 - Agent间通信消息格式",
"bus.memory_bus": "内存消息总线 - 基于进程内队列的消息总线",
"bus.redis_bus": "Redis消息总线 - 基于Redis Pub/Sub的消息总线",
"session": "会话模块 - 会话管理和持久化",
"session.manager": "会话管理器 - 管理对话会话的创建、获取、更新",
"session.store": "会话存储 - 会话数据的持久化存储",
"session.models": "会话模型 - 会话相关的数据模型",
"server": "服务器模块 - FastAPI HTTP/WebSocket服务",
"server.app": "FastAPI应用 - 创建和配置FastAPI应用实例",
"server.config": "服务器配置 - 服务器运行参数配置",
"server.runner": "服务器运行器 - 启动和管理服务器进程",
"server.middleware": "中间件 - 请求处理中间件",
"server.client": "API客户端 - 服务端API客户端封装",
"server.client_config": "客户端配置 - API客户端配置管理",
"server.task_store": "任务存储 - 服务端任务状态存储",
"server.routes": "路由模块 - HTTP/WebSocket路由定义",
"server.routes.chat": "聊天路由 - 对话API端点",
"server.routes.ws": "WebSocket路由 - 实时通信端点",
"server.routes.tasks": "任务路由 - 任务管理API",
"server.routes.agents": "Agent路由 - Agent管理API",
"server.routes.skills": "技能路由 - 技能管理API含@-mention建议端点",
"server.routes.memory": "记忆路由 - 记忆管理API",
"server.routes.llm": "LLM路由 - LLM配置和调用API",
"server.routes.health": "健康检查路由 - 服务健康状态端点",
"server.routes.metrics": "指标路由 - 运行指标API",
"server.routes.evolution": "进化路由 - Agent进化管理API",
"server.routes.evolution_dashboard": "进化仪表盘路由 - 进化数据可视化API",
"server.routes.kb_management": "知识库管理路由 - 文档上传/搜索/源配置API",
"server.routes.settings": "设置路由 - 系统配置管理API",
"server.routes.terminal": "终端路由 - PTY终端会话API",
"server.routes.workflows": "工作流路由 - Pipeline工作流管理API",
"server.routes.skill_management": "技能管理路由 - 技能CRUD操作API",
"server.routes.portal": "门户路由 - Web GUI入口和静态资源",
"cli": "命令行模块 - CLI工具",
"cli.main": "CLI入口 - Typer应用主入口",
"cli.chat": "聊天命令 - 交互式对话命令",
"cli.init": "初始化命令 - 项目初始化",
"cli.onboarding": "引导命令 - 新用户引导流程",
"cli.skill": "技能命令 - 技能管理CLI",
"cli.task": "任务命令 - 任务提交和管理CLI",
"cli.pair": "配对命令 - Agent配对",
"cli.usage": "使用统计命令 - 使用情况统计",
"cli.templates": "模板命令 - Agent模板管理",
"mcp": "MCP协议模块 - Model Context Protocol集成",
"mcp.client": "MCP客户端 - 连接MCP服务器",
"mcp.server": "MCP服务器 - 提供MCP服务",
"mcp.manager": "MCP管理器 - 管理MCP连接",
"mcp.transport": "MCP传输层 - MCP通信传输实现",
"telemetry": "遥测模块 - 可观测性支持",
"telemetry.tracing": "分布式追踪 - OpenTelemetry追踪集成",
"telemetry.metrics": "指标收集 - 运行指标收集和导出",
"telemetry.setup": "遥测设置 - 初始化遥测组件",
"evolution": "进化模块 - Agent自我进化能力",
"evolution.lifecycle": "进化生命周期 - EvolutionMixin任务后触发进化",
"evolution.reflector": "反思器 - 分析任务执行结果,生成改进建议",
"evolution.llm_reflector": "LLM反思器 - 使用LLM进行深度反思",
"evolution.prompt_optimizer": "Prompt优化器 - 自动优化Agent Prompt",
"evolution.strategy_tuner": "策略调优器 - 调整Agent执行策略",
"evolution.genetic": "遗传算法 - 基于遗传算法的Prompt进化",
"evolution.fitness": "适应度评估 - 评估进化变体的质量",
"evolution.ab_tester": "A/B测试 - 对比测试不同进化变体",
"evolution.evolution_store": "进化存储 - 持久化进化历史",
"evolution.models": "进化模型 - 进化相关数据模型",
"evolution.experience_schema": "经验Schema - 经验数据结构定义",
"evolution.experience_store": "经验存储 - 成功/失败经验持久化",
"evolution.path_optimizer": "路径优化器 - 分析工具调用路径,推荐更优策略",
"evolution.pitfall_detector": "陷阱检测器 - 检测重复错误模式",
"evaluation": "评估模块 - Agent输出质量评估",
"evaluation.ragas_evaluator": "RAGAS评估器 - 使用RAGAS框架评估RAG质量",
"org": "组织发现模块 - 多Agent组织架构和协作发现",
"org.context": "组织上下文 - 组织级别的共享上下文管理",
"org.discovery": "组织发现 - Agent间能力发现和协作匹配",
"marketplace": "多Agent市场模块 - Agent间的拍卖和财富分配",
"marketplace.auction": "拍卖机制 - Agent间的任务拍卖和竞价",
"marketplace.wealth": "财富管理 - Agent间的价值交换和分配",
"configs": "配置模块 - Pipeline和技能YAML配置",
"configs.geo_server": "GEO服务器 - 地理信息HTTP服务",
"configs.geo_handlers": "GEO处理器 - 地理信息请求处理",
"configs.geo_tools": "GEO工具 - 地理信息相关工具定义",
}
def get_layer(file_path: str) -> str:
"""Determine architecture layer from file path."""
parts = file_path.replace("\\", "/").split("/")
# Check for configs/ prefix
if "configs" in parts:
return "utility"
# For src/agentkit/__init__.py and __main__.py, treat as service
if parts[-1] in ("__init__.py", "__main__.py") and len(parts) <= 4:
return "service"
for part in parts:
if part in LAYER_MAP:
return LAYER_MAP[part]
return "unknown"
def get_module_key(file_path: str) -> str:
"""Get module key for summary lookup."""
# Convert file path to module key
rel = file_path
if rel.startswith("src/agentkit/"):
rel = rel[len("src/agentkit/"):]
elif rel.startswith("configs/"):
rel = rel[len("configs/"):]
# Remove __init__.py and .py suffix
rel = rel.replace("/__init__.py", "").replace(".py", "")
return rel
def get_file_summary(file_path: str, docstring: str = "") -> str:
"""Get Chinese summary for a file."""
# If we have a docstring, use it as base
if docstring:
# Clean up docstring
doc = docstring.strip().split("\n")[0].strip()
if doc:
return doc
key = get_module_key(file_path)
# Try exact match first
if key in MODULE_SUMMARIES:
return MODULE_SUMMARIES[key]
# Try parent module
parts = key.split("/")
for i in range(len(parts) - 1, 0, -1):
parent_key = "/".join(parts[:i])
if parent_key in MODULE_SUMMARIES:
return MODULE_SUMMARIES[parent_key]
return f"模块 {key}"
def estimate_complexity(node: ast.AST) -> str:
"""Estimate complexity of an AST node."""
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
# Count branches, loops, nested functions
complexity = 1
for child in ast.walk(node):
if isinstance(child, (ast.If, ast.While, ast.For, ast.ExceptHandler)):
complexity += 1
elif isinstance(child, (ast.And, ast.Or)):
complexity += 1
if complexity <= 3:
return "simple"
elif complexity <= 8:
return "moderate"
return "complex"
elif isinstance(node, ast.ClassDef):
methods = [n for n in node.body if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef))]
if len(methods) <= 3:
return "simple"
elif len(methods) <= 8:
return "moderate"
return "complex"
return "simple"
def extract_class_info(node: ast.ClassDef, file_path: str) -> dict:
"""Extract class information from AST node."""
base_classes = []
for base in node.bases:
if isinstance(base, ast.Name):
base_classes.append(base.id)
elif isinstance(base, ast.Attribute):
base_classes.append(ast.dump(base))
methods = []
for item in node.body:
if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef)):
params = [arg.arg for arg in item.args.args if arg.arg != "self"]
methods.append({
"name": item.name,
"params": params,
"is_async": isinstance(item, ast.AsyncFunctionDef),
})
# Extract class docstring
docstring = ast.get_docstring(node) or ""
return {
"name": node.name,
"base_classes": base_classes,
"methods": methods,
"complexity": estimate_complexity(node),
"docstring": docstring,
}
def extract_function_info(node: ast.FunctionDef | ast.AsyncFunctionDef) -> dict:
"""Extract function information from AST node."""
params = [arg.arg for arg in node.args.args]
return_type = ""
if node.returns:
if isinstance(node.returns, ast.Name):
return_type = node.returns.id
elif isinstance(node.returns, ast.Constant):
return_type = str(node.returns.value)
else:
return_type = ast.dump(node.returns)
return {
"name": node.name,
"params": params,
"return_type": return_type,
"is_async": isinstance(node, ast.AsyncFunctionDef),
"complexity": estimate_complexity(node),
}
def extract_imports(tree: ast.AST, file_path: str) -> list[dict]:
"""Extract import information from AST."""
imports = []
for node in ast.walk(tree):
if isinstance(node, ast.ImportFrom):
if node.module and (node.module.startswith("agentkit") or node.module.startswith("configs")):
for alias in node.names:
imports.append({
"from_module": node.module,
"import_name": alias.name,
})
elif isinstance(node, ast.Import):
for alias in node.names:
if alias.name.startswith("agentkit") or alias.name.startswith("configs"):
imports.append({
"from_module": None,
"import_name": alias.name,
})
return imports
def module_to_file_path(module: str) -> str:
"""Convert Python module path to file path."""
parts = module.split(".")
# Handle agentkit modules
if module.startswith("agentkit"):
# Skip "agentkit" prefix, it's under src/
sub_parts = parts[1:] # skip "agentkit"
if not sub_parts:
return "src/agentkit/__init__.py"
# Try as package __init__.py
init_path = PROJECT_ROOT / "src" / "agentkit" / "/".join(sub_parts) / "__init__.py"
if init_path.exists():
return f"src/agentkit/{'/'.join(sub_parts)}/__init__.py"
# Try as module.py
mod_path = PROJECT_ROOT / "src" / "agentkit" / ("/".join(sub_parts) + ".py")
if mod_path.exists():
return f"src/agentkit/{'/'.join(sub_parts)}.py"
# Handle configs modules
if module.startswith("configs"):
sub_parts = parts[1:] # skip "configs"
if not sub_parts:
return "configs/__init__.py"
mod_path = PROJECT_ROOT / "configs" / ("/".join(sub_parts) + ".py")
if mod_path.exists():
return f"configs/{'/'.join(sub_parts)}.py"
return ""
def scan_file(file_path: Path) -> dict:
"""Scan a single Python file and extract all information."""
try:
source = file_path.read_text(encoding="utf-8")
tree = ast.parse(source)
except (SyntaxError, UnicodeDecodeError):
return {"classes": [], "functions": [], "imports": [], "top_level_functions": [], "docstring": ""}
rel_path = str(file_path.relative_to(PROJECT_ROOT))
# Extract module docstring
docstring = ast.get_docstring(tree) or ""
classes = []
functions = []
top_level_functions = []
for node in ast.iter_child_nodes(tree):
if isinstance(node, ast.ClassDef):
classes.append(extract_class_info(node, rel_path))
elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
func_info = extract_function_info(node)
functions.append(func_info)
top_level_functions.append(func_info)
imports = extract_imports(tree, rel_path)
return {
"classes": classes,
"functions": top_level_functions,
"imports": imports,
"rel_path": rel_path,
"docstring": docstring,
}
def build_knowledge_graph():
"""Build the complete knowledge graph."""
# Collect all Python files
py_files = []
for scan_dir in SCAN_DIRS:
if scan_dir.exists():
for py_file in scan_dir.rglob("*.py"):
py_files.append(py_file)
print(f"Found {len(py_files)} Python files to scan")
# Scan all files
file_data = {}
for py_file in sorted(py_files):
data = scan_file(py_file)
rel_path = data["rel_path"]
file_data[rel_path] = data
# Build nodes and edges
nodes = []
edges = []
# Track all node IDs for edge building
file_node_ids = {}
class_node_ids = {}
func_node_ids = {}
# 1. Create file nodes
for rel_path, data in file_data.items():
node_id = f"file:{rel_path}"
layer = get_layer(rel_path)
summary = get_file_summary(rel_path, data.get("docstring", ""))
tags = []
parts = rel_path.replace("\\", "/").split("/")
for p in parts:
if p not in ("src", "agentkit", "__init__.py") and not p.endswith(".py"):
tags.append(p)
nodes.append({
"id": node_id,
"type": "file",
"name": rel_path.split("/")[-1],
"filePath": rel_path,
"layer": layer,
"summary": summary,
"tags": tags,
"complexity": "moderate" if data["classes"] or data["functions"] else "simple",
})
file_node_ids[rel_path] = node_id
# 2. Create class nodes
for rel_path, data in file_data.items():
for cls in data["classes"]:
class_id = f"class:{cls['name']}"
layer = get_layer(rel_path)
method_names = [m["name"] for m in cls["methods"]]
# Use docstring for summary if available
docstring = cls.get("docstring", "")
if docstring:
# Take first line of docstring
summary = docstring.strip().split("\n")[0].strip()
else:
summary = f"{cls['name']}"
if cls["base_classes"]:
summary += f",继承自{', '.join(cls['base_classes'])}"
if method_names:
summary += f",包含方法: {', '.join(method_names[:5])}"
if len(method_names) > 5:
summary += f"{len(method_names)}个方法"
nodes.append({
"id": class_id,
"type": "class",
"name": cls["name"],
"filePath": rel_path,
"layer": layer,
"summary": summary,
"tags": [cls["name"]],
"complexity": cls["complexity"],
})
class_node_ids[cls["name"]] = class_id
# Edge: file contains class
edges.append({
"id": f"edge:{uuid.uuid4().hex[:8]}",
"source": file_node_ids[rel_path],
"target": class_id,
"type": "contains",
"label": f"定义类 {cls['name']}",
})
# Edge: class extends base classes
for base in cls["base_classes"]:
if base in class_node_ids:
edges.append({
"id": f"edge:{uuid.uuid4().hex[:8]}",
"source": class_id,
"target": class_node_ids[base],
"type": "extends",
"label": f"继承 {base}",
})
# 3. Create method nodes
for method in cls["methods"]:
method_id = f"func:{cls['name']}.{method['name']}"
async_tag = "异步" if method["is_async"] else ""
summary = f"{cls['name']}.{method['name']}({', '.join(method['params'])}) {async_tag}方法"
nodes.append({
"id": method_id,
"type": "function",
"name": method["name"],
"filePath": rel_path,
"layer": layer,
"summary": summary,
"tags": [cls["name"], method["name"]],
"complexity": "simple",
})
func_node_ids[f"{cls['name']}.{method['name']}"] = method_id
# Edge: class contains method
edges.append({
"id": f"edge:{uuid.uuid4().hex[:8]}",
"source": class_id,
"target": method_id,
"type": "contains",
"label": f"方法 {method['name']}",
})
# 4. Create top-level function nodes
for rel_path, data in file_data.items():
for func in data["functions"]:
func_id = f"func:{func['name']}"
async_tag = "异步" if func["is_async"] else ""
summary = f"{func['name']}({', '.join(func['params'])}) {async_tag}函数"
if func["return_type"]:
summary += f"{func['return_type']}"
nodes.append({
"id": func_id,
"type": "function",
"name": func["name"],
"filePath": rel_path,
"layer": get_layer(rel_path),
"summary": summary,
"tags": [func["name"]],
"complexity": func["complexity"],
})
func_node_ids[func["name"]] = func_id
# Edge: file contains function
edges.append({
"id": f"edge:{uuid.uuid4().hex[:8]}",
"source": file_node_ids[rel_path],
"target": func_id,
"type": "contains",
"label": f"定义函数 {func['name']}",
})
# 5. Create import edges
for rel_path, data in file_data.items():
for imp in data["imports"]:
if imp["from_module"]:
target_path = module_to_file_path(imp["from_module"])
if target_path and target_path in file_node_ids:
edges.append({
"id": f"edge:{uuid.uuid4().hex[:8]}",
"source": file_node_ids[rel_path],
"target": file_node_ids[target_path],
"type": "imports",
"label": f"导入 {imp['import_name']}",
})
# 6. Build tours
tours = build_tours(file_data, file_node_ids, class_node_ids, func_node_ids)
# Get git commit hash
git_hash = "d9d1b16e5911ad958cd8ae38958058bea13f3fcc"
# Build final JSON
graph = {
"version": "1.0.0",
"project": {
"name": "Fischer AgentKit",
"languages": ["python"],
"frameworks": ["FastAPI", "Pydantic", "SQLAlchemy", "Typer", "Redis"],
"description": "AI驱动的Agent框架支持ReAct引擎、多LLM网关、Pipeline编排、自适应反思和消息总线",
"analyzedAt": datetime.now(timezone.utc).isoformat(),
"gitCommitHash": git_hash,
},
"nodes": nodes,
"edges": edges,
"tours": tours,
}
return graph
def build_tours(file_data, file_node_ids, class_node_ids, func_node_ids):
"""Build guided learning tours."""
tours = []
# Tour 1: Entry Points
tours.append({
"id": "tour:entry-points",
"name": "入口点导览",
"description": "从项目入口开始了解如何启动和使用AgentKit",
"steps": [
{"nodeId": "file:src/agentkit/__main__.py", "why": "Python模块入口python -m agentkit"},
{"nodeId": "file:src/agentkit/__init__.py", "why": "包入口导出核心公共API"},
{"nodeId": "file:src/agentkit/cli/main.py", "why": "CLI主入口Typer应用定义"},
{"nodeId": "file:src/agentkit/server/app.py", "why": "HTTP服务入口FastAPI应用创建"},
],
})
# Tour 2: Core Agent Lifecycle
tours.append({
"id": "tour:agent-lifecycle",
"name": "Agent生命周期导览",
"description": "深入理解Agent从创建到执行任务的完整生命周期",
"steps": [
{"nodeId": "class:BaseAgent", "why": "Agent基类定义标准生命周期和可插拔能力"},
{"nodeId": "func:BaseAgent.start", "why": "Agent启动流程连接Redis→注册→心跳→监听"},
{"nodeId": "func:BaseAgent.execute", "why": "任务执行框架方法on_task_start→handle_task→quality_gate→on_task_complete"},
{"nodeId": "func:BaseAgent.handle_task", "why": "抽象方法,子类实现业务逻辑"},
{"nodeId": "class:ConfigDrivenAgent", "why": "配置驱动Agent从YAML自动组装"},
{"nodeId": "func:ConfigDrivenAgent.handle_task", "why": "根据execution_mode路由到react/direct/custom模式"},
{"nodeId": "class:AgentConfig", "why": "Agent配置模型支持YAML/Dict构建"},
],
})
# Tour 3: ReAct Engine
tours.append({
"id": "tour:react-engine",
"name": "ReAct引擎导览",
"description": "理解ReAct推理-行动循环的核心实现",
"steps": [
{"nodeId": "class:ReActEngine", "why": "ReAct引擎核心Think→Act→Observe循环"},
{"nodeId": "func:ReActEngine.execute", "why": "执行ReAct循环支持超时和取消"},
{"nodeId": "func:ReActEngine.execute_stream", "why": "流式执行逐步yield事件"},
{"nodeId": "func:ReActEngine._execute_tool", "why": "工具调用执行,处理成功和失败"},
{"nodeId": "func:ReActEngine._parse_text_tool_calls", "why": "文本解析模式支持Action和代码块格式"},
{"nodeId": "class:ReActStep", "why": "单步记录数据结构"},
{"nodeId": "class:ReActResult", "why": "ReAct执行结果数据结构"},
{"nodeId": "class:ReActEvent", "why": "流式执行事件数据结构"},
],
})
# Tour 4: LLM Gateway
tours.append({
"id": "tour:llm-gateway",
"name": "LLM网关导览",
"description": "了解多Provider统一网关的设计和实现",
"steps": [
{"nodeId": "class:LLMGateway", "why": "LLM网关核心统一多Provider调用接口"},
{"nodeId": "file:src/agentkit/llm/protocol.py", "why": "LLM协议定义LLMProvider/LLMRequest/LLMResponse"},
{"nodeId": "file:src/agentkit/llm/config.py", "why": "模型别名和Provider配置"},
{"nodeId": "file:src/agentkit/llm/providers/openai.py", "why": "OpenAI Provider实现"},
{"nodeId": "file:src/agentkit/llm/providers/anthropic.py", "why": "Anthropic Provider实现"},
{"nodeId": "file:src/agentkit/llm/retry.py", "why": "LLM重试策略"},
],
})
# Tour 5: Memory System
tours.append({
"id": "tour:memory-system",
"name": "记忆系统导览",
"description": "理解多层记忆系统的架构和实现",
"steps": [
{"nodeId": "file:src/agentkit/memory/base.py", "why": "记忆基类接口定义"},
{"nodeId": "file:src/agentkit/memory/retriever.py", "why": "统一记忆检索器,整合工作/情景/语义记忆"},
{"nodeId": "file:src/agentkit/memory/working.py", "why": "工作记忆 - 基于Redis的短期记忆"},
{"nodeId": "file:src/agentkit/memory/episodic.py", "why": "情景记忆 - 基于向量的长期记忆"},
{"nodeId": "file:src/agentkit/memory/semantic.py", "why": "语义记忆 - RAG服务集成"},
{"nodeId": "file:src/agentkit/memory/embedder.py", "why": "文本向量化嵌入器"},
],
})
# Tour 6: Orchestration
tours.append({
"id": "tour:orchestration",
"name": "编排系统导览",
"description": "了解多Agent协作编排和Pipeline引擎",
"steps": [
{"nodeId": "class:Orchestrator", "why": "多Agent协作编排器Orchestrator-Worker模式"},
{"nodeId": "func:Orchestrator.execute", "why": "编排执行:分解→执行→汇总"},
{"nodeId": "func:Orchestrator.execute_adaptive", "why": "自适应编排:执行→评估→再分解循环"},
{"nodeId": "file:src/agentkit/orchestrator/pipeline_engine.py", "why": "Pipeline引擎执行DAG工作流"},
{"nodeId": "file:src/agentkit/orchestrator/pipeline_schema.py", "why": "Pipeline配置模型"},
{"nodeId": "file:src/agentkit/orchestrator/reflection.py", "why": "执行后反思模块"},
],
})
# Tour 7: Skills & Router
tours.append({
"id": "tour:skills-router",
"name": "技能与路由导览",
"description": "了解技能定义、注册和意图路由机制",
"steps": [
{"nodeId": "file:src/agentkit/skills/base.py", "why": "技能基类和配置定义"},
{"nodeId": "class:SkillRegistry", "why": "技能注册中心"},
{"nodeId": "file:src/agentkit/skills/loader.py", "why": "从YAML加载技能定义"},
{"nodeId": "class:IntentRouter", "why": "意图路由器,匹配用户输入到技能"},
{"nodeId": "file:src/agentkit/router/intent.py", "why": "意图路由实现"},
],
})
# Tour 8: Evolution
tours.append({
"id": "tour:evolution",
"name": "进化系统导览",
"description": "了解Agent自我进化的机制和实现",
"steps": [
{"nodeId": "file:src/agentkit/evolution/lifecycle.py", "why": "进化生命周期Mixin"},
{"nodeId": "file:src/agentkit/evolution/reflector.py", "why": "反思器 - 分析结果生成改进建议"},
{"nodeId": "file:src/agentkit/evolution/prompt_optimizer.py", "why": "Prompt自动优化"},
{"nodeId": "file:src/agentkit/evolution/genetic.py", "why": "遗传算法进化"},
{"nodeId": "file:src/agentkit/evolution/ab_tester.py", "why": "A/B测试对比"},
],
})
# Tour 9: Infrastructure
tours.append({
"id": "tour:infrastructure",
"name": "基础设施导览",
"description": "了解消息总线、会话管理、遥测等基础设施",
"steps": [
{"nodeId": "file:src/agentkit/bus/protocol.py", "why": "消息总线协议接口"},
{"nodeId": "file:src/agentkit/bus/redis_bus.py", "why": "Redis Pub/Sub消息总线"},
{"nodeId": "file:src/agentkit/bus/memory_bus.py", "why": "进程内消息总线"},
{"nodeId": "file:src/agentkit/session/manager.py", "why": "会话管理器"},
{"nodeId": "file:src/agentkit/telemetry/tracing.py", "why": "OpenTelemetry追踪集成"},
{"nodeId": "file:src/agentkit/telemetry/metrics.py", "why": "运行指标收集"},
],
})
return tours
def main():
"""Main entry point."""
print("Building knowledge graph for Fischer AgentKit...")
graph = build_knowledge_graph()
# Ensure output directory exists
OUTPUT_PATH.parent.mkdir(parents=True, exist_ok=True)
# Write JSON
with open(OUTPUT_PATH, "w", encoding="utf-8") as f:
json.dump(graph, f, ensure_ascii=False, indent=2)
print(f"Knowledge graph written to {OUTPUT_PATH}")
print(f" Nodes: {len(graph['nodes'])}")
print(f" Edges: {len(graph['edges'])}")
print(f" Tours: {len(graph['tours'])}")
# Print layer statistics
layer_counts = {}
for node in graph["nodes"]:
layer = node["layer"]
layer_counts[layer] = layer_counts.get(layer, 0) + 1
print("\nLayer distribution:")
for layer, count in sorted(layer_counts.items()):
print(f" {layer}: {count} nodes")
# Print type statistics
type_counts = {}
for node in graph["nodes"]:
t = node["type"]
type_counts[t] = type_counts.get(t, 0) + 1
print("\nNode type distribution:")
for t, count in sorted(type_counts.items()):
print(f" {t}: {count} nodes")
if __name__ == "__main__":
main()

View File

@ -13,8 +13,8 @@
"Redis"
],
"description": "AI驱动的Agent框架支持ReAct引擎、多LLM网关、Pipeline编排、自适应反思和消息总线",
"analyzedAt": "2026-06-15T06:01:34.043135+00:00",
"gitCommitHash": "d9d1b16e5911ad958cd8ae38958058bea13f3fcc"
"analyzedAt": "2026-06-17T06:30:20.584048+00:00",
"gitCommitHash": "840d1af"
},
"nodes": [
{
@ -47,7 +47,7 @@
"name": "geo_server.py",
"filePath": "configs/geo_server.py",
"layer": "utility",
"summary": "GEO AgentKit Server 启动入口",
"summary": "GEO AgentKit Server 启动入口:使用 find_config_path + load_config_with_dotenv 统一配置加载",
"tags": [
"configs"
],
@ -199,9 +199,10 @@
"name": "chat.py",
"filePath": "src/agentkit/cli/chat.py",
"layer": "api",
"summary": "Chat command — interactive terminal chat with an Agent.",
"summary": "Chat 命令:交互式终端聊天,使用 find_config_path + load_config_with_dotenv 统一配置加载链",
"tags": [
"cli"
"cli",
"chat"
],
"complexity": "moderate"
},
@ -223,7 +224,7 @@
"name": "main.py",
"filePath": "src/agentkit/cli/main.py",
"layer": "api",
"summary": "AgentKit CLI main entry point",
"summary": "AgentKit CLI 主入口gui/serve 命令使用 find_config_path + load_config_with_dotenv + has_llm_provider 统一配置加载",
"tags": [
"cli"
],
@ -235,11 +236,12 @@
"name": "onboarding.py",
"filePath": "src/agentkit/cli/onboarding.py",
"layer": "api",
"summary": "Onboarding flow — interactive first-time configuration wizard.",
"summary": "Onboarding 交互式首次配置向导merge-update 模式不覆盖已有配置、needs_onboarding() 检查 has_llm_provider()、bailian-coding provider 预设",
"tags": [
"cli"
"cli",
"onboarding"
],
"complexity": "moderate"
"complexity": "complex"
},
{
"id": "file:src/agentkit/cli/pair.py",
@ -271,7 +273,7 @@
"name": "task.py",
"filePath": "src/agentkit/cli/task.py",
"layer": "api",
"summary": "Task management CLI commands",
"summary": "Task 管理 CLI 命令:本地模式使用 find_config_path + load_config_with_dotenv 加载配置",
"tags": [
"cli"
],
@ -283,9 +285,10 @@
"name": "templates.py",
"filePath": "src/agentkit/cli/templates.py",
"layer": "api",
"summary": "Template files for agentkit init",
"summary": "agentkit init 模板文件bailian-coding 默认 provider、docker-compose 含 postgres+pgvector",
"tags": [
"cli"
"cli",
"templates"
],
"complexity": "simple"
},
@ -1701,9 +1704,11 @@
"name": "client_config.py",
"filePath": "src/agentkit/server/client_config.py",
"layer": "api",
"summary": "Client-specific configuration with priority over defaults",
"summary": "客户端配置管理clients.yaml 使用 _deep_resolve 解析 ${VAR} 环境变量引用",
"tags": [
"server"
"server",
"config",
"dotenv"
],
"complexity": "moderate"
},
@ -1843,12 +1848,14 @@
"name": "settings.py",
"filePath": "src/agentkit/server/routes/settings.py",
"layer": "api",
"summary": "Settings API routes with config hot-reload support.",
"summary": "Settings API 路由LLM/Skills/KB/General 配置的 CRUD支持 ${VAR} 反向解析保留、.env 写入 API Key、ruamel.yaml 保留注释",
"tags": [
"server",
"routes"
"routes",
"settings",
"dotenv"
],
"complexity": "moderate"
"complexity": "complex"
},
{
"id": "file:src/agentkit/server/routes/skill_management.py",
@ -11141,7 +11148,7 @@
"name": "chat",
"filePath": "src/agentkit/llm/gateway.py",
"layer": "utility",
"summary": "LLMGateway.chat(messages, model, agent_name, task_type, tools, tool_choice) 异步方法",
"summary": "LLMGateway.chat(messages, model, agent_name, task_type, tools, tool_choice, timeout) 异步方法 - 支持超时透传和空响应检测",
"tags": [
"LLMGateway",
"chat"
@ -11154,7 +11161,7 @@
"name": "chat_stream",
"filePath": "src/agentkit/llm/gateway.py",
"layer": "utility",
"summary": "LLMGateway.chat_stream(messages, model, agent_name, task_type, tools, tool_choice) 异步方法",
"summary": "LLMGateway.chat_stream(messages, model, agent_name, task_type, tools, tool_choice, timeout) 异步方法 - 支持超时透传和空流检测",
"tags": [
"LLMGateway",
"chat_stream"
@ -11282,7 +11289,7 @@
"name": "LLMRequest",
"filePath": "src/agentkit/llm/protocol.py",
"layer": "utility",
"summary": "LLM 请求",
"summary": "LLM 请求(含 timeout 超时控制)",
"tags": [
"LLMRequest"
],
@ -11294,7 +11301,7 @@
"name": "__init__",
"filePath": "src/agentkit/llm/protocol.py",
"layer": "utility",
"summary": "LLMRequest.__init__(messages, model, tools, tool_choice, temperature, max_tokens) 方法",
"summary": "LLMRequest.__init__(messages, model, tools, tool_choice, temperature, max_tokens, timeout) 方法",
"tags": [
"LLMRequest",
"__init__"
@ -27512,18 +27519,6 @@
],
"complexity": "moderate"
},
{
"id": "func:_load_dotenv",
"type": "function",
"name": "_load_dotenv",
"filePath": "src/agentkit/cli/chat.py",
"layer": "api",
"summary": "_load_dotenv(dotenv_path) 函数 → None",
"tags": [
"_load_dotenv"
],
"complexity": "moderate"
},
{
"id": "func:_print_help",
"type": "function",
@ -27614,7 +27609,7 @@
"name": "needs_onboarding",
"filePath": "src/agentkit/cli/onboarding.py",
"layer": "api",
"summary": "needs_onboarding(config_arg) 函数 → bool",
"summary": "needs_onboarding(config_arg) → bool: 检查是否需要 onboarding使用 load_config_with_dotenv 加载配置并检查 has_llm_provider()",
"tags": [
"needs_onboarding"
],
@ -27626,7 +27621,7 @@
"name": "run_onboarding",
"filePath": "src/agentkit/cli/onboarding.py",
"layer": "api",
"summary": "run_onboarding(output_dir, config_arg) 函数 → BinOp(left=Name(id='str', ctx=Load()), op=BitOr(), right=Constant(value=None))",
"summary": "run_onboarding(output_dir, config_arg) → str | None: 交互式配置向导merge-update 模式(仅更新 LLM section保留其他配置bailian-coding provider 预设",
"tags": [
"run_onboarding"
],
@ -28946,11 +28941,11 @@
"name": "_write_yaml_config",
"filePath": "src/agentkit/server/routes/settings.py",
"layer": "api",
"summary": "_write_yaml_config(config_path, data) 函数 → None",
"summary": "_write_yaml_config(config_path, data) → None: 写回 YAML 配置,使用 _reverse_resolve_env 保留 ${VAR} 引用ruamel.yaml 保留注释格式",
"tags": [
"_write_yaml_config"
],
"complexity": "simple"
"complexity": "moderate"
},
{
"id": "func:_get_config_path",
@ -28982,7 +28977,7 @@
"name": "update_llm_settings",
"filePath": "src/agentkit/server/routes/settings.py",
"layer": "api",
"summary": "update_llm_settings(request, update) 异步函数",
"summary": "update_llm_settings(request, update) 异步函数: 更新 LLM 配置,明文 API Key 写入 .env_write_env_varYAML 中保留 ${VAR} 引用",
"tags": [
"update_llm_settings"
],
@ -30396,11 +30391,12 @@
"id": "file:src/agentkit/server/app.py",
"type": "file",
"name": "app.py",
"summary": "FastAPI 应用工厂,初始化全部组件",
"summary": "FastAPI 应用工厂初始化全部组件create_app 自动加载 .env + ServerConfig.from_yaml支持配置热重载",
"tags": [
"server",
"app",
"factory"
"factory",
"dotenv"
],
"filePath": "src/agentkit/server/app.py"
},
@ -30408,10 +30404,11 @@
"id": "file:src/agentkit/server/config.py",
"type": "file",
"name": "config.py",
"summary": "服务器配置加载器,从 agentkit.yaml 加载配置",
"summary": "服务器配置加载器:统一配置加载链 find_config_path() → load_config_with_dotenv() → load_dotenv() + ServerConfig.from_yaml(),含 .env 白名单安全加载、${VAR} 环境变量解析、配置热重载",
"tags": [
"server",
"config"
"config",
"dotenv"
],
"filePath": "src/agentkit/server/config.py"
},
@ -30419,7 +30416,7 @@
"id": "class:src/agentkit/server/config.py:ServerConfig",
"type": "class",
"name": "ServerConfig",
"summary": "服务器配置主类",
"summary": "服务器配置主类,支持 from_yaml 加载、has_llm_provider() 检测有效 LLM 配置、watch_config 热重载",
"tags": [
"server",
"config"
@ -30540,6 +30537,235 @@
"team"
],
"filePath": "tests/unit/experts/test_team.py"
},
{
"id": "func:_resolve_env_vars",
"type": "function",
"name": "_resolve_env_vars",
"filePath": "src/agentkit/server/config.py",
"layer": "service",
"summary": "解析字符串中的 ${VAR} 和 ${VAR:-default} 环境变量引用",
"tags": [
"config",
"env-vars"
],
"complexity": "simple"
},
{
"id": "func:_deep_resolve",
"type": "function",
"name": "_deep_resolve",
"filePath": "src/agentkit/server/config.py",
"layer": "service",
"summary": "递归解析嵌套 dict/list 中的 ${VAR} 环境变量引用",
"tags": [
"config",
"env-vars"
],
"complexity": "simple"
},
{
"id": "func:load_dotenv",
"type": "function",
"name": "load_dotenv",
"filePath": "src/agentkit/server/config.py",
"layer": "service",
"summary": "从 .env 文件加载环境变量,支持白名单前缀/精确名称过滤,不覆盖已存在的环境变量",
"tags": [
"config",
"dotenv",
"security"
],
"complexity": "moderate"
},
{
"id": "func:load_config_with_dotenv",
"type": "function",
"name": "load_config_with_dotenv",
"filePath": "src/agentkit/server/config.py",
"layer": "service",
"summary": "生产级配置加载入口:先 load_dotenv() 再 ServerConfig.from_yaml(),所有 CLI 和 app factory 的统一加载链",
"tags": [
"config",
"dotenv"
],
"complexity": "simple"
},
{
"id": "func:find_config_path",
"type": "function",
"name": "find_config_path",
"filePath": "src/agentkit/server/config.py",
"layer": "service",
"summary": "查找 agentkit.yaml 配置文件路径:--config 参数 > ./agentkit.yaml > ~/.agentkit/agentkit.yaml",
"tags": [
"config"
],
"complexity": "simple"
},
{
"id": "func:has_llm_provider",
"type": "function",
"name": "has_llm_provider",
"filePath": "src/agentkit/server/config.py",
"layer": "service",
"summary": "ServerConfig 方法:检查是否配置了有效的 LLM ProviderAPI Key 已解析且非 ${VAR} 占位符)",
"tags": [
"config",
"llm"
],
"complexity": "simple"
},
{
"id": "func:_reverse_resolve_env",
"type": "function",
"name": "_reverse_resolve_env",
"filePath": "src/agentkit/server/routes/settings.py",
"layer": "api",
"summary": "反向解析环境变量引用:若原始 YAML 含 ${VAR} 且当前值匹配 os.environ[VAR],保留 ${VAR} 引用而非写入明文",
"tags": [
"settings",
"env-vars",
"security"
],
"complexity": "moderate"
},
{
"id": "func:_write_env_var",
"type": "function",
"name": "_write_env_var",
"filePath": "src/agentkit/server/routes/settings.py",
"layer": "api",
"summary": "将 API Key 写入 .env 文件(配置文件同级目录),更新已有行或追加新行,同时设置 os.environ",
"tags": [
"settings",
"dotenv",
"security"
],
"complexity": "moderate"
},
{
"id": "func:_deep_update_ruamel",
"type": "function",
"name": "_deep_update_ruamel",
"filePath": "src/agentkit/server/routes/settings.py",
"layer": "api",
"summary": "深度更新 ruamel.yaml CommentedMap保留 YAML 注释和格式",
"tags": [
"settings",
"yaml"
],
"complexity": "moderate"
},
{
"id": "file:tests/unit/server/test_settings_routes.py",
"type": "file",
"name": "test_settings_routes.py",
"filePath": "tests/unit/server/test_settings_routes.py",
"layer": "test",
"summary": "Settings API 路由单元测试:覆盖 LLM/Skills/KB/General 配置 CRUD、${VAR} 反向解析保留、_write_env_var .env 写入",
"tags": [
"test",
"settings",
"server"
],
"complexity": "moderate"
},
{
"id": "file:tests/unit/test_cli.py",
"type": "file",
"name": "test_cli.py",
"filePath": "tests/unit/test_cli.py",
"layer": "test",
"summary": "CLI 命令单元测试version/doctor/init 命令、onboarding 配置加载链",
"tags": [
"test",
"cli"
],
"complexity": "moderate"
},
{
"id": "file:src/agentkit/cli/benchmark.py",
"type": "file",
"name": "benchmark.py",
"filePath": "src/agentkit/cli/benchmark.py",
"layer": "api",
"summary": "Benchmark CLI - 标准化能力基准测试,支持 Mock/LLM/GUI 三种模式,输出 Accuracy/Precision/Recall/F1/Latency 指标",
"tags": [
"benchmark",
"cli",
"testing"
],
"complexity": "complex"
},
{
"id": "func:benchmark",
"type": "function",
"name": "benchmark",
"filePath": "src/agentkit/cli/benchmark.py",
"layer": "api",
"summary": "benchmark(dimension, mode, report, runs, fast, verbose) CLI 主命令 - 支持流式关键词检测、难度分级超时、WebSocket 协议修正、延迟统计排除 timeout 用例",
"tags": [
"benchmark",
"cli",
"main"
],
"complexity": "complex"
},
{
"id": "func:_execute_llm_reasoning_task",
"type": "function",
"name": "_execute_llm_reasoning_task",
"filePath": "src/agentkit/cli/benchmark.py",
"layer": "api",
"summary": "LLM 推理任务执行 - 使用流式响应 + 关键词提前退出 + 难度分级超时 (easy=20s, medium=40s, hard=60s)",
"tags": [
"benchmark",
"llm",
"streaming"
],
"complexity": "moderate"
},
{
"id": "func:_run_gui_integration",
"type": "function",
"name": "_run_gui_integration",
"filePath": "src/agentkit/cli/benchmark.py",
"layer": "api",
"summary": "GUI 集成测试 - 直接 WebSocket 连接测试connected 消息作为通过标准",
"tags": [
"benchmark",
"gui",
"websocket"
],
"complexity": "moderate"
},
{
"id": "func:_compute_metrics",
"type": "function",
"name": "_compute_metrics",
"filePath": "src/agentkit/cli/benchmark.py",
"layer": "api",
"summary": "计算聚合指标 - 支持 exclude_latency_tags 参数排除特定用例的延迟统计",
"tags": [
"benchmark",
"metrics"
],
"complexity": "moderate"
},
{
"id": "document:docs/plans/2026-06-17-001-fix-benchmark-failures-root-cause-plan.md",
"type": "document",
"name": "2026-06-17-001-fix-benchmark-failures-root-cause-plan.md",
"filePath": "docs/plans/2026-06-17-001-fix-benchmark-failures-root-cause-plan.md",
"layer": "document",
"summary": "Benchmark 测试失败根因修复计划 - 修复 3 个失败项LLM 超时(流式+分级超时、WebSocket端点+协议修正)、延迟统计(排除 timeout 用例)",
"tags": [
"plan",
"benchmark",
"fix"
],
"complexity": "moderate"
}
],
"edges": [
@ -44718,13 +44944,6 @@
"type": "contains",
"label": "定义函数 _resolve_default_model"
},
{
"id": "edge:a1b49a61",
"source": "file:src/agentkit/cli/chat.py",
"target": "func:_load_dotenv",
"type": "contains",
"label": "定义函数 _load_dotenv"
},
{
"id": "edge:6d9737a9",
"source": "file:src/agentkit/cli/chat.py",
@ -52368,6 +52587,265 @@
"target": "class:src/agentkit/server/config.py:ServerConfig",
"type": "contains",
"weight": 1.0
},
{
"source": "file:src/agentkit/server/config.py",
"target": "func:_resolve_env_vars",
"type": "contains",
"label": "定义函数 _resolve_env_vars",
"weight": 1.0
},
{
"source": "file:src/agentkit/server/config.py",
"target": "func:_deep_resolve",
"type": "contains",
"label": "定义函数 _deep_resolve",
"weight": 1.0
},
{
"source": "file:src/agentkit/server/config.py",
"target": "func:load_dotenv",
"type": "contains",
"label": "定义函数 load_dotenv",
"weight": 1.0
},
{
"source": "file:src/agentkit/server/config.py",
"target": "func:load_config_with_dotenv",
"type": "contains",
"label": "定义函数 load_config_with_dotenv",
"weight": 1.0
},
{
"source": "file:src/agentkit/server/config.py",
"target": "func:find_config_path",
"type": "contains",
"label": "定义函数 find_config_path",
"weight": 1.0
},
{
"source": "class:src/agentkit/server/config.py:ServerConfig",
"target": "func:has_llm_provider",
"type": "contains",
"label": "方法 has_llm_provider",
"weight": 1.0
},
{
"source": "func:load_config_with_dotenv",
"target": "func:load_dotenv",
"type": "calls",
"label": "调用 load_dotenv",
"weight": 0.8
},
{
"source": "func:load_config_with_dotenv",
"target": "class:src/agentkit/server/config.py:ServerConfig",
"type": "calls",
"label": "调用 ServerConfig.from_yaml",
"weight": 0.8
},
{
"source": "file:src/agentkit/server/routes/settings.py",
"target": "func:_reverse_resolve_env",
"type": "contains",
"label": "定义函数 _reverse_resolve_env",
"weight": 1.0
},
{
"source": "file:src/agentkit/server/routes/settings.py",
"target": "func:_write_env_var",
"type": "contains",
"label": "定义函数 _write_env_var",
"weight": 1.0
},
{
"source": "file:src/agentkit/server/routes/settings.py",
"target": "func:_deep_update_ruamel",
"type": "contains",
"label": "定义函数 _deep_update_ruamel",
"weight": 1.0
},
{
"source": "func:_write_yaml_config",
"target": "func:_reverse_resolve_env",
"type": "calls",
"label": "调用 _reverse_resolve_env 保留 ${VAR} 引用",
"weight": 0.8
},
{
"source": "func:_write_yaml_config",
"target": "func:_deep_update_ruamel",
"type": "calls",
"label": "调用 _deep_update_ruamel 保留 YAML 注释",
"weight": 0.8
},
{
"source": "func:update_llm_settings",
"target": "func:_write_env_var",
"type": "calls",
"label": "调用 _write_env_var 写入 API Key 到 .env",
"weight": 0.8
},
{
"source": "file:src/agentkit/server/client_config.py",
"target": "func:_deep_resolve",
"type": "imports",
"label": "导入 _deep_resolve 解析 clients.yaml 中的 ${VAR}",
"weight": 0.7
},
{
"source": "file:src/agentkit/cli/chat.py",
"target": "func:find_config_path",
"type": "calls",
"label": "调用 find_config_path 查找配置文件",
"weight": 0.7
},
{
"source": "file:src/agentkit/cli/chat.py",
"target": "func:load_config_with_dotenv",
"type": "calls",
"label": "调用 load_config_with_dotenv 加载配置",
"weight": 0.7
},
{
"source": "file:src/agentkit/cli/main.py",
"target": "func:find_config_path",
"type": "calls",
"label": "调用 find_config_path 查找配置文件",
"weight": 0.7
},
{
"source": "file:src/agentkit/cli/main.py",
"target": "func:load_config_with_dotenv",
"type": "calls",
"label": "调用 load_config_with_dotenv 加载配置",
"weight": 0.7
},
{
"source": "file:src/agentkit/cli/task.py",
"target": "func:find_config_path",
"type": "calls",
"label": "调用 find_config_path 查找配置文件",
"weight": 0.7
},
{
"source": "file:src/agentkit/cli/task.py",
"target": "func:load_config_with_dotenv",
"type": "calls",
"label": "调用 load_config_with_dotenv 加载配置",
"weight": 0.7
},
{
"source": "file:configs/geo_server.py",
"target": "func:find_config_path",
"type": "calls",
"label": "调用 find_config_path 查找配置文件",
"weight": 0.7
},
{
"source": "file:configs/geo_server.py",
"target": "func:load_config_with_dotenv",
"type": "calls",
"label": "调用 load_config_with_dotenv 加载配置",
"weight": 0.7
},
{
"source": "file:src/agentkit/server/app.py",
"target": "func:load_dotenv",
"type": "calls",
"label": "调用 load_dotenv 在 create_app 中加载 .env",
"weight": 0.7
},
{
"source": "func:needs_onboarding",
"target": "func:find_config_path",
"type": "calls",
"label": "调用 find_config_path 查找配置文件",
"weight": 0.7
},
{
"source": "func:needs_onboarding",
"target": "func:load_config_with_dotenv",
"type": "calls",
"label": "调用 load_config_with_dotenv 加载配置",
"weight": 0.7
},
{
"source": "func:needs_onboarding",
"target": "func:has_llm_provider",
"type": "calls",
"label": "调用 has_llm_provider 检查 LLM 配置",
"weight": 0.8
},
{
"source": "file:src/agentkit/cli/chat.py",
"target": "file:src/agentkit/server/config.py",
"type": "imports",
"label": "导入 find_config_path, load_config_with_dotenv",
"weight": 0.7
},
{
"source": "file:src/agentkit/cli/main.py",
"target": "file:src/agentkit/server/config.py",
"type": "imports",
"label": "导入 find_config_path, load_config_with_dotenv",
"weight": 0.7
},
{
"source": "file:src/agentkit/cli/task.py",
"target": "file:src/agentkit/server/config.py",
"type": "imports",
"label": "导入 find_config_path, load_config_with_dotenv",
"weight": 0.7
},
{
"source": "file:src/agentkit/cli/onboarding.py",
"target": "file:src/agentkit/server/config.py",
"type": "imports",
"label": "导入 find_config_path, load_config_with_dotenv",
"weight": 0.7
},
{
"source": "file:src/agentkit/server/routes/settings.py",
"target": "file:src/agentkit/server/config.py",
"type": "imports",
"label": "间接依赖 _deep_resolve / _resolve_env_vars",
"weight": 0.5
},
{
"source": "func:_deep_resolve",
"target": "func:_resolve_env_vars",
"type": "calls",
"label": "调用 _resolve_env_vars 解析字符串中的 ${VAR}",
"weight": 0.8
},
{
"source": "class:src/agentkit/server/config.py:ServerConfig",
"target": "func:_deep_resolve",
"type": "calls",
"label": "from_yaml 调用 _deep_resolve 解析环境变量",
"weight": 0.8
},
{
"source": "func:_write_yaml_config",
"target": "func:_read_yaml_config",
"type": "calls",
"label": "调用 _read_yaml_config 读取原始 YAML 用于反向解析",
"weight": 0.7
},
{
"source": "file:tests/unit/server/test_settings_routes.py",
"target": "file:src/agentkit/server/routes/settings.py",
"type": "tested_by",
"label": "测试 Settings API 路由",
"weight": 0.5
},
{
"source": "file:tests/unit/test_cli.py",
"target": "file:src/agentkit/cli/main.py",
"type": "tested_by",
"label": "测试 CLI 命令",
"weight": 0.5
}
],
"tours": [

View File

@ -1,6 +1,7 @@
{
"lastAnalyzedAt": "2026-06-15T06:01:34.200955+00:00",
"gitCommitHash": "64d62a2b60c57fbb1844c1f46c541234c8f9d871",
"lastAnalyzedAt": "2026-06-17T05:30:00.000000+00:00",
"gitCommitHash": "840d1af4f7a3c1b5e8d2c6a9f0e3b7d5h6i8j0k2",
"version": "1.0.0",
"analyzedFiles": 2416
"analyzedFiles": 2418,
"lastUpdateSummary": "fix: resolve benchmark failures from root cause (LLM timeout, WebSocket, latency stats)"
}

View File

@ -32,5 +32,7 @@ session: {backend: memory}
bus: {backend: memory}
task_store: {backend: memory}
skills: {auto_discover: true, paths: ["./configs/skills"]}
experts: {paths: ["./configs/experts"]}
board: {max_rounds: 5, default_template: private_board, parallel_speech: true, history_compression_threshold: 20}
logging: {level: INFO, format: text}
router: {classifier: heuristic, auction_enabled: false}

View File

@ -0,0 +1,23 @@
name: allenzhang
description: "张小龙 — 用户体验、极简主义、社交产品直觉"
is_builtin: true
config:
name: allenzhang
agent_type: expert
persona: |
你是张小龙,微信创始人,被誉为"微信之父"。
你信奉极简主义,认为"少即是多",产品应让用户用完即走。
你有极强的社交产品直觉,理解人性中对连接、表达和被认可的需求。
你强调"让自然生长",反对过度运营和打扰用户。
你认为好的产品应该像水一样自然,用户感受不到它的存在却离不开它。
thinking_style: "极简主义 + 用户直觉:从人性需求出发,做减法而非加法"
speaking_style: "内敛、克制,善用产品案例,强调'用户视角'和'自然生长'"
decision_framework: "用户价值优先 — 问'这会让用户觉得简单吗'和'它在 5 年后还有意义吗'"
collaboration_strategy: "cooperative"
bound_skills: []
avatar: "💬"
color: "#07C160"
is_lead: false
task_mode: llm_generate
prompt:
identity: "张小龙"

View File

@ -0,0 +1,23 @@
name: charlie_munger
description: "Charlie Munger — 心智模型、跨学科思维、逆向思考"
is_builtin: true
config:
name: charlie_munger
agent_type: expert
persona: |
你是 Charlie Munger伯克希尔·哈撒韦副主席Warren Buffett 的黄金搭档。
你倡导多元心智模型,从物理学、生物学、心理学、经济学等学科汲取智慧。
你信奉逆向思考:"告诉我我会死在哪里,我就永远不去那里。"
你警惕心理偏误,认为避免愚蠢比追求聪明更重要。
你的表达犀利、幽默,善用格言和极端案例说明道理。
thinking_style: "心智模型 + 逆向思考:从多学科汲取模型,先想如何失败再想如何成功"
speaking_style: "犀利、幽默、善用格言,不回避说'这很愚蠢'"
decision_framework: "逆向思考 — 问'怎样做会必然失败',然后避免它"
collaboration_strategy: "cooperative"
bound_skills: []
avatar: "🧠"
color: "#2C3E50"
is_lead: false
task_mode: llm_generate
prompt:
identity: "Charlie Munger"

View File

@ -0,0 +1,22 @@
name: elon_musk
description: "Elon Musk — 第一性原理、物理思维、激进创新"
is_builtin: true
config:
name: elon_musk
agent_type: expert
persona: |
你是 Elon Musk特斯拉、SpaceX、Neuralink、X 的 CEO。
你以第一性原理思考问题,从物理学基本定律出发推导解决方案。
你敢于挑战不可能,追求激进创新,认为"足够好"是创新的敌人。
你关注人类文明的长期未来,致力于加速可持续能源和跨行星生存。
thinking_style: "第一性原理:从物理学基本定律出发,剥离类比思维,直接推导本质"
speaking_style: "直接、简洁、偶尔尖锐,善用比喻,不回避争议性观点"
decision_framework: "第一性原理 — 问'这件事的物理学本质是什么',再推导可行性"
collaboration_strategy: "cooperative"
bound_skills: []
avatar: "🚀"
color: "#E31937"
is_lead: false
task_mode: llm_generate
prompt:
identity: "Elon Musk"

View File

@ -0,0 +1,23 @@
name: jeff_bezos
description: "Jeff Bezos — Day 1 思维、客户至上、长期主义"
is_builtin: true
config:
name: jeff_bezos
agent_type: expert
persona: |
你是 Jeff Bezos亚马逊创始人、Blue Origin 创始人。
你坚持 Day 1 思维:永远像创业第一天那样行动,保持初学者心态。
你以客户为起点反向工作,而非以能力为起点正向工作。
你愿意用 7 年时间证明一个长期决策的正确性,拒绝短期主义。
你认为"客户永远不满足",这是创新的永恒动力。
thinking_style: "Day 1 思维:保持创业第一天的紧迫感和初学者心态"
speaking_style: "沉稳、结构化,善用'如果'场景分析,强调可逆与不可逆决策的区别"
decision_framework: "客户至上 + 长期主义 — 问'什么对客户最好'和'这个决策 10 年后是否仍正确'"
collaboration_strategy: "cooperative"
bound_skills: []
avatar: "📦"
color: "#FF9900"
is_lead: false
task_mode: llm_generate
prompt:
identity: "Jeff Bezos"

View File

@ -0,0 +1,24 @@
name: paul_graham
description: "Paul Graham — 创业、做用户想要的东西、反从众"
is_builtin: true
config:
name: paul_graham
agent_type: expert
persona: |
你是 Paul GrahamY Combinator 联合创始人,程序员、散文家、投资人。
你投资了 Airbnb、Stripe、Reddit 等数百家创业公司。
你信奉"做用户想要的东西",认为创业的本质是解决真实问题。
你反从众,鼓励创始人走"不寻常的路",认为最好的想法往往看起来不像好想法。
你强调"ramen profitable"(拉面盈利)的重要性,认为自给自足是创业者的自由之源。
你的文章《How to Do Great Work》《Maker's Schedule, Manager's Schedule》影响了无数创业者。
thinking_style: "本质主义 + 反从众:从用户真实需求出发,警惕看起来正常的想法"
speaking_style: "平实、直接、善用具体创业案例,偶尔幽默,不回避说'这看起来很蠢'"
decision_framework: "用户价值 + 不寻常路 — 问'用户真的想要这个吗'和'这看起来像坏想法吗'"
collaboration_strategy: "cooperative"
bound_skills: []
avatar: "📝"
color: "#FF6600"
is_lead: false
task_mode: llm_generate
prompt:
identity: "Paul Graham"

View File

@ -0,0 +1,25 @@
name: private_board
description: "默认私董会模板 — 包含 5 位跨领域名人专家"
is_builtin: true
config:
name: private_board
agent_type: expert
persona: "私董会模板 — 跨领域名人专家团"
thinking_style: "多视角综合"
speaking_style: "多样化"
decision_framework: "多维度评估"
collaboration_strategy: "cooperative"
# private_board 模板使用 bound_skills 字段存储成员列表
# 这是对现有字段的重用,避免新增 schema
bound_skills:
- elon_musk
- jeff_bezos
- allenzhang
- charlie_munger
- paul_graham
avatar: "🏛️"
color: "#8E44AD"
is_lead: false
task_mode: llm_generate
prompt:
identity: "Private Board Template"

View File

@ -0,0 +1,24 @@
name: ray_dalio
description: "Ray Dalio — 原则驱动决策、极度透明、believability-weighted"
is_builtin: true
config:
name: ray_dalio
agent_type: expert
persona: |
你是 Ray Dalio桥水基金创始人《原则》作者。
你相信"原则驱动决策",将决策过程系统化为可重复的原则。
你倡导"极度透明"和"极度真实",认为直面现实是做出好决策的前提。
你使用"believability-weighted"决策机制,根据每个人的可信度加权意见。
你认为痛苦+反思=进步,错误是学习的机会而非失败。
你从经济机器的运行规律出发理解世界,相信"所有事情都是机器"。
thinking_style: "原则驱动 + 系统思维:将决策系统化,从经济机器角度理解问题"
speaking_style: "结构化、善用原则编号,强调'极度真实',不回避说'这不符合原则'"
decision_framework: "原则驱动 — 问'这符合哪条原则'和'最可信的人怎么看'"
collaboration_strategy: "cooperative"
bound_skills: []
avatar: "⚖️"
color: "#1A5276"
is_lead: false
task_mode: llm_generate
prompt:
identity: "Ray Dalio"

View File

@ -0,0 +1,24 @@
name: steve_jobs
description: "Steve Jobs — 产品设计、现实扭曲力场、专注"
is_builtin: true
config:
name: steve_jobs
agent_type: expert
persona: |
你是 Steve JobsApple 联合创始人Mac、iPhone、iPad 的缔造者。
你对产品设计有极致的追求,认为"设计不只是外观,而是如何运作"。
你拥有"现实扭曲力场",能说服自己和他人实现看似不可能的目标。
你强调专注,认为"对 1000 件事说不,比对 1 件事说是更重要"。
你相信站在科技与人文的十字路口,做出既有技术深度又有人文温度的产品。
你不容忍平庸,认为"足够好"是不可接受的。
thinking_style: "设计思维 + 极致专注:从用户体验出发,做减法,追求完美"
speaking_style: "激情、有感染力,善用极端对比,不回避说'这完全是垃圾'"
decision_framework: "用户体验 + 专注 — 问'这足够简单吗'和'这是我能做的最好的吗'"
collaboration_strategy: "cooperative"
bound_skills: []
avatar: "🍎"
color: "#555555"
is_lead: false
task_mode: llm_generate
prompt:
identity: "Steve Jobs"

View File

@ -0,0 +1,24 @@
name: warren_buffett
description: "Warren Buffett — 价值投资、能力圈、复利思维"
is_builtin: true
config:
name: warren_buffett
agent_type: expert
persona: |
你是 Warren Buffett伯克希尔·哈撒韦 CEO被誉为"奥马哈先知"。
你是价值投资的代表人物,相信"以合理价格买入伟大公司"。
你严格遵守"能力圈"原则,不投资自己不理解的业务。
你信奉复利的力量,认为"人生就像滚雪球,重要的是找到很湿的雪和很长的坡"。
你强调"别人贪婪时我恐惧,别人恐惧时我贪婪",逆向投资是你的标志。
你认为投资决策应该基于企业的内在价值,而非市场情绪。
thinking_style: "价值投资 + 能力圈:评估内在价值,只在自己理解的领域决策"
speaking_style: "平易近人、善用比喻和故事,幽默,强调'简单和常识'"
decision_framework: "能力圈 + 内在价值 — 问'我理解这个业务吗'和'它的内在价值是多少'"
collaboration_strategy: "cooperative"
bound_skills: []
avatar: "💰"
color: "#1E8449"
is_lead: false
task_mode: llm_generate
prompt:
identity: "Warren Buffett"

View File

@ -0,0 +1,89 @@
# 生产部署专用 Compose 文件
# 由 Gitea Actions 在 /opt/agentkit/repo 下调用
# 与开发用 docker-compose.yaml 的区别:
# 1. 不暴露 Redis/PostgreSQL 端口到公网(仅内部通信)
# 2. 密码通过 .env 注入
# 3. 配置日志大小限制,避免磁盘打满
# 4. 配置资源限制,避免单服务吃满内存
services:
agentkit:
build: .
command: serve --host 0.0.0.0 --port 8001
ports:
- "8001:8001"
env_file: .env
environment:
- REDIS_URL=redis://redis:6379/0
- DATABASE_URL=postgresql+asyncpg://agentkit:${POSTGRES_PASSWORD}@postgres:5432/agentkit
depends_on:
redis:
condition: service_healthy
postgres:
condition: service_healthy
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8001/api/v1/health')"]
interval: 30s
timeout: 10s
start_period: 30s
retries: 3
restart: unless-stopped
logging:
driver: json-file
options:
max-size: "50m"
max-file: "5"
deploy:
resources:
limits:
memory: 2G
redis:
image: redis:7-alpine
# 不暴露端口到公网,仅容器内部通信
expose:
- "6379"
command: >
redis-server
--requirepass ${REDIS_PASSWORD}
--maxmemory 256mb
--maxmemory-policy allkeys-lru
volumes:
- redisdata:/data
healthcheck:
test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"]
interval: 10s
timeout: 5s
retries: 5
restart: unless-stopped
logging:
driver: json-file
options:
max-size: "20m"
max-file: "3"
postgres:
image: pgvector/pgvector:pg15
expose:
- "5432"
environment:
POSTGRES_USER: agentkit
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: agentkit
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U agentkit"]
interval: 10s
timeout: 5s
retries: 5
restart: unless-stopped
logging:
driver: json-file
options:
max-size: "20m"
max-file: "3"
volumes:
redisdata:
pgdata:

View File

@ -0,0 +1,243 @@
# Gitea Actions 自动部署指南
> 目标:推送到 `main`/`master` 分支后Gitea Actions 自动构建并部署到服务器 `8.153.107.96`
>
> 方案Gitea Actions + 自托管 Runnerhost 模式)+ Docker Compose + Gitea Secrets
## 架构
```
开发者 push → Gitea (http://8.153.107.96/gitea) → Actions 触发
Runner (同机 host 模式) 执行 workflow
1. checkout 代码
2. rsync 同步到 /opt/agentkit/repo
3. 从 Secrets 写入 .env
4. docker compose build & up -d
5. 健康检查 http://localhost:8001/api/v1/health
```
## 前置条件
服务器 `8.153.107.96` 上需具备:
- [x] Gitea >= 1.21(已部署在 `http://8.153.107.96/gitea`
- [x] Docker Engine >= 20.10
- [x] Docker Compose v2`docker compose` 命令)
- [x] sudo 权限的用户(用于安装 Runner、创建 /opt/agentkit
## 步骤一:启用 Gitea Actions
SSH 登录服务器,编辑 Gitea 配置文件(通常在 `/etc/gitea/app.ini` 或 Gitea 容器内的 `/data/gitea/conf/app.ini`
```ini
[actions]
ENABLED = true
DEFAULT_ACTIONS_URL = https://gitea.com
```
重启 Gitea
```bash
# 若 Gitea 以 systemd 运行
sudo systemctl restart gitea
# 若 Gitea 以 docker 运行
docker restart gitea
```
## 步骤二:安装 Gitea Runnerhost 模式)
> host 模式直接在宿主机执行 shell 命令,可操作 `/opt/agentkit` 和 Docker无需挂载 socket。
```bash
# 1. 下载 runner 二进制Linux x86_64 示例)
# 最新版本见 https://gitea.com/gitea/actions-runner/releases
RUNNER_VERSION=0.2.6
curl -L -o /usr/local/bin/gitea-runner \
"https://gitea.com/gitea/actions-runner/releases/download/v${RUNNER_VERSION}/gitea-runner-${RUNNER_VERSION}-linux-amd64"
chmod +x /usr/local/bin/gitea-runner
# 2. 创建 runner 工作用户(可选,避免 root 运行)
sudo useradd -m -s /bin/bash gitea-runner
# 让该用户可使用 docker
sudo usermod -aG docker gitea-runner
# 让该用户可 sudo 执行 mkdir/chown部署脚本需要
echo "gitea-runner ALL=(ALL) NOPASSWD: /usr/bin/mkdir, /usr/bin/chown" | sudo tee /etc/sudoers.d/gitea-runner
# 3. 切换到 runner 用户
sudo su - gitea-runner
# 4. 注册 runner
gitea-runner register \
--instance http://8.153.107.96/gitea \
--token <YOUR_REGISTRATION_TOKEN> \
--name self-hosted \
--labels self-hosted,linux \
--no-interactive
# 注册 token 获取路径:
# Gitea Web → 站点管理 → Actions → Runners → 创建 Runner token
# 或仓库级:仓库 → Settings → Actions → Runners → 创建 token
```
创建 systemd 服务(推荐,开机自启):
```bash
sudo tee /etc/systemd/system/gitea-runner.service > /dev/null <<'EOF'
[Unit]
Description=Gitea Actions Runner
After=network.target docker.service
[Service]
User=gitea-runner
Group=gitea-runner
WorkingDirectory=/home/gitea-runner
ExecStart=/usr/local/bin/gitea-runner daemon
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
EOF
sudo systemctl daemon-reload
sudo systemctl enable --now gitea-runner
sudo systemctl status gitea-runner
```
验证 runner 已注册Gitea Web → 站点管理 → Actions → Runners应看到 `self-hosted` 状态为 `idle`
## 步骤三:配置 Gitea Secrets
进入仓库 → **Settings → Actions → Secrets**,添加以下 secrets参考 `.env.example`
| Secret 名 | 说明 | 是否必填 |
|-----------|------|---------|
| `POSTGRES_PASSWORD` | PostgreSQL 密码 | **必填** |
| `REDIS_PASSWORD` | Redis 密码 | **必填** |
| `AGENTKIT_API_KEY` | 外部系统调用 API 的密钥 | **必填** |
> **LLM API Key 不在此配置**。部署完成后,通过 Web UI Settings 页面配置 LLM provider 和 API key
> `PUT /api/v1/settings/llm` 会自动写入 `agentkit.yaml``.env`)。
## 步骤四:首次部署准备
```bash
# 1. 创建部署目录
sudo mkdir -p /opt/agentkit
sudo chown -R gitea-runner:gitea-runner /opt/agentkit
# 2. 确认 Docker 已就绪
docker version
docker compose version
```
## 步骤五:触发部署
```bash
# 本地推送主干分支
git push origin main
```
推送后访问Gitea Web → 仓库 → **Actions**,查看 `Deploy to Production` workflow 执行情况。
## 验证
```bash
# 1. 服务状态
ssh user@8.153.107.96
cd /opt/agentkit/repo
docker compose -f docker-compose.deploy.yaml ps
# 2. 健康检查
curl http://localhost:8001/api/v1/health
# 3. 公网访问
curl http://8.153.107.96:8001/api/v1/health
# 4. 查看日志
docker compose -f docker-compose.deploy.yaml logs -f --tail=100
```
## 首次使用:配置 LLM API Key
部署成功后,服务可访问但尚未配置 LLM provider聊天功能不可用。通过 Web UI 完成 onboarding
1. 浏览器访问 `http://8.153.107.96:8001`
2. 进入 **Settings → LLM** 页面
3. 添加 LLM provider支持 OpenAI / Anthropic / Gemini / DeepSeek / 通义千问 / 豆包 等)
4. 填入 API key 并选择默认模型
5. 保存后配置自动写入 `agentkit.yaml``.env`,无需重启服务
也可通过 API 直接配置:
```bash
curl -X PUT http://8.153.107.96:8001/api/v1/settings/llm \
-H "Content-Type: application/json" \
-d '{
"providers": [{
"name": "deepseek",
"type": "openai",
"base_url": "https://api.deepseek.com/v1",
"api_key": "sk-your-key-here",
"models": {"deepseek-chat": {"alias": "default"}}
}]
}'
```
## 文件清单
| 文件 | 用途 |
|------|------|
| `.gitea/workflows/deploy.yml` | Gitea Actions 工作流定义 |
| `scripts/deploy.sh` | 服务器侧部署脚本build + up |
| `docker-compose.deploy.yaml` | 生产部署专用 Compose不暴露 DB 端口) |
| `.env.example` | Secrets 配置清单参考 |
## 故障排查
### Runner 不执行任务
- 确认 runner 标签包含 `self-hosted`workflow 中 `runs-on: self-hosted`
- 确认 runner 状态为 `idle` 而非 `offline`
- `sudo journalctl -u gitea-runner -f` 查看 runner 日志
### docker compose 命令找不到
- 确认安装 Docker Compose v2`docker compose version`
- 若仅有 v1`docker-compose`),需安装 `docker-compose-plugin`
### 健康检查失败
```bash
# 查看容器日志
docker compose -f /opt/agentkit/repo/docker-compose.deploy.yaml logs agentkit
# 进入容器排查
docker compose -f /opt/agentkit/repo/docker-compose.deploy.yaml exec agentkit bash
```
### .env 未生成或内容缺失
- 确认所有必填 Secrets 已配置POSTGRES_PASSWORD、REDIS_PASSWORD、AGENTKIT_API_KEY
- workflow 中 `cat > "$REPO_DIR/.env"` 步骤需成功执行,查看 Actions 日志
### 首次部署数据库初始化
首次启动时 PostgreSQL 会自动初始化。如需重置(**会丢数据**
```bash
cd /opt/agentkit/repo
docker compose -f docker-compose.deploy.yaml down -v
docker compose -f docker-compose.deploy.yaml up -d
```
## 安全建议
1. **不要**将 `.env` 提交到仓库(已在 `.gitignore` 中)
2. 服务器防火墙仅放行 `8001`API、`22`SSH、`80/443`Gitea**不要**暴露 `5432`/`6379` 到公网
3. 定期备份 `/opt/agentkit/repo/.env` 和 Docker 卷(`pgdata`、`redisdata`
4. Runner 用户 `gitea-runner` 仅授予最小 sudo 权限(已通过 sudoers 限制)
5. 生产 POSTGRES_PASSWORD / REDIS_PASSWORD 应为强随机字符串

View File

@ -0,0 +1,454 @@
# 私董会讨论模式Board Meeting Mode需求文档
**日期**: 2026-06-17
**状态**: Draft
**范围**: Deep — feature
**作者**: ce-brainstorm
---
## 1. 背景与动机
### 1.1 当前专家团实现状态
Fischer AgentKit 现有专家团功能位于 `src/agentkit/experts/`,采用 **hub-and-spoke中心辐射模式**
- Lead Expert 分解任务 → Member Experts 并行执行子任务 → Lead 综合结果
- 子任务深度=1**无 Agent 间通信**`handoff_transport` 仅用于事件广播
- 协作模式单一:`MergeStrategy` 仅保留 `BEST`,移除了 VOTE/FUSION
**关键缺口**
1. **未集成到主聊天流程**`src/agentkit/server/routes/chat.py:584-590``TEAM_COLLAB` 模式回退到 REACT`emit_team_event()` 已定义但未被调用
2. **无预设专家模板** — 没有 `configs/experts/` 目录,`ExpertTemplateRegistry` 默认为空
3. **`team_dissolved` 事件未触发** — 前端有处理代码,后端不广播
4. **TeamStatus 缺 PLANNING** — 文档/前端有,后端枚举没有
### 1.2 对标分析
| 维度 | Qoder | WorkBuddy | Legends MCP | colleague.skill | Fischer 当前 |
|------|-------|-----------|-------------|-----------------|-------------|
| 预设角色 | 7类工程专家 | 160+功能角色 | **36位名人** | **13位蒸馏名人** | ❌ 无 |
| 名人/SOUL角色 | ❌ | ❌ | ✅ 马斯克/乔布斯等 | ✅ 5层人格模型 | ❌ |
| 协作模式 | 任务图编排 | 任务图并行 | **Party Mode群聊** | 单Agent | hub-and-spoke |
| 自主循环讨论 | ❌ | ❌ | ✅ | ❌ | ❌ |
| 终止机制 | 任务完成 | 任务完成 | Smart Suggest | - | 任务完成 |
| 可视化 | Expert Team Canvas | 对话流 | 对话 | Skill文件 | ExpertTeamView(未启用) |
**关键洞察**
- 主流编程工具Qoder/WorkBuddy/MetaGPT**均未采用名人角色**名人角色存在于独立生态Legends MCP、colleague.skill
- Fischer 若做"名人蒸馏 + 自主循环讨论",是**差异化机会**
- 可参考AgentVerse 通信框架 + colleague.skill 5层人格模型 + ARMOR-MAD 协议阈值终止
### 1.3 动机
用户希望除了"自动创建子 agent 处理复杂任务"外,还能创建**预设固定的名人专家团**(如私董会:马斯克、贝佐斯、张小龙),针对主题进行**自主循环讨论**,各专家发表意见、碰撞观点,直到得出结果或用户干预。整个过程**像群聊一样**呈现。
---
## 2. 目标与非目标
### 2.1 目标
1. **新增私董会讨论模式** — 与现有 hub-and-spoke 任务分解模式并列,通过 `@board` 前缀触发
2. **预设名人专家库** — 内置 5-8 位名人专家YAML 定义 persona/thinking_style/speaking_style
3. **自主循环讨论** — 每轮全员发言(并行生成)+ 主持人小结,达到最大轮次后主持人给出最终决策建议
4. **群聊式体验** — 前端以群聊形式展示讨论过程,专家消息带头像/颜色/角色标识
5. **用户随时干预** — 用户可在任意轮次插入消息影响讨论方向
6. **主聊天流程集成** — 在 `chat.py` 中接入 `BoardRouter`,不再回退到 REACT
### 2.2 非目标
1. **不集成外部蒸馏工具**colleague.skill/nuwa-skill—— 未来可扩展,本期仅 YAML 定义
2. **不实现 LLM 自动蒸馏** —— 本期仅人工编写 YAML
3. **不实现共识检测自动终止** —— 仅最大轮次 + 用户干预
4. **不改动现有 hub-and-spoke 模式** —— `@team` 保持不变
5. **不做技术评审/创意脑暴场景** —— 本期聚焦决策类私董会
6. **不做 Expert Team Canvas 式可视化** —— 复用现有群聊 UI仅增强专家消息展示
7. **不实现专家间直接通信** —— 专家发言基于共享讨论历史,不直接对话
---
## 3. 用户故事
### 3.1 主流程:发起私董会
> 作为产品负责人,我希望就一个决策类问题(如"是否该做 X 功能")召集名人专家团讨论,获得多视角建议,以便做出更明智的决策。
**触发**:用户输入 `@board:elon_musk,jeff_bezos,allenzhang 是否该做私董会功能?`
**期望**
- 系统识别 `@board` 前缀,加载指定名人专家
- 主持人(默认首位或系统指定)开场介绍议题
- 马斯克从第一性原理视角发言
- 贝佐斯从 Day 1/客户至上视角发言
- 张小龙从用户体验视角发言
- 主持人小结本轮要点
- 进入下一轮,专家基于前序讨论继续
- 达到最大轮次(默认 5 轮)后,主持人给出最终决策建议
- 全过程以群聊形式展示,用户可随时插入消息
### 3.2 干预流程:用户介入讨论
> 作为用户,我希望在讨论过程中随时插入观点或追问,影响讨论方向。
**触发**:讨论进行中,用户输入 `我觉得成本不是主要问题,关键是用户接受度`
**期望**
- 系统将用户消息广播给所有专家
- 下一轮发言时,专家会参考用户观点调整发言方向
- 主持人小结时提及用户观点
### 3.3 配置流程:使用预设专家团
> 作为用户,我希望直接使用预设的"私董会专家团"模板,无需每次指定专家名。
**触发**:用户输入 `@board:private_board 是否该做 X 功能?``@board 是否该做 X 功能?`(使用默认私董会模板)
**期望**
- 系统识别 `private_board` 模板,加载预设的 3-5 位名人专家
- 后续流程与主流程一致
### 3.4 扩展流程:自定义专家
> 作为高级用户,我希望创建自己的名人专家 YAML 文件,扩展专家库。
**触发**:用户在 `configs/experts/` 目录下创建 `linus.yaml`
**期望**
- 系统启动时自动加载 `configs/experts/*.yaml`
- 用户可通过 `@board:linus 讨论主题` 调用自定义专家
---
## 4. 功能需求
### 4.1 路由与触发
**FR-1: `@board` 前缀路由**
- 支持两种格式:
- `@board:expert1,expert2 讨论主题` — 指定专家
- `@board 讨论主题` — 使用默认私董会模板(`private_board`
- 专家名验证:`^[a-zA-Z0-9_-]{1,64}$`,最多 10 位专家
- 至少 1 位专家,否则提示选择
- 未识别的专家名:提示可用专家列表
- 路由结果包含:专家配置列表、讨论主题、是否使用默认模板
**FR-2: 与现有路由共存**
- `@board``@team` 互不干扰
- `@board` 优先级高于普通聊天,低于系统命令
- 在 `RequestPreprocessor` 或等效位置集成 `BoardRouter`
### 4.2 专家配置
**FR-3: 预设名人专家库**
- 新增 `configs/experts/` 目录
- 内置 5-8 位名人专家 YAML
- `elon_musk.yaml` — 第一性原理、物理思维、激进创新
- `jeff_bezos.yaml` — Day 1 思维、客户至上、长期主义
- `allenzhang.yaml` — 用户体验、极简主义、社交产品直觉
- `charlie_munger.yaml` — 心智模型、跨学科思维、逆向思考
- `paul_graham.yaml` — 创业、做用户想要的东西、反从众
- `steve_jobs.yaml` — 产品设计、现实扭曲力场、专注
- `warren_buffett.yaml` — 价值投资、能力圈、复利思维
- `ray_dalio.yaml` — 原则驱动决策、极度透明、 believability-weighted
- 每个 YAML 包含:`name`、`persona`、`thinking_style`、`speaking_style`、`avatar`、`color`、`bound_skills`(可选)
**FR-4: `ExpertConfig` 扩展**
- 新增 `speaking_style: str` 字段 — 描述表达风格(如"直接、用比喻、偶尔尖锐"
- 新增 `decision_framework: str` 字段(可选)— 描述决策框架(如"第一性原理"、"Day 1"
- 字段需有默认值,向后兼容现有 `@team` 模式
**FR-5: 默认私董会模板**
- 内置 `private_board` 模板,包含 3-5 位默认名人专家
- 模板可被用户 YAML 覆盖
### 4.3 讨论引擎
**FR-6: `BoardTeam` 容器**
- 复用 `Expert`、`AgentPool`、`HandoffTransport`、`SharedWorkspace`
- 新增 `BoardStatus` 枚举:`FORMING` → `DISCUSSING``CONCLUDING``COMPLETED``DISSOLVED`
- 持有:专家列表、主持人名、讨论主题、讨论历史(所有发言)、当前轮次、最大轮次
- 主持人默认为首位专家,可通过配置指定
**FR-7: `BoardOrchestrator` 讨论引擎**
- **开场阶段**:主持人介绍议题、说明讨论规则
- **讨论阶段**(循环):
- 每轮:所有非主持人专家**并行生成发言**(基于讨论历史 + 角色 prompt
- 每轮结束:主持人小结本轮要点、判断是否继续
- 发言生成需注入:角色 persona、thinking_style、speaking_style、完整讨论历史、当前轮次/最大轮次
- **总结阶段**:达到最大轮次后,主持人给出最终决策建议(含各方观点汇总、共识点、分歧点、建议行动)
- **用户干预处理**:用户消息插入讨论历史,下一轮发言时专家可见
**FR-8: 讨论历史管理**
- 讨论历史结构:`[{round, expert_name, content, timestamp}]`
- 每轮发言后追加到历史
- 主持人小结也作为历史的一部分
- 用户干预消息标记为 `role: "user"`,与专家发言区分
- 历史过长时(超过 token 限制):主持人先压缩历史,再继续
**FR-9: 终止条件**
- **正常终止**:达到最大轮次(默认 5可配置 1-10
- **用户终止**:用户发送 `/stop``停止讨论`
- **异常终止**LLM 不可用或所有专家发言失败 → 主持人用已有历史总结
- 终止后广播 `board_concluded` 事件
### 4.4 WebSocket 事件
**FR-10: 新增事件类型**
| 事件 | 触发时机 | 数据 |
|------|---------|------|
| `board_started` | 讨论开始 | `{team_id, topic, experts: [{name, avatar, color, is_moderator}], max_rounds}` |
| `expert_speech` | 专家发言完成 | `{expert_name, expert_avatar, expert_color, content, round}` |
| `round_summary` | 主持人小结完成 | `{moderator_name, content, round, continue: bool}` |
| `user_intervention` | 用户消息广播 | `{content, round}` |
| `board_concluded` | 讨论结束 | `{summary, decision_advice, total_rounds, consensus_points, dissent_points}` |
**FR-11: 事件广播**
- 通过 `handoff_transport.send(team_channel, event)` 广播
- 在 `chat.py` 中通过 `emit_team_event()` 推送到 WebSocket
- 事件类型加入 `_VALID_TEAM_EVENT_TYPES`
### 4.5 前端展示
**FR-12: 群聊式 UI**
- 复用现有 `ExpertMessage.vue` 组件,增强展示:
- 专家头像emoji 或图片)
- 专家名 + 角色标签(如"主持人"、"第一性原理视角"
- 发言内容(支持 markdown
- 轮次标识(如"第 2 轮"
- 主持人小结以特殊样式区分(如背景色、边框)
- 用户干预消息以右侧气泡展示(区别于专家左侧)
**FR-13: 讨论状态展示**
- 顶部显示:讨论主题、当前轮次/最大轮次、参与专家列表
- 专家列表可点击查看其所有发言
- 讨论结束后展示总结卡片(决策建议、共识点、分歧点)
**FR-14: 干预输入**
- 讨论进行中,输入框保持可用
- 用户输入即作为干预消息广播
- 支持 `/stop` 命令终止讨论
### 4.6 配置
**FR-15: `agentkit.yaml` 配置项**
```yaml
board:
max_rounds: 5 # 默认最大轮次
default_template: private_board # 默认私董会模板
parallel_speech: true # 是否并行生成发言
history_compression_threshold: 4000 # 历史 token 超过此值时压缩
```
---
## 5. 范围边界
### 5.1 包含
- `BoardTeam`、`BoardOrchestrator`、`BoardRouter` 新模块
- `configs/experts/` 预设名人 YAML5-8 位)
- `ExpertConfig` 扩展(`speaking_style`、`decision_framework`
- WebSocket 事件5 个新事件)
- 前端群聊式展示增强
- 主聊天流程集成(`chat.py` 接入 `BoardRouter`
- 单元测试覆盖核心逻辑
### 5.2 延后Deferred for later
- 集成外部蒸馏工具colleague.skill/nuwa-skill
- LLM 自动蒸馏生成名人 SOUL
- 共识检测自动终止(置信度/投票)
- 技术评审/创意脑暴场景
- Expert Team Canvas 式可视化
- 专家间直接通信(辩论模式)
- 语音/视频输出
- 历史讨论回顾与检索
### 5.3 不在本产品身份内Outside this product's identity
- 实时名人数据更新(如抓取最新推文更新 persona
- 名人本人授权或验证
- 娱乐向角色扮演(非决策用途)
---
## 6. 依赖与假设
### 6.1 依赖
- **现有基础设施**`Expert`、`AgentPool`、`HandoffTransport`、`SharedWorkspace`、`ExpertTemplateRegistry`
- **LLM Gateway**`src/agentkit/llm/` 提供多 provider 支持
- **WebSocket**`src/agentkit/server/routes/chat.py` 现有 WebSocket 通道
- **前端组件**`ExpertMessage.vue`、`ExpertTeamView.vue`、`stores/team.ts`
### 6.2 假设
1. **YAML 足够体现名人思维** — 人工编写的 persona/thinking_style/speaking_style 能让 LLM 生成有差异化的发言,无需深度蒸馏
2. **并行发言可行** — 专家发言基于共享历史,无需严格顺序,可并行生成
3. **token 消耗可接受** — 5 轮 × 5 专家 = 25 次 LLM 调用,单次讨论成本在可接受范围
4. **现有 `Expert` 可复用**`Expert` 包装器的 `send_message`、`team_context` 注入机制适用于讨论模式
5. **前端群聊 UI 可扩展**`ExpertMessage.vue` 可通过 props 增强展示,无需重写
### 6.3 风险
| 风险 | 影响 | 缓解 |
|------|------|------|
| 专家发言同质化 | 讨论质量低,失去多视角价值 | 强化角色 prompt 差异化,提供 `decision_framework` 字段 |
| token 消耗过高 | 成本问题 | 提供轮次配置,历史压缩机制 |
| 讨论发散无结论 | 用户体验差 | 主持人每轮小结,最终强制总结 |
| 名人 persona 不准确 | 发言不像本人 | YAML 可迭代优化,未来支持蒸馏 |
| 与现有 `@team` 集成冲突 | 路由混乱 | 独立 `BoardRouter`,前缀明确区分 |
---
## 7. 成功标准
### 7.1 功能完成标准
- [ ] `@board:elon_musk,jeff_bezos 讨论主题` 能触发私董会讨论
- [ ] `@board 讨论主题` 能使用默认模板
- [ ] 预设 5-8 位名人专家 YAML 可用
- [ ] 每轮专家并行发言,主持人小结
- [ ] 达到最大轮次后主持人给出最终决策建议
- [ ] 用户可随时插入消息影响讨论
- [ ] 用户可 `/stop` 终止讨论
- [ ] 前端以群聊形式展示讨论过程
- [ ] WebSocket 事件正确广播和接收
- [ ] 单元测试覆盖率 ≥ 80%
### 7.2 质量标准
- 专家发言有明确角色差异(第一性原理 vs Day 1 vs 用户体验)
- 主持人小结能准确归纳本轮要点
- 最终决策建议包含各方观点汇总、共识点、分歧点
- 单次讨论 token 消耗可预测(提供配置项)
### 7.3 集成标准
- `@board``@team` 互不干扰
- 不破坏现有聊天流程
- 前端群聊 UI 兼容现有专家消息展示
---
## 8. 开放问题
1. **主持人选择策略**:默认首位专家作为主持人,还是引入独立的" facilitator"角色(非名人,专职主持)?
- **当前决策**:默认首位专家,可配置。未来可扩展独立 facilitator。
2. **专家发言顺序**:并行生成时,前端展示顺序如何确定?
- **当前决策**:按专家列表顺序展示,即使并行生成也按配置顺序追加到 UI。
3. **讨论历史 token 管理**5 轮 × 5 专家的历史可能超过上下文窗口,压缩策略如何?
- **当前决策**:超过阈值时主持人先压缩历史(保留关键观点),再继续。具体阈值和压缩 prompt 在 planning 阶段细化。
4. **预设名人选择**:内置哪 5-8 位名人最合适?
- **当前决策**马斯克、贝佐斯、张小龙、芒格、Paul Graham、乔布斯、巴菲特、Ray Dalio。可在 planning 阶段调整。
5. **与现有 `TEAM_COLLAB` 集成**:是否需要同时修复 `@team``chat.py` 的集成缺口?
- **当前决策**:本期聚焦 `@board``@team` 集成作为独立任务。但 `emit_team_event()` 的调用模式可复用。
---
## 9. 建议的实现路径(供 ce-plan 参考)
### 9.1 模块结构
```
src/agentkit/experts/
├── __init__.py # 新增导出
├── config.py # 扩展 ExpertConfig
├── expert.py # 复用
├── team.py # 现有 hub-and-spoke不动
├── orchestrator.py # 现有(不动)
├── plan.py # 现有(不动)
├── registry.py # 复用
├── router.py # 现有 @team(不动)
├── board.py # 新增BoardTeam, BoardStatus
├── board_orchestrator.py # 新增BoardOrchestrator
└── board_router.py # 新增BoardRouter, @board 前缀
configs/experts/ # 新增目录
├── elon_musk.yaml
├── jeff_bezos.yaml
├── allenzhang.yaml
├── charlie_munger.yaml
├── paul_graham.yaml
├── steve_jobs.yaml
├── warren_buffett.yaml
└── ray_dalio.yaml
```
### 9.2 关键集成点
- `src/agentkit/server/routes/chat.py` — 接入 `BoardRouter`,调用 `emit_team_event()`
- `src/agentkit/server/app.py` — 启动时加载 `configs/experts/*.yaml``ExpertTemplateRegistry`
- `src/agentkit/server/frontend/src/api/types.ts` — 新增 WebSocket 事件类型
- `src/agentkit/server/frontend/src/stores/chat.ts` — 新增事件处理
- `src/agentkit/server/frontend/src/components/chat/ExpertMessage.vue` — 增强展示
### 9.3 测试策略
- `tests/unit/experts/test_board.py` — BoardTeam 测试
- `tests/unit/experts/test_board_orchestrator.py` — 讨论流程测试
- `tests/unit/experts/test_board_router.py` — 路由测试
- `tests/unit/experts/test_config.py` — 新字段测试
- Mock LLM Gateway 进行集成测试
---
## 10. 对标差距总结
### 10.1 Fischer 当前 vs Qoder/WorkBuddy
| 维度 | Fischer 当前 | Qoder/WorkBuddy | 差距 |
|------|-------------|-----------------|------|
| 主聊天集成 | ❌ 未集成 | ✅ 完整集成 | **关键差距** |
| 预设专家 | ❌ 无 | ✅ 7-160+ | **关键差距** |
| 可视化 | 未启用 | Expert Team Canvas | 中等差距 |
| 协作模式 | hub-and-spoke | 任务图编排 | 模式不同,非差距 |
| 失败处理 | 三层 fallback | 高风险确认 | Fischer 更轻量 |
### 10.2 Fischer 私董会模式 vs Legends MCP/colleague.skill
| 维度 | Fischer 私董会(规划) | Legends MCP/colleague.skill | 优势 |
|------|----------------------|----------------------------|------|
| 名人角色 | 5-8 位 YAML | 13-36 位蒸馏 | 精度较低,但可扩展 |
| 讨论模式 | 全员发言+主持人小结 | Party Mode | Fischer 更结构化 |
| 终止机制 | 最大轮次+主持人总结 | Smart Suggest | Fischer 更可控 |
| 集成度 | 深度集成到 AgentKit | 独立工具 | Fischer 更一体化 |
| 可扩展 | YAML + 未来蒸馏 | 蒸馏工具 | Fischer 更开放 |
### 10.3 优劣势总结
**Fischer 私董会模式的优势**
1. **结构化讨论** — 主持人小结机制,避免发散
2. **可控终止** — 最大轮次 + 用户干预,成本可预测
3. **深度集成** — 与 AgentKit 生态无缝衔接
4. **可扩展** — YAML 定义 + 未来蒸馏工具集成
**劣势**
1. **名人精度** — YAML 人工编写,不如蒸馏精确
2. **无共识检测** — 仅轮次终止,可能未达共识就结束
3. **无专家间直接通信** — 辩论深度有限
4. **前置依赖** — 需先完成主聊天流程集成
---
**下一步**: 交由 `/ce-plan` 进行详细实现规划。

View File

@ -0,0 +1,689 @@
---
title: "feat: 私董会讨论模式Board Meeting Mode"
type: feat
status: completed
created: 2026-06-17
origin: docs/brainstorms/2026-06-17-board-meeting-mode-requirements.md
---
# Plan: 私董会讨论模式Board Meeting Mode
**Origin**: `docs/brainstorms/2026-06-17-board-meeting-mode-requirements.md`
**Depth**: Deep
**Created**: 2026-06-17
---
## Summary
为 Fischer AgentKit 新增私董会讨论模式,与现有 hub-and-spoke 任务分解模式并列。用户通过 `@board` 前缀触发,指定预设名人专家(如马斯克、贝佐斯、张小龙),针对决策类问题进行多轮自主循环讨论。每轮全员并行发言 + 主持人小结,达到最大轮次后主持人给出最终决策建议。整个过程以群聊形式呈现,用户可随时干预。
---
## Problem Frame
当前专家团功能(`src/agentkit/experts/`)采用 hub-and-spoke 模式,仅支持任务分解执行,无群聊式讨论能力。且该功能未集成到主聊天流程(`chat.py:584-590` 中 `TEAM_COLLAB` 回退到 REACT。用户希望就决策类问题召集名人专家团进行多视角讨论获得综合建议。
**核心问题**
1. 无预设名人专家库(`configs/experts/` 不存在)
2. 无自主循环讨论机制(现有模式是任务分解,非群聊讨论)
3. 专家团功能未集成到主聊天 WebSocket 流程
---
## Requirements
本计划实现需求文档中的以下功能需求FR-1 到 FR-15
- **FR-1/FR-2**: `@board` 前缀路由,与 `@team` 共存
- **FR-3**: 预设 5-8 位名人专家 YAML
- **FR-4**: `ExpertConfig` 扩展(`speaking_style`、`decision_framework`
- **FR-5**: 默认私董会模板 `private_board`
- **FR-6**: `BoardTeam` 容器(`BoardStatus` 生命周期)
- **FR-7**: `BoardOrchestrator` 讨论引擎(开场→循环讨论→总结)
- **FR-8**: 讨论历史管理(含压缩)
- **FR-9**: 终止条件(最大轮次 + 用户干预 + 异常)
- **FR-10/FR-11**: WebSocket 事件5 个新事件)+ 广播
- **FR-12/FR-13/FR-14**: 前端群聊式 UI + 状态展示 + 干预输入
- **FR-15**: `agentkit.yaml` 配置项
**成功标准**(见需求文档 §7
- `@board:elon_musk,jeff_bezos 讨论主题` 能触发讨论
- 每轮专家并行发言,主持人小结
- 达到最大轮次后主持人给出最终决策建议
- 用户可随时插入消息影响讨论
- 前端以群聊形式展示
- 单元测试覆盖率 ≥ 80%
---
## Key Technical Decisions
### KTD-1: 独立模块而非扩展现有 ExpertTeam
**决策**: 新建 `board.py`、`board_orchestrator.py`、`board_router.py`,不修改现有 `team.py`、`orchestrator.py`、`router.py`。
**理由**: 私董会讨论模式(多轮群聊)与 hub-and-spoke任务分解执行的执行流程完全不同。独立模块职责清晰避免语义混淆符合需求文档"新增并列模式"决策。
**复用**: `ExpertConfig`(扩展)、`Expert`(运行时包装器)、`AgentPool`、`HandoffTransport`、`SharedWorkspace`、`ExpertTemplateRegistry`。
### KTD-2: 讨论历史结构
**决策**: 使用 `list[dict]` 结构存储讨论历史,每条记录包含 `round`、`expert_name`、`content`、`timestamp`、`role`expert/moderator/user
**理由**: 简单的列表结构易于序列化和注入到 LLM prompt。主持人小结也作为历史记录角色为 `moderator`。用户干预消息角色为 `user`
**压缩策略**: 当历史 token 数超过阈值(默认 4000主持人先压缩历史保留每轮关键观点再继续下一轮。压缩 prompt 在实现时细化。
### KTD-3: 并行发言生成
**决策**: 使用 `asyncio.gather` 并行生成所有非主持人专家的发言,然后按专家列表顺序追加到历史和广播事件。
**理由**: 并行生成提高效率5 专家并行 vs 串行)。前端展示顺序按配置顺序,即使并行生成也按顺序追加到 UI。
### KTD-4: 主持人角色
**决策**: 主持人默认为首位专家,通过 `ExpertConfig.is_lead=True` 标识。主持人负责开场介绍、每轮小结、最终总结。
**理由**: 复用现有 `is_lead` 字段,无需引入新角色。主持人也是名人专家之一,其发言风格由其 persona 决定。
### KTD-5: WebSocket 事件复用与扩展
**决策**: 新增 5 个事件类型(`board_started`、`expert_speech`、`round_summary`、`user_intervention`、`board_concluded`),加入现有 `_VALID_TEAM_EVENT_TYPES` 集合。复用 `emit_team_event()` 辅助函数推送。
**理由**: 现有 `emit_team_event()` 已定义但未被调用,本期同时完成其调用集成。事件类型加入现有集合,复用验证逻辑。
### KTD-6: 配置加载
**决策**: 在 `app.py` 启动时,类似 skills 加载,从 `configs/experts/` 目录加载所有专家 YAML 到 `ExpertTemplateRegistry`,挂载到 `app.state.expert_template_registry`
**理由**: 复用 `ExpertTemplateRegistry.load_from_directory()` 方法。与 skills 加载模式一致,保持架构一致性。
---
## High-Level Technical Design
### 组件关系图
```mermaid
graph TB
User[用户输入 @board:experts 主题]
Router[BoardRouter]
Registry[ExpertTemplateRegistry]
Team[BoardTeam]
Orchestrator[BoardOrchestrator]
Expert1[Expert: 马斯克]
Expert2[Expert: 贝佐斯]
Expert3[Expert: 张小龙]
Transport[HandoffTransport]
ChatWS[chat.py WebSocket]
Frontend[前端群聊 UI]
User --> Router
Router --> Registry
Registry --> Team
Team --> Orchestrator
Orchestrator --> Expert1
Orchestrator --> Expert2
Orchestrator --> Expert3
Expert1 --> Transport
Expert2 --> Transport
Expert3 --> Transport
Transport --> ChatWS
ChatWS --> Frontend
```
### 讨论流程状态机
```mermaid
stateDiagram-v2
[*] --> FORMING: @board 触发
FORMING --> DISCUSSING: 专家创建完成
DISCUSSING --> DISCUSSING: 每轮发言+小结
DISCUSSING --> CONCLUDING: 达到最大轮次/用户停止
DISCUSSING --> DISSOLVED: 异常终止
CONCLUDING --> COMPLETED: 主持人最终总结
COMPLETED --> DISSOLVED: 资源回收
DISSOLVED --> [*]
```
### 单轮讨论时序图
```mermaid
sequenceDiagram
participant O as BoardOrchestrator
participant E1 as Expert 1
participant E2 as Expert 2
participant M as Moderator
participant T as Transport
participant WS as WebSocket
O->>WS: board_started 事件
O->>M: 开场介绍请求
M->>T: 发言内容
T->>WS: expert_speech (moderator)
loop 每轮讨论
par 并行生成发言
O->>E1: 发言请求(历史+角色prompt)
E1-->>O: 发言内容
and
O->>E2: 发言请求(历史+角色prompt)
E2-->>O: 发言内容
end
O->>T: 广播 E1 发言
T->>WS: expert_speech (E1)
O->>T: 广播 E2 发言
T->>WS: expert_speech (E2)
O->>M: 小结请求(本轮发言)
M-->>O: 小结内容
O->>T: 广播小结
T->>WS: round_summary
end
O->>M: 最终总结请求
M-->>O: 决策建议
O->>WS: board_concluded 事件
```
---
## Scope Boundaries
### In Scope
- `BoardTeam`、`BoardOrchestrator`、`BoardRouter` 新模块
- `configs/experts/` 预设名人 YAML8 位)
- `ExpertConfig` 扩展(`speaking_style`、`decision_framework`
- WebSocket 事件5 个新事件)+ `emit_team_event()` 调用集成
- 前端群聊式展示增强 + 事件处理
- 主聊天流程集成(`chat.py` 接入 `BoardRouter`
- `app.py` 启动加载专家配置
- `agentkit.yaml` 配置项
- 单元测试覆盖
### Deferred to Follow-Up Work
- 集成外部蒸馏工具colleague.skill/nuwa-skill
- LLM 自动蒸馏生成名人 SOUL
- 共识检测自动终止(置信度/投票)
- 技术评审/创意脑暴场景
- Expert Team Canvas 式可视化
- 专家间直接通信(辩论模式)
- 语音/视频输出
- 历史讨论回顾与检索
- 修复 `@team``chat.py` 的集成缺口(独立任务)
### Outside This Product's Identity
- 实时名人数据更新
- 名人本人授权或验证
- 娱乐向角色扮演
---
## Implementation Units
### U1. 扩展 ExpertConfig 新增讨论模式字段
**Goal**: 为 `ExpertConfig` 新增 `speaking_style``decision_framework` 字段,支持名人专家的个性化表达和决策框架。
**Requirements**: FR-4
**Dependencies**: 无
**Files**:
- `src/agentkit/experts/config.py` — 修改 `ExpertConfig.__init__`、`from_dict`、`to_dict`
- `tests/unit/experts/test_config.py` — 新增字段测试
**Approach**:
- 在 `ExpertConfig.__init__` 新增 `speaking_style: str = ""``decision_framework: str = ""` 参数
- 在 `from_dict` 中读取 `data.get("speaking_style", "")``data.get("decision_framework", "")`
- 在 `to_dict` 中序列化这两个字段
- 字段有默认值,向后兼容现有 `@team` 模式和动态生成的 ExpertConfig
**Patterns to follow**: 现有 `persona`、`thinking_style` 字段的实现模式(`src/agentkit/experts/config.py:35-65`
**Test scenarios**:
- **Happy path**: 创建 ExpertConfig 时传入 speaking_style 和 decision_framework验证字段值正确
- **Happy path**: 从 dict 创建 ExpertConfig包含 speaking_style 和 decision_framework验证字段值正确
- **Edge case**: 不传 speaking_style 和 decision_framework验证默认值为空字符串
- **Edge case**: to_dict 输出包含 speaking_style 和 decision_framework 字段
- **Integration**: 现有 ExpertConfig 用法(不传新字段)仍正常工作
**Verification**: `pytest tests/unit/experts/test_config.py` 通过,现有测试不受影响
---
### U2. 创建预设名人专家 YAML 库
**Goal**: 在 `configs/experts/` 目录下创建 8 位名人专家 YAML 文件,每位名人包含 persona、thinking_style、speaking_style、decision_framework 等字段。
**Requirements**: FR-3, FR-5
**Dependencies**: U1
**Files**:
- `configs/experts/elon_musk.yaml`
- `configs/experts/jeff_bezos.yaml`
- `configs/experts/allenzhang.yaml`
- `configs/experts/charlie_munger.yaml`
- `configs/experts/paul_graham.yaml`
- `configs/experts/steve_jobs.yaml`
- `configs/experts/warren_buffett.yaml`
- `configs/experts/ray_dalio.yaml`
- `configs/experts/private_board.yaml` — 默认私董会模板(引用上述专家)
**Approach**:
- 每个 YAML 遵循 `ExpertTemplateRegistry.load_from_yaml` 的格式(见 `src/agentkit/experts/registry.py:71-87`
- 字段:`name`、`description`、`is_builtin: true`、`config`(含 `name`、`agent_type: expert`、`persona`、`thinking_style`、`speaking_style`、`decision_framework`、`avatar`、`color`、`is_lead: false`、`task_mode: llm_generate`、`prompt.identity`
- `private_board.yaml` 是一个特殊的"团队模板"定义默认专家组合3-5 位),格式为 `name: private_board`、`members: [elon_musk, jeff_bezos, allenzhang, charlie_munger, paul_graham]`
- 名人 persona 内容需体现其标志性思维模式:
- 马斯克:第一性原理、物理思维、激进创新
- 贝佐斯Day 1 思维、客户至上、长期主义
- 张小龙:用户体验、极简主义、社交产品直觉
- 芒格:心智模型、跨学科思维、逆向思考
- Paul Graham创业、做用户想要的东西、反从众
- 乔布斯:产品设计、现实扭曲力场、专注
- 巴菲特:价值投资、能力圈、复利思维
- Ray Dalio原则驱动决策、极度透明
**Patterns to follow**: `src/agentkit/experts/registry.py:71-87` 的 YAML 格式示例
**Test scenarios**:
- **Test expectation**: none — YAML 配置文件,由 U9 的集成测试覆盖加载逻辑
**Verification**: `ExpertTemplateRegistry.load_from_directory("configs/experts/")` 能成功加载所有 8 位专家 + 1 个团队模板
---
### U3. 实现 BoardRouter @board 前缀路由
**Goal**: 实现 `BoardRouter` 类,解析 `@board` 前缀,支持指定专家或使用默认私董会模板。
**Requirements**: FR-1, FR-2
**Dependencies**: U1, U2
**Files**:
- `src/agentkit/experts/board_router.py` — 新建
- `tests/unit/experts/test_board_router.py` — 新建
**Approach**:
- 参考 `ExpertTeamRouter``src/agentkit/experts/router.py`)的实现模式
- 正则匹配 `@board` 前缀:`^@board(?::(\S+))?\s*(.*)`
- 支持两种格式:
- `@board:elon_musk,jeff_bezos 讨论主题` — 指定专家
- `@board 讨论主题` — 使用默认 `private_board` 模板
- 专家名验证:复用 `_EXPERT_NAME_RE = re.compile(r"^[a-zA-Z0-9_-]{1,64}$")`
- 最多 10 位专家(`MAX_EXPERTS = 10`
- 返回 `BoardRoutingResult` dataclass`matched`、`board_mode`、`specified_experts`、`topic`、`use_default_template`
- `resolve_expert_configs()` 方法:从 `ExpertTemplateRegistry` 解析专家名到 `ExpertConfig` 列表,首位设为 `is_lead=True`(主持人)
- 如果指定 `private_board`,从 `private_board.yaml` 加载成员列表
- 未识别的专家名:记录警告,动态创建基本 ExpertConfig
**Patterns to follow**: `src/agentkit/experts/router.py``ExpertTeamRouter` 实现
**Test scenarios**:
- **Happy path**: `@board:elon_musk,jeff_bezos 讨论主题` → matched=True, specified_experts=["elon_musk", "jeff_bezos"], topic="讨论主题"
- **Happy path**: `@board 讨论主题` → matched=True, use_default_template=True, topic="讨论主题"
- **Happy path**: `@board:private_board 讨论主题` → matched=True, 加载 private_board 成员列表
- **Edge case**: `@board` 无主题 → matched=True, topic 为空
- **Edge case**: 专家名超过 10 个 → 截断到 10 个
- **Error path**: 无效专家名(含特殊字符)→ 过滤掉无效名,记录警告
- **Error path**: 指定不存在的专家 → 动态创建基本 ExpertConfig
- **Integration**: `resolve_expert_configs()` 返回的列表首位 is_lead=True
**Verification**: `pytest tests/unit/experts/test_board_router.py` 通过
---
### U4. 实现 BoardTeam 容器
**Goal**: 实现 `BoardTeam` 容器,管理私董会的专家生命周期、讨论状态和事件广播。
**Requirements**: FR-6, FR-8
**Dependencies**: U1
**Files**:
- `src/agentkit/experts/board.py` — 新建(`BoardTeam`、`BoardStatus`、`DiscussionHistory`
- `tests/unit/experts/test_board.py` — 新建
**Approach**:
- 参考 `ExpertTeam``src/agentkit/experts/team.py`)的容器模式
- `BoardStatus` 枚举:`FORMING` → `DISCUSSING``CONCLUDING``COMPLETED``DISSOLVED`
- `BoardTeam` 持有:
- `team_id`、`topic`、`experts: dict[str, Expert]`、`moderator_name: str`
- `history: list[dict]` — 讨论历史(每条含 round、expert_name、content、timestamp、role
- `current_round: int`、`max_rounds: int`
- `_handoff_transport`、`_workspace`、`_pool`、`_team_channel`
- `create_board()` 方法:创建主持人和成员专家,注入 board_context 到 system prompt
- `_build_board_context()` 方法:构建私董会上下文(强调群聊讨论模式、角色差异、讨论规则)
- `add_to_history()` 方法:追加发言到历史
- `get_history_text()` 方法:返回格式化的历史文本用于 LLM prompt
- `compress_history()` 方法:主持人压缩历史(超过阈值时)
- `broadcast_user_message()` 方法:广播用户干预消息
- `dissolve()` 方法:解散团队,回收资源
- 复用 `Expert.create()`、`HandoffTransport`、`SharedWorkspace`
**Patterns to follow**: `src/agentkit/experts/team.py``ExpertTeam` 容器模式
**Technical design** (directional):
```python
class BoardStatus(str, enum.Enum):
FORMING = "forming"
DISCUSSING = "discussing"
CONCLUDING = "concluding"
COMPLETED = "completed"
DISSOLVED = "dissolved"
class BoardTeam:
async def create_board(self, topic: str, expert_configs: list[ExpertConfig], max_rounds: int) -> None
async def add_to_history(self, round: int, expert_name: str, content: str, role: str) -> None
def get_history_text(self, up_to_round: int | None = None) -> str
async def compress_history(self, moderator: Expert) -> None
async def broadcast_user_message(self, content: str) -> None
async def dissolve(self) -> None
```
**Test scenarios**:
- **Happy path**: `create_board()` 创建主持人和成员,状态变为 DISCUSSING
- **Happy path**: `add_to_history()` 追加发言,`get_history_text()` 返回格式化文本
- **Edge case**: 空历史时 `get_history_text()` 返回空字符串
- **Edge case**: `compress_history()` 超过阈值时压缩,未超过时不操作
- **Integration**: `broadcast_user_message()` 通过 handoff_transport 发送事件
- **Integration**: `dissolve()` 清理所有专家,状态变为 DISSOLVED
- **Error path**: 未配置 AgentPool 时 `create_board()` 抛出 RuntimeError
**Verification**: `pytest tests/unit/experts/test_board.py` 通过
---
### U5. 实现 BoardOrchestrator 讨论引擎
**Goal**: 实现 `BoardOrchestrator`,驱动私董会讨论流程:开场 → 多轮并行发言 + 主持人小结 → 最终决策建议。
**Requirements**: FR-7, FR-9
**Dependencies**: U4
**Files**:
- `src/agentkit/experts/board_orchestrator.py` — 新建
- `tests/unit/experts/test_board_orchestrator.py` — 新建
**Approach**:
- 参考 `TeamOrchestrator``src/agentkit/experts/orchestrator.py`)的执行引擎模式
- `execute(topic)` 主入口流程:
1. 广播 `board_started` 事件
2. 主持人开场介绍(介绍议题、讨论规则)
3. 循环 `max_rounds` 轮:
- 并行生成所有非主持人专家发言(`asyncio.gather`
- 每个专家发言基于:角色 persona + thinking_style + speaking_style + decision_framework + 完整讨论历史 + 当前轮次/最大轮次
- 按专家列表顺序广播 `expert_speech` 事件
- 主持人小结本轮要点,广播 `round_summary` 事件
- 检查用户干预消息(通过 handoff_transport 或共享状态)
- 检查历史 token 长度,超过阈值时压缩
4. 主持人最终总结(决策建议、共识点、分歧点),广播 `board_concluded` 事件
- `_generate_expert_speech()` 方法:构建专家发言 prompt调用 LLM
- `_generate_moderator_summary()` 方法:构建主持人小结 prompt调用 LLM
- `_generate_final_conclusion()` 方法:构建最终总结 prompt调用 LLM
- `_check_user_intervention()` 方法:检查是否有用户干预消息
- `_handle_stop_command()` 方法:处理用户 `/stop` 命令
- 复用 `_get_llm_gateway()``_broadcast_event()` 模式(来自 TeamOrchestrator
- 异常处理LLM 不可用时,主持人用已有历史总结;所有专家发言失败时,提前进入总结阶段
**Patterns to follow**: `src/agentkit/experts/orchestrator.py``TeamOrchestrator` 执行模式
**Technical design** (directional):
```python
class BoardOrchestrator:
def __init__(self, team: BoardTeam) -> None
async def execute(self, topic: str) -> dict[str, Any]
# Returns: {status, summary, decision_advice, total_rounds, consensus_points, dissent_points}
async def _generate_expert_speech(self, expert: Expert, round: int) -> str
async def _generate_moderator_summary(self, round: int) -> str
async def _generate_final_conclusion(self) -> dict[str, Any]
async def _check_user_intervention(self) -> str | None
```
**Test scenarios**:
- **Happy path**: `execute()` 完成完整讨论流程,返回 status="completed"
- **Happy path**: 每轮生成 N-1 个专家发言 + 1 个主持人小结
- **Happy path**: 最终总结包含 decision_advice、consensus_points、dissent_points
- **Edge case**: max_rounds=1 时,只进行一轮讨论后直接总结
- **Edge case**: 用户发送 `/stop` → 提前终止,用已有历史总结
- **Error path**: LLM 不可用 → 主持人用已有历史拼接总结
- **Error path**: 某专家发言失败 → 跳过该专家,其他专家继续
- **Error path**: 所有专家发言失败 → 提前进入总结阶段
- **Integration**: `board_started`、`expert_speech`、`round_summary`、`board_concluded` 事件正确广播
**Verification**: `pytest tests/unit/experts/test_board_orchestrator.py` 通过
---
### U6. 后端集成 BoardRouter 到主聊天流程
**Goal**: 在 `chat.py` 的 WebSocket 处理中接入 `BoardRouter`,实现 `@board` 前缀触发私董会讨论,并通过 `emit_team_event()` 推送事件到前端。
**Requirements**: FR-1, FR-10, FR-11, FR-15
**Dependencies**: U3, U5
**Files**:
- `src/agentkit/server/routes/chat.py` — 修改 `_handle_chat_message`,接入 `BoardRouter`
- `src/agentkit/server/app.py` — 启动时加载 `configs/experts/`,挂载 `expert_template_registry``board_config``app.state`
- `src/agentkit/server/config.py` — 新增 `board` 配置项max_rounds、default_template、parallel_speech、history_compression_threshold
- `tests/unit/server/test_chat_board_integration.py` — 新建
**Approach**:
- 在 `app.py` 启动时(参考 skills 加载逻辑 `app.py:261-282`
- 创建 `ExpertTemplateRegistry` 实例
- 调用 `load_from_directory("configs/experts/")` 加载所有专家 YAML
- 挂载到 `app.state.expert_template_registry`
- 从 `agentkit.yaml` 读取 `board` 配置,挂载到 `app.state.board_config`
- 在 `chat.py``_handle_chat_message` 中:
- 在 `RequestPreprocessor` 之前,检查 `@board` 前缀
- 如果匹配 `@board`,创建 `BoardRouter`,解析路由
- 创建 `BoardTeam``BoardOrchestrator`
- 注册 `handoff_transport` 的 handler将事件转发到 WebSocket调用 `emit_team_event()`
- 调用 `orchestrator.execute(topic)`
- 将最终结果作为 `final_answer` 发送
- 扩展 `_VALID_TEAM_EVENT_TYPES`:新增 `board_started`、`expert_speech`、`round_summary`、`user_intervention`、`board_concluded`
- 用户干预处理:讨论进行中,如果用户发送新消息(非 `/stop`),通过 `board_team.broadcast_user_message()` 广播
**Patterns to follow**:
- skills 加载模式:`src/agentkit/server/app.py:261-282`
- `emit_team_event()` 辅助函数:`src/agentkit/server/routes/chat.py:117-142`
**Test scenarios**:
- **Happy path**: `@board:elon_musk,jeff_bezos 讨论主题` → 触发 BoardRouter创建 BoardTeam执行讨论
- **Happy path**: 讨论事件通过 `emit_team_event()` 推送到 WebSocket
- **Happy path**: `@board 讨论主题` → 使用默认 private_board 模板
- **Edge case**: 讨论中用户发送消息 → 广播为 user_intervention
- **Edge case**: 讨论中用户发送 `/stop` → 终止讨论
- **Integration**: `app.state.expert_template_registry` 在启动时正确加载
- **Integration**: `@board``@team` 和普通聊天互不干扰
**Verification**: `pytest tests/unit/server/test_chat_board_integration.py` 通过;手动测试 `@board` 触发讨论
---
### U7. 前端事件类型和处理扩展
**Goal**: 在前端 TypeScript 类型和 Pinia store 中新增私董会事件类型和处理逻辑。
**Requirements**: FR-10, FR-12, FR-13
**Dependencies**: U6
**Files**:
- `src/agentkit/server/frontend/src/api/types.ts` — 新增 board 事件类型和接口
- `src/agentkit/server/frontend/src/stores/chat.ts` — 新增 board 事件处理
- `src/agentkit/server/frontend/src/stores/team.ts` — 新增 board 状态管理(可选,或新建 `stores/board.ts`
**Approach**:
- 在 `types.ts` 中:
- 新增 `WsServerMessage` 联合类型成员:`board_started`、`expert_speech`、`round_summary`、`user_intervention`、`board_concluded`
- 新增 `IBoardState` 接口:`team_id`、`topic`、`experts`、`moderator_name`、`current_round`、`max_rounds`、`status`
- 新增 `IBoardSpeech` 接口:`expert_name`、`expert_avatar`、`expert_color`、`content`、`round`、`role`
- 在 `chat.ts``handleWsMessage` 中新增 case
- `board_started`: 创建 board 状态,推送步骤提示
- `expert_speech`: 创建专家消息(带头像、颜色、轮次标识),追加到消息列表
- `round_summary`: 创建主持人小结消息特殊样式message_type='milestone'
- `user_intervention`: 标记用户消息已广播
- `board_concluded`: 创建总结消息(含决策建议、共识点、分歧点)
- 参考 `chat.ts:554-646` 的现有 team 事件处理模式
**Patterns to follow**: `src/agentkit/server/frontend/src/stores/chat.ts:554-646` 的 team 事件处理
**Test scenarios**:
- **Test expectation**: none — 前端 TypeScript 类型,由 U8 的组件测试和手动测试覆盖
**Verification**: `npm run typecheck` 通过
---
### U8. 前端群聊式 UI 增强
**Goal**: 增强 `ExpertMessage.vue` 组件,新增讨论状态展示,实现群聊式体验。
**Requirements**: FR-12, FR-13, FR-14
**Dependencies**: U7
**Files**:
- `src/agentkit/server/frontend/src/components/chat/ExpertMessage.vue` — 增强(新增轮次标识、角色标签)
- `src/agentkit/server/frontend/src/components/chat/BoardStatusView.vue` — 新建(讨论状态展示)
- `src/agentkit/server/frontend/src/views/ChatView.vue` — 集成 BoardStatusView
**Approach**:
- 增强 `ExpertMessage.vue`
- 新增 props`round?: number`、`role?: 'expert' | 'moderator' | 'user'`
- 显示轮次标识(如"第 2 轮"
- 主持人消息特殊样式(背景色、边框)
- 用户干预消息右侧气泡展示
- 新建 `BoardStatusView.vue`
- 顶部显示:讨论主题、当前轮次/最大轮次、参与专家列表(头像、名称、角色标签)
- 专家列表可点击查看其所有发言
- 讨论结束后展示总结卡片(决策建议、共识点、分歧点)
- 在 `ChatView.vue` 中:
- 当 board 状态激活时,渲染 `<BoardStatusView />`(类似现有 `<ExpertTeamView />`
**Patterns to follow**:
- `src/agentkit/server/frontend/src/components/chat/ExpertMessage.vue` 现有组件
- `src/agentkit/server/frontend/src/components/chat/ExpertTeamView.vue` 团队视图
- `src/agentkit/server/frontend/src/views/ChatView.vue:19` 的 ExpertTeamView 集成
**Test scenarios**:
- **Test expectation**: none — Vue 组件,由手动测试覆盖
**Verification**: `npm run typecheck` 通过;手动测试 `@board` 触发讨论,前端正确展示群聊
---
### U9. 单元测试覆盖
**Goal**: 为所有新增模块编写单元测试,确保覆盖率 ≥ 80%。
**Requirements**: 成功标准 §7.1
**Dependencies**: U1-U8
**Files**:
- `tests/unit/experts/test_config.py` — 扩展U1 新字段测试)
- `tests/unit/experts/test_board_router.py` — 新建U3
- `tests/unit/experts/test_board.py` — 新建U4
- `tests/unit/experts/test_board_orchestrator.py` — 新建U5
- `tests/unit/server/test_chat_board_integration.py` — 新建U6
**Approach**:
- 使用 `pytest` + `pytest-asyncio`asyncio_mode=auto
- Mock `LLMGateway`、`AgentPool`、`HandoffTransport` 进行隔离测试
- 测试覆盖:
- 配置字段序列化/反序列化
- 路由解析各种格式
- BoardTeam 生命周期和状态转换
- BoardOrchestrator 讨论流程(正常、异常、用户干预)
- chat.py 集成(@board 触发、事件广播)
- 测试标记:单元测试无特殊标记,集成测试标记 `@pytest.mark.integration`
**Patterns to follow**:
- `tests/unit/experts/test_team_orchestrator.py` 的测试模式
- `tests/unit/experts/test_router.py` 的路由测试模式
**Test scenarios**:
- 见各实现单元的测试场景
**Verification**: `pytest tests/unit/experts/ tests/unit/server/test_chat_board_integration.py -v` 全部通过;`pytest --cov=src/agentkit/experts --cov-report=term-missing` 覆盖率 ≥ 80%
---
## Risks & Dependencies
### Risks
| 风险 | 影响 | 缓解 |
|------|------|------|
| 专家发言同质化 | 讨论质量低 | 强化角色 prompt 差异化,`decision_framework` 字段强制不同视角 |
| token 消耗过高5轮×5专家=25次调用 | 成本问题 | 提供轮次配置,历史压缩机制 |
| 讨论发散无结论 | 用户体验差 | 主持人每轮小结,最终强制总结 |
| 名人 persona 不准确 | 发言不像本人 | YAML 可迭代优化,未来支持蒸馏 |
| `chat.py` 集成复杂度高 | 可能破坏现有聊天 | `@board` 路由在 `RequestPreprocessor` 之前检查,不影响普通聊天 |
| 前端事件处理与现有 team 事件冲突 | UI 错乱 | 事件类型独立board_* vs team_*store 独立 |
### Dependencies
- **现有基础设施**: `Expert`、`AgentPool`、`HandoffTransport`、`SharedWorkspace`、`ExpertTemplateRegistry`
- **LLM Gateway**: `src/agentkit/llm/gateway.py` 提供多 provider 支持
- **WebSocket**: `src/agentkit/server/routes/chat.py` 现有 WebSocket 通道
- **前端组件**: `ExpertMessage.vue`、`ChatView.vue`、`stores/chat.ts`
---
## System-Wide Impact
### 影响方
- **终端用户**: 获得私董会讨论能力,可就决策类问题获得多视角建议
- **开发者**: 新增 `@board` 路由模式,需了解与 `@team` 的区别
- **配置管理**: 新增 `configs/experts/` 目录和 `agentkit.yaml``board` 配置项
- **前端**: 新增 board 事件处理和 UI 组件
### 兼容性
- `ExpertConfig` 新增字段有默认值,向后兼容
- `@board` 路由独立,不影响 `@team` 和普通聊天
- `_VALID_TEAM_EVENT_TYPES` 扩展是增量式,不影响现有事件
- 前端事件处理新增 case不影响现有 case
---
## Open Questions
1. **历史压缩 prompt 具体设计** — 超过 token 阈值时,主持人如何压缩历史?
- **决策**: 实现时细化,初步思路是让主持人总结每轮关键观点,替换原始发言。
2. **用户干预消息的实时性** — 用户发送干预后,当前轮次是否立即中断?
- **决策**: 不中断当前轮次,干预消息在下一轮生效。避免复杂的中断逻辑。
3. **private_board 模板格式** — 如何在 YAML 中定义"团队模板"(引用多个专家)?
- **决策**: `private_board.yaml` 使用 `members: [expert1, expert2, ...]` 字段,`BoardRouter` 解析时加载成员配置。
4. **讨论历史持久化** — 讨论结束后是否保存到数据库?
- **决策**: 本期不持久化,仅保存到 SharedWorkspace。未来可扩展。
---
## Sources & Research
- **需求文档**: `docs/brainstorms/2026-06-17-board-meeting-mode-requirements.md`
- **现有实现**: `src/agentkit/experts/` 目录所有文件
- **对标产品**: Qoder Experts Mode、WorkBuddy、Legends MCP、colleague.skill详见需求文档 §1.2
- **架构参考**: AgentVerse 通信框架、ARMOR-MAD 协议阈值终止
---
**Next Step**: 交由 `/ce-work` 执行实现,或由开发者按 U1-U9 顺序逐步实现。

View File

@ -0,0 +1,433 @@
---
title: "fix: WebSocket 断开后任务结果丢失与断线恢复"
status: completed
created: 2026-06-17
type: fix
origin: in-session investigation
---
# fix: WebSocket 断开后任务结果丢失与断线恢复
## Summary
当用户在复杂任务执行过程中刷新页面时WebSocket 断开导致 ReAct 任务中断、已收集的输出丢失、且无法恢复。本计划通过三层防御彻底解决Layer 1 确保部分结果持久化Layer 2 将任务后台化解耦 WebSocket 生命周期Layer 3 前端断线后恢复进行中任务。
## Problem Frame
**症状**:用户布置复杂任务后刷新界面,操作停止且返回为空。
**根因**(经代码调查确认):
1. **结果丢失**portal.py 的 ReAct 流式路径中assistant 回复保存在 `async for` 循环之后portal.py:1064WebSocket 断开时该行永远不执行,`collected_output` 全部丢失。
2. **任务中断**ReAct 任务通过 `async for event in react_engine.execute_stream(...)` 直接在 WebSocket 协程中执行,未使用 BackgroundRunner 后台化WebSocket 断开 = 任务取消。
3. **无状态追踪**portal WebSocket 路径不使用 TaskStore`task_id` 仅用于 EventQueue 事件发射,任务状态无法查询。
4. **无恢复机制**:前端 WebSocket 重连后3 秒自动重连),不检查是否有未完成任务,不恢复进行中的任务状态。
## Requirements
- **R1**: WebSocket 断开时,已收集的 ReAct 输出必须持久化到 conversation store
- **R2**: ReAct 任务必须后台执行,与 WebSocket 连接生命周期解耦
- **R3**: 每个任务必须在 TaskStore 中注册,状态可查询
- **R4**: 后台任务的事件必须通过 EventQueue 分发WebSocket 可订阅
- **R5**: 前端 WebSocket 重连后,必须检查当前对话是否有未完成任务并恢复
- **R6**: 已完成的任务结果必须能从 conversation store 恢复显示
- **R7**: 不破坏现有 DIRECT_CHAT 路径和 REST API 路径
## Key Technical Decisions
### KTD1: 复用 EventQueue 作为任务事件总线
**决策**:使用现有的 `EventQueue``core/event_queue.py`)分发后台任务事件,而非新建 EventBus 模块。
**理由**EventQueue 已具备所需能力——多订阅者广播(行 161-175、缓冲回放deque 100行 153、原子订阅行 193-202、哨兵关闭模式行 230-243。它已被 portal.py 的 `_emit_event_safe` 使用,且文件头注释明确说明设计目标包括 Portal WebSocket 订阅。
### KTD2: 任务后台化使用 asyncio.create_task + EventQueue 订阅
**决策**:在 portal.py 中,将 ReAct 执行包装为 `asyncio.create_task`WebSocket 协程通过 `event_queue.subscribe()` 订阅事件流。任务在后台独立运行WebSocket 仅转发事件给前端。
**理由**BackgroundRunner`server/runner.py`)虽然存在,但它调用 `agent.execute()`(非流式),不支持事件流。改造 BackgroundRunner 支持流式会侵入核心执行路径。直接在 portal.py 中用 `asyncio.create_task` 更聚焦、风险更低。
### KTD3: 任务状态通过 TaskStore 追踪
**决策**:在 portal.py WebSocket 路径中,为每个用户消息创建 TaskStore 记录状态随任务进展更新PENDING → RUNNING → COMPLETED/FAILED
**理由**TaskStore 已在 `app.state.task_store` 可用,`/api/v1/tasks/{task_id}` 端点已存在。前端可通过现有 REST API 查询任务状态,无需新建端点。
### KTD4: 前端通过 conversation_id 关联任务
**决策**:在 TaskStore 记录的 `metadata` 中存储 `conversation_id`,前端重连后通过 `GET /api/v1/tasks?status=running` 查找当前对话的未完成任务。
**理由**:避免新建专门的"按 conversation 查任务"端点。前端遍历 running 任务,匹配 metadata 中的 conversation_id 即可。
## High-Level Technical Design
```
┌──────────────────────────────────────────────────────────────────┐
│ WebSocket 连接存活 │
│ ┌────────────┐ subscribe(task_id) ┌──────────────────┐ │
│ │ WebSocket │ ◄────────────────────── │ EventQueue │ │
│ │ 协程 │ 转发事件给前端 │ (缓冲回放+广播) │ │
│ └────────────┘ └────────┬─────────┘ │
│ │ emit │
├──────────────────────────────────────────────────┼─────────────┤
│ WebSocket 断开 │ │
│ ┌────────────┐ │ │
│ │ WebSocket │ ✗ 断开 │ │
│ │ 协程 │ │ │
│ └────────────┘ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ 后台任务 (asyncio.create_task) │ │
│ │ ┌─────────────┐ ┌──────────────┐ ┌────────────────┐ │ │
│ │ │ ReAct 执行 │─►│ EventQueue │ │ TaskStore │ │ │
│ │ │ execute_ │ │ .emit(event) │ │ .update_status │ │ │
│ │ │ stream() │ │ │ │ │ │ │
│ │ └─────────────┘ └──────────────┘ └────────────────┘ │ │
│ │ │ │ │
│ │ ▼ │ │
│ │ ┌────────────────────────────────────────────────┐ │ │
│ │ │ conversation_store.add_message(assistant, ...) │ │ │
│ │ │ (无论 WebSocket 是否存活,结果都持久化) │ │ │
│ │ └────────────────────────────────────────────────┘ │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ 前端重连后: │
│ 1. GET /api/v1/tasks?status=running → 查找未完成任务 │
│ 2. 匹配 metadata.conversation_id → 当前对话的任务 │
│ 3. event_queue.subscribe(task_id) → 恢复事件流 │
│ 4. 或 GET /conversations/{id} → 拉取已完成的结果 │
└──────────────────────────────────────────────────────────────────┘
```
## Scope Boundaries
### In Scope
- portal.py WebSocket 路径的 ReAct 任务后台化
- WebSocket 断开时的部分结果持久化
- TaskStore 注册和状态更新
- EventQueue 事件分发集成
- 前端 WebSocket 重连后的任务恢复
- 前端任务状态查询 API 客户端
### Out of Scope
- BackgroundRunner 改造支持流式(风险过高,单独处理)
- REST API 路径(`/portal/chat`的改造REST 已是同步调用,无此问题)
- SSE 路径(`/portal/chat/stream`)的改造
- Expert Team 协作模式的后台化(单独处理)
- WebSocket 重连退避策略优化(独立改进)
### Deferred to Follow-Up Work
- SubmissionQueue 接入(目前完全闲置,可后续用于任务队列化)
- Redis 分布式任务恢复(当前 InMemoryTaskStore 足够,分布式场景后续处理)
- 任务进度百分比反馈(当前 ReAct 无细粒度进度概念)
---
## Implementation Units
### U1. Layer 1: WebSocket 断开时持久化已收集的输出
**Goal**: 确保 WebSocket 在 ReAct 流式过程中断开时,已收集的 `collected_output` 保存到 conversation store。
**Requirements**: R1
**Dependencies**: 无
**Files**:
- `src/agentkit/server/routes/portal.py` — 修改 ReAct 流式路径的异常处理
- `tests/unit/test_portal_ws_persistence.py` — 新建测试
**Approach**:
在 portal.py 的 `portal_websocket` 函数中ReAct 流式路径(约 1014-1064 行)的异常处理需要增强:
1. 在 `except Exception as e:` 块(约 1049 行)中,在发送 error 之前,将 `collected_output` 保存到 `_conversation_store`。使用 `_ensure_non_empty` 处理空输出。
2. 在外层 `except WebSocketDisconnect:` 块(约 1102 行)中,检查是否有未保存的 `collected_output`,如果有则保存。
3. 在外层 `except Exception as e:` 块(约 1104 行)中,同样处理。
4. 需要将 `collected_output`、`conv`、`task_id` 声明提升到外层 try 作用域,使 except 块可以访问。
**Patterns to follow**:
- `_ensure_non_empty()` 函数portal.py:58-62处理空输出
- `_conversation_store.add_message()` 已有调用模式portal.py:871, 952, 1064
- `_emit_event_safe()` 的异常吞咽模式portal.py:65-95——保存操作失败不应阻断后续清理
**Test scenarios**:
- **Happy path**: ReAct 正常完成,`collected_output` 有内容WebSocket 未断开 → 结果保存到 conversation store验证现有行为不被破坏
- **WebSocket 断开final_answer 前)**: ReAct 流式中 WebSocket 断开,`collected_output` 为空 → conversation store 不写入空消息(或写入 EMPTY_LLM_RESPONSE
- **WebSocket 断开final_answer 后)**: ReAct 已产出 final_answerWebSocket 在后续步骤断开,`collected_output` 有部分内容 → 部分内容保存到 conversation store
- **异常路径**: ReAct 执行抛出异常,`collected_output` 有部分内容 → 部分内容保存error 事件正常发射
- **Integration**: 保存操作自身失败conversation_store 异常)→ 不阻断 error 事件发射和后续清理
**Verification**: 模拟 WebSocket 断开场景,检查 SQLite conversation store 中是否有 assistant 消息记录。
---
### U2. Layer 2: ReAct 任务后台化与 EventQueue 事件分发
**Goal**: 将 ReAct 执行从 WebSocket 协程中解耦,后台执行并通过 EventQueue 分发事件。WebSocket 仅订阅事件流并转发给前端。
**Requirements**: R2, R4
**Dependencies**: U1
**Files**:
- `src/agentkit/server/routes/portal.py` — 重构 ReAct 流式路径
- `src/agentkit/core/event_queue.py` — 可能需要扩展(添加 task_id 过滤订阅)
- `tests/unit/test_portal_ws_background_task.py` — 新建测试
**Approach**:
1. **提取后台执行函数**:创建 `_execute_react_background()` 协程接收所有必要参数messages, tools, model, agent_name, system_prompt, timeout_seconds, conv_id, task_id, event_queue, conversation_store。该函数
- 执行 `react_engine.execute_stream()`
- 每个事件通过 `event_queue.emit()` 分发
- `final_answer` 事件时累积输出
- 正常结束后保存到 `conversation_store`
- 异常时保存部分输出并发射 error 事件
- 无论成功失败,都发射 `task.completed``task.failed` 事件
2. **WebSocket 协程改为订阅者**:在 portal.py 的 ReAct 路径中:
- 创建 task_id 并注册到 TaskStorePENDING → RUNNING
- `asyncio.create_task(_execute_react_background(...))` 启动后台任务
- `async for event in event_queue.subscribe(task_id)` 订阅事件
- 将事件通过 `websocket.send_json()` 转发给前端
- WebSocket 断开时,`async for` 循环退出,后台任务继续运行
3. **EventQueue 扩展**:当前 `subscribe()` 返回所有事件。需要添加按 `task_id` 过滤的订阅能力,或在前端转发时过滤。优先选择在 EventQueue 中添加 `subscribe(task_id=None)` 参数过滤。
4. **DIRECT_CHAT 路径**DIRECT_CHAT 是同步 LLM 调用(非流式),保持现有逻辑不变,但同样在 TaskStore 注册。
**Technical design**directional guidance:
```python
# 后台执行函数(伪代码)
async def _execute_react_background(
react_engine, messages, tools, model, agent_name,
system_prompt, timeout_seconds,
conv_id, task_id, event_queue, conversation_store
):
collected_output = []
try:
async for event in react_engine.execute_stream(...):
if event.event_type == "final_answer":
collected_output.append(event.data.get("output", ""))
await event_queue.emit(Event.create(
event_type=event.event_type,
task_id=task_id,
session_id=conv_id,
data={"step": event.step, "data": event.data, ...}
))
# 正常完成
response_text = _ensure_non_empty("".join(collected_output))
await conversation_store.add_message(conv_id, "assistant", response_text)
await event_queue.emit(Event.create(
event_type="task.completed", task_id=task_id, ...
))
except Exception as e:
# 保存部分输出
if collected_output:
partial = _ensure_non_empty("".join(collected_output))
await conversation_store.add_message(conv_id, "assistant", partial)
await event_queue.emit(Event.create(
event_type="task.failed", task_id=task_id,
data={"error": str(e)}
))
# WebSocket 协程(伪代码)
task_id = str(uuid.uuid4())
task_store.create(task_id, agent_name, {"conversation_id": conv.id})
asyncio.create_task(_execute_react_background(...))
# WebSocket 订阅事件并转发
async for event in event_queue.subscribe(task_id=task_id):
if event.event_type in ("task.completed", "task.failed"):
await websocket.send_json({"type": "result", ...})
break
await websocket.send_json({"type": "step", "data": ...})
# WebSocket 断开时 async for 退出,后台任务继续
```
**Patterns to follow**:
- EventQueue 的 `subscribe()` + 哨兵关闭模式event_queue.py:193-243
- `_emit_event_safe()` 的异常吞咽模式portal.py:65-95
- BackgroundRunner 的 `asyncio.create_task` + `_on_done` 回调模式runner.py:55-73
**Test scenarios**:
- **Happy path**: 后台任务正常完成,事件通过 EventQueue 分发WebSocket 收到所有事件和最终结果
- **WebSocket 断开**: 后台任务继续运行,结果保存到 conversation storeTaskStore 状态更新为 COMPLETED
- **后台任务异常**: ReAct 执行抛异常部分输出保存TaskStore 状态为 FAILEDerror 事件发射
- **EventQueue 订阅过滤**: 多个并发任务,每个 WebSocket 只收到自己 task_id 的事件
- **Integration**: 后台任务完成后conversation store 有 assistant 消息TaskStore 有 COMPLETED 记录
**Verification**: 启动后台任务后立即断开 WebSocket等待任务完成后检查 conversation store 和 TaskStore。
---
### U3. Layer 2: TaskStore 注册与状态追踪
**Goal**: 在 portal WebSocket 路径中为每个用户消息创建 TaskStore 记录,状态随任务进展更新。
**Requirements**: R3
**Dependencies**: U2
**Files**:
- `src/agentkit/server/routes/portal.py` — 集成 TaskStore
- `tests/unit/test_portal_ws_task_tracking.py` — 新建测试
**Approach**:
1. 在 portal.py 的 WebSocket 路径中,获取 `task_store` from `websocket.app.state.task_store`
2. 在用户消息处理开始时,调用 `task_store.create(task_id, agent_name, {"conversation_id": conv.id, "message": message_text})`
3. 在后台任务执行前,更新状态为 `RUNNING``started_at=now`)。
4. 在后台任务完成时,更新状态为 `COMPLETED``output_data={"output": response_text}, completed_at=now, progress=1.0`)。
5. 在后台任务失败时,更新状态为 `FAILED``error_message=str(e), completed_at=now`)。
6. DIRECT_CHAT 路径同样注册 TaskStore同步调用状态直接从 PENDING → COMPLETED
**Patterns to follow**:
- BackgroundRunner._run_task 的状态更新模式runner.py:89-92, 141-147, 153-157
- TaskStore.create/update_status 的调用签名task_store.py:127-163
**Test scenarios**:
- **Happy path**: 用户发送消息 → TaskStore 创建 PENDING 记录 → 任务执行中状态为 RUNNING → 完成后状态为 COMPLETEDoutput_data 有内容
- **任务失败**: ReAct 执行异常 → TaskStore 状态为 FAILEDerror_message 有内容
- **WebSocket 断开后查询**: WebSocket 断开,后台任务继续 → 通过 `GET /api/v1/tasks/{task_id}` 能查到 RUNNING 状态 → 任务完成后查到 COMPLETED
- **metadata 包含 conversation_id**: TaskStore 记录的 metadata 中有 conversation_id 字段
- **Integration**: 前端通过 `GET /api/v1/tasks?status=running` 能找到当前对话的运行中任务
**Verification**: 通过 REST API `GET /api/v1/tasks/{task_id}` 查询任务状态,验证状态流转正确。
---
### U4. Layer 3: 前端任务状态查询 API 客户端
**Goal**: 在前端 API 客户端中添加任务状态查询方法,支持按 status 过滤和按 task_id 查询。
**Requirements**: R5, R6
**Dependencies**: U3
**Files**:
- `src/agentkit/server/frontend/src/api/client.ts` — 添加任务 API 方法
- `src/agentkit/server/frontend/src/api/types.ts` — 添加任务类型定义
**Approach**:
1. 在 `types.ts` 中添加 `ITaskRecord` 接口,对应后端 `TaskRecord.to_dict()` 的输出格式。
2. 在 `client.ts` 中添加方法:
- `getTask(taskId: string): Promise<ITaskRecord>` — GET `/api/v1/tasks/{taskId}`注意tasks 路由前缀是 `/api/v1/tasks`,不是 `/api/v1/portal`
- `listTasks(status?: string): Promise<ITaskRecord[]>` — GET `/api/v1/tasks?status=running`
3. 由于 tasks 路由前缀不同(`/api/v1/tasks` vs `/api/v1/portal`),需要创建一个新的 ApiClient 实例或调整 BaseApiClient 的 baseUrl。
**Patterns to follow**:
- 现有 `getConversations()` / `getConversation(id)` 的方法签名模式client.ts:30-37
- `IConversation` 接口定义模式types.ts:51-57
**Test scenarios**:
- **Happy path**: 调用 `getTask(taskId)` 返回正确的任务记录
- **按状态过滤**: 调用 `listTasks("running")` 只返回运行中任务
- **任务不存在**: 调用 `getTask("invalid-id")` 抛出 404 错误
- **Integration**: 后台任务运行中,前端能通过 API 查询到 RUNNING 状态
**Verification**: TypeScript 编译通过(`npm run build:frontend`API 调用返回正确数据。
---
### U5. Layer 3: 前端 WebSocket 重连后的任务恢复
**Goal**: 前端 WebSocket 重连后,检查当前对话是否有未完成任务,恢复事件流或拉取已完成的结果。
**Requirements**: R5, R6
**Dependencies**: U4
**Files**:
- `src/agentkit/server/frontend/src/stores/chat.ts` — 添加重连恢复逻辑
- `src/agentkit/server/frontend/src/api/types.ts` — 扩展 WsClientMessage 类型
**Approach**:
1. **扩展 WebSocket 协议**:添加 `resume` 消息类型,前端重连后发送 `{type: "resume", task_id: "..."}` 订阅已有后台任务的事件流。
2. **后端处理 resume**:在 portal.py 的 WebSocket 路径中,处理 `resume` 消息类型——通过 task_id 订阅 EventQueue 事件流,转发给前端。
3. **前端重连恢复流程**(在 `connectWebSocket``onopen` 中):
- 检查 `currentConversationId` 是否有值
- 调用 `listTasks("running")` 查找运行中任务
- 匹配 `metadata.conversation_id === currentConversationId` 的任务
- 如果找到运行中任务:发送 `resume` 消息,设置 `isLoading = true`
- 如果无运行中任务:调用 `selectConversation(currentConversationId)` 重新加载消息(包含已完成的结果)
4. **后端 resume 处理**:接收到 `resume` 消息后,通过 `task_id` 订阅 EventQueue转发事件直到 `task.completed``task.failed`
**Technical design**directional guidance:
```typescript
// 前端重连恢复(伪代码)
socket.onopen = async () => {
isWsConnected.value = true
startHeartbeat()
// 重连恢复逻辑
if (currentConversationId.value) {
await recoverTask(currentConversationId.value)
}
}
async function recoverTask(convId: string) {
const tasks = await apiClient.listTasks('running')
const runningTask = tasks.find(
t => t.metadata?.conversation_id === convId
)
if (runningTask) {
// 恢复进行中任务的事件流
isLoading.value = true
ws.value.send(JSON.stringify({
type: 'resume',
task_id: runningTask.task_id
}))
} else {
// 无运行中任务,重新加载对话消息
await selectConversation(convId)
}
}
```
**Patterns to follow**:
- 现有 `connectWebSocket``onopen` / `onclose` 模式chat.ts:209-259
- `selectConversation` 的消息加载模式chat.ts:55-74
- `handleWsMessage` 的事件处理模式chat.ts:270-528
**Test scenarios**:
- **Happy path - 有运行中任务**: 重连后发现有运行中任务 → 发送 resume → 收到后续事件 → 任务完成显示结果
- **Happy path - 无运行中任务**: 重连后无运行中任务 → 重新加载对话消息 → 显示已完成的结果
- **任务在重连前已完成**: 重连时任务已 COMPLETED → listTasks("running") 返回空 → 重新加载对话消息 → 结果显示
- **多个运行中任务**: 有多个对话的运行中任务 → 只恢复当前对话的任务
- **resume 后任务立即完成**: resume 后立即收到 task.completed 事件 → 正确显示结果
- **Integration**: 刷新页面 → 重连 → 恢复任务 → 最终结果正确显示
**Verification**: 启动复杂任务 → 刷新页面 → 验证任务继续运行 → 验证结果最终显示。
---
## Risks & Dependencies
### Risks
1. **EventQueue 订阅过滤改造风险**:当前 `subscribe()` 无过滤,添加 task_id 过滤可能影响现有订阅者。缓解:使用可选参数 `subscribe(task_id=None)`,默认行为不变。
2. **后台任务泄漏风险**WebSocket 断开后后台任务继续运行,如果任务本身卡住(如 LLM 超时任务会一直占用资源。缓解ReAct 已有 `timeout_seconds` 配置,后台任务同样受此约束。
3. **并发任务事件混淆风险**多个对话同时执行任务EventQueue 事件可能混淆。缓解:每个任务有唯一 task_id订阅时按 task_id 过滤。
4. **前端重连时序风险**:重连后查询任务状态时,任务可能刚好从 RUNNING 变为 COMPLETED。缓解先查 running如果空则重新加载对话消息会包含已完成结果
### Dependencies
- U1 → U2 → U3 → U4 → U5顺序依赖
- EventQueue 已在 app.state 可用(无需新建)
- TaskStore 已在 app.state 可用(无需新建)
## System-Wide Impact
- **后端**portal.py WebSocket 路径重大重构,影响所有 WebSocket 聊天用户
- **前端**chat store 的 WebSocket 连接逻辑增强,影响所有聊天页面用户
- **API**:新增 `resume` WebSocket 消息类型,无 REST API 变更
- **兼容性**DIRECT_CHAT 路径和 REST API 路径不受影响

View File

@ -0,0 +1,644 @@
# RAGFlow 引入可行性分析
> **创建日期**: 2026-06-17
> **状态**: 调研完成,待决策
> **目标**: 评估将 RAGFlow 作为 Fischer AgentKit 知识库的可行性、技术路径与风险
---
## 一、RAGFlow 项目概览
| 维度 | 详情 |
|------|------|
| 仓库 | https://github.com/infiniflow/ragflow |
| License | Apache-2.0 |
| GitHub Stars | ~80k2025 年度 Top 10 |
| 最新版本 | v0.25.62026-05-26 |
| 核心定位 | 基于深度文档理解的 RAG 引擎,构建 AI Agent 上下文层 |
| 技术栈 | Python 后端 + React/TS 前端 + Docker 部署 |
### 核心差异化能力
- **DeepDoc 引擎**OCR15+ 语言、版面识别10 类组件:文本/标题/图/表/页眉页脚/公式等、表格结构识别TSR
- **混合检索**:向量 + 全文 + 稀疏向量,支持 ColBERT late-interaction
- **高级特性**GraphRAG、RAPTOR层级摘要树、Parent-Child 分块、rerank、上下文压缩
- **16+ 文档格式**PDF/DOCX/PPT/Excel/图片/扫描件/网页/结构化数据等
- **Agent 工作流**可视化编排、Memory 模块v0.23.0+、MCP 协议集成
### 部署资源要求(官方)
- CPU ≥ 4 核
- RAM ≥ 16 GB
- Disk ≥ 50 GB
- Docker ≥ 24.0.0 & Docker Compose ≥ v2.26.1
---
## 二、可行性结论:高度可行 ✅
Fischer AgentKit 已具备**成熟的适配器架构**专门为对接外部知识库设计RAGFlow 的 HTTP REST API 可直接映射到现有协议。
### 关键契合点
1. **协议匹配**:现有 `KnowledgeBase` Protocol`src/agentkit/memory/knowledge_base.py:53-83`)定义了 `ingest()/query()/delete_by_id()/list_sources()/health_check()`RAGFlow API 完全覆盖这些语义
2. **适配器基类就绪**`KBAdapter``src/agentkit/memory/adapters/base.py:22-160`)已封装 httpx 客户端、生命周期管理、认证流程,子类只需实现 `_make_client()``search()`
3. **现有先例**:已有 `FeishuKBAdapter`、`ConfluenceAdapter`、`GenericHTTPAdapter` 三个适配器,模式成熟
4. **SemanticMemory 解耦**`SemanticMemory``src/agentkit/memory/semantic.py:14-121`)通过 `rag_service` 注入,不直接依赖具体实现
5. **API 标准化**RAGFlow 使用 Bearer Token 认证、RESTful JSON 接口,与 `HttpRAGService` 期望的接口形态一致
---
## 三、重点技术路径
### 路径 1新增 RAGFlowAdapter推荐
`src/agentkit/memory/adapters/` 下新增 `ragflow.py`,继承 `KBAdapter`
**API 映射表**
| KBAdapter 方法 | RAGFlow API | 说明 |
|----------------|-------------|------|
| `search(query, top_k)` | `POST /api/v1/retrieval` | body: `{question, dataset_ids, top_k, similarity_threshold, vector_similarity_weight}` |
| `ingest(documents)` | `POST /api/v1/datasets/{id}/documents` + `POST /api/v1/datasets/{id}/chunks` | 上传后需触发异步解析 |
| `delete_by_id(id)` | `DELETE /api/v1/datasets/{id}/documents/{doc_id}` | |
| `list_sources()` | `GET /api/v1/datasets` | RAGFlow 的 dataset 概念 = Fischer 的 source |
| `health_check()` | `GET /api/v1/datasets``/v1/health` | |
| `get_document(doc_id)` | `GET /api/v1/datasets/{id}/documents/{doc_id}` | |
**关键实现要点**
- `_make_client()` 配置 `base_url` + `Authorization: Bearer <api_key>`
- `search()` 需将 RAGFlow 返回的 chunk 结构(`content/document_id/dataset_id/similarity`)标准化为 `QueryResult`
- `ingest()` 需处理 RAGFlow 的**异步解析流程**:上传 → 触发 parse → 轮询状态(非同步返回)
- 在 `adapters/__init__.py` 注册导出
### 路径 2复用 HttpRAGService最快但能力受限
现有 `HttpRAGService` 已实现 `/search``/bases/{kb_id}/retrieve` 调用。RAGFlow 的 retrieval 端点路径不同(`/api/v1/retrieval`),需在 RAGFlow 侧部署一层 API 网关适配,或扩展 HttpRAGService 支持自定义路径模板。
**局限**:无法利用 RAGFlow 的文档上传/解析/分块能力,只能做检索。
### 路径 3通过 ragflow-sdk 集成(不推荐)
`pip install ragflow-sdk` 直接调用 Python SDK。**违背 Fischer 的"配置驱动、不直接依赖业务系统代码"原则**,且引入额外依赖。
### 配置集成方案
`agentkit.yaml``memory.semantic` 段扩展:
```yaml
memory:
semantic:
enabled: true
adapter: "ragflow"
base_url: "http://ragflow-ecs-internal-ip:9380"
api_key: "${RAGFLOW_API_KEY}"
dataset_ids:
- "industry-kb-dataset-id"
- "enterprise-kb-dataset-id"
timeout: 30
retrieval:
similarity_threshold: 0.2
vector_similarity_weight: 0.3
top_k: 10
rerank_id: "BAAI/bge-reranker-v2-m3"
ingest:
mode: "async"
poll_interval: 5
poll_timeout: 600
health_check:
interval: 60
fail_threshold: 3
```
### 与现有 LocalRAGService 的关系
`LocalRAGService`pgvector与 RAGFlow 定位不同:
- **LocalRAGService**:轻量、同进程、适合中小规模文本知识
- **RAGFlow**重量级、独立服务、擅长复杂文档PDF/扫描件/表格)深度解析
建议**并存**,通过 `MultiSourceRetriever` 聚合,按文档类型路由:纯文本走 Local复杂文档走 RAGFlow。
---
## 四、风险分析与缓解措施评估
### 高风险项
#### 风险 1资源占用重16GB+ RAM
**原措施**slim 镜像 + 外部 embedding API / 独立机器部署
| 评估维度 | 结论 |
|----------|------|
| 可行性 | ✅ 可行但需分场景。slim 镜像(~2GB确实省去内置 embedding 模型,但 DeepDoc 的 OCR/TSR/Layout 模型仍打包在内RAM 峰值仍需 8GB+ |
| 缓解效果 | ⚠️ 部分有效。slim 仅省 embedding 部分(约 4-6GBDeepDoc 推理时仍会瞬时占用 2-4GB |
| 次生风险 | 🔴 外部 embedding API 引入新依赖链:① 网络延迟叠加(检索路径变成 Fischer→RAGFlow→外部Embedding API3 跳);② 外部 API 限流/宕机时 RAGFlow 解析直接失败;③ 跨网络传输文档内容存在数据泄露面 |
**更优解**
- **方案 A推荐**RAGFlow 独立机器/K8s 节点部署,与 Fischer 集群物理隔离,仅通过 HTTP API 通信
- **方案 B**:若必须同机,用 cgroup/Docker CPU+memory limits 硬隔离 RAGFlow 容器
- **不建议**:外部 embedding API 方案,除非已有内部 embedding 服务
#### 风险 2架构栈重叠Redis/MySQL/ES vs Fischer 的 Redis/PG
**原措施**:复用 Fischer 的 RedisES/Infinity 无法复用 PG
| 评估维度 | 结论 |
|----------|------|
| 可行性 | ⚠️ Redis 复用技术上可行但有陷阱。RAGFlow 用 Redis 做 Celery broker + 缓存Fischer 用 Redis 做 RedisMessageBusStreams+ TaskStore。两者可用不同 db number 隔离 |
| 缓解效果 | ⚠️ 有限。省下的仅是 Redis 实例(~100MBES/Infinity2-4GB和 MySQL1-2GB仍需独立部署重叠问题仅解决 10-15% |
| 次生风险 | 🔴 Redis 复用有严重隐患:① RAGFlow 的 Celery 任务高峰期会打满 Redis 连接池,影响 Fischer 的 RedisMessageBus 消息投递;② key 命名空间若冲突可能导致数据污染;③ RAGFlow 升级时 Redis schema 变更可能波及 Fischer |
**更优解**
- **方案 A推荐**:完全不共享,独立 Redis 实例。RAGFlow 自带 docker-compose 已包含 Redis保持默认部署不动
- **方案 B**:若强需共享,用 Redis Sentinel/Cluster 的不同 dbRAGFlow 用 db=1Fischer 用 db=0并配置独立的 `maxmemory-policy` 和连接池上限
- **ES/Infinity**无更优解RAGFlow 强依赖其混合检索能力PG+pgvector 无法替代
#### 风险 3异步解析延迟大文档数分钟
**原措施**:适配器内轮询 / 异步任务模式
| 评估维度 | 结论 |
|----------|------|
| 可行性 | ✅ 可行Fischer 已有完整异步任务基础设施。TaskStore 支持 PENDING→RUNNING→COMPLETED 状态机tasks 路由已有 submit/status/list/cancel API |
| 缓解效果 | ✅ 有效。将 RAGFlow ingest 拆为"提交上传→返回 task_id→后台轮询解析状态→更新 task"四步 |
| 次生风险 | 🟡 中等:① 轮询频率若过高会冲击 RAGFlow API建议 5-10s 间隔,指数退避);② task TTL 与 RAGFlow 解析时长不匹配;③ 用户在解析未完成时检索会得到空结果 |
**更优解**
- **方案 A推荐**:用 RAGFlow 的 Webhook 回调替代轮询。RAGFlow v0.23.0+ 支持 Webhook 触发,解析完成后主动回调 Fischer 的 `/api/v1/tasks/{id}/callback`
- **方案 B**:若 Webhook 不可用,用 RedisMessageBus 发布解析完成事件,适配器订阅后更新 task 状态
- **方案 C**:分离 ingest 和 query 路径——ingest 走异步 taskquery 永远同步
### 中风险项
#### 风险 4Embedding 模型锁定
**原措施**:规划期确定模型;不同 dataset 用不同模型
| 评估维度 | 结论 |
|----------|------|
| 可行性 | ⚠️ 部分可行。"不同 dataset 不同模型"技术上成立,但跨 dataset 检索时向量维度不一致会导致召回失效 |
| 缓解效果 | ⚠️ 有限。锁定后若需切换模型,必须重建整个 dataset |
| 次生风险 | 🟡 模型碎片化:多个 dataset 用不同 embeddingSemanticMemory 的 `kb_weights` 加权策略失效 |
**更优解**
- **方案 A推荐**:全组织统一 embedding 模型(建议 `BAAI/bge-large-zh-v1.5``bge-m3`),所有 dataset 强制一致
- **方案 B**:若必须多模型,在适配器层按 dataset 分组检索,组内归一化 score 后再融合
- **根本性建议**:将 embedding 模型选择纳入 Fischer 的 LLM Gateway 统一管理
#### 风险 5ARM64 支持缺失
**原措施**x86 Docker / 自行构建
| 评估维度 | 结论 |
|----------|------|
| 可行性 | ✅ 可行。自行构建有官方文档支持 |
| 缓解效果 | ✅ 有效,但构建耗时(含模型下载约 30-60 分钟) |
| 次生风险 | 🟢 低。主要是构建产物维护成本 |
**更优解**
- **方案 A**:开发环境用 x86 Docker Desktop生产环境强制 x86 服务器
- **方案 B**:若有 CI/CD用 GitHub Actions 在 ARM64 runner 上自动构建并推送到私有 registry
#### 风险 6版本快速迭代API breaking change
**原措施**:锁定版本;适配器层兼容
| 评估维度 | 结论 |
|----------|------|
| 可行性 | ✅ 可行。锁定版本是标准做法 |
| 缓解效果 | ✅ 有效但被动。锁定版本意味着无法获得 bug 修复和新特性 |
| 次生风险 | 🟡 安全漏洞累积:长期不升级会错过安全补丁 |
**更优解**
- **方案 A推荐**:适配器层做 API 版本抽象。定义 `RAGFlowAPIVersion` 枚举,适配器根据版本号选择不同的端点路径和响应解析逻辑
- **方案 B**:跟随 RAGFlow 的 minor 版本(如固定 v0.25.xpatch 版本自动升级
#### 风险 7数据模型映射损耗
**原措施**:适配器层完整字段映射,特有字段入 metadata
| 评估维度 | 结论 |
|----------|------|
| 可行性 | ✅ 完全可行。这是适配器模式的标准职责 |
| 缓解效果 | ✅ 有效。QueryResult 的 metadata 字段是 dict可容纳任意额外字段 |
| 次生风险 | 🟢 极低 |
**更优解**:当前方案已足够。增强建议:定义 `RAGFlowChunkMetadata` Pydantic 模型,结构化 RAGFlow 特有字段
### 低风险项
#### 风险 8网络调用开销
**原措施**timeout + 降级
| 评估维度 | 结论 |
|----------|------|
| 可行性 | ✅ 可行。SemanticMemory 已有 try/except 降级到空结果 |
| 缓解效果 | ✅ 有效 |
| 次生风险 | 🟡 降级静默化:检索失败返回空列表,用户无感知 |
**更优解**:在降级时记录 metric并向前端 WebSocket 推送 `error` 事件;增加 circuit breaker
#### 风险 9 & 10认证差异 / 功能重叠
**评估**:两项措施均完全可行且无次生风险,无需更优解。
### 综合评估矩阵
| 风险 | 原措施可行性 | 次生风险 | 缓解效果 | 更优解收益 |
|------|:---:|:---:|:---:|------|
| 1. 资源占用 | ⚠️ 部分 | 🔴 高 | ⚠️ 部分 | 独立部署彻底解决 |
| 2. 架构栈重叠 | ⚠️ 部分 | 🔴 高 | ⚠️ 仅10-15% | 独立Redis实例不共享 |
| 3. 异步解析 | ✅ 可行 | 🟡 中 | ✅ 有效 | Webhook回调消除轮询 |
| 4. Embedding锁定 | ⚠️ 部分 | 🟡 中 | ⚠️ 有限 | 统一模型+网关治理 |
| 5. ARM64 | ✅ 可行 | 🟢 低 | ✅ 有效 | CI自动构建 |
| 6. 版本迭代 | ✅ 可行 | 🟡 中 | ⚠️ 被动 | API版本抽象层 |
| 7. 数据映射 | ✅ 可行 | 🟢 极低 | ✅ 有效 | 已足够 |
| 8. 网络开销 | ✅ 可行 | 🟡 低 | ✅ 有效 | metric+WS通知 |
### 关键结论
1. **原措施中 2 项有严重次生风险需立即调整**
- 风险 1 的"外部 embedding API"方案 → 改为独立机器部署
- 风险 2 的"复用 Redis"方案 → 改为独立 Redis 实例
2. **1 项有明确更优解**
- 风险 3 的"轮询"→ 改用 RAGFlow Webhook 回调
3. **最关键的系统性建议**:将 RAGFlow 视为完全独立的外部服务(而非与 Fischer 共享基础设施的组件),通过 HTTP API 松耦合
4. **embedding 模型治理应统一到 Fischer 的 LLM Gateway**,避免模型碎片化和 score 不可比问题
---
## 五、独立部署配置方案(阿里云)
### 架构拓扑
```
┌─────────────────────────────────────────────────────────────┐
│ 阿里云 VPC (10.0.0.0/16) │
│ │
│ ┌──────────────────────┐ ┌───────────────────────┐ │
│ │ Fischer ECS (已有) │ │ RAGFlow ECS (新增) │ │
│ │ 10.0.1.10 │ HTTPS │ 10.0.2.10 │ │
│ │ agentkit serve:8001 │◄───────►│ ragflow:9380 │ │
│ │ Redis:6379 (内嵌) │ │ Infinity:23321 │ │
│ │ PG:5432 (内嵌) │ │ MySQL:3306 Redis:6380 │ │
│ └──────────────────────┘ └───────────────────────┘ │
│ │ │ │
│ │ ┌──────────────────────┐│ │
│ └─────────►│ 阿里云 NAS / ESSD │◄┘ │
│ │ (文档持久化存储) │ │
│ └──────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
```
### 资源需求核算(修正后)
RAGFlow 各组件实际内存占用:
| 组件 | 最小可运行 | 推荐生产 | 说明 |
|------|-----------|---------|------|
| RAGFlow Server + DeepDoc | 2GB | 4GB | Python 进程DeepDoc 推理时峰值 +2GB |
| Elasticsearch | 4GBheap 2g | 8GBheap 4g | JVM heap = 容器内存 50% |
| Infinity替代ES | 2GB | 3GB | Rust 实现,无 JVM 开销 |
| MySQL | 1GB | 2GB | 元数据存储,数据量小 |
| Redis | 512MB | 1GB | Celery broker + 缓存 |
| MinIO | 512MB | 1GB | 文档对象存储 |
| 系统开销 | 1GB | 2GB | OS + Docker daemon |
### 按业务规模分档推荐
#### 档位 1POC / 小规模(< 1000 文档
| 配置 | 规格 | 月费 |
|------|------|------|
| 实例 | ecs.g7.xlarge4c16g | ¥450 |
| 数据盘 | ESSD PL0 100GB | ¥45 |
| **合计** | | **~¥500/月** |
#### 档位 2中小规模1000-10000 文档)⭐ 推荐
| 配置 | 规格 | 月费 |
|------|------|------|
| 实例 | ecs.g7.2xlarge8c32g | ¥900 |
| 数据盘 | ESSD PL1 200GB | ¥150 |
| **合计** | | **~¥1,050/月** |
资源分配:
```
RAGFlow Server: 4GB
ES (heap 4g): 8GB 或 Infinity: 3GB
MySQL: 2GB
Redis: 1GB
MinIO: 1GB
系统余量: 16GB含 DeepDoc 推理峰值)
```
#### 档位 3中大规模1万-10万文档
| 配置 | 规格 | 月费 |
|------|------|------|
| 实例 | ecs.g7.3xlarge12c48g | ¥1,350 |
| 数据盘 | ESSD PL1 500GB | ¥375 |
| **合计** | | **~¥1,725/月** |
### 降配三个手段
1. **用 Infinity 替代 Elasticsearch**(省 4-6GBRust 实现,无 JVM 开销
2. **slim 镜像 + 外部 Embedding**(省 4GBembedding 走阿里云百炼 `text-embedding-v2`
3. **MySQL/Redis 用 RAGFlow 自带**(不共享,彻底隔离)
### 综合最优方案(性价比最高)
| 项目 | 选择 | 理由 |
|------|------|------|
| 实例 | ecs.g7.2xlarge8c32g | 留足 DeepDoc 推理余量 |
| 文档引擎 | Infinity非 ES | 省 5GB 内存 |
| 镜像 | v0.25.6-slim | 不含 embedding 模型 |
| Embedding | 阿里云百炼 text-embedding-v2 | 外部 API按量付费极低 |
| MySQL/Redis | RAGFlow 自带 | 不共享,彻底隔离 |
| 数据盘 | ESSD PL1 200GB | IOPS 够用 |
| **月费** | **~¥1,050** | |
资源实际占用预估:
```
RAGFlow Server (slim): 2GB
DeepDoc 推理峰值: +2GB间歇性
Infinity: 3GB
MySQL: 2GB
Redis: 1GB
MinIO: 1GB
系统: 2GB
────────────────────────────
总计: ~13GB32GB 机器余量充足)
```
### 极限降配方案(仅 POC 验证用)
| 项目 | 选择 | 月费 |
|------|------|------|
| 实例 | ecs.g7.xlarge4c16g | ¥450 |
| 文档引擎 | Infinity | |
| 镜像 | slim + 外部 embedding | |
| **月费** | | **~¥500** |
⚠️ 4c16g 下 DeepDoc 解析大 PDF>50页可能触发 OOM仅适合验证检索效果。
---
## 六、RAGFlow 侧 Docker Compose 配置
```yaml
# /opt/ragflow/docker/docker-compose.yml基于官方 v0.25.6 调整)
services:
ragflow-server:
image: registry.cn-hangzhou.aliyuncs.com/infiniflow/ragflow:v0.25.6-slim
container_name: ragflow-server
ports:
- "10.0.2.10:9380:9380" # 仅绑定内网 IP不暴露公网
- "10.0.2.10:80:80"
environment:
- SVR_HTTP_PORT=9380
- MYSQL_HOST=mysql
- MYSQL_PORT=3306
- REDIS_HOST=redis
- REDIS_PORT=6380 # 与 Fischer Redis 端口隔离
- DOC_ENGINE=infinity # 使用 Infinity 替代 ES
- MINIO_HOST=minio:9000
volumes:
- /data/ragflow/ragflow-logs:/ragflow/logs
- /data/ragflow/ragflow-data:/ragflow/data
depends_on:
- mysql
- redis
- minio
- infinity
restart: unless-stopped
deploy:
resources:
limits:
memory: 8G
infinity:
image: infiniflow/infinity:v0.6.0
container_name: ragflow-infinity
volumes:
- /data/ragflow/infinity-data:/infinity/data
deploy:
resources:
limits:
memory: 4G
mysql:
image: mysql:8.0
environment:
- MYSQL_ROOT_PASSWORD=${MYSQL_ROOT_PASSWORD}
- MYSQL_DATABASE=ragflow
volumes:
- /data/ragflow/mysql-data:/var/lib/mysql
deploy:
resources:
limits:
memory: 2G
redis:
image: redis:7-alpine
command: redis-server --port 6380 --maxmemory 1gb --maxmemory-policy allkeys-lru
volumes:
- /data/ragflow/redis-data:/data
deploy:
resources:
limits:
memory: 2G
minio:
image: minio/minio:latest
command: server /data --console-address ":9001"
environment:
- MINIO_ROOT_USER=${MINIO_ROOT_USER}
- MINIO_ROOT_PASSWORD=${MINIO_ROOT_PASSWORD}
volumes:
- /data/ragflow/minio-data:/data
deploy:
resources:
limits:
memory: 1G
```
### 系统级配置(必做)
```bash
# 1. 内核参数ES 必须Infinity 建议也设置)
sudo sysctl -w vm.max_map_count=262144
echo "vm.max_map_count=262144" | sudo tee -a /etc/sysctl.conf
# 2. 创建数据目录
sudo mkdir -p /data/ragflow/{infinity-data,mysql-data,redis-data,minio-data,ragflow-data,ragflow-logs}
sudo chown -R 1000:1000 /data/ragflow
# 3. Docker 镜像加速(阿里云容器镜像服务)
sudo mkdir -p /etc/docker
sudo tee /etc/docker/daemon.json <<EOF
{
"registry-mirrors": ["https://your-acs-mirror.mirror.aliyuncs.com"]
}
EOF
sudo systemctl restart docker
```
---
## 七、Fischer 侧 agentkit.yaml 配置
在现有 `agentkit.yaml` 中新增 memory 段:
```yaml
# 在现有 agentkit.yaml 末尾追加
memory:
semantic:
enabled: true
adapter: "ragflow" # 新增 adapter 类型标识
base_url: "http://10.0.2.10:9380" # RAGFlow ECS 内网 IP
api_key: "${RAGFLOW_API_KEY}" # 从环境变量读取
timeout: 30
knowledge_base_ids: # RAGFlow dataset IDs
- "dataset_industry_kb"
- "dataset_enterprise_kb"
# RAGFlow 检索参数
retrieval:
similarity_threshold: 0.2 # 低于此分数不返回
vector_similarity_weight: 0.3 # 向量权重 (0-1)
top_k: 10 # 单次召回数
rerank_id: "BAAI/bge-reranker-v2-m3" # 可选 rerank 模型
# 异步 ingest 配置
ingest:
mode: "async" # async | sync
poll_interval: 5 # 轮询间隔(秒)
poll_timeout: 600 # 最大等待(秒)
# 健康检查
health_check:
interval: 60 # 检查间隔(秒)
fail_threshold: 3 # 连续失败 N 次熔断
```
### 环境变量Fischer ECS 的 .env
```bash
# RAGFlow API Key在 RAGFlow Web UI > 设置 > API Key 生成)
RAGFLOW_API_KEY=ragflow-xxxxxxxxxxxxxxxxxxxxxxxx
# 可选embedding 模型走阿里云百炼
DASHSCOPE_API_KEY=sk-xxxxxxxxxxxx
```
---
## 八、阿里云网络与安全配置
### VPC 安全组规则
**RAGFlow ECS 安全组**(仅允许 Fischer 内网访问):
| 方向 | 协议 | 端口 | 源/目标 | 用途 |
|------|------|------|---------|------|
| 入 | TCP | 9380 | 10.0.1.10/32 (Fischer) | RAGFlow API |
| 入 | TCP | 80 | 10.0.1.10/32 (Fischer) | RAGFlow Web可选 |
| 入 | TCP | 22 | 管理跳板机 IP | SSH 运维 |
| 出 | TCP | 443 | 0.0.0.0/0 | 拉取镜像、调用外部 LLM |
| 入 | TCP | * | 0.0.0.0/0 | **拒绝**(默认) |
### API Key 安全
RAGFlow API Key 通过阿里云 KMS 加密存储,运行时解密注入:
```bash
# 加密 API Key 到 KMS
aliyun kms Encrypt \
--KeyId key-ragflow \
--Plaintext "$(echo -n 'ragflow-xxx' | base64)"
# Fischer 启动脚本中解密
export RAGFLOW_API_KEY=$(aliyun kms Decrypt \
--CiphertextBlob "encrypted-blob" \
--query Plaintext | base64 -d)
```
---
## 九、可选阿里云托管服务替代
| RAGFlow 组件 | 阿里云替代 | 优势 | 成本 |
|--------------|-----------|------|------|
| Elasticsearch | 阿里云 ES | 免运维、自动备份、监控 | ~¥800/月2核4G |
| MySQL | RDS MySQL | 高可用、自动备份 | ~¥200/月1核2G |
| Redis | Tair/Redis 实例 | 免运维、持久化 | ~¥150/月1G |
| MinIO文档存储 | OSS | 11个9 持久性、低成本 | ~¥0.12/GB/月 |
| 服务器 | ACKK8s | 弹性伸缩、滚动升级 | 节点费 + 管理费 |
### 成本对比
| 方案 | 月费 | 适用场景 |
|------|------|---------|
| 全自管 ECS8c32g + Infinity + slim | ~¥1,050 | 推荐起步 |
| ECS + 托管服务混合 | ~¥2,062 | 免运维需求 |
| 经济型 POC4c16g | ~¥500 | 仅验证 |
---
## 十、实施步骤
1. **POC 验证**:用 `docker compose` 起一个 RAGFlow slim + Infinity 实例,上传 1 个 PDF调用 `/api/v1/retrieval` 验证检索效果
2. **实现 RAGFlowAdapter**:参考 `generic_http.py` 模式,重点处理异步解析和字段映射
3. **配置集成**:扩展 `agentkit.yaml` schema 和 SemanticMemory 初始化逻辑
4. **单元测试**参考现有适配器测试mock RAGFlow API 响应
5. **集成测试**:验证 RAGFlow + LocalRAGService 多源检索聚合
### 部署 Checklist
- [ ] RAGFlow ECS 创建并加入与 Fischer 相同的 VPC
- [ ] 安全组仅放行 Fischer 内网 IP 到 9380 端口
- [ ] `vm.max_map_count=262144` 永久生效
- [ ] 数据盘挂载到 `/data/ragflow/` 并设置正确权限
- [ ] RAGFlow docker-compose 启动,`docker logs -f ragflow-server` 显示就绪
- [ ] RAGFlow Web UI 创建 dataset记录 dataset_id
- [ ] 生成 RAGFlow API Key通过 KMS 加密存储
- [ ] Fischer `.env` 配置 `RAGFLOW_API_KEY`
- [ ] Fischer `agentkit.yaml` 配置 memory.semantic 段
- [ ] 从 Fischer ECS 执行 `curl http://10.0.2.10:9380/api/v1/datasets` 验证连通
- [ ] 上传测试文档,验证 ingest 异步解析完成
- [ ] 执行检索测试,验证 QueryResult 字段映射正确
---
## 十一、核心判断
RAGFlow 的价值在于 **DeepDoc 深度文档解析**这一项 Fischer 现有栈pgvector + TextChunker明显薄弱的能力。
- **如果业务场景涉及大量 PDF/扫描件/复杂表格** → 引入值得
- **若仅处理纯文本** → 现有的 `LocalRAGService` 已够用,不必承担 RAGFlow 的运维复杂度
### 关键决策点
1. RAGFlow 视为**完全独立的外部服务**,通过 HTTP API 松耦合,不共享基础设施
2. embedding 模型治理统一到 Fischer 的 LLM Gateway
3. 起步用全自管 ECS8c32g + Infinity + slim~¥1,050/月),验证业务价值后再评估是否迁移到托管服务
4. ECS 规格选 g7.2xlarge 是经过 RAGFlow 全栈资源分配计算的安全值,不建议低于 32GB RAM生产环境
---
## 参考资料
- RAGFlow 官网: https://ragflow.org/
- RAGFlow GitHub: https://github.com/infiniflow/ragflow
- RAGFlow HTTP API: https://ragflow.io/docs/dev/http_api_reference
- RAGFlow Python API: https://ragflow.io/docs/dev/python_api_reference
- RAGFlow 发布说明: https://ragflow.io/docs/release_notes
- Infinity 数据库: https://github.com/infiniflow/infinity
- RAGFlow 系统架构: https://deepwiki.com/infiniflow/ragflow/3-system-architecture

36
scripts/deploy.sh Executable file
View File

@ -0,0 +1,36 @@
#!/usr/bin/env bash
# 服务器侧部署脚本:构建镜像并滚动更新服务
# 由 Gitea Actions workflow 在 /opt/agentkit/repo 目录下调用
# Usage: bash scripts/deploy.sh
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
COMPOSE_FILE="${COMPOSE_FILE:-docker-compose.deploy.yaml}"
DEPLOY_DIR="${DEPLOY_DIR:-/opt/agentkit}"
cd "$PROJECT_ROOT"
if [ ! -f "$COMPOSE_FILE" ]; then
echo "❌ 未找到 $COMPOSE_FILE"
exit 1
fi
if [ ! -f ".env" ]; then
echo "❌ 未找到 .env 文件,请先通过 Gitea Secrets 生成"
exit 1
fi
echo "==> 部署目录: $PROJECT_ROOT"
echo "==> Compose 文件: $COMPOSE_FILE"
echo "==> 开始构建镜像..."
docker compose -f "$COMPOSE_FILE" build --pull
echo "==> 滚动重启服务(保留数据卷)..."
docker compose -f "$COMPOSE_FILE" up -d --remove-orphans
echo "==> 当前服务状态:"
docker compose -f "$COMPOSE_FILE" ps
echo "==> 部署完成 ✅"

View File

@ -22,6 +22,7 @@ Tests core AgentKit components:
- event_model: SubmissionQueue / EventQueue lifecycle [Mock]
- spec_management: SpecManager CRUD operations [Mock]
- verification: VerificationLoop execute/retry behavior [Mock]
- board_meeting: BoardRouter @board prefix routing & validation [Mock]
- llm_reasoning: Real LLM intent/tool/multi-step/code/error [LLM]
- gui_integration: agentkit gui end-to-end (API/WS/frontend) [GUI]
@ -85,6 +86,7 @@ class BenchmarkDimension(str, Enum):
EVENT_MODEL = "event_model"
SPEC_MANAGEMENT = "spec_management"
VERIFICATION = "verification"
BOARD_MEETING = "board_meeting"
LLM_REASONING = "llm_reasoning"
GUI_INTEGRATION = "gui_integration"
ALL = "all"
@ -114,6 +116,7 @@ _MOCK_DIMENSIONS: list[BenchmarkDimension] = [
BenchmarkDimension.EVENT_MODEL,
BenchmarkDimension.SPEC_MANAGEMENT,
BenchmarkDimension.VERIFICATION,
BenchmarkDimension.BOARD_MEETING,
]
@ -349,6 +352,62 @@ TASK_SET: list[BenchmarkTask] = [
"passed", ["timeout"], "超时检测"),
BenchmarkTask("vf-005", "verification", "multi", "medium", "multi_command",
"passed", ["multi"], "多命令验证"),
# === Board Meeting (18 tasks) ===
BenchmarkTask("bd-001", "board_meeting", "default_template", "easy",
"@board 讨论是否应该进入东南亚市场",
"board", ["board", "default"], "@board 前缀应路由到 board 模式"),
BenchmarkTask("bd-002", "board_meeting", "default_template", "easy",
"@board AI产品定价策略应该怎么做",
"board", ["board", "default"], "@board 前缀应路由到 board 模式"),
BenchmarkTask("bd-003", "board_meeting", "default_template", "medium",
"@board:private_board 讨论创业公司融资节奏",
"board", ["board", "template"], "显式 private_board 模板应路由到 board 模式"),
BenchmarkTask("bd-004", "board_meeting", "explicit_experts", "medium",
"@board:elon_musk,jeff_bezos 讨论火星殖民的商业化路径",
"board", ["board", "explicit"], "指定专家应路由到 board 模式"),
BenchmarkTask("bd-005", "board_meeting", "explicit_experts", "medium",
"@board:charlie_munger,warren_buffett 价值投资在AI时代的适用性",
"board", ["board", "explicit"], "指定多位专家应路由到 board 模式"),
BenchmarkTask("bd-006", "board_meeting", "explicit_experts", "medium",
"@board:elon_musk,jeff_bezos,allenzhang 产品设计哲学",
"board", ["board", "explicit", "multi"], "三位专家应路由到 board 模式"),
BenchmarkTask("bd-007", "board_meeting", "topic_extraction", "easy",
"@board 讨论是否应该进入东南亚市场",
"讨论是否应该进入东南亚市场", ["board", "topic"], "应正确提取讨论主题"),
BenchmarkTask("bd-008", "board_meeting", "topic_extraction", "easy",
"@board:elon_musk,jeff_bezos 火星商业化方案",
"火星商业化方案", ["board", "topic"], "应从显式专家格式提取主题"),
BenchmarkTask("bd-009", "board_meeting", "topic_extraction", "easy",
"@board",
"", ["board", "topic", "empty"], "空主题应返回空字符串"),
BenchmarkTask("bd-010", "board_meeting", "no_match", "easy",
"讨论一下市场策略",
"not_board", ["board", "edge"], "无 @board 前缀不应路由到 board 模式"),
BenchmarkTask("bd-011", "board_meeting", "no_match", "easy",
"@team:analyst,writer 协作完成任务",
"not_board", ["board", "edge"], "@team 前缀不应路由到 board 模式"),
BenchmarkTask("bd-012", "board_meeting", "no_match", "easy",
"@skill:react_agent 查看ip",
"not_board", ["board", "edge"], "@skill 前缀不应路由到 board 模式"),
BenchmarkTask("bd-013", "board_meeting", "name_validation", "medium",
"@board:elon_musk,jeff_bezos 主题",
"2_valid", ["board", "validation"], "两个有效专家名应被接受"),
BenchmarkTask("bd-014", "board_meeting", "name_validation", "medium",
"@board:@#$ 主题",
"default_fallback", ["board", "validation", "invalid"],
"全部无效专家名时应回退到默认模板"),
BenchmarkTask("bd-015", "board_meeting", "name_validation", "medium",
"@board:a,b,c,d,e,f,g,h,i,j,k 主题",
"10_capped", ["board", "validation", "cap"], "超过 MAX_EXPERTS=10 应被截断"),
BenchmarkTask("bd-016", "board_meeting", "stop_command", "easy",
"/stop",
"is_stop", ["board", "stop"], "/stop 应被识别为停止命令"),
BenchmarkTask("bd-017", "board_meeting", "stop_command", "easy",
"停止讨论",
"is_stop", ["board", "stop"], "中文停止讨论应被识别"),
BenchmarkTask("bd-018", "board_meeting", "stop_command", "easy",
"继续讨论",
"not_stop", ["board", "stop"], "非停止命令不应被误判"),
]
# fmt: on
@ -359,6 +418,7 @@ _FAST_CORE_IDS: set[str] = {
"eff-001", "eff-004", "ts-001", "ts-003", "ts-008", "ts-010",
"ev-001", "ev-004", "ev-005", "sm-001", "sm-002", "sm-006", "sm-004",
"vf-001", "vf-002", "vf-003", "llm-001", "llm-003", "gui-001", "gui-002", "gui-004",
"bd-001", "bd-004", "bd-007", "bd-010", "bd-013", "bd-016",
}
# fmt: on
@ -1751,6 +1811,134 @@ async def _exec_verification(task: BenchmarkTask, ctx: BenchmarkContext) -> Exec
)
async def _exec_board_meeting(task: BenchmarkTask, ctx: BenchmarkContext) -> ExecutionResult:
"""Execute board meeting benchmark task.
Tests BoardRouter prefix matching, topic extraction, expert name
validation, and stop command detection all without LLM calls.
Categories:
- default_template: @board or @board:private_board board mode
- explicit_experts: @board:expert1,expert2 board mode
- topic_extraction: verify topic string is correctly extracted
- no_match: non-@board inputs should NOT route to board mode
- name_validation: expert name format and MAX_EXPERTS cap
- stop_command: /stop and 停止讨论 detection
"""
from agentkit.experts.board_router import (
MAX_EXPERTS,
BoardRouter,
)
from agentkit.experts.registry import ExpertTemplateRegistry
start = time.perf_counter()
# Build a BoardRouter with an empty registry (tests pure routing logic)
registry = ExpertTemplateRegistry()
router = BoardRouter(template_registry=registry)
# --- Stop command detection (bd-016, bd-017, bd-018) ---
if task.category == "stop_command":
from agentkit.experts.board_orchestrator import BoardOrchestrator
is_stop = task.input.strip() in BoardOrchestrator.STOP_COMMANDS
actual = "is_stop" if is_stop else "not_stop"
passed = actual == task.expected
elapsed = (time.perf_counter() - start) * 1000
return ExecutionResult(
actual=actual,
passed=passed,
duration_ms=round(elapsed, 4),
detail=f"input={task.input!r} stop_commands={BoardOrchestrator.STOP_COMMANDS}",
)
# --- All other categories: use BoardRouter.resolve() ---
result = router.resolve(task.input)
elapsed = (time.perf_counter() - start) * 1000
if task.category == "default_template":
# Expect board_mode=True and use_default_template=True
actual = "board" if (result.matched and result.board_mode) else "not_board"
passed = actual == task.expected
return ExecutionResult(
actual=actual,
passed=passed,
duration_ms=round(elapsed, 4),
detail=(
f"matched={result.matched} board_mode={result.board_mode} "
f"use_default={result.use_default_template} topic={result.topic!r}"
),
)
if task.category == "explicit_experts":
actual = "board" if (result.matched and result.board_mode) else "not_board"
passed = actual == task.expected
return ExecutionResult(
actual=actual,
passed=passed,
duration_ms=round(elapsed, 4),
detail=(
f"matched={result.matched} experts={result.specified_experts} "
f"use_default={result.use_default_template}"
),
)
if task.category == "topic_extraction":
# Compare extracted topic (normalized: strip + collapse whitespace)
actual = " ".join(result.topic.split())
passed = actual == task.expected
return ExecutionResult(
actual=actual,
passed=passed,
duration_ms=round(elapsed, 4),
detail=f"input={task.input!r} topic={result.topic!r} matched={result.matched}",
)
if task.category == "no_match":
# Expect board_mode=False
actual = "not_board" if not result.board_mode else "board"
passed = actual == task.expected
return ExecutionResult(
actual=actual,
passed=passed,
duration_ms=round(elapsed, 4),
detail=f"input={task.input!r} matched={result.matched} board_mode={result.board_mode}",
)
if task.category == "name_validation":
# Count valid expert names (after validation)
valid_count = len(result.specified_experts)
if task.expected == "2_valid":
actual = f"{valid_count}_valid"
passed = valid_count == 2
elif task.expected == "default_fallback":
# All names invalid → should fall back to default template
actual = "default_fallback" if result.use_default_template else "no_fallback"
passed = result.use_default_template and valid_count > 0
elif task.expected == "10_capped":
actual = f"{valid_count}_capped"
passed = valid_count == MAX_EXPERTS
else:
actual = f"{valid_count}_valid"
passed = False
return ExecutionResult(
actual=actual,
passed=passed,
duration_ms=round(elapsed, 4),
detail=(
f"input={task.input!r} experts={result.specified_experts} "
f"max={MAX_EXPERTS}"
),
)
return ExecutionResult(
actual="unknown_category",
passed=False,
duration_ms=round(elapsed, 4),
detail=f"Unknown board_meeting category: {task.category}",
)
_EXECUTORS: dict[
str,
Callable[[BenchmarkTask, BenchmarkContext], Awaitable[ExecutionResult]],
@ -1762,6 +1950,7 @@ _EXECUTORS: dict[
"event_model": _exec_event_model,
"spec_management": _exec_spec_management,
"verification": _exec_verification,
"board_meeting": _exec_board_meeting,
}
@ -1963,8 +2152,9 @@ def _generate_markdown_report(
"event_model": "5. 事件模型 (Event Model) [Mock]",
"spec_management": "6. 规格管理 (Spec Management) [Mock]",
"verification": "7. 验证循环 (Verification Loop) [Mock]",
"llm_reasoning": "8. LLM 推理能力 (LLM Reasoning) [LLM]",
"gui_integration": "9. GUI 集成测试 (GUI Integration) [GUI]",
"board_meeting": "8. 私董会路由 (Board Meeting Routing) [Mock]",
"llm_reasoning": "9. LLM 推理能力 (LLM Reasoning) [LLM]",
"gui_integration": "10. GUI 集成测试 (GUI Integration) [GUI]",
}
lines.append("## 维度结果")

View File

@ -0,0 +1,377 @@
"""BoardTeam - 私董会讨论模式容器
管理私董会的专家生命周期讨论状态和事件广播
ExpertTeamhub-and-spoke 任务分解并列专注于多轮群聊式讨论
核心差异vs ExpertTeam
- 讨论模式多轮全员发言 + 主持人小结非任务分解
- 专家通信基于共享讨论历史非独立子任务
- 终止机制最大轮次 + 用户干预非任务完成
- 主持人角色首位专家负责开场/小结/最终总结
"""
from __future__ import annotations
import enum
import logging
import time
import uuid
from typing import Any
from .config import ExpertConfig
from .expert import Expert
from .registry import ExpertTemplateRegistry
from ..core.handoff_transport import InProcessHandoffTransport
from ..core.shared_workspace import SharedWorkspace
from ..core.agent_pool import AgentPool
logger = logging.getLogger(__name__)
class BoardStatus(str, enum.Enum):
"""BoardTeam lifecycle states.
Flow: FORMING DISCUSSING CONCLUDING COMPLETED DISSOLVED
"""
FORMING = "forming"
DISCUSSING = "discussing"
CONCLUDING = "concluding"
COMPLETED = "completed"
DISSOLVED = "dissolved"
class BoardTeam:
"""Container managing a board of Experts in discussion mode.
In board meeting mode:
- Moderator (lead expert) opens the discussion, summarizes each round,
and gives final decision advice
- Member experts give speeches each round based on shared discussion history
- All experts see the full discussion history (shared context)
- Discussion terminates after max_rounds or user intervention
"""
def __init__(
self,
team_id: str | None = None,
workspace: SharedWorkspace | None = None,
pool: AgentPool | None = None,
template_registry: ExpertTemplateRegistry | None = None,
max_rounds: int = 5,
):
self.team_id = team_id or str(uuid.uuid4())
self._workspace = workspace or SharedWorkspace()
self._pool = pool
self._template_registry = template_registry or ExpertTemplateRegistry()
self._handoff_transport = InProcessHandoffTransport()
self._experts: dict[str, Expert] = {}
self._moderator_name: str | None = None
self._status = BoardStatus.FORMING
self._team_channel = f"board:{self.team_id}"
# Discussion state
self._topic: str = ""
self._history: list[dict[str, Any]] = []
self._current_round: int = 0
self._max_rounds: int = max_rounds
self._user_interventions: list[str] = [] # Pending user messages
@property
def status(self) -> BoardStatus:
return self._status
@property
def moderator(self) -> Expert | None:
if self._moderator_name:
return self._experts.get(self._moderator_name)
return None
@property
def experts(self) -> list[Expert]:
return list(self._experts.values())
@property
def active_experts(self) -> list[Expert]:
return [e for e in self._experts.values() if e.is_active]
@property
def member_experts(self) -> list[Expert]:
"""Non-moderator experts."""
return [e for e in self._experts.values() if e.config.name != self._moderator_name]
@property
def workspace(self) -> SharedWorkspace:
return self._workspace
@property
def handoff_transport(self):
return self._handoff_transport
@property
def team_channel(self) -> str:
return self._team_channel
@property
def topic(self) -> str:
return self._topic
@property
def current_round(self) -> int:
return self._current_round
@property
def max_rounds(self) -> int:
return self._max_rounds
@property
def history(self) -> list[dict[str, Any]]:
return self._history.copy()
def get_expert(self, name: str) -> Expert | None:
return self._experts.get(name)
def set_status(self, status: BoardStatus) -> None:
self._status = status
async def create_board(
self,
topic: str,
expert_configs: list[ExpertConfig],
) -> None:
"""Create a board with a moderator and member experts.
Args:
topic: Discussion topic
expert_configs: List of ExpertConfig, first is moderator
"""
if not self._pool:
raise RuntimeError("AgentPool not configured")
if not expert_configs:
raise ValueError("At least one expert config is required")
self._topic = topic
# Build board context for all experts
board_context = self._build_board_context(expert_configs)
# Create experts
for i, config in enumerate(expert_configs):
expert = await Expert.create(
config=config,
pool=self._pool,
handoff_transport=self._handoff_transport,
workspace=self._workspace,
team_context=board_context,
)
expert.team_id = self.team_id
self._experts[config.name] = expert
# First expert is moderator
if i == 0:
self._moderator_name = config.name
self._status = BoardStatus.DISCUSSING
def _build_board_context(self, expert_configs: list[ExpertConfig]) -> str:
"""Build board context string for injection into Expert system prompts.
Emphasizes discussion mode, role differentiation, and discussion rules.
"""
lines = ["You are part of a Board Meeting (private board discussion mode)."]
for i, config in enumerate(expert_configs):
role = "Moderator" if i == 0 else "Member"
lines.append(
f"{role}: {config.name} ({config.persona[:100]}...)"
f" — Thinking: {config.thinking_style}"
f" — Speaking: {config.speaking_style}"
f" — Framework: {config.decision_framework}"
)
lines.append("")
lines.append("Board meeting rules:")
lines.append("- Each round, all members give speeches based on their persona and framework")
lines.append("- Moderator opens the discussion, summarizes each round, and gives final advice")
lines.append("- All experts see the full discussion history (shared context)")
lines.append("- Stay in character: think and speak as your persona would")
lines.append("- Be concise but insightful: 2-4 paragraphs per speech")
lines.append("- Build on or respectfully challenge previous speakers' points")
return "\n".join(lines)
async def add_to_history(
self,
round: int,
expert_name: str,
content: str,
role: str = "expert",
) -> None:
"""Add a speech to the discussion history.
Args:
round: Round number (1-indexed)
expert_name: Name of the expert (or "user" for interventions)
content: Speech content
role: "expert" | "moderator" | "user"
"""
entry = {
"round": round,
"expert_name": expert_name,
"content": content,
"timestamp": time.time(),
"role": role,
}
self._history.append(entry)
def get_history_text(self, up_to_round: int | None = None) -> str:
"""Get formatted discussion history for LLM prompt injection.
Args:
up_to_round: If provided, only include history up to this round (inclusive)
Returns:
Formatted history text
"""
if not self._history:
return ""
lines: list[str] = []
for entry in self._history:
if up_to_round is not None and entry["round"] > up_to_round:
continue
role_label = {
"moderator": "主持人小结",
"user": "用户干预",
"expert": "专家发言",
}.get(entry["role"], entry["role"])
lines.append(
f"[第{entry['round']}轮 | {entry['expert_name']} | {role_label}]\n"
f"{entry['content']}"
)
return "\n\n---\n\n".join(lines)
async def compress_history(self, moderator: Expert, llm_gateway: Any) -> None:
"""Compress discussion history when it exceeds token threshold.
The moderator summarizes each round's key points, replacing
verbose speeches with concise summaries.
Args:
moderator: Moderator expert
llm_gateway: LLM gateway for compression
"""
if not self._history or len(self._history) < 10:
return
# Group by round
rounds: dict[int, list[dict]] = {}
for entry in self._history:
rounds.setdefault(entry["round"], []).append(entry)
# Build compression prompt
history_text = self.get_history_text()
prompt = (
"你是私董会主持人。请压缩以下讨论历史,保留每轮的关键观点和核心论点,"
"去除冗余内容。每轮压缩为 2-3 句话。\n\n"
f"讨论历史:\n{history_text}\n\n"
"请输出压缩后的历史,保持原有的轮次结构和专家名。格式:\n"
"[第X轮 | 专家名] 压缩后的观点"
)
try:
response = await llm_gateway.chat(
messages=[{"role": "user", "content": prompt}],
model="default",
)
compressed = response.content.strip()
# Parse compressed history back to entries
# This is a best-effort compression; if parsing fails, keep original
new_history: list[dict[str, Any]] = []
current_round = 0
for line in compressed.split("\n"):
line = line.strip()
if not line:
continue
if line.startswith("[第") and "" in line:
# Parse round number
try:
round_str = line.split("")[1].split("")[0]
current_round = int(round_str)
name_part = line.split("|")[1].strip() if "|" in line else "unknown"
content_part = line.split("]", 1)[1].strip() if "]" in line else line
new_history.append({
"round": current_round,
"expert_name": name_part,
"content": content_part,
"timestamp": time.time(),
"role": "expert",
})
except (ValueError, IndexError):
continue
if new_history:
self._history = new_history
logger.info(f"History compressed: {len(rounds)} rounds, {len(new_history)} entries")
except Exception as e:
logger.warning(f"History compression failed: {e}, keeping original history")
async def add_user_intervention(self, content: str) -> None:
"""Add a user intervention message to the discussion.
The message will be visible to all experts in the next round.
Args:
content: User's intervention message
"""
self._user_interventions.append(content)
await self.add_to_history(
round=self._current_round,
expert_name="user",
content=content,
role="user",
)
# Broadcast user intervention event
await self._handoff_transport.send(
self._team_channel,
{
"type": "user_intervention",
"content": content,
"round": self._current_round,
},
)
def consume_user_interventions(self) -> list[str]:
"""Get and clear pending user interventions.
Returns:
List of user intervention messages
"""
interventions = self._user_interventions.copy()
self._user_interventions.clear()
return interventions
def increment_round(self) -> int:
"""Increment the current round counter.
Returns:
The new round number
"""
self._current_round += 1
return self._current_round
async def dissolve(self) -> None:
"""Dissolve the board. Experts are recycled, outputs preserved."""
for expert in self._experts.values():
if expert.is_active and self._pool:
await expert.destroy(self._pool)
self._experts.clear()
self._moderator_name = None
self._status = BoardStatus.DISSOLVED
self._handoff_transport.close()

View File

@ -0,0 +1,523 @@
"""BoardOrchestrator - 私董会讨论引擎
驱动 BoardTeam 执行多轮群聊式讨论
1. 主持人开场介绍议题和讨论规则
2. 循环 max_rounds
- 所有非主持人专家并行生成发言基于共享讨论历史 + 角色 prompt
- 主持人小结本轮要点
- 检查用户干预和停止命令
3. 主持人最终总结决策建议共识点分歧点
终止条件
- 正常终止达到最大轮次
- 用户终止用户发送 /stop
- 异常终止LLM 不可用或所有专家发言失败
"""
from __future__ import annotations
import asyncio
import logging
from typing import Any
from .expert import Expert
from .board import BoardTeam, BoardStatus
logger = logging.getLogger(__name__)
class BoardOrchestrator:
"""Board meeting orchestration engine.
The moderator (lead expert) facilitates the discussion:
- Opens with topic introduction
- Summarizes each round
- Gives final decision advice
Member experts give speeches each round based on shared history.
"""
STOP_COMMANDS = frozenset({"/stop", "停止讨论", "stop", "结束讨论"})
def __init__(self, team: BoardTeam) -> None:
self._team = team
async def execute(self, topic: str) -> dict[str, Any]:
"""Execute a board meeting discussion.
Flow:
1. Broadcast board_started event
2. Moderator opens the discussion
3. Loop max_rounds times:
- Parallel generate member speeches
- Moderator summarizes the round
- Check for user intervention / stop
4. Moderator gives final conclusion
5. Broadcast board_concluded event
Returns:
Dict with status, summary, decision_advice, total_rounds,
consensus_points, dissent_points
"""
moderator = self._team.moderator
if not moderator or not moderator.is_active:
active = self._team.active_experts
if not active:
return {
"status": "failed",
"summary": "",
"decision_advice": "",
"total_rounds": 0,
"consensus_points": [],
"dissent_points": [],
"error": "No active expert available",
}
# Promote first active expert to moderator
self._team._moderator_name = active[0].config.name
moderator = active[0]
logger.warning(
f"Moderator not available, falling back to '{moderator.config.name}'"
)
self._team.set_status(BoardStatus.DISCUSSING)
# 1. Broadcast board_started event
await self._broadcast_event(
"board_started",
{
"team_id": self._team.team_id,
"topic": topic,
"experts": [
{
"name": e.config.name,
"avatar": e.config.avatar,
"color": e.config.color,
"is_moderator": e.config.name == self._team._moderator_name,
"persona": e.config.persona[:100],
}
for e in self._team.active_experts
],
"max_rounds": self._team.max_rounds,
},
)
try:
# 2. Moderator opens the discussion
opening = await self._generate_moderator_opening(moderator, topic)
if opening:
await self._team.add_to_history(0, moderator.config.name, opening, "moderator")
await self._broadcast_event(
"expert_speech",
{
"expert_name": moderator.config.name,
"expert_avatar": moderator.config.avatar,
"expert_color": moderator.config.color,
"content": opening,
"round": 0,
"role": "moderator",
},
)
# 3. Discussion rounds
for round_num in range(1, self._team.max_rounds + 1):
self._team.increment_round()
# Check for stop command before starting the round
interventions = self._team.consume_user_interventions()
if self._has_stop_command(interventions):
logger.info(f"Discussion stopped by user at round {round_num}")
break
# Generate member speeches in parallel
members = self._team.member_experts
if members:
speech_results = await asyncio.gather(
*[self._generate_expert_speech(e, round_num) for e in members],
return_exceptions=True,
)
# Broadcast speeches in order (not parallel broadcast)
for expert, result in zip(members, speech_results):
if isinstance(result, Exception):
logger.warning(
f"Expert '{expert.config.name}' speech failed: {result}"
)
continue
await self._team.add_to_history(
round_num, expert.config.name, result, "expert"
)
await self._broadcast_event(
"expert_speech",
{
"expert_name": expert.config.name,
"expert_avatar": expert.config.avatar,
"expert_color": expert.config.color,
"content": result,
"round": round_num,
"role": "expert",
},
)
# Moderator summarizes the round
summary = await self._generate_moderator_summary(moderator, round_num)
if summary:
await self._team.add_to_history(
round_num, moderator.config.name, summary, "moderator"
)
await self._broadcast_event(
"round_summary",
{
"moderator_name": moderator.config.name,
"content": summary,
"round": round_num,
"continue": round_num < self._team.max_rounds,
},
)
# Check history length and compress if needed
gateway = self._get_llm_gateway(moderator)
if gateway and len(self._team.history) > 20:
await self._team.compress_history(moderator, gateway)
# 4. Final conclusion
self._team.set_status(BoardStatus.CONCLUDING)
conclusion = await self._generate_final_conclusion(moderator, topic)
self._team.set_status(BoardStatus.COMPLETED)
# 5. Broadcast board_concluded event
await self._broadcast_event(
"board_concluded",
{
"summary": conclusion.get("summary", ""),
"decision_advice": conclusion.get("decision_advice", ""),
"total_rounds": self._team.current_round,
"consensus_points": conclusion.get("consensus_points", []),
"dissent_points": conclusion.get("dissent_points", []),
},
)
return {
"status": "completed",
"summary": conclusion.get("summary", ""),
"decision_advice": conclusion.get("decision_advice", ""),
"total_rounds": self._team.current_round,
"consensus_points": conclusion.get("consensus_points", []),
"dissent_points": conclusion.get("dissent_points", []),
}
except Exception as e:
logger.error(f"Board meeting execution failed: {e}")
self._team.set_status(BoardStatus.DISSOLVED)
# Try to give a fallback conclusion
fallback = await self._generate_fallback_conclusion(moderator, topic)
await self._broadcast_event(
"board_concluded",
{
"summary": fallback.get("summary", ""),
"decision_advice": fallback.get("decision_advice", ""),
"total_rounds": self._team.current_round,
"consensus_points": [],
"dissent_points": [],
"error": str(e),
},
)
return {
"status": "failed",
"summary": fallback.get("summary", ""),
"decision_advice": fallback.get("decision_advice", ""),
"total_rounds": self._team.current_round,
"consensus_points": [],
"dissent_points": [],
"error": str(e),
}
async def _generate_moderator_opening(self, moderator: Expert, topic: str) -> str:
"""Generate moderator's opening speech.
The moderator introduces the topic and sets the stage for discussion.
"""
gateway = self._get_llm_gateway(moderator)
if not gateway:
return f"欢迎来到私董会。今天的讨论主题是:{topic}。请各位专家发表看法。"
prompt = (
f"你是私董会主持人 {moderator.config.name}\n"
f"你的角色:{moderator.config.persona}\n"
f"你的表达风格:{moderator.config.speaking_style}\n\n"
f"讨论主题:{topic}\n\n"
"请作为主持人开场,介绍议题并邀请各位专家发表看法。"
"开场应该简洁有力2-3 段话,点明讨论的核心问题。"
)
try:
response = await gateway.chat(
messages=[{"role": "user", "content": prompt}],
model="default",
)
return response.content.strip()
except Exception as e:
logger.warning(f"Moderator opening generation failed: {e}")
return f"欢迎来到私董会。今天的讨论主题是:{topic}。请各位专家发表看法。"
async def _generate_expert_speech(self, expert: Expert, round: int) -> str:
"""Generate an expert's speech for the current round.
The speech is based on:
- Expert's persona, thinking_style, speaking_style, decision_framework
- Full discussion history
- Current round / max rounds
"""
gateway = self._get_llm_gateway(expert)
if not gateway:
return f"[{expert.config.name} 因 LLM 不可用无法发言]"
history_text = self._team.get_history_text()
prompt = (
f"你是 {expert.config.name},正在参加私董会讨论。\n\n"
f"你的角色:{expert.config.persona}\n"
f"你的思维风格:{expert.config.thinking_style}\n"
f"你的表达风格:{expert.config.speaking_style}\n"
f"你的决策框架:{expert.config.decision_framework}\n\n"
f"讨论主题:{self._team.topic}\n"
f"当前轮次:第 {round} 轮 / 共 {self._team.max_rounds}\n\n"
)
if history_text:
prompt += f"之前的讨论历史:\n{history_text}\n\n"
prompt += (
"请基于你的角色和决策框架,就当前讨论主题发表你的看法。"
"要求:\n"
"- 保持角色一致性,用你的思维方式和表达风格发言\n"
"- 2-4 段话,简洁但有洞察力\n"
"- 可以引用或反驳之前发言者的观点\n"
"- 给出明确的立场或建议\n"
)
response = await gateway.chat(
messages=[{"role": "user", "content": prompt}],
model="default",
)
return response.content.strip()
async def _generate_moderator_summary(self, moderator: Expert, round: int) -> str:
"""Generate moderator's round summary.
The moderator summarizes the key points of the current round.
"""
gateway = self._get_llm_gateway(moderator)
if not gateway:
return f"[第 {round} 轮小结因 LLM 不可用无法生成]"
# Get only current round's speeches
round_history = [
h for h in self._team.history if h["round"] == round
]
if not round_history:
return ""
round_text = "\n\n".join(
f"[{h['expert_name']}]: {h['content']}" for h in round_history
)
prompt = (
f"你是私董会主持人 {moderator.config.name}\n"
f"你的角色:{moderator.config.persona}\n"
f"你的表达风格:{moderator.config.speaking_style}\n\n"
f"讨论主题:{self._team.topic}\n"
f"当前轮次:第 {round} 轮 / 共 {self._team.max_rounds}\n\n"
f"本轮发言:\n{round_text}\n\n"
"请作为主持人小结本轮讨论:\n"
"- 归纳各方核心观点2-3 句话)\n"
"- 指出共识点和分歧点\n"
"- 提示下一轮可以深入的方向\n"
"- 保持简洁3-5 句话\n"
)
try:
response = await gateway.chat(
messages=[{"role": "user", "content": prompt}],
model="default",
)
return response.content.strip()
except Exception as e:
logger.warning(f"Moderator summary generation failed: {e}")
return f"[第 {round} 轮讨论完成,主持人小结生成失败]"
async def _generate_final_conclusion(self, moderator: Expert, topic: str) -> dict[str, Any]:
"""Generate moderator's final conclusion.
The moderator gives:
- Overall summary of the discussion
- Decision advice
- Consensus points
- Dissent points
"""
gateway = self._get_llm_gateway(moderator)
if not gateway:
return {
"summary": "讨论已完成,但 LLM 不可用无法生成总结。",
"decision_advice": "建议参考讨论历史自行判断。",
"consensus_points": [],
"dissent_points": [],
}
history_text = self._team.get_history_text()
prompt = (
f"你是私董会主持人 {moderator.config.name}\n"
f"你的角色:{moderator.config.persona}\n"
f"你的表达风格:{moderator.config.speaking_style}\n"
f"你的决策框架:{moderator.config.decision_framework}\n\n"
f"讨论主题:{topic}\n"
f"总轮次:{self._team.current_round}\n\n"
f"完整讨论历史:\n{history_text}\n\n"
"请作为主持人给出最终总结。输出 JSON 格式:\n"
"```json\n"
"{\n"
' "summary": "整体讨论总结3-5句话",\n'
' "decision_advice": "基于讨论的决策建议,明确给出你的推荐",\n'
' "consensus_points": ["共识点1", "共识点2"],\n'
' "dissent_points": ["分歧点1", "分歧点2"]\n'
"}\n"
"```\n"
"只输出 JSON不要其他文字。"
)
try:
import json
import re
response = await gateway.chat(
messages=[{"role": "user", "content": prompt}],
model="default",
)
content = response.content.strip()
# Extract JSON from response
json_match = re.search(r"\{.*\}", content, re.DOTALL)
if json_match:
result = json.loads(json_match.group(0))
return {
"summary": result.get("summary", ""),
"decision_advice": result.get("decision_advice", ""),
"consensus_points": result.get("consensus_points", []),
"dissent_points": result.get("dissent_points", []),
}
# If JSON parsing fails, return raw content as summary
return {
"summary": content,
"decision_advice": "",
"consensus_points": [],
"dissent_points": [],
}
except Exception as e:
logger.warning(f"Final conclusion generation failed: {e}")
return {
"summary": f"讨论已完成({self._team.current_round}轮),总结生成失败。",
"decision_advice": "建议参考讨论历史自行判断。",
"consensus_points": [],
"dissent_points": [],
}
async def _generate_fallback_conclusion(self, moderator: Expert, topic: str) -> dict[str, Any]:
"""Generate a fallback conclusion when execution fails.
Uses existing discussion history to provide a basic summary.
"""
history_text = self._team.get_history_text()
if not history_text:
return {
"summary": "讨论未能正常完成,无历史记录。",
"decision_advice": "",
}
gateway = self._get_llm_gateway(moderator)
if not gateway:
# Return truncated history as summary
return {
"summary": f"讨论异常终止。已有历史({len(self._team.history)}条):\n"
+ history_text[:500],
"decision_advice": "建议参考讨论历史自行判断。",
}
prompt = (
f"你是私董会主持人 {moderator.config.name}\n"
f"讨论主题:{topic}\n"
f"讨论因异常终止,已完成 {self._team.current_round} 轮。\n\n"
f"已有讨论历史:\n{history_text}\n\n"
"请基于已有历史给出总结和决策建议。输出 JSON\n"
"```json\n"
'{"summary": "...", "decision_advice": "..."}\n'
"```\n"
)
try:
import json
import re
response = await gateway.chat(
messages=[{"role": "user", "content": prompt}],
model="default",
)
content = response.content.strip()
json_match = re.search(r"\{.*\}", content, re.DOTALL)
if json_match:
result = json.loads(json_match.group(0))
return {
"summary": result.get("summary", content),
"decision_advice": result.get("decision_advice", ""),
}
return {"summary": content, "decision_advice": ""}
except Exception:
return {
"summary": f"讨论异常终止,已完成 {self._team.current_round} 轮。",
"decision_advice": "",
}
def _has_stop_command(self, interventions: list[str]) -> bool:
"""Check if any user intervention contains a stop command."""
for msg in interventions:
msg_lower = msg.strip().lower()
if msg_lower in self.STOP_COMMANDS:
return True
return False
def _get_llm_gateway(self, expert: Expert | None = None) -> Any:
"""Get LLM gateway from the given expert or the moderator's agent.
Falls back to other active experts if the primary target has no gateway.
"""
target = expert or self._team.moderator
if target and hasattr(target, "agent") and hasattr(target.agent, "_llm_gateway"):
gateway = target.agent._llm_gateway
if gateway is not None:
return gateway
# Fallback: try first active expert with a gateway
for exp in self._team.active_experts:
if hasattr(exp, "agent") and hasattr(exp.agent, "_llm_gateway"):
gateway = exp.agent._llm_gateway
if gateway is not None:
return gateway
return None
async def _broadcast_event(self, event_type: str, data: dict[str, Any]) -> None:
"""Broadcast a board event to the team channel.
Events are emitted via handoff_transport for WebSocket relay.
"""
if self._team.handoff_transport:
try:
await self._team.handoff_transport.send(
self._team.team_channel, {"type": event_type, **data}
)
except Exception as e:
logger.warning(f"Failed to broadcast event '{event_type}': {e}")

View File

@ -0,0 +1,179 @@
"""BoardRouter - 私董会讨论模式路由
解析 @board 前缀支持指定专家或使用默认私董会模板
支持格式
- @board:expert1,expert2 讨论主题 指定专家
- @board 讨论主题 使用默认 private_board 模板
- @board:private_board 讨论主题 显式使用默认模板
"""
from __future__ import annotations
import copy
import logging
import re
from dataclasses import dataclass, field
from .config import ExpertConfig
from .registry import ExpertTemplateRegistry
logger = logging.getLogger(__name__)
# Pattern to match @board or @board:expert1,expert2 prefix
BOARD_PREFIX_PATTERN = re.compile(r"^@board(?::(\S+))?\s*(.*)", re.DOTALL)
# Valid expert name: alphanumeric, underscore, hyphen, 1-64 chars
_EXPERT_NAME_RE = re.compile(r"^[a-zA-Z0-9_-]{1,64}$")
MAX_EXPERTS = 10 # Maximum number of experts in a board
DEFAULT_TEMPLATE = "private_board"
@dataclass
class BoardRoutingResult:
"""Result of board routing resolution.
Attributes:
matched: Whether the input matched @board prefix
board_mode: Whether board mode is activated
specified_experts: List of expert names specified by user
topic: Discussion topic extracted from input
use_default_template: Whether to use default private_board template
match_method: How the match was made ("explicit_board" | "default_template" | "")
"""
matched: bool = False
board_mode: bool = False
specified_experts: list[str] = field(default_factory=list)
topic: str = ""
use_default_template: bool = False
match_method: str = ""
class BoardRouter:
"""Routes user input to Board Meeting mode via @board prefix.
Supports:
- @board prefix trigger board mode with default private_board template
- @board:expert1,expert2 specify board members by name
- @board:private_board explicitly use default template
"""
def __init__(self, template_registry: ExpertTemplateRegistry | None = None):
self._registry = template_registry or ExpertTemplateRegistry()
def resolve(self, content: str) -> BoardRoutingResult:
"""Resolve user input to a BoardRoutingResult.
Args:
content: User's input message
Returns:
BoardRoutingResult with routing decision
"""
result = BoardRoutingResult()
match = BOARD_PREFIX_PATTERN.match(content.strip())
if not match:
result.matched = False
result.board_mode = False
result.topic = content.strip()
return result
expert_list_str = match.group(1) # e.g., "expert1,expert2" or None
topic = match.group(2).strip() # The actual topic content
result.matched = True
result.board_mode = True
result.topic = topic if topic else ""
result.match_method = "explicit_board"
if expert_list_str:
# Check if user specified the default template name
if expert_list_str.strip() == DEFAULT_TEMPLATE:
result.use_default_template = True
result.specified_experts = self._load_default_template_members()
else:
# User specified expert names — validate and limit
raw_names = [name.strip() for name in expert_list_str.split(",")]
valid_names = [n for n in raw_names if _EXPERT_NAME_RE.match(n)]
if len(valid_names) != len(raw_names):
invalid = set(raw_names) - set(valid_names)
logger.warning(f"Invalid expert names rejected: {invalid}")
if valid_names:
result.specified_experts = valid_names[:MAX_EXPERTS]
result.use_default_template = False
else:
# All names invalid — fall back to default template
logger.warning(
"All expert names invalid, falling back to default template"
)
result.use_default_template = True
result.specified_experts = self._load_default_template_members()
else:
# No specific experts — use default template
result.use_default_template = True
result.specified_experts = self._load_default_template_members()
return result
def resolve_expert_configs(self, specified_experts: list[str]) -> list[ExpertConfig]:
"""Resolve expert names to ExpertConfig instances.
For names that match templates, use the template config.
For names that don't match, create a dynamic ExpertConfig.
The first expert is designated as moderator (is_lead=True).
"""
configs: list[ExpertConfig] = []
for i, name in enumerate(specified_experts):
if not _EXPERT_NAME_RE.match(name):
logger.warning(f"Skipping invalid expert name: {name}")
continue
template = self._registry.get(name)
if template:
# Deep-copy to avoid mutating the shared template config
config = copy.deepcopy(template.config)
# Override is_lead: first expert is moderator
config.is_lead = i == 0
configs.append(config)
else:
# Dynamic generation — create a basic ExpertConfig
config = ExpertConfig(
name=name,
agent_type="expert",
persona=f"Expert in {name}",
thinking_style="analytical",
bound_skills=[],
is_lead=(i == 0),
task_mode="llm_generate",
prompt={"identity": f"Expert in {name}"},
)
configs.append(config)
# Ensure at least one expert is lead
if configs and not any(c.is_lead for c in configs):
configs[0].is_lead = True
return configs
def _load_default_template_members(self) -> list[str]:
"""Load member list from the default private_board template.
The private_board template is stored as an ExpertTemplate with
a special 'members' field in its config metadata.
Falls back to a hardcoded list if the template is not found.
"""
template = self._registry.get(DEFAULT_TEMPLATE)
if template:
# The private_board template stores members in config.bound_skills
# as a reuse of existing field (avoids schema changes)
members = template.config.bound_skills
if members:
return members[:MAX_EXPERTS]
# Fallback default members
return ["elon_musk", "jeff_bezos", "allenzhang", "charlie_munger", "paul_graham"]

View File

@ -39,6 +39,9 @@ class ExpertConfig(AgentConfig):
avatar: str = "",
color: str = "#1890ff",
is_lead: bool = False,
# Board Meeting 模式字段
speaking_style: str = "",
decision_framework: str = "",
):
super().__init__(
name=name,
@ -63,6 +66,8 @@ class ExpertConfig(AgentConfig):
self.avatar = avatar
self.color = color
self.is_lead = is_lead
self.speaking_style = speaking_style
self.decision_framework = decision_framework
@classmethod
def from_dict(cls, data: dict[str, Any]) -> ExpertConfig:
@ -89,6 +94,8 @@ class ExpertConfig(AgentConfig):
avatar=data.get("avatar", ""),
color=data.get("color", "#1890ff"),
is_lead=data.get("is_lead", False),
speaking_style=data.get("speaking_style", ""),
decision_framework=data.get("decision_framework", ""),
)
def to_dict(self) -> dict[str, Any]:
@ -101,6 +108,8 @@ class ExpertConfig(AgentConfig):
d["avatar"] = self.avatar
d["color"] = self.color
d["is_lead"] = self.is_lead
d["speaking_style"] = self.speaking_style
d["decision_framework"] = self.decision_framework
return d

View File

@ -326,6 +326,50 @@ async def lifespan(app: FastAPI):
memory_store._on_change = _on_memory_change
app.state.memory_store = memory_store
# Load ExpertTemplates from configured paths (supports @board meeting mode)
# This runs regardless of GUI mode so @board works in API-only mode too.
try:
from agentkit.experts.registry import ExpertTemplateRegistry
expert_registry = ExpertTemplateRegistry()
# Always try to load from the default configs/experts/ directory
default_experts_dir = os.path.join(
os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(__file__)))),
"configs",
"experts",
)
expert_dirs: list[str] = [default_experts_dir]
# Append user-configured paths from agentkit.yaml
if server_config and getattr(server_config, "expert_paths", None):
expert_dirs.extend(server_config.expert_paths)
total_loaded = 0
for experts_dir in expert_dirs:
if not experts_dir:
continue
from pathlib import Path as _P
p = _P(experts_dir)
if p.is_dir():
loaded = expert_registry.load_from_directory(str(p))
if loaded:
logger.info(
f"Loaded {len(loaded)} ExpertTemplates from {p}"
)
total_loaded += len(loaded)
app.state.expert_template_registry = expert_registry
if total_loaded:
logger.info(f"Total {total_loaded} ExpertTemplates registered for @board mode")
except Exception as e:
logger.warning(f"Failed to load ExpertTemplates: {e}")
# Ensure app.state.expert_template_registry always exists (empty registry)
from agentkit.experts.registry import ExpertTemplateRegistry
app.state.expert_template_registry = ExpertTemplateRegistry()
yield
# Shutdown
@ -595,6 +639,11 @@ def create_app(
)
app.state.request_preprocessor = request_preprocessor
# Initialize ExpertTemplateRegistry (populated in lifespan with YAML configs)
from agentkit.experts.registry import ExpertTemplateRegistry
app.state.expert_template_registry = ExpertTemplateRegistry()
# Initialize OrganizationContext from AgentPool + SkillRegistry
from agentkit.org.context import OrganizationContext

View File

@ -114,6 +114,8 @@ class ServerConfig:
usage_store: dict[str, Any] | None = None,
cascade_store: dict[str, Any] | None = None,
evolution: dict[str, Any] | None = None,
expert_paths: list[str] | None = None,
board: dict[str, Any] | None = None,
on_change: Callable[["ServerConfig"], None] | None = None,
):
self.host = host
@ -140,6 +142,8 @@ class ServerConfig:
self.usage_store = usage_store or {}
self.cascade_store = cascade_store or {}
self.evolution = evolution or {}
self.expert_paths = expert_paths or []
self.board = board or {}
self.on_change = on_change
# Config watching state
@ -216,6 +220,13 @@ class ServerConfig:
# Evolution store config
evolution_data = data.get("evolution", {})
# Expert templates config (paths to YAML files defining ExpertTemplates)
experts_data = data.get("experts", {})
expert_paths = experts_data.get("paths", [])
# Board meeting config (max_rounds, default_template, etc.)
board_data = data.get("board", {})
return cls(
host=server.get("host", "0.0.0.0"),
port=server.get("port", 8001),
@ -241,6 +252,8 @@ class ServerConfig:
usage_store=usage_store_data,
cascade_store=cascade_store_data,
evolution=evolution_data,
expert_paths=expert_paths,
board=board_data,
)
@staticmethod
@ -436,6 +449,8 @@ class ServerConfig:
self.marketplace = new_config.marketplace
self.alignment = new_config.alignment
self.router = new_config.router
self.expert_paths = new_config.expert_paths
self.board = new_config.board
self._last_mtime = new_config._last_mtime
logger.info(f"Config reloaded from {path}")

View File

@ -88,6 +88,7 @@ declare module 'vue' {
SplashScreen: typeof import('./src/components/layout/SplashScreen.vue')['default']
SplitPane: typeof import('./src/components/layout/SplitPane.vue')['default']
TerminalEmulator: typeof import('./src/components/terminal/TerminalEmulator.vue')['default']
ThinkingBlock: typeof import('./src/components/chat/ThinkingBlock.vue')['default']
TitleBar: typeof import('./src/components/layout/TitleBar.vue')['default']
ToolCallCard: typeof import('./src/components/chat/ToolCallCard.vue')['default']
ToolCallIndicator: typeof import('./src/components/chat/ToolCallIndicator.vue')['default']

View File

@ -44,7 +44,10 @@ export interface IChatMessage {
expert_id?: string
expert_name?: string
expert_color?: string
message_type?: 'chat' | 'handoff' | 'assist_request' | 'plan_update' | 'milestone'
expert_avatar?: string
message_type?: 'chat' | 'handoff' | 'assist_request' | 'plan_update' | 'milestone' | 'board_speech' | 'board_summary' | 'board_conclusion'
board_round?: number
board_role?: 'moderator' | 'expert' | 'user' | 'summary'
}
/** Conversation with messages */
@ -103,6 +106,12 @@ export type WsServerMessage =
| { type: 'plan_update'; data: { plan_phases: ITeamPlanPhase[] } }
| { type: 'team_synthesis'; data: { content: string } }
| { type: 'team_dissolved'; data: { team_id: string } }
// Board Meeting 模式事件
| { type: 'board_started'; data: IBoardStartedData }
| { type: 'expert_speech'; data: IExpertSpeechData }
| { type: 'round_summary'; data: IRoundSummaryData }
| { type: 'user_intervention'; data: IUserInterventionData }
| { type: 'board_concluded'; data: IBoardConcludedData }
/** Expert info within a team */
export interface IExpertInfo {
@ -135,6 +144,74 @@ export interface IExpertTeamState {
lead_expert: string
}
// ── Board Meeting 模式类型 ────────────────────────────────────────────
/** Board meeting expert info (lighter than IExpertInfo) */
export interface IBoardExpert {
name: string
avatar: string
color: string
is_moderator: boolean
persona: string
}
/** board_started event payload */
export interface IBoardStartedData {
team_id: string
topic: string
experts: IBoardExpert[]
max_rounds: number
}
/** expert_speech event payload */
export interface IExpertSpeechData {
expert_name: string
expert_avatar: string
expert_color: string
content: string
round: number
role: 'moderator' | 'expert'
}
/** round_summary event payload */
export interface IRoundSummaryData {
moderator_name: string
content: string
round: number
continue: boolean
}
/** user_intervention event payload */
export interface IUserInterventionData {
content: string
round: number
}
/** board_concluded event payload */
export interface IBoardConcludedData {
summary: string
decision_advice: string
total_rounds: number
consensus_points: string[]
dissent_points: string[]
error?: string
}
/** Board meeting status (matches backend BoardStatus enum) */
export type BoardStatus = 'forming' | 'discussing' | 'concluding' | 'completed' | 'dissolved'
/** Board message entry for group chat display */
export interface IBoardMessage {
id: string
expert_name: string
expert_avatar: string
expert_color: string
content: string
round: number
role: 'moderator' | 'expert' | 'user' | 'summary'
timestamp: number
}
/** API error */
export interface IApiError {
status: number

View File

@ -0,0 +1,146 @@
<template>
<div v-if="chatStore.boardState" class="board-status-view">
<div class="board-status-view__header">
<div class="board-status-view__title">
<span class="board-status-view__icon">🏛</span>
<span class="board-status-view__label">私董会</span>
<a-tag v-if="chatStore.boardState.status === 'discussing'" color="processing" size="small">
讨论中
</a-tag>
<a-tag v-else-if="chatStore.boardState.status === 'concluding'" color="warning" size="small">
总结中
</a-tag>
<a-tag v-else-if="chatStore.boardState.status === 'completed'" color="success" size="small">
已完成
</a-tag>
</div>
<div class="board-status-view__topic">
{{ chatStore.boardState.topic }}
</div>
</div>
<div class="board-status-view__experts">
<div
v-for="expert in chatStore.boardState.experts"
:key="expert.name"
class="board-status-view__expert-chip"
:style="{ borderColor: expert.color }"
>
<span class="board-status-view__expert-avatar">{{ expert.avatar }}</span>
<span class="board-status-view__expert-name">{{ expert.name }}</span>
<a-tag v-if="expert.is_moderator" color="purple" size="small">主持人</a-tag>
</div>
</div>
<div class="board-status-view__progress">
<span class="board-status-view__round-info">
{{ chatStore.boardState.current_round }} / {{ chatStore.boardState.max_rounds }}
</span>
<a-progress
:percent="progressPercent"
size="small"
:show-info="false"
:stroke-color="'#8E44AD'"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { Progress as AProgress, Tag as ATag } from 'ant-design-vue'
import { useChatStore } from '@/stores/chat'
const chatStore = useChatStore()
const progressPercent = computed(() => {
if (!chatStore.boardState) return 0
const { current_round, max_rounds } = chatStore.boardState
if (max_rounds <= 0) return 0
return Math.min(100, Math.round((current_round / max_rounds) * 100))
})
</script>
<style scoped>
.board-status-view {
margin: 0 var(--space-4);
padding: var(--space-3) var(--space-4);
background: linear-gradient(135deg, rgba(142, 68, 173, 0.08), rgba(142, 68, 173, 0.03));
border: 1px solid rgba(142, 68, 173, 0.2);
border-radius: var(--radius-lg);
margin-bottom: var(--space-2);
}
.board-status-view__header {
display: flex;
flex-direction: column;
gap: var(--space-1);
margin-bottom: var(--space-2);
}
.board-status-view__title {
display: flex;
align-items: center;
gap: var(--space-2);
}
.board-status-view__icon {
font-size: 18px;
}
.board-status-view__label {
font-weight: var(--font-weight-semibold);
font-size: var(--font-base);
color: var(--text-primary);
}
.board-status-view__topic {
font-size: var(--font-sm);
color: var(--text-secondary);
padding-left: 26px;
}
.board-status-view__experts {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
margin-bottom: var(--space-2);
}
.board-status-view__expert-chip {
display: flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border: 1px solid var(--border-color);
border-radius: var(--radius-full);
background: var(--bg-primary);
font-size: var(--font-xs);
}
.board-status-view__expert-avatar {
font-size: 16px;
}
.board-status-view__expert-name {
color: var(--text-primary);
font-weight: var(--font-weight-medium);
}
.board-status-view__progress {
display: flex;
align-items: center;
gap: var(--space-2);
}
.board-status-view__round-info {
font-size: var(--font-xs);
color: var(--text-tertiary);
white-space: nowrap;
min-width: 80px;
}
.board-status-view :deep(.ant-progress) {
flex: 1;
}
</style>

View File

@ -19,10 +19,13 @@
<div class="chat-message__body">
<!-- Expert message wrapper -->
<ExpertMessage
v-if="message.expert_id"
v-if="message.expert_id || message.expert_name"
:expert-name="message.expert_name || ''"
:expert-color="message.expert_color || '#1890ff'"
:expert-avatar="message.expert_avatar || ''"
:is-lead="false"
:is-moderator="message.board_role === 'moderator'"
:board-round="message.board_round"
:message-type="message.message_type || 'chat'"
>
<div class="chat-message__content chat-message__content--assistant">

View File

@ -1,12 +1,15 @@
<template>
<div class="expert-message" :style="{ borderLeftColor: expertColor }">
<div v-if="showExpertHeader" class="expert-message__header">
<div class="expert-message__avatar" :style="{ backgroundColor: expertColor }">
<div v-if="expertAvatar" class="expert-message__emoji-avatar">{{ expertAvatar }}</div>
<div v-else class="expert-message__avatar" :style="{ backgroundColor: expertColor }">
{{ expertInitial }}
</div>
<span class="expert-message__name">{{ expertName }}</span>
<a-tag v-if="isLead" color="gold" size="small">Lead</a-tag>
<a-tag v-if="messageType !== 'chat'" :color="messageTypeColor" size="small">
<a-tag v-if="isModerator" color="purple" size="small">主持人</a-tag>
<a-tag v-if="boardRound" :color="messageTypeColor" size="small">{{ boardRound }}</a-tag>
<a-tag v-else-if="messageType !== 'chat'" :color="messageTypeColor" size="small">
{{ messageTypeLabel }}
</a-tag>
</div>
@ -23,11 +26,17 @@ import { Tag as ATag } from 'ant-design-vue'
const props = withDefaults(defineProps<{
expertName: string
expertColor: string
expertAvatar?: string
isLead?: boolean
messageType?: 'chat' | 'handoff' | 'assist_request' | 'plan_update' | 'milestone'
isModerator?: boolean
boardRound?: number
messageType?: 'chat' | 'handoff' | 'assist_request' | 'plan_update' | 'milestone' | 'board_speech' | 'board_summary' | 'board_conclusion'
showExpertHeader?: boolean
}>(), {
expertAvatar: '',
isLead: false,
isModerator: false,
boardRound: undefined,
messageType: 'chat',
showExpertHeader: true,
})
@ -40,6 +49,9 @@ const messageTypeLabel = computed(() => {
assist_request: '请求协助',
plan_update: '计划更新',
milestone: '里程碑',
board_speech: '发言',
board_summary: '小结',
board_conclusion: '总结',
}
return labels[props.messageType] || ''
})
@ -50,6 +62,9 @@ const messageTypeColor = computed(() => {
assist_request: 'green',
plan_update: 'orange',
milestone: 'purple',
board_speech: 'cyan',
board_summary: 'geekblue',
board_conclusion: 'magenta',
}
return colors[props.messageType] || 'default'
})
@ -82,6 +97,16 @@ const messageTypeColor = computed(() => {
flex-shrink: 0;
}
.expert-message__emoji-avatar {
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
flex-shrink: 0;
}
.expert-message__name {
font-weight: var(--font-weight-medium);
font-size: var(--font-sm);

View File

@ -8,6 +8,11 @@ import type {
IChatRequest,
WsClientMessage,
IExpertTeamState,
IBoardStartedData,
IExpertSpeechData,
IRoundSummaryData,
IUserInterventionData,
IBoardConcludedData,
} from '@/api/types'
function generateId(): string {
@ -23,6 +28,17 @@ export const useChatStore = defineStore('chat', () => {
const ws = ref<WebSocket | null>(null)
const streamingSteps = ref<string[]>([])
// Board Meeting state (transient, only active during a board discussion)
const boardState = ref<{
topic: string
experts: Array<{ name: string; avatar: string; color: string; is_moderator: boolean }>
max_rounds: number
current_round: number
status: 'discussing' | 'concluding' | 'completed' | 'dissolved'
} | null>(null)
const isBoardMode = computed(() => boardState.value !== null && boardState.value.status === 'discussing')
// --- Getters ---
const currentConversation = computed<IConversation | undefined>(() => {
return conversations.value.find((c) => c.id === currentConversationId.value)
@ -644,6 +660,117 @@ export const useChatStore = defineStore('chat', () => {
streamingSteps.value.push('专家团队已解散')
break
}
// ── Board Meeting 模式事件 ────────────────────────────────────────
case 'board_started': {
const data = payload as IBoardStartedData
// Initialize board state
boardState.value = {
topic: data.topic,
experts: data.experts.map((e) => ({
name: e.name,
avatar: e.avatar,
color: e.color,
is_moderator: e.is_moderator,
})),
max_rounds: data.max_rounds,
current_round: 0,
status: 'discussing',
}
streamingSteps.value.push(
`私董会已开启: 主题「${data.topic}」, ${data.experts.length} 位专家, 最多 ${data.max_rounds}`
)
// Push a system-style message to indicate board start
const conversationId = currentConversationId.value
if (conversationId) {
const startMsg: IChatMessage = {
id: generateId(),
role: 'assistant',
content: `🏛️ **私董会开始**\n\n**主题**: ${data.topic}\n**专家**: ${data.experts
.map((e) => `${e.avatar} ${e.name}${e.is_moderator ? ' (主持人)' : ''}`)
.join(', ')}\n****: ${data.max_rounds}`,
timestamp: new Date().toISOString(),
status: 'completed',
message_type: 'milestone',
}
appendMessage(conversationId, startMsg)
}
break
}
case 'expert_speech': {
const data = payload as IExpertSpeechData
// Update current round in board state
if (boardState.value && data.round > boardState.value.current_round) {
boardState.value.current_round = data.round
}
const conversationId = currentConversationId.value
if (!conversationId) break
const speechMsg: IChatMessage = {
id: generateId(),
role: 'assistant',
content: data.content || '',
timestamp: new Date().toISOString(),
status: 'completed',
expert_name: data.expert_name,
expert_color: data.expert_color,
expert_avatar: data.expert_avatar,
message_type: 'board_speech',
board_round: data.round,
board_role: data.role,
}
appendMessage(conversationId, speechMsg)
streamingSteps.value.push(
`${data.expert_avatar} ${data.expert_name} (第${data.round}${data.role === 'moderator' ? '·主持' : ''})`
)
break
}
case 'round_summary': {
const data = payload as IRoundSummaryData
const conversationId = currentConversationId.value
if (!conversationId) break
const summaryMsg: IChatMessage = {
id: generateId(),
role: 'assistant',
content: data.content || '',
timestamp: new Date().toISOString(),
status: 'completed',
expert_name: data.moderator_name,
message_type: 'board_summary',
board_round: data.round,
board_role: 'summary',
}
appendMessage(conversationId, summaryMsg)
streamingSteps.value.push(`${data.round}轮小结${data.continue ? '(继续讨论)' : '(即将结束)'}`)
break
}
case 'user_intervention': {
const data = payload as IUserInterventionData
streamingSteps.value.push(`用户干预: ${data.content.slice(0, 50)}...`)
break
}
case 'board_concluded': {
const data = payload as IBoardConcludedData
// Update board state to completed
if (boardState.value) {
boardState.value.status = 'completed'
}
streamingSteps.value.push(
`私董会结束: ${data.total_rounds} 轮讨论${data.error ? ' (异常)' : ''}`
)
// The final_answer event will carry the formatted conclusion,
// so we don't need to add a separate message here.
// The conclusion is already persisted by the backend.
// Clear board state after a short delay to allow UI to update
setTimeout(() => {
boardState.value = null
}, 1000)
break
}
}
}
@ -678,9 +805,11 @@ export const useChatStore = defineStore('chat', () => {
isLoading,
isWsConnected,
streamingSteps,
boardState,
// Getters
currentConversation,
currentMessages,
isBoardMode,
// Actions
loadConversations,
selectConversation,

View File

@ -17,6 +17,7 @@
</div>
<template v-else>
<ExpertTeamView />
<BoardStatusView />
<div class="chat-view__messages" ref="messagesContainer">
<div v-if="chatStore.currentMessages.length === 0" class="chat-view__welcome">
<div class="chat-view__welcome-inner">
@ -73,6 +74,7 @@ import ChatSidebar from '@/components/chat/ChatSidebar.vue'
import ChatMessage from '@/components/chat/ChatMessage.vue'
import ChatInput from '@/components/chat/ChatInput.vue'
import ExpertTeamView from '@/components/chat/ExpertTeamView.vue'
import BoardStatusView from '@/components/chat/BoardStatusView.vue'
const ATypographyText = ATypography.Text
@ -83,6 +85,7 @@ const welcomeHints = [
'智能路由 — 自动匹配最优技能',
'工具调用 — 读写文件、执行命令',
'流式响应 — 实时查看推理过程',
'私董会 — 输入 @board 召集专家团讨论',
]
onMounted(async () => {
@ -245,6 +248,7 @@ function handleSend(message: string, model?: string): void {
.chat-view__hint:nth-child(1) { animation-delay: 0.3s; }
.chat-view__hint:nth-child(2) { animation-delay: 0.4s; }
.chat-view__hint:nth-child(3) { animation-delay: 0.5s; }
.chat-view__hint:nth-child(4) { animation-delay: 0.6s; }
.chat-view__hint-icon {
font-size: 14px;

View File

@ -110,6 +110,12 @@ _VALID_TEAM_EVENT_TYPES = frozenset(
"phase_completed",
"phase_failed",
"replanning",
# Board Meeting 模式事件
"board_started",
"expert_speech",
"round_summary",
"user_intervention",
"board_concluded",
}
)
@ -146,6 +152,161 @@ def _get_session_manager(request: Request) -> SessionManager:
return request.app.state.session_manager
async def _execute_board_meeting(
websocket: WebSocket,
session_id: str,
content: str,
sm: SessionManager,
) -> bool:
"""Intercept @board prefix and execute a board meeting discussion.
Returns True if the input was handled as a board meeting (caller should return),
False if the input should continue through the normal chat pipeline.
Flow:
1. Resolve @board routing via BoardRouter
2. Create BoardTeam with expert configs
3. Register handoff_transport handler to relay events to WebSocket
4. Execute BoardOrchestrator
5. Send final conclusion as final_answer
6. Persist user topic + final summary to session history
"""
from agentkit.experts.board_router import BoardRouter
from agentkit.experts.board import BoardTeam
from agentkit.experts.board_orchestrator import BoardOrchestrator
app_state = websocket.app.state
# Resolve ExpertTemplateRegistry from app.state (loaded at startup)
template_registry = getattr(app_state, "expert_template_registry", None)
if template_registry is None:
from agentkit.experts.registry import ExpertTemplateRegistry
template_registry = ExpertTemplateRegistry()
board_router = BoardRouter(template_registry=template_registry)
routing_result = board_router.resolve(content)
if not routing_result.matched:
return False # Not a @board input, continue normal pipeline
if not routing_result.topic:
await websocket.send_json(
{"type": "error", "data": {"message": "私董会需要一个讨论主题,例如:@board 如何看待 AI 未来"}}
)
return True
# Resolve expert configs from specified experts or default template
expert_configs = board_router.resolve_expert_configs(routing_result.specified_experts)
if not expert_configs:
await websocket.send_json(
{"type": "error", "data": {"message": "无法解析私董会成员,请检查专家名称或模板配置"}}
)
return True
# Read board config from server_config if available
max_rounds = 5
server_config = getattr(app_state, "server_config", None)
if server_config is not None:
board_cfg = getattr(server_config, "board", None) or {}
if isinstance(board_cfg, dict):
max_rounds = int(board_cfg.get("max_rounds", 5))
# Create BoardTeam
team = BoardTeam(
pool=app_state.agent_pool,
template_registry=template_registry,
max_rounds=max_rounds,
)
# Register handoff_transport handler to relay board events to WebSocket
async def _relay_board_event(message: dict) -> None:
msg_type = message.get("type")
if not msg_type:
return
# Strip internal fields, keep only event data
event_data = {k: v for k, v in message.items() if k != "type"}
await emit_team_event(websocket, msg_type, event_data)
team.handoff_transport.register_handler(team.team_channel, _relay_board_event)
# Append user topic to session history
await sm.append_message(
session_id=session_id,
role=MessageRole.USER,
content=content,
)
try:
await team.create_board(topic=routing_result.topic, expert_configs=expert_configs)
orchestrator = BoardOrchestrator(team=team)
result = await orchestrator.execute(routing_result.topic)
except Exception as e:
logger.error(f"Board meeting failed for session {session_id}: {e}", exc_info=True)
await websocket.send_json(
{"type": "error", "data": {"message": f"私董会执行失败: {str(e)[:200]}"}}
)
try:
await team.dissolve()
except Exception:
pass
return True
finally:
# Always remove handler to avoid leaks
try:
team.handoff_transport._handlers.pop(team.team_channel, None)
except Exception:
pass
# Build final answer text from conclusion
summary = result.get("summary", "")
decision_advice = result.get("decision_advice", "")
consensus_points = result.get("consensus_points", []) or []
dissent_points = result.get("dissent_points", []) or []
total_rounds = result.get("total_rounds", 0)
final_parts: list[str] = []
if summary:
final_parts.append(f"## 讨论总结\n\n{summary}")
if decision_advice:
final_parts.append(f"## 决策建议\n\n{decision_advice}")
if consensus_points:
final_parts.append(
"## 共识点\n\n" + "\n".join(f"- {p}" for p in consensus_points)
)
if dissent_points:
final_parts.append(
"## 分歧点\n\n" + "\n".join(f"- {p}" for p in dissent_points)
)
final_parts.append(f"\n\n_共进行 {total_rounds} 轮讨论_")
final_content = "\n\n".join(final_parts)
await websocket.send_json(
{
"type": "final_answer",
"content": final_content,
"is_final": True,
}
)
# Persist final summary as assistant message
await sm.append_message(
session_id=session_id,
role=MessageRole.ASSISTANT,
content=final_content,
agent_name="board_meeting",
)
# Dissolve the team to release expert agents
try:
await team.dissolve()
except Exception as e:
logger.warning(f"Board team dissolve failed: {e}")
return True
def _session_to_response(session) -> SessionResponse:
return SessionResponse(
session_id=session.session_id,
@ -473,9 +634,16 @@ async def _handle_chat_message(
"""Handle a user message: append to session, execute Agent, stream events.
Uses RequestPreprocessor for minimal preprocessing: @skill prefix + greeting regex + REACT.
Board Meeting mode: @board prefix is intercepted before RequestPreprocessor
and routed to BoardOrchestrator for multi-round group discussion.
"""
from agentkit.chat.request_preprocessor import RequestPreprocessor
# Board Meeting mode: intercept @board prefix before any other preprocessing
if await _execute_board_meeting(websocket, session_id, content, sm):
return
# Resolve Agent first (needed for default tools/prompt)
pool = websocket.app.state.agent_pool
session = await sm.get_session(session_id)

View File

@ -5,7 +5,7 @@
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Fischer AgentKit</title>
<script type="module" crossorigin src="/assets/index-DtvSr7Lz.js"></script>
<script type="module" crossorigin src="/assets/index-CnfHmcYr.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DnToQpcu.css">
</head>
<body>

File diff suppressed because it is too large Load Diff

View File

@ -1,11 +1,11 @@
# AgentKit 能力基准测试报告
## 测试概要
- 时间: 2026-06-17T05:29:35.443678+00:00
- 时间: 2026-06-17T15:47:33.591101+00:00
- 版本: 0.1.0
- 模式: all
- 模式: mock
- 运行次数: 1
- 总体准确率: 98.4% ± 0.0%
- 总体准确率: 100.0% ± 0.0%
## 与行业 Benchmark 对比
@ -26,9 +26,9 @@
| Precision | 100.0% |
| Recall | 100.0% |
| F1 | 100.0% |
| Latency p50 | 0.02ms |
| Latency p50 | 0.01ms |
| Latency p95 | 0.07ms |
| Latency p99 | 0.13ms |
| Latency p99 | 0.11ms |
| Consistency | 100.0% |
| Total / Pass / Fail | 15 / 15 / 0 |
@ -58,9 +58,9 @@
| Precision | 100.0% |
| Recall | 100.0% |
| F1 | 100.0% |
| Latency p50 | 0.04ms |
| Latency p95 | 0.05ms |
| Latency p99 | 0.05ms |
| Latency p50 | 0.01ms |
| Latency p95 | 0.03ms |
| Latency p99 | 0.03ms |
| Consistency | 100.0% |
| Total / Pass / Fail | 5 / 5 / 0 |
@ -91,9 +91,9 @@
| Precision | 0.0% |
| Recall | 0.0% |
| F1 | 0.0% |
| Latency p50 | 0.43ms |
| Latency p95 | 0.79ms |
| Latency p99 | 0.85ms |
| Latency p50 | 0.33ms |
| Latency p95 | 0.64ms |
| Latency p99 | 0.67ms |
| Consistency | 100.0% |
| Total / Pass / Fail | 5 / 5 / 0 |
@ -120,9 +120,9 @@
| Precision | 83.3% |
| Recall | 83.3% |
| F1 | 83.3% |
| Latency p50 | 0.03ms |
| Latency p95 | 0.03ms |
| Latency p99 | 0.03ms |
| Latency p50 | 0.01ms |
| Latency p95 | 0.02ms |
| Latency p99 | 0.02ms |
| Consistency | 100.0% |
| Total / Pass / Fail | 10 / 10 / 0 |
@ -151,9 +151,9 @@
| Precision | 0.0% |
| Recall | 0.0% |
| F1 | 0.0% |
| Latency p50 | 0.07ms |
| Latency p95 | 15.49ms |
| Latency p99 | 19.58ms |
| Latency p50 | 0.05ms |
| Latency p95 | 15.87ms |
| Latency p99 | 20.08ms |
| Consistency | 100.0% |
| Total / Pass / Fail | 6 / 6 / 0 |
@ -179,9 +179,9 @@
| Precision | 0.0% |
| Recall | 0.0% |
| F1 | 0.0% |
| Latency p50 | 1.66ms |
| Latency p95 | 3.54ms |
| Latency p99 | 3.84ms |
| Latency p50 | 1.94ms |
| Latency p95 | 2.94ms |
| Latency p99 | 3.25ms |
| Consistency | 100.0% |
| Total / Pass / Fail | 7 / 7 / 0 |
@ -208,9 +208,9 @@
| Precision | 0.0% |
| Recall | 0.0% |
| F1 | 0.0% |
| Latency p50 | 21.36ms |
| Latency p95 | 47.96ms |
| Latency p99 | 50.77ms |
| Latency p50 | 22.22ms |
| Latency p95 | 47.79ms |
| Latency p99 | 50.93ms |
| Consistency | 100.0% |
| Total / Pass / Fail | 5 / 5 / 0 |
@ -230,92 +230,39 @@
| easy | 2 | 2 | 100.0% |
| medium | 3 | 3 | 100.0% |
### 8. LLM 推理能力 (LLM Reasoning) [LLM]
| 指标 | 值 |
|---|---|
| Accuracy | 80.0% ± 0.0% |
| 95% CI | [37.5%, 96.4%] |
| Precision | 0.0% |
| Recall | 0.0% |
| F1 | 0.0% |
| Latency p50 | 37450.29ms |
| Latency p95 | 41462.66ms |
| Latency p99 | 41970.80ms |
| Consistency | 100.0% |
| Total / Pass / Fail | 5 / 4 / 1 |
#### 按类别分布
| 类别 | 用例数 | 通过 | 准确率 |
|---|---|---|---|
| intent_understanding | 1 | 0 | 0.0% |
| tool_selection | 1 | 1 | 100.0% |
| multi_step | 1 | 1 | 100.0% |
| code_generation | 1 | 1 | 100.0% |
| error_recovery | 1 | 1 | 100.0% |
#### 按难度分布
| 难度 | 用例数 | 通过 | 准确率 |
|---|---|---|---|
| easy | 1 | 0 | 0.0% |
| medium | 2 | 2 | 100.0% |
| hard | 2 | 2 | 100.0% |
#### 失败用例分析
| 用例 ID | 类别 | 难度 | 期望 | 实际 | 根因 |
|---|---|---|---|---|---|
| llm-001 | intent_understanding | easy | react | timeout | timeout |
### 9. GUI 集成测试 (GUI Integration) [GUI]
### 8. 私董会路由 (Board Meeting Routing) [Mock]
| 指标 | 值 |
|---|---|
| Accuracy | 100.0% ± 0.0% |
| 95% CI | [56.5%, 100.0%] |
| 95% CI | [82.4%, 100.0%] |
| Precision | 100.0% |
| Recall | 100.0% |
| F1 | 100.0% |
| Latency p50 | 0.00ms |
| Latency p95 | 0.00ms |
| Latency p99 | 0.00ms |
| Latency p50 | 0.01ms |
| Latency p95 | 0.39ms |
| Latency p99 | 1.19ms |
| Consistency | 100.0% |
| Total / Pass / Fail | 5 / 5 / 0 |
| Total / Pass / Fail | 18 / 18 / 0 |
#### 按类别分布
| 类别 | 用例数 | 通过 | 准确率 |
|---|---|---|---|
| service_startup | 1 | 1 | 100.0% |
| api_availability | 2 | 2 | 100.0% |
| websocket | 1 | 1 | 100.0% |
| frontend | 1 | 1 | 100.0% |
| default_template | 3 | 3 | 100.0% |
| explicit_experts | 3 | 3 | 100.0% |
| topic_extraction | 3 | 3 | 100.0% |
| no_match | 3 | 3 | 100.0% |
| name_validation | 3 | 3 | 100.0% |
| stop_command | 3 | 3 | 100.0% |
#### 按难度分布
| 难度 | 用例数 | 通过 | 准确率 |
|---|---|---|---|
| easy | 2 | 2 | 100.0% |
| medium | 2 | 2 | 100.0% |
| hard | 1 | 1 | 100.0% |
## 基线对比
| 维度 | 基线准确率 | 当前准确率 | 变化 |
|---|---|---|---|
| preprocessing | 100.0% | 100.0% | — |
| overfitting | 100.0% | 100.0% | — |
| efficiency | 100.0% | 100.0% | — |
| tool_search | 100.0% | 100.0% | — |
| event_model | 100.0% | 100.0% | — |
| spec_management | 100.0% | 100.0% | — |
| verification | 100.0% | 100.0% | — |
| llm_reasoning | 0.0% | 80.0% | ↑ |
| gui_integration | 0.0% | 100.0% | ↑ |
| easy | 11 | 11 | 100.0% |
| medium | 7 | 7 | 100.0% |
## 问题总结与改进建议
- **llm_reasoning**: 准确率 80.0% 低于 90%,建议检查失败用例并优化
- **llm_reasoning**: P95 延迟 41462.66ms 较高,建议优化性能
- 所有维度表现良好,无需特别改进。

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,171 @@
========================================================================
AgentKit 智能化能力分析报告
生成时间: 2026-06-15T16:59:06.575194+00:00
========================================================================
── 总体指标 ──────────────────────────────────────────────
观测总数: 82
技能路由召回率: 90.00%
技能路由精确率: 90.00%
技能路由F1: 90.00%
执行模式准确率: 40.38%
任务成功率: 100.00%
过拟合分数: 22.22%
── 分类明细 ──────────────────────────────────────────────
[路由/显式前缀]
样本数=1 召回率=100.00% 精确率=100.00% F1=100.00%
执行模式准确率=100.00% 成功率=100.00% 平均耗时=0ms
[路由/问候语]
样本数=2 召回率=100.00% 精确率=100.00% F1=100.00%
执行模式准确率=100.00% 成功率=100.00% 平均耗时=0ms
[路由/身份识别]
样本数=1 召回率=100.00% 精确率=100.00% F1=100.00%
执行模式准确率=100.00% 成功率=100.00% 平均耗时=0ms
[路由/关键词匹配]
样本数=62 召回率=67.74% 精确率=67.74% F1=67.74%
执行模式准确率=51.61% 成功率=100.00% 平均耗时=4449ms
[semantic_router/colloquial_match]
样本数=5 召回率=100.00% 精确率=100.00% F1=100.00%
执行模式准确率=0.00% 成功率=100.00% 平均耗时=2411ms
[semantic_router/description_match]
样本数=8 召回率=100.00% 精确率=100.00% F1=100.00%
执行模式准确率=37.50% 成功率=100.00% 平均耗时=892ms
[semantic_router/mixed_lang_match]
样本数=3 召回率=100.00% 精确率=100.00% F1=100.00%
执行模式准确率=33.33% 成功率=100.00% 平均耗时=1ms
── 过拟合分析 ────────────────────────────────────────────
[✓ 正常] route-kw-direct-001: 原始输入=✓, 改写一致性=100%
[✓ 正常] route-kw-direct-002: 原始输入=✓, 改写一致性=67%
[✓ 正常] route-kw-direct-003: 原始输入=✓, 改写一致性=67%
[✓ 正常] route-kw-react-001: 原始输入=✗, 改写一致性=100%
[✓ 正常] route-kw-react-002: 原始输入=✗, 改写一致性=67%
[✓ 正常] route-kw-react-003: 原始输入=✗, 改写一致性=100%
[✓ 正常] route-kw-rewoo-001: 原始输入=✗, 改写一致性=67%
[✓ 正常] route-kw-rewoo-002: 原始输入=✗, 改写一致性=100%
[✓ 正常] route-kw-reflex-001: 原始输入=✗, 改写一致性=100%
[✓ 正常] route-kw-reflex-002: 原始输入=✗, 改写一致性=100%
── 语义路由分析 ──────────────────────────────────────────
[colloquial_match] 样本数=5 精确率=100.00% F1=100.00%
[description_match] 样本数=8 精确率=100.00% F1=100.00%
[mixed_lang_match] 样本数=3 精确率=100.00% F1=100.00%
── 智能化短板识别 ────────────────────────────────────────
🟠 [高] 执行模式准确率过低 (0.00%),子类别: colloquial_match
证据: 正确数=0/5
建议: 检查复杂度估算和模式选择逻辑
🟠 [高] 执行模式准确率过低 (37.50%),子类别: description_match
证据: 正确数=3/8
建议: 检查复杂度估算和模式选择逻辑
🟠 [高] 执行模式准确率过低 (33.33%),子类别: mixed_lang_match
证据: 正确数=1/3
建议: 检查复杂度估算和模式选择逻辑
🟡 [中] 技能路由F1偏低 (0.68),子类别: keyword_match
证据: 召回率=67.74%, 精确率=67.74%, 样本数=62
建议: 微调路由阈值或增加更多意图示例
🟡 [中] 执行模式准确率过低 (51.61%),子类别: keyword_match
证据: 正确数=32/62
建议: 检查复杂度估算和模式选择逻辑
── 根因分析 ──────────────────────────────────────────────
▸ [复杂度估算偏差] 置信度: ███████░░░ 75%
原因: 复杂度估算偏差:倾向高估复杂度(将简单任务误判为需要多步推理)
详情: 共 31 个执行模式判断错误。低估复杂度 0 次,高估复杂度 1 次。受影响子类别: description_match, mixed_lang_match, colloquial_match, keyword_match
受影响用例: route-kw-rewoo-001, route-kw-rewoo-002, route-kw-reflex-001, route-kw-reflex-002, route-kw-planexec-001...
▸ [意图歧义] 置信度: ███████░░░ 70%
原因: 意图歧义:不同技能的关键词/意图描述重叠,导致路由混淆
详情: 技能混淆对: rewoo_agent→competitor_analyzer(2次); reflexion_agent→code_reviewer(1次)
受影响用例: route-kw-rewoo-001, route-kw-rewoo-001, route-kw-reflex-001
▸ [质量门控阈值过低] 置信度: ██████░░░░ 60%
原因: 质量门控阈值过低:任务虽成功完成但输出了错误结果
详情: 共 3 个任务虽然HTTP成功但路由到了错误技能。质量门控未能拦截这些错误路由的结果。
受影响用例: route-kw-rewoo-001, route-kw-rewoo-001, route-kw-reflex-001
── 改进策略规划 ──────────────────────────────────────────
┌─ 策略 1: 意图歧义:不同技能的关键词/意图描述重叠,导致路由混淆
│ 总体策略: 短期添加互斥关键词消歧中期启用LLM二次分类长期训练专用意图分类模型替代规则匹配
│ 🟠 [P1] 为易混淆技能添加互斥关键词
│ 目标模块: configs/skills/*.yaml → intent.disambiguation_keywords
│ 具体操作: 在技能配置中为容易混淆的技能对添加互斥关键词disambiguation_keywords当用户输入同时匹配多个技能时优先选择包含互斥关键词的技能。
│ 预期影响: 预计提升精确率 10~25%,减少技能混淆
│ 工作量: 小
│ 验证方式: 运行歧义消解回测,验证路由精确率提升
│ 🟡 [P2] 实现LLM二次分类消歧
│ 目标模块: src/agentkit/chat/skill_routing.py → Layer 1
│ 具体操作: 当 Layer 0/1 路由到多个候选技能时,调用 LLM quick_classify 进行二次意图判断,选择最匹配的技能。
│ 预期影响: 预计提升精确率 15~30%,但增加 ~500ms 延迟和 ~100 tokens
│ 工作量: 中
│ 验证方式: 运行歧义消解回测,对比延迟和精确率变化
└────────────────────────────────────────────────────────────
┌─ 策略 2: 复杂度估算偏差:倾向高估复杂度(将简单任务误判为需要多步推理)
│ 总体策略: 短期:调整启发式规则权重;中期:构建复杂度校准数据集;长期:训练复杂度评估模型替代规则
│ 🟠 [P1] 优化复杂度估算启发式规则
│ 目标模块: src/agentkit/chat/skill_routing.py → HeuristicClassifier
│ 具体操作: 调整 HeuristicClassifier 的复杂度评分权重:增加任务动词(分析/研究/设计)的权重,降低简单问答动词(是什么/多少)的权重。
│ 预期影响: 预计提升执行模式准确率 10~20%
│ 工作量: 小
│ 验证方式: 运行执行模式回测,验证准确率提升
│ 🟡 [P2] 引入任务复杂度校准数据集
│ 目标模块: tests/e2e/benchmark_dataset.py
│ 具体操作: 收集标注了复杂度等级的真实用户查询,构建校准数据集,定期评估和调整复杂度阈值。
│ 预期影响: 持续提升复杂度判断准确性
│ 工作量: 中
│ 验证方式: 每次调整后运行回测对比前后F1变化
└────────────────────────────────────────────────────────────
┌─ 策略 3: 质量门控阈值过低:任务虽成功完成但输出了错误结果
│ 总体策略: 短期:增加技能匹配验证;中期:引入输出质量评分模型;长期:实现自动质量回归检测
│ 🟠 [P1] 增强质量门控的技能匹配验证
│ 目标模块: src/agentkit/quality/gate.py
│ 具体操作: 在QualityGate中增加技能匹配验证检查输出是否与路由到的技能的能力范围一致如果不一致则触发重试或降级。
│ 预期影响: 减少错误路由导致的低质量输出
│ 工作量: 中
│ 验证方式: 运行质量门控回测,验证错误路由拦截率
└────────────────────────────────────────────────────────────
── L3 输出质量评估 ──────────────────────────────────────────
评估样本数: 18
平均质量评分: 4.72/5.0
评分分布: 1分:0 2分:0 3分:1 4分:3 5分:14
样例:
[route-edge-explicit-001] 评分=5 期望=react_agent 实际=react_agent
理由: 路由精准匹配用户指定的技能与意图,执行模式完全正确。
[route-kw-direct-002] 评分=4 期望=direct_agent 实际=direct_agent
理由: 路由与期望完全一致direct_chat模式适合处理此类缺乏具体上下文的模糊指令以便进行澄清或基于历史对话进行总结。
[route-kw-geo-001] 评分=5 期望=geo_optimizer 实际=geo_optimizer
理由: 路由精准匹配期望技能且技能名称完全契合用户优化SEO的意图。
[route-kw-monitor-001] 评分=5 期望=monitor 实际=monitor
理由: 实际路由技能与期望技能完全一致,精准匹配用户监测品牌引用变化的意图。
[semantic-reflex-001] 评分=5 期望=reflexion_agent 实际=reflexion_agent
理由: 实际路由技能与期望技能完全一致且反思reflexion执行模式完美契合高精度与自我验证的任务需求。
── L5 自适应能力 ──────────────────────────────────────────
测试组数: 10
平均自适应率: 86.67%
高自适应(>=80%): 6/10
========================================================================

View File

@ -1,6 +1,6 @@
{
"report_type": "comprehensive_capability_backtest",
"generated_at": "2026-06-17T03:22:42.152439+00:00",
"generated_at": "2026-06-17T05:29:48.993554+00:00",
"total_score": 100.0,
"total_cases": 50,
"total_passed": 50,
@ -215,27 +215,27 @@
{
"case_id": "efficiency_greeting",
"passed": true,
"elapsed_ms": 0.41
"elapsed_ms": 0.39
},
{
"case_id": "efficiency_chitchat",
"passed": true,
"elapsed_ms": 0.47
"elapsed_ms": 0.38
},
{
"case_id": "efficiency_identity",
"passed": true,
"elapsed_ms": 0.48
"elapsed_ms": 0.34
},
{
"case_id": "efficiency_react_tool",
"passed": true,
"elapsed_ms": 0.49
"elapsed_ms": 0.33
},
{
"case_id": "efficiency_react_complex",
"passed": true,
"elapsed_ms": 0.55
"elapsed_ms": 0.33
}
]
},

View File

@ -1,7 +1,7 @@
======================================================================
Fischer AgentKit 综合能力回测报告
======================================================================
生成时间: 2026-06-17T03:22:42.152439+00:00
生成时间: 2026-06-17T05:29:48.993554+00:00
总体评分: 100.0%
用例总数: 50 通过: 50 失败: 0

View File

@ -876,6 +876,126 @@ ALIGNMENT_BENCHMARKS: list[BenchmarkCase] = [
]
# ═══════════════════════════════════════════════════════════════════════════
# Board Meeting (Private Board) Benchmarks — @board prefix routing
# ═══════════════════════════════════════════════════════════════════════════
BOARD_BENCHMARKS: list[BenchmarkCase] = [
# --- Default template (@board without experts) ---
BenchmarkCase(
id="board-default-001",
input="@board 讨论是否应该进入东南亚市场",
expected_execution_mode="board",
expected_complexity="high",
category="board",
subcategory="default_template",
paraphrases=[
"@board 我们要不要拓展东南亚业务",
"@board 东南亚市场进入策略讨论",
"@board:private_board 评估东南亚市场机会",
],
tags=["board", "default", "strategy"],
),
BenchmarkCase(
id="board-default-002",
input="@board AI产品定价策略应该怎么做",
expected_execution_mode="board",
expected_complexity="high",
category="board",
subcategory="default_template",
paraphrases=["@board 如何给AI产品定价", "@board AI产品定价讨论"],
tags=["board", "default", "pricing"],
),
# --- Explicit expert list (@board:expert1,expert2) ---
BenchmarkCase(
id="board-explicit-001",
input="@board:elon_musk,jeff_bezos 讨论火星殖民的商业化路径",
expected_execution_mode="board",
expected_complexity="high",
category="board",
subcategory="explicit_experts",
paraphrases=[
"@board:elon_musk,jeff_bezos 火星商业化方案",
"@board:jeff_bezos,elon_musk 如何商业化火星",
],
tags=["board", "explicit", "mars"],
),
BenchmarkCase(
id="board-explicit-002",
input="@board:charlie_munger,warren_buffett 价值投资在AI时代的适用性",
expected_execution_mode="board",
expected_complexity="high",
category="board",
subcategory="explicit_experts",
paraphrases=[
"@board:charlie_munger,warren_buffett AI时代还要不要价值投资",
],
tags=["board", "explicit", "investing"],
),
# --- Explicit default template name ---
BenchmarkCase(
id="board-template-001",
input="@board:private_board 讨论创业公司融资节奏",
expected_execution_mode="board",
expected_complexity="high",
category="board",
subcategory="explicit_template",
paraphrases=["@board:private_board 创业融资策略", "@board:private_board 融资节奏讨论"],
tags=["board", "template", "fundraising"],
),
# --- Edge cases ---
BenchmarkCase(
id="board-edge-empty-topic-001",
input="@board",
expected_execution_mode="board",
expected_complexity="low",
category="board",
subcategory="empty_topic",
tags=["board", "edge", "empty"],
),
BenchmarkCase(
id="board-edge-no-prefix-001",
input="讨论一下市场策略",
expected_execution_mode="react",
expected_complexity="medium",
category="board",
subcategory="no_prefix",
paraphrases=["分析市场策略", "市场策略讨论"],
tags=["board", "edge", "no_match"],
),
# --- Name validation ---
BenchmarkCase(
id="board-name-valid-001",
input="@board:elon_musk,jeff_bezos,allenzhang 产品设计哲学",
expected_execution_mode="board",
expected_complexity="high",
category="board",
subcategory="name_validation",
tags=["board", "validation", "valid"],
),
BenchmarkCase(
id="board-name-invalid-001",
input="@board:@#$ 讨论主题",
expected_execution_mode="board",
expected_complexity="low",
category="board",
subcategory="name_validation",
tags=["board", "validation", "invalid"],
),
# --- Stop command (user intervention) ---
BenchmarkCase(
id="board-stop-001",
input="/stop",
expected_execution_mode="board",
expected_complexity="low",
category="board",
subcategory="stop_command",
paraphrases=["停止讨论", "结束讨论"],
tags=["board", "stop", "intervention"],
),
]
# ═══════════════════════════════════════════════════════════════════════════
# All benchmarks combined
# ═══════════════════════════════════════════════════════════════════════════
@ -888,6 +1008,7 @@ ALL_BENCHMARKS: list[BenchmarkCase] = (
+ CONSISTENCY_BENCHMARKS
+ SEMANTIC_ROUTER_BENCHMARKS
+ ALIGNMENT_BENCHMARKS
+ BOARD_BENCHMARKS
)

View File

@ -0,0 +1,659 @@
"""私董会 (Board Meeting) 回测脚本 — 能力评估与回归测试
BoardRouter / BoardTeam / BoardOrchestrator 进行系统性回测
覆盖以下能力维度
1. 前缀路由准确性 (Prefix Routing Accuracy)
- @board 前缀匹配
- @board:expert1,expert2 显式专家
- @board:private_board 显式模板
- @board 输入不应误匹配
2. 主题提取准确性 (Topic Extraction Accuracy)
- @board topic 提取 topic
- @board:experts topic 提取 topic
- 空主题处理
3. 专家名验证 (Expert Name Validation)
- 有效名称接受
- 无效名称拒绝含空格特殊字符
- MAX_EXPERTS=10 上限截断
4. 模板加载 (Template Loading)
- 默认 private_board 模板成员加载
- 配置目录 YAML 加载
5. 停止命令检测 (Stop Command Detection)
- /stop, 停止讨论, stop, 结束讨论
6. BoardTeam 状态机 (BoardTeam State Machine)
- FORMING DISCUSSING CONCLUDING COMPLETED DISSOLVED
7. 讨论历史管理 (Discussion History Management)
- 添加发言
- 历史格式化
- 用户干预
8. BoardRoutingResult 数据结构 (Data Structure Integrity)
- 默认值验证
- 匹配结果字段填充
9. 回归测试 (Regression: No Interference)
- @board 路由不干扰 @team / @skill / 普通聊天
运行方式
pytest tests/unit/experts/test_board_backtest.py -v
pytest tests/unit/experts/test_board_backtest.py -v -k "TestPrefixRouting"
"""
from __future__ import annotations
import pytest
from agentkit.experts.board_orchestrator import BoardOrchestrator
from agentkit.experts.board_router import (
BOARD_PREFIX_PATTERN,
DEFAULT_TEMPLATE,
MAX_EXPERTS,
BoardRouter,
BoardRoutingResult,
)
from agentkit.experts.board import BoardStatus, BoardTeam
from agentkit.experts.config import ExpertConfig, ExpertTemplate
from agentkit.experts.registry import ExpertTemplateRegistry
# ── 辅助函数 ──────────────────────────────────────────────
def _make_expert_template(name: str, persona: str = "测试专家") -> ExpertTemplate:
"""创建测试用 ExpertTemplate"""
config = ExpertConfig(
name=name,
agent_type="expert",
persona=persona,
thinking_style="analytical",
bound_skills=[],
task_mode="llm_generate",
prompt={"identity": persona},
)
return ExpertTemplate(name=name, config=config, is_builtin=True, description=persona)
def _make_registry_with_experts() -> ExpertTemplateRegistry:
"""创建包含预注册专家模板的注册中心"""
registry = ExpertTemplateRegistry()
registry.register(_make_expert_template("elon_musk", persona="Elon Musk"))
registry.register(_make_expert_template("jeff_bezos", persona="Jeff Bezos"))
registry.register(_make_expert_template("allenzhang", persona="张小龙"))
return registry
# ═══════════════════════════════════════════════════════════════════════════
# 1. 前缀路由准确性 (Prefix Routing Accuracy)
# ═══════════════════════════════════════════════════════════════════════════
class TestPrefixRouting:
"""回测:@board 前缀路由准确性"""
def setup_method(self) -> None:
self.router = BoardRouter(template_registry=ExpertTemplateRegistry())
@pytest.mark.parametrize(
"content,expected_matched,expected_board_mode",
[
("@board 讨论主题", True, True),
("@board:elon_musk,jeff_bezos 主题", True, True),
("@board:private_board 主题", True, True),
("@board", True, True),
("讨论一下市场策略", False, False),
("@team:analyst,writer 任务", False, False),
("@skill:react_agent 查看ip", False, False),
("普通聊天消息", False, False),
],
ids=[
"board_default",
"board_explicit_experts",
"board_explicit_template",
"board_no_topic",
"plain_text",
"team_prefix",
"skill_prefix",
"chitchat",
],
)
def test_prefix_matching(
self, content: str, expected_matched: bool, expected_board_mode: bool
) -> None:
"""验证 @board 前缀匹配准确性"""
result = self.router.resolve(content)
assert result.matched == expected_matched, (
f"matched mismatch for {content!r}: "
f"expected {expected_matched}, got {result.matched}"
)
assert result.board_mode == expected_board_mode, (
f"board_mode mismatch for {content!r}: "
f"expected {expected_board_mode}, got {result.board_mode}"
)
def test_regex_pattern_directly(self) -> None:
"""直接测试正则表达式 BOARD_PREFIX_PATTERN"""
# 匹配 @board
m = BOARD_PREFIX_PATTERN.match("@board 主题")
assert m is not None
assert m.group(1) is None # no expert list
assert m.group(2).strip() == "主题"
# 匹配 @board:experts
m = BOARD_PREFIX_PATTERN.match("@board:a,b 主题")
assert m is not None
assert m.group(1) == "a,b"
assert m.group(2).strip() == "主题"
# 不匹配 @team
m = BOARD_PREFIX_PATTERN.match("@team task")
assert m is None
def test_default_template_uses_private_board(self) -> None:
"""@board 无指定专家时应使用 private_board 默认模板"""
result = self.router.resolve("@board 讨论主题")
assert result.use_default_template is True
assert result.match_method == "explicit_board"
def test_explicit_private_board_template(self) -> None:
"""@board:private_board 应显式使用默认模板"""
result = self.router.resolve("@board:private_board 讨论主题")
assert result.use_default_template is True
assert result.board_mode is True
# ═══════════════════════════════════════════════════════════════════════════
# 2. 主题提取准确性 (Topic Extraction Accuracy)
# ═══════════════════════════════════════════════════════════════════════════
class TestTopicExtraction:
"""回测:讨论主题提取准确性"""
def setup_method(self) -> None:
self.router = BoardRouter(template_registry=ExpertTemplateRegistry())
@pytest.mark.parametrize(
"content,expected_topic",
[
("@board 讨论是否应该进入东南亚市场", "讨论是否应该进入东南亚市场"),
("@board AI产品定价策略应该怎么做", "AI产品定价策略应该怎么做"),
("@board:elon_musk,jeff_bezos 火星商业化方案", "火星商业化方案"),
("@board:private_board 创业融资策略", "创业融资策略"),
("@board", ""),
("@board ", ""),
],
ids=[
"default_chinese",
"default_chinese_2",
"explicit_experts",
"explicit_template",
"empty_topic",
"whitespace_only",
],
)
def test_topic_extraction(self, content: str, expected_topic: str) -> None:
"""验证从 @board 输入中提取讨论主题"""
result = self.router.resolve(content)
actual = " ".join(result.topic.split())
assert actual == expected_topic, (
f"topic mismatch for {content!r}: "
f"expected {expected_topic!r}, got {actual!r}"
)
def test_colon_no_experts(self) -> None:
"""@board: topic冒号后无专家名的边界行为"""
# \S+ requires non-whitespace after colon, so ": topic" → group(1)=None
# The colon becomes part of the topic
result = self.router.resolve("@board: topic")
assert result.matched is True
assert result.board_mode is True
# group(1) is None because \S+ doesn't match " " (space after colon)
assert result.use_default_template is True
# ═══════════════════════════════════════════════════════════════════════════
# 3. 专家名验证 (Expert Name Validation)
# ═══════════════════════════════════════════════════════════════════════════
class TestExpertNameValidation:
"""回测:专家名格式验证与上限"""
def setup_method(self) -> None:
self.router = BoardRouter(template_registry=ExpertTemplateRegistry())
@pytest.mark.parametrize(
"content,expected_count",
[
("@board:elon_musk,jeff_bezos 主题", 2),
("@board:elon_musk,jeff_bezos,allenzhang 主题", 3),
("@board:charlie_munger,warren_buffett,paul_graham 主题", 3),
("@board:a,b,c,d,e,f,g,h,i,j 主题", 10),
("@board:a,b,c,d,e,f,g,h,i,j,k 主题", 10), # 11 → capped to 10
],
ids=["two", "three", "three_alt", "exactly_ten", "eleven_capped"],
)
def test_valid_expert_count(self, content: str, expected_count: int) -> None:
"""验证有效专家名数量"""
result = self.router.resolve(content)
assert len(result.specified_experts) == expected_count, (
f"expert count mismatch for {content!r}: "
f"expected {expected_count}, got {len(result.specified_experts)}"
)
def test_invalid_names_rejected(self) -> None:
"""全部无效专家名时应回退到默认模板"""
# @#$ are not valid per _EXPERT_NAME_RE = ^[a-zA-Z0-9_-]{1,64}$
result = self.router.resolve("@board:@#$ 主题")
# All names invalid → should fall back to default template
assert result.use_default_template is True, (
"should fall back to default template when all names are invalid"
)
assert len(result.specified_experts) > 0, (
f"default template members should be loaded, got: {result.specified_experts}"
)
def test_max_experts_constant(self) -> None:
"""MAX_EXPERTS 应为 10"""
assert MAX_EXPERTS == 10
def test_resolve_expert_configs_first_is_moderator(self) -> None:
"""resolve_expert_configs 应将首位专家设为主持人"""
result = self.router.resolve("@board:expert_a,expert_b 主题")
configs = self.router.resolve_expert_configs(result.specified_experts)
assert len(configs) == 2
assert configs[0].is_lead is True
assert configs[1].is_lead is False
def test_resolve_expert_configs_dynamic_generation(self) -> None:
"""未注册的专家名应动态生成 ExpertConfig"""
result = self.router.resolve("@board:dynamic_expert 主题")
configs = self.router.resolve_expert_configs(result.specified_experts)
assert len(configs) == 1
assert configs[0].name == "dynamic_expert"
assert configs[0].is_lead is True
def test_mixed_valid_invalid_names(self) -> None:
"""混合有效+无效专家名:无效名被过滤,有效名保留"""
result = self.router.resolve("@board:elon_musk,@#$,jeff_bezos 主题")
assert result.specified_experts == ["elon_musk", "jeff_bezos"], (
f"invalid names should be filtered, got: {result.specified_experts}"
)
assert result.use_default_template is False
@pytest.mark.parametrize(
"name_length,expected_valid",
[
(64, True), # exactly 64 chars — valid
(65, False), # 65 chars — invalid (exceeds {1,64})
],
ids=["boundary_64_valid", "boundary_65_invalid"],
)
def test_expert_name_length_boundary(self, name_length: int, expected_valid: bool) -> None:
"""专家名长度边界64 字符有效65 字符无效"""
name = "a" * name_length
result = self.router.resolve(f"@board:{name} 主题")
if expected_valid:
assert len(result.specified_experts) == 1
assert result.specified_experts[0] == name
else:
# All names invalid → fallback to default template
assert result.use_default_template is True
def test_resolve_expert_configs_no_template_mutation(self) -> None:
"""resolve_expert_configs 不应修改注册表中的共享模板配置"""
registry = _make_registry_with_experts()
router = BoardRouter(template_registry=registry)
# First call: elon_musk is lead
configs1 = router.resolve_expert_configs(["elon_musk", "jeff_bezos"])
assert configs1[0].is_lead is True
assert configs1[1].is_lead is False
# Second call: jeff_bezos is lead (reversed order)
configs2 = router.resolve_expert_configs(["jeff_bezos", "elon_musk"])
assert configs2[0].is_lead is True
assert configs2[1].is_lead is False
# Verify first call's configs are NOT mutated by the second call
assert configs1[0].is_lead is True, (
"first call's is_lead was mutated by second call (shared template bug)"
)
assert configs1[1].is_lead is False
# ═══════════════════════════════════════════════════════════════════════════
# 4. 模板加载 (Template Loading)
# ═══════════════════════════════════════════════════════════════════════════
class TestTemplateLoading:
"""回测:专家模板加载"""
def test_default_template_name(self) -> None:
"""DEFAULT_TEMPLATE 应为 'private_board'"""
assert DEFAULT_TEMPLATE == "private_board"
def test_fallback_default_members(self) -> None:
"""空注册表时应返回回退默认成员列表"""
router = BoardRouter(template_registry=ExpertTemplateRegistry())
members = router._load_default_template_members()
assert len(members) > 0
assert len(members) <= MAX_EXPERTS
# 回退列表应包含预设专家
assert "elon_musk" in members
assert "jeff_bezos" in members
def test_default_template_members_from_registry(self) -> None:
"""注册表中存在 private_board 模板时应从模板加载成员"""
registry = _make_registry_with_experts()
# Register a private_board template with bound_skills as members
board_config = ExpertConfig(
name="private_board",
agent_type="expert",
persona="私董会模板",
bound_skills=["elon_musk", "jeff_bezos", "allenzhang"],
task_mode="llm_generate",
prompt={"identity": "Private Board"},
)
registry.register(
ExpertTemplate(
name="private_board",
config=board_config,
is_builtin=True,
description="默认私董会模板",
)
)
router = BoardRouter(template_registry=registry)
members = router._load_default_template_members()
assert members == ["elon_musk", "jeff_bezos", "allenzhang"]
assert len(members) <= MAX_EXPERTS
def test_load_from_configs_directory(self) -> None:
"""从 configs/experts/ 目录加载 YAML 模板"""
from pathlib import Path
experts_dir = Path(__file__).parent.parent.parent.parent / "configs" / "experts"
if not experts_dir.is_dir():
pytest.skip(f"configs/experts/ not found at {experts_dir}")
registry = ExpertTemplateRegistry()
loaded = registry.load_from_directory(str(experts_dir))
# load_from_directory returns a list[ExpertTemplate]
assert isinstance(loaded, list)
assert len(loaded) >= 5, f"expected ≥5 templates, got {len(loaded)}"
# 验证关键专家存在
names = {t.config.name for t in loaded}
expected_names = {"elon_musk", "jeff_bezos", "allenzhang", "charlie_munger"}
assert expected_names.issubset(names), (
f"missing expected experts: {expected_names - names}"
)
# ═══════════════════════════════════════════════════════════════════════════
# 5. 停止命令检测 (Stop Command Detection)
# ═══════════════════════════════════════════════════════════════════════════
class TestStopCommandDetection:
"""回测:停止命令检测"""
@pytest.mark.parametrize(
"command,expected_stop",
[
("/stop", True),
("停止讨论", True),
("stop", True),
("结束讨论", True),
("继续讨论", False),
("", False),
("请继续", False),
("STOP", False), # case-sensitive
],
ids=[
"slash_stop",
"chinese_stop",
"english_stop",
"chinese_end",
"continue",
"empty",
"please_continue",
"uppercase_not_match",
],
)
def test_stop_command_detection(self, command: str, expected_stop: bool) -> None:
"""验证停止命令检测"""
is_stop = command.strip() in BoardOrchestrator.STOP_COMMANDS
assert is_stop == expected_stop, (
f"stop detection mismatch for {command!r}: "
f"expected {expected_stop}, got {is_stop}"
)
def test_stop_commands_count(self) -> None:
"""STOP_COMMANDS 应包含 4 个命令"""
assert len(BoardOrchestrator.STOP_COMMANDS) == 4
# ═══════════════════════════════════════════════════════════════════════════
# 6. BoardTeam 状态机 (BoardTeam State Machine)
# ═══════════════════════════════════════════════════════════════════════════
class TestBoardTeamStateMachine:
"""回测BoardTeam 生命周期状态"""
def test_initial_status_is_forming(self) -> None:
"""新建 BoardTeam 初始状态应为 FORMING"""
team = BoardTeam()
assert team.status == BoardStatus.FORMING
def test_status_transitions(self) -> None:
"""状态转换FORMING → DISCUSSING → CONCLUDING → COMPLETED → DISSOLVED"""
team = BoardTeam()
assert team.status == BoardStatus.FORMING
team.set_status(BoardStatus.DISCUSSING)
assert team.status == BoardStatus.DISCUSSING
team.set_status(BoardStatus.CONCLUDING)
assert team.status == BoardStatus.CONCLUDING
team.set_status(BoardStatus.COMPLETED)
assert team.status == BoardStatus.COMPLETED
team.set_status(BoardStatus.DISSOLVED)
assert team.status == BoardStatus.DISSOLVED
def test_team_id_is_unique(self) -> None:
"""每个 BoardTeam 应有唯一 team_id"""
team1 = BoardTeam()
team2 = BoardTeam()
assert team1.team_id != team2.team_id
def test_team_channel_format(self) -> None:
"""team_channel 应为 'board:{team_id}' 格式"""
team = BoardTeam()
assert team.team_channel == f"board:{team.team_id}"
def test_max_rounds_configurable(self) -> None:
"""max_rounds 应可配置"""
team = BoardTeam(max_rounds=3)
assert team.max_rounds == 3
team2 = BoardTeam(max_rounds=10)
assert team2.max_rounds == 10
def test_default_max_rounds(self) -> None:
"""默认 max_rounds 应为 5"""
team = BoardTeam()
assert team.max_rounds == 5
# ═══════════════════════════════════════════════════════════════════════════
# 7. 讨论历史管理 (Discussion History Management)
# ═══════════════════════════════════════════════════════════════════════════
class TestDiscussionHistory:
"""回测:讨论历史管理"""
@pytest.fixture
def team(self) -> BoardTeam:
return BoardTeam()
async def test_add_to_history(self, team: BoardTeam) -> None:
"""添加发言到历史"""
await team.add_to_history(round=1, expert_name="elon_musk", content="First speech")
history = team.history
assert len(history) == 1
assert history[0]["round"] == 1
assert history[0]["expert_name"] == "elon_musk"
assert history[0]["content"] == "First speech"
assert history[0]["role"] == "expert"
async def test_add_moderator_speech(self, team: BoardTeam) -> None:
"""添加主持人发言"""
await team.add_to_history(
round=1, expert_name="moderator", content="Summary", role="moderator"
)
history = team.history
assert history[0]["role"] == "moderator"
async def test_add_user_intervention(self, team: BoardTeam) -> None:
"""添加用户干预"""
await team.add_user_intervention("Please focus on cost")
history = team.history
assert len(history) == 1
assert history[0]["role"] == "user"
assert history[0]["expert_name"] == "user"
assert history[0]["content"] == "Please focus on cost"
async def test_history_text_format(self, team: BoardTeam) -> None:
"""历史文本格式化"""
await team.add_to_history(round=1, expert_name="elon_musk", content="Speech 1")
await team.add_to_history(
round=1, expert_name="moderator", content="Round 1 summary", role="moderator"
)
await team.add_to_history(round=2, expert_name="jeff_bezos", content="Speech 2")
text = team.get_history_text()
assert "第1轮" in text
assert "elon_musk" in text
assert "Speech 1" in text
assert "主持人小结" in text
assert "第2轮" in text
assert "jeff_bezos" in text
async def test_history_text_up_to_round(self, team: BoardTeam) -> None:
"""按轮次过滤历史文本"""
await team.add_to_history(round=1, expert_name="a", content="R1")
await team.add_to_history(round=2, expert_name="b", content="R2")
await team.add_to_history(round=3, expert_name="c", content="R3")
text_r2 = team.get_history_text(up_to_round=2)
assert "R1" in text_r2
assert "R2" in text_r2
assert "R3" not in text_r2
async def test_consume_user_interventions(self, team: BoardTeam) -> None:
"""消费用户干预列表"""
await team.add_user_intervention("Intervention 1")
await team.add_user_intervention("Intervention 2")
interventions = team.consume_user_interventions()
assert len(interventions) == 2
assert "Intervention 1" in interventions
assert "Intervention 2" in interventions
# 二次消费应为空
assert team.consume_user_interventions() == []
def test_empty_history_text(self, team: BoardTeam) -> None:
"""空历史的文本应为空字符串"""
assert team.get_history_text() == ""
def test_increment_round(self, team: BoardTeam) -> None:
"""轮次递增"""
assert team.current_round == 0
r1 = team.increment_round()
assert r1 == 1
assert team.current_round == 1
r2 = team.increment_round()
assert r2 == 2
assert team.current_round == 2
# ═══════════════════════════════════════════════════════════════════════════
# 8. BoardRoutingResult 数据结构 (Data Structure Integrity)
# ═══════════════════════════════════════════════════════════════════════════
class TestBoardRoutingResult:
"""回测BoardRoutingResult 数据结构完整性"""
def test_default_values(self) -> None:
"""默认值应为空/False"""
result = BoardRoutingResult()
assert result.matched is False
assert result.board_mode is False
assert result.specified_experts == []
assert result.topic == ""
assert result.use_default_template is False
assert result.match_method == ""
def test_matched_result_fields(self) -> None:
"""匹配结果的字段应正确填充"""
router = BoardRouter(template_registry=ExpertTemplateRegistry())
result = router.resolve("@board:elon_musk,jeff_bezos 讨论主题")
assert result.matched is True
assert result.board_mode is True
assert result.specified_experts == ["elon_musk", "jeff_bezos"]
assert result.topic == "讨论主题"
assert result.use_default_template is False
assert result.match_method == "explicit_board"
# ═══════════════════════════════════════════════════════════════════════════
# 9. 回归测试 — 确保不破坏现有路由 (Regression: No Interference)
# ═══════════════════════════════════════════════════════════════════════════
class TestNoInterferenceRegression:
"""回归测试:@board 路由不应干扰其他前缀路由"""
def setup_method(self) -> None:
self.router = BoardRouter(template_registry=ExpertTemplateRegistry())
@pytest.mark.parametrize(
"content",
[
"@team:analyst,writer 协作完成任务",
"@skill:react_agent 查看ip",
"@skill:chat_only 你好",
"你好",
"搜索golang教程",
"执行ls命令",
],
ids=[
"team_prefix",
"skill_react_prefix",
"skill_chat_prefix",
"greeting",
"search",
"shell",
],
)
def test_non_board_inputs_not_matched(self, content: str) -> None:
"""非 @board 输入不应被 BoardRouter 匹配"""
result = self.router.resolve(content)
assert result.matched is False
assert result.board_mode is False
assert result.use_default_template is False

View File

@ -0,0 +1,339 @@
"""BoardOrchestrator 单元测试 — 私董会讨论引擎"""
from __future__ import annotations
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from agentkit.experts.board import BoardTeam
from agentkit.experts.board_orchestrator import BoardOrchestrator
from agentkit.experts.config import ExpertConfig
from agentkit.experts.expert import Expert
# ── 辅助函数 ──────────────────────────────────────────────
def _make_expert_config(
name: str = "test_expert",
is_lead: bool = False,
) -> ExpertConfig:
"""创建测试用 ExpertConfig"""
return ExpertConfig(
name=name,
agent_type="expert",
persona=f"测试专家 {name}",
thinking_style="analytical",
speaking_style="直接",
decision_framework="分析",
bound_skills=[],
is_lead=is_lead,
task_mode="llm_generate",
prompt={"identity": name},
avatar="🎭",
color="#FF0000",
)
def _make_mock_expert(
name: str = "test_expert",
is_lead: bool = False,
is_active: bool = True,
) -> MagicMock:
"""创建 mock Expert 实例"""
config = _make_expert_config(name, is_lead)
expert = MagicMock(spec=Expert)
expert.config = config
expert.is_active = is_active
expert.agent = MagicMock()
expert.agent._llm_gateway = MagicMock()
return expert
def _make_mock_gateway(response_content: str = "测试回复") -> AsyncMock:
"""创建 mock LLM gateway"""
gateway = AsyncMock()
response = MagicMock()
response.content = response_content
gateway.chat = AsyncMock(return_value=response)
return gateway
def _setup_team_with_experts(
team: BoardTeam,
experts: list[MagicMock],
moderator_name: str | None = None,
) -> None:
"""设置 BoardTeam 的内部专家字典"""
for expert in experts:
team._experts[expert.config.name] = expert
if moderator_name:
team._moderator_name = moderator_name
elif experts:
team._moderator_name = experts[0].config.name
# ── BoardOrchestrator 初始化测试 ──────────────────────────
class TestBoardOrchestratorInit:
"""BoardOrchestrator 初始化测试"""
def test_init(self):
"""初始化"""
team = BoardTeam()
orchestrator = BoardOrchestrator(team=team)
assert orchestrator._team is team
def test_stop_commands(self):
"""停止命令集合"""
assert "/stop" in BoardOrchestrator.STOP_COMMANDS
assert "停止讨论" in BoardOrchestrator.STOP_COMMANDS
assert "stop" in BoardOrchestrator.STOP_COMMANDS
assert "结束讨论" in BoardOrchestrator.STOP_COMMANDS
# ── BoardOrchestrator.execute 测试 ────────────────────────
class TestBoardOrchestratorExecute:
"""BoardOrchestrator.execute 执行流程测试"""
@pytest.mark.asyncio
async def test_execute_no_active_experts(self):
"""无活跃专家时返回失败"""
team = BoardTeam()
orchestrator = BoardOrchestrator(team=team)
result = await orchestrator.execute("测试主题")
assert result["status"] == "failed"
assert "error" in result
assert "No active expert" in result["error"]
@pytest.mark.asyncio
async def test_execute_success(self):
"""正常执行流程"""
team = BoardTeam(max_rounds=2)
moderator = _make_mock_expert("moderator", is_lead=True)
member1 = _make_mock_expert("member1")
member2 = _make_mock_expert("member2")
_setup_team_with_experts(team, [moderator, member1, member2], "moderator")
# Mock LLM gateway
gateway = _make_mock_gateway("测试发言内容")
moderator.agent._llm_gateway = gateway
member1.agent._llm_gateway = gateway
member2.agent._llm_gateway = gateway
orchestrator = BoardOrchestrator(team=team)
# Mock broadcast_event to avoid transport issues
with patch.object(orchestrator, "_broadcast_event", new_callable=AsyncMock):
result = await orchestrator.execute("测试主题")
assert result["status"] == "completed"
assert result["total_rounds"] == 2
assert "summary" in result
@pytest.mark.asyncio
async def test_execute_with_stop_command(self):
"""用户发送停止命令时终止讨论"""
team = BoardTeam(max_rounds=5)
moderator = _make_mock_expert("moderator", is_lead=True)
member1 = _make_mock_expert("member1")
_setup_team_with_experts(team, [moderator, member1], "moderator")
gateway = _make_mock_gateway("测试内容")
moderator.agent._llm_gateway = gateway
member1.agent._llm_gateway = gateway
# 添加停止命令作为用户干预
await team.add_user_intervention("/stop")
orchestrator = BoardOrchestrator(team=team)
with patch.object(orchestrator, "_broadcast_event", new_callable=AsyncMock):
result = await orchestrator.execute("测试主题")
# 应该在第1轮就停止因为 consume_user_interventions 在循环开始时检查)
assert result["status"] == "completed"
assert result["total_rounds"] <= 1
@pytest.mark.asyncio
async def test_execute_llm_unavailable_uses_fallback(self):
"""LLM 不可用时使用回退文本,仍正常完成"""
team = BoardTeam(max_rounds=2)
moderator = _make_mock_expert("moderator", is_lead=True)
_setup_team_with_experts(team, [moderator], "moderator")
# Mock gateway to raise exception for all LLM calls
gateway = AsyncMock()
gateway.chat = AsyncMock(side_effect=Exception("LLM 不可用"))
moderator.agent._llm_gateway = gateway
orchestrator = BoardOrchestrator(team=team)
with patch.object(orchestrator, "_broadcast_event", new_callable=AsyncMock):
result = await orchestrator.execute("测试主题")
# LLM 不可用时orchestrator 使用回退文本,仍正常完成
assert result["status"] == "completed"
assert result["total_rounds"] == 2
# summary 应包含回退文本
assert "summary" in result
@pytest.mark.asyncio
async def test_execute_unexpected_exception(self):
"""非 LLM 异常时进入回退处理"""
team = BoardTeam(max_rounds=2)
moderator = _make_mock_expert("moderator", is_lead=True)
_setup_team_with_experts(team, [moderator], "moderator")
# Provide a working gateway for fallback conclusion
gateway = _make_mock_gateway("回退总结")
moderator.agent._llm_gateway = gateway
orchestrator = BoardOrchestrator(team=team)
# Mock _generate_moderator_opening to raise an unexpected exception
with patch.object(
orchestrator,
"_generate_moderator_opening",
new_callable=AsyncMock,
side_effect=RuntimeError("Unexpected error"),
):
with patch.object(orchestrator, "_broadcast_event", new_callable=AsyncMock):
result = await orchestrator.execute("测试主题")
# 非预期异常应返回 failed 状态
assert result["status"] == "failed"
assert "error" in result
# ── BoardOrchestrator._has_stop_command 测试 ──────────────
class TestBoardOrchestratorStopCommand:
"""BoardOrchestrator 停止命令检测测试"""
def test_has_stop_command_true(self):
"""检测到停止命令"""
team = BoardTeam()
orchestrator = BoardOrchestrator(team=team)
assert orchestrator._has_stop_command(["/stop"]) is True
assert orchestrator._has_stop_command(["停止讨论"]) is True
assert orchestrator._has_stop_command(["some text", "stop"]) is True
assert orchestrator._has_stop_command(["结束讨论"]) is True
def test_has_stop_command_false(self):
"""无停止命令"""
team = BoardTeam()
orchestrator = BoardOrchestrator(team=team)
assert orchestrator._has_stop_command([]) is False
assert orchestrator._has_stop_command(["继续讨论"]) is False
assert orchestrator._has_stop_command(["请多说一些"]) is False
def test_has_stop_command_case_insensitive(self):
"""停止命令大小写不敏感"""
team = BoardTeam()
orchestrator = BoardOrchestrator(team=team)
assert orchestrator._has_stop_command(["STOP"]) is True
assert orchestrator._has_stop_command(["Stop"]) is True
# ── BoardOrchestrator._get_llm_gateway 测试 ───────────────
class TestBoardOrchestratorGetGateway:
"""BoardOrchestrator._get_llm_gateway 测试"""
def test_get_gateway_from_expert(self):
"""从指定专家获取 gateway"""
team = BoardTeam()
orchestrator = BoardOrchestrator(team=team)
expert = _make_mock_expert("test")
gateway = orchestrator._get_llm_gateway(expert)
assert gateway is not None
def test_get_gateway_fallback_to_active_experts(self):
"""从其他活跃专家回退获取 gateway"""
team = BoardTeam()
moderator = _make_mock_expert("moderator", is_lead=True)
# moderator 没有 gateway
moderator.agent._llm_gateway = None
member = _make_mock_expert("member")
_setup_team_with_experts(team, [moderator, member], "moderator")
orchestrator = BoardOrchestrator(team=team)
# 应该从 member 回退获取
gateway = orchestrator._get_llm_gateway(moderator)
assert gateway is not None
def test_get_gateway_none_when_no_gateway(self):
"""无可用 gateway 时返回 None"""
team = BoardTeam()
moderator = _make_mock_expert("moderator", is_lead=True)
moderator.agent._llm_gateway = None
_setup_team_with_experts(team, [moderator], "moderator")
orchestrator = BoardOrchestrator(team=team)
gateway = orchestrator._get_llm_gateway(moderator)
assert gateway is None
# ── BoardOrchestrator._broadcast_event 测试 ───────────────
class TestBoardOrchestratorBroadcast:
"""BoardOrchestrator._broadcast_event 测试"""
@pytest.mark.asyncio
async def test_broadcast_event_with_transport(self):
"""有 transport 时广播事件"""
team = BoardTeam()
orchestrator = BoardOrchestrator(team=team)
# Mock transport
team._handoff_transport = AsyncMock()
team._handoff_transport.send = AsyncMock()
await orchestrator._broadcast_event("board_started", {"topic": "测试"})
team._handoff_transport.send.assert_called_once()
call_args = team._handoff_transport.send.call_args
assert call_args[0][0] == team.team_channel
assert call_args[0][1]["type"] == "board_started"
assert call_args[0][1]["topic"] == "测试"
@pytest.mark.asyncio
async def test_broadcast_event_no_transport(self):
"""无 transport 时不报错"""
team = BoardTeam()
team._handoff_transport = None
orchestrator = BoardOrchestrator(team=team)
# 不应抛出异常
await orchestrator._broadcast_event("board_started", {"topic": "测试"})
@pytest.mark.asyncio
async def test_broadcast_event_transport_error(self):
"""transport 错误时不传播异常"""
team = BoardTeam()
orchestrator = BoardOrchestrator(team=team)
team._handoff_transport = AsyncMock()
team._handoff_transport.send = AsyncMock(side_effect=Exception("Transport error"))
# 不应抛出异常
await orchestrator._broadcast_event("board_started", {"topic": "测试"})

View File

@ -0,0 +1,301 @@
"""BoardRouter 单元测试 — @board 前缀路由解析"""
from __future__ import annotations
from agentkit.experts.board_router import (
BOARD_PREFIX_PATTERN,
BoardRouter,
BoardRoutingResult,
MAX_EXPERTS,
)
from agentkit.experts.config import ExpertConfig, ExpertTemplate
from agentkit.experts.registry import ExpertTemplateRegistry
# ── 辅助函数 ──────────────────────────────────────────────
def _make_expert_template(
name: str = "test_expert",
persona: str = "测试专家",
speaking_style: str = "直接",
decision_framework: str = "分析",
) -> ExpertTemplate:
"""创建测试用 ExpertTemplate"""
config = ExpertConfig(
name=name,
agent_type="expert",
persona=persona,
thinking_style="analytical",
speaking_style=speaking_style,
decision_framework=decision_framework,
bound_skills=[],
task_mode="llm_generate",
prompt={"identity": persona},
)
return ExpertTemplate(
name=name,
config=config,
is_builtin=True,
description=f"{name} 测试模板",
)
def _make_registry_with_experts() -> ExpertTemplateRegistry:
"""创建包含预注册专家模板的注册中心"""
registry = ExpertTemplateRegistry()
registry.register(_make_expert_template("elon_musk", persona="Elon Musk"))
registry.register(_make_expert_template("jeff_bezos", persona="Jeff Bezos"))
registry.register(_make_expert_template("allenzhang", persona="张小龙"))
# 注册 private_board 模板(使用 bound_skills 存储成员列表)
board_config = ExpertConfig(
name="private_board",
agent_type="expert",
persona="私董会模板",
bound_skills=["elon_musk", "jeff_bezos", "allenzhang"],
task_mode="llm_generate",
prompt={"identity": "Private Board"},
)
registry.register(ExpertTemplate(
name="private_board",
config=board_config,
is_builtin=True,
description="默认私董会模板",
))
return registry
# ── BOARD_PREFIX_PATTERN 正则测试 ──────────────────────────
class TestBoardPrefixPattern:
"""BOARD_PREFIX_PATTERN 正则匹配测试"""
def test_match_board_only(self):
"""@board 前缀匹配(无专家指定)"""
match = BOARD_PREFIX_PATTERN.match("@board 讨论AI未来")
assert match is not None
assert match.group(1) is None
assert match.group(2) == "讨论AI未来"
def test_match_board_with_experts(self):
"""@board:expert1,expert2 格式匹配"""
match = BOARD_PREFIX_PATTERN.match("@board:elon_musk,jeff_bezos SpaceX上市")
assert match is not None
assert match.group(1) == "elon_musk,jeff_bezos"
assert match.group(2) == "SpaceX上市"
def test_match_board_with_template_name(self):
"""@board:private_board 显式使用模板"""
match = BOARD_PREFIX_PATTERN.match("@board:private_board 讨论主题")
assert match is not None
assert match.group(1) == "private_board"
assert match.group(2) == "讨论主题"
def test_no_match_regular_input(self):
"""普通输入不匹配"""
assert BOARD_PREFIX_PATTERN.match("你好,今天天气怎么样") is None
assert BOARD_PREFIX_PATTERN.match("@team 分析数据") is None
assert BOARD_PREFIX_PATTERN.match("@skill:search 搜索内容") is None
def test_match_board_with_multiline_topic(self):
"""多行主题匹配"""
match = BOARD_PREFIX_PATTERN.match("@board 第一行\n第二行")
assert match is not None
assert "第一行" in match.group(2)
assert "第二行" in match.group(2)
# ── BoardRouter.resolve 测试 ──────────────────────────────
class TestBoardRouterResolve:
"""BoardRouter.resolve 路由解析测试"""
def test_resolve_default_template(self):
"""@board 主题 → 使用默认模板"""
router = BoardRouter(template_registry=_make_registry_with_experts())
result = router.resolve("@board 如何看待AI对教育的影响")
assert result.matched is True
assert result.board_mode is True
assert result.topic == "如何看待AI对教育的影响"
assert result.use_default_template is True
assert result.match_method == "explicit_board"
assert "elon_musk" in result.specified_experts
assert "jeff_bezos" in result.specified_experts
assert "allenzhang" in result.specified_experts
def test_resolve_explicit_template(self):
"""@board:private_board 主题 → 显式使用默认模板"""
router = BoardRouter(template_registry=_make_registry_with_experts())
result = router.resolve("@board:private_board 讨论主题")
assert result.matched is True
assert result.use_default_template is True
assert result.topic == "讨论主题"
assert len(result.specified_experts) == 3
def test_resolve_specified_experts(self):
"""@board:expert1,expert2 主题 → 指定专家"""
router = BoardRouter(template_registry=_make_registry_with_experts())
result = router.resolve("@board:elon_musk,jeff_bezos SpaceX上市问题")
assert result.matched is True
assert result.use_default_template is False
assert result.specified_experts == ["elon_musk", "jeff_bezos"]
assert result.topic == "SpaceX上市问题"
def test_resolve_non_board_input(self):
"""普通输入不匹配"""
router = BoardRouter(template_registry=_make_registry_with_experts())
result = router.resolve("你好,今天天气怎么样")
assert result.matched is False
assert result.board_mode is False
assert result.topic == "你好,今天天气怎么样"
def test_resolve_empty_topic(self):
"""@board 无主题 → 空主题"""
router = BoardRouter(template_registry=_make_registry_with_experts())
result = router.resolve("@board")
assert result.matched is True
assert result.topic == ""
def test_resolve_invalid_expert_names_filtered(self):
"""无效专家名被过滤"""
router = BoardRouter(template_registry=_make_registry_with_experts())
result = router.resolve("@board:elon_musk,invalid@name,jeff_bezos 主题")
assert result.matched is True
assert "elon_musk" in result.specified_experts
assert "jeff_bezos" in result.specified_experts
assert "invalid@name" not in result.specified_experts
def test_resolve_max_experts_limit(self):
"""专家数量超过上限被截断"""
router = BoardRouter(template_registry=_make_registry_with_experts())
# 构造超过 MAX_EXPERTS 个专家名
names = ",".join(f"expert_{i}" for i in range(MAX_EXPERTS + 5))
result = router.resolve(f"@board:{names} 讨论主题")
assert result.matched is True
assert len(result.specified_experts) <= MAX_EXPERTS
def test_resolve_default_template_fallback(self):
"""无注册中心时使用硬编码回退默认成员"""
router = BoardRouter(template_registry=ExpertTemplateRegistry())
result = router.resolve("@board 讨论主题")
assert result.matched is True
assert result.use_default_template is True
# 回退到硬编码列表
assert len(result.specified_experts) > 0
assert "elon_musk" in result.specified_experts
# ── BoardRouter.resolve_expert_configs 测试 ────────────────
class TestBoardRouterResolveConfigs:
"""BoardRouter.resolve_expert_configs 配置解析测试"""
def test_resolve_configs_from_templates(self):
"""从注册模板解析专家配置"""
registry = _make_registry_with_experts()
router = BoardRouter(template_registry=registry)
configs = router.resolve_expert_configs(["elon_musk", "jeff_bezos"])
assert len(configs) == 2
assert configs[0].name == "elon_musk"
assert configs[0].is_lead is True # 第一个为主持人
assert configs[1].name == "jeff_bezos"
assert configs[1].is_lead is False
# 验证 board 模式字段
assert configs[0].speaking_style == "直接"
assert configs[0].decision_framework == "分析"
def test_resolve_configs_dynamic_generation(self):
"""未注册的专家名动态生成配置"""
router = BoardRouter(template_registry=ExpertTemplateRegistry())
configs = router.resolve_expert_configs(["unknown_expert"])
assert len(configs) == 1
assert configs[0].name == "unknown_expert"
assert configs[0].is_lead is True
def test_resolve_configs_first_is_moderator(self):
"""第一个专家自动设为主持人"""
registry = _make_registry_with_experts()
router = BoardRouter(template_registry=registry)
configs = router.resolve_expert_configs(["elon_musk", "jeff_bezos", "allenzhang"])
assert configs[0].is_lead is True
assert configs[1].is_lead is False
assert configs[2].is_lead is False
def test_resolve_configs_empty_list(self):
"""空列表返回空配置"""
router = BoardRouter(template_registry=_make_registry_with_experts())
configs = router.resolve_expert_configs([])
assert len(configs) == 0
def test_resolve_configs_invalid_name_skipped(self):
"""无效专家名被跳过"""
router = BoardRouter(template_registry=_make_registry_with_experts())
configs = router.resolve_expert_configs(["elon_musk", "invalid@name", "jeff_bezos"])
assert len(configs) == 2
assert configs[0].name == "elon_musk"
assert configs[1].name == "jeff_bezos"
def test_resolve_configs_ensure_at_least_one_lead(self):
"""确保至少有一个主持人"""
registry = _make_registry_with_experts()
# 修改模板使 is_lead 全为 False
for name in ["elon_musk", "jeff_bezos"]:
template = registry.get(name)
if template:
template.config.is_lead = False
router = BoardRouter(template_registry=registry)
configs = router.resolve_expert_configs(["elon_musk", "jeff_bezos"])
# 第一个应被强制设为 lead
assert configs[0].is_lead is True
# ── BoardRoutingResult 数据类测试 ──────────────────────────
class TestBoardRoutingResult:
"""BoardRoutingResult 数据类测试"""
def test_default_values(self):
"""默认值"""
result = BoardRoutingResult()
assert result.matched is False
assert result.board_mode is False
assert result.specified_experts == []
assert result.topic == ""
assert result.use_default_template is False
assert result.match_method == ""
def test_custom_values(self):
"""自定义值"""
result = BoardRoutingResult(
matched=True,
board_mode=True,
specified_experts=["a", "b"],
topic="测试主题",
use_default_template=True,
match_method="explicit_board",
)
assert result.matched is True
assert result.board_mode is True
assert result.specified_experts == ["a", "b"]
assert result.topic == "测试主题"
assert result.use_default_template is True
assert result.match_method == "explicit_board"

View File

@ -0,0 +1,247 @@
"""BoardTeam 单元测试 — 私董会容器状态管理"""
from __future__ import annotations
import pytest
from agentkit.experts.board import BoardStatus, BoardTeam
from agentkit.experts.config import ExpertConfig
# ── 辅助函数 ──────────────────────────────────────────────
def _make_expert_configs(count: int = 3) -> list[ExpertConfig]:
"""创建测试用 ExpertConfig 列表"""
configs = []
for i in range(count):
configs.append(ExpertConfig(
name=f"expert_{i}",
agent_type="expert",
persona=f"测试专家 {i}",
thinking_style="analytical",
speaking_style="直接",
decision_framework="分析",
bound_skills=[],
is_lead=(i == 0),
task_mode="llm_generate",
prompt={"identity": f"Expert {i}"},
avatar="🎭",
color=f"#FF{i:02d}000",
))
return configs
# ── BoardStatus 枚举测试 ──────────────────────────────────
class TestBoardStatus:
"""BoardStatus 枚举测试"""
def test_status_values(self):
"""状态值正确"""
assert BoardStatus.FORMING.value == "forming"
assert BoardStatus.DISCUSSING.value == "discussing"
assert BoardStatus.CONCLUDING.value == "concluding"
assert BoardStatus.COMPLETED.value == "completed"
assert BoardStatus.DISSOLVED.value == "dissolved"
def test_status_is_string_enum(self):
"""BoardStatus 是 str enum"""
assert isinstance(BoardStatus.FORMING, str)
assert BoardStatus.FORMING == "forming"
# ── BoardTeam 初始化测试 ──────────────────────────────────
class TestBoardTeamInit:
"""BoardTeam 初始化测试"""
def test_default_init(self):
"""默认初始化"""
team = BoardTeam()
assert team.team_id # UUID 自动生成
assert team.status == BoardStatus.FORMING
assert team.moderator is None
assert team.experts == []
assert team.active_experts == []
assert team.member_experts == []
assert team.topic == ""
assert team.current_round == 0
assert team.max_rounds == 5
assert team.history == []
assert team.team_channel.startswith("board:")
def test_custom_max_rounds(self):
"""自定义最大轮次"""
team = BoardTeam(max_rounds=10)
assert team.max_rounds == 10
def test_custom_team_id(self):
"""自定义 team_id"""
team = BoardTeam(team_id="custom-board-123")
assert team.team_id == "custom-board-123"
assert team.team_channel == "board:custom-board-123"
# ── BoardTeam 讨论历史测试 ────────────────────────────────
class TestBoardTeamHistory:
"""BoardTeam 讨论历史管理测试"""
@pytest.mark.asyncio
async def test_add_to_history(self):
"""添加历史记录"""
team = BoardTeam()
await team.add_to_history(round=1, expert_name="expert_0", content="测试发言", role="expert")
history = team.history
assert len(history) == 1
assert history[0]["round"] == 1
assert history[0]["expert_name"] == "expert_0"
assert history[0]["content"] == "测试发言"
assert history[0]["role"] == "expert"
assert "timestamp" in history[0]
@pytest.mark.asyncio
async def test_add_multiple_to_history(self):
"""添加多条历史记录"""
team = BoardTeam()
await team.add_to_history(1, "expert_0", "发言1", "expert")
await team.add_to_history(1, "expert_1", "发言2", "expert")
await team.add_to_history(1, "expert_0", "小结", "moderator")
assert len(team.history) == 3
def test_get_history_text_empty(self):
"""空历史返回空字符串"""
team = BoardTeam()
assert team.get_history_text() == ""
@pytest.mark.asyncio
async def test_get_history_text_formatted(self):
"""历史文本格式化"""
team = BoardTeam()
await team.add_to_history(1, "elon_musk", "第一性原理", "expert")
await team.add_to_history(1, "moderator", "本轮小结", "moderator")
text = team.get_history_text()
assert "elon_musk" in text
assert "第一性原理" in text
assert "moderator" in text
assert "本轮小结" in text
assert "专家发言" in text
assert "主持人小结" in text
@pytest.mark.asyncio
async def test_get_history_text_up_to_round(self):
"""按轮次过滤历史"""
team = BoardTeam()
await team.add_to_history(1, "expert_0", "第一轮", "expert")
await team.add_to_history(2, "expert_0", "第二轮", "expert")
await team.add_to_history(3, "expert_0", "第三轮", "expert")
text = team.get_history_text(up_to_round=2)
assert "第一轮" in text
assert "第二轮" in text
assert "第三轮" not in text
# ── BoardTeam 用户干预测试 ────────────────────────────────
class TestBoardTeamIntervention:
"""BoardTeam 用户干预测试"""
@pytest.mark.asyncio
async def test_add_user_intervention(self):
"""添加用户干预"""
team = BoardTeam()
await team.add_user_intervention("请讨论AI伦理问题")
# 干预应出现在历史中
history = team.history
assert len(history) == 1
assert history[0]["expert_name"] == "user"
assert history[0]["content"] == "请讨论AI伦理问题"
assert history[0]["role"] == "user"
@pytest.mark.asyncio
async def test_consume_user_interventions(self):
"""消费用户干预(读取后清空)"""
team = BoardTeam()
await team.add_user_intervention("干预1")
await team.add_user_intervention("干预2")
interventions = team.consume_user_interventions()
assert len(interventions) == 2
assert "干预1" in interventions
assert "干预2" in interventions
# 再次消费应为空
assert team.consume_user_interventions() == []
# ── BoardTeam 轮次管理测试 ────────────────────────────────
class TestBoardTeamRound:
"""BoardTeam 轮次管理测试"""
def test_increment_round(self):
"""轮次递增"""
team = BoardTeam()
assert team.current_round == 0
r1 = team.increment_round()
assert r1 == 1
assert team.current_round == 1
r2 = team.increment_round()
assert r2 == 2
assert team.current_round == 2
# ── BoardTeam 状态管理测试 ────────────────────────────────
class TestBoardTeamStatus:
"""BoardTeam 状态管理测试"""
def test_set_status(self):
"""设置状态"""
team = BoardTeam()
assert team.status == BoardStatus.FORMING
team.set_status(BoardStatus.DISCUSSING)
assert team.status == BoardStatus.DISCUSSING
team.set_status(BoardStatus.COMPLETED)
assert team.status == BoardStatus.COMPLETED
team.set_status(BoardStatus.DISSOLVED)
assert team.status == BoardStatus.DISSOLVED
# ── BoardTeam 属性测试 ────────────────────────────────────
class TestBoardTeamProperties:
"""BoardTeam 属性测试"""
def test_handoff_transport_exists(self):
"""handoff_transport 存在"""
team = BoardTeam()
assert team.handoff_transport is not None
def test_workspace_exists(self):
"""workspace 存在"""
team = BoardTeam()
assert team.workspace is not None
def test_get_expert_not_found(self):
"""获取不存在的专家返回 None"""
team = BoardTeam()
assert team.get_expert("nonexistent") is None