706 lines
29 KiB
Python
706 lines
29 KiB
Python
"""PlanExecEngine 单元测试
|
||
|
||
测试 Plan-and-Execute 执行引擎适配器:
|
||
1. 3步任务: plan → execute steps → aggregate
|
||
2. 步骤失败时触发重规划
|
||
3. 接口兼容性(与 ReActEngine 一致)
|
||
4. CancellationToken 取消
|
||
"""
|
||
|
||
import asyncio
|
||
import json
|
||
from datetime import datetime, timezone
|
||
from unittest.mock import AsyncMock, MagicMock, patch
|
||
|
||
import pytest
|
||
|
||
from agentkit.core.plan_exec_engine import PlanExecEngine
|
||
from agentkit.core.plan_executor import PlanExecutionResult, StepExecutionResult
|
||
from agentkit.core.plan_schema import ExecutionPlan, PlanStep, PlanStepStatus
|
||
from agentkit.core.protocol import CancellationToken, TaskMessage, TaskResult, TaskStatus
|
||
from agentkit.core.react import ReActEvent, ReActResult, ReActStep
|
||
from agentkit.orchestrator.pipeline_schema import (
|
||
Pipeline,
|
||
PipelineResult,
|
||
PipelineStage,
|
||
ReflectionReport,
|
||
StageResult,
|
||
StageStatus,
|
||
)
|
||
|
||
|
||
# ── Test Helpers ──────────────────────────────────────────
|
||
|
||
|
||
def make_plan(
|
||
goal: str = "test goal",
|
||
steps: list[PlanStep] | None = None,
|
||
parallel_groups: list[list[str]] | None = None,
|
||
) -> ExecutionPlan:
|
||
"""快速构造 ExecutionPlan"""
|
||
if steps is None:
|
||
steps = [
|
||
PlanStep(step_id="step-0", name="Step 0", description="First step"),
|
||
PlanStep(step_id="step-1", name="Step 1", description="Second step", dependencies=["step-0"]),
|
||
PlanStep(step_id="step-2", name="Step 2", description="Final step", dependencies=["step-1"]),
|
||
]
|
||
if parallel_groups is None:
|
||
parallel_groups = [["step-0"], ["step-1"], ["step-2"]]
|
||
return ExecutionPlan(
|
||
goal=goal,
|
||
steps=steps,
|
||
parallel_groups=parallel_groups,
|
||
)
|
||
|
||
|
||
def make_step_result(
|
||
step_id: str,
|
||
status: PlanStepStatus = PlanStepStatus.COMPLETED,
|
||
result: dict | None = None,
|
||
error: str | None = None,
|
||
) -> StepExecutionResult:
|
||
"""快速构造 StepExecutionResult"""
|
||
return StepExecutionResult(
|
||
step_id=step_id,
|
||
status=status,
|
||
result=result or {"content": f"result of {step_id}"},
|
||
error=error,
|
||
)
|
||
|
||
|
||
def make_plan_result(
|
||
plan_id: str = "test-plan",
|
||
step_results: dict[str, StepExecutionResult] | None = None,
|
||
status: TaskStatus = TaskStatus.COMPLETED,
|
||
) -> PlanExecutionResult:
|
||
"""快速构造 PlanExecutionResult"""
|
||
if step_results is None:
|
||
step_results = {
|
||
"step-0": make_step_result("step-0"),
|
||
"step-1": make_step_result("step-1"),
|
||
"step-2": make_step_result("step-2"),
|
||
}
|
||
return PlanExecutionResult(
|
||
plan_id=plan_id,
|
||
step_results=step_results,
|
||
status=status,
|
||
total_duration_ms=100.0,
|
||
)
|
||
|
||
|
||
def make_reflection_report(
|
||
failed_stage: str = "step-1",
|
||
failure_type: str = "logic_error",
|
||
root_cause: str = "Test failure",
|
||
suggested_fix: str = "Retry with adjusted parameters",
|
||
) -> ReflectionReport:
|
||
"""快速构造 ReflectionReport"""
|
||
return ReflectionReport(
|
||
failure_type=failure_type,
|
||
root_cause=root_cause,
|
||
suggested_fix=suggested_fix,
|
||
failed_stage=failed_stage,
|
||
reflection_number=1,
|
||
)
|
||
|
||
|
||
def make_revised_pipeline(
|
||
original_pipeline: Pipeline,
|
||
failed_stage: str = "step-1",
|
||
) -> Pipeline:
|
||
"""构造修正后的 Pipeline"""
|
||
new_stages = []
|
||
for stage in original_pipeline.stages:
|
||
if stage.name == failed_stage:
|
||
new_stages.append(PipelineStage(
|
||
name=stage.name,
|
||
agent=stage.agent,
|
||
action=f"Revised: {stage.action}",
|
||
depends_on=stage.depends_on,
|
||
inputs=stage.inputs,
|
||
))
|
||
else:
|
||
new_stages.append(stage)
|
||
return Pipeline(
|
||
name=f"{original_pipeline.name}_replanned",
|
||
version=original_pipeline.version,
|
||
description=original_pipeline.description,
|
||
stages=new_stages,
|
||
)
|
||
|
||
|
||
# ── Test: 3-step task ────────────────────────────────────
|
||
|
||
|
||
class TestThreeStepTask:
|
||
"""测试 3 步任务: plan → execute steps → aggregate"""
|
||
|
||
async def test_execute_returns_react_result(self):
|
||
"""execute() 应返回 ReActResult"""
|
||
engine = PlanExecEngine(llm_gateway=None)
|
||
|
||
# Mock GoalPlanner
|
||
plan = make_plan()
|
||
with patch.object(engine._planner, "generate_plan", AsyncMock(return_value=plan)):
|
||
# Mock PlanExecutor
|
||
plan_result = make_plan_result()
|
||
with patch("agentkit.core.plan_exec_engine.PlanExecutor") as MockExecutor:
|
||
mock_executor_instance = MagicMock()
|
||
mock_executor_instance.execute = AsyncMock(return_value=plan_result)
|
||
MockExecutor.return_value = mock_executor_instance
|
||
|
||
result = await engine.execute(
|
||
messages=[{"role": "user", "content": "调研3个竞品并生成报告"}],
|
||
)
|
||
|
||
assert isinstance(result, ReActResult)
|
||
assert result.output # 有输出
|
||
assert result.total_steps > 0
|
||
assert result.total_tokens >= 0
|
||
assert result.status in ("success", "partial", "error", "cancelled", "timeout")
|
||
|
||
async def test_execute_trajectory_contains_plan_and_steps(self):
|
||
"""trajectory 应包含 plan_generated 和步骤完成记录"""
|
||
engine = PlanExecEngine(llm_gateway=None)
|
||
|
||
plan = make_plan()
|
||
plan_result = make_plan_result()
|
||
|
||
with patch.object(engine._planner, "generate_plan", AsyncMock(return_value=plan)):
|
||
with patch("agentkit.core.plan_exec_engine.PlanExecutor") as MockExecutor:
|
||
mock_executor_instance = MagicMock()
|
||
mock_executor_instance.execute = AsyncMock(return_value=plan_result)
|
||
MockExecutor.return_value = mock_executor_instance
|
||
|
||
result = await engine.execute(
|
||
messages=[{"role": "user", "content": "调研3个竞品并生成报告"}],
|
||
)
|
||
|
||
# trajectory 应包含: plan_generated + 3 step_completed + final_answer
|
||
actions = [s.action for s in result.trajectory]
|
||
assert "plan_generated" in actions
|
||
assert "final_answer" in actions
|
||
# 3 个步骤完成
|
||
step_completed_count = sum(1 for a in actions if a == "step_completed")
|
||
assert step_completed_count == 3
|
||
|
||
async def test_execute_aggregates_step_results(self):
|
||
"""最终输出应聚合所有成功步骤的结果"""
|
||
engine = PlanExecEngine(llm_gateway=None)
|
||
|
||
plan = make_plan()
|
||
step_results = {
|
||
"step-0": make_step_result("step-0", result={"data": "research result"}),
|
||
"step-1": make_step_result("step-1", result={"data": "analysis result"}),
|
||
"step-2": make_step_result("step-2", result={"data": "report result"}),
|
||
}
|
||
plan_result = make_plan_result(step_results=step_results)
|
||
|
||
with patch.object(engine._planner, "generate_plan", AsyncMock(return_value=plan)):
|
||
with patch("agentkit.core.plan_exec_engine.PlanExecutor") as MockExecutor:
|
||
mock_executor_instance = MagicMock()
|
||
mock_executor_instance.execute = AsyncMock(return_value=plan_result)
|
||
MockExecutor.return_value = mock_executor_instance
|
||
|
||
result = await engine.execute(
|
||
messages=[{"role": "user", "content": "调研3个竞品并生成报告"}],
|
||
)
|
||
|
||
# 输出应包含所有步骤的结果
|
||
assert "research result" in result.output
|
||
assert "analysis result" in result.output
|
||
assert "report result" in result.output
|
||
|
||
async def test_execute_stream_yields_events(self):
|
||
"""execute_stream() 应 yield 正确的事件序列"""
|
||
engine = PlanExecEngine(llm_gateway=None)
|
||
|
||
plan = make_plan()
|
||
plan_result = make_plan_result()
|
||
|
||
with patch.object(engine._planner, "generate_plan", AsyncMock(return_value=plan)):
|
||
with patch("agentkit.core.plan_exec_engine.PlanExecutor") as MockExecutor:
|
||
mock_executor_instance = MagicMock()
|
||
mock_executor_instance.execute = AsyncMock(return_value=plan_result)
|
||
MockExecutor.return_value = mock_executor_instance
|
||
|
||
events = []
|
||
async for event in engine.execute_stream(
|
||
messages=[{"role": "user", "content": "调研3个竞品并生成报告"}],
|
||
):
|
||
events.append(event)
|
||
|
||
event_types = [e.event_type for e in events]
|
||
assert "planning" in event_types
|
||
assert "plan_generated" in event_types
|
||
assert "step_executing" in event_types
|
||
assert "step_completed" in event_types
|
||
assert "final_answer" in event_types
|
||
|
||
async def test_execute_stream_final_answer_event(self):
|
||
"""final_answer 事件应包含输出和元数据"""
|
||
engine = PlanExecEngine(llm_gateway=None)
|
||
|
||
plan = make_plan()
|
||
plan_result = make_plan_result()
|
||
|
||
with patch.object(engine._planner, "generate_plan", AsyncMock(return_value=plan)):
|
||
with patch("agentkit.core.plan_exec_engine.PlanExecutor") as MockExecutor:
|
||
mock_executor_instance = MagicMock()
|
||
mock_executor_instance.execute = AsyncMock(return_value=plan_result)
|
||
MockExecutor.return_value = mock_executor_instance
|
||
|
||
events = []
|
||
async for event in engine.execute_stream(
|
||
messages=[{"role": "user", "content": "调研3个竞品并生成报告"}],
|
||
):
|
||
events.append(event)
|
||
|
||
final_event = [e for e in events if e.event_type == "final_answer"][0]
|
||
assert "output" in final_event.data
|
||
assert "total_steps" in final_event.data
|
||
assert "total_tokens" in final_event.data
|
||
assert "plan_id" in final_event.data
|
||
|
||
|
||
# ── Test: Replanning ─────────────────────────────────────
|
||
|
||
|
||
class TestReplanning:
|
||
"""测试步骤失败时触发重规划"""
|
||
|
||
async def test_replanning_triggered_on_step_failure(self):
|
||
"""步骤失败时应触发重规划"""
|
||
engine = PlanExecEngine(llm_gateway=None, max_replans=1)
|
||
|
||
plan = make_plan()
|
||
|
||
# 第一次执行:step-1 失败
|
||
failed_step_results = {
|
||
"step-0": make_step_result("step-0"),
|
||
"step-1": make_step_result("step-1", status=PlanStepStatus.FAILED, result=None, error="Agent error"),
|
||
"step-2": make_step_result("step-2", status=PlanStepStatus.SKIPPED, error="Skipped due to dependency"),
|
||
}
|
||
first_result = make_plan_result(step_results=failed_step_results, status=TaskStatus.PARTIALLY_COMPLETED)
|
||
|
||
# 重规划后的第二次执行:全部成功
|
||
second_result = make_plan_result()
|
||
|
||
# Mock GoalPlanner
|
||
with patch.object(engine._planner, "generate_plan", AsyncMock(return_value=plan)):
|
||
# Mock PlanExecutor — 第一次失败,第二次成功
|
||
with patch("agentkit.core.plan_exec_engine.PlanExecutor") as MockExecutor:
|
||
mock_executor_instance = MagicMock()
|
||
mock_executor_instance.execute = AsyncMock(side_effect=[first_result, second_result])
|
||
MockExecutor.return_value = mock_executor_instance
|
||
|
||
# Mock PipelineReflector
|
||
report = make_reflection_report()
|
||
with patch.object(engine._reflector, "reflect", AsyncMock(return_value=report)):
|
||
# Mock PipelineReplanner
|
||
pipeline = engine._plan_to_pipeline(plan, "")
|
||
revised_pipeline = make_revised_pipeline(pipeline)
|
||
with patch.object(engine._replanner, "replan", AsyncMock(return_value=revised_pipeline)):
|
||
result = await engine.execute(
|
||
messages=[{"role": "user", "content": "调研3个竞品并生成报告"}],
|
||
)
|
||
|
||
# 应有重规划步骤
|
||
actions = [s.action for s in result.trajectory]
|
||
assert "replanning" in actions
|
||
# 最终结果应该是成功的(重规划后)
|
||
assert result.status == "success"
|
||
|
||
async def test_replanning_stream_yields_replanning_event(self):
|
||
"""流式执行中重规划应 yield replanning 事件"""
|
||
engine = PlanExecEngine(llm_gateway=None, max_replans=1)
|
||
|
||
plan = make_plan()
|
||
|
||
failed_step_results = {
|
||
"step-0": make_step_result("step-0"),
|
||
"step-1": make_step_result("step-1", status=PlanStepStatus.FAILED, result=None, error="Agent error"),
|
||
"step-2": make_step_result("step-2", status=PlanStepStatus.SKIPPED, error="Skipped"),
|
||
}
|
||
first_result = make_plan_result(step_results=failed_step_results, status=TaskStatus.PARTIALLY_COMPLETED)
|
||
second_result = make_plan_result()
|
||
|
||
with patch.object(engine._planner, "generate_plan", AsyncMock(return_value=plan)):
|
||
with patch("agentkit.core.plan_exec_engine.PlanExecutor") as MockExecutor:
|
||
mock_executor_instance = MagicMock()
|
||
mock_executor_instance.execute = AsyncMock(side_effect=[first_result, second_result])
|
||
MockExecutor.return_value = mock_executor_instance
|
||
|
||
report = make_reflection_report()
|
||
with patch.object(engine._reflector, "reflect", AsyncMock(return_value=report)):
|
||
pipeline = engine._plan_to_pipeline(plan, "")
|
||
revised_pipeline = make_revised_pipeline(pipeline)
|
||
with patch.object(engine._replanner, "replan", AsyncMock(return_value=revised_pipeline)):
|
||
events = []
|
||
async for event in engine.execute_stream(
|
||
messages=[{"role": "user", "content": "调研3个竞品并生成报告"}],
|
||
):
|
||
events.append(event)
|
||
|
||
event_types = [e.event_type for e in events]
|
||
assert "replanning" in event_types
|
||
|
||
async def test_max_replans_exhausted_returns_partial(self):
|
||
"""重规划次数耗尽后应返回部分结果"""
|
||
engine = PlanExecEngine(llm_gateway=None, max_replans=1)
|
||
|
||
plan = make_plan()
|
||
|
||
# 两次执行都失败
|
||
failed_step_results = {
|
||
"step-0": make_step_result("step-0"),
|
||
"step-1": make_step_result("step-1", status=PlanStepStatus.FAILED, result=None, error="Persistent error"),
|
||
"step-2": make_step_result("step-2", status=PlanStepStatus.SKIPPED, error="Skipped"),
|
||
}
|
||
failed_result = make_plan_result(step_results=failed_step_results, status=TaskStatus.PARTIALLY_COMPLETED)
|
||
|
||
with patch.object(engine._planner, "generate_plan", AsyncMock(return_value=plan)):
|
||
with patch("agentkit.core.plan_exec_engine.PlanExecutor") as MockExecutor:
|
||
mock_executor_instance = MagicMock()
|
||
mock_executor_instance.execute = AsyncMock(return_value=failed_result)
|
||
MockExecutor.return_value = mock_executor_instance
|
||
|
||
report = make_reflection_report()
|
||
with patch.object(engine._reflector, "reflect", AsyncMock(return_value=report)):
|
||
pipeline = engine._plan_to_pipeline(plan, "")
|
||
revised_pipeline = make_revised_pipeline(pipeline)
|
||
with patch.object(engine._replanner, "replan", AsyncMock(return_value=revised_pipeline)):
|
||
result = await engine.execute(
|
||
messages=[{"role": "user", "content": "调研3个竞品并生成报告"}],
|
||
)
|
||
|
||
# 应该是 partial 状态
|
||
assert result.status == "partial"
|
||
# 输出应包含失败信息
|
||
assert "failed" in result.output.lower() or "error" in result.output.lower() or "step-0" in result.output
|
||
|
||
async def test_all_steps_failed_returns_error_status(self):
|
||
"""所有步骤失败时应返回 error 状态"""
|
||
engine = PlanExecEngine(llm_gateway=None, max_replans=0)
|
||
|
||
plan = make_plan()
|
||
|
||
all_failed_results = {
|
||
"step-0": make_step_result("step-0", status=PlanStepStatus.FAILED, result=None, error="Error 0"),
|
||
"step-1": make_step_result("step-1", status=PlanStepStatus.SKIPPED, error="Skipped"),
|
||
"step-2": make_step_result("step-2", status=PlanStepStatus.SKIPPED, error="Skipped"),
|
||
}
|
||
failed_result = make_plan_result(step_results=all_failed_results, status=TaskStatus.FAILED)
|
||
|
||
with patch.object(engine._planner, "generate_plan", AsyncMock(return_value=plan)):
|
||
with patch("agentkit.core.plan_exec_engine.PlanExecutor") as MockExecutor:
|
||
mock_executor_instance = MagicMock()
|
||
mock_executor_instance.execute = AsyncMock(return_value=failed_result)
|
||
MockExecutor.return_value = mock_executor_instance
|
||
|
||
result = await engine.execute(
|
||
messages=[{"role": "user", "content": "调研3个竞品并生成报告"}],
|
||
)
|
||
|
||
assert result.status == "error"
|
||
|
||
|
||
# ── Test: Interface Compatibility ─────────────────────────
|
||
|
||
|
||
class TestInterfaceCompatibility:
|
||
"""测试与 ReActEngine 接口兼容性"""
|
||
|
||
async def test_execute_signature_compatible(self):
|
||
"""execute() 签名应与 ReActEngine 一致"""
|
||
import inspect
|
||
from agentkit.core.react import ReActEngine
|
||
|
||
react_sig = inspect.signature(ReActEngine.execute)
|
||
plan_exec_sig = inspect.signature(PlanExecEngine.execute)
|
||
|
||
react_params = list(react_sig.parameters.keys())
|
||
plan_exec_params = list(plan_exec_sig.parameters.keys())
|
||
|
||
assert react_params == plan_exec_params, (
|
||
f"Parameter mismatch: ReActEngine has {react_params}, "
|
||
f"PlanExecEngine has {plan_exec_params}"
|
||
)
|
||
|
||
async def test_execute_stream_signature_compatible(self):
|
||
"""execute_stream() 签名应与 ReActEngine 一致"""
|
||
import inspect
|
||
from agentkit.core.react import ReActEngine
|
||
|
||
react_sig = inspect.signature(ReActEngine.execute_stream)
|
||
plan_exec_sig = inspect.signature(PlanExecEngine.execute_stream)
|
||
|
||
react_params = list(react_sig.parameters.keys())
|
||
plan_exec_params = list(plan_exec_sig.parameters.keys())
|
||
|
||
assert react_params == plan_exec_params, (
|
||
f"Parameter mismatch: ReActEngine has {react_params}, "
|
||
f"PlanExecEngine has {plan_exec_params}"
|
||
)
|
||
|
||
async def test_returns_react_result(self):
|
||
"""execute() 应返回 ReActResult 实例"""
|
||
engine = PlanExecEngine(llm_gateway=None)
|
||
|
||
plan = make_plan()
|
||
plan_result = make_plan_result()
|
||
|
||
with patch.object(engine._planner, "generate_plan", AsyncMock(return_value=plan)):
|
||
with patch("agentkit.core.plan_exec_engine.PlanExecutor") as MockExecutor:
|
||
mock_executor_instance = MagicMock()
|
||
mock_executor_instance.execute = AsyncMock(return_value=plan_result)
|
||
MockExecutor.return_value = mock_executor_instance
|
||
|
||
result = await engine.execute(
|
||
messages=[{"role": "user", "content": "test"}],
|
||
)
|
||
|
||
assert isinstance(result, ReActResult)
|
||
assert hasattr(result, "output")
|
||
assert hasattr(result, "trajectory")
|
||
assert hasattr(result, "total_steps")
|
||
assert hasattr(result, "total_tokens")
|
||
assert hasattr(result, "status")
|
||
|
||
async def test_stream_yields_react_events(self):
|
||
"""execute_stream() 应 yield ReActEvent 实例"""
|
||
engine = PlanExecEngine(llm_gateway=None)
|
||
|
||
plan = make_plan()
|
||
plan_result = make_plan_result()
|
||
|
||
with patch.object(engine._planner, "generate_plan", AsyncMock(return_value=plan)):
|
||
with patch("agentkit.core.plan_exec_engine.PlanExecutor") as MockExecutor:
|
||
mock_executor_instance = MagicMock()
|
||
mock_executor_instance.execute = AsyncMock(return_value=plan_result)
|
||
MockExecutor.return_value = mock_executor_instance
|
||
|
||
async for event in engine.execute_stream(
|
||
messages=[{"role": "user", "content": "test"}],
|
||
):
|
||
assert isinstance(event, ReActEvent)
|
||
assert hasattr(event, "event_type")
|
||
assert hasattr(event, "step")
|
||
assert hasattr(event, "data")
|
||
assert hasattr(event, "timestamp")
|
||
|
||
async def test_trajectory_contains_react_steps(self):
|
||
"""trajectory 中的元素应为 ReActStep 实例"""
|
||
engine = PlanExecEngine(llm_gateway=None)
|
||
|
||
plan = make_plan()
|
||
plan_result = make_plan_result()
|
||
|
||
with patch.object(engine._planner, "generate_plan", AsyncMock(return_value=plan)):
|
||
with patch("agentkit.core.plan_exec_engine.PlanExecutor") as MockExecutor:
|
||
mock_executor_instance = MagicMock()
|
||
mock_executor_instance.execute = AsyncMock(return_value=plan_result)
|
||
MockExecutor.return_value = mock_executor_instance
|
||
|
||
result = await engine.execute(
|
||
messages=[{"role": "user", "content": "test"}],
|
||
)
|
||
|
||
for step in result.trajectory:
|
||
assert isinstance(step, ReActStep)
|
||
|
||
|
||
# ── Test: Cancellation ───────────────────────────────────
|
||
|
||
|
||
class TestCancellationToken:
|
||
"""测试 CancellationToken 取消"""
|
||
|
||
async def test_cancelled_before_planning(self):
|
||
"""在规划前取消应抛出 TaskCancelledError"""
|
||
from agentkit.core.exceptions import TaskCancelledError
|
||
|
||
engine = PlanExecEngine(llm_gateway=None)
|
||
token = CancellationToken()
|
||
token.cancel()
|
||
|
||
with pytest.raises(TaskCancelledError):
|
||
await engine.execute(
|
||
messages=[{"role": "user", "content": "test"}],
|
||
cancellation_token=token,
|
||
)
|
||
|
||
async def test_cancelled_during_execution(self):
|
||
"""在执行过程中取消应抛出 TaskCancelledError"""
|
||
from agentkit.core.exceptions import TaskCancelledError
|
||
|
||
engine = PlanExecEngine(llm_gateway=None)
|
||
token = CancellationToken()
|
||
|
||
plan = make_plan()
|
||
|
||
# 让 generate_plan 正常执行,但在执行循环中取消
|
||
call_count = 0
|
||
|
||
async def mock_generate_plan(*args, **kwargs):
|
||
return plan
|
||
|
||
async def mock_execute(plan_arg, task_msg):
|
||
nonlocal call_count
|
||
call_count += 1
|
||
if call_count == 1:
|
||
# 第一次调用后取消
|
||
token.cancel()
|
||
# 模拟 PlanExecutor 内部在 execute 完成后检查取消
|
||
# 这里返回结果,取消会在下一轮循环检查时生效
|
||
return make_plan_result(step_results={
|
||
"step-0": make_step_result("step-0", status=PlanStepStatus.FAILED, error="fail"),
|
||
"step-1": make_step_result("step-1", status=PlanStepStatus.SKIPPED, error="Skipped"),
|
||
"step-2": make_step_result("step-2", status=PlanStepStatus.SKIPPED, error="Skipped"),
|
||
}, status=TaskStatus.FAILED)
|
||
|
||
with patch.object(engine._planner, "generate_plan", AsyncMock(side_effect=mock_generate_plan)):
|
||
with patch("agentkit.core.plan_exec_engine.PlanExecutor") as MockExecutor:
|
||
mock_executor_instance = MagicMock()
|
||
mock_executor_instance.execute = AsyncMock(side_effect=mock_execute)
|
||
MockExecutor.return_value = mock_executor_instance
|
||
|
||
# 因为取消发生在 replanning 循环的检查点
|
||
with pytest.raises(TaskCancelledError):
|
||
await engine.execute(
|
||
messages=[{"role": "user", "content": "test"}],
|
||
cancellation_token=token,
|
||
)
|
||
|
||
async def test_stream_cancelled(self):
|
||
"""流式执行中取消应抛出 TaskCancelledError"""
|
||
from agentkit.core.exceptions import TaskCancelledError
|
||
|
||
engine = PlanExecEngine(llm_gateway=None)
|
||
token = CancellationToken()
|
||
token.cancel()
|
||
|
||
with pytest.raises(TaskCancelledError):
|
||
async for _ in engine.execute_stream(
|
||
messages=[{"role": "user", "content": "test"}],
|
||
cancellation_token=token,
|
||
):
|
||
pass
|
||
|
||
|
||
# ── Test: Timeout ────────────────────────────────────────
|
||
|
||
|
||
class TestTimeout:
|
||
"""测试超时处理"""
|
||
|
||
async def test_timeout_raises_task_timeout_error(self):
|
||
"""超时应抛出 TaskTimeoutError"""
|
||
from agentkit.core.exceptions import TaskTimeoutError
|
||
|
||
engine = PlanExecEngine(llm_gateway=None)
|
||
|
||
plan = make_plan()
|
||
|
||
async def slow_generate_plan(*args, **kwargs):
|
||
await asyncio.sleep(10) # 模拟慢速规划
|
||
return plan
|
||
|
||
with patch.object(engine._planner, "generate_plan", AsyncMock(side_effect=slow_generate_plan)):
|
||
with pytest.raises(TaskTimeoutError):
|
||
await engine.execute(
|
||
messages=[{"role": "user", "content": "test"}],
|
||
timeout_seconds=0.1,
|
||
)
|
||
|
||
|
||
# ── Test: Helper Methods ────────────────────────────────
|
||
|
||
|
||
class TestHelperMethods:
|
||
"""测试辅助方法"""
|
||
|
||
def test_extract_goal(self):
|
||
"""应从消息中提取用户目标"""
|
||
messages = [
|
||
{"role": "system", "content": "You are a helpful assistant"},
|
||
{"role": "user", "content": "调研3个竞品"},
|
||
]
|
||
goal = PlanExecEngine._extract_goal(messages)
|
||
assert goal == "调研3个竞品"
|
||
|
||
def test_extract_goal_empty_messages(self):
|
||
"""空消息应返回空字符串"""
|
||
assert PlanExecEngine._extract_goal([]) == ""
|
||
|
||
def test_extract_skill_names(self):
|
||
"""应从工具列表中提取名称"""
|
||
from agentkit.tools.base import Tool
|
||
|
||
class FakeTool(Tool):
|
||
async def execute(self, **kwargs):
|
||
return {}
|
||
|
||
tools = [FakeTool(name="search", description="search tool"), FakeTool(name="analyze", description="analyze tool")]
|
||
names = PlanExecEngine._extract_skill_names(tools)
|
||
assert names == ["search", "analyze"]
|
||
|
||
def test_extract_skill_names_none(self):
|
||
"""None 工具列表应返回空列表"""
|
||
assert PlanExecEngine._extract_skill_names(None) == []
|
||
|
||
def test_aggregate_output_completed(self):
|
||
"""成功步骤应聚合到输出"""
|
||
plan = make_plan()
|
||
plan_result = make_plan_result()
|
||
output = PlanExecEngine._aggregate_output(plan, plan_result)
|
||
assert "Step 0" in output
|
||
assert "Step 1" in output
|
||
assert "Step 2" in output
|
||
|
||
def test_aggregate_output_all_failed(self):
|
||
"""全部失败应返回失败信息"""
|
||
plan = make_plan()
|
||
step_results = {
|
||
"step-0": make_step_result("step-0", status=PlanStepStatus.FAILED, error="Error 0"),
|
||
"step-1": make_step_result("step-1", status=PlanStepStatus.SKIPPED, error="Skipped"),
|
||
"step-2": make_step_result("step-2", status=PlanStepStatus.SKIPPED, error="Skipped"),
|
||
}
|
||
plan_result = make_plan_result(step_results=step_results, status=TaskStatus.FAILED)
|
||
output = PlanExecEngine._aggregate_output(plan, plan_result)
|
||
assert "failed" in output.lower()
|
||
|
||
def test_plan_to_pipeline_conversion(self):
|
||
"""ExecutionPlan 应正确转换为 Pipeline"""
|
||
plan = make_plan()
|
||
pipeline = PlanExecEngine._plan_to_pipeline(plan, "test_agent")
|
||
|
||
assert pipeline.name.startswith("plan_")
|
||
assert len(pipeline.stages) == 3
|
||
assert pipeline.stages[0].name == "step-0"
|
||
assert pipeline.stages[1].depends_on == ["step-0"]
|
||
|
||
def test_pipeline_to_plan_conversion(self):
|
||
"""Pipeline 应正确转回 ExecutionPlan"""
|
||
plan = make_plan()
|
||
pipeline = PlanExecEngine._plan_to_pipeline(plan, "test_agent")
|
||
converted = PlanExecEngine._pipeline_to_plan(pipeline, plan.goal)
|
||
|
||
assert converted.goal == plan.goal
|
||
assert len(converted.steps) == 3
|
||
|
||
def test_merge_completed_results(self):
|
||
"""已完成步骤结果应合并到新计划"""
|
||
plan = make_plan()
|
||
plan_result = make_plan_result(step_results={
|
||
"step-0": make_step_result("step-0", result={"data": "done"}),
|
||
"step-1": make_step_result("step-1", status=PlanStepStatus.FAILED, error="fail"),
|
||
"step-2": make_step_result("step-2", status=PlanStepStatus.SKIPPED, error="skip"),
|
||
})
|
||
|
||
PlanExecEngine._merge_completed_results(plan, plan_result)
|
||
|
||
assert plan.get_step("step-0").status == PlanStepStatus.COMPLETED
|
||
assert plan.get_step("step-0").result == {"data": "done"}
|
||
assert plan.get_step("step-2").status == PlanStepStatus.SKIPPED
|