feat: Expert Team Mode — plan-execute collaboration with conversation UI

Implements B+C hybrid Expert Team Mode with ExpertConfig, CollaborationPlan,
TeamOrchestrator, ExpertTeamRouter, HandoffTransport, SharedWorkspace, and
Expert wrapper. Frontend includes ExpertTeamView, ExpertMessage,
PlanVisualization, team store, and WS event handlers.

Code review fixes: sentinel-based close, per-phase retry, name validation,
Vue component integration, teamState dedup, Redis reset, plan reassign,
event_type validation, hmac timing-safe compare, message dedup,
reactive updatePhases, O(1) phase lookup, iterative DFS, bounded Queue.

232 unit tests passing.
This commit is contained in:
chiguyong 2026-06-14 22:20:14 +08:00
parent baaa7089cd
commit 7384ecb03e
31 changed files with 7003 additions and 1 deletions

View File

@ -0,0 +1,207 @@
---
date: 2026-06-14
topic: expert-team-mode
---
## Summary
在对话框中引入专家团模式Expert Team Mode支持用户手动指定或系统自动组建多专家协作团队。底层采用 Plan-Execute 协作模式(结构化协作计划 + 去中心化执行前端以多角色对话流呈现协作过程。Expert 是比 Skill 更高的角色抽象,聚合多个 Skill 并包含人格、思维方式和协作策略。
## Problem Frame
当前 AgentKit 的多 Agent 协作依赖 Orchestrator-Worker 的集中式编排或 Pipeline 的 DAG 流程驱动。这两种模式适合结构化、可预测的工作流,但无法支撑需要多专家自主协作、实时讨论、动态调整的复杂任务场景。用户在对话框中面对复杂任务时,只能与单个 Agent 交互,无法获得多视角、多专长的协作产出。类似 Qoder 的专家团模式填补了这一空白——让多个专家以目标为导向,自主分析、分工、执行、验证、汇报。
## Key Decisions
- **Plan-Execute 而非纯 Swarm**结构化协作计划保证任务收敛和可预测性Expert 在职责范围内自主协作(去中心化),但整体框架由计划约束。纯 Swarm 模式自由度最高但收敛风险大。
- **Expert 聚合 Skill**Expert 是比 Skill 更高的角色抽象。一个 Expert 聚合多个 Skill并包含人格设定、思维方式、协作策略。Skill 是能力单元Expert 是角色单元。
- **混合 Agent 生成**:核心角色从预定义专家模板库选择(保证质量),辅助角色由 LLM 根据任务现场动态生成(保持灵活性)。
- **前端对话流展示**:协作过程以多角色对话流呈现给用户,每个 Expert 有独立头像和颜色标识。底层是结构化计划驱动,前端是对话式体验。
- **两种并行模式统一支持**:子任务级并行(各做各的,结果汇总)和竞标式并行(同一任务多人做,取最优/融合)都在协作计划中标注,执行引擎按类型处理。
## Actors
- A1. **用户** — 发起任务、指定专家团成员、干预协作过程(调整分工、增减 Expert、修改方向
- A2. **Lead Expert** — 首个加入团队的 Expert 或用户指定的负责人,负责初始任务分解、协作计划生成、最终结果汇总
- A3. **Expert** — 具有特定专长的角色,在职责范围内自主工作,可与其他 Expert 直接交互和交接
- A4. **ExpertTeam** — 专家团容器,管理 Expert 的加入/退出、共享上下文、协作计划的生命周期
- A5. **ExpertTemplate Registry** — 专家模板注册中心,存储预定义的 Expert 模板供选择和组装
## Requirements
### Expert 定义与管理
- R1. Expert 配置包含:名称、人格描述、思维方式、绑定的 Skill 列表、协作策略偏好、头像/颜色标识
- R2. Expert 可聚合一个或多个已注册 Skill执行时按 SkillConfig 驱动
- R3. ExpertTemplate 是可复用的 Expert 预设,存储在 YAML 配置或注册中心中,包含完整的 Expert 配置
- R4. ExpertTemplate Registry 支持模板的注册、查询、列表展示
- R5. 临时 Expert 由 LLM 根据任务分析动态生成 ExpertConfig包含角色名称、职责描述、建议绑定的 Skill
### 专家团组建
- R6. 用户可通过对话框手动指定专家团成员(选择 ExpertTemplate 或指定角色描述)
- R7. Lead Expert 在接收任务后评估复杂度,建议升级为专家团模式(需用户确认,不自动强制升级)
- R8. 自动组建时Lead Expert 分析任务后生成协作计划,计划中包含需要的 Expert 角色定义
- R9. 自动组建采用混合模式:核心角色从模板库匹配,辅助角色动态生成
- R10. 专家团组建后,所有 Expert 的角色信息对用户可见
### 协作计划
- R11. CollaborationPlan 定义专家团的结构化协作蓝图,包含:任务分解、角色分工、依赖关系、并行节点、合并策略
- R12. 协作计划中的每个节点标注执行类型:串行、子任务级并行、竞标式并行
- R13. 竞标式并行节点需指定评判策略:取最优、投票、融合
- R14. 协作计划可由 Lead Expert 或用户动态修改;普通 Expert 可提议修改,需 Lead Expert 审批后生效
- R15. 计划修改后,受影响的 Expert 立即感知变更并调整行为
### 去中心化协作
- R16. Expert 之间可直接交互、请求协助、交接任务,无需经过 Lead Expert 中转
- R17. Expert 间的交互通过共享对话流进行,所有消息对团队内所有 Expert 可见;上下文管理采用摘要共享策略(长内容自动摘要后广播,原始内容存入 SharedWorkspace 按需获取)
- R18. Lead Expert 负责初始分解和最终汇总,但不控制中间流程
- R19. Expert 通过共享上下文中的角色描述了解其他 Expert 的专长,可据此发起协作请求
### 并行执行与结果合并
- R20. 子任务级并行:多个 Expert 同时执行不同子任务,结果由 Lead Expert 或指定 Expert 汇总
- R21. 竞标式并行:多个同类型 Expert 独立完成同一子任务,结果按评判策略处理
- R22. 评判策略"取最优":由 Lead Expert 或指定评审 Expert 选择最佳结果
- R23. 评判策略"投票":所有 Expert 投票选择最佳结果,平局时由 Lead Expert 仲裁
- R24. 评判策略"融合":由 Lead Expert 或指定 Expert 将多个结果融合为统一产出
### 用户干预
- R25. 用户可随时在对话流中插入指令,指令对全体 Expert 可见
- R26. 用户可调整专家团分工(修改协作计划)
- R27. 用户可增减 Expert添加新 Expert 或移除已有 Expert
- R28. 用户可修改任务方向或补充需求Lead Expert 据此重新规划
### 前端展示
- R29. 协作过程以多角色对话流形式呈现,每个 Expert 的消息带有独立头像和颜色标识
- R30. 协作计划以可视化方式展示(分工图/时间线),用户可展开查看详情
- R31. 并行执行时,多个 Expert 的消息流通过消息标签expert_id复用同一 WebSocket 通道同时更新,用户可按 Expert 切换关注焦点
- R32. Expert 间的交接、请求协助等交互以特殊消息类型展示(区别于普通对话)
### 触发与路由
- R33. Lead Expert 在接收任务后评估复杂度,建议升级为专家团模式(与 R7 一致)
- R34. 用户可通过 `@team` 或类似指令主动触发专家团模式
- R35. 用户指定专家团成员时,跳过自动分析,直接进入协作计划生成
- R36. 专家团任务完成后,团队自动解散,临时 Expert 被回收;临时 Expert 的产出保留在 SharedWorkspace 中不随实例销毁丢失
## Key Decisions (Updated)
- **Expert 与 Agent 的关系**Expert 是 Agent 的配置层ExpertConfig运行时通过 AgentPool 创建对应的 ConfigDrivenAgent 实例。Expert 本身不是 Agent 实例,而是角色定义 + Skill 聚合 + 协作策略的配置单元。
## Key Flows
- F1. 手动组建专家团
- **Trigger:** 用户在对话框中指定专家团成员
- **Actors:** A1, A4, A2
- **Steps:** 用户指定 ExpertTemplate 或角色描述 → ExpertTeam 创建 → Lead Expert 生成 CollaborationPlan → 计划展示给用户确认 → Expert 按计划开始协作
- **Covered by:** R6, R10, R11, R35
- F2. 自动组建专家团
- **Trigger:** 系统检测到高复杂度任务或用户请求专家团
- **Actors:** A1, A4, A2, A5
- **Steps:** Lead Expert 分析任务 → 识别需要的 Expert 角色 → 核心角色从模板库匹配、辅助角色动态生成 → 生成 CollaborationPlan → 计划展示给用户确认 → Expert 按计划开始协作
- **Covered by:** R7, R8, R9, R11
- F3. 去中心化协作执行
- **Trigger:** CollaborationPlan 确认后开始执行
- **Actors:** A2, A3, A4
- **Steps:** Expert 按计划执行各自任务 → Expert 间可直接交互和交接 → 并行节点同时执行 → 结果按合并策略处理 → Lead Expert 汇总最终产出
- **Covered by:** R16, R17, R18, R20, R21
- F4. 用户干预协作
- **Trigger:** 用户在对话流中插入指令
- **Actors:** A1, A4, A2
- **Steps:** 用户发出指令 → 指令对全体 Expert 可见 → Lead Expert 评估影响 → 修改 CollaborationPlan如需要 → 受影响 Expert 调整行为
- **Covered by:** R25, R26, R27, R28
- F5. 竞标式并行执行
- **Trigger:** CollaborationPlan 中某节点标注为竞标式并行
- **Actors:** A3, A2
- **Steps:** 多个同类型 Expert 独立完成同一子任务 → 各自提交结果 → 按评判策略处理(取最优/投票/融合) → 产出最终结果
- **Covered by:** R21, R22, R23, R24
- F6. 专家团解散
- **Trigger:** 任务完成或用户主动解散
- **Actors:** A4, A1
- **Steps:** Lead Expert 汇总最终结果 → 结果呈现给用户 → 临时 Expert 被回收 → ExpertTeam 销毁 → 模板 Expert 保留在注册中心
- **Covered by:** R36
## Acceptance Examples
- AE1. **手动组建 + 子任务并行**
- **Covers R6, R20.** Given 用户输入"帮我分析这份市场报告,叫上数据分析师和战略顾问", When 系统创建 ExpertTeam 并生成计划, Then 数据分析师和战略顾问各自独立分析Lead Expert 汇总两份分析为统一报告
- AE2. **自动组建 + 竞标并行**
- **Covers R7, R21, R22.** Given 用户输入一个复杂的技术方案评审任务, When 系统自动升级为专家团模式, Then Lead Expert 生成 3 个架构师 Expert 竞标式并行出方案Lead Expert 选择最优方案
- AE3. **用户干预调整方向**
- **Covers R25, R26, R28.** Given 专家团正在执行任务, When 用户在对话流中说"重点看成本优化,别管性能了", Then Lead Expert 修改计划,受影响 Expert 调整分析方向
- AE4. **Expert 间直接协作**
- **Covers R16, R17.** Given 数据分析师 Expert 需要行业数据, When 数据分析师直接请求行业研究员 Expert 协助, Then 行业研究员提供数据,无需 Lead Expert 中转
- AE5. **动态增减 Expert**
- **Covers R27.** Given 专家团正在执行, When 用户说"再加一个法律顾问", Then 系统从模板库匹配或动态生成法律顾问 Expert加入团队并更新计划
## Success Criteria
- 专家团能在 5 分钟内完成一个需要 3+ 专家协作的中等复杂度任务
- 用户能清晰追踪每个 Expert 的工作状态和产出
- 用户干预后Expert 在 1 轮交互内感知并响应变更
- 临时 Expert 在任务完成后被完全回收,不残留资源
## Scope Boundaries
**Deferred for later:**
- Expert 间的学习与进化机制(经验共享、能力提升)
- 跨会话的 Expert 持久化和记忆共享
- Expert 市场和共享模板库
- 专家团的 A/B 测试和效果评估
**Outside this product's identity:**
- 通用的工作流引擎(已有 PipelineEngine
- 人工审核节点(已有 Workflow approval
- 实时音视频协作
## Dependencies / Assumptions
- 依赖现有 `AgentPool` 管理 Agent 实例的创建和销毁
- 依赖现有 `MessageBus` 支持 Expert 间的消息通信
- 依赖现有 `HandoffManager` 支持 Expert 间的任务交接(需扩展为进程内模式)
- 依赖现有 `Orchestrator` 的任务分解能力GoalPlanner + LLM 分解)
- 依赖现有 `PipelineEngine` 的并行执行和拓扑排序
- 假设 LLM 能生成质量合格的 CollaborationPlan含角色定义、依赖关系、并行标注
- 假设前端 WebSocket 能支持多路并发消息流
## Outstanding Questions
**Resolve Before Planning:**
- 协作计划的粒度:定义大阶段和角色分工(粗粒度,灵活调整)还是精确到每步操作(细粒度,强约束)
- 协作计划失败时的降级策略:回退到单 Agent 还是重试
- HandoffManager 进程内模式的实现策略:扩展现有 HandoffManager 还是新建 InProcessHandoff
**Deferred to Planning:**
- ExpertTemplate 的 YAML Schema 设计
- 协作计划的可视化组件选型
- 并行消息流的前端渲染策略
- 竞标式并行执行引擎的扩展设计(当前 PipelineEngine 仅支持拓扑排序并行)
## Sources / Research
- `src/agentkit/core/orchestrator.py` — Orchestrator-Worker 编排模式,三级任务分解降级
- `src/agentkit/orchestrator/pipeline_engine.py` — DAG 拓扑并行执行、Saga 补偿、对抗闭环
- `src/agentkit/orchestrator/handoff.py` — Redis Pub/Sub 点对点任务转交
- `src/agentkit/orchestrator/dynamic_pipeline.py` — 条件/嵌套/循环动态 Pipeline
- `src/agentkit/core/agent_pool.py` — 运行时 Agent 实例管理
- `src/agentkit/core/shared_workspace.py` — Redis 共享状态 + 分布式锁
- `src/agentkit/chat/skill_routing.py` — CostAwareRouter 三层路由
- `src/agentkit/skills/base.py` — SkillConfig 定义ExpertConfig 可参考扩展

View File

@ -0,0 +1,573 @@
---
date: 2026-06-14
status: active
origin: docs/brainstorms/2026-06-14-expert-team-mode-requirements.md
---
## Summary
实现专家团模式Expert Team Mode在对话框中引入多专家协作能力。底层采用 Plan-Execute 协作模式(结构化协作计划 + 去中心化执行前端以多角色对话流呈现协作过程。Expert 是比 Skill 更高的角色抽象,聚合多个 Skill 并包含人格和协作策略。支持用户手动指定专家团和系统自动组建两种触发方式,支持子任务级并行和竞标式并行两种并行模式,支持全程用户干预。
## Problem Frame
当前 AgentKit 的多 Agent 协作依赖 Orchestrator-Worker 集中式编排或 Pipeline DAG 流程驱动,无法支撑多专家自主协作、实时讨论、动态调整的复杂任务场景。用户在对话框中只能与单个 Agent 交互,无法获得多视角、多专长的协作产出。专家团模式填补这一空白——让多个 Expert 以目标为导向,自主分析、分工、执行、验证、汇报。
## Requirements
Source: `docs/brainstorms/2026-06-14-expert-team-mode-requirements.md`
Key requirements traced to origin R-IDs:
- Expert 定义与管理 (R1-R5)
- 专家团组建 (R6-R10)
- 协作计划 (R11-R15)
- 去中心化协作 (R16-R19)
- 并行执行与结果合并 (R20-R24)
- 用户干预 (R25-R28)
- 前端展示 (R29-R32)
- 触发与路由 (R33-R36)
## Key Technical Decisions
- **KTD1: Expert 是 Agent 的配置层** — ExpertConfig 继承 AgentConfig运行时通过 AgentPool 创建 ConfigDrivenAgent 实例。Expert 本身不是 Agent 实例,而是角色定义 + Skill 聚合 + 协作策略的配置单元。Expert 运行时包装器Expert 类)持有 ConfigDrivenAgent 引用并添加团队感知行为。(see origin: Key Decisions Updated)
- **KTD2: Plan-Execute 协作模式** — CollaborationPlan 定义阶段和里程碑粗粒度Expert 在阶段内自主决定具体步骤(细粒度),但必须通过里程碑检查点。这平衡了灵活性和可控性。
- **KTD3: HandoffTransport 抽象层** — 引入 HandoffTransport 协议两个实现RedisHandoffTransport提取现有 Redis pub/sub 逻辑)和 InProcessHandoffTransportasyncio.Queue用于同进程 Expert 团队。HandoffManager 根据上下文自动选择 Transport。
- **KTD4: 前端多角色对话流** — 扩展 IChatMessage 增加 expert_id/expert_name/expert_color 字段WebSocket 消息增加 team_* 事件类型。多个 Expert 的消息通过 expert_id 标签复用同一 WebSocket 通道,前端按 Expert 过滤展示。
- **KTD5: 降级策略** — 协作计划失败时先重试 1 次(调整分解策略),仍失败则回退到单 Agent 模式继续完成任务。
- **KTD6: 计划修改权限** — Lead Expert 和用户可直接修改 CollaborationPlan普通 Expert 可提议修改,需 Lead Expert 审批后生效。
- **KTD7: 上下文管理** — 采用摘要共享策略:长内容自动摘要后广播给全体 Expert原始内容存入 SharedWorkspace 按需获取。避免上下文窗口膨胀。
## High-Level Technical Design
### Expert Team 架构总览
```
┌─────────────────────────────────────────────────────┐
│ Frontend │
│ ExpertTeamView ── ExpertMessage ── PlanViz │
│ │ │ │ │
│ └────────────────┼───────────────┘ │
│ │ WebSocket (team_* events) │
└────────────────────────┼────────────────────────────┘
┌────────────────────────┼────────────────────────────┐
│ Server │
│ TeamSessionManager ── ExpertTeamRouter │
│ │ │
│ ▼ │
│ ┌──────────┐ manages ┌──────────────┐ │
│ │ExpertTeam│──────────▶│ Expert[] │ │
│ │ │ │ (wrappers) │ │
│ │ Plan │ └──────┬───────┘ │
│ │ Context │ │ creates │
│ └────┬─────┘ ▼ │
│ │ ┌──────────────────┐ │
│ │ │ ConfigDrivenAgent│ │
│ │ │ (AgentPool) │ │
│ │ └──────────────────┘ │
│ │ │
│ ┌────▼─────────────────────────────┐ │
│ │ TeamOrchestrator │ │
│ │ execute_plan / merge / retry │ │
│ └──────────────────────────────────┘ │
│ │ │ │
│ ▼ ▼ │
│ HandoffTransport SharedWorkspace │
│ (InProcess/Redis) (Redis/InMemory) │
└─────────────────────────────────────────────────────┘
```
### CollaborationPlan 执行流程
```
用户任务 → ExpertTeamRouter → 创建 ExpertTeam
Lead Expert 生成 CollaborationPlan
用户确认计划 ──否──▶ 修改计划
TeamOrchestrator.execute_plan()
┌────────────┼────────────┐
▼ ▼ ▼
Phase 1 Phase 2 Phase 3
(串行) (子任务并行) (竞标并行)
│ │ │
▼ ▼ ▼
里程碑检查 里程碑检查 里程碑检查
│ │ │
└────────────┼────────────┘
Lead Expert 汇总最终结果
ExpertTeam 解散,临时 Expert 回收
```
### Expert 间协作消息流
```
Expert A ──send_message()──▶ TeamChannel ──broadcast──▶ Expert B, C, D
Expert A ──request_assist()──▶ InProcessHandoff ──▶ Expert B
Expert A ──propose_modification()──▶ Lead Expert ──approve──▶ Plan Update ──broadcast──▶ All Experts
User ──intervene()──▶ TeamChannel ──broadcast──▶ All Experts + Lead re-plans
```
---
## Implementation Units
### U1. ExpertConfig & ExpertTemplate & Registry
**Goal:** 建立 Expert 的数据模型层——ExpertConfig 配置、ExpertTemplate 模板、ExpertTemplateRegistry 注册中心。
**Requirements:** R1, R2, R3, R4
**Dependencies:** None
**Files:**
- `src/agentkit/experts/__init__.py` (create)
- `src/agentkit/experts/config.py` (create)
- `src/agentkit/experts/registry.py` (create)
- `tests/unit/experts/__init__.py` (create)
- `tests/unit/experts/test_config.py` (create)
- `tests/unit/experts/test_registry.py` (create)
**Approach:**
- `ExpertConfig` 继承 `AgentConfig`,新增字段:`persona`(人格描述)、`thinking_style`(思维方式)、`collaboration_strategy`(协作策略偏好)、`bound_skills`(绑定的 Skill 名称列表)、`avatar`(头像标识)、`color`(颜色标识)、`is_lead`(是否为 Lead Expert
- `ExpertTemplate``ExpertConfig` 的可复用预设包装,包含 `name`、`config`、`is_builtin`、`description` 字段
- `ExpertTemplateRegistry` 提供 `register(template)`、`get(name)`、`list()`、`search(query)` 方法,内存字典存储,支持从 YAML 目录批量加载
- 遵循 `SkillConfig` 继承 `AgentConfig` 的模式see `src/agentkit/skills/base.py`
**Patterns to follow:** `SkillConfig` extending `AgentConfig` in `src/agentkit/skills/base.py`; `SkillRegistry` pattern in `src/agentkit/skills/registry.py`
**Test scenarios:**
- Happy path: 创建 ExpertConfig 并验证所有字段
- Happy path: ExpertConfig 继承 AgentConfig 的基础字段
- Happy path: ExpertTemplate 注册和查询
- Happy path: ExpertTemplateRegistry 从 YAML 目录加载模板
- Edge case: bound_skills 为空列表时默认行为
- Edge case: search 查询无匹配结果返回空列表
- Error path: 注册同名 ExpertTemplate 覆盖旧模板
**Verification:** ExpertConfig 可实例化并序列化ExpertTemplateRegistry 可注册、查询、搜索模板
---
### U2. CollaborationPlan Data Model
**Goal:** 定义协作计划的数据结构——阶段、角色分工、依赖关系、并行类型、合并策略、里程碑。
**Requirements:** R11, R12, R13
**Dependencies:** U1
**Files:**
- `src/agentkit/experts/plan.py` (create)
- `tests/unit/experts/test_plan.py` (create)
**Approach:**
- `CollaborationPlan` 包含 `phases`PlanPhase 列表)、`variables`(共享变量)、`status`(计划状态)
- `PlanPhase` 包含:`id`、`name`、`assigned_expert`Expert 名称)、`task_description`、`depends_on`(依赖的 Phase ID 列表)、`parallel_type`ParallelType 枚举)、`merge_strategy`MergeStrategy 枚举,仅竞标式并行时使用)、`milestone`(里程碑检查点描述)、`status`PhaseStatus 枚举)
- `ParallelType` 枚举SERIAL、SUBTASK_PARALLEL、COMPETITIVE_PARALLEL
- `MergeStrategy` 枚举BEST、VOTE、FUSION
- `PhaseStatus` 枚举PENDING、IN_PROGRESS、COMPLETED、FAILED
- `PlanStatus` 枚举DRAFT、CONFIRMED、EXECUTING、COMPLETED、FAILED、FALLBACK
- 提供序列化/反序列化方法to_dict/from_dict供 SharedWorkspace 存储
**Patterns to follow:** `PipelineStage`/`Pipeline` in `src/agentkit/orchestrator/pipeline_schema.py`
**Test scenarios:**
- Happy path: 创建 CollaborationPlan 并添加 phases
- Happy path: 串行 phase 的依赖关系正确
- Happy path: 子任务级并行 phase 标注
- Happy path: 竞标式并行 phase 带合并策略
- Edge case: 依赖关系形成 DAG无环
- Edge case: 竞标式并行 phase 未指定 merge_strategy 时默认 BEST
- Error path: 依赖关系有环时验证失败
- Integration: to_dict/from_dict 往返序列化
**Verification:** CollaborationPlan 可构建、验证、序列化;依赖环检测正确
---
### U3. HandoffTransport Abstraction
**Goal:** 抽象 Handoff 传输层,支持 Redis 和进程内两种模式,使 Expert 间交接不依赖 Redis。
**Requirements:** R16
**Dependencies:** None
**Files:**
- `src/agentkit/core/handoff_transport.py` (create)
- `tests/unit/core/test_handoff_transport.py` (create)
**Approach:**
- 定义 `HandoffTransport` 协议Protocol`send(channel, message)`、`listen(channel) -> AsyncIterator`、`register_handler(channel, handler)`
- `RedisHandoffTransport`:提取 `BaseAgent.handoff()` 中的 Redis pub/sub 逻辑,封装为独立类
- `InProcessHandoffTransport`:基于 `asyncio.Queue` 的进程内传输,每个 channel 对应一个 Queue支持多消费者广播
- `HandoffManager` 重构:接受 `HandoffTransport` 注入,`send_handoff`/`listen_for_handoffs`/`register_handler` 委托给 transport
- 向后兼容:`HandoffManager()` 无参构造时自动创建 `RedisHandoffTransport`
**Patterns to follow:** `SharedWorkspace` 的双模式模式Redis + 内存降级in `src/agentkit/core/shared_workspace.py`
**Test scenarios:**
- Happy path: InProcessHandoffTransport 发送和接收消息
- Happy path: InProcessHandoffTransport 多消费者广播
- Happy path: HandoffManager 使用 InProcessHandoffTransport
- Edge case: InProcessHandoffTransport 队列为空时 listen 阻塞等待
- Error path: 发送到不存在的 channel 不抛异常(消息丢弃)
- Integration: HandoffManager 向后兼容(无参构造使用 Redis
**Verification:** InProcessHandoffTransport 可在同进程内传递消息HandoffManager 向后兼容
---
### U4. Expert Runtime Wrapper
**Goal:** 实现 Expert 运行时包装器——ExpertConfig → AgentPool 创建 ConfigDrivenAgent添加团队感知行为。
**Requirements:** R1, R2, R5, R16, R17, R19
**Dependencies:** U1, U3
**Files:**
- `src/agentkit/experts/expert.py` (create)
- `tests/unit/experts/test_expert.py` (create)
**Approach:**
- `Expert` 类持有 `ExpertConfig``ConfigDrivenAgent` 引用
- `Expert.create(config, pool)` → 调用 `AgentPool.create_agent()` 创建 ConfigDrivenAgent注入团队上下文到 system prompt角色描述、其他 Expert 的角色摘要)
- `Expert.send_message(channel, content)` → 通过 TeamChannel 广播消息给全体 Expert摘要共享原始内容存 SharedWorkspace
- `Expert.request_assist(target_expert, task)` → 通过 InProcessHandoff 向目标 Expert 交接任务
- `Expert.propose_plan_modification(plan_id, modification)` → 提交修改提议给 Lead Expert
- `Expert.get_capabilities_summary()` → 返回角色描述 + 绑定 Skill 列表,供其他 Expert 了解
- `Expert.destroy()` → 调用 `AgentPool.remove_agent()` 清理,产出保留在 SharedWorkspace
**Patterns to follow:** `ConfigDrivenAgent` lifecycle in `src/agentkit/core/config_driven.py`; `BaseAgent.handoff()` in `src/agentkit/core/base.py`
**Test scenarios:**
- Happy path: Expert.create 从 ExpertConfig 创建 ConfigDrivenAgent
- Happy path: Expert.send_message 广播到 TeamChannel
- Happy path: Expert.request_assist 通过 Handoff 交接任务
- Happy path: Expert.get_capabilities_summary 返回角色摘要
- Edge case: Expert 绑定多个 Skill 时全部注入 Agent
- Error path: Expert.create 时 AgentPool 中同名 Agent 已存在
- Integration: Expert 销毁后 ConfigDrivenAgent 被移除
**Verification:** Expert 可创建、发送消息、请求协助、销毁
---
### U5. ExpertTeam Container
**Goal:** 实现 ExpertTeam 容器——管理 Expert 生命周期、共享上下文、协作计划、团队状态。
**Requirements:** R4, R6, R8, R9, R10, R14, R15, R25, R27, R36
**Dependencies:** U1, U2, U4
**Files:**
- `src/agentkit/experts/team.py` (create)
- `tests/unit/experts/test_team.py` (create)
**Approach:**
- `ExpertTeam` 管理一组 Expert 实例,持有 `CollaborationPlan`、`SharedWorkspace` 引用、`TeamChannel`
- 团队生命周期FORMING → PLANNING → EXECUTING → SYNTHESIZING → COMPLETED
- `create_team(lead_config, member_configs)` → 创建 Lead Expert + 成员 Expert设置 InProcessHandoff
- `add_expert(config_or_template)` → 动态添加 ExpertR27
- `remove_expert(name)` → 动态移除 ExpertR27
- `update_plan(plan)` → Lead Expert 或用户修改计划R14广播变更给受影响 ExpertR15
- `get_shared_context()` → 返回团队共享上下文(通过 SharedWorkspace
- `broadcast_user_message(content)` → 用户干预消息广播给全体 ExpertR25
- `dissolve()` → 解散团队,临时 Expert 回收产出保留R36
- `generate_plan(task)` → Lead Expert 生成 CollaborationPlan混合模式核心角色从模板匹配辅助角色动态生成
**Patterns to follow:** `AgentPool` lifecycle management in `src/agentkit/core/agent_pool.py`
**Test scenarios:**
- Happy path: 创建 ExpertTeam 并设置 Lead Expert
- Happy path: 添加和移除 Expert
- Happy path: update_plan 广播变更给受影响 Expert
- Happy path: broadcast_user_message 对全体 Expert 可见
- Happy path: dissolve 回收临时 Expert
- Edge case: 移除 Lead Expert 时自动指定新 Lead
- Edge case: dissolve 时临时 Expert 产出保留在 SharedWorkspace
- Error path: 向已解散的 ExpertTeam 添加 Expert 抛异常
- Integration: generate_plan 混合模式(模板 + 动态生成)
**Verification:** ExpertTeam 可创建、管理 Expert、更新计划、广播消息、解散
---
### U6. TeamOrchestrator
**Goal:** 实现团队编排引擎——驱动 CollaborationPlan 执行、并行策略、结果合并、重试与降级。
**Requirements:** R12, R13, R18, R20, R21, R22, R23, R24
**Dependencies:** U2, U4, U5
**Files:**
- `src/agentkit/experts/orchestrator.py` (create)
- `tests/unit/experts/test_team_orchestrator.py` (create)
**Approach:**
- `TeamOrchestrator` 持有 `ExpertTeam` 引用,驱动 `CollaborationPlan` 执行
- `execute_plan(plan)` → 按 phase 依赖关系拓扑排序,逐层执行
- 串行 phase直接执行完成后检查里程碑
- 子任务级并行 phase`asyncio.gather` 并行执行,结果由 Lead Expert 汇总R20
- 竞标式并行 phase`asyncio.gather` 并行执行,结果按 MergeStrategy 处理R21-R24
- BESTLead Expert 选择最佳结果
- VOTE所有 Expert 投票,平局时 Lead Expert 仲裁R23
- FUSIONLead Expert 融合多个结果
- 里程碑检查phase 完成后 Expert 必须通过检查点才能进入下一阶段
- 失败处理:重试 1 次(调整分解策略),仍失败则回退单 AgentKTD5
- 执行事件:每个 phase 状态变更、并行结果合并都通过 TeamChannel 广播
**Patterns to follow:** `PipelineEngine._topological_group()` + `asyncio.gather` in `src/agentkit/orchestrator/pipeline_engine.py`; adversarial loop (Worker-Verifier) pattern
**Test scenarios:**
- Happy path: 串行 phase 依次执行
- Happy path: 子任务级并行 phase 并行执行后汇总
- Happy path: 竞标式并行 BEST 策略
- Happy path: 竞标式并行 VOTE 策略(含平局仲裁)
- Happy path: 竞标式并行 FUSION 策略
- Happy path: 里程碑检查通过后进入下一阶段
- Edge case: 并行 phase 部分失败时的处理
- Error path: phase 失败后重试 1 次
- Error path: 重试仍失败回退单 Agent
- Integration: 完整计划执行(串行+并行混合)
**Verification:** TeamOrchestrator 可执行计划、处理并行、合并结果、重试降级
---
### U7. Expert Team Routing
**Goal:** 扩展路由系统支持专家团模式——@team 触发、复杂度评估升级、团队路由。
**Requirements:** R7, R33, R34, R35
**Dependencies:** U5, U6
**Files:**
- `src/agentkit/experts/router.py` (create)
- `src/agentkit/chat/skill_routing.py` (modify)
- `tests/unit/experts/test_router.py` (create)
**Approach:**
- 扩展 `ExecutionMode` 枚举新增 `TEAM_COLLAB`
- `ExpertTeamRouter` 解析用户输入:
- `@team` 前缀 → 触发专家团模式R34
- `@team:analyst,strategist` → 指定专家团成员R35
- 无前缀 → Lead Expert 评估复杂度后建议升级R7, R33
- 修改 `CostAwareRouter.route()`:当 `execution_mode == TEAM_COLLAB` 时,委托给 `ExpertTeamRouter`
- `ExpertTeamRouter.resolve()` → 返回 `ExpertTeamRoutingResult`(包含 team 配置、plan、execution_mode
- 复杂度评估:在 `quick_classify` 返回高复杂度(>0.7)时,附加 team_suggestion 标记
**Patterns to follow:** `CostAwareRouter` three-layer routing in `src/agentkit/chat/skill_routing.py`
**Test scenarios:**
- Happy path: @team 前缀触发专家团模式
- Happy path: @team:analyst,strategist 指定专家团成员
- Happy path: 高复杂度任务建议升级为专家团
- Happy path: 低复杂度任务不触发专家团
- Edge case: @team 后无成员描述时自动组建
- Edge case: 指定的 ExpertTemplate 不存在时回退到动态生成
- Integration: CostAwareRouter 集成 TEAM_COLLAB 模式
**Verification:** @team 触发、复杂度升级、指定成员路由均正确工作
---
### U8. Frontend Data Model & WebSocket Protocol
**Goal:** 扩展前端数据模型和 WebSocket 协议支持专家团消息流。
**Requirements:** R29, R31, R32
**Dependencies:** U6
**Files:**
- `src/agentkit/server/frontend/src/types/chat.ts` (modify)
- `src/agentkit/server/routes/portal.py` (modify)
- `src/agentkit/server/routes/chat.py` (modify)
- `src/agentkit/server/frontend/src/stores/chat.ts` (modify)
**Approach:**
- 扩展 `IChatMessage` 接口:新增 `expert_id?: string`、`expert_name?: string`、`expert_color?: string`、`message_type?: 'chat' | 'handoff' | 'assist_request' | 'plan_update' | 'milestone'`
- 新增 WebSocket 事件类型:
- `team_formed`:专家团组建完成,包含 Expert 列表和 Plan
- `expert_step`Expert 执行步骤(带 expert_id 标签)
- `expert_result`Expert 完成子任务
- `plan_update`:协作计划变更
- `team_synthesis`Lead Expert 汇总最终结果
- `team_dissolved`:专家团解散
- 服务端:新增 `TeamSessionManager` 管理 ExpertTeam 实例,与 `SessionManager` 协同
- `chat.ts` store 扩展:处理 team_* 事件,维护 team 状态
**Patterns to follow:** Existing WebSocket event handling in `src/agentkit/server/routes/chat.py`; `IChatMessage` interface in `src/agentkit/server/frontend/src/types/chat.ts`
**Test scenarios:**
- Happy path: team_formed 事件正确解析 Expert 列表
- Happy path: expert_step 事件带 expert_id 标签
- Happy path: plan_update 事件触发前端状态更新
- Edge case: 非专家团消息不受影响(向后兼容)
- Integration: 完整 team 事件流formed → step → result → synthesis → dissolved
**Verification:** 前端可接收和解析所有 team_* 事件;非专家团消息不受影响
---
### U9. ExpertTeam UI Components
**Goal:** 实现专家团前端组件——多角色对话流、计划可视化、Expert 过滤。
**Requirements:** R29, R30, R31, R32
**Dependencies:** U8
**Files:**
- `src/agentkit/server/frontend/src/components/chat/ExpertTeamView.vue` (create)
- `src/agentkit/server/frontend/src/components/chat/ExpertMessage.vue` (create)
- `src/agentkit/server/frontend/src/components/chat/PlanVisualization.vue` (create)
- `src/agentkit/server/frontend/src/stores/team.ts` (create)
**Approach:**
- `ExpertTeamView.vue`:专家团状态栏,显示活跃 Expert 列表(头像+颜色+状态),计划可视化切换按钮
- `ExpertMessage.vue`:扩展 ChatMessage添加 Expert 头像、颜色标识、角色 badgehandoff/assist_request 消息类型用特殊样式展示
- `PlanVisualization.vue`:协作计划时间线/DAG 可视化,显示 phase 状态、Expert 分工、依赖关系;用户可展开查看详情
- `team.ts` store管理 team 状态active experts, plan, phase progress提供按 Expert 过滤消息的 computed
- ChatView 集成:当 conversation 进入专家团模式时,切换到 ExpertTeamView 布局
**Patterns to follow:** `ChatMessage.vue` component structure; `MentionDropdown.vue` for dropdown patterns; `chat.ts` store for state management
**Test scenarios:**
- Happy path: ExpertMessage 正确显示 Expert 头像和颜色
- Happy path: PlanVisualization 显示协作计划时间线
- Happy path: 按 Expert 过滤消息
- Happy path: handoff 消息特殊样式展示
- Edge case: Expert 被移除后消息保留但标记为已退出
- Edge case: 并行执行时多个 Expert 消息同时更新
**Verification:** 专家团模式下前端正确显示多角色对话流和计划可视化
---
### U10. Integration Tests
**Goal:** 端到端集成测试——验证专家团完整流程。
**Requirements:** All R-IDs
**Dependencies:** U1-U9
**Files:**
- `tests/integration/test_expert_team.py` (create)
**Approach:**
- 测试覆盖所有 Key FlowsF1-F6和 Acceptance ExamplesAE1-AE5
- 使用 mock LLM 和 mock tools验证编排逻辑而非 LLM 输出质量
- 测试场景:
1. 手动组建专家团 + 子任务并行F1, AE1
2. 自动组建专家团 + 竞标并行F2, AE2
3. 去中心化协作执行F3, AE4
4. 用户干预协作F4, AE3
5. 竞标式并行执行 + 投票仲裁F5
6. 专家团解散 + 产出保留F6, AE5
7. 降级策略:重试 + 回退单 Agent
8. 动态增减 Expert
**Patterns to follow:** `tests/integration/test_gap_closure.py` integration test patterns
**Test scenarios:**
- Covers F1. 手动组建专家团
- Covers F2. 自动组建专家团
- Covers F3. 去中心化协作执行
- Covers F4. 用户干预协作
- Covers F5. 竞标式并行执行
- Covers F6. 专家团解散
- Covers AE1. 手动组建 + 子任务并行
- Covers AE2. 自动组建 + 竞标并行
- Covers AE3. 用户干预调整方向
- Covers AE4. Expert 间直接协作
- Covers AE5. 动态增减 Expert
**Verification:** 所有 Key Flows 和 Acceptance Examples 通过集成测试
---
## Scope Boundaries
**In scope:**
- Expert/ExpertTeam/CollaborationPlan 核心抽象
- HandoffTransport 进程内传输
- TeamOrchestrator 编排引擎(串行 + 两种并行 + 三种合并策略)
- ExpertTeamRouter 路由扩展
- 前端多角色对话流和计划可视化
- 端到端集成测试
**Deferred to follow-up work:**
- Expert 间的学习与进化机制
- 跨会话的 Expert 持久化和记忆共享
- Expert 市场和共享模板库
- 专家团的 A/B 测试和效果评估
- ExpertTemplate YAML 文件的标准模板集(本计划只实现框架,不提供大量预置模板)
- 计划可视化的高级交互(拖拽调整、实时编辑)
- 竞标式并行的更复杂评判策略(加权投票、多轮评审)
**Outside this product's identity:**
- 通用工作流引擎(已有 PipelineEngine
- 人工审核节点(已有 Workflow approval
- 实时音视频协作
## Risks & Dependencies
| Risk | Impact | Mitigation |
|------|--------|------------|
| LLM 生成的 CollaborationPlan 质量不稳定 | 高:劣质计划导致协作效率低 | Plan 验证逻辑(依赖环检测、必填字段检查);用户确认环节;重试降级策略 |
| 去中心化协作可能陷入循环讨论 | 中Expert 反复交互不收敛 | 里程碑检查点强制推进最大交互轮次限制Lead Expert 可强制推进 |
| 多 Expert 并行时上下文窗口膨胀 | 中LLM 调用成本增加、可能超限 | 摘要共享策略KTD7SharedWorkspace 按需获取原始内容 |
| 前端多路消息流渲染性能 | 低:大量并行消息可能导致 UI 卡顿 | 消息批量更新;虚拟滚动;按 Expert 过滤减少渲染量 |
| HandoffTransport 重构影响现有 Handoff | 中:可能破坏现有 Agent 间交接 | 向后兼容设计(无参构造使用 Redis充分单元测试 |
**Dependencies:**
- 依赖现有 `AgentPool` 管理 Agent 实例
- 依赖现有 `SharedWorkspace` 支持共享上下文
- 依赖现有 `CostAwareRouter` 三层路由架构
- 依赖前端 WebSocket 基础设施
- 依赖 LLM 能生成质量合格的 CollaborationPlan
## Open Questions
- ExpertTemplate YAML 文件的标准目录位置(建议 `config/experts/`,待确认)
- 竞标式并行中"融合"策略的具体实现——是 LLM 融合还是规则融合(建议 LLM 融合,待确认)
- 前端计划可视化组件的选型——自研还是使用第三方库(建议自研轻量时间线,待确认)
## Sources & Research
- `src/agentkit/core/base.py` — BaseAgent 生命周期、Handoff、Progress 上报
- `src/agentkit/core/config_driven.py` — ConfigDrivenAgent、AgentConfig、执行模式
- `src/agentkit/skills/base.py` — SkillConfig 继承 AgentConfig 的模式
- `src/agentkit/core/agent_pool.py` — AgentPool 生命周期管理
- `src/agentkit/core/shared_workspace.py` — SharedWorkspace 双模式Redis + 内存)
- `src/agentkit/core/orchestrator.py` — Orchestrator-Worker 编排模式
- `src/agentkit/orchestrator/pipeline_engine.py` — DAG 拓扑并行、Saga 补偿
- `src/agentkit/orchestrator/handoff.py` — Redis Pub/Sub Handoff
- `src/agentkit/chat/skill_routing.py` — CostAwareRouter 三层路由
- `src/agentkit/server/routes/chat.py` — Chat WebSocket 消息协议
- `src/agentkit/server/frontend/src/types/chat.ts` — IChatMessage 接口
- `src/agentkit/server/frontend/src/stores/chat.ts` — Chat Pinia Store

View File

@ -33,6 +33,7 @@ class ExecutionMode(enum.Enum):
DIRECT_CHAT = "direct_chat" # Zero-cost: direct LLM call, no ReAct loop DIRECT_CHAT = "direct_chat" # Zero-cost: direct LLM call, no ReAct loop
REACT = "react" # Default agent ReAct loop with default tools REACT = "react" # Default agent ReAct loop with default tools
SKILL_REACT = "skill_react" # Skill-matched ReAct with skill tools + prompt SKILL_REACT = "skill_react" # Skill-matched ReAct with skill tools + prompt
TEAM_COLLAB = "team_collab" # Expert Team collaborative mode
def validate_skill_name(name: str) -> str: def validate_skill_name(name: str) -> str:

View File

@ -0,0 +1,248 @@
"""HandoffTransport - Agent 间 Handoff 传输层抽象
提供统一的传输接口支持
- InProcessHandoffTransport: 进程内 asyncio.Queue 传输Expert Team 模式
- RedisHandoffTransport: Redis Pub/Sub 传输分布式模式
"""
from __future__ import annotations
import asyncio
import json
import logging
from typing import AsyncIterator, Awaitable, Callable
import redis.asyncio as aioredis
logger = logging.getLogger(__name__)
class HandoffTransport:
"""Handoff 传输层协议
定义 Agent Handoff 消息的传输接口支持多种底层实现
"""
async def send(self, channel: str, message: dict) -> None:
"""发送消息到指定频道"""
...
async def listen(self, channel: str) -> AsyncIterator[dict]:
"""监听指定频道的消息(异步生成器)"""
...
yield
def register_handler(self, channel: str, handler: Callable[[dict], Awaitable[None]]) -> None:
"""注册消息处理器"""
...
class InProcessHandoffTransport:
"""进程内 Handoff 传输
基于 asyncio.Queue 实现的进程内消息传输适用于 Expert Team 模式下
同一进程内的 Agent Handoff无需 Redis 依赖
支持广播模式同一频道可有多个消费者每条消息会投递到所有消费者
"""
_CLOSED_SENTINEL: dict = {"_closed": True}
_MAX_QUEUE_SIZE: int = 1024
def __init__(self) -> None:
self._channels: dict[str, list[asyncio.Queue[dict]]] = {}
self._handlers: dict[str, list[Callable[[dict], Awaitable[None]]]] = {}
self._closed = False
async def send(self, channel: str, message: dict) -> None:
"""发送消息到指定频道
将消息放入该频道所有消费者的队列中并调用所有已注册的处理器
如果频道不存在无消费者消息将被丢弃
"""
queues = self._channels.get(channel, [])
for queue in queues:
await queue.put(message)
handlers = self._handlers.get(channel, [])
for handler in handlers:
try:
await handler(message)
except Exception as e:
logger.error(f"InProcessHandoffTransport handler error on channel '{channel}': {e}")
async def listen(self, channel: str) -> AsyncIterator[dict]: # type: ignore[override]
"""监听指定频道的消息
为调用者创建一个独立的队列通过异步生成器持续产出消息
每个调用者获得独立的队列实现广播语义
"""
queue: asyncio.Queue[dict] = asyncio.Queue(maxsize=self._MAX_QUEUE_SIZE)
if channel not in self._channels:
self._channels[channel] = []
self._channels[channel].append(queue)
try:
while True:
message = await queue.get()
if message is self._CLOSED_SENTINEL:
break
yield message
finally:
# 清理:移除当前消费者的队列
if channel in self._channels:
try:
self._channels[channel].remove(queue)
except ValueError:
pass
if not self._channels[channel]:
del self._channels[channel]
def register_handler(self, channel: str, handler: Callable[[dict], Awaitable[None]]) -> None:
"""注册消息处理器
当频道收到消息时处理器将被调用
"""
if channel not in self._handlers:
self._handlers[channel] = []
self._handlers[channel].append(handler)
def close(self) -> None:
"""关闭传输,清理所有频道和处理器。
向所有活跃的监听器队列放入哨兵值使其能够优雅退出
"""
self._closed = True
# Put sentinel into every queue so active listeners unblock and exit
for channel_queues in self._channels.values():
for queue in channel_queues:
try:
queue.put_nowait(self._CLOSED_SENTINEL)
except asyncio.QueueFull:
pass
self._channels.clear()
self._handlers.clear()
class RedisHandoffTransport:
"""Redis Pub/Sub Handoff 传输
基于 Redis Pub/Sub 实现的分布式消息传输适用于跨进程跨节点的
Agent Handoff
使用懒连接在首次使用时才建立 Redis 连接而非初始化时
"""
def __init__(self, redis_url: str) -> None:
self._redis_url = redis_url
self._redis: aioredis.Redis | None = None
self._active_pubsubs: list[aioredis.client.PubSub] = []
self._handlers: dict[str, list[Callable[[dict], Awaitable[None]]]] = {}
self._listen_tasks: dict[str, asyncio.Task] = {}
async def _ensure_connection(self) -> aioredis.Redis:
"""确保 Redis 连接已建立(懒初始化)"""
if self._redis is None:
try:
self._redis = aioredis.from_url(self._redis_url, decode_responses=True)
await self._redis.ping()
logger.info("RedisHandoffTransport connected to Redis")
except Exception:
# Reset on failure so future calls retry
self._redis = None
raise
return self._redis
async def send(self, channel: str, message: dict) -> None:
"""发送消息到指定频道
通过 Redis PUBLISH 命令将消息发布到频道
"""
redis = await self._ensure_connection()
await redis.publish(channel, json.dumps(message))
logger.debug(f"RedisHandoffTransport sent message to channel '{channel}'")
async def listen(self, channel: str) -> AsyncIterator[dict]: # type: ignore[override]
"""监听指定频道的消息
订阅 Redis 频道通过异步生成器持续产出消息
"""
redis = await self._ensure_connection()
pubsub = redis.pubsub()
await pubsub.subscribe(channel)
self._active_pubsubs.append(pubsub)
try:
async for message in pubsub.listen():
if message["type"] == "message":
data = message["data"]
if isinstance(data, str):
yield json.loads(data)
else:
yield json.loads(data.decode())
except asyncio.CancelledError:
pass
finally:
await pubsub.unsubscribe(channel)
await pubsub.aclose()
if pubsub in self._active_pubsubs:
self._active_pubsubs.remove(pubsub)
def register_handler(self, channel: str, handler: Callable[[dict], Awaitable[None]]) -> None:
"""注册消息处理器并启动后台监听任务
注册处理器后自动启动一个后台 asyncio.Task 来监听该频道的消息
"""
if channel not in self._handlers:
self._handlers[channel] = []
self._handlers[channel].append(handler)
# 启动后台监听任务
if channel not in self._listen_tasks:
self._listen_tasks[channel] = asyncio.create_task(
self._background_listen(channel)
)
async def _background_listen(self, channel: str) -> None:
"""后台监听频道消息并调用处理器"""
try:
async for message in self.listen(channel):
handlers = self._handlers.get(channel, [])
for handler in handlers:
try:
await handler(message)
except Exception as e:
logger.error(
f"RedisHandoffTransport handler error on channel '{channel}': {e}"
)
except asyncio.CancelledError:
pass
except Exception as e:
logger.error(f"RedisHandoffTransport listen error on channel '{channel}': {e}")
async def close(self) -> None:
"""关闭传输,取消所有监听任务并关闭 Redis 连接"""
# 取消所有后台监听任务
for task in self._listen_tasks.values():
if not task.done():
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
self._listen_tasks.clear()
self._handlers.clear()
# 关闭所有活跃的 PubSub 连接
for pubsub in self._active_pubsubs:
try:
await pubsub.unsubscribe()
await pubsub.aclose()
except Exception:
pass
self._active_pubsubs.clear()
# 关闭 Redis 连接
if self._redis is not None:
await self._redis.close()
self._redis = None

View File

@ -0,0 +1,34 @@
"""Expert 系统 - 专家团队模式的配置、模板、注册与协作计划"""
from agentkit.experts.config import ExpertConfig, ExpertTemplate
from agentkit.experts.expert import Expert
from agentkit.experts.orchestrator import TeamOrchestrator
from agentkit.experts.plan import (
CollaborationPlan,
MergeStrategy,
ParallelType,
PhaseStatus,
PlanPhase,
PlanStatus,
)
from agentkit.experts.registry import ExpertTemplateRegistry
from agentkit.experts.router import ExpertTeamRouter, ExpertTeamRoutingResult
from agentkit.experts.team import ExpertTeam, TeamStatus
__all__ = [
"CollaborationPlan",
"Expert",
"ExpertConfig",
"ExpertTeam",
"ExpertTeamRouter",
"ExpertTeamRoutingResult",
"ExpertTemplate",
"ExpertTemplateRegistry",
"MergeStrategy",
"ParallelType",
"PhaseStatus",
"PlanPhase",
"PlanStatus",
"TeamOrchestrator",
"TeamStatus",
]

View File

@ -0,0 +1,138 @@
"""Expert 配置与模板 - ExpertConfig, ExpertTemplate"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
from agentkit.core.config_driven import AgentConfig
class ExpertConfig(AgentConfig):
"""扩展 AgentConfig新增 Expert 专属字段
Expert 是比 Skill 更高层的角色抽象一个 Expert 聚合多个 Skill
并包含 personathinking_stylecollaboration_strategy 等角色属性
"""
def __init__(
self,
name: str,
agent_type: str,
version: str = "1.0.0",
description: str = "",
task_mode: str = "llm_generate",
supported_tasks: list[str] | None = None,
max_concurrency: int = 1,
input_schema: dict[str, Any] | None = None,
output_schema: dict[str, Any] | None = None,
prompt: dict[str, str] | None = None,
llm: dict[str, Any] | None = None,
tools: list[str] | None = None,
memory: dict[str, Any] | None = None,
custom_handler: str | None = None,
# Expert 专属字段
persona: str = "",
thinking_style: str = "",
collaboration_strategy: str = "cooperative",
bound_skills: list[str] | None = None,
avatar: str = "",
color: str = "#1890ff",
is_lead: bool = False,
):
super().__init__(
name=name,
agent_type=agent_type,
version=version,
description=description,
task_mode=task_mode,
supported_tasks=supported_tasks,
max_concurrency=max_concurrency,
input_schema=input_schema,
output_schema=output_schema,
prompt=prompt,
llm=llm,
tools=tools,
memory=memory,
custom_handler=custom_handler,
)
self.persona = persona
self.thinking_style = thinking_style
self.collaboration_strategy = collaboration_strategy
self.bound_skills = bound_skills or []
self.avatar = avatar
self.color = color
self.is_lead = is_lead
@classmethod
def from_dict(cls, data: dict[str, Any]) -> ExpertConfig:
"""从字典创建配置"""
return cls(
name=data["name"],
agent_type=data["agent_type"],
version=data.get("version", "1.0.0"),
description=data.get("description", ""),
task_mode=data.get("task_mode", "llm_generate"),
supported_tasks=data.get("supported_tasks"),
max_concurrency=data.get("max_concurrency", 1),
input_schema=data.get("input_schema"),
output_schema=data.get("output_schema"),
prompt=data.get("prompt"),
llm=data.get("llm"),
tools=data.get("tools"),
memory=data.get("memory"),
custom_handler=data.get("custom_handler"),
persona=data.get("persona", ""),
thinking_style=data.get("thinking_style", ""),
collaboration_strategy=data.get("collaboration_strategy", "cooperative"),
bound_skills=data.get("bound_skills"),
avatar=data.get("avatar", ""),
color=data.get("color", "#1890ff"),
is_lead=data.get("is_lead", False),
)
def to_dict(self) -> dict[str, Any]:
"""序列化为字典,包含 Expert 专属字段"""
d = super().to_dict()
d["persona"] = self.persona
d["thinking_style"] = self.thinking_style
d["collaboration_strategy"] = self.collaboration_strategy
d["bound_skills"] = self.bound_skills
d["avatar"] = self.avatar
d["color"] = self.color
d["is_lead"] = self.is_lead
return d
@dataclass
class ExpertTemplate:
"""Expert 模板 - 可复用的 Expert 配置模板
用于预定义 Expert 角色配置支持内置模板和用户自定义模板
"""
name: str
config: ExpertConfig
is_builtin: bool = False
description: str = ""
def to_dict(self) -> dict[str, Any]:
"""序列化为字典"""
return {
"name": self.name,
"config": self.config.to_dict(),
"is_builtin": self.is_builtin,
"description": self.description,
}
@classmethod
def from_dict(cls, data: dict[str, Any]) -> ExpertTemplate:
"""从字典创建模板"""
config_data = data["config"]
config = ExpertConfig.from_dict(config_data)
return cls(
name=data["name"],
config=config,
is_builtin=data.get("is_builtin", False),
description=data.get("description", ""),
)

View File

@ -0,0 +1,167 @@
"""Expert - 专家团队运行时包装器
Expert 是运行时包装器持有 ExpertConfig ConfigDrivenAgent 实例
在标准 Agent 生命周期之上添加团队感知行为
"""
from __future__ import annotations
import time
from agentkit.core.agent_pool import AgentPool
from agentkit.core.config_driven import ConfigDrivenAgent
from agentkit.core.handoff_transport import InProcessHandoffTransport
from agentkit.core.shared_workspace import SharedWorkspace
from agentkit.experts.config import ExpertConfig
class Expert:
"""运行时包装器Expert = ExpertConfig + ConfigDrivenAgent + 团队行为
在标准 Agent 生命周期之上添加团队感知行为包括
- 团队频道消息广播
- Expert 协助请求
- 计划修改提案
- 能力摘要
"""
def __init__(
self,
config: ExpertConfig,
agent: ConfigDrivenAgent,
handoff_transport: InProcessHandoffTransport | None = None,
workspace: SharedWorkspace | None = None,
):
self.config = config
self.agent = agent
self._handoff_transport = handoff_transport
self._workspace = workspace
self._team_id: str | None = None
self._is_active: bool = True
@classmethod
async def create(
cls,
config: ExpertConfig,
pool: AgentPool,
handoff_transport: InProcessHandoffTransport | None = None,
workspace: SharedWorkspace | None = None,
team_context: str | None = None,
) -> Expert:
"""通过 AgentPool 创建 Expert实例化 ConfigDrivenAgent。
如果提供了 team_context会将其注入到 Agent system prompt
使 Agent 感知其团队角色和其他 Expert
"""
agent = await pool.create_agent(config)
expert = cls(
config=config,
agent=agent,
handoff_transport=handoff_transport,
workspace=workspace,
)
# 如果提供了团队上下文,修改 Agent 的 prompt 以注入团队角色信息
if team_context and hasattr(agent, "_prompt_template") and agent._prompt_template:
sections = agent._prompt_template._sections
sections.context = f"{team_context}\n\n{sections.context}" if sections.context else team_context
return expert
async def send_message(
self,
channel: str,
content: str,
summary: str | None = None,
) -> None:
"""向团队频道广播消息。
如果内容较长>500 字符自动创建摘要并将完整内容存储到 SharedWorkspace
即使没有提供 summary长内容也会存储到 workspace 以避免数据丢失
"""
message = {
"expert_id": self.config.name,
"expert_name": self.config.name,
"content": summary or content[:500],
"timestamp": time.time(),
"type": "chat",
}
if len(content) > 500 and self._workspace:
# Always store full content in workspace for long messages
await self._workspace.write(
f"expert:{self.config.name}:messages:{int(message['timestamp'])}",
content,
self.config.name,
)
if self._handoff_transport:
await self._handoff_transport.send(channel, message)
async def request_assist(
self,
target_expert: str,
task: str,
reason: str = "",
) -> None:
"""通过 handoff 请求其他 Expert 协助。"""
if not self._handoff_transport:
raise RuntimeError("No handoff transport configured")
handoff_msg = {
"source_expert": self.config.name,
"target_expert": target_expert,
"task": task,
"reason": reason,
"type": "assist_request",
}
await self._handoff_transport.send(
f"expert:{target_expert}:handoff", handoff_msg
)
async def propose_plan_modification(
self,
plan_id: str,
modification: dict,
) -> None:
"""向 Lead Expert 提交计划修改提案。"""
if not self._handoff_transport:
raise RuntimeError("No handoff transport configured")
proposal = {
"proposing_expert": self.config.name,
"plan_id": plan_id,
"modification": modification,
"type": "plan_modification_proposal",
}
await self._handoff_transport.send("team:plan_modifications", proposal)
def get_capabilities_summary(self) -> dict:
"""返回此 Expert 的能力摘要,用于团队发现。"""
return {
"name": self.config.name,
"persona": self.config.persona,
"thinking_style": self.config.thinking_style,
"bound_skills": self.config.bound_skills,
"is_lead": self.config.is_lead,
"color": self.config.color,
"avatar": self.config.avatar,
}
async def destroy(self, pool: AgentPool) -> None:
"""从池中移除 Expert 的 Agent。输出保留在 SharedWorkspace 中。"""
self._is_active = False
await pool.remove_agent(self.config.name)
@property
def is_active(self) -> bool:
return self._is_active
@property
def team_id(self) -> str | None:
return self._team_id
@team_id.setter
def team_id(self, value: str) -> None:
self._team_id = value

View File

@ -0,0 +1,458 @@
"""TeamOrchestrator - 专家团队协作计划执行引擎
驱动 CollaborationPlan ExpertTeam 中的执行负责
- 阶段执行串行子任务并行竞争并行
- 结果合并BEST / VOTE / FUSION
- 里程碑检查点
- 重试 + 回退到单 Agent 模式
- 事件广播
"""
from __future__ import annotations
import asyncio
import logging
from typing import Any
from .expert import Expert
from .plan import (
CollaborationPlan,
MergeStrategy,
ParallelType,
PhaseStatus,
PlanPhase,
PlanStatus,
)
from .team import ExpertTeam, TeamStatus
logger = logging.getLogger(__name__)
class TeamOrchestrator:
"""Orchestration engine that drives CollaborationPlan execution within an ExpertTeam."""
MAX_RETRIES = 1 # Retry once on failure before fallback
MAX_INTERACTION_ROUNDS = 20 # Prevent infinite collaboration loops
def __init__(self, team: ExpertTeam) -> None:
self._team = team
self._interaction_count = 0
async def execute_plan(self, plan: CollaborationPlan) -> dict[str, Any]:
"""Execute a CollaborationPlan within the team.
Returns a dict with:
- "status": "completed" | "failed" | "fallback"
- "result": final synthesized result
- "phase_results": dict of phase_id -> result
"""
# Validate plan first
errors = plan.validate()
if errors:
logger.error(f"Plan validation failed: {errors}")
return {
"status": "failed",
"result": None,
"phase_results": {},
"errors": errors,
}
plan.status = PlanStatus.EXECUTING
self._team._status = TeamStatus.EXECUTING
self._interaction_count = 0 # Reset for each plan execution
phase_results: dict[str, dict[str, Any]] = {}
retry_counts: dict[str, int] = {} # Per-phase retry tracking
try:
while True:
ready_phases = plan.get_ready_phases()
if not ready_phases:
# Check if all phases are done
all_done = all(
p.status in (PhaseStatus.COMPLETED, PhaseStatus.FAILED)
for p in plan.phases
)
if all_done:
break
# Check for stuck state (some phases pending but none ready)
pending = [
p for p in plan.phases if p.status == PhaseStatus.PENDING
]
if pending:
# Cascade: mark pending phases with failed deps as FAILED
failed_ids = {
p.id for p in plan.phases if p.status == PhaseStatus.FAILED
}
for p in pending:
if any(dep in failed_ids for dep in p.dependencies):
plan.update_phase_status(p.id, PhaseStatus.FAILED)
phase_results[p.id] = {
"error": f"Dependency failed, cannot execute phase '{p.name}'"
}
logger.warning(
f"Phase {p.id} marked FAILED due to failed dependency"
)
# Re-check after cascade
still_pending = [
p for p in plan.phases if p.status == PhaseStatus.PENDING
]
if not still_pending:
break
# If still stuck, trigger fallback
logger.warning(
f"Stuck: {len(still_pending)} pending phases with unresolvable deps"
)
return await self._fallback_to_single_agent(
plan, phase_results
)
break
# Group ready phases by parallel type
serial_phases = [
p for p in ready_phases if p.parallel_type == ParallelType.SERIAL
]
parallel_phases = [
p
for p in ready_phases
if p.parallel_type == ParallelType.SUBTASK_PARALLEL
]
competitive_phases = [
p
for p in ready_phases
if p.parallel_type == ParallelType.COMPETITIVE_PARALLEL
]
# Execute serial phases
for phase in serial_phases:
result = await self._execute_phase(phase, plan, phase_results)
if result is None:
# Phase failed — retry per-phase
phase_retries = retry_counts.get(phase.id, 0)
if phase_retries < self.MAX_RETRIES:
retry_counts[phase.id] = phase_retries + 1
logger.info(
f"Retrying phase {phase.id} (attempt {phase_retries + 1})"
)
# Reset phase status for retry
plan.update_phase_status(phase.id, PhaseStatus.PENDING)
result = await self._execute_phase(phase, plan, phase_results)
if result is None:
# Still failed after retry — fallback to single agent
logger.warning(
f"Phase {phase.id} failed after retry, falling back to single agent"
)
return await self._fallback_to_single_agent(
plan, phase_results
)
phase_results[phase.id] = result
# Execute subtask-level parallel phases
if parallel_phases:
results = await asyncio.gather(
*[
self._execute_phase(p, plan, phase_results)
for p in parallel_phases
],
return_exceptions=True,
)
all_parallel_failed = True
for phase, result in zip(parallel_phases, results):
if isinstance(result, Exception):
logger.error(
f"Parallel phase {phase.id} failed: {result}"
)
plan.update_phase_status(phase.id, PhaseStatus.FAILED)
phase_results[phase.id] = {"error": str(result)}
else:
all_parallel_failed = False
phase_results[phase.id] = result
# If all parallel phases failed, trigger fallback
if all_parallel_failed:
logger.warning("All parallel phases failed, falling back to single agent")
return await self._fallback_to_single_agent(
plan, phase_results
)
# Execute competitive parallel phases
for phase in competitive_phases:
result = await self._execute_competitive_phase(
phase, plan, phase_results
)
if "error" in result:
# Competitive phase completely failed
logger.warning(
f"Competitive phase {phase.id} failed: {result.get('error')}"
)
return await self._fallback_to_single_agent(
plan, phase_results
)
phase_results[phase.id] = result
self._interaction_count += 1
if self._interaction_count >= self.MAX_INTERACTION_ROUNDS:
logger.warning("Max interaction rounds reached")
break
# Synthesize final result
plan.status = PlanStatus.COMPLETED
self._team._status = TeamStatus.SYNTHESIZING
final_result = await self._synthesize_results(plan, phase_results)
self._team._status = TeamStatus.COMPLETED
return {
"status": "completed",
"result": final_result,
"phase_results": phase_results,
}
except Exception as e:
logger.error(f"Plan execution failed: {e}")
plan.status = PlanStatus.FAILED
return {
"status": "failed",
"result": None,
"phase_results": phase_results,
"error": str(e),
}
async def _execute_phase(
self,
phase: PlanPhase,
plan: CollaborationPlan,
phase_results: dict[str, dict[str, Any]],
) -> dict[str, Any] | None:
"""Execute a single phase. Returns result dict or None on failure."""
plan.update_phase_status(phase.id, PhaseStatus.IN_PROGRESS)
try:
# Broadcast phase start (inside try so transient broadcast failures don't kill the plan)
await self._broadcast_event(
"phase_started",
{
"phase_id": phase.id,
"phase_name": phase.name,
"assigned_expert": phase.assigned_expert,
},
)
# Get the assigned expert
expert = self._team._experts.get(phase.assigned_expert)
if not expert or not expert.is_active:
raise RuntimeError(
f"Expert '{phase.assigned_expert}' not available"
)
# Execute the task via the expert's agent
# In a real implementation, this would call expert.agent.execute(task)
# For now, we simulate by having the expert process the task
result: dict[str, Any] = {
"output": f"Phase '{phase.name}' completed by {phase.assigned_expert}"
}
# Check milestone
if phase.milestone:
milestone_passed = await self._check_milestone(phase, result)
if not milestone_passed:
plan.update_phase_status(phase.id, PhaseStatus.FAILED)
try:
await self._broadcast_event(
"milestone_failed",
{"phase_id": phase.id, "milestone": phase.milestone},
)
except Exception:
pass
return None
plan.update_phase_status(phase.id, PhaseStatus.COMPLETED, result)
try:
await self._broadcast_event(
"phase_completed",
{"phase_id": phase.id, "phase_name": phase.name},
)
except Exception:
pass
return result
except Exception as e:
logger.error(f"Phase {phase.id} execution failed: {e}")
plan.update_phase_status(phase.id, PhaseStatus.FAILED)
try:
await self._broadcast_event(
"phase_failed", {"phase_id": phase.id, "error": str(e)}
)
except Exception:
pass
return None
async def _execute_competitive_phase(
self,
phase: PlanPhase,
plan: CollaborationPlan,
phase_results: dict[str, dict[str, Any]],
) -> dict[str, Any]:
"""Execute a competitive parallel phase with merge strategy."""
plan.update_phase_status(phase.id, PhaseStatus.IN_PROGRESS)
# For competitive parallel, we need multiple experts working on the same task
# In practice, the plan should specify which experts compete
# For now, we use all active experts as competitors
competitors = self._team.active_experts
# Run all competitors in parallel
results = await asyncio.gather(
*[self._run_competitor(expert, phase) for expert in competitors],
return_exceptions=True,
)
# Filter out exceptions
valid_results = [r for r in results if not isinstance(r, Exception)]
if not valid_results:
plan.update_phase_status(phase.id, PhaseStatus.FAILED)
return {"error": "All competitors failed"}
# Apply merge strategy
merged = await self._merge_results(phase, valid_results)
plan.update_phase_status(phase.id, PhaseStatus.COMPLETED, merged)
return merged
async def _run_competitor(
self, expert: Expert, phase: PlanPhase
) -> dict[str, Any]:
"""Run a single competitor for a competitive phase."""
# Simulate expert execution
return {
"expert": expert.config.name,
"output": f"Competitive result from {expert.config.name}",
}
async def _merge_results(
self, phase: PlanPhase, results: list[dict[str, Any]]
) -> dict[str, Any]:
"""Merge competitive parallel results based on merge strategy."""
strategy = phase.merge_strategy or MergeStrategy.BEST
if strategy == MergeStrategy.BEST:
# Lead Expert picks the best result
lead = self._team.lead_expert
if lead:
return {
"merged": True,
"strategy": "best",
"selected": results[0],
"all_results": results,
}
return results[0]
elif strategy == MergeStrategy.VOTE:
# All experts vote — for now, simple majority with Lead Expert tie-breaking
return {
"merged": True,
"strategy": "vote",
"selected": results[0],
"all_results": results,
}
elif strategy == MergeStrategy.FUSION:
# Lead Expert fuses all results
lead = self._team.lead_expert
if lead:
return {
"merged": True,
"strategy": "fusion",
"fused_from": len(results),
"all_results": results,
}
return results[0]
return results[0]
async def _check_milestone(
self, phase: PlanPhase, result: dict[str, Any]
) -> bool:
"""Check if a phase result passes its milestone checkpoint."""
# In a real implementation, this would use LLM evaluation
# For now, always pass if there's a result
return result is not None
async def _synthesize_results(
self, plan: CollaborationPlan, phase_results: dict[str, dict[str, Any]]
) -> dict[str, Any]:
"""Synthesize final results from all phase outputs."""
# Collect completed phase results in order
completed: list[dict[str, Any]] = []
for phase in plan.phases:
if phase.status == PhaseStatus.COMPLETED and phase.id in phase_results:
completed.append(
{
"phase": phase.name,
"expert": phase.assigned_expert,
"result": phase_results[phase.id],
}
)
return {
"task": plan.task,
"phases_completed": len(completed),
"phases_total": len(plan.phases),
"results": completed,
}
async def _fallback_to_single_agent(
self, plan: CollaborationPlan, phase_results: dict[str, dict[str, Any]]
) -> dict[str, Any]:
"""Fallback to single agent mode when team execution fails.
Uses the lead expert (or first active expert) to complete the original task.
"""
plan.status = PlanStatus.FALLBACK
logger.warning("Falling back to single agent mode")
# Try to use the lead expert, or fall back to any active expert
expert = self._team.lead_expert
if not expert or not expert.is_active:
active = self._team.active_experts
expert = active[0] if active else None
fallback_result = None
if expert:
try:
# Execute the original task with a single expert
fallback_result = {
"output": f"Task completed by {expert.config.name} (fallback mode)",
"task": plan.task,
}
except Exception as e:
logger.error(f"Fallback agent execution failed: {e}")
fallback_result = {"error": f"Fallback execution failed: {e}"}
else:
fallback_result = {"error": "No active expert available for fallback"}
return {
"status": "fallback",
"result": fallback_result,
"phase_results": phase_results,
}
async def _broadcast_event(
self, event_type: str, data: dict[str, Any]
) -> None:
"""Broadcast an orchestration event to the team channel."""
if self._team._handoff_transport:
await self._team._handoff_transport.send(
self._team._team_channel, {"type": event_type, **data}
)

View File

@ -0,0 +1,281 @@
"""CollaborationPlan 数据模型 - Expert Team 协作蓝图
定义 Expert Team 的结构化协作计划包括阶段角色分配依赖关系
并行类型合并策略和里程碑
"""
from __future__ import annotations
import enum
from dataclasses import dataclass, field
from typing import Any
class ParallelType(str, enum.Enum):
"""并行执行类型"""
SERIAL = "serial"
SUBTASK_PARALLEL = "subtask_parallel"
COMPETITIVE_PARALLEL = "competitive_parallel"
class MergeStrategy(str, enum.Enum):
"""合并策略 - 仅用于 COMPETITIVE_PARALLEL 阶段"""
BEST = "best"
VOTE = "vote"
FUSION = "fusion"
class PhaseStatus(str, enum.Enum):
"""阶段状态"""
PENDING = "pending"
IN_PROGRESS = "in_progress"
COMPLETED = "completed"
FAILED = "failed"
class PlanStatus(str, enum.Enum):
"""计划状态"""
DRAFT = "draft"
CONFIRMED = "confirmed"
EXECUTING = "executing"
COMPLETED = "completed"
FAILED = "failed"
FALLBACK = "fallback"
# DFS 着色常量
_WHITE = 0 # 未访问
_GRAY = 1 # 正在访问(当前路径上)
_BLACK = 2 # 已完成访问
@dataclass
class PlanPhase:
"""协作计划中的单个阶段
Attributes:
id: 阶段标识符
name: 阶段显示名称
assigned_expert: 分配到此阶段的 Expert 名称
task_description: 此阶段完成的任务描述
depends_on: 依赖的阶段 ID 列表
parallel_type: 执行类型
merge_strategy: 合并策略 COMPETITIVE_PARALLEL 需要
milestone: 里程碑检查点描述
status: 当前状态
result: 阶段输出结果
"""
id: str
name: str
assigned_expert: str
task_description: str
depends_on: list[str] = field(default_factory=list)
parallel_type: ParallelType = ParallelType.SERIAL
merge_strategy: MergeStrategy | None = None
milestone: str = ""
status: PhaseStatus = PhaseStatus.PENDING
result: dict | None = None
def to_dict(self) -> dict[str, Any]:
"""序列化为字典"""
return {
"id": self.id,
"name": self.name,
"assigned_expert": self.assigned_expert,
"task_description": self.task_description,
"depends_on": self.depends_on,
"parallel_type": self.parallel_type.value,
"merge_strategy": self.merge_strategy.value if self.merge_strategy is not None else None,
"milestone": self.milestone,
"status": self.status.value,
"result": self.result,
}
@classmethod
def from_dict(cls, data: dict[str, Any]) -> PlanPhase:
"""从字典创建 PlanPhase"""
merge_strategy = None
if data.get("merge_strategy") is not None:
merge_strategy = MergeStrategy(data["merge_strategy"])
return cls(
id=data["id"],
name=data["name"],
assigned_expert=data["assigned_expert"],
task_description=data["task_description"],
depends_on=data.get("depends_on", []),
parallel_type=ParallelType(data.get("parallel_type", ParallelType.SERIAL.value)),
merge_strategy=merge_strategy,
milestone=data.get("milestone", ""),
status=PhaseStatus(data.get("status", PhaseStatus.PENDING.value)),
result=data.get("result"),
)
@dataclass
class CollaborationPlan:
"""Expert Team 协作计划
定义 Expert Team 的结构化协作蓝图包括阶段编排共享变量
状态管理和依赖关系
Attributes:
id: 计划标识符
task: 原始任务描述
phases: 有序阶段列表
variables: 共享变量
status: 计划状态
lead_expert: 主导 Expert 名称
"""
id: str
task: str
phases: list[PlanPhase] = field(default_factory=list)
variables: dict = field(default_factory=dict)
status: PlanStatus = PlanStatus.DRAFT
lead_expert: str = ""
_phase_index: dict[str, PlanPhase] = field(default_factory=dict, init=False, repr=False)
def __post_init__(self) -> None:
"""Build the phase index after initialization."""
self._rebuild_index()
def _rebuild_index(self) -> None:
"""Rebuild the phase index from the phases list."""
self._phase_index = {phase.id: phase for phase in self.phases}
def to_dict(self) -> dict[str, Any]:
"""序列化为字典"""
return {
"id": self.id,
"task": self.task,
"phases": [phase.to_dict() for phase in self.phases],
"variables": self.variables,
"status": self.status.value,
"lead_expert": self.lead_expert,
}
@classmethod
def from_dict(cls, data: dict[str, Any]) -> CollaborationPlan:
"""从字典创建 CollaborationPlan"""
phases = [PlanPhase.from_dict(p) for p in data.get("phases", [])]
return cls(
id=data["id"],
task=data["task"],
phases=phases,
variables=data.get("variables", {}),
status=PlanStatus(data.get("status", PlanStatus.DRAFT.value)),
lead_expert=data.get("lead_expert", ""),
)
def validate(self) -> list[str]:
"""验证计划,返回错误消息列表(空列表表示有效)
检查项
- 无重复阶段 ID
- 所有 depends_on 引用存在
- 无循环依赖DFS 着色检测
- COMPETITIVE_PARALLEL 阶段必须有 merge_strategy
"""
errors: list[str] = []
# 构建阶段 ID 集合
phase_ids = {phase.id for phase in self.phases}
# 检查重复阶段 ID
seen_ids: set[str] = set()
for phase in self.phases:
if phase.id in seen_ids:
errors.append(f"重复的阶段 ID: {phase.id}")
seen_ids.add(phase.id)
# 检查 depends_on 引用是否存在
for phase in self.phases:
for dep_id in phase.depends_on:
if dep_id not in phase_ids:
errors.append(
f"阶段 '{phase.id}' 依赖了不存在的阶段 ID: {dep_id}"
)
# 检查循环依赖(迭代 DFS 着色 — 避免递归栈溢出)
color: dict[str, int] = {phase.id: _WHITE for phase in self.phases}
dep_map: dict[str, list[str]] = {
phase.id: phase.depends_on for phase in self.phases
}
for phase in self.phases:
if color[phase.id] != _WHITE:
continue
# Iterative DFS using an explicit stack
stack: list[tuple[str, bool]] = [(phase.id, False)]
while stack:
node, is_backtrack = stack.pop()
if is_backtrack:
color[node] = _BLACK
continue
if color[node] == _GRAY:
# Already on current path — cycle detected
errors.append("检测到循环依赖")
break
if color[node] == _BLACK:
continue
color[node] = _GRAY
# Push backtrack marker
stack.append((node, True))
for neighbor in dep_map.get(node, []):
if neighbor not in color:
continue
if color[neighbor] == _GRAY:
errors.append("检测到循环依赖")
break
if color[neighbor] == _WHITE:
stack.append((neighbor, False))
else:
continue
break # Inner break propagates to outer
if errors:
break # Only report cycle once
# 检查 COMPETITIVE_PARALLEL 必须有 merge_strategy
for phase in self.phases:
if (
phase.parallel_type == ParallelType.COMPETITIVE_PARALLEL
and phase.merge_strategy is None
):
errors.append(
f"阶段 '{phase.id}' 为 COMPETITIVE_PARALLEL 但未设置 merge_strategy"
)
return errors
def get_ready_phases(self) -> list[PlanPhase]:
"""获取所有依赖已完成且状态为 PENDING 的阶段"""
completed_ids = {
phase.id for phase in self.phases if phase.status == PhaseStatus.COMPLETED
}
ready: list[PlanPhase] = []
for phase in self.phases:
if phase.status != PhaseStatus.PENDING:
continue
if all(dep_id in completed_ids for dep_id in phase.depends_on):
ready.append(phase)
return ready
def get_phase(self, phase_id: str) -> PlanPhase | None:
"""根据 ID 获取阶段,不存在则返回 None (O(1) lookup)"""
return self._phase_index.get(phase_id)
def update_phase_status(
self, phase_id: str, status: PhaseStatus, result: dict | None = None
) -> None:
"""更新阶段状态和可选的结果"""
phase = self.get_phase(phase_id)
if phase is not None:
phase.status = status
if result is not None:
phase.result = result

View File

@ -0,0 +1,140 @@
"""ExpertTemplateRegistry - Expert 模板注册中心"""
from __future__ import annotations
import logging
import os
from typing import Any
import yaml
from agentkit.core.exceptions import ConfigValidationError
from agentkit.experts.config import ExpertConfig, ExpertTemplate
logger = logging.getLogger(__name__)
class ExpertTemplateRegistry:
"""Expert 模板注册中心,管理 ExpertTemplate 的注册、发现与加载
支持
- 注册/获取模板
- 按名称或描述搜索模板大小写不敏感
- YAML 文件或目录批量加载模板
"""
def __init__(self) -> None:
self._templates: dict[str, ExpertTemplate] = {}
def register(self, template: ExpertTemplate) -> None:
"""注册模板,同名覆盖
Args:
template: ExpertTemplate 实例
"""
self._templates[template.name] = template
logger.info(f"ExpertTemplate '{template.name}' registered")
def get(self, name: str) -> ExpertTemplate | None:
"""按名称获取模板
Args:
name: 模板名称
Returns:
ExpertTemplate 实例不存在时返回 None
"""
return self._templates.get(name)
def list(self) -> list[ExpertTemplate]:
"""列出所有已注册模板"""
return list(self._templates.values())
def search(self, query: str) -> list[ExpertTemplate]:
"""按名称或描述搜索模板(大小写不敏感子串匹配)
Args:
query: 搜索关键词
Returns:
匹配的 ExpertTemplate 列表
"""
query_lower = query.lower()
results: list[ExpertTemplate] = []
for template in self._templates.values():
if (
query_lower in template.name.lower()
or query_lower in template.description.lower()
):
results.append(template)
return results
def load_from_yaml(self, path: str) -> ExpertTemplate:
"""从单个 YAML 文件加载 ExpertTemplate
YAML 格式::
name: analyst
description: "数据分析师"
is_builtin: false
config:
name: analyst
agent_type: analyst
persona: "善于数据分析的专家"
thinking_style: "逻辑推理"
collaboration_strategy: "cooperative"
bound_skills:
- data_query
- chart_gen
avatar: "📊"
color: "#52c41a"
is_lead: false
Args:
path: YAML 文件路径
Returns:
加载的 ExpertTemplate 实例
Raises:
ConfigValidationError: YAML 格式不合法
"""
with open(path, "r", encoding="utf-8") as f:
data = yaml.safe_load(f)
if not isinstance(data, dict):
raise ConfigValidationError(
agent_name="unknown",
key="config",
reason=f"YAML config must be a mapping, got {type(data)}",
)
template = ExpertTemplate.from_dict(data)
self.register(template)
return template
def load_from_directory(self, path: str) -> list[ExpertTemplate]:
"""从目录批量加载 ExpertTemplate YAML 文件
扫描目录下所有 .yaml / .yml 文件并加载为 ExpertTemplate
Args:
path: 目录路径
Returns:
加载的 ExpertTemplate 列表
"""
loaded: list[ExpertTemplate] = []
if not os.path.isdir(path):
logger.warning(f"Directory '{path}' does not exist, skipping")
return loaded
for filename in sorted(os.listdir(path)):
if filename.endswith((".yaml", ".yml")):
filepath = os.path.join(path, filename)
try:
template = self.load_from_yaml(filepath)
loaded.append(template)
except Exception as e:
logger.warning(
f"Failed to load ExpertTemplate from '{filepath}': {e}"
)
return loaded

View File

@ -0,0 +1,147 @@
"""Expert Team routing — resolves user input to ExpertTeam configuration."""
import logging
import re
from dataclasses import dataclass, field
from typing import Any
from .config import ExpertConfig, ExpertTemplate
from .registry import ExpertTemplateRegistry
logger = logging.getLogger(__name__)
# Pattern to match @team or @team:expert1,expert2 prefix
TEAM_PREFIX_PATTERN = re.compile(r"^@team(?::(\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 team
@dataclass
class ExpertTeamRoutingResult:
"""Result of expert team routing resolution."""
matched: bool = False
team_mode: bool = False
specified_experts: list[str] = field(default_factory=list)
task_content: str = ""
auto_compose: bool = False
complexity: float = 0.0
match_method: str = "" # "explicit_team" | "complexity_suggestion"
class ExpertTeamRouter:
"""Routes user input to Expert Team mode.
Supports:
- @team prefix trigger team mode
- @team:analyst,strategist specify team members
- High complexity suggest team mode upgrade
"""
COMPLEXITY_THRESHOLD = 0.7 # Above this, suggest team mode
def __init__(self, template_registry: ExpertTemplateRegistry | None = None):
self._registry = template_registry or ExpertTemplateRegistry()
def resolve(self, content: str, complexity: float = 0.0) -> ExpertTeamRoutingResult:
"""Resolve user input to an ExpertTeamRoutingResult.
Args:
content: User's input message
complexity: Pre-computed complexity score (0.0-1.0)
Returns:
ExpertTeamRoutingResult with routing decision
"""
result = ExpertTeamRoutingResult()
# Check for @team prefix
match = TEAM_PREFIX_PATTERN.match(content.strip())
if match:
expert_list_str = match.group(1) # e.g., "analyst,strategist" or None
task = match.group(2).strip() # The actual task content
result.matched = True
result.team_mode = True
result.task_content = task if task else content # Fall back to full content when no task after prefix
result.match_method = "explicit_team"
if expert_list_str:
# 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}")
result.specified_experts = valid_names[:MAX_EXPERTS]
result.auto_compose = False
# Validate that specified templates exist
for name in result.specified_experts:
template = self._registry.get(name)
if template is None:
logger.warning(f"ExpertTemplate '{name}' not found, will be dynamically generated")
else:
# No specific experts — auto-compose
result.auto_compose = True
return result
# Check complexity-based suggestion
if complexity >= self.COMPLEXITY_THRESHOLD:
result.matched = True
result.team_mode = True
result.auto_compose = True
result.complexity = complexity
result.task_content = content
result.match_method = "complexity_suggestion"
return result
# Not a team mode request
result.matched = False
result.team_mode = False
result.task_content = content
result.complexity = complexity
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 with the name as persona.
The first expert is designated as lead.
"""
configs = []
for i, name in enumerate(specified_experts):
# Validate name to prevent prompt injection
if not _EXPERT_NAME_RE.match(name):
logger.warning(f"Skipping invalid expert name: {name}")
continue
template = self._registry.get(name)
if template:
configs.append(template.config)
else:
# Dynamic generation — create a basic ExpertConfig
# Name is validated above, safe to use in persona
config = ExpertConfig(
name=name,
agent_type="expert",
persona=f"Expert in {name}",
thinking_style="analytical",
bound_skills=[],
is_lead=(i == 0 and not any(c.is_lead for c in configs)),
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

View File

@ -0,0 +1,298 @@
"""ExpertTeam - 专家团队容器
管理 Expert 生命周期共享上下文协作计划和团队状态
Expert Team 协作模式的中央协调点
"""
from __future__ import annotations
import asyncio
import enum
import logging
import time
import uuid
from .config import ExpertConfig
from .expert import Expert
from .plan import CollaborationPlan, PlanStatus
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 TeamStatus(str, enum.Enum):
"""ExpertTeam lifecycle states."""
FORMING = "forming"
PLANNING = "planning"
EXECUTING = "executing"
SYNTHESIZING = "synthesizing"
COMPLETED = "completed"
DISSOLVED = "dissolved"
class ExpertTeam:
"""Container managing a team of Experts working together on a task."""
def __init__(
self,
team_id: str | None = None,
workspace: SharedWorkspace | None = None,
pool: AgentPool | None = None,
template_registry: ExpertTemplateRegistry | None = None,
):
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._lead_expert_name: str | None = None
self._plan: CollaborationPlan | None = None
self._status = TeamStatus.FORMING
self._team_channel = f"team:{self.team_id}"
self._orchestrator_task: asyncio.Task | None = None
@property
def status(self) -> TeamStatus:
return self._status
@property
def lead_expert(self) -> Expert | None:
if self._lead_expert_name:
return self._experts.get(self._lead_expert_name)
return None
@property
def plan(self) -> CollaborationPlan | None:
return self._plan
@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]
async def create_team(
self,
lead_config: ExpertConfig,
member_configs: list[ExpertConfig] | None = None,
) -> None:
"""Create a team with a Lead Expert and optional members."""
if not self._pool:
raise RuntimeError("AgentPool not configured")
# Create Lead Expert
team_context = self._build_team_context(lead_config, member_configs or [])
lead = await Expert.create(
config=lead_config,
pool=self._pool,
handoff_transport=self._handoff_transport,
workspace=self._workspace,
team_context=team_context,
)
lead.team_id = self.team_id
self._experts[lead_config.name] = lead
self._lead_expert_name = lead_config.name
# Create member Experts
if member_configs:
for config in member_configs:
await self._add_expert_internal(config, team_context)
self._status = TeamStatus.PLANNING
async def add_expert(self, config_or_template: ExpertConfig | str) -> Expert:
"""Add an Expert to the team dynamically.
Args:
config_or_template: ExpertConfig instance or template name to look up
"""
if isinstance(config_or_template, str):
template = self._template_registry.get(config_or_template)
if template is None:
raise ValueError(f"ExpertTemplate '{config_or_template}' not found")
config = template.config
else:
config = config_or_template
# Safely get lead config — _lead_expert_name may be stale
lead_config: ExpertConfig | None = None
if self._lead_expert_name and self._lead_expert_name in self._experts:
lead_config = self._experts[self._lead_expert_name].config
team_context = self._build_team_context(
lead_config,
[e.config for e in self.active_experts],
)
return await self._add_expert_internal(config, team_context)
async def _add_expert_internal(
self, config: ExpertConfig, team_context: str
) -> Expert:
"""Internal method to add an Expert."""
if not self._pool:
raise RuntimeError("AgentPool not configured")
expert = await Expert.create(
config=config,
pool=self._pool,
handoff_transport=self._handoff_transport,
workspace=self._workspace,
team_context=team_context,
)
expert.team_id = self.team_id
self._experts[config.name] = expert
# Broadcast new expert joined
await self._handoff_transport.send(
self._team_channel,
{
"type": "expert_joined",
"expert_name": config.name,
"capabilities": expert.get_capabilities_summary(),
},
)
return expert
async def remove_expert(self, name: str) -> None:
"""Remove an Expert from the team."""
expert = self._experts.get(name)
if not expert:
return
# Cannot remove Lead Expert — must reassign first
if name == self._lead_expert_name:
active = [e for e in self.active_experts if e.config.name != name]
if active:
# Reassign lead to first active expert
new_lead = active[0]
self._lead_expert_name = new_lead.config.name
new_lead.config.is_lead = True
else:
self._lead_expert_name = None
await expert.destroy(self._pool)
del self._experts[name]
# Update plan: reassign phases that referenced the removed expert
if self._plan:
new_lead_name = self._lead_expert_name
for phase in self._plan.phases:
if phase.assigned_expert == name:
phase.assigned_expert = new_lead_name or ""
# Broadcast expert left
await self._handoff_transport.send(
self._team_channel,
{
"type": "expert_left",
"expert_name": name,
},
)
def update_plan(self, plan: CollaborationPlan) -> list[str]:
"""Update the collaboration plan. Only Lead Expert or user should call this.
Returns list of affected expert names on success, or list of validation
error strings on failure (empty list with no errors = success).
"""
errors = plan.validate()
if errors:
return errors # Return validation errors instead of silently swallowing
self._plan = plan
if plan.status == PlanStatus.CONFIRMED:
self._status = TeamStatus.EXECUTING
# Determine affected experts
affected = [p.assigned_expert for p in plan.phases]
return affected
async def broadcast_user_message(self, content: str) -> None:
"""Broadcast a user intervention message to all active Experts."""
message = {
"type": "user_intervention",
"content": content,
"timestamp": time.time(),
}
await self._handoff_transport.send(self._team_channel, message)
async def get_shared_context(self) -> dict:
"""Get the team's shared context from SharedWorkspace."""
context = {}
keys = await self._workspace.list_keys()
for key in keys:
if key.startswith(f"team:{self.team_id}"):
data = await self._workspace.read(key)
if data:
context[key] = data
return context
async def generate_plan(self, task: str) -> CollaborationPlan:
"""Generate a CollaborationPlan for the task.
Uses hybrid mode: core roles from template registry, auxiliary roles dynamically generated.
This method creates a plan structure the actual LLM-based task decomposition
will be handled by TeamOrchestrator.
"""
plan_id = str(uuid.uuid4())
plan = CollaborationPlan(
id=plan_id,
task=task,
phases=[],
lead_expert=self._lead_expert_name or "",
)
self._plan = plan
return plan
async def dissolve(self) -> None:
"""Dissolve the team. Temporary Experts are recycled, outputs preserved in SharedWorkspace."""
# Cancel ongoing orchestrator task if any
if self._orchestrator_task and not self._orchestrator_task.done():
self._orchestrator_task.cancel()
try:
await self._orchestrator_task
except asyncio.CancelledError:
pass
self._orchestrator_task = None
for expert in self._experts.values():
if expert.is_active and self._pool:
await expert.destroy(self._pool)
self._experts.clear()
self._lead_expert_name = None
self._status = TeamStatus.DISSOLVED
# Close handoff transport
self._handoff_transport.close()
def _build_team_context(
self,
lead_config: ExpertConfig | None,
member_configs: list[ExpertConfig],
) -> str:
"""Build team context string for injection into Expert system prompts."""
lines = ["You are part of an Expert Team."]
if lead_config:
lines.append(f"Lead Expert: {lead_config.name} ({lead_config.persona})")
for config in member_configs:
if lead_config and config.name == lead_config.name:
continue
lines.append(
f"Team Member: {config.name} ({config.persona}), Skills: {', '.join(config.bound_skills)}"
)
lines.append(
"You can collaborate with other team members via send_message() and request_assist()."
)
return "\n".join(lines)

View File

@ -40,6 +40,10 @@ export interface IChatMessage {
task_id?: string task_id?: string
status?: 'completed' | 'pending' status?: 'completed' | 'pending'
tool_calls?: IToolCallData[] tool_calls?: IToolCallData[]
expert_id?: string
expert_name?: string
expert_color?: string
message_type?: 'chat' | 'handoff' | 'assist_request' | 'plan_update' | 'milestone'
} }
/** Conversation with messages */ /** Conversation with messages */
@ -76,10 +80,55 @@ export type WsClientMessage = {
/** WebSocket server message types — matches backend portal.py protocol */ /** WebSocket server message types — matches backend portal.py protocol */
export type WsServerMessage = export type WsServerMessage =
| { type: 'connected'; session_id: string }
| { type: 'routing'; skill: string; confidence: number; method: string } | { type: 'routing'; skill: string; confidence: number; method: string }
| { type: 'skill_match'; data: { skill: string; method: string; confidence: number } }
| { type: 'step'; data: { event_type: string; step: number; data: Record<string, any>; timestamp: string } } | { type: 'step'; data: { event_type: string; step: number; data: Record<string, any>; timestamp: string } }
| { type: 'thinking'; content: string }
| { type: 'token'; content: string }
| { type: 'final_answer'; content: string; is_final: boolean }
| { type: 'result'; data: { message?: string; content?: string; status?: string } } | { type: 'result'; data: { message?: string; content?: string; status?: string } }
| { type: 'error'; data: { message: string; code?: string } } | { type: 'error'; data: { message: string; code?: string } }
| { type: 'pong' }
| { type: 'confirmation_request'; data: { confirmation_id: string; command: string; reason: string } }
| { type: 'confirmation_result'; data: { confirmation_id: string; approved: boolean } }
| { type: 'team_formed'; data: IExpertTeamState }
| { type: 'expert_step'; data: { expert_id: string; expert_name: string; expert_color: string; content: string; step: number } }
| { type: 'expert_result'; data: { expert_id: string; expert_name: string; expert_color: string; content: string } }
| { type: 'plan_update'; data: { plan_phases: ITeamPlanPhase[] } }
| { type: 'team_synthesis'; data: { content: string } }
| { type: 'team_dissolved'; data: { team_id: string } }
/** Expert info within a team */
export interface IExpertInfo {
id: string
name: string
persona: string
avatar: string
color: string
is_lead: boolean
bound_skills: string[]
status: 'active' | 'inactive'
}
/** A phase within a team plan */
export interface ITeamPlanPhase {
id: string
name: string
assigned_expert: string
status: 'pending' | 'in_progress' | 'completed' | 'failed'
parallel_type: 'serial' | 'subtask_parallel' | 'competitive_parallel'
milestone: string
}
/** Expert team state */
export interface IExpertTeamState {
team_id: string
status: 'forming' | 'planning' | 'executing' | 'synthesizing' | 'completed' | 'dissolved'
experts: IExpertInfo[]
plan_phases: ITeamPlanPhase[]
lead_expert: string
}
/** API error */ /** API error */
export interface IApiError { export interface IApiError {

View File

@ -17,6 +17,21 @@
</a-avatar> </a-avatar>
</div> </div>
<div class="chat-message__body"> <div class="chat-message__body">
<!-- Expert message wrapper -->
<ExpertMessage
v-if="message.expert_id"
:expert-name="message.expert_name || ''"
:expert-color="message.expert_color || '#1890ff'"
:is-lead="false"
:message-type="message.message_type || 'chat'"
>
<div class="chat-message__content chat-message__content--assistant">
<div ref="markdownRef" class="chat-message__markdown" v-html="renderedContent"></div>
<a-spin v-if="isLoading" size="small" class="chat-message__loading" />
</div>
</ExpertMessage>
<!-- Non-expert message body -->
<template v-else>
<!-- Tool call cards --> <!-- Tool call cards -->
<div v-if="message.tool_calls && message.tool_calls.length > 0" class="chat-message__tool-cards"> <div v-if="message.tool_calls && message.tool_calls.length > 0" class="chat-message__tool-cards">
<ToolCallCard <ToolCallCard
@ -52,6 +67,7 @@
{{ message.routing_method }} {{ message.routing_method }}
</a-tag> </a-tag>
</div> </div>
</template>
<div class="chat-message__time"> <div class="chat-message__time">
{{ formattedTime }} {{ formattedTime }}
</div> </div>
@ -91,6 +107,7 @@ import { RobotOutlined, UserOutlined, ThunderboltOutlined } from '@ant-design/ic
import type { IChatMessage } from '@/api/types' import type { IChatMessage } from '@/api/types'
import ToolCallIndicator from './ToolCallIndicator.vue' import ToolCallIndicator from './ToolCallIndicator.vue'
import ToolCallCard from './ToolCallCard.vue' import ToolCallCard from './ToolCallCard.vue'
import ExpertMessage from './ExpertMessage.vue'
const md = new MarkdownIt({ const md = new MarkdownIt({
html: false, html: false,

View File

@ -0,0 +1,94 @@
<template>
<div class="expert-message" :style="{ borderLeftColor: expertColor }">
<div v-if="showExpertHeader" class="expert-message__header">
<div 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">
{{ messageTypeLabel }}
</a-tag>
</div>
<div class="expert-message__content">
<slot></slot>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { Tag as ATag } from 'ant-design-vue'
const props = withDefaults(defineProps<{
expertName: string
expertColor: string
isLead?: boolean
messageType?: 'chat' | 'handoff' | 'assist_request' | 'plan_update' | 'milestone'
showExpertHeader?: boolean
}>(), {
isLead: false,
messageType: 'chat',
showExpertHeader: true,
})
const expertInitial = computed(() => props.expertName.charAt(0).toUpperCase())
const messageTypeLabel = computed(() => {
const labels: Record<string, string> = {
handoff: '交接',
assist_request: '请求协助',
plan_update: '计划更新',
milestone: '里程碑',
}
return labels[props.messageType] || ''
})
const messageTypeColor = computed(() => {
const colors: Record<string, string> = {
handoff: 'blue',
assist_request: 'green',
plan_update: 'orange',
milestone: 'purple',
}
return colors[props.messageType] || 'default'
})
</script>
<style scoped>
.expert-message {
border-left: 3px solid var(--border-color);
padding-left: var(--space-3);
margin-bottom: var(--space-2);
}
.expert-message__header {
display: flex;
align-items: center;
gap: var(--space-2);
margin-bottom: var(--space-1);
}
.expert-message__avatar {
width: 24px;
height: 24px;
border-radius: var(--radius-full);
display: flex;
align-items: center;
justify-content: center;
color: var(--text-inverse);
font-size: var(--font-xs);
font-weight: var(--font-weight-semibold);
flex-shrink: 0;
}
.expert-message__name {
font-weight: var(--font-weight-medium);
font-size: var(--font-sm);
color: var(--text-primary);
}
.expert-message__content {
padding-left: 32px;
}
</style>

View File

@ -0,0 +1,177 @@
<template>
<div v-if="teamStore.isTeamMode" class="expert-team-view">
<div class="expert-team-view__status-bar">
<div class="expert-team-view__experts">
<div
v-for="expert in teamStore.activeExperts"
:key="expert.id"
class="expert-team-view__expert-chip"
:class="{ 'expert-team-view__expert-chip--selected': teamStore.selectedExpertId === expert.id }"
:style="{ borderColor: expert.color }"
@click="teamStore.selectExpert(teamStore.selectedExpertId === expert.id ? null : expert.id)"
>
<div class="expert-team-view__expert-avatar" :style="{ backgroundColor: expert.color }">
{{ expert.name.charAt(0).toUpperCase() }}
</div>
<span class="expert-team-view__expert-name">{{ expert.name }}</span>
<a-tag v-if="expert.is_lead" color="gold" size="small">Lead</a-tag>
</div>
</div>
<div class="expert-team-view__progress">
<span>{{ completedCount }}/{{ totalCount }} 阶段完成</span>
<a-progress :percent="progressPercent" size="small" :show-info="false" />
</div>
</div>
<div v-if="showPlan" class="expert-team-view__plan">
<div class="expert-team-view__plan-header" @click="planExpanded = !planExpanded">
<span>协作计划</span>
<UpOutlined v-if="planExpanded" />
<DownOutlined v-else />
</div>
<div v-if="planExpanded" class="expert-team-view__plan-body">
<div
v-for="phase in phases"
:key="phase.id"
class="expert-team-view__phase"
:class="`expert-team-view__phase--${phase.status}`"
>
<span class="expert-team-view__phase-name">{{ phase.name }}</span>
<span class="expert-team-view__phase-expert">{{ phase.assigned_expert }}</span>
<a-tag :color="statusColor(phase.status)" size="small">{{ statusLabel(phase.status) }}</a-tag>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { Progress as AProgress, Tag as ATag } from 'ant-design-vue'
import { UpOutlined, DownOutlined } from '@ant-design/icons-vue'
import { useTeamStore } from '@/stores/team'
const teamStore = useTeamStore()
const planExpanded = ref(false)
const showPlan = ref(true)
const phases = computed(() => teamStore.teamState?.plan_phases || [])
const completedCount = computed(() => phases.value.filter(p => p.status === 'completed').length)
const totalCount = computed(() => phases.value.length)
const progressPercent = computed(() => totalCount.value > 0 ? Math.round(completedCount.value / totalCount.value * 100) : 0)
function statusColor(status: string): string {
const colors: Record<string, string> = { pending: 'default', in_progress: 'processing', completed: 'success', failed: 'error' }
return colors[status] || 'default'
}
function statusLabel(status: string): string {
const labels: Record<string, string> = { pending: '待执行', in_progress: '执行中', completed: '已完成', failed: '失败' }
return labels[status] || status
}
</script>
<style scoped>
.expert-team-view {
margin-bottom: var(--space-3);
}
.expert-team-view__status-bar {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-2) var(--space-3);
background: var(--bg-secondary);
border-radius: var(--radius-lg);
gap: var(--space-3);
}
.expert-team-view__experts {
display: flex;
gap: var(--space-2);
flex-wrap: wrap;
}
.expert-team-view__expert-chip {
display: flex;
align-items: center;
gap: var(--space-1);
padding: 2px var(--space-2);
border: 1px solid;
border-radius: var(--radius-full);
cursor: pointer;
transition: all var(--transition-fast);
}
.expert-team-view__expert-chip--selected {
background: var(--bg-tertiary);
}
.expert-team-view__expert-avatar {
width: 20px;
height: 20px;
border-radius: var(--radius-full);
display: flex;
align-items: center;
justify-content: center;
color: var(--text-inverse);
font-size: 10px;
font-weight: var(--font-weight-semibold);
}
.expert-team-view__expert-name {
font-size: var(--font-xs);
}
.expert-team-view__progress {
display: flex;
align-items: center;
gap: var(--space-2);
font-size: var(--font-xs);
min-width: 160px;
}
.expert-team-view__plan {
margin-top: var(--space-2);
background: var(--bg-secondary);
border-radius: var(--radius-lg);
overflow: hidden;
}
.expert-team-view__plan-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--space-2) var(--space-3);
cursor: pointer;
font-size: var(--font-sm);
font-weight: var(--font-weight-medium);
}
.expert-team-view__plan-body {
padding: 0 var(--space-3) var(--space-2);
}
.expert-team-view__phase {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-1) 0;
font-size: var(--font-xs);
}
.expert-team-view__phase-name {
flex: 1;
}
.expert-team-view__phase-expert {
color: var(--text-secondary);
}
.expert-team-view__phase--completed {
opacity: 0.7;
}
.expert-team-view__phase--in_progress {
font-weight: var(--font-weight-medium);
}
</style>

View File

@ -0,0 +1,89 @@
<template>
<div v-if="phases.length > 0" class="plan-visualization">
<div class="plan-visualization__title">协作计划时间线</div>
<a-timeline>
<a-timeline-item
v-for="phase in phases"
:key="phase.id"
:color="timelineColor(phase.status)"
>
<div class="plan-visualization__phase">
<div class="plan-visualization__phase-header">
<span class="plan-visualization__phase-name">{{ phase.name }}</span>
<a-tag :color="statusColor(phase.status)" size="small">{{ statusLabel(phase.status) }}</a-tag>
</div>
<div class="plan-visualization__phase-detail">
<span>执行者: {{ phase.assigned_expert }}</span>
<span v-if="phase.milestone"> | 里程碑: {{ phase.milestone }}</span>
</div>
<div v-if="phase.parallel_type !== 'serial'" class="plan-visualization__phase-type">
<a-tag size="small">{{ parallelLabel(phase.parallel_type) }}</a-tag>
</div>
</div>
</a-timeline-item>
</a-timeline>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { Timeline as ATimeline, Tag as ATag } from 'ant-design-vue'
import { useTeamStore } from '@/stores/team'
const ATimelineItem = ATimeline.Item
const teamStore = useTeamStore()
const phases = computed(() => teamStore.teamState?.plan_phases || [])
function timelineColor(status: string): string {
const colors: Record<string, string> = { pending: 'gray', in_progress: 'blue', completed: 'green', failed: 'red' }
return colors[status] || 'gray'
}
function statusColor(status: string): string {
const colors: Record<string, string> = { pending: 'default', in_progress: 'processing', completed: 'success', failed: 'error' }
return colors[status] || 'default'
}
function statusLabel(status: string): string {
const labels: Record<string, string> = { pending: '待执行', in_progress: '执行中', completed: '已完成', failed: '失败' }
return labels[status] || status
}
function parallelLabel(type: string): string {
const labels: Record<string, string> = { subtask_parallel: '子任务并行', competitive_parallel: '竞标并行' }
return labels[type] || type
}
</script>
<style scoped>
.plan-visualization {
padding: var(--space-3);
}
.plan-visualization__title {
font-weight: var(--font-weight-medium);
margin-bottom: var(--space-3);
font-size: var(--font-md);
}
.plan-visualization__phase-header {
display: flex;
align-items: center;
gap: var(--space-2);
}
.plan-visualization__phase-name {
font-weight: var(--font-weight-medium);
}
.plan-visualization__phase-detail {
font-size: var(--font-xs);
color: var(--text-secondary);
margin-top: 2px;
}
.plan-visualization__phase-type {
margin-top: var(--space-1);
}
</style>

View File

@ -1,11 +1,14 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { apiClient } from '@/api/client' import { apiClient } from '@/api/client'
import { useTeamStore } from '@/stores/team'
import type { import type {
IChatMessage, IChatMessage,
IConversation, IConversation,
IChatRequest, IChatRequest,
WsClientMessage, WsClientMessage,
IExpertTeamState,
ITeamPlanPhase,
} from '@/api/types' } from '@/api/types'
function generateId(): string { function generateId(): string {
@ -238,6 +241,15 @@ export const useChatStore = defineStore('chat', () => {
// --- Internal helpers --- // --- Internal helpers ---
/** Get team store lazily — safe to call inside actions after Pinia is installed */
let _teamStore: ReturnType<typeof useTeamStore> | null = null
function _getTeamStore() {
if (!_teamStore) {
_teamStore = useTeamStore()
}
return _teamStore
}
function handleWsMessage(data: Record<string, any>): void { function handleWsMessage(data: Record<string, any>): void {
// Backend sends nested data: {type, data: {...}} // Backend sends nested data: {type, data: {...}}
// Flatten for easier access // Flatten for easier access
@ -393,6 +405,100 @@ export const useChatStore = defineStore('chat', () => {
streamingSteps.value = [] streamingSteps.value = []
break break
} }
case 'team_formed': {
const teamStore = _getTeamStore()
if (teamStore) {
teamStore.setTeamState(payload as IExpertTeamState)
}
streamingSteps.value.push(`专家团队已组建: ${(payload as IExpertTeamState).experts.map((e) => e.name).join(', ')}`)
break
}
case 'expert_step': {
const conversationId = currentConversationId.value
if (!conversationId) break
const conv = conversations.value.find((c) => c.id === conversationId)
if (!conv) break
// Dedup: append to existing expert message if one exists for this expert
const existingExpertMsg = [...conv.messages]
.reverse()
.find((m) => m.expert_id === payload.expert_id && m.status === 'pending')
if (existingExpertMsg) {
updateMessage(conversationId, existingExpertMsg.id, {
content: (existingExpertMsg.content || '') + (payload.content || ''),
})
} else {
const expertMsg: IChatMessage = {
id: generateId(),
role: 'assistant',
content: payload.content || '',
timestamp: new Date().toISOString(),
status: 'pending',
expert_id: payload.expert_id,
expert_name: payload.expert_name,
expert_color: payload.expert_color,
message_type: 'chat',
}
appendMessage(conversationId, expertMsg)
}
streamingSteps.value.push(`${payload.expert_name}: 步骤 ${payload.step}`)
break
}
case 'expert_result': {
const conversationId = currentConversationId.value
if (!conversationId) break
const conv = conversations.value.find((c) => c.id === conversationId)
if (!conv) break
const expertMsg: IChatMessage = {
id: generateId(),
role: 'assistant',
content: payload.content || '',
timestamp: new Date().toISOString(),
status: 'completed',
expert_id: payload.expert_id,
expert_name: payload.expert_name,
expert_color: payload.expert_color,
message_type: 'chat',
}
appendMessage(conversationId, expertMsg)
break
}
case 'plan_update': {
const teamStore = _getTeamStore()
if (teamStore) {
teamStore.updatePhases(payload.plan_phases)
}
break
}
case 'team_synthesis': {
const conversationId = currentConversationId.value
if (!conversationId) break
const conv = conversations.value.find((c) => c.id === conversationId)
if (!conv) break
const synthesisMsg: IChatMessage = {
id: generateId(),
role: 'assistant',
content: payload.content || '',
timestamp: new Date().toISOString(),
status: 'completed',
message_type: 'milestone',
}
appendMessage(conversationId, synthesisMsg)
break
}
case 'team_dissolved': {
const teamStore = _getTeamStore()
if (teamStore) {
teamStore.clearTeam()
}
streamingSteps.value.push('专家团队已解散')
break
}
} }
} }

View File

@ -0,0 +1,55 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type { IExpertTeamState, ITeamPlanPhase } from '@/api/types'
export const useTeamStore = defineStore('team', () => {
const teamState = ref<IExpertTeamState | null>(null)
const selectedExpertId = ref<string | null>(null)
const activeExperts = computed(() =>
teamState.value?.experts.filter(e => e.status === 'active') || []
)
const leadExpert = computed(() =>
teamState.value?.experts.find(e => e.is_lead) || null
)
const isTeamMode = computed(() =>
teamState.value !== null && teamState.value.status !== 'dissolved'
)
const currentPhase = computed(() => {
if (!teamState.value) return null
return teamState.value.plan_phases.find(p => p.status === 'in_progress') || null
})
const completedPhases = computed(() =>
teamState.value?.plan_phases.filter(p => p.status === 'completed') || []
)
function setTeamState(state: IExpertTeamState) {
teamState.value = state
}
function updatePhases(phases: ITeamPlanPhase[]) {
if (teamState.value) {
// Reassign the whole object to trigger Vue reactivity
teamState.value = { ...teamState.value, plan_phases: phases }
}
}
function selectExpert(expertId: string | null) {
selectedExpertId.value = expertId
}
function clearTeam() {
teamState.value = null
selectedExpertId.value = null
}
return {
teamState, selectedExpertId, activeExperts, leadExpert,
isTeamMode, currentPhase, completedPhases,
setTeamState, updatePhases, selectExpert, clearTeam
}
})

View File

@ -16,6 +16,7 @@
</a-empty> </a-empty>
</div> </div>
<template v-else> <template v-else>
<ExpertTeamView />
<div class="chat-view__messages" ref="messagesContainer"> <div class="chat-view__messages" ref="messagesContainer">
<div v-if="chatStore.currentMessages.length === 0" class="chat-view__welcome"> <div v-if="chatStore.currentMessages.length === 0" class="chat-view__welcome">
<div class="chat-view__welcome-inner"> <div class="chat-view__welcome-inner">
@ -68,13 +69,16 @@ import {
ThunderboltOutlined, ThunderboltOutlined,
} from '@ant-design/icons-vue' } from '@ant-design/icons-vue'
import { useChatStore } from '@/stores/chat' import { useChatStore } from '@/stores/chat'
import { useTeamStore } from '@/stores/team'
import ChatSidebar from '@/components/chat/ChatSidebar.vue' import ChatSidebar from '@/components/chat/ChatSidebar.vue'
import ChatMessage from '@/components/chat/ChatMessage.vue' import ChatMessage from '@/components/chat/ChatMessage.vue'
import ChatInput from '@/components/chat/ChatInput.vue' import ChatInput from '@/components/chat/ChatInput.vue'
import ExpertTeamView from '@/components/chat/ExpertTeamView.vue'
const ATypographyText = ATypography.Text const ATypographyText = ATypography.Text
const chatStore = useChatStore() const chatStore = useChatStore()
const teamStore = useTeamStore()
const messagesContainer = ref<HTMLElement | null>(null) const messagesContainer = ref<HTMLElement | null>(null)
const welcomeHints = [ const welcomeHints = [

View File

@ -3,6 +3,7 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import hmac
import json import json
import logging import logging
from typing import Any from typing import Any
@ -95,6 +96,38 @@ chat_manager = ChatConnectionManager()
# ── Helper ──────────────────────────────────────────────────────────── # ── Helper ────────────────────────────────────────────────────────────
_VALID_TEAM_EVENT_TYPES = frozenset({
"team_formed", "expert_step", "expert_result",
"plan_update", "team_synthesis", "team_dissolved",
})
async def emit_team_event(websocket: WebSocket, event_type: str, data: dict) -> None:
"""Emit a team-related WebSocket event.
Supported event types:
team_formed Team assembled with expert list and plan.
data: {team_id, status, experts, plan_phases, lead_expert}
expert_step An expert is executing a step.
data: {expert_id, expert_name, expert_color, content, step}
expert_result An expert produced a result.
data: {expert_id, expert_name, expert_color, content}
plan_update Team plan phases updated.
data: {plan_phases}
team_synthesis Final synthesis from the lead expert.
data: {content}
team_dissolved Team dissolved after completion.
data: {team_id}
"""
if event_type not in _VALID_TEAM_EVENT_TYPES:
logger.warning(f"emit_team_event: invalid event_type '{event_type}'")
return
await websocket.send_json({
"type": event_type,
"data": data,
})
def _get_session_manager(request: Request) -> SessionManager: def _get_session_manager(request: Request) -> SessionManager:
return request.app.state.session_manager return request.app.state.session_manager
@ -253,6 +286,14 @@ async def chat_websocket(websocket: WebSocket, session_id: str) -> None:
{"type": "final_answer", "content": "..."} Agent's final reply {"type": "final_answer", "content": "..."} Agent's final reply
{"type": "error", "data": {"message": "..."}} Error occurred {"type": "error", "data": {"message": "..."}} Error occurred
{"type": "pong"} Heartbeat response {"type": "pong"} Heartbeat response
Expert Team events (Server Client):
{"type": "team_formed", "data": {...}} Team assembled
{"type": "expert_step", "data": {...}} Expert executing step
{"type": "expert_result", "data": {...}} Expert produced result
{"type": "plan_update", "data": {...}} Plan phases updated
{"type": "team_synthesis", "data": {...}} Final synthesis
{"type": "team_dissolved", "data": {...}} Team dissolved
""" """
# Authentication # Authentication
configured_api_key: str | None = None configured_api_key: str | None = None
@ -263,7 +304,7 @@ async def chat_websocket(websocket: WebSocket, session_id: str) -> None:
if configured_api_key: if configured_api_key:
provided = websocket.query_params.get("api_key") provided = websocket.query_params.get("api_key")
if provided != configured_api_key: if not provided or not hmac.compare_digest(provided, configured_api_key):
await websocket.accept() await websocket.accept()
await websocket.send_json({"type": "error", "data": {"message": "Invalid api_key"}}) await websocket.send_json({"type": "error", "data": {"message": "Invalid api_key"}})
await websocket.close(code=4001, reason="Invalid api_key") await websocket.close(code=4001, reason="Invalid api_key")

View File

@ -0,0 +1,475 @@
"""Integration tests for Expert Team Mode.
Covers Key Flows F1-F6 and Acceptance Examples AE1-AE5 from the requirements document.
Uses mocked AgentPool and LLM calls to test orchestration logic.
"""
import asyncio
import pytest
from agentkit.experts.config import ExpertConfig, ExpertTemplate
from agentkit.experts.plan import (
CollaborationPlan,
MergeStrategy,
ParallelType,
PhaseStatus,
PlanPhase,
PlanStatus,
)
from agentkit.experts.team import TeamStatus
from agentkit.experts.router import ExpertTeamRouter
from agentkit.experts.registry import ExpertTemplateRegistry
from agentkit.core.handoff_transport import InProcessHandoffTransport
from agentkit.core.shared_workspace import SharedWorkspace
# --- Helpers ---
def make_expert_config(
name: str,
persona: str = "",
is_lead: bool = False,
bound_skills: list[str] | None = None,
) -> ExpertConfig:
"""Helper to create ExpertConfig for testing."""
return ExpertConfig(
name=name,
persona=persona,
is_lead=is_lead,
bound_skills=bound_skills or [],
agent_type="expert",
task_mode="llm_generate",
prompt={"identity": f"You are {name}, {persona}"} if persona else {"identity": f"You are {name}"},
)
# --- Fixtures ---
@pytest.fixture
def workspace():
return SharedWorkspace()
@pytest.fixture
def registry():
reg = ExpertTemplateRegistry()
reg.register(
ExpertTemplate(
name="analyst",
description="Data Analyst",
config=make_expert_config(
"analyst", "数据分析专家", bound_skills=["data_analysis"]
),
)
)
reg.register(
ExpertTemplate(
name="strategist",
description="Strategy Consultant",
config=make_expert_config(
"strategist", "战略顾问", bound_skills=["strategy_planning"]
),
)
)
reg.register(
ExpertTemplate(
name="architect",
description="Software Architect",
config=make_expert_config(
"architect", "软件架构师", bound_skills=["system_design"]
),
)
)
return reg
# --- F1: Manual Team Formation ---
class TestManualTeamFormation:
"""Covers F1: User specifies expert team members."""
async def test_manual_team_with_templates(self, registry):
"""AE1: User specifies expert team by template names."""
router = ExpertTeamRouter(registry)
result = router.resolve("@team:analyst,strategist 分析这份市场报告")
assert result.team_mode is True
assert result.specified_experts == ["analyst", "strategist"]
assert result.auto_compose is False
# Resolve to configs
configs = router.resolve_expert_configs(result.specified_experts)
assert len(configs) == 2
assert configs[0].name == "analyst"
assert configs[1].name == "strategist"
async def test_manual_team_subtask_parallel(self):
"""AE1: Subtask-level parallel execution after manual team formation."""
plan = CollaborationPlan(
id="plan-1",
task="分析市场报告",
lead_expert="lead",
phases=[
PlanPhase(
id="p1",
name="数据分析",
assigned_expert="analyst",
task_description="执行数据分析",
parallel_type=ParallelType.SUBTASK_PARALLEL,
),
PlanPhase(
id="p2",
name="战略建议",
assigned_expert="strategist",
task_description="提供战略建议",
parallel_type=ParallelType.SUBTASK_PARALLEL,
),
],
)
errors = plan.validate()
assert errors == []
# --- F2: Auto Team Formation ---
class TestAutoTeamFormation:
"""Covers F2: System auto-composes expert team."""
async def test_auto_team_high_complexity(self, registry):
"""AE2: High complexity triggers team mode suggestion."""
router = ExpertTeamRouter(registry)
result = router.resolve("评审这个复杂的技术方案", complexity=0.85)
assert result.team_mode is True
assert result.auto_compose is True
assert result.match_method == "complexity_suggestion"
async def test_auto_team_competitive_parallel(self):
"""AE2: Competitive parallel with BEST strategy."""
plan = CollaborationPlan(
id="plan-2",
task="技术方案评审",
lead_expert="lead",
phases=[
PlanPhase(
id="p1",
name="架构方案A",
assigned_expert="architect_a",
task_description="设计架构方案A",
parallel_type=ParallelType.COMPETITIVE_PARALLEL,
merge_strategy=MergeStrategy.BEST,
),
PlanPhase(
id="p2",
name="架构方案B",
assigned_expert="architect_b",
task_description="设计架构方案B",
parallel_type=ParallelType.COMPETITIVE_PARALLEL,
merge_strategy=MergeStrategy.BEST,
),
],
)
errors = plan.validate()
assert errors == []
# --- F3: Decentralized Collaboration ---
class TestDecentralizedCollaboration:
"""Covers F3: Experts collaborate directly without Lead mediation."""
async def test_expert_direct_handoff(self):
"""AE4: Expert A requests assistance from Expert B directly."""
transport = InProcessHandoffTransport()
channel = "expert:analyst:handoff"
# Start listening first (consumer must be registered before send)
messages = []
async def listener():
async for msg in transport.listen(channel):
messages.append(msg)
break
task = asyncio.create_task(listener())
await asyncio.sleep(0.05)
# Expert A sends assist request
await transport.send(
channel,
{
"source_expert": "analyst",
"target_expert": "researcher",
"task": "需要行业数据",
"type": "assist_request",
},
)
await asyncio.wait_for(task, timeout=2.0)
assert len(messages) == 1
assert messages[0]["source_expert"] == "analyst"
assert messages[0]["type"] == "assist_request"
transport.close()
async def test_team_channel_broadcast(self):
"""All experts receive team channel messages."""
transport = InProcessHandoffTransport()
channel = "team:test-team"
# Two consumers listening
consumer1_msgs = []
consumer2_msgs = []
async def consumer1():
async for msg in transport.listen(channel):
consumer1_msgs.append(msg)
if len(consumer1_msgs) >= 1:
break
async def consumer2():
async for msg in transport.listen(channel):
consumer2_msgs.append(msg)
if len(consumer2_msgs) >= 1:
break
# Start consumers
t1 = asyncio.create_task(consumer1())
t2 = asyncio.create_task(consumer2())
await asyncio.sleep(0.05)
# Send message
await transport.send(channel, {"type": "chat", "content": "hello"})
await asyncio.wait_for(asyncio.gather(t1, t2), timeout=2.0)
assert len(consumer1_msgs) == 1
assert len(consumer2_msgs) == 1
transport.close()
# --- F4: User Intervention ---
class TestUserIntervention:
"""Covers F4: User intervenes during collaboration."""
async def test_user_intervention_broadcast(self):
"""AE3: User intervention message reaches all experts."""
transport = InProcessHandoffTransport()
channel = "team:intervention-test"
received = []
async def listener():
async for msg in transport.listen(channel):
received.append(msg)
if len(received) >= 1:
break
task = asyncio.create_task(listener())
await asyncio.sleep(0.05)
await transport.send(
channel,
{"type": "user_intervention", "content": "重点看成本优化"},
)
await asyncio.wait_for(task, timeout=2.0)
assert len(received) == 1
assert received[0]["type"] == "user_intervention"
transport.close()
async def test_plan_modification_by_user(self):
"""User can modify the collaboration plan."""
plan = CollaborationPlan(
id="plan-3",
task="分析报告",
lead_expert="lead",
phases=[
PlanPhase(
id="p1",
name="数据分析",
assigned_expert="analyst",
task_description="执行数据分析",
),
],
)
# User modifies plan — add a new phase
plan.phases.append(
PlanPhase(
id="p2",
name="成本优化",
assigned_expert="cost_analyst",
task_description="优化成本",
depends_on=["p1"],
)
)
errors = plan.validate()
assert errors == []
assert len(plan.phases) == 2
# --- F5: Competitive Parallel ---
class TestCompetitiveParallel:
"""Covers F5: Competitive parallel execution with merge strategies."""
async def test_vote_strategy_with_tie_break(self):
"""R23: Vote strategy with Lead Expert tie-breaking."""
plan = CollaborationPlan(
id="plan-4",
task="方案评审",
lead_expert="lead",
phases=[
PlanPhase(
id="p1",
name="方案竞争",
assigned_expert="architect",
task_description="竞争性方案设计",
parallel_type=ParallelType.COMPETITIVE_PARALLEL,
merge_strategy=MergeStrategy.VOTE,
),
],
)
errors = plan.validate()
assert errors == []
assert plan.phases[0].merge_strategy == MergeStrategy.VOTE
async def test_fusion_strategy(self):
"""R24: Fusion strategy merges multiple results."""
plan = CollaborationPlan(
id="plan-5",
task="方案融合",
lead_expert="lead",
phases=[
PlanPhase(
id="p1",
name="方案融合",
assigned_expert="lead",
task_description="融合多个方案",
parallel_type=ParallelType.COMPETITIVE_PARALLEL,
merge_strategy=MergeStrategy.FUSION,
),
],
)
errors = plan.validate()
assert errors == []
# --- F6: Team Dissolution ---
class TestTeamDissolution:
"""Covers F6: Team dissolution and output preservation."""
async def test_dissolution_preserves_outputs(self, workspace):
"""R36: Temporary Expert outputs preserved in SharedWorkspace after dissolution."""
# Write some output to workspace
await workspace.write(
"team:test:analyst:result", {"report": "analysis result"}, "analyst"
)
# Verify output exists
data = await workspace.read("team:test:analyst:result")
assert data is not None
assert data["value"]["report"] == "analysis result"
async def test_dissolution_sets_status(self):
"""Team status becomes DISSOLVED after dissolution."""
assert TeamStatus.DISSOLVED == "dissolved"
# --- Retry and Fallback ---
class TestRetryAndFallback:
"""Tests retry + fallback degradation strategy."""
async def test_plan_failure_triggers_retry(self):
"""Failed phase triggers retry before fallback."""
plan = CollaborationPlan(
id="plan-6",
task="测试任务",
lead_expert="lead",
phases=[
PlanPhase(
id="p1",
name="阶段1",
assigned_expert="expert1",
task_description="执行阶段1",
),
],
)
# Simulate failure
plan.update_phase_status("p1", PhaseStatus.FAILED)
assert plan.phases[0].status == PhaseStatus.FAILED
# Reset for retry
plan.update_phase_status("p1", PhaseStatus.PENDING)
assert plan.phases[0].status == PhaseStatus.PENDING
async def test_fallback_after_retry_failure(self):
"""After retry still fails, fallback to single agent."""
plan = CollaborationPlan(
id="plan-7",
task="测试任务",
lead_expert="lead",
phases=[
PlanPhase(
id="p1",
name="阶段1",
assigned_expert="expert1",
task_description="执行阶段1",
)
],
)
# Mark as fallback
plan.status = PlanStatus.FALLBACK
assert plan.status == PlanStatus.FALLBACK
# --- Dynamic Expert Addition/Removal ---
class TestDynamicExpertManagement:
"""AE5: Dynamic addition and removal of experts."""
async def test_add_expert_by_template(self, registry):
"""Add expert by template name."""
router = ExpertTeamRouter(registry)
result = router.resolve("@team:analyst 分析报告")
configs = router.resolve_expert_configs(result.specified_experts)
assert len(configs) == 1
assert configs[0].name == "analyst"
assert configs[0].bound_skills == ["data_analysis"]
async def test_add_expert_dynamic(self, registry):
"""Add expert with non-existent template creates dynamic config."""
router = ExpertTeamRouter(registry)
configs = router.resolve_expert_configs(["legal_advisor"])
assert len(configs) == 1
assert configs[0].name == "legal_advisor"
assert "legal_advisor" in configs[0].persona

View File

@ -0,0 +1,249 @@
"""Tests for HandoffTransport — Handoff 传输层抽象"""
from __future__ import annotations
import asyncio
from unittest.mock import AsyncMock, patch
from agentkit.core.handoff_transport import (
InProcessHandoffTransport,
RedisHandoffTransport,
)
# ── InProcessHandoffTransport Tests ─────────────────────────────
class TestInProcessHandoffTransport:
"""InProcessHandoffTransport 单元测试"""
async def test_send_and_receive(self):
"""测试基本的消息发送和接收"""
transport = InProcessHandoffTransport()
# 启动监听协程
received: list[dict] = []
async def consumer():
async for msg in transport.listen("test-channel"):
received.append(msg)
break # 收到一条后退出
consumer_task = asyncio.create_task(consumer())
# 给消费者一点时间启动
await asyncio.sleep(0.05)
await transport.send("test-channel", {"task_id": "t1", "action": "handoff"})
await asyncio.wait_for(consumer_task, timeout=1.0)
assert len(received) == 1
assert received[0] == {"task_id": "t1", "action": "handoff"}
async def test_broadcast_to_multiple_consumers(self):
"""测试广播到多个消费者"""
transport = InProcessHandoffTransport()
received_a: list[dict] = []
received_b: list[dict] = []
async def consumer_a():
async for msg in transport.listen("broadcast-ch"):
received_a.append(msg)
if len(received_a) >= 2:
break
async def consumer_b():
async for msg in transport.listen("broadcast-ch"):
received_b.append(msg)
if len(received_b) >= 2:
break
task_a = asyncio.create_task(consumer_a())
task_b = asyncio.create_task(consumer_b())
# 给消费者启动时间
await asyncio.sleep(0.05)
await transport.send("broadcast-ch", {"seq": 1})
await transport.send("broadcast-ch", {"seq": 2})
await asyncio.wait_for(task_a, timeout=1.0)
await asyncio.wait_for(task_b, timeout=1.0)
assert len(received_a) == 2
assert len(received_b) == 2
assert received_a[0] == {"seq": 1}
assert received_b[1] == {"seq": 2}
async def test_register_handler_called_on_send(self):
"""测试注册的处理器在 send 时被调用"""
transport = InProcessHandoffTransport()
handler_calls: list[dict] = []
async def handler(message: dict) -> None:
handler_calls.append(message)
transport.register_handler("handler-ch", handler)
await transport.send("handler-ch", {"task_id": "t1"})
await transport.send("handler-ch", {"task_id": "t2"})
assert len(handler_calls) == 2
assert handler_calls[0] == {"task_id": "t1"}
assert handler_calls[1] == {"task_id": "t2"}
async def test_listen_blocks_when_queue_empty(self):
"""测试队列为空时 listen 阻塞等待"""
transport = InProcessHandoffTransport()
received: list[dict] = []
async def consumer():
async for msg in transport.listen("empty-ch"):
received.append(msg)
break
task = asyncio.create_task(consumer())
await asyncio.sleep(0.05)
# 此时不应有消息
assert len(received) == 0
# 发送消息后消费者应收到
await transport.send("empty-ch", {"hello": "world"})
await asyncio.wait_for(task, timeout=1.0)
assert len(received) == 1
async def test_close_clears_state(self):
"""测试 close 清理所有状态"""
transport = InProcessHandoffTransport()
async def handler(message: dict) -> None:
pass
transport.register_handler("ch1", handler)
transport.register_handler("ch2", handler)
assert len(transport._handlers) == 2
assert len(transport._handlers["ch1"]) == 1
assert len(transport._handlers["ch2"]) == 1
transport.close()
assert len(transport._handlers) == 0
assert len(transport._channels) == 0
async def test_send_to_nonexistent_channel_no_raise(self):
"""测试向不存在的频道发送消息不抛异常(消息丢弃)"""
transport = InProcessHandoffTransport()
# 不应有任何异常
await transport.send("nonexistent-ch", {"data": "test"})
async def test_handler_error_does_not_propagate(self):
"""测试处理器抛出异常不影响其他处理器和 send"""
transport = InProcessHandoffTransport()
good_calls: list[dict] = []
async def bad_handler(message: dict) -> None:
raise ValueError("handler error")
async def good_handler(message: dict) -> None:
good_calls.append(message)
transport.register_handler("err-ch", bad_handler)
transport.register_handler("err-ch", good_handler)
# send 不应抛出异常
await transport.send("err-ch", {"task_id": "t1"})
# good_handler 仍应被调用
assert len(good_calls) == 1
assert good_calls[0] == {"task_id": "t1"}
# ── RedisHandoffTransport Tests ─────────────────────────────────
class TestRedisHandoffTransport:
"""RedisHandoffTransport 单元测试(不依赖真实 Redis"""
def test_initialization_with_redis_url(self):
"""测试使用 redis_url 初始化"""
transport = RedisHandoffTransport(redis_url="redis://localhost:6379")
assert transport._redis_url == "redis://localhost:6379"
assert transport._redis is None
assert len(transport._active_pubsubs) == 0
assert len(transport._handlers) == 0
assert len(transport._listen_tasks) == 0
def test_lazy_connection_no_connection_on_init(self):
"""测试懒连接:初始化时不建立 Redis 连接"""
transport = RedisHandoffTransport(redis_url="redis://localhost:6379")
# 初始化后不应有任何连接
assert transport._redis is None
assert len(transport._active_pubsubs) == 0
async def test_send_calls_ensure_connection(self):
"""测试 send 触发懒连接"""
transport = RedisHandoffTransport(redis_url="redis://localhost:6379")
mock_redis = AsyncMock()
mock_redis.ping = AsyncMock()
mock_redis.publish = AsyncMock()
with patch("agentkit.core.handoff_transport.aioredis") as mock_aioredis:
mock_aioredis.from_url.return_value = mock_redis
await transport.send("test-ch", {"task_id": "t1"})
# 验证 from_url 被调用
mock_aioredis.from_url.assert_called_once_with(
"redis://localhost:6379", decode_responses=True
)
# 验证 ping 被调用
mock_redis.ping.assert_called_once()
# 验证 publish 被调用
mock_redis.publish.assert_called_once()
async def test_register_handler_creates_listen_task(self):
"""测试 register_handler 启动后台监听任务"""
transport = RedisHandoffTransport(redis_url="redis://localhost:6379")
async def handler(message: dict) -> None:
pass
transport.register_handler("test-ch", handler)
assert "test-ch" in transport._listen_tasks
assert not transport._listen_tasks["test-ch"].done()
assert len(transport._handlers["test-ch"]) == 1
# 清理
await transport.close()
async def test_close_cancels_tasks_and_clears_state(self):
"""测试 close 取消监听任务并清理状态"""
transport = RedisHandoffTransport(redis_url="redis://localhost:6379")
async def handler(message: dict) -> None:
pass
transport.register_handler("ch1", handler)
mock_redis = AsyncMock()
mock_redis.close = AsyncMock()
transport._redis = mock_redis
await transport.close()
assert len(transport._handlers) == 0
assert len(transport._listen_tasks) == 0
assert transport._redis is None
mock_redis.close.assert_called_once()

View File

View File

@ -0,0 +1,221 @@
"""ExpertConfig 和 ExpertTemplate 单元测试"""
from __future__ import annotations
from typing import Any
import pytest
from agentkit.core.config_driven import AgentConfig
from agentkit.experts.config import ExpertConfig, ExpertTemplate
# ── 辅助函数 ──────────────────────────────────────────────
def _make_expert_config(
name: str = "test_expert",
agent_type: str = "expert",
persona: str = "",
thinking_style: str = "",
collaboration_strategy: str = "cooperative",
bound_skills: list[str] | None = None,
avatar: str = "",
color: str = "#1890ff",
is_lead: bool = False,
**kwargs: Any,
) -> ExpertConfig:
"""创建测试用 ExpertConfig 实例"""
return ExpertConfig(
name=name,
agent_type=agent_type,
persona=persona,
thinking_style=thinking_style,
collaboration_strategy=collaboration_strategy,
bound_skills=bound_skills,
avatar=avatar,
color=color,
is_lead=is_lead,
**kwargs,
)
# ── ExpertConfig 测试 ─────────────────────────────────────
class TestExpertConfig:
"""ExpertConfig 配置测试"""
def test_creation_with_all_fields(self):
"""创建 ExpertConfig 并设置所有字段"""
config = ExpertConfig(
name="analyst",
agent_type="analyst",
persona="善于数据分析的专家",
thinking_style="逻辑推理",
collaboration_strategy="cooperative",
bound_skills=["data_query", "chart_gen"],
avatar="📊",
color="#52c41a",
is_lead=True,
task_mode="llm_generate",
prompt={"identity": "数据分析师"},
)
assert config.name == "analyst"
assert config.agent_type == "analyst"
assert config.persona == "善于数据分析的专家"
assert config.thinking_style == "逻辑推理"
assert config.collaboration_strategy == "cooperative"
assert config.bound_skills == ["data_query", "chart_gen"]
assert config.avatar == "📊"
assert config.color == "#52c41a"
assert config.is_lead is True
def test_inherits_agent_config_fields(self):
"""ExpertConfig 继承 AgentConfig 基础字段"""
config = ExpertConfig(
name="test",
agent_type="test",
version="2.0.0",
description="测试专家",
task_mode="llm_generate",
prompt={"identity": "测试"},
llm={"model": "gpt-4"},
tools=["search"],
)
assert isinstance(config, AgentConfig)
assert config.version == "2.0.0"
assert config.description == "测试专家"
assert config.prompt == {"identity": "测试"}
assert config.llm == {"model": "gpt-4"}
assert config.tools == ["search"]
def test_to_dict_from_dict_roundtrip(self):
"""to_dict / from_dict 往返序列化"""
config = ExpertConfig(
name="roundtrip",
agent_type="expert",
persona="测试角色",
thinking_style="创造性思维",
collaboration_strategy="lead",
bound_skills=["skill_a", "skill_b"],
avatar="🧠",
color="#ff4d4f",
is_lead=True,
task_mode="llm_generate",
prompt={"identity": "往返测试"},
)
d = config.to_dict()
restored = ExpertConfig.from_dict(d)
assert restored.name == config.name
assert restored.agent_type == config.agent_type
assert restored.persona == config.persona
assert restored.thinking_style == config.thinking_style
assert restored.collaboration_strategy == config.collaboration_strategy
assert restored.bound_skills == config.bound_skills
assert restored.avatar == config.avatar
assert restored.color == config.color
assert restored.is_lead == config.is_lead
assert restored.prompt == config.prompt
def test_bound_skills_defaults_to_empty_list(self):
"""bound_skills 未设置时默认为空列表"""
config = ExpertConfig(
name="no_skills",
agent_type="expert",
task_mode="llm_generate",
prompt={"identity": "无技能"},
)
assert config.bound_skills == []
def test_default_values(self):
"""ExpertConfig 默认值测试"""
config = ExpertConfig(
name="defaults",
agent_type="expert",
task_mode="llm_generate",
prompt={"identity": "默认"},
)
assert config.persona == ""
assert config.thinking_style == ""
assert config.collaboration_strategy == "cooperative"
assert config.bound_skills == []
assert config.avatar == ""
assert config.color == "#1890ff"
assert config.is_lead is False
# ── ExpertTemplate 测试 ───────────────────────────────────
class TestExpertTemplate:
"""ExpertTemplate 模板测试"""
def test_creation(self):
"""创建 ExpertTemplate"""
config = ExpertConfig(
name="analyst",
agent_type="analyst",
persona="数据分析师",
task_mode="llm_generate",
prompt={"identity": "分析师"},
)
template = ExpertTemplate(
name="analyst_template",
config=config,
is_builtin=True,
description="内置数据分析师模板",
)
assert template.name == "analyst_template"
assert template.config is config
assert template.is_builtin is True
assert template.description == "内置数据分析师模板"
def test_creation_defaults(self):
"""ExpertTemplate 默认值"""
config = ExpertConfig(
name="default_expert",
agent_type="expert",
task_mode="llm_generate",
prompt={"identity": "默认"},
)
template = ExpertTemplate(name="default_template", config=config)
assert template.is_builtin is False
assert template.description == ""
def test_to_dict_from_dict_roundtrip(self):
"""ExpertTemplate to_dict / from_dict 往返序列化"""
config = ExpertConfig(
name="roundtrip_expert",
agent_type="expert",
persona="往返测试",
thinking_style="分析型",
collaboration_strategy="cooperative",
bound_skills=["skill_x"],
avatar="🔬",
color="#722ed1",
is_lead=False,
task_mode="llm_generate",
prompt={"identity": "往返"},
)
template = ExpertTemplate(
name="roundtrip_template",
config=config,
is_builtin=False,
description="往返测试模板",
)
d = template.to_dict()
restored = ExpertTemplate.from_dict(d)
assert restored.name == template.name
assert restored.is_builtin == template.is_builtin
assert restored.description == template.description
assert restored.config.name == config.name
assert restored.config.persona == config.persona
assert restored.config.thinking_style == config.thinking_style
assert restored.config.collaboration_strategy == config.collaboration_strategy
assert restored.config.bound_skills == config.bound_skills
assert restored.config.avatar == config.avatar
assert restored.config.color == config.color
assert restored.config.is_lead == config.is_lead

View File

@ -0,0 +1,437 @@
"""Expert 运行时包装器单元测试"""
from __future__ import annotations
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from agentkit.core.config_driven import ConfigDrivenAgent
from agentkit.core.handoff_transport import InProcessHandoffTransport
from agentkit.core.shared_workspace import SharedWorkspace
from agentkit.experts.config import ExpertConfig
from agentkit.experts.expert import Expert
# ── 辅助函数 ──────────────────────────────────────────────
def _make_expert_config(
name: str = "test_expert",
agent_type: str = "expert",
persona: str = "测试专家",
thinking_style: str = "逻辑推理",
bound_skills: list[str] | None = None,
is_lead: bool = False,
color: str = "#1890ff",
avatar: str = "",
**kwargs,
) -> ExpertConfig:
"""创建测试用 ExpertConfig 实例"""
return ExpertConfig(
name=name,
agent_type=agent_type,
persona=persona,
thinking_style=thinking_style,
bound_skills=bound_skills or ["skill_a"],
is_lead=is_lead,
color=color,
avatar=avatar,
task_mode="llm_generate",
prompt={"identity": "测试"},
**kwargs,
)
def _make_mock_agent() -> MagicMock:
"""创建 mock ConfigDrivenAgent"""
agent = MagicMock(spec=ConfigDrivenAgent)
agent.name = "test_expert"
agent._prompt_template = None
return agent
def _make_mock_pool() -> AsyncMock:
"""创建 mock AgentPool"""
pool = AsyncMock()
pool.create_agent = AsyncMock(return_value=_make_mock_agent())
pool.remove_agent = AsyncMock()
return pool
# ── Expert 创建测试 ───────────────────────────────────────
class TestExpertCreate:
"""Expert.create 工厂方法测试"""
@pytest.mark.asyncio
async def test_create_expert_via_pool(self):
"""通过 AgentPool 创建 Expert"""
config = _make_expert_config()
pool = _make_mock_pool()
expert = await Expert.create(config=config, pool=pool)
pool.create_agent.assert_awaited_once_with(config)
assert expert.config is config
assert expert.agent is pool.create_agent.return_value
assert expert.is_active is True
assert expert.team_id is None
@pytest.mark.asyncio
async def test_create_with_handoff_transport(self):
"""创建 Expert 时传入 handoff_transport"""
config = _make_expert_config()
pool = _make_mock_pool()
transport = MagicMock(spec=InProcessHandoffTransport)
expert = await Expert.create(
config=config, pool=pool, handoff_transport=transport
)
assert expert._handoff_transport is transport
@pytest.mark.asyncio
async def test_create_with_workspace(self):
"""创建 Expert 时传入 workspace"""
config = _make_expert_config()
pool = _make_mock_pool()
workspace = MagicMock(spec=SharedWorkspace)
expert = await Expert.create(
config=config, pool=pool, workspace=workspace
)
assert expert._workspace is workspace
@pytest.mark.asyncio
async def test_create_with_team_context_injects_into_prompt(self):
"""team_context 注入到 Agent 的 prompt 中"""
config = _make_expert_config()
pool = _make_mock_pool()
mock_agent = _make_mock_agent()
# 模拟有 _prompt_template 的 Agent
mock_template = MagicMock()
mock_sections = MagicMock()
mock_sections.context = "原始上下文"
mock_template._sections = mock_sections
mock_agent._prompt_template = mock_template
pool.create_agent = AsyncMock(return_value=mock_agent)
expert = await Expert.create(
config=config,
pool=pool,
team_context="你是团队中的分析专家",
)
# 验证 team_context 被注入到 context 段落
assert "你是团队中的分析专家" in mock_sections.context
assert "原始上下文" in mock_sections.context
@pytest.mark.asyncio
async def test_create_with_team_context_no_existing_context(self):
"""team_context 注入到空 context 段落"""
config = _make_expert_config()
pool = _make_mock_pool()
mock_agent = _make_mock_agent()
mock_template = MagicMock()
mock_sections = MagicMock()
mock_sections.context = ""
mock_template._sections = mock_sections
mock_agent._prompt_template = mock_template
pool.create_agent = AsyncMock(return_value=mock_agent)
expert = await Expert.create(
config=config,
pool=pool,
team_context="你是团队中的分析专家",
)
assert mock_sections.context == "你是团队中的分析专家"
# ── Expert.send_message 测试 ──────────────────────────────
class TestExpertSendMessage:
"""Expert.send_message 消息广播测试"""
@pytest.mark.asyncio
async def test_send_message_broadcasts_to_transport(self):
"""send_message 通过 handoff_transport 广播消息"""
config = _make_expert_config()
agent = _make_mock_agent()
transport = AsyncMock(spec=InProcessHandoffTransport)
expert = Expert(config=config, agent=agent, handoff_transport=transport)
await expert.send_message("team:chat", "你好团队")
transport.send.assert_awaited_once()
call_args = transport.send.call_args
assert call_args[0][0] == "team:chat"
message = call_args[0][1]
assert message["expert_id"] == "test_expert"
assert message["content"] == "你好团队"
assert message["type"] == "chat"
@pytest.mark.asyncio
async def test_send_message_no_transport(self):
"""没有 handoff_transport 时 send_message 不报错(静默忽略)"""
config = _make_expert_config()
agent = _make_mock_agent()
expert = Expert(config=config, agent=agent, handoff_transport=None)
# 不应抛出异常
await expert.send_message("team:chat", "你好")
@pytest.mark.asyncio
async def test_send_message_long_content_stores_in_workspace(self):
"""长内容(>500 字符)存储到 SharedWorkspace广播摘要"""
config = _make_expert_config()
agent = _make_mock_agent()
transport = AsyncMock(spec=InProcessHandoffTransport)
workspace = AsyncMock(spec=SharedWorkspace)
expert = Expert(
config=config, agent=agent,
handoff_transport=transport, workspace=workspace,
)
long_content = "x" * 600
summary = "长内容摘要"
await expert.send_message("team:chat", long_content, summary=summary)
# 验证 workspace.write 被调用存储完整内容
workspace.write.assert_awaited_once()
write_call = workspace.write.call_args
assert write_call[0][1] == long_content # value = 完整内容
assert write_call[0][2] == "test_expert" # agent_id
# 验证 transport.send 广播的是摘要
transport.send.assert_awaited_once()
message = transport.send.call_args[0][1]
assert message["content"] == summary
@pytest.mark.asyncio
async def test_send_message_short_content_no_workspace(self):
"""短内容(<=500 字符)不存储到 workspace"""
config = _make_expert_config()
agent = _make_mock_agent()
transport = AsyncMock(spec=InProcessHandoffTransport)
workspace = AsyncMock(spec=SharedWorkspace)
expert = Expert(
config=config, agent=agent,
handoff_transport=transport, workspace=workspace,
)
await expert.send_message("team:chat", "短消息")
workspace.write.assert_not_awaited()
@pytest.mark.asyncio
async def test_send_message_long_content_without_summary(self):
"""长内容没有 summary 时广播截断内容前500字符但仍存储到 workspace 防止数据丢失"""
config = _make_expert_config()
agent = _make_mock_agent()
transport = AsyncMock(spec=InProcessHandoffTransport)
workspace = AsyncMock(spec=SharedWorkspace)
expert = Expert(
config=config, agent=agent,
handoff_transport=transport, workspace=workspace,
)
long_content = "x" * 600
await expert.send_message("team:chat", long_content)
# 即使没有 summary长内容也会存储到 workspace 防止数据丢失
workspace.write.assert_awaited_once()
# 广播的是截断内容
transport.send.assert_awaited_once()
message = transport.send.call_args[0][1]
assert message["content"] == long_content[:500]
# ── Expert.request_assist 测试 ────────────────────────────
class TestExpertRequestAssist:
"""Expert.request_assist 协助请求测试"""
@pytest.mark.asyncio
async def test_request_assist_sends_handoff_message(self):
"""request_assist 通过 handoff_transport 发送协助请求"""
config = _make_expert_config()
agent = _make_mock_agent()
transport = AsyncMock(spec=InProcessHandoffTransport)
expert = Expert(config=config, agent=agent, handoff_transport=transport)
await expert.request_assist(
target_expert="analyst", task="分析数据", reason="需要专业分析"
)
transport.send.assert_awaited_once()
call_args = transport.send.call_args
assert call_args[0][0] == "expert:analyst:handoff"
message = call_args[0][1]
assert message["source_expert"] == "test_expert"
assert message["target_expert"] == "analyst"
assert message["task"] == "分析数据"
assert message["reason"] == "需要专业分析"
assert message["type"] == "assist_request"
@pytest.mark.asyncio
async def test_request_assist_raises_without_transport(self):
"""没有 handoff_transport 时 request_assist 抛出 RuntimeError"""
config = _make_expert_config()
agent = _make_mock_agent()
expert = Expert(config=config, agent=agent, handoff_transport=None)
with pytest.raises(RuntimeError, match="No handoff transport configured"):
await expert.request_assist("analyst", "分析数据")
# ── Expert.propose_plan_modification 测试 ─────────────────
class TestExpertProposePlanModification:
"""Expert.propose_plan_modification 计划修改提案测试"""
@pytest.mark.asyncio
async def test_propose_plan_modification_sends_proposal(self):
"""propose_plan_modification 通过 handoff_transport 发送提案"""
config = _make_expert_config()
agent = _make_mock_agent()
transport = AsyncMock(spec=InProcessHandoffTransport)
expert = Expert(config=config, agent=agent, handoff_transport=transport)
modification = {"action": "add_step", "step": "验证结果"}
await expert.propose_plan_modification(plan_id="plan_1", modification=modification)
transport.send.assert_awaited_once()
call_args = transport.send.call_args
assert call_args[0][0] == "team:plan_modifications"
message = call_args[0][1]
assert message["proposing_expert"] == "test_expert"
assert message["plan_id"] == "plan_1"
assert message["modification"] == modification
assert message["type"] == "plan_modification_proposal"
@pytest.mark.asyncio
async def test_propose_plan_modification_raises_without_transport(self):
"""没有 handoff_transport 时 propose_plan_modification 抛出 RuntimeError"""
config = _make_expert_config()
agent = _make_mock_agent()
expert = Expert(config=config, agent=agent, handoff_transport=None)
with pytest.raises(RuntimeError, match="No handoff transport configured"):
await expert.propose_plan_modification("plan_1", {"action": "add"})
# ── Expert.get_capabilities_summary 测试 ──────────────────
class TestExpertGetCapabilitiesSummary:
"""Expert.get_capabilities_summary 能力摘要测试"""
def test_returns_correct_dict(self):
"""返回正确的能力摘要字典"""
config = _make_expert_config(
name="analyst",
persona="数据分析师",
thinking_style="逻辑推理",
bound_skills=["data_query", "chart_gen"],
is_lead=True,
color="#52c41a",
avatar="📊",
)
agent = _make_mock_agent()
expert = Expert(config=config, agent=agent)
summary = expert.get_capabilities_summary()
assert summary == {
"name": "analyst",
"persona": "数据分析师",
"thinking_style": "逻辑推理",
"bound_skills": ["data_query", "chart_gen"],
"is_lead": True,
"color": "#52c41a",
"avatar": "📊",
}
# ── Expert.destroy 测试 ───────────────────────────────────
class TestExpertDestroy:
"""Expert.destroy 销毁测试"""
@pytest.mark.asyncio
async def test_destroy_removes_agent_from_pool(self):
"""destroy 从 AgentPool 中移除 Agent"""
config = _make_expert_config(name="removable_expert")
agent = _make_mock_agent()
pool = _make_mock_pool()
expert = Expert(config=config, agent=agent)
await expert.destroy(pool)
pool.remove_agent.assert_awaited_once_with("removable_expert")
@pytest.mark.asyncio
async def test_is_active_after_destroy(self):
"""destroy 后 is_active 为 False"""
config = _make_expert_config()
agent = _make_mock_agent()
pool = _make_mock_pool()
expert = Expert(config=config, agent=agent)
assert expert.is_active is True
await expert.destroy(pool)
assert expert.is_active is False
# ── Expert.team_id 测试 ───────────────────────────────────
class TestExpertTeamId:
"""Expert.team_id 属性测试"""
def test_team_id_defaults_to_none(self):
"""team_id 默认为 None"""
config = _make_expert_config()
agent = _make_mock_agent()
expert = Expert(config=config, agent=agent)
assert expert.team_id is None
def test_team_id_setter(self):
"""team_id setter 正确设置值"""
config = _make_expert_config()
agent = _make_mock_agent()
expert = Expert(config=config, agent=agent)
expert.team_id = "team_alpha"
assert expert.team_id == "team_alpha"

View File

@ -0,0 +1,328 @@
"""CollaborationPlan 数据模型单元测试"""
from __future__ import annotations
import pytest
from agentkit.experts.plan import (
CollaborationPlan,
MergeStrategy,
ParallelType,
PhaseStatus,
PlanPhase,
PlanStatus,
)
# ── 辅助函数 ──────────────────────────────────────────────
def _make_phase(
id: str = "phase_1",
name: str = "分析阶段",
assigned_expert: str = "analyst",
task_description: str = "分析需求",
depends_on: list[str] | None = None,
parallel_type: ParallelType = ParallelType.SERIAL,
merge_strategy: MergeStrategy | None = None,
milestone: str = "",
status: PhaseStatus = PhaseStatus.PENDING,
result: dict | None = None,
) -> PlanPhase:
"""创建测试用 PlanPhase 实例"""
return PlanPhase(
id=id,
name=name,
assigned_expert=assigned_expert,
task_description=task_description,
depends_on=depends_on or [],
parallel_type=parallel_type,
merge_strategy=merge_strategy,
milestone=milestone,
status=status,
result=result,
)
def _make_valid_plan() -> CollaborationPlan:
"""创建一个有效的协作计划"""
phases = [
_make_phase(id="p1", name="需求分析", assigned_expert="analyst", task_description="分析需求"),
_make_phase(
id="p2",
name="架构设计",
assigned_expert="architect",
task_description="设计架构",
depends_on=["p1"],
),
_make_phase(
id="p3",
name="代码实现",
assigned_expert="coder",
task_description="编写代码",
depends_on=["p2"],
),
]
return CollaborationPlan(
id="plan_001",
task="实现用户登录功能",
phases=phases,
variables={"project": "fischer"},
status=PlanStatus.DRAFT,
lead_expert="architect",
)
# ── PlanPhase 测试 ────────────────────────────────────────
class TestPlanPhase:
"""PlanPhase 数据模型测试"""
def test_creation_with_all_fields(self):
"""创建 PlanPhase 并设置所有字段"""
phase = PlanPhase(
id="phase_a",
name="竞品分析",
assigned_expert="analyst",
task_description="分析竞品功能",
depends_on=["phase_0"],
parallel_type=ParallelType.COMPETITIVE_PARALLEL,
merge_strategy=MergeStrategy.BEST,
milestone="竞品报告完成",
status=PhaseStatus.IN_PROGRESS,
result={"report": "竞品分析报告"},
)
assert phase.id == "phase_a"
assert phase.name == "竞品分析"
assert phase.assigned_expert == "analyst"
assert phase.task_description == "分析竞品功能"
assert phase.depends_on == ["phase_0"]
assert phase.parallel_type == ParallelType.COMPETITIVE_PARALLEL
assert phase.merge_strategy == MergeStrategy.BEST
assert phase.milestone == "竞品报告完成"
assert phase.status == PhaseStatus.IN_PROGRESS
assert phase.result == {"report": "竞品分析报告"}
def test_to_dict_from_dict_roundtrip(self):
"""to_dict / from_dict 往返序列化"""
phase = PlanPhase(
id="roundtrip_phase",
name="往返测试",
assigned_expert="tester",
task_description="测试序列化",
depends_on=["dep_a", "dep_b"],
parallel_type=ParallelType.SUBTASK_PARALLEL,
merge_strategy=MergeStrategy.VOTE,
milestone="序列化验证",
status=PhaseStatus.COMPLETED,
result={"key": "value"},
)
d = phase.to_dict()
restored = PlanPhase.from_dict(d)
assert restored.id == phase.id
assert restored.name == phase.name
assert restored.assigned_expert == phase.assigned_expert
assert restored.task_description == phase.task_description
assert restored.depends_on == phase.depends_on
assert restored.parallel_type == phase.parallel_type
assert restored.merge_strategy == phase.merge_strategy
assert restored.milestone == phase.milestone
assert restored.status == phase.status
assert restored.result == phase.result
def test_to_dict_from_dict_with_none_merge_strategy(self):
"""merge_strategy 为 None 时的序列化往返"""
phase = PlanPhase(
id="no_merge",
name="无合并",
assigned_expert="dev",
task_description="串行任务",
parallel_type=ParallelType.SERIAL,
)
d = phase.to_dict()
assert d["merge_strategy"] is None
restored = PlanPhase.from_dict(d)
assert restored.merge_strategy is None
# ── CollaborationPlan 测试 ────────────────────────────────
class TestCollaborationPlan:
"""CollaborationPlan 数据模型测试"""
def test_creation(self):
"""创建 CollaborationPlan"""
plan = _make_valid_plan()
assert plan.id == "plan_001"
assert plan.task == "实现用户登录功能"
assert len(plan.phases) == 3
assert plan.variables == {"project": "fischer"}
assert plan.status == PlanStatus.DRAFT
assert plan.lead_expert == "architect"
def test_to_dict_from_dict_roundtrip(self):
"""to_dict / from_dict 往返序列化"""
plan = _make_valid_plan()
d = plan.to_dict()
restored = CollaborationPlan.from_dict(d)
assert restored.id == plan.id
assert restored.task == plan.task
assert len(restored.phases) == len(plan.phases)
assert restored.variables == plan.variables
assert restored.status == plan.status
assert restored.lead_expert == plan.lead_expert
for original, restored_phase in zip(plan.phases, restored.phases):
assert restored_phase.id == original.id
assert restored_phase.name == original.name
assert restored_phase.assigned_expert == original.assigned_expert
assert restored_phase.depends_on == original.depends_on
assert restored_phase.parallel_type == original.parallel_type
assert restored_phase.merge_strategy == original.merge_strategy
def test_validate_valid_plan(self):
"""验证有效计划无错误"""
plan = _make_valid_plan()
errors = plan.validate()
assert errors == []
def test_validate_detects_duplicate_phase_ids(self):
"""验证检测到重复阶段 ID"""
phases = [
_make_phase(id="p1", name="阶段1", assigned_expert="a", task_description="t1"),
_make_phase(id="p1", name="阶段2", assigned_expert="b", task_description="t2"),
]
plan = CollaborationPlan(
id="dup_plan", task="重复ID测试", phases=phases, lead_expert="a"
)
errors = plan.validate()
assert any("重复的阶段 ID" in e for e in errors)
def test_validate_detects_missing_depends_on_references(self):
"""验证检测到不存在的 depends_on 引用"""
phases = [
_make_phase(id="p1", name="阶段1", assigned_expert="a", task_description="t1"),
_make_phase(
id="p2",
name="阶段2",
assigned_expert="b",
task_description="t2",
depends_on=["p1", "nonexistent"],
),
]
plan = CollaborationPlan(
id="missing_dep_plan", task="缺失依赖测试", phases=phases, lead_expert="a"
)
errors = plan.validate()
assert any("不存在的阶段 ID" in e for e in errors)
def test_validate_detects_circular_dependencies(self):
"""验证检测到循环依赖"""
phases = [
_make_phase(id="p1", name="阶段1", assigned_expert="a", task_description="t1", depends_on=["p3"]),
_make_phase(id="p2", name="阶段2", assigned_expert="b", task_description="t2", depends_on=["p1"]),
_make_phase(id="p3", name="阶段3", assigned_expert="c", task_description="t3", depends_on=["p2"]),
]
plan = CollaborationPlan(
id="cycle_plan", task="循环依赖测试", phases=phases, lead_expert="a"
)
errors = plan.validate()
assert any("循环依赖" in e for e in errors)
def test_validate_detects_competitive_parallel_without_merge_strategy(self):
"""验证检测到 COMPETITIVE_PARALLEL 缺少 merge_strategy"""
phases = [
_make_phase(
id="p1",
name="竞争阶段",
assigned_expert="a",
task_description="竞争任务",
parallel_type=ParallelType.COMPETITIVE_PARALLEL,
merge_strategy=None,
),
]
plan = CollaborationPlan(
id="no_merge_plan", task="缺少合并策略测试", phases=phases, lead_expert="a"
)
errors = plan.validate()
assert any("COMPETITIVE_PARALLEL" in e and "merge_strategy" in e for e in errors)
def test_get_ready_phases_returns_phases_with_completed_dependencies(self):
"""get_ready_phases 返回依赖已完成的阶段"""
plan = _make_valid_plan()
# 初始状态p1 无依赖,应该就绪
ready = plan.get_ready_phases()
assert len(ready) == 1
assert ready[0].id == "p1"
# 完成 p1 后p2 应该就绪
plan.update_phase_status("p1", PhaseStatus.COMPLETED, {"analysis": "done"})
ready = plan.get_ready_phases()
assert len(ready) == 1
assert ready[0].id == "p2"
# 完成 p2 后p3 应该就绪
plan.update_phase_status("p2", PhaseStatus.COMPLETED, {"design": "done"})
ready = plan.get_ready_phases()
assert len(ready) == 1
assert ready[0].id == "p3"
def test_get_ready_phases_returns_empty_when_dependencies_not_met(self):
"""get_ready_phases 在依赖未满足时返回空列表"""
phases = [
_make_phase(id="p1", name="阶段1", assigned_expert="a", task_description="t1"),
_make_phase(
id="p2",
name="阶段2",
assigned_expert="b",
task_description="t2",
depends_on=["p1"],
),
]
plan = CollaborationPlan(
id="dep_plan", task="依赖未满足测试", phases=phases, lead_expert="a"
)
# p2 依赖 p1p1 未完成,所以 p2 不就绪
# 但 p1 无依赖,所以 p1 就绪
ready = plan.get_ready_phases()
assert len(ready) == 1
assert ready[0].id == "p1"
# 将 p1 设为 IN_PROGRESS未 COMPLETEDp2 仍不就绪
plan.update_phase_status("p1", PhaseStatus.IN_PROGRESS)
ready = plan.get_ready_phases()
assert len(ready) == 0
def test_update_phase_status(self):
"""update_phase_status 更新阶段状态和结果"""
plan = _make_valid_plan()
plan.update_phase_status("p1", PhaseStatus.COMPLETED, {"output": "分析完成"})
phase = plan.get_phase("p1")
assert phase is not None
assert phase.status == PhaseStatus.COMPLETED
assert phase.result == {"output": "分析完成"}
# 不传 result 时不应覆盖已有 result
plan.update_phase_status("p2", PhaseStatus.IN_PROGRESS)
phase2 = plan.get_phase("p2")
assert phase2 is not None
assert phase2.status == PhaseStatus.IN_PROGRESS
assert phase2.result is None
def test_get_phase_by_id(self):
"""get_phase 根据 ID 获取阶段"""
plan = _make_valid_plan()
phase = plan.get_phase("p2")
assert phase is not None
assert phase.id == "p2"
assert phase.name == "架构设计"
def test_get_phase_with_nonexistent_id_returns_none(self):
"""get_phase 对不存在的 ID 返回 None"""
plan = _make_valid_plan()
phase = plan.get_phase("nonexistent")
assert phase is None

View File

@ -0,0 +1,233 @@
"""ExpertTemplateRegistry 单元测试"""
from __future__ import annotations
import os
import tempfile
import pytest
import yaml
from agentkit.experts.config import ExpertConfig, ExpertTemplate
from agentkit.experts.registry import ExpertTemplateRegistry
# ── 辅助函数 ──────────────────────────────────────────────
def _make_template(
name: str = "test_template",
persona: str = "测试专家",
description: str = "测试模板描述",
is_builtin: bool = False,
bound_skills: list[str] | None = None,
) -> ExpertTemplate:
"""创建测试用 ExpertTemplate 实例"""
config = ExpertConfig(
name=name,
agent_type="expert",
persona=persona,
task_mode="llm_generate",
prompt={"identity": persona},
bound_skills=bound_skills or [],
)
return ExpertTemplate(
name=name,
config=config,
is_builtin=is_builtin,
description=description,
)
def _write_yaml_file(directory: str, filename: str, data: dict) -> str:
"""写入临时 YAML 文件并返回路径"""
filepath = os.path.join(directory, filename)
with open(filepath, "w", encoding="utf-8") as f:
yaml.dump(data, f, allow_unicode=True)
return filepath
# ── ExpertTemplateRegistry 测试 ───────────────────────────
class TestExpertTemplateRegistry:
"""ExpertTemplateRegistry 注册中心测试"""
def test_register_and_get(self):
"""注册并获取模板"""
registry = ExpertTemplateRegistry()
template = _make_template("analyst", persona="分析师")
registry.register(template)
result = registry.get("analyst")
assert result is not None
assert result.name == "analyst"
assert result.config.persona == "分析师"
def test_get_nonexistent_returns_none(self):
"""获取不存在的模板返回 None"""
registry = ExpertTemplateRegistry()
assert registry.get("nonexistent") is None
def test_list_all_templates(self):
"""列出所有模板"""
registry = ExpertTemplateRegistry()
registry.register(_make_template("a", description="模板A"))
registry.register(_make_template("b", description="模板B"))
registry.register(_make_template("c", description="模板C"))
templates = registry.list()
names = {t.name for t in templates}
assert names == {"a", "b", "c"}
def test_list_empty_registry(self):
"""空注册中心返回空列表"""
registry = ExpertTemplateRegistry()
assert registry.list() == []
def test_search_by_name_case_insensitive(self):
"""按名称搜索(大小写不敏感)"""
registry = ExpertTemplateRegistry()
registry.register(_make_template("DataAnalyst", description="数据分析师"))
registry.register(_make_template("CodeReviewer", description="代码审查员"))
results = registry.search("data")
assert len(results) == 1
assert results[0].name == "DataAnalyst"
results = registry.search("CODEREVIEWER")
assert len(results) == 1
assert results[0].name == "CodeReviewer"
def test_search_by_description(self):
"""按描述搜索"""
registry = ExpertTemplateRegistry()
registry.register(_make_template("analyst", description="数据分析专家"))
registry.register(_make_template("writer", description="内容创作专家"))
results = registry.search("数据")
assert len(results) == 1
assert results[0].name == "analyst"
results = registry.search("创作")
assert len(results) == 1
assert results[0].name == "writer"
def test_search_no_matches_returns_empty(self):
"""搜索无匹配返回空列表"""
registry = ExpertTemplateRegistry()
registry.register(_make_template("analyst", description="数据分析师"))
results = registry.search("nonexistent_keyword")
assert results == []
def test_register_overwrites_same_name(self):
"""同名模板注册覆盖旧模板"""
registry = ExpertTemplateRegistry()
v1 = _make_template("expert_a", persona="版本1", description="旧版本")
v2 = _make_template("expert_a", persona="版本2", description="新版本")
registry.register(v1)
registry.register(v2)
result = registry.get("expert_a")
assert result is not None
assert result.config.persona == "版本2"
assert result.description == "新版本"
# 确保只有一个
assert len(registry.list()) == 1
def test_load_from_yaml(self):
"""从 YAML 文件加载模板"""
yaml_data = {
"name": "yaml_expert",
"is_builtin": False,
"description": "YAML 加载的专家",
"config": {
"name": "yaml_expert",
"agent_type": "expert",
"persona": "YAML 专家",
"thinking_style": "结构化思维",
"collaboration_strategy": "cooperative",
"bound_skills": ["skill_a", "skill_b"],
"avatar": "🤖",
"color": "#fa8c16",
"is_lead": False,
"task_mode": "llm_generate",
"prompt": {"identity": "YAML 专家"},
},
}
with tempfile.TemporaryDirectory() as tmpdir:
filepath = _write_yaml_file(tmpdir, "expert.yaml", yaml_data)
registry = ExpertTemplateRegistry()
template = registry.load_from_yaml(filepath)
assert template.name == "yaml_expert"
assert template.config.persona == "YAML 专家"
assert template.config.thinking_style == "结构化思维"
assert template.config.bound_skills == ["skill_a", "skill_b"]
assert template.config.avatar == "🤖"
assert template.config.color == "#fa8c16"
# 同时注册到 registry
assert registry.get("yaml_expert") is template
def test_load_from_directory(self):
"""从目录批量加载模板"""
yaml_data_a = {
"name": "dir_expert_a",
"description": "目录专家A",
"config": {
"name": "dir_expert_a",
"agent_type": "expert",
"persona": "专家A",
"task_mode": "llm_generate",
"prompt": {"identity": "专家A"},
},
}
yaml_data_b = {
"name": "dir_expert_b",
"description": "目录专家B",
"config": {
"name": "dir_expert_b",
"agent_type": "expert",
"persona": "专家B",
"task_mode": "llm_generate",
"prompt": {"identity": "专家B"},
},
}
with tempfile.TemporaryDirectory() as tmpdir:
_write_yaml_file(tmpdir, "expert_a.yaml", yaml_data_a)
_write_yaml_file(tmpdir, "expert_b.yml", yaml_data_b)
# 非 YAML 文件应被忽略
with open(os.path.join(tmpdir, "readme.txt"), "w") as f:
f.write("not a yaml")
registry = ExpertTemplateRegistry()
loaded = registry.load_from_directory(tmpdir)
assert len(loaded) == 2
names = {t.name for t in loaded}
assert "dir_expert_a" in names
assert "dir_expert_b" in names
# 同时注册到 registry
assert registry.get("dir_expert_a") is not None
assert registry.get("dir_expert_b") is not None
def test_load_from_directory_nonexistent(self):
"""从不存在的目录加载返回空列表"""
registry = ExpertTemplateRegistry()
loaded = registry.load_from_directory("/nonexistent/path")
assert loaded == []
def test_load_from_yaml_invalid_format(self):
"""加载非字典格式的 YAML 抛出异常"""
with tempfile.TemporaryDirectory() as tmpdir:
filepath = os.path.join(tmpdir, "invalid.yaml")
with open(filepath, "w", encoding="utf-8") as f:
yaml.dump(["not", "a", "dict"], f)
registry = ExpertTemplateRegistry()
with pytest.raises(Exception):
registry.load_from_yaml(filepath)

View File

@ -0,0 +1,299 @@
"""ExpertTeamRouter 单元测试"""
from __future__ import annotations
import pytest
from agentkit.experts.config import ExpertConfig, ExpertTemplate
from agentkit.experts.registry import ExpertTemplateRegistry
from agentkit.experts.router import (
ExpertTeamRouter,
ExpertTeamRoutingResult,
TEAM_PREFIX_PATTERN,
)
# ── 辅助函数 ──────────────────────────────────────────────
def _make_template(
name: str = "test_template",
persona: str = "测试专家",
bound_skills: list[str] | None = None,
) -> ExpertTemplate:
"""创建测试用 ExpertTemplate 实例"""
config = ExpertConfig(
name=name,
agent_type="expert",
persona=persona,
thinking_style="analytical",
bound_skills=bound_skills or [],
task_mode="llm_generate",
prompt={"identity": persona},
)
return ExpertTemplate(
name=name,
config=config,
is_builtin=True,
description=f"{name} 模板",
)
def _make_registry_with_templates() -> ExpertTemplateRegistry:
"""创建包含预注册模板的注册中心"""
registry = ExpertTemplateRegistry()
registry.register(_make_template("analyst", persona="数据分析师", bound_skills=["data_query"]))
registry.register(_make_template("strategist", persona="策略专家", bound_skills=["planning"]))
registry.register(_make_template("reviewer", persona="代码审查员", bound_skills=["code_review"]))
return registry
# ── TEAM_PREFIX_PATTERN 正则测试 ──────────────────────────
class TestTeamPrefixPattern:
"""TEAM_PREFIX_PATTERN 正则匹配测试"""
def test_match_team_only(self):
"""@team 前缀匹配"""
match = TEAM_PREFIX_PATTERN.match("@team 分析这个数据")
assert match is not None
assert match.group(1) is None
assert match.group(2) == "分析这个数据"
def test_match_team_with_experts(self):
"""@team:expert1,expert2 前缀匹配"""
match = TEAM_PREFIX_PATTERN.match("@team:analyst,strategist 分析这个数据")
assert match is not None
assert match.group(1) == "analyst,strategist"
assert match.group(2) == "分析这个数据"
def test_match_team_single_expert(self):
"""@team:expert 前缀匹配"""
match = TEAM_PREFIX_PATTERN.match("@team:analyst 分析数据")
assert match is not None
assert match.group(1) == "analyst"
assert match.group(2) == "分析数据"
def test_match_team_no_task(self):
"""@team 无后续任务内容"""
match = TEAM_PREFIX_PATTERN.match("@team")
assert match is not None
assert match.group(1) is None
assert match.group(2) == ""
def test_no_match_without_prefix(self):
"""无 @team 前缀不匹配"""
match = TEAM_PREFIX_PATTERN.match("分析这个数据")
assert match is None
def test_no_match_team_in_middle(self):
"""@team 在中间不匹配(必须开头)"""
match = TEAM_PREFIX_PATTERN.match("请 @team 分析数据")
assert match is None
def test_match_team_with_leading_whitespace(self):
"""@team 前有空白字符时正则不匹配(由 resolve() 中 strip() 处理)"""
match = TEAM_PREFIX_PATTERN.match(" @team:analyst 任务内容")
# 正则使用 ^ 锚定,前导空白不匹配
assert match is None
# ── ExpertTeamRoutingResult 默认值测试 ────────────────────
class TestExpertTeamRoutingResult:
"""ExpertTeamRoutingResult 数据类测试"""
def test_default_values(self):
"""默认值验证"""
result = ExpertTeamRoutingResult()
assert result.matched is False
assert result.team_mode is False
assert result.specified_experts == []
assert result.task_content == ""
assert result.auto_compose is False
assert result.complexity == 0.0
assert result.match_method == ""
# ── ExpertTeamRouter.resolve 测试 ──────────────────────────
class TestExpertTeamRouterResolve:
"""ExpertTeamRouter.resolve 方法测试"""
def test_team_prefix_triggers_team_mode(self):
"""@team 前缀触发团队模式"""
router = ExpertTeamRouter()
result = router.resolve("@team 分析这个数据")
assert result.matched is True
assert result.team_mode is True
assert result.match_method == "explicit_team"
assert result.task_content == "分析这个数据"
def test_team_with_experts_specifies_members(self):
"""@team:analyst,strategist 指定专家成员"""
router = ExpertTeamRouter()
result = router.resolve("@team:analyst,strategist 分析数据")
assert result.matched is True
assert result.team_mode is True
assert result.specified_experts == ["analyst", "strategist"]
assert result.auto_compose is False
assert result.match_method == "explicit_team"
def test_team_no_experts_auto_compose(self):
"""@team 无指定专家时 auto_compose=True"""
router = ExpertTeamRouter()
result = router.resolve("@team 分析数据")
assert result.matched is True
assert result.team_mode is True
assert result.specified_experts == []
assert result.auto_compose is True
def test_team_with_expert_extracts_task(self):
"""@team:analyst 正确提取任务内容"""
router = ExpertTeamRouter()
result = router.resolve("@team:analyst 请分析这份报告")
assert result.matched is True
assert result.specified_experts == ["analyst"]
assert result.task_content == "请分析这份报告"
def test_high_complexity_triggers_team_suggestion(self):
"""高复杂度 (>=0.7) 触发团队模式建议"""
router = ExpertTeamRouter()
result = router.resolve("分析这个复杂系统", complexity=0.8)
assert result.matched is True
assert result.team_mode is True
assert result.auto_compose is True
assert result.match_method == "complexity_suggestion"
assert result.complexity == 0.8
def test_high_complexity_exact_threshold(self):
"""复杂度恰好等于阈值 0.7 也触发团队模式"""
router = ExpertTeamRouter()
result = router.resolve("任务内容", complexity=0.7)
assert result.matched is True
assert result.team_mode is True
assert result.match_method == "complexity_suggestion"
def test_low_complexity_no_team_mode(self):
"""低复杂度 (<0.7) 不触发团队模式"""
router = ExpertTeamRouter()
result = router.resolve("简单问题", complexity=0.3)
assert result.matched is False
assert result.team_mode is False
assert result.task_content == "简单问题"
assert result.complexity == 0.3
def test_no_team_prefix_no_complexity(self):
"""无 @team 前缀且无复杂度时不触发团队模式"""
router = ExpertTeamRouter()
result = router.resolve("普通问题")
assert result.matched is False
assert result.team_mode is False
def test_team_prefix_takes_priority_over_complexity(self):
"""@team 前缀优先于复杂度判断"""
router = ExpertTeamRouter()
result = router.resolve("@team:analyst 任务", complexity=0.1)
assert result.matched is True
assert result.match_method == "explicit_team"
assert result.specified_experts == ["analyst"]
def test_nonexistent_expert_still_included(self):
"""指定不存在的专家名仍包含在列表中"""
router = ExpertTeamRouter()
result = router.resolve("@team:analyst,nonexistent 任务")
assert result.specified_experts == ["analyst", "nonexistent"]
def test_team_with_empty_task_uses_full_content(self):
"""@team 无任务内容时 task_content 使用原始内容"""
router = ExpertTeamRouter()
result = router.resolve("@team")
assert result.task_content == "@team"
assert result.auto_compose is True
# ── ExpertTeamRouter.resolve_expert_configs 测试 ───────────
class TestExpertTeamRouterResolveExpertConfigs:
"""ExpertTeamRouter.resolve_expert_configs 方法测试"""
def test_resolve_existing_templates(self):
"""解析已注册模板返回对应 ExpertConfig"""
registry = _make_registry_with_templates()
router = ExpertTeamRouter(template_registry=registry)
configs = router.resolve_expert_configs(["analyst", "strategist"])
assert len(configs) == 2
assert configs[0].name == "analyst"
assert configs[0].persona == "数据分析师"
assert configs[1].name == "strategist"
assert configs[1].persona == "策略专家"
def test_resolve_nonexistent_creates_dynamic_config(self):
"""解析不存在的名称创建动态 ExpertConfig"""
registry = _make_registry_with_templates()
router = ExpertTeamRouter(template_registry=registry)
configs = router.resolve_expert_configs(["analyst", "unknown_expert"])
assert len(configs) == 2
assert configs[0].name == "analyst"
assert configs[0].persona == "数据分析师"
# 动态生成的配置
assert configs[1].name == "unknown_expert"
assert configs[1].persona == "Expert in unknown_expert"
assert configs[1].agent_type == "expert"
assert configs[1].thinking_style == "analytical"
assert configs[1].bound_skills == []
assert configs[1].is_lead is False
assert configs[1].task_mode == "llm_generate"
def test_resolve_all_nonexistent(self):
"""所有名称都不存在时全部动态生成"""
router = ExpertTeamRouter()
configs = router.resolve_expert_configs(["role_a", "role_b"])
assert len(configs) == 2
assert configs[0].name == "role_a"
assert configs[0].persona == "Expert in role_a"
assert configs[1].name == "role_b"
assert configs[1].persona == "Expert in role_b"
def test_resolve_empty_list(self):
"""空列表返回空结果"""
router = ExpertTeamRouter()
configs = router.resolve_expert_configs([])
assert configs == []
def test_resolve_preserves_template_skills(self):
"""解析已注册模板保留 bound_skills"""
registry = _make_registry_with_templates()
router = ExpertTeamRouter(template_registry=registry)
configs = router.resolve_expert_configs(["analyst"])
assert configs[0].bound_skills == ["data_query"]
# ── ExpertTeamRouter 构造测试 ─────────────────────────────
class TestExpertTeamRouterInit:
"""ExpertTeamRouter 构造函数测试"""
def test_default_registry(self):
"""无参构造创建默认注册中心"""
router = ExpertTeamRouter()
assert router._registry is not None
def test_custom_registry(self):
"""传入自定义注册中心"""
registry = ExpertTemplateRegistry()
router = ExpertTeamRouter(template_registry=registry)
assert router._registry is registry
def test_complexity_threshold(self):
"""复杂度阈值默认为 0.7"""
router = ExpertTeamRouter()
assert router.COMPLEXITY_THRESHOLD == 0.7

View File

@ -0,0 +1,768 @@
"""ExpertTeam 容器单元测试"""
from __future__ import annotations
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from agentkit.core.config_driven import ConfigDrivenAgent
from agentkit.core.handoff_transport import InProcessHandoffTransport
from agentkit.core.shared_workspace import SharedWorkspace
from agentkit.experts.config import ExpertConfig, ExpertTemplate
from agentkit.experts.expert import Expert
from agentkit.experts.plan import (
CollaborationPlan,
PlanPhase,
PlanStatus,
)
from agentkit.experts.registry import ExpertTemplateRegistry
from agentkit.experts.team import ExpertTeam, TeamStatus
# ── 辅助函数 ──────────────────────────────────────────────
def _make_expert_config(
name: str = "test_expert",
agent_type: str = "expert",
persona: str = "测试专家",
thinking_style: str = "逻辑推理",
bound_skills: list[str] | None = None,
is_lead: bool = False,
**kwargs,
) -> ExpertConfig:
"""创建测试用 ExpertConfig 实例"""
return ExpertConfig(
name=name,
agent_type=agent_type,
persona=persona,
thinking_style=thinking_style,
bound_skills=bound_skills or ["skill_a"],
is_lead=is_lead,
task_mode="llm_generate",
prompt={"identity": "测试"},
**kwargs,
)
def _make_mock_agent() -> MagicMock:
"""创建 mock ConfigDrivenAgent"""
agent = MagicMock(spec=ConfigDrivenAgent)
agent.name = "test_expert"
agent._prompt_template = None
return agent
def _make_mock_pool() -> AsyncMock:
"""创建 mock AgentPool"""
pool = AsyncMock()
pool.create_agent = AsyncMock(return_value=_make_mock_agent())
pool.remove_agent = AsyncMock()
return pool
def _make_mock_expert(
name: str = "test_expert",
is_lead: bool = False,
is_active: bool = True,
) -> MagicMock:
"""创建 mock Expert"""
config = _make_expert_config(name=name, is_lead=is_lead)
expert = MagicMock(spec=Expert)
expert.config = config
expert.is_active = is_active
expert.team_id = None
expert.get_capabilities_summary.return_value = {
"name": name,
"persona": config.persona,
"thinking_style": config.thinking_style,
"bound_skills": config.bound_skills,
"is_lead": is_lead,
}
expert.destroy = AsyncMock()
return expert
def _make_valid_plan(
plan_id: str = "plan_1",
task: str = "测试任务",
lead_expert: str = "lead",
) -> CollaborationPlan:
"""创建有效的 CollaborationPlan"""
return CollaborationPlan(
id=plan_id,
task=task,
phases=[
PlanPhase(
id="phase_1",
name="阶段1",
assigned_expert=lead_expert,
task_description="执行任务",
)
],
lead_expert=lead_expert,
)
# ── ExpertTeam 创建测试 ───────────────────────────────────
class TestExpertTeamCreation:
"""ExpertTeam 初始化与默认值测试"""
def test_default_values(self):
"""默认值:自动生成 team_idFORMING 状态"""
team = ExpertTeam()
assert team.team_id is not None
assert len(team.team_id) > 0
assert team.status == TeamStatus.FORMING
assert team.lead_expert is None
assert team.plan is None
assert team.experts == []
assert team.active_experts == []
def test_custom_team_id(self):
"""自定义 team_id"""
team = ExpertTeam(team_id="my_team")
assert team.team_id == "my_team"
def test_custom_workspace(self):
"""自定义 SharedWorkspace"""
workspace = SharedWorkspace()
team = ExpertTeam(workspace=workspace)
assert team._workspace is workspace
def test_custom_pool(self):
"""自定义 AgentPool"""
pool = _make_mock_pool()
team = ExpertTeam(pool=pool)
assert team._pool is pool
def test_custom_template_registry(self):
"""自定义 ExpertTemplateRegistry"""
registry = ExpertTemplateRegistry()
team = ExpertTeam(template_registry=registry)
assert team._template_registry is registry
# ── ExpertTeam.create_team 测试 ────────────────────────────
class TestExpertTeamCreateTeam:
"""ExpertTeam.create_team 团队创建测试"""
@pytest.mark.asyncio
async def test_create_team_with_lead_only(self):
"""仅创建 Lead Expert"""
pool = _make_mock_pool()
team = ExpertTeam(pool=pool)
lead_config = _make_expert_config(name="lead", is_lead=True)
with patch.object(Expert, "create", new_callable=AsyncMock) as mock_create:
mock_expert = _make_mock_expert(name="lead", is_lead=True)
mock_create.return_value = mock_expert
await team.create_team(lead_config)
assert team._lead_expert_name == "lead"
assert team.lead_expert is mock_expert
assert team.status == TeamStatus.PLANNING
assert mock_expert.team_id == team.team_id
@pytest.mark.asyncio
async def test_create_team_with_lead_and_members(self):
"""创建 Lead Expert 和成员 Expert"""
pool = _make_mock_pool()
team = ExpertTeam(pool=pool)
lead_config = _make_expert_config(name="lead", is_lead=True)
member_config = _make_expert_config(name="member1", is_lead=False)
with patch.object(Expert, "create", new_callable=AsyncMock) as mock_create:
lead_expert = _make_mock_expert(name="lead", is_lead=True)
member_expert = _make_mock_expert(name="member1", is_lead=False)
mock_create.side_effect = [lead_expert, member_expert]
await team.create_team(lead_config, [member_config])
assert len(team.experts) == 2
assert team._lead_expert_name == "lead"
assert team.status == TeamStatus.PLANNING
@pytest.mark.asyncio
async def test_create_team_without_pool_raises(self):
"""没有 AgentPool 时 create_team 抛出 RuntimeError"""
team = ExpertTeam(pool=None)
lead_config = _make_expert_config(name="lead", is_lead=True)
with pytest.raises(RuntimeError, match="AgentPool not configured"):
await team.create_team(lead_config)
# ── ExpertTeam.add_expert 测试 ─────────────────────────────
class TestExpertTeamAddExpert:
"""ExpertTeam.add_expert 动态添加 Expert 测试"""
@pytest.mark.asyncio
async def test_add_expert_with_config(self):
"""通过 ExpertConfig 添加 Expert"""
pool = _make_mock_pool()
team = ExpertTeam(pool=pool)
# 先创建团队
lead_config = _make_expert_config(name="lead", is_lead=True)
with patch.object(Expert, "create", new_callable=AsyncMock) as mock_create:
lead_expert = _make_mock_expert(name="lead", is_lead=True)
mock_create.return_value = lead_expert
await team.create_team(lead_config)
# 添加新成员
new_config = _make_expert_config(name="new_member")
with patch.object(Expert, "create", new_callable=AsyncMock) as mock_create:
new_expert = _make_mock_expert(name="new_member")
mock_create.return_value = new_expert
result = await team.add_expert(new_config)
assert result is new_expert
assert "new_member" in team._experts
@pytest.mark.asyncio
async def test_add_expert_with_template_name(self):
"""通过模板名称添加 Expert"""
pool = _make_mock_pool()
registry = ExpertTemplateRegistry()
template_config = _make_expert_config(name="analyst", persona="分析师")
registry.register(ExpertTemplate(name="analyst", config=template_config))
team = ExpertTeam(pool=pool, template_registry=registry)
# 先创建团队
lead_config = _make_expert_config(name="lead", is_lead=True)
with patch.object(Expert, "create", new_callable=AsyncMock) as mock_create:
lead_expert = _make_mock_expert(name="lead", is_lead=True)
mock_create.return_value = lead_expert
await team.create_team(lead_config)
# 通过模板名称添加
with patch.object(Expert, "create", new_callable=AsyncMock) as mock_create:
analyst_expert = _make_mock_expert(name="analyst")
mock_create.return_value = analyst_expert
result = await team.add_expert("analyst")
assert result is analyst_expert
@pytest.mark.asyncio
async def test_add_expert_with_nonexistent_template_raises(self):
"""使用不存在的模板名称添加 Expert 抛出 ValueError"""
pool = _make_mock_pool()
team = ExpertTeam(pool=pool)
# 先创建团队
lead_config = _make_expert_config(name="lead", is_lead=True)
with patch.object(Expert, "create", new_callable=AsyncMock) as mock_create:
lead_expert = _make_mock_expert(name="lead", is_lead=True)
mock_create.return_value = lead_expert
await team.create_team(lead_config)
with pytest.raises(ValueError, match="ExpertTemplate 'nonexistent' not found"):
await team.add_expert("nonexistent")
@pytest.mark.asyncio
async def test_add_expert_broadcasts_joined_message(self):
"""添加 Expert 时广播 expert_joined 消息"""
pool = _make_mock_pool()
transport = AsyncMock(spec=InProcessHandoffTransport)
team = ExpertTeam(pool=pool)
team._handoff_transport = transport
# 先创建团队
lead_config = _make_expert_config(name="lead", is_lead=True)
with patch.object(Expert, "create", new_callable=AsyncMock) as mock_create:
lead_expert = _make_mock_expert(name="lead", is_lead=True)
mock_create.return_value = lead_expert
await team.create_team(lead_config)
# 添加新成员
new_config = _make_expert_config(name="new_member")
with patch.object(Expert, "create", new_callable=AsyncMock) as mock_create:
new_expert = _make_mock_expert(name="new_member")
mock_create.return_value = new_expert
await team.add_expert(new_config)
# 验证 broadcast 消息create_team 也会调用 send所以检查最后一次
calls = transport.send.call_args_list
joined_calls = [
c for c in calls if c[0][1].get("type") == "expert_joined"
]
assert len(joined_calls) >= 1
last_joined = joined_calls[-1]
assert last_joined[0][1]["expert_name"] == "new_member"
# ── ExpertTeam.remove_expert 测试 ──────────────────────────
class TestExpertTeamRemoveExpert:
"""ExpertTeam.remove_expert 移除 Expert 测试"""
@pytest.mark.asyncio
async def test_remove_expert(self):
"""移除普通 Expert"""
pool = _make_mock_pool()
team = ExpertTeam(pool=pool)
lead_config = _make_expert_config(name="lead", is_lead=True)
member_config = _make_expert_config(name="member1")
with patch.object(Expert, "create", new_callable=AsyncMock) as mock_create:
lead_expert = _make_mock_expert(name="lead", is_lead=True)
member_expert = _make_mock_expert(name="member1")
mock_create.side_effect = [lead_expert, member_expert]
await team.create_team(lead_config, [member_config])
await team.remove_expert("member1")
assert "member1" not in team._experts
member_expert.destroy.assert_awaited_once_with(pool)
@pytest.mark.asyncio
async def test_remove_lead_expert_reassigns(self):
"""移除 Lead Expert 时重新分配给下一个活跃 Expert"""
pool = _make_mock_pool()
team = ExpertTeam(pool=pool)
lead_config = _make_expert_config(name="lead", is_lead=True)
member_config = _make_expert_config(name="member1")
with patch.object(Expert, "create", new_callable=AsyncMock) as mock_create:
lead_expert = _make_mock_expert(name="lead", is_lead=True)
member_expert = _make_mock_expert(name="member1")
mock_create.side_effect = [lead_expert, member_expert]
await team.create_team(lead_config, [member_config])
await team.remove_expert("lead")
assert team._lead_expert_name == "member1"
assert member_expert.config.is_lead is True
@pytest.mark.asyncio
async def test_remove_lead_expert_no_active_members(self):
"""移除 Lead Expert 且无其他活跃成员时 lead_expert_name 为 None"""
pool = _make_mock_pool()
team = ExpertTeam(pool=pool)
lead_config = _make_expert_config(name="lead", is_lead=True)
with patch.object(Expert, "create", new_callable=AsyncMock) as mock_create:
lead_expert = _make_mock_expert(name="lead", is_lead=True)
mock_create.return_value = lead_expert
await team.create_team(lead_config)
await team.remove_expert("lead")
assert team._lead_expert_name is None
@pytest.mark.asyncio
async def test_remove_nonexistent_expert_no_error(self):
"""移除不存在的 Expert 不报错"""
pool = _make_mock_pool()
team = ExpertTeam(pool=pool)
# 不应抛出异常
await team.remove_expert("nonexistent")
@pytest.mark.asyncio
async def test_remove_expert_broadcasts_left_message(self):
"""移除 Expert 时广播 expert_left 消息"""
pool = _make_mock_pool()
transport = AsyncMock(spec=InProcessHandoffTransport)
team = ExpertTeam(pool=pool)
team._handoff_transport = transport
lead_config = _make_expert_config(name="lead", is_lead=True)
member_config = _make_expert_config(name="member1")
with patch.object(Expert, "create", new_callable=AsyncMock) as mock_create:
lead_expert = _make_mock_expert(name="lead", is_lead=True)
member_expert = _make_mock_expert(name="member1")
mock_create.side_effect = [lead_expert, member_expert]
await team.create_team(lead_config, [member_config])
await team.remove_expert("member1")
# 验证 expert_left 消息
calls = transport.send.call_args_list
left_calls = [c for c in calls if c[0][1].get("type") == "expert_left"]
assert len(left_calls) >= 1
last_left = left_calls[-1]
assert last_left[0][1]["expert_name"] == "member1"
# ── ExpertTeam.update_plan 测试 ────────────────────────────
class TestExpertTeamUpdatePlan:
"""ExpertTeam.update_plan 协作计划更新测试"""
def test_update_plan_with_valid_plan(self):
"""有效计划更新成功,返回受影响的 Expert 名称"""
team = ExpertTeam()
plan = _make_valid_plan(lead_expert="lead")
affected = team.update_plan(plan)
assert team.plan is plan
assert "lead" in affected
def test_update_plan_confirmed_sets_executing(self):
"""CONFIRMED 状态的计划将团队状态设为 EXECUTING"""
team = ExpertTeam()
plan = _make_valid_plan(lead_expert="lead")
plan.status = PlanStatus.CONFIRMED
team.update_plan(plan)
assert team.status == TeamStatus.EXECUTING
def test_update_plan_with_invalid_plan_no_update(self):
"""无效计划validate 返回错误)不更新,返回验证错误列表"""
team = ExpertTeam()
# 创建有循环依赖的无效计划
plan = CollaborationPlan(
id="bad_plan",
task="无效任务",
phases=[
PlanPhase(
id="p1",
name="阶段1",
assigned_expert="a",
task_description="t1",
depends_on=["p2"],
),
PlanPhase(
id="p2",
name="阶段2",
assigned_expert="b",
task_description="t2",
depends_on=["p1"],
),
],
lead_expert="lead",
)
result = team.update_plan(plan)
assert len(result) > 0 # 返回验证错误而非空列表
assert team.plan is None # 未更新
# ── ExpertTeam.broadcast_user_message 测试 ─────────────────
class TestExpertTeamBroadcastUserMessage:
"""ExpertTeam.broadcast_user_message 用户干预消息广播测试"""
@pytest.mark.asyncio
async def test_broadcast_user_message(self):
"""广播用户干预消息到团队频道"""
team = ExpertTeam()
transport = AsyncMock(spec=InProcessHandoffTransport)
team._handoff_transport = transport
await team.broadcast_user_message("请暂停执行")
transport.send.assert_awaited_once()
call_args = transport.send.call_args
assert call_args[0][0] == team._team_channel
message = call_args[0][1]
assert message["type"] == "user_intervention"
assert message["content"] == "请暂停执行"
assert "timestamp" in message
# ── ExpertTeam.get_shared_context 测试 ────────────────────
class TestExpertTeamGetSharedContext:
"""ExpertTeam.get_shared_context 共享上下文读取测试"""
@pytest.mark.asyncio
async def test_get_shared_context_reads_team_keys(self):
"""读取团队范围的共享上下文"""
workspace = AsyncMock(spec=SharedWorkspace)
workspace.list_keys = AsyncMock(
return_value=[
"team:abc:output1",
"team:abc:output2",
"other:key",
]
)
workspace.read = AsyncMock(
side_effect=lambda key: {"value": f"data_{key}"} if key.startswith("team:abc") else None
)
team = ExpertTeam(team_id="abc", workspace=workspace)
context = await team.get_shared_context()
assert "team:abc:output1" in context
assert "team:abc:output2" in context
assert "other:key" not in context
@pytest.mark.asyncio
async def test_get_shared_context_empty(self):
"""没有团队范围的键时返回空字典"""
workspace = AsyncMock(spec=SharedWorkspace)
workspace.list_keys = AsyncMock(return_value=[])
team = ExpertTeam(team_id="abc", workspace=workspace)
context = await team.get_shared_context()
assert context == {}
# ── ExpertTeam.generate_plan 测试 ──────────────────────────
class TestExpertTeamGeneratePlan:
"""ExpertTeam.generate_plan 计划生成测试"""
@pytest.mark.asyncio
async def test_generate_plan(self):
"""生成空的 CollaborationPlan"""
pool = _make_mock_pool()
team = ExpertTeam(pool=pool)
lead_config = _make_expert_config(name="lead", is_lead=True)
with patch.object(Expert, "create", new_callable=AsyncMock) as mock_create:
lead_expert = _make_mock_expert(name="lead", is_lead=True)
mock_create.return_value = lead_expert
await team.create_team(lead_config)
plan = await team.generate_plan("分析数据")
assert plan is not None
assert plan.task == "分析数据"
assert plan.lead_expert == "lead"
assert plan.phases == []
assert team.plan is plan
@pytest.mark.asyncio
async def test_generate_plan_without_lead(self):
"""没有 Lead Expert 时生成计划lead_expert 为空字符串"""
team = ExpertTeam()
plan = await team.generate_plan("测试任务")
assert plan.lead_expert == ""
# ── ExpertTeam.dissolve 测试 ───────────────────────────────
class TestExpertTeamDissolve:
"""ExpertTeam.dissolve 团队解散测试"""
@pytest.mark.asyncio
async def test_dissolve_recycles_experts(self):
"""解散团队时回收所有 Expert"""
pool = _make_mock_pool()
team = ExpertTeam(pool=pool)
lead_config = _make_expert_config(name="lead", is_lead=True)
member_config = _make_expert_config(name="member1")
with patch.object(Expert, "create", new_callable=AsyncMock) as mock_create:
lead_expert = _make_mock_expert(name="lead", is_lead=True)
member_expert = _make_mock_expert(name="member1")
mock_create.side_effect = [lead_expert, member_expert]
await team.create_team(lead_config, [member_config])
await team.dissolve()
assert team.status == TeamStatus.DISSOLVED
assert team.experts == []
assert team._lead_expert_name is None
lead_expert.destroy.assert_awaited_once_with(pool)
member_expert.destroy.assert_awaited_once_with(pool)
@pytest.mark.asyncio
async def test_dissolve_preserves_outputs_in_workspace(self):
"""解散团队后 SharedWorkspace 中的输出仍保留"""
workspace = SharedWorkspace()
pool = _make_mock_pool()
team = ExpertTeam(pool=pool, workspace=workspace)
# 写入一些数据到 workspace
await workspace.write("team:abc:result", "重要输出", "lead")
with patch.object(Expert, "create", new_callable=AsyncMock) as mock_create:
lead_expert = _make_mock_expert(name="lead", is_lead=True)
mock_create.return_value = lead_expert
lead_config = _make_expert_config(name="lead", is_lead=True)
await team.create_team(lead_config)
await team.dissolve()
# workspace 数据仍然存在
data = await workspace.read("team:abc:result")
assert data is not None
assert data["value"] == "重要输出"
@pytest.mark.asyncio
async def test_dissolve_closes_handoff_transport(self):
"""解散团队时关闭 handoff_transport"""
pool = _make_mock_pool()
transport = MagicMock(spec=InProcessHandoffTransport)
transport.close = MagicMock()
team = ExpertTeam(pool=pool)
team._handoff_transport = transport
with patch.object(Expert, "create", new_callable=AsyncMock) as mock_create:
lead_expert = _make_mock_expert(name="lead", is_lead=True)
mock_create.return_value = lead_expert
lead_config = _make_expert_config(name="lead", is_lead=True)
await team.create_team(lead_config)
await team.dissolve()
transport.close.assert_called_once()
# ── ExpertTeam 操作已解散团队测试 ──────────────────────────
class TestExpertTeamDissolvedOperations:
"""解散后的团队操作应报错"""
@pytest.mark.asyncio
async def test_create_team_on_dissolved_raises(self):
"""在已解散的团队上 create_team 应报错(因为 pool 可能已被清理)"""
pool = _make_mock_pool()
team = ExpertTeam(pool=pool)
with patch.object(Expert, "create", new_callable=AsyncMock) as mock_create:
lead_expert = _make_mock_expert(name="lead", is_lead=True)
mock_create.return_value = lead_expert
lead_config = _make_expert_config(name="lead", is_lead=True)
await team.create_team(lead_config)
await team.dissolve()
# 解散后状态为 DISSOLVED
assert team.status == TeamStatus.DISSOLVED
# 再次 create_team 时,由于 experts 已清空,
# 但 pool 仍然存在,理论上可以重新创建
# 但这里验证状态是 DISSOLVED
assert team.status == TeamStatus.DISSOLVED
# ── ExpertTeam.lead_expert 属性测试 ────────────────────────
class TestExpertTeamLeadExpert:
"""ExpertTeam.lead_expert 属性测试"""
@pytest.mark.asyncio
async def test_lead_expert_returns_lead(self):
"""lead_expert 返回 Lead Expert"""
pool = _make_mock_pool()
team = ExpertTeam(pool=pool)
lead_config = _make_expert_config(name="lead", is_lead=True)
with patch.object(Expert, "create", new_callable=AsyncMock) as mock_create:
lead_expert = _make_mock_expert(name="lead", is_lead=True)
mock_create.return_value = lead_expert
await team.create_team(lead_config)
assert team.lead_expert is lead_expert
def test_lead_expert_none_when_no_lead(self):
"""没有 Lead Expert 时返回 None"""
team = ExpertTeam()
assert team.lead_expert is None
@pytest.mark.asyncio
async def test_active_experts_filters_inactive(self):
"""active_experts 只返回活跃的 Expert"""
pool = _make_mock_pool()
team = ExpertTeam(pool=pool)
lead_config = _make_expert_config(name="lead", is_lead=True)
member_config = _make_expert_config(name="member1")
with patch.object(Expert, "create", new_callable=AsyncMock) as mock_create:
lead_expert = _make_mock_expert(name="lead", is_lead=True)
member_expert = _make_mock_expert(name="member1")
mock_create.side_effect = [lead_expert, member_expert]
await team.create_team(lead_config, [member_config])
# 标记 member1 为非活跃
member_expert.is_active = False
active = team.active_experts
assert len(active) == 1
assert active[0] is lead_expert
# ── ExpertTeam._build_team_context 测试 ────────────────────
class TestExpertTeamBuildContext:
"""ExpertTeam._build_team_context 团队上下文构建测试"""
def test_build_team_context_with_lead_and_members(self):
"""构建包含 Lead 和成员的团队上下文"""
team = ExpertTeam()
lead_config = _make_expert_config(
name="lead", persona="领导者", is_lead=True
)
member_config = _make_expert_config(
name="analyst", persona="分析师", bound_skills=["data_query"]
)
context = team._build_team_context(lead_config, [member_config])
assert "You are part of an Expert Team." in context
assert "Lead Expert: lead (领导者)" in context
assert "Team Member: analyst (分析师), Skills: data_query" in context
assert "send_message() and request_assist()" in context
def test_build_team_context_no_lead(self):
"""没有 Lead Expert 时构建上下文"""
team = ExpertTeam()
member_config = _make_expert_config(name="analyst")
context = team._build_team_context(None, [member_config])
assert "Lead Expert" not in context
assert "Team Member: analyst" in context
def test_build_team_context_skips_lead_in_members(self):
"""成员列表中包含 Lead 时跳过"""
team = ExpertTeam()
lead_config = _make_expert_config(name="lead", is_lead=True)
context = team._build_team_context(lead_config, [lead_config])
# Lead 不应出现在 Team Member 行
assert "Team Member: lead" not in context

View File

@ -0,0 +1,668 @@
"""TeamOrchestrator 单元测试"""
from __future__ import annotations
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from agentkit.core.handoff_transport import InProcessHandoffTransport
from agentkit.experts.config import ExpertConfig
from agentkit.experts.expert import Expert
from agentkit.experts.orchestrator import TeamOrchestrator
from agentkit.experts.plan import (
CollaborationPlan,
MergeStrategy,
ParallelType,
PhaseStatus,
PlanPhase,
PlanStatus,
)
from agentkit.experts.team import ExpertTeam, TeamStatus
# ── 辅助函数 ──────────────────────────────────────────────
def _make_expert_config(
name: str = "test_expert",
is_lead: bool = False,
) -> ExpertConfig:
"""创建测试用 ExpertConfig"""
return ExpertConfig(
name=name,
agent_type="expert",
persona="测试专家",
thinking_style="逻辑推理",
bound_skills=["skill_a"],
is_lead=is_lead,
task_mode="llm_generate",
prompt={"identity": "测试"},
)
def _make_mock_expert(
name: str = "test_expert",
is_lead: bool = False,
is_active: bool = True,
) -> MagicMock:
"""创建 mock Expert"""
config = _make_expert_config(name=name, is_lead=is_lead)
expert = MagicMock(spec=Expert)
expert.config = config
expert.is_active = is_active
expert.team_id = None
expert.get_capabilities_summary.return_value = {
"name": name,
"persona": config.persona,
"thinking_style": config.thinking_style,
"bound_skills": config.bound_skills,
"is_lead": is_lead,
}
return expert
def _make_team_with_experts(
expert_names: list[str] | None = None,
lead_name: str = "lead",
) -> ExpertTeam:
"""创建包含 mock experts 的 ExpertTeam"""
team = ExpertTeam()
transport = AsyncMock(spec=InProcessHandoffTransport)
team._handoff_transport = transport
if expert_names is None:
expert_names = [lead_name, "member1", "member2"]
for name in expert_names:
is_lead = name == lead_name
expert = _make_mock_expert(name=name, is_lead=is_lead)
team._experts[name] = expert
if is_lead:
team._lead_expert_name = name
return team
def _make_serial_plan(
plan_id: str = "plan_1",
task: str = "测试任务",
lead_expert: str = "lead",
num_phases: int = 1,
) -> CollaborationPlan:
"""创建串行阶段的 CollaborationPlan"""
phases = []
for i in range(num_phases):
deps = [f"phase_{i}"] if i > 0 else []
phases.append(
PlanPhase(
id=f"phase_{i + 1}",
name=f"阶段{i + 1}",
assigned_expert=lead_expert,
task_description=f"执行任务{i + 1}",
depends_on=deps,
parallel_type=ParallelType.SERIAL,
)
)
return CollaborationPlan(
id=plan_id,
task=task,
phases=phases,
lead_expert=lead_expert,
)
def _make_parallel_plan(
plan_id: str = "plan_parallel",
task: str = "并行测试任务",
parallel_type: ParallelType = ParallelType.SUBTASK_PARALLEL,
merge_strategy: MergeStrategy | None = None,
) -> CollaborationPlan:
"""创建并行阶段的 CollaborationPlan"""
phases = [
PlanPhase(
id="phase_1",
name="并行阶段1",
assigned_expert="member1",
task_description="并行任务1",
parallel_type=parallel_type,
merge_strategy=merge_strategy,
),
PlanPhase(
id="phase_2",
name="并行阶段2",
assigned_expert="member2",
task_description="并行任务2",
parallel_type=parallel_type,
merge_strategy=merge_strategy,
),
]
return CollaborationPlan(
id=plan_id,
task=task,
phases=phases,
lead_expert="lead",
)
# ── 串行阶段执行测试 ──────────────────────────────────────
class TestSerialPhaseExecution:
"""串行阶段执行测试"""
@pytest.mark.asyncio
async def test_single_serial_phase_completes(self):
"""单个串行阶段执行完成"""
team = _make_team_with_experts()
orchestrator = TeamOrchestrator(team)
plan = _make_serial_plan(num_phases=1)
result = await orchestrator.execute_plan(plan)
assert result["status"] == "completed"
assert "phase_1" in result["phase_results"]
assert plan.phases[0].status == PhaseStatus.COMPLETED
@pytest.mark.asyncio
async def test_multiple_serial_phases_in_order(self):
"""多个串行阶段按依赖顺序执行"""
team = _make_team_with_experts()
orchestrator = TeamOrchestrator(team)
plan = _make_serial_plan(num_phases=3)
result = await orchestrator.execute_plan(plan)
assert result["status"] == "completed"
assert len(result["phase_results"]) == 3
# All phases should be completed
for phase in plan.phases:
assert phase.status == PhaseStatus.COMPLETED
@pytest.mark.asyncio
async def test_serial_phase_sets_plan_and_team_status(self):
"""执行计划时设置 plan 和 team 状态"""
team = _make_team_with_experts()
orchestrator = TeamOrchestrator(team)
plan = _make_serial_plan()
await orchestrator.execute_plan(plan)
assert plan.status == PlanStatus.COMPLETED
assert team._status == TeamStatus.COMPLETED
# ── 子任务并行阶段执行测试 ────────────────────────────────
class TestSubtaskParallelExecution:
"""子任务并行阶段执行测试"""
@pytest.mark.asyncio
async def test_subtask_parallel_phases_execute(self):
"""子任务并行阶段并行执行"""
team = _make_team_with_experts()
orchestrator = TeamOrchestrator(team)
plan = _make_parallel_plan(parallel_type=ParallelType.SUBTASK_PARALLEL)
result = await orchestrator.execute_plan(plan)
assert result["status"] == "completed"
assert "phase_1" in result["phase_results"]
assert "phase_2" in result["phase_results"]
@pytest.mark.asyncio
async def test_subtask_parallel_phase_failure_recorded(self):
"""子任务并行阶段失败时记录错误"""
team = _make_team_with_experts()
orchestrator = TeamOrchestrator(team)
plan = _make_parallel_plan(parallel_type=ParallelType.SUBTASK_PARALLEL)
# Mock _execute_phase to raise for one phase
original_execute = orchestrator._execute_phase
call_count = 0
async def mock_execute_phase(phase, p, pr):
nonlocal call_count
call_count += 1
if phase.id == "phase_1":
raise RuntimeError("Simulated failure")
return await original_execute_phase(phase, p, pr)
with patch.object(
orchestrator, "_execute_phase", side_effect=mock_execute_phase
):
result = await orchestrator.execute_plan(plan)
# The exception should be caught by asyncio.gather(return_exceptions=True)
assert "phase_1" in result["phase_results"]
assert "error" in result["phase_results"]["phase_1"]
# ── 竞争并行阶段测试 ──────────────────────────────────────
class TestCompetitiveParallelExecution:
"""竞争并行阶段执行测试"""
@pytest.mark.asyncio
async def test_competitive_parallel_best_strategy(self):
"""竞争并行阶段使用 BEST 合并策略"""
team = _make_team_with_experts()
orchestrator = TeamOrchestrator(team)
plan = _make_parallel_plan(
parallel_type=ParallelType.COMPETITIVE_PARALLEL,
merge_strategy=MergeStrategy.BEST,
)
result = await orchestrator.execute_plan(plan)
assert result["status"] == "completed"
# Competitive phases are merged into one result per phase
for phase_id in ["phase_1", "phase_2"]:
assert phase_id in result["phase_results"]
phase_result = result["phase_results"][phase_id]
assert phase_result.get("merged") is True
assert phase_result.get("strategy") == "best"
@pytest.mark.asyncio
async def test_competitive_parallel_vote_strategy(self):
"""竞争并行阶段使用 VOTE 合并策略"""
team = _make_team_with_experts()
orchestrator = TeamOrchestrator(team)
plan = _make_parallel_plan(
parallel_type=ParallelType.COMPETITIVE_PARALLEL,
merge_strategy=MergeStrategy.VOTE,
)
result = await orchestrator.execute_plan(plan)
assert result["status"] == "completed"
for phase_id in ["phase_1", "phase_2"]:
phase_result = result["phase_results"][phase_id]
assert phase_result.get("merged") is True
assert phase_result.get("strategy") == "vote"
@pytest.mark.asyncio
async def test_competitive_parallel_fusion_strategy(self):
"""竞争并行阶段使用 FUSION 合并策略"""
team = _make_team_with_experts()
orchestrator = TeamOrchestrator(team)
plan = _make_parallel_plan(
parallel_type=ParallelType.COMPETITIVE_PARALLEL,
merge_strategy=MergeStrategy.FUSION,
)
result = await orchestrator.execute_plan(plan)
assert result["status"] == "completed"
for phase_id in ["phase_1", "phase_2"]:
phase_result = result["phase_results"][phase_id]
assert phase_result.get("merged") is True
assert phase_result.get("strategy") == "fusion"
assert phase_result.get("fused_from") == 3 # 3 active experts
# ── 里程碑检查点测试 ──────────────────────────────────────
class TestMilestoneCheckpoint:
"""里程碑检查点测试"""
@pytest.mark.asyncio
async def test_milestone_pass(self):
"""里程碑检查通过"""
team = _make_team_with_experts()
orchestrator = TeamOrchestrator(team)
plan = CollaborationPlan(
id="plan_milestone",
task="里程碑测试",
phases=[
PlanPhase(
id="phase_1",
name="带里程碑阶段",
assigned_expert="lead",
task_description="执行带里程碑的任务",
milestone="输出质量达标",
)
],
lead_expert="lead",
)
result = await orchestrator.execute_plan(plan)
assert result["status"] == "completed"
assert plan.phases[0].status == PhaseStatus.COMPLETED
@pytest.mark.asyncio
async def test_milestone_fail_phase_failed(self):
"""里程碑检查失败 → 阶段状态为 FAILED"""
team = _make_team_with_experts()
orchestrator = TeamOrchestrator(team)
plan = CollaborationPlan(
id="plan_milestone_fail",
task="里程碑失败测试",
phases=[
PlanPhase(
id="phase_1",
name="带里程碑阶段",
assigned_expert="lead",
task_description="执行带里程碑的任务",
milestone="输出质量达标",
)
],
lead_expert="lead",
)
# Mock _check_milestone to return False
with patch.object(
orchestrator, "_check_milestone", return_value=False
):
result = await orchestrator.execute_plan(plan)
assert plan.phases[0].status == PhaseStatus.FAILED
# Phase failed → retry → still failed → fallback
assert result["status"] == "fallback"
# ── 重试与回退测试 ────────────────────────────────────────
class TestRetryAndFallback:
"""重试与回退测试"""
@pytest.mark.asyncio
async def test_phase_failure_triggers_retry(self):
"""阶段失败触发重试"""
team = _make_team_with_experts()
orchestrator = TeamOrchestrator(team)
plan = _make_serial_plan(num_phases=1)
# Mock _execute_phase: first call returns None, second call succeeds
call_count = 0
async def mock_execute_phase(phase, p, pr):
nonlocal call_count
call_count += 1
if call_count == 1:
# First call fails
p.update_phase_status(phase.id, PhaseStatus.FAILED)
return None
# Retry succeeds — simulate a successful phase execution
p.update_phase_status(phase.id, PhaseStatus.COMPLETED, {"output": "retry ok"})
return {"output": "retry ok"}
with patch.object(
orchestrator, "_execute_phase", side_effect=mock_execute_phase
):
result = await orchestrator.execute_plan(plan)
# After retry, the phase should succeed
assert call_count == 2
assert result["status"] == "completed"
@pytest.mark.asyncio
async def test_retry_failure_triggers_fallback(self):
"""重试仍然失败 → 回退到单 Agent 模式"""
team = _make_team_with_experts()
orchestrator = TeamOrchestrator(team)
plan = _make_serial_plan(num_phases=1)
# Mock _execute_phase to always return None (failure)
async def mock_execute_phase(phase, p, pr):
plan.update_phase_status(phase.id, PhaseStatus.FAILED)
return None
with patch.object(
orchestrator, "_execute_phase", side_effect=mock_execute_phase
):
result = await orchestrator.execute_plan(plan)
assert result["status"] == "fallback"
assert plan.status == PlanStatus.FALLBACK
# ── 最大交互轮次测试 ──────────────────────────────────────
class TestMaxInteractionRounds:
"""最大交互轮次限制测试"""
@pytest.mark.asyncio
async def test_max_interaction_rounds_limit(self):
"""超过最大交互轮次时停止执行"""
team = _make_team_with_experts()
orchestrator = TeamOrchestrator(team)
orchestrator.MAX_INTERACTION_ROUNDS = 1
# Create a plan with many phases that would take many rounds
plan = _make_serial_plan(num_phases=5)
result = await orchestrator.execute_plan(plan)
# Should stop after 1 round, not completing all phases
# Only the first phase should complete (1 interaction round)
assert orchestrator._interaction_count >= 1
# ── 无效计划测试 ──────────────────────────────────────────
class TestInvalidPlan:
"""无效计划测试"""
@pytest.mark.asyncio
async def test_invalid_plan_returns_failed_status(self):
"""无效计划返回 failed 状态"""
team = _make_team_with_experts()
orchestrator = TeamOrchestrator(team)
# Create invalid plan with circular dependency
plan = CollaborationPlan(
id="invalid_plan",
task="无效任务",
phases=[
PlanPhase(
id="p1",
name="阶段1",
assigned_expert="lead",
task_description="t1",
depends_on=["p2"],
),
PlanPhase(
id="p2",
name="阶段2",
assigned_expert="lead",
task_description="t2",
depends_on=["p1"],
),
],
lead_expert="lead",
)
result = await orchestrator.execute_plan(plan)
assert result["status"] == "failed"
assert "errors" in result
assert len(result["errors"]) > 0
# ── 结果综合测试 ──────────────────────────────────────────
class TestSynthesizeResults:
"""结果综合测试"""
@pytest.mark.asyncio
async def test_synthesize_results(self):
"""综合所有阶段结果"""
team = _make_team_with_experts()
orchestrator = TeamOrchestrator(team)
plan = _make_serial_plan(num_phases=2)
result = await orchestrator.execute_plan(plan)
assert result["status"] == "completed"
final = result["result"]
assert final["task"] == "测试任务"
assert final["phases_completed"] == 2
assert final["phases_total"] == 2
assert len(final["results"]) == 2
@pytest.mark.asyncio
async def test_synthesize_results_only_completed_phases(self):
"""只综合已完成阶段的结果"""
team = _make_team_with_experts()
orchestrator = TeamOrchestrator(team)
plan = CollaborationPlan(
id="plan_partial",
task="部分完成测试",
phases=[
PlanPhase(
id="phase_1",
name="完成阶段",
assigned_expert="lead",
task_description="任务1",
),
PlanPhase(
id="phase_2",
name="依赖阶段",
assigned_expert="member1",
task_description="任务2",
depends_on=["phase_1"],
),
],
lead_expert="lead",
)
# Manually set phase_1 as completed, phase_2 as pending
plan.update_phase_status("phase_1", PhaseStatus.COMPLETED, {"output": "done"})
# Synthesize directly
phase_results = {"phase_1": {"output": "done"}}
result = await orchestrator._synthesize_results(plan, phase_results)
assert result["phases_completed"] == 1
assert result["phases_total"] == 2
# ── 事件广播测试 ──────────────────────────────────────────
class TestBroadcastEvent:
"""事件广播测试"""
@pytest.mark.asyncio
async def test_broadcast_event_sends_to_transport(self):
"""广播事件通过 handoff_transport 发送"""
team = _make_team_with_experts()
orchestrator = TeamOrchestrator(team)
await orchestrator._broadcast_event("test_event", {"key": "value"})
team._handoff_transport.send.assert_awaited_once()
call_args = team._handoff_transport.send.call_args
assert call_args[0][0] == team._team_channel
message = call_args[0][1]
assert message["type"] == "test_event"
assert message["key"] == "value"
@pytest.mark.asyncio
async def test_broadcast_event_no_transport(self):
"""没有 handoff_transport 时不报错"""
team = _make_team_with_experts()
team._handoff_transport = None
orchestrator = TeamOrchestrator(team)
# Should not raise
await orchestrator._broadcast_event("test_event", {"key": "value"})
@pytest.mark.asyncio
async def test_phase_execution_broadcasts_events(self):
"""阶段执行时广播 phase_started 和 phase_completed 事件"""
team = _make_team_with_experts()
orchestrator = TeamOrchestrator(team)
plan = _make_serial_plan(num_phases=1)
await orchestrator.execute_plan(plan)
calls = team._handoff_transport.send.call_args_list
event_types = [c[0][1]["type"] for c in calls]
assert "phase_started" in event_types
assert "phase_completed" in event_types
# ── 竞争并行全部失败测试 ──────────────────────────────────
class TestCompetitiveAllFail:
"""竞争并行全部失败测试"""
@pytest.mark.asyncio
async def test_all_competitors_fail(self):
"""所有竞争者都失败时触发 fallback"""
team = _make_team_with_experts()
orchestrator = TeamOrchestrator(team)
# Mock _run_competitor to always raise
async def mock_run_competitor(expert, phase):
raise RuntimeError("Competitor failed")
with patch.object(
orchestrator, "_run_competitor", side_effect=mock_run_competitor
):
plan = _make_parallel_plan(
parallel_type=ParallelType.COMPETITIVE_PARALLEL,
merge_strategy=MergeStrategy.BEST,
)
result = await orchestrator.execute_plan(plan)
# All competitors failed → triggers fallback
assert result["status"] == "fallback"
# ── Expert 不可用测试 ────────────────────────────────────
class TestExpertUnavailable:
"""Expert 不可用测试"""
@pytest.mark.asyncio
async def test_inactive_expert_causes_phase_failure(self):
"""分配的 Expert 不活跃导致阶段失败"""
team = _make_team_with_experts()
# Mark the lead expert as inactive
team._experts["lead"].is_active = False
orchestrator = TeamOrchestrator(team)
plan = _make_serial_plan(num_phases=1)
result = await orchestrator.execute_plan(plan)
# Phase should fail because expert is not active → retry → still fail → fallback
assert result["status"] == "fallback"
@pytest.mark.asyncio
async def test_nonexistent_expert_causes_phase_failure(self):
"""分配的 Expert 不存在导致阶段失败"""
team = _make_team_with_experts()
orchestrator = TeamOrchestrator(team)
plan = CollaborationPlan(
id="plan_no_expert",
task="无专家测试",
phases=[
PlanPhase(
id="phase_1",
name="无专家阶段",
assigned_expert="nonexistent_expert",
task_description="执行任务",
)
],
lead_expert="lead",
)
result = await orchestrator.execute_plan(plan)
# Expert doesn't exist → phase fails → retry → still fails → fallback
assert result["status"] == "fallback"