feat(experts): U4 TeamOrchestrator parallel independent subtasks (R1-R5)

Add explicit support for parallel execution of dependency-free subtasks:
- TeamPlan.get_independent_subtasks(): returns phases with depends_on==[]
  (introspection entry — topological_sort already groups them in layer 0)
- TeamOrchestrator.MAX_INDEPENDENT_SUBTASKS=10: aligns with router.MAX_EXPERTS
- _rebalance_independent_subtasks(): when Lead over-decomposes (>10
  independent subtasks), re-decompose once with merge hint. Fallbacks:
  no gateway → keep original; LLM error → keep original; single-phase
  fallback → keep original (don't collapse 11→1); still over → return
  new (MAX_PHASES truncation handles it)

Tests: 15 new tests covering get_independent_subtasks, topological_sort
layer contract, SharedWorkspace path uniqueness, rebalance (5 paths),
and full execute() integration. Existing TeamOrchestrator tests pass.
This commit is contained in:
Chiguyong 2026-07-06 13:43:31 +08:00
parent 7627403a8a
commit 81a35dac27
3 changed files with 545 additions and 0 deletions

View File

@ -62,6 +62,9 @@ class TeamOrchestrator(
MAX_DEBATE_ROUNDS = 4 # Hard cap on debate rounds per phase
MAX_DEBATES = 3 # Hard cap on auto-inserted debate phases per execution
DEFAULT_MAX_CONCURRENT_PHASES = 3 # 同层最大并发阶段数,避免 LLM 限流洪峰
# IQ-Boost/U4 (R4): aligns with router.MAX_EXPERTS — if Lead decomposes
# more independent subtasks than this, re-decompose once with a merge hint.
MAX_INDEPENDENT_SUBTASKS = 10
STOP_COMMANDS = frozenset({"/stop", "停止", "stop", "结束"})
# G9/U4: RollbackExecutor default timeout for validation_command / rollback_command.
# Override via constructor `rollback_timeout` from `rollback.default_timeout` config.
@ -197,6 +200,11 @@ class TeamOrchestrator(
PlanPhase(name="执行", assigned_expert=lead.config.name, task_description=task)
]
# IQ-Boost/U4 (R4): if Lead over-decomposed independent subtasks beyond
# MAX_INDEPENDENT_SUBTASKS, ask Lead to re-decompose with a merge hint
# (one retry — further overflow falls through to MAX_PHASES truncation).
phases = await self._rebalance_independent_subtasks(lead, task, phases)
plan.phases = phases[: self.MAX_PHASES]
# U3: Optionally add plan review debate before execution
@ -531,6 +539,70 @@ class TeamOrchestrator(
self._team.set_status(TeamStatus.EXECUTING)
return await self._run_pipeline(lead, plan, phase_results, task)
async def _rebalance_independent_subtasks(
self, lead: Expert, task: str, phases: list[PlanPhase]
) -> list[PlanPhase]:
"""IQ-Boost/U4 (R4): if phases contain more independent subtasks
(depends_on == []) than MAX_INDEPENDENT_SUBTASKS, ask Lead to
re-decompose with a merge hint. One retry only further overflow
is handled by MAX_PHASES truncation in execute().
Returns the original phases if count is within limit or retry fails.
"""
# Count independent subtasks via a transient TeamPlan (avoids mutating
# the real plan before execute() finalizes phase list).
independent_count = sum(1 for ph in phases if not ph.depends_on)
if independent_count <= self.MAX_INDEPENDENT_SUBTASKS:
return phases
logger.info(
f"U4: Lead decomposed {independent_count} independent subtasks "
f"(> {self.MAX_INDEPENDENT_SUBTASKS}), requesting re-decompose with merge hint"
)
gateway = self._get_llm_gateway(lead)
if not gateway:
# No gateway — can't re-decompose; rely on MAX_PHASES truncation.
return phases
# Re-decompose with explicit merge hint
original_hint = (
f"Previous decomposition produced {independent_count} independent subtasks "
f"(no dependencies), exceeding the {self.MAX_INDEPENDENT_SUBTASKS} limit. "
f"Please merge related subtasks so the total independent (depends_on=[]) "
f"subtasks is at most {self.MAX_INDEPENDENT_SUBTASKS}. Keep dependencies "
f"where natural."
)
# Temporarily wrap task with hint — call _decompose_task with augmented task.
augmented_task = f"{task}\n\n[Re-decomposition hint]: {original_hint}"
try:
new_phases = await self._decompose_task(lead, augmented_task)
except (LLMProviderError, asyncio.TimeoutError, ConnectionError, ValueError) as e:
logger.warning(f"U4 re-decompose failed: {e}, keeping original phases")
return phases
if not new_phases:
return phases
new_independent = sum(1 for ph in new_phases if not ph.depends_on)
if new_independent <= self.MAX_INDEPENDENT_SUBTASKS:
# ponytail: detect single-phase fallback (LLM returned invalid
# JSON → _decompose_task returned degenerate single phase).
# Collapsing 11 subtasks to 1 is worse than truncation; keep
# original so MAX_PHASES handles it.
if len(new_phases) == 1 and new_independent < independent_count:
logger.info("U4: re-decompose fell back to single phase, keeping original")
return phases
logger.info(f"U4: re-decompose succeeded ({new_independent} independent subtasks)")
return new_phases
# Still over limit — take the new decomposition anyway (truncation will cap it)
logger.info(
f"U4: re-decompose still has {new_independent} independent subtasks, "
f"proceeding with MAX_PHASES truncation"
)
return new_phases
async def _decompose_task(self, lead: Expert, task: str) -> list[PlanPhase]:
"""Lead Expert decomposes task into phases using LLM.

View File

@ -443,3 +443,12 @@ class TeamPlan:
in_degree[dep_id] -= 1
return layers
def get_independent_subtasks(self) -> list[PlanPhase]:
"""返回无依赖的子任务depends_on == [])。
IQ-Boost/U4 (R1): 用于 Lead 分解后检查独立子任务数量是否超过
MAX_EXPERTS这些子任务会被 topological_sort 派发到 layer 0 并行执行
同层并行已有本方法仅提供显式 introspection 入口
"""
return [ph for ph in self.phases if not ph.depends_on]

View File

@ -0,0 +1,464 @@
"""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