fischer-agentkit/tests/unit/test_team_parallel.py

465 lines
17 KiB
Python

"""U4: TeamOrchestrator 无依赖子任务并行模式单元测试 (R1-R5).
Covers:
- TeamPlan.get_independent_subtasks() introspection
- _rebalance_independent_subtasks() MAX_EXPERTS enforcement (R4)
- topological_sort 同层并行 (existing behavior, validated here for U4 contract)
- SharedWorkspace 路径唯一性 (phase_id is UUID → no collision)
- 综合阶段等待所有并行子任务完成 (layer-sequential contract)
"""
from __future__ import annotations
import json
from unittest.mock import AsyncMock, MagicMock
import pytest
from agentkit.core.handoff_transport import InProcessHandoffTransport
from agentkit.core.protocol import TaskResult, TaskStatus
from agentkit.experts.config import ExpertConfig
from agentkit.experts.expert import Expert
from agentkit.experts.orchestrator import TeamOrchestrator
from agentkit.experts.plan import PlanPhase, TeamPlan
from agentkit.experts.team import ExpertTeam
from tests.unit.experts._helpers import (
make_chat_stream_mock,
make_execute_stream_mock,
)
# ── Helpers ────────────────────────────────────────────────────────────
def _make_expert_config(name: str, is_lead: bool = False) -> 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": "测试"},
llm={"model": "default"},
)
def _make_mock_expert(name: str, is_lead: bool = False) -> MagicMock:
config = _make_expert_config(name=name, is_lead=is_lead)
expert = MagicMock(spec=Expert)
expert.config = config
expert.is_active = True
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,
}
mock_agent = MagicMock()
mock_agent.execute = AsyncMock(
return_value=TaskResult(
task_id="test",
agent_name=name,
status=TaskStatus.COMPLETED.value,
output_data={"content": f"Result from {name}"},
error_message=None,
started_at=None,
completed_at=None,
)
)
mock_agent.execute_stream = make_execute_stream_mock(f"Result from {name}")
mock_agent._llm_gateway = None
expert.agent = mock_agent
return expert
def _make_team_with_experts(
expert_names: list[str] | None = None,
lead_name: str = "lead",
) -> 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_phase(
id: str, name: str, assigned_expert: str, depends_on: list[str] | None = None
) -> PlanPhase:
return PlanPhase(
id=id,
name=name,
assigned_expert=assigned_expert,
task_description=f"task for {name}",
depends_on=depends_on or [],
)
def _make_mock_llm_gateway_with_phases(
phases_responses: list[list[dict]],
synthesis_content: str = "综合结果",
) -> MagicMock:
"""Gateway that returns successive decompositions then synthesis.
phases_responses: each element is a list of phase dicts to return on
successive decomposition calls. After exhausted, returns synthesis.
"""
gateway = AsyncMock()
decomp_responses = [MagicMock(content=json.dumps(phases)) for phases in phases_responses]
synth_response = MagicMock(content=synthesis_content)
call_count = [0]
async def chat_side_effect(messages, model=None, **kwargs):
call_count[0] += 1
if call_count[0] <= len(decomp_responses):
return decomp_responses[call_count[0] - 1]
return synth_response
gateway.chat = AsyncMock(side_effect=chat_side_effect)
gateway.chat_stream = make_chat_stream_mock(synthesis_content)
return gateway
# ── TeamPlan.get_independent_subtasks ─────────────────────────────────
class TestGetIndependentSubtasks:
"""U4/R1: TeamPlan.get_independent_subtasks() returns phases with no
depends_on."""
def test_returns_empty_for_no_phases(self):
plan = TeamPlan(task="test", lead_expert="lead")
assert plan.get_independent_subtasks() == []
def test_returns_only_independent_phases(self):
plan = TeamPlan(
task="test",
lead_expert="lead",
phases=[
_make_phase("p1", "A", "lead", depends_on=[]),
_make_phase("p2", "B", "member1", depends_on=["p1"]),
_make_phase("p3", "C", "member2", depends_on=[]),
_make_phase("p4", "D", "member1", depends_on=["p1", "p3"]),
],
)
independent = plan.get_independent_subtasks()
assert len(independent) == 2
assert {ph.id for ph in independent} == {"p1", "p3"}
def test_returns_all_when_no_dependencies(self):
plan = TeamPlan(
task="test",
lead_expert="lead",
phases=[
_make_phase("p1", "A", "lead", depends_on=[]),
_make_phase("p2", "B", "member1", depends_on=[]),
_make_phase("p3", "C", "member2", depends_on=[]),
],
)
independent = plan.get_independent_subtasks()
assert len(independent) == 3
# ── topological_sort 同层并行 contract (U4/R1, R3) ────────────────────
class TestParallelLayerContract:
"""U4/R1, R3: independent subtasks land in layer 0 (parallel); synthesis
waits for all layers to complete (layer-sequential)."""
def test_three_independent_subtasks_in_layer_0(self):
plan = TeamPlan(
task="test",
lead_expert="lead",
phases=[
_make_phase("p1", "A", "lead", depends_on=[]),
_make_phase("p2", "B", "member1", depends_on=[]),
_make_phase("p3", "C", "member2", depends_on=[]),
],
)
layers = plan.topological_sort()
assert len(layers) == 1
assert len(layers[0]) == 3
assert {ph.id for ph in layers[0]} == {"p1", "p2", "p3"}
def test_shared_dependency_degrades_to_layered(self):
"""R3: subtasks sharing an upstream dependency are NOT parallel with
that upstream — they land in later layers."""
plan = TeamPlan(
task="test",
lead_expert="lead",
phases=[
_make_phase("p1", "Base", "lead", depends_on=[]),
_make_phase("p2", "A", "member1", depends_on=["p1"]),
_make_phase("p3", "B", "member2", depends_on=["p1"]),
],
)
layers = plan.topological_sort()
assert len(layers) == 2
assert len(layers[0]) == 1 # only Base
assert layers[0][0].id == "p1"
assert len(layers[1]) == 2 # A and B parallel (same layer)
assert {ph.id for ph in layers[1]} == {"p2", "p3"}
def test_synthesis_waits_for_all_layers(self):
"""R3: dependent phase in last layer — synthesis cannot run until
its layer completes (validated via topological_sort layer count)."""
plan = TeamPlan(
task="test",
lead_expert="lead",
phases=[
_make_phase("p1", "A", "lead", depends_on=[]),
_make_phase("p2", "B", "member1", depends_on=["p1"]),
_make_phase("p3", "C", "member2", depends_on=["p2"]),
],
)
layers = plan.topological_sort()
assert len(layers) == 3 # strict sequential — no parallelism
# ── SharedWorkspace 路径唯一性 (U4/R2) ─────────────────────────────────
class TestSharedWorkspacePathUniqueness:
"""U4/R2: parallel subtask output paths must not collide. PlanPhase.id
is UUID by default → unique per phase, ensuring
``{plan_id}/phase/{phase_id}/output`` never overlaps."""
def test_default_phase_ids_are_unique(self):
phases = [
PlanPhase(name=f"phase_{i}", assigned_expert="lead", task_description="t")
for i in range(5)
]
ids = [ph.id for ph in phases]
assert len(set(ids)) == len(ids), "phase_ids must be unique"
def test_independent_subtasks_have_unique_output_paths(self):
plan = TeamPlan(
task="test",
lead_expert="lead",
phases=[
PlanPhase(name="A", assigned_expert="lead", task_description="t"),
PlanPhase(name="B", assigned_expert="member1", task_description="t"),
PlanPhase(name="C", assigned_expert="member2", task_description="t"),
],
)
independent = plan.get_independent_subtasks()
paths = {f"{plan.id}/phase/{ph.id}/output" for ph in independent}
assert len(paths) == len(independent)
# ── _rebalance_independent_subtasks (U4/R4 MAX_EXPERTS) ────────────────
class TestRebalanceIndependentSubtasks:
"""U4/R4: when Lead over-decomposes independent subtasks beyond
MAX_INDEPENDENT_SUBTASKS, request re-decompose with merge hint."""
@pytest.mark.asyncio
async def test_no_rebalance_when_within_limit(self):
"""5 independent subtasks ≤ 10 → no re-decompose."""
team = _make_team_with_experts()
orchestrator = TeamOrchestrator(team)
# 5 independent phases
phases = [_make_phase(f"p{i}", f"phase_{i}", "lead", depends_on=[]) for i in range(5)]
original_phases = list(phases)
result = await orchestrator._rebalance_independent_subtasks(
lead=team.lead_expert, task="test", phases=phases
)
assert result is original_phases or result == original_phases
@pytest.mark.asyncio
async def test_rebalance_triggers_when_over_limit(self):
"""11 independent subtasks > 10 → re-decompose with merge hint."""
team = _make_team_with_experts()
orchestrator = TeamOrchestrator(team)
# Original: 11 independent (over limit)
original_phases = [
_make_phase(f"p{i}", f"phase_{i}", "lead", depends_on=[]) for i in range(11)
]
# Re-decomposed: 8 independent (within limit) — merge succeeded
gateway = _make_mock_llm_gateway_with_phases(
phases_responses=[
[ # first call (original _decompose_task already happened —
# but _rebalance calls _decompose_task again, returning this)
{
"name": f"merged_{i}",
"assigned_expert": "lead",
"task_description": "t",
"depends_on": [],
}
for i in range(8)
],
]
)
team._experts["lead"].agent._llm_gateway = gateway
result = await orchestrator._rebalance_independent_subtasks(
lead=team.lead_expert, task="test", phases=original_phases
)
# Should return the re-decomposed phases (8 independent)
assert len(result) == 8
assert all(not ph.depends_on for ph in result)
# Original phases should NOT be returned
assert {ph.id for ph in result}.isdisjoint({ph.id for ph in original_phases})
@pytest.mark.asyncio
async def test_rebalance_no_gateway_returns_original(self):
"""No LLM gateway → can't re-decompose, return original."""
team = _make_team_with_experts()
orchestrator = TeamOrchestrator(team)
# gateway is None by default in _make_mock_expert
original_phases = [
_make_phase(f"p{i}", f"phase_{i}", "lead", depends_on=[]) for i in range(11)
]
result = await orchestrator._rebalance_independent_subtasks(
lead=team.lead_expert, task="test", phases=original_phases
)
assert result is original_phases
@pytest.mark.asyncio
async def test_rebalance_still_over_limit_returns_new_anyway(self):
"""Re-decompose still produces >MAX independent → return new phases
(MAX_PHASES truncation handles it in execute())."""
team = _make_team_with_experts()
orchestrator = TeamOrchestrator(team)
original_phases = [
_make_phase(f"p{i}", f"phase_{i}", "lead", depends_on=[]) for i in range(11)
]
# Re-decompose returns 12 independent (still over)
gateway = _make_mock_llm_gateway_with_phases(
phases_responses=[
[
{
"name": f"q{i}",
"assigned_expert": "lead",
"task_description": "t",
"depends_on": [],
}
for i in range(12)
],
]
)
team._experts["lead"].agent._llm_gateway = gateway
result = await orchestrator._rebalance_independent_subtasks(
lead=team.lead_expert, task="test", phases=original_phases
)
# Should return the 12-phase re-decomposition (truncation happens later)
assert len(result) == 12
@pytest.mark.asyncio
async def test_rebalance_decompose_failure_returns_original(self):
"""Re-decompose raises LLMProviderError → return original phases."""
from agentkit.core.exceptions import LLMProviderError
team = _make_team_with_experts()
orchestrator = TeamOrchestrator(team)
original_phases = [
_make_phase(f"p{i}", f"phase_{i}", "lead", depends_on=[]) for i in range(11)
]
gateway = AsyncMock()
gateway.chat = AsyncMock(side_effect=LLMProviderError("LLM down"))
gateway.chat_stream = make_chat_stream_mock("err")
team._experts["lead"].agent._llm_gateway = gateway
result = await orchestrator._rebalance_independent_subtasks(
lead=team.lead_expert, task="test", phases=original_phases
)
assert result is original_phases
@pytest.mark.asyncio
async def test_rebalance_empty_redecompose_returns_original(self):
"""Re-decompose returns empty list → return original phases."""
team = _make_team_with_experts()
orchestrator = TeamOrchestrator(team)
original_phases = [
_make_phase(f"p{i}", f"phase_{i}", "lead", depends_on=[]) for i in range(11)
]
# Gateway returns malformed JSON → _parse_phases returns []
gateway = AsyncMock()
bad_response = MagicMock(content="not json")
gateway.chat = AsyncMock(return_value=bad_response)
gateway.chat_stream = make_chat_stream_mock("err")
team._experts["lead"].agent._llm_gateway = gateway
result = await orchestrator._rebalance_independent_subtasks(
lead=team.lead_expert, task="test", phases=original_phases
)
assert result is original_phases
# ── Integration: full execute() with parallel subtasks ────────────────
class TestExecuteParallelSubtasks:
"""U4/R1, R3, R5: full execute() path with independent subtasks running
in parallel + synthesis waits for all."""
@pytest.mark.asyncio
async def test_three_parallel_subtasks_complete_then_synthesis(self):
"""3 independent subtasks → all run in layer 0 → synthesis runs
after all complete."""
team = _make_team_with_experts()
orchestrator = TeamOrchestrator(team)
gateway = _make_mock_llm_gateway_with_phases(
phases_responses=[
[
{
"name": "A",
"assigned_expert": "lead",
"task_description": "a",
"depends_on": [],
},
{
"name": "B",
"assigned_expert": "member1",
"task_description": "b",
"depends_on": [],
},
{
"name": "C",
"assigned_expert": "member2",
"task_description": "c",
"depends_on": [],
},
],
]
)
team._experts["lead"].agent._llm_gateway = gateway
result = await orchestrator.execute("并行任务")
assert result["status"] == "completed"
plan: TeamPlan = result["plan"]
layers = plan.topological_sort()
assert len(layers) == 1 # all 3 in layer 0 (parallel)
assert len(layers[0]) == 3
# All phases completed
completed = plan.completed_phases
assert len(completed) == 3
# phase_results has all 3
phase_results: dict = result["phase_results"]
assert len(phase_results) == 3