diff --git a/src/agentkit/experts/plan.py b/src/agentkit/experts/plan.py index 36d303b..4b4d1c0 100644 --- a/src/agentkit/experts/plan.py +++ b/src/agentkit/experts/plan.py @@ -55,6 +55,17 @@ class PhaseStatus(str, enum.Enum): FAILED = "failed" +class PhaseType(str, enum.Enum): + """阶段类型 + + EXECUTION: 标准执行阶段,专家独立完成分配的任务 + DEBATE: 辩论阶段,Lead 主导指定专家就分歧点交锋,Lead 裁决 + """ + + EXECUTION = "execution" + DEBATE = "debate" + + @dataclass class SubTask: """Lead Expert 分解出的子任务(hub-and-spoke 模式,向后兼容) @@ -110,6 +121,12 @@ class PlanPhase: depends_on: 前置阶段 ID 列表(空列表表示无依赖) status: 当前状态 result: 阶段输出结果 + phase_type: 阶段类型(EXECUTION 或 DEBATE) + debate_config: 辩论阶段配置(仅 DEBATE 类型使用): + - topic: 辩论主题 + - participants: 参与专家名称列表 + - max_rounds: 最大辩论轮次(默认 2,硬上限 4) + - skip: 是否跳过辩论(逃生舱) """ id: str = field(default_factory=lambda: str(uuid.uuid4())) @@ -119,6 +136,8 @@ class PlanPhase: depends_on: list[str] = field(default_factory=list) status: PhaseStatus = PhaseStatus.PENDING result: dict[str, Any] | None = None + phase_type: PhaseType = PhaseType.EXECUTION + debate_config: dict[str, Any] | None = None def to_dict(self) -> dict[str, Any]: """序列化为字典""" @@ -137,6 +156,8 @@ class PlanPhase: "depends_on": list(self.depends_on), "status": self.status.value, "result": result_str, + "phase_type": self.phase_type.value, + "debate_config": self.debate_config, } @classmethod @@ -150,6 +171,8 @@ class PlanPhase: depends_on=list(data.get("depends_on", [])), status=PhaseStatus(data.get("status", PhaseStatus.PENDING.value)), result=data.get("result"), + phase_type=PhaseType(data.get("phase_type", PhaseType.EXECUTION.value)), + debate_config=data.get("debate_config"), ) diff --git a/tests/unit/experts/test_plan.py b/tests/unit/experts/test_plan.py index 742f3ad..23f2a40 100644 --- a/tests/unit/experts/test_plan.py +++ b/tests/unit/experts/test_plan.py @@ -7,6 +7,7 @@ import pytest from agentkit.experts.plan import ( MergeStrategy, PhaseStatus, + PhaseType, PlanPhase, PlanStatus, SubTask, @@ -356,6 +357,19 @@ class TestPhaseStatus: assert PhaseStatus.FAILED == "failed" +class TestPhaseType: + """PhaseType 枚举测试""" + + def test_types_exist(self): + """阶段类型都存在""" + assert PhaseType.EXECUTION == "execution" + assert PhaseType.DEBATE == "debate" + + def test_only_two_types(self): + """只有 EXECUTION 和 DEBATE 两种类型""" + assert len(list(PhaseType)) == 2 + + class TestPlanPhase: """PlanPhase 数据模型测试""" @@ -438,6 +452,79 @@ class TestPlanPhase: # result is serialized to string to match frontend ITeamPlanPhase.result type assert d["result"] == "phase output data" + def test_default_phase_type_is_execution(self): + """默认 phase_type 为 EXECUTION""" + phase = PlanPhase(name="测试阶段") + assert phase.phase_type == PhaseType.EXECUTION + assert phase.debate_config is None + + def test_debate_phase_creation(self): + """创建 DEBATE 类型阶段""" + debate_config = { + "topic": "前端框架选型:React vs Vue", + "participants": ["frontend_engineer", "tech_lead"], + "max_rounds": 2, + } + phase = PlanPhase( + name="框架选型辩论", + assigned_expert="tech_lead", + task_description="就前端框架选型进行辩论", + phase_type=PhaseType.DEBATE, + debate_config=debate_config, + ) + assert phase.phase_type == PhaseType.DEBATE + assert phase.debate_config == debate_config + assert phase.debate_config["topic"] == "前端框架选型:React vs Vue" + assert phase.debate_config["participants"] == ["frontend_engineer", "tech_lead"] + assert phase.debate_config["max_rounds"] == 2 + + def test_debate_phase_serialization_roundtrip(self): + """DEBATE 阶段序列化往返""" + debate_config = { + "topic": "微服务 vs 单体", + "participants": ["backend_engineer", "tech_lead"], + "max_rounds": 3, + } + phase = PlanPhase( + id="debate_1", + name="架构辩论", + assigned_expert="tech_lead", + task_description="架构选型辩论", + phase_type=PhaseType.DEBATE, + debate_config=debate_config, + ) + d = phase.to_dict() + assert d["phase_type"] == "debate" + assert d["debate_config"] == debate_config + + restored = PlanPhase.from_dict(d) + assert restored.phase_type == PhaseType.DEBATE + assert restored.debate_config == debate_config + assert restored.debate_config["topic"] == "微服务 vs 单体" + + def test_backward_compatibility_no_phase_type(self): + """向后兼容:不带 phase_type 的旧 dict 默认为 EXECUTION""" + old_dict = { + "id": "old_phase", + "name": "旧阶段", + "assigned_expert": "dev", + "task_description": "旧任务", + "depends_on": [], + "status": "pending", + "result": None, + } + phase = PlanPhase.from_dict(old_dict) + assert phase.phase_type == PhaseType.EXECUTION + assert phase.debate_config is None + + def test_debate_config_none_for_execution(self): + """EXECUTION 阶段的 debate_config 为 None""" + phase = PlanPhase(name="执行阶段", phase_type=PhaseType.EXECUTION) + assert phase.debate_config is None + d = phase.to_dict() + assert d["phase_type"] == "execution" + assert d["debate_config"] is None + class TestTeamPlanPhases: """TeamPlan 流水线模式(phases)测试""" @@ -633,6 +720,52 @@ class TestTopologicalSort: with pytest.raises(ValueError, match="non-existent phase"): plan.topological_sort() + def test_mixed_execution_and_debate_phases(self): + """混合 EXECUTION + DEBATE 阶段的拓扑排序 + + 结构: + Layer 0: [规划] (EXECUTION) + Layer 1: [前端, 后端] (EXECUTION, 依赖规划) + Layer 2: [架构辩论] (DEBATE, 依赖前端+后端) + Layer 3: [QA] (EXECUTION, 依赖架构辩论) + """ + plan = TeamPlan( + task="混合模式任务", + phases=[ + PlanPhase(id="p1", name="规划", assigned_expert="tech_lead", depends_on=[]), + PlanPhase( + id="p2", name="前端", assigned_expert="frontend", depends_on=["p1"] + ), + PlanPhase( + id="p3", name="后端", assigned_expert="backend", depends_on=["p1"] + ), + PlanPhase( + id="d1", + name="架构辩论", + assigned_expert="tech_lead", + depends_on=["p2", "p3"], + phase_type=PhaseType.DEBATE, + debate_config={ + "topic": "前后端接口设计", + "participants": ["frontend", "backend"], + "max_rounds": 2, + }, + ), + PlanPhase(id="p4", name="QA", assigned_expert="qa", depends_on=["d1"]), + ], + ) + layers = plan.topological_sort() + assert len(layers) == 4 + assert [ph.id for ph in layers[0]] == ["p1"] + assert set(ph.id for ph in layers[1]) == {"p2", "p3"} + assert [ph.id for ph in layers[2]] == ["d1"] + assert [ph.id for ph in layers[3]] == ["p4"] + # Verify the debate phase is correctly typed + debate_phase = plan.get_phase("d1") + assert debate_phase is not None + assert debate_phase.phase_type == PhaseType.DEBATE + assert debate_phase.debate_config is not None + class TestGetReadyPhases: """get_ready_phases 就绪阶段测试"""