fischer-agentkit/tests/unit/test_rewoo_engine.py

1148 lines
43 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""ReWOO Engine 单元测试"""
import json
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from agentkit.llm.gateway import LLMGateway
from agentkit.llm.protocol import LLMResponse, TokenUsage, ToolCall
from agentkit.tools.base import Tool
# ── Test Helpers ──────────────────────────────────────────
class FakeTool(Tool):
"""用于测试的 Fake Tool"""
def __init__(
self,
name: str = "fake_tool",
description: str = "A fake tool for testing",
input_schema: dict | None = None,
result: dict | None = None,
should_fail: bool = False,
):
super().__init__(
name=name,
description=description,
input_schema=input_schema,
)
self._result = result or {"status": "ok"}
self._should_fail = should_fail
self.call_count = 0
self.last_kwargs: dict | None = None
async def execute(self, **kwargs) -> dict:
self.call_count += 1
self.last_kwargs = kwargs
if self._should_fail:
raise RuntimeError(f"Tool '{self.name}' execution failed")
return self._result
def make_mock_gateway(responses: list[LLMResponse]) -> LLMGateway:
"""创建一个 mock LLMGateway按顺序返回给定响应"""
gateway = MagicMock(spec=LLMGateway)
gateway.chat = AsyncMock(side_effect=responses)
return gateway
def make_response(
content: str = "",
tool_calls: list[ToolCall] | None = None,
prompt_tokens: int = 10,
completion_tokens: int = 20,
) -> LLMResponse:
"""快速构造 LLMResponse"""
return LLMResponse(
content=content,
model="test-model",
usage=TokenUsage(
prompt_tokens=prompt_tokens,
completion_tokens=completion_tokens,
),
tool_calls=tool_calls or [],
)
def make_plan_response(
steps: list[dict],
reasoning: str = "Plan reasoning",
prompt_tokens: int = 50,
completion_tokens: int = 100,
) -> LLMResponse:
"""构造包含执行计划的 LLMResponse"""
plan_json = json.dumps({
"reasoning": reasoning,
"steps": steps,
})
return make_response(
content=plan_json,
prompt_tokens=prompt_tokens,
completion_tokens=completion_tokens,
)
# ── Test: Single-step Plan ────────────────────────────────
class TestReWOOSingleStepPlan:
"""单步计划:规划 1 个工具调用,执行后综合"""
async def test_single_tool_call_plan(self):
from agentkit.core.rewoo import ReWOOEngine
from agentkit.core.react import ReActResult
tool = FakeTool(name="calculator", result={"value": 42})
# Phase 1: Planning response
plan_response = make_plan_response([
{"step_id": 1, "tool_name": "calculator", "arguments": {"expr": "6*7"}, "reasoning": "Need to calculate"},
])
# Phase 3: Synthesis response
synthesis_response = make_response(content="The result is 42")
gateway = make_mock_gateway([plan_response, synthesis_response])
engine = ReWOOEngine(llm_gateway=gateway)
result = await engine.execute(
messages=[{"role": "user", "content": "Calculate 6*7"}],
tools=[tool],
)
assert isinstance(result, ReActResult)
assert result.output == "The result is 42"
# trajectory: 1 tool_call + 1 final_answer = 2 steps
assert result.total_steps == 2
assert len(result.trajectory) == 2
assert result.trajectory[0].action == "tool_call"
assert result.trajectory[0].tool_name == "calculator"
assert result.trajectory[0].arguments == {"expr": "6*7"}
assert result.trajectory[0].result == {"value": 42}
assert result.trajectory[1].action == "final_answer"
assert result.trajectory[1].content == "The result is 42"
assert tool.call_count == 1
# ── Test: Multi-step Plan ─────────────────────────────────
class TestReWOOMultiStepPlan:
"""多步计划:规划 3 个工具调用,全部执行后综合"""
async def test_three_step_plan(self):
from agentkit.core.rewoo import ReWOOEngine
search_tool = FakeTool(name="search", result={"results": ["Python is great"]})
calc_tool = FakeTool(name="calculator", result={"value": 100})
weather_tool = FakeTool(name="weather", result={"temp": 25, "city": "Shanghai"})
plan_response = make_plan_response([
{"step_id": 1, "tool_name": "search", "arguments": {"query": "Python"}, "reasoning": "Search first"},
{"step_id": 2, "tool_name": "calculator", "arguments": {"expr": "10*10"}, "reasoning": "Calculate"},
{"step_id": 3, "tool_name": "weather", "arguments": {"city": "Shanghai"}, "reasoning": "Check weather"},
])
synthesis_response = make_response(content="Based on search, calculation (100), and weather (25°C), here is the answer")
gateway = make_mock_gateway([plan_response, synthesis_response])
engine = ReWOOEngine(llm_gateway=gateway)
result = await engine.execute(
messages=[{"role": "user", "content": "Search, calculate and check weather"}],
tools=[search_tool, calc_tool, weather_tool],
)
# 3 tool_calls + 1 final_answer = 4 steps
assert result.total_steps == 4
assert result.trajectory[0].tool_name == "search"
assert result.trajectory[1].tool_name == "calculator"
assert result.trajectory[2].tool_name == "weather"
assert result.trajectory[3].action == "final_answer"
assert search_tool.call_count == 1
assert calc_tool.call_count == 1
assert weather_tool.call_count == 1
assert "100" in result.output
assert "25" in result.output
async def test_plan_step_ids_preserved(self):
from agentkit.core.rewoo import ReWOOEngine, ReWOOStep
tool = FakeTool(name="tool_a", result={"a": 1})
plan_response = make_plan_response([
{"step_id": 1, "tool_name": "tool_a", "arguments": {"x": 1}, "reasoning": "Step 1"},
{"step_id": 2, "tool_name": "tool_a", "arguments": {"x": 2}, "reasoning": "Step 2"},
])
synthesis_response = make_response(content="Done")
gateway = make_mock_gateway([plan_response, synthesis_response])
engine = ReWOOEngine(llm_gateway=gateway)
result = await engine.execute(
messages=[{"role": "user", "content": "Do two things"}],
tools=[tool],
)
# Check ReWOOStep has plan_step_id
assert isinstance(result.trajectory[0], ReWOOStep)
assert result.trajectory[0].plan_step_id == 1
assert isinstance(result.trajectory[1], ReWOOStep)
assert result.trajectory[1].plan_step_id == 2
# ── Test: Tool Call Failure ───────────────────────────────
class TestReWOOToolCallFailure:
"""工具调用失败:一个工具失败,其余继续执行"""
async def test_one_tool_fails_others_continue(self):
from agentkit.core.rewoo import ReWOOEngine
good_tool = FakeTool(name="good_tool", result={"status": "ok"})
bad_tool = FakeTool(name="bad_tool", should_fail=True)
another_tool = FakeTool(name="another_tool", result={"data": "hello"})
plan_response = make_plan_response([
{"step_id": 1, "tool_name": "good_tool", "arguments": {}, "reasoning": "Call good tool"},
{"step_id": 2, "tool_name": "bad_tool", "arguments": {}, "reasoning": "Call bad tool"},
{"step_id": 3, "tool_name": "another_tool", "arguments": {}, "reasoning": "Call another tool"},
])
synthesis_response = make_response(content="Partial results with one error")
gateway = make_mock_gateway([plan_response, synthesis_response])
engine = ReWOOEngine(llm_gateway=gateway)
result = await engine.execute(
messages=[{"role": "user", "content": "Use all tools"}],
tools=[good_tool, bad_tool, another_tool],
)
# All 3 tools should have been attempted
assert good_tool.call_count == 1
assert bad_tool.call_count == 1
assert another_tool.call_count == 1
# Step 2 should have error result
assert result.trajectory[1].tool_name == "bad_tool"
assert "error" in str(result.trajectory[1].result).lower() or "failed" in str(result.trajectory[1].result).lower()
# Step 3 should still succeed
assert result.trajectory[2].tool_name == "another_tool"
assert result.trajectory[2].result == {"data": "hello"}
# Final answer should still be generated
assert result.trajectory[3].action == "final_answer"
assert result.output == "Partial results with one error"
async def test_tool_not_found_returns_error(self):
from agentkit.core.rewoo import ReWOOEngine
plan_response = make_plan_response([
{"step_id": 1, "tool_name": "nonexistent_tool", "arguments": {}, "reasoning": "Call missing tool"},
])
synthesis_response = make_response(content="Tool was not found, but here is my answer")
gateway = make_mock_gateway([plan_response, synthesis_response])
engine = ReWOOEngine(llm_gateway=gateway)
result = await engine.execute(
messages=[{"role": "user", "content": "Use missing tool"}],
tools=[], # Empty tools list
)
assert result.trajectory[0].action == "tool_call"
assert "error" in str(result.trajectory[0].result).lower() or "not found" in str(result.trajectory[0].result).lower()
assert result.output == "Tool was not found, but here is my answer"
# ── Test: Planning Failure Fallback ───────────────────────
class TestReWOOPlanningFailureFallback:
"""规划失败LLM 未返回有效 JSON 时回退到 ReActEngine"""
async def test_invalid_json_falls_back_to_react(self):
from agentkit.core.rewoo import ReWOOEngine
# Planning returns invalid JSON, simplified planning also fails
invalid_plan_response = make_response(content="I cannot create a plan for this task.")
simplified_fail_response = make_response(content="Still cannot create a plan")
# ReAct fallback responses
react_tool_response = make_response(
content="",
tool_calls=[ToolCall(id="tc_1", name="search", arguments={"query": "test"})],
)
react_final_response = make_response(content="ReAct fallback answer")
gateway = make_mock_gateway([
invalid_plan_response,
simplified_fail_response,
react_tool_response,
react_final_response,
])
engine = ReWOOEngine(llm_gateway=gateway)
tool = FakeTool(name="search", result={"results": ["found"]})
result = await engine.execute(
messages=[{"role": "user", "content": "Complex task"}],
tools=[tool],
)
# Should have fallen back to ReAct and produced a result
assert result.output == "ReAct fallback answer"
assert result.total_steps >= 1
assert result.fallback_strategy == "react"
async def test_malformed_json_falls_back_to_react(self):
from agentkit.core.rewoo import ReWOOEngine
# Planning returns malformed JSON, simplified planning also fails, ReAct succeeds
malformed_response = make_response(content='{"reasoning": "plan", "steps": [invalid json')
simplified_fail_response = make_response(content='Also not a plan')
react_response = make_response(content="ReAct answer")
gateway = make_mock_gateway([malformed_response, simplified_fail_response, react_response])
engine = ReWOOEngine(llm_gateway=gateway)
result = await engine.execute(
messages=[{"role": "user", "content": "Task"}],
)
assert result.output == "ReAct answer"
assert result.fallback_strategy == "react"
async def test_missing_steps_key_falls_back_to_react(self):
from agentkit.core.rewoo import ReWOOEngine
# JSON without "steps" key, simplified planning also fails, ReAct succeeds
no_steps_response = make_response(content='{"reasoning": "no steps here"}')
simplified_fail_response = make_response(content='Also no steps')
react_response = make_response(content="ReAct fallback")
gateway = make_mock_gateway([no_steps_response, simplified_fail_response, react_response])
engine = ReWOOEngine(llm_gateway=gateway)
result = await engine.execute(
messages=[{"role": "user", "content": "Task"}],
)
assert result.output == "ReAct fallback"
assert result.fallback_strategy == "react"
# ── Test: Cancellation Token ──────────────────────────────
class TestReWOOCancellation:
"""ReWOO 取消令牌测试"""
async def test_cancel_before_execution_raises_error(self):
from agentkit.core.rewoo import ReWOOEngine
from agentkit.core.protocol import CancellationToken
from agentkit.core.exceptions import TaskCancelledError
gateway = make_mock_gateway([make_response(content="plan")])
engine = ReWOOEngine(llm_gateway=gateway)
token = CancellationToken()
token.cancel()
with pytest.raises(TaskCancelledError):
await engine.execute(
messages=[{"role": "user", "content": "Task"}],
cancellation_token=token,
)
async def test_cancel_mid_execution(self):
from agentkit.core.rewoo import ReWOOEngine
from agentkit.core.protocol import CancellationToken
from agentkit.core.exceptions import TaskCancelledError
token = CancellationToken()
call_count = 0
tool = FakeTool(name="tool_a", result={"a": 1})
async def chat_with_cancel(**kwargs):
nonlocal call_count
call_count += 1
# First call is planning, cancel after it
if call_count >= 1:
token.cancel()
# Return a plan with multiple steps
return make_plan_response([
{"step_id": 1, "tool_name": "tool_a", "arguments": {"x": 1}, "reasoning": "Step 1"},
{"step_id": 2, "tool_name": "tool_a", "arguments": {"x": 2}, "reasoning": "Step 2"},
])
gateway = MagicMock(spec=LLMGateway)
gateway.chat = AsyncMock(side_effect=chat_with_cancel)
engine = ReWOOEngine(llm_gateway=gateway)
with pytest.raises(TaskCancelledError):
await engine.execute(
messages=[{"role": "user", "content": "Task"}],
tools=[tool],
cancellation_token=token,
)
async def test_uncancelled_token_works_normally(self):
from agentkit.core.rewoo import ReWOOEngine
from agentkit.core.protocol import CancellationToken
plan_response = make_plan_response([
{"step_id": 1, "tool_name": "search", "arguments": {"q": "test"}, "reasoning": "Search"},
])
synthesis_response = make_response(content="Answer")
gateway = make_mock_gateway([plan_response, synthesis_response])
engine = ReWOOEngine(llm_gateway=gateway)
tool = FakeTool(name="search", result={"results": ["found"]})
token = CancellationToken() # Not cancelled
result = await engine.execute(
messages=[{"role": "user", "content": "Task"}],
tools=[tool],
cancellation_token=token,
)
assert result.output == "Answer"
assert result.status == "success"
# ── Test: Timeout ─────────────────────────────────────────
class TestReWOOTimeout:
"""ReWOO 超时测试"""
async def test_timeout_raises_task_timeout_error(self):
import asyncio
from agentkit.core.rewoo import ReWOOEngine
from agentkit.core.exceptions import TaskTimeoutError
async def slow_chat(**kwargs):
await asyncio.sleep(0.5)
return make_response(content="slow")
gateway = MagicMock(spec=LLMGateway)
gateway.chat = AsyncMock(side_effect=slow_chat)
engine = ReWOOEngine(llm_gateway=gateway)
with pytest.raises(TaskTimeoutError):
await engine.execute(
messages=[{"role": "user", "content": "Slow task"}],
timeout_seconds=0.3,
)
async def test_timeout_zero_means_no_timeout(self):
import asyncio
from agentkit.core.rewoo import ReWOOEngine
async def slightly_slow_chat(**kwargs):
await asyncio.sleep(0.1)
return make_response(content="done")
gateway = MagicMock(spec=LLMGateway)
gateway.chat = AsyncMock(side_effect=slightly_slow_chat)
engine = ReWOOEngine(llm_gateway=gateway)
result = await engine.execute(
messages=[{"role": "user", "content": "Task"}],
timeout_seconds=0,
)
assert result.output == "done"
# ── Test: Interface Compatibility ─────────────────────────
class TestReWOOInterfaceCompatibility:
"""ReWOOEngine 与 ReActEngine 接口兼容性"""
async def test_same_return_type(self):
from agentkit.core.rewoo import ReWOOEngine
from agentkit.core.react import ReActResult
plan_response = make_plan_response([
{"step_id": 1, "tool_name": "tool_a", "arguments": {}, "reasoning": "Step"},
])
synthesis_response = make_response(content="Answer")
gateway = make_mock_gateway([plan_response, synthesis_response])
engine = ReWOOEngine(llm_gateway=gateway)
tool = FakeTool(name="tool_a", result={"a": 1})
result = await engine.execute(
messages=[{"role": "user", "content": "Task"}],
tools=[tool],
)
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_same_execute_signature(self):
"""验证 execute 方法签名与 ReActEngine 兼容"""
import inspect
from agentkit.core.rewoo import ReWOOEngine
from agentkit.core.react import ReActEngine
rewoo_sig = inspect.signature(ReWOOEngine.execute)
react_sig = inspect.signature(ReActEngine.execute)
rewoo_params = list(rewoo_sig.parameters.keys())
react_params = list(react_sig.parameters.keys())
assert rewoo_params == react_params, f"Parameter mismatch: ReWOO={rewoo_params}, ReAct={react_params}"
async def test_trajectory_uses_react_step(self):
"""验证 trajectory 中的步骤兼容 ReActStep"""
from agentkit.core.rewoo import ReWOOEngine, ReWOOStep
from agentkit.core.react import ReActStep
plan_response = make_plan_response([
{"step_id": 1, "tool_name": "tool_a", "arguments": {"x": 1}, "reasoning": "Step"},
])
synthesis_response = make_response(content="Done")
gateway = make_mock_gateway([plan_response, synthesis_response])
engine = ReWOOEngine(llm_gateway=gateway)
tool = FakeTool(name="tool_a", result={"a": 1})
result = await engine.execute(
messages=[{"role": "user", "content": "Task"}],
tools=[tool],
)
# ReWOOStep should be a subclass of ReActStep
for step in result.trajectory:
assert isinstance(step, ReActStep), f"Step {step} is not a ReActStep"
# Tool call steps should be ReWOOStep with plan_step_id
tool_steps = [s for s in result.trajectory if s.action == "tool_call"]
for step in tool_steps:
assert isinstance(step, ReWOOStep)
assert step.plan_step_id is not None
async def test_status_field_present(self):
from agentkit.core.rewoo import ReWOOEngine
plan_response = make_plan_response([
{"step_id": 1, "tool_name": "tool_a", "arguments": {}, "reasoning": "Step"},
])
synthesis_response = make_response(content="Answer")
gateway = make_mock_gateway([plan_response, synthesis_response])
engine = ReWOOEngine(llm_gateway=gateway)
tool = FakeTool(name="tool_a", result={"a": 1})
result = await engine.execute(
messages=[{"role": "user", "content": "Task"}],
tools=[tool],
)
assert result.status == "success"
# ── Test: Empty Plan (No Tools Needed) ────────────────────
class TestReWOOEmptyPlan:
"""空计划LLM 判断无需工具,直接回答"""
async def test_empty_plan_direct_answer(self):
from agentkit.core.rewoo import ReWOOEngine
# Plan with empty steps
plan_response = make_plan_response([], reasoning="No tools needed")
direct_response = make_response(content="Direct answer without tools")
gateway = make_mock_gateway([plan_response, direct_response])
engine = ReWOOEngine(llm_gateway=gateway)
result = await engine.execute(
messages=[{"role": "user", "content": "Simple question"}],
)
assert result.output == "Direct answer without tools"
assert result.total_steps == 1
assert result.trajectory[0].action == "final_answer"
# ── Test: Token Accumulation ──────────────────────────────
class TestReWOOTokenAccumulation:
"""Token 累积测试"""
async def test_total_tokens_accumulated(self):
from agentkit.core.rewoo import ReWOOEngine
plan_response = make_plan_response(
steps=[{"step_id": 1, "tool_name": "tool_a", "arguments": {}, "reasoning": "Step"}],
prompt_tokens=100,
completion_tokens=50,
)
synthesis_response = make_response(
content="Answer",
prompt_tokens=200,
completion_tokens=30,
)
gateway = make_mock_gateway([plan_response, synthesis_response])
engine = ReWOOEngine(llm_gateway=gateway)
tool = FakeTool(name="tool_a", result={"a": 1})
result = await engine.execute(
messages=[{"role": "user", "content": "Task"}],
tools=[tool],
)
# 100+50 + 200+30 = 380
assert result.total_tokens == 380
# ── Test: Streaming ───────────────────────────────────────
class TestReWOOStreaming:
"""ReWOO 流式执行测试"""
async def test_stream_yields_correct_events(self):
from agentkit.core.rewoo import ReWOOEngine
from agentkit.core.react import ReActEvent
plan_response = make_plan_response([
{"step_id": 1, "tool_name": "tool_a", "arguments": {"x": 1}, "reasoning": "Step 1"},
])
synthesis_response = make_response(content="Final answer")
gateway = make_mock_gateway([plan_response, synthesis_response])
engine = ReWOOEngine(llm_gateway=gateway)
tool = FakeTool(name="tool_a", result={"a": 1})
events = []
async for event in engine.execute_stream(
messages=[{"role": "user", "content": "Task"}],
tools=[tool],
):
events.append(event)
event_types = [e.event_type for e in events]
assert "planning" in event_types
assert "plan_generated" in event_types
assert "tool_call" in event_types
assert "tool_result" in event_types
assert "synthesis" in event_types
assert "final_answer" in event_types
# Verify event order
planning_idx = event_types.index("planning")
plan_gen_idx = event_types.index("plan_generated")
tool_call_idx = event_types.index("tool_call")
tool_result_idx = event_types.index("tool_result")
synthesis_idx = event_types.index("synthesis")
final_idx = event_types.index("final_answer")
assert planning_idx < plan_gen_idx < tool_call_idx < tool_result_idx < synthesis_idx < final_idx
async def test_stream_plan_generated_event_data(self):
from agentkit.core.rewoo import ReWOOEngine
from agentkit.core.react import ReActEvent
plan_response = make_plan_response([
{"step_id": 1, "tool_name": "tool_a", "arguments": {"x": 1}, "reasoning": "Step 1"},
{"step_id": 2, "tool_name": "tool_b", "arguments": {"y": 2}, "reasoning": "Step 2"},
])
synthesis_response = make_response(content="Done")
gateway = make_mock_gateway([plan_response, synthesis_response])
engine = ReWOOEngine(llm_gateway=gateway)
tool_a = FakeTool(name="tool_a", result={"a": 1})
tool_b = FakeTool(name="tool_b", result={"b": 2})
events = []
async for event in engine.execute_stream(
messages=[{"role": "user", "content": "Task"}],
tools=[tool_a, tool_b],
):
events.append(event)
plan_event = next(e for e in events if e.event_type == "plan_generated")
assert "steps" in plan_event.data
assert len(plan_event.data["steps"]) == 2
assert plan_event.data["steps"][0]["tool_name"] == "tool_a"
assert plan_event.data["steps"][1]["tool_name"] == "tool_b"
async def test_stream_final_answer_event_data(self):
from agentkit.core.rewoo import ReWOOEngine
plan_response = make_plan_response([
{"step_id": 1, "tool_name": "tool_a", "arguments": {}, "reasoning": "Step"},
])
synthesis_response = make_response(content="Final answer")
gateway = make_mock_gateway([plan_response, synthesis_response])
engine = ReWOOEngine(llm_gateway=gateway)
tool = FakeTool(name="tool_a", result={"a": 1})
events = []
async for event in engine.execute_stream(
messages=[{"role": "user", "content": "Task"}],
tools=[tool],
):
events.append(event)
final_event = next(e for e in events if e.event_type == "final_answer")
assert final_event.data["output"] == "Final answer"
assert "total_steps" in final_event.data
assert "total_tokens" in final_event.data
async def test_stream_planning_failure_falls_back(self):
from agentkit.core.rewoo import ReWOOEngine
# Invalid plan, simplified also fails, then ReAct fallback
invalid_plan = make_response(content="Not a plan")
simplified_fail = make_response(content="Still not a plan")
react_response = make_response(content="ReAct answer")
gateway = make_mock_gateway([invalid_plan, simplified_fail, react_response])
engine = ReWOOEngine(llm_gateway=gateway)
events = []
async for event in engine.execute_stream(
messages=[{"role": "user", "content": "Task"}],
):
events.append(event)
# Should have events from ReAct fallback
event_types = [e.event_type for e in events]
assert "planning" in event_types # ReWOO planning started
# After fallback, ReAct events should appear
assert "final_answer" in event_types
# ── Test: Plan Parsing ────────────────────────────────────
class TestReWOOPlanParsing:
"""计划解析测试"""
def test_parse_valid_json(self):
from agentkit.core.rewoo import ReWOOEngine
engine = ReWOOEngine(llm_gateway=MagicMock(spec=LLMGateway))
content = json.dumps({
"reasoning": "Need to search and calculate",
"steps": [
{"step_id": 1, "tool_name": "search", "arguments": {"q": "test"}, "reasoning": "Search"},
{"step_id": 2, "tool_name": "calc", "arguments": {"expr": "1+1"}, "reasoning": "Calculate"},
],
})
plan = engine._parse_plan(content)
assert plan is not None
assert plan.reasoning == "Need to search and calculate"
assert len(plan.steps) == 2
assert plan.steps[0].tool_name == "search"
assert plan.steps[1].tool_name == "calc"
def test_parse_json_in_code_block(self):
from agentkit.core.rewoo import ReWOOEngine
engine = ReWOOEngine(llm_gateway=MagicMock(spec=LLMGateway))
content = '```json\n{"reasoning": "Plan", "steps": [{"step_id": 1, "tool_name": "search", "arguments": {}, "reasoning": "Search"}]}\n```'
plan = engine._parse_plan(content)
assert plan is not None
assert len(plan.steps) == 1
def test_parse_json_with_surrounding_text(self):
from agentkit.core.rewoo import ReWOOEngine
engine = ReWOOEngine(llm_gateway=MagicMock(spec=LLMGateway))
content = 'Here is my plan:\n{"reasoning": "Plan", "steps": [{"step_id": 1, "tool_name": "search", "arguments": {}, "reasoning": "Search"}]}\nThat should work!'
plan = engine._parse_plan(content)
assert plan is not None
assert len(plan.steps) == 1
def test_parse_invalid_json_returns_none(self):
from agentkit.core.rewoo import ReWOOEngine
engine = ReWOOEngine(llm_gateway=MagicMock(spec=LLMGateway))
plan = engine._parse_plan("This is not JSON at all")
assert plan is None
def test_parse_missing_steps_returns_none(self):
from agentkit.core.rewoo import ReWOOEngine
engine = ReWOOEngine(llm_gateway=MagicMock(spec=LLMGateway))
plan = engine._parse_plan('{"reasoning": "No steps"}')
assert plan is None
def test_parse_steps_without_tool_name_skipped(self):
from agentkit.core.rewoo import ReWOOEngine
engine = ReWOOEngine(llm_gateway=MagicMock(spec=LLMGateway))
content = json.dumps({
"reasoning": "Plan",
"steps": [
{"step_id": 1, "arguments": {}, "reasoning": "No tool name"},
{"step_id": 2, "tool_name": "search", "arguments": {}, "reasoning": "Has tool name"},
],
})
plan = engine._parse_plan(content)
assert plan is not None
assert len(plan.steps) == 1
assert plan.steps[0].tool_name == "search"
# ── Test: Max Plan Steps ──────────────────────────────────
class TestReWOOMaxPlanSteps:
"""最大计划步数限制"""
async def test_plan_truncated_to_max_steps(self):
from agentkit.core.rewoo import ReWOOEngine
# Create a plan with 5 steps, but max_plan_steps=2
plan_steps = [
{"step_id": i, "tool_name": "tool_a", "arguments": {"x": i}, "reasoning": f"Step {i}"}
for i in range(1, 6)
]
plan_response = make_plan_response(plan_steps)
synthesis_response = make_response(content="Done")
gateway = make_mock_gateway([plan_response, synthesis_response])
engine = ReWOOEngine(llm_gateway=gateway, max_plan_steps=2)
tool = FakeTool(name="tool_a", result={"a": 1})
result = await engine.execute(
messages=[{"role": "user", "content": "Task"}],
tools=[tool],
)
# Only 2 tool calls should be executed (truncated from 5)
tool_call_steps = [s for s in result.trajectory if s.action == "tool_call"]
assert len(tool_call_steps) == 2
async def test_max_plan_steps_validation(self):
from agentkit.core.rewoo import ReWOOEngine
with pytest.raises(ValueError, match="max_plan_steps must be >= 1"):
ReWOOEngine(llm_gateway=MagicMock(spec=LLMGateway), max_plan_steps=0)
# Need to import ReActResult for type checking in tests
from agentkit.core.react import ReActResult
# ── Test: Progressive Fallback Chain ──────────────────────
class TestReWOOProgressiveFallback:
"""渐进式回退链planning → simplified_rewoo → react → direct"""
async def test_normal_planning_succeeds_no_fallback(self):
"""正常规划成功,不使用回退"""
from agentkit.core.rewoo import ReWOOEngine
tool = FakeTool(name="calculator", result={"value": 42})
plan_response = make_plan_response([
{"step_id": 1, "tool_name": "calculator", "arguments": {"expr": "6*7"}, "reasoning": "Calculate"},
])
synthesis_response = make_response(content="The result is 42")
gateway = make_mock_gateway([plan_response, synthesis_response])
engine = ReWOOEngine(llm_gateway=gateway)
result = await engine.execute(
messages=[{"role": "user", "content": "Calculate 6*7"}],
tools=[tool],
)
assert result.output == "The result is 42"
assert result.fallback_strategy is None
async def test_planning_fails_simplified_succeeds(self):
"""规划失败,简化规划成功 → fallback_strategy="simplified_rewoo" """
from agentkit.core.rewoo import ReWOOEngine
tool = FakeTool(name="search", result={"results": ["found"]})
# First plan fails (invalid JSON), second (simplified) succeeds
invalid_plan_response = make_response(content="I cannot create a plan for this task.")
simplified_plan_response = make_plan_response([
{"step_id": 1, "tool_name": "search", "arguments": {"query": "test"}, "reasoning": "Simplified search"},
])
synthesis_response = make_response(content="Simplified result")
gateway = make_mock_gateway([invalid_plan_response, simplified_plan_response, synthesis_response])
engine = ReWOOEngine(llm_gateway=gateway)
result = await engine.execute(
messages=[{"role": "user", "content": "Complex task"}],
tools=[tool],
)
assert result.output == "Simplified result"
assert result.fallback_strategy == "simplified_rewoo"
async def test_planning_and_simplified_fail_react_succeeds(self):
"""规划和简化规划都失败ReAct 回退成功 → fallback_strategy="react" """
from agentkit.core.rewoo import ReWOOEngine
# Both plan attempts fail (invalid JSON), ReAct succeeds
invalid_plan1 = make_response(content="Not a plan")
invalid_plan2 = make_response(content="Still not a plan")
react_response = make_response(content="ReAct fallback answer")
gateway = make_mock_gateway([invalid_plan1, invalid_plan2, react_response])
engine = ReWOOEngine(llm_gateway=gateway)
result = await engine.execute(
messages=[{"role": "user", "content": "Complex task"}],
)
assert result.output == "ReAct fallback answer"
assert result.fallback_strategy == "react"
async def test_all_fail_direct_fallback(self):
"""规划、简化规划、ReAct 全部失败 → fallback_strategy="direct" """
from agentkit.core.rewoo import ReWOOEngine
# Both plan attempts fail
invalid_plan1 = make_response(content="Not a plan")
invalid_plan2 = make_response(content="Still not a plan")
# Make ReAct engine fail by having its LLM call raise an exception
call_count = 0
async def chat_side_effect(**kwargs):
nonlocal call_count
call_count += 1
if call_count <= 2:
# First two calls are for planning (both fail to parse)
return make_response(content="Not a plan")
if call_count == 3:
# ReAct engine call - raise exception
raise RuntimeError("ReAct engine failed")
# Direct fallback call
return make_response(content="Direct fallback answer")
gateway = MagicMock(spec=LLMGateway)
gateway.chat = AsyncMock(side_effect=chat_side_effect)
engine = ReWOOEngine(llm_gateway=gateway)
result = await engine.execute(
messages=[{"role": "user", "content": "Impossible task"}],
)
assert result.output == "Direct fallback answer"
assert result.fallback_strategy == "direct"
async def test_fallback_strategies_constant_exists(self):
"""验证 FALLBACK_STRATEGIES 常量存在"""
from agentkit.core.rewoo import ReWOOEngine
assert hasattr(ReWOOEngine, "FALLBACK_STRATEGIES")
assert ReWOOEngine.FALLBACK_STRATEGIES == ["simplified_rewoo", "react", "direct"]
async def test_react_fallback_with_tools(self):
"""规划失败后 ReAct 回退,带工具调用"""
from agentkit.core.rewoo import ReWOOEngine
tool = FakeTool(name="search", result={"results": ["found"]})
# Both plan attempts fail
invalid_plan1 = make_response(content="Cannot plan")
invalid_plan2 = make_response(content="Still cannot plan")
# ReAct: tool call then final answer
react_tool_response = make_response(
content="",
tool_calls=[ToolCall(id="tc_1", name="search", arguments={"query": "test"})],
)
react_final_response = make_response(content="ReAct answer with tool")
gateway = make_mock_gateway([
invalid_plan1,
invalid_plan2,
react_tool_response,
react_final_response,
])
engine = ReWOOEngine(llm_gateway=gateway)
result = await engine.execute(
messages=[{"role": "user", "content": "Search task"}],
tools=[tool],
)
assert result.output == "ReAct answer with tool"
assert result.fallback_strategy == "react"
# ── Test: Configurable Fallback Strategies ──────────────────
class TestReWOOConfigurableFallback:
"""ReWOO 回退链配置化测试"""
async def test_default_fallback_strategies(self):
"""默认回退链simplified_rewoo → react → direct"""
from agentkit.core.rewoo import ReWOOEngine
engine = ReWOOEngine(llm_gateway=MagicMock(spec=LLMGateway))
assert engine._fallback_strategies == ["simplified_rewoo", "react", "direct"]
async def test_custom_fallback_strategies(self):
"""自定义回退链plan_exec → react → direct"""
from agentkit.core.rewoo import ReWOOEngine
engine = ReWOOEngine(
llm_gateway=MagicMock(spec=LLMGateway),
fallback_strategies=["plan_exec", "react", "direct"],
)
assert engine._fallback_strategies == ["plan_exec", "react", "direct"]
async def test_invalid_strategy_name_skipped(self):
"""无效策略名跳过并警告"""
from agentkit.core.rewoo import ReWOOEngine
engine = ReWOOEngine(
llm_gateway=MagicMock(spec=LLMGateway),
fallback_strategies=["invalid_strategy", "react", "direct"],
)
assert engine._fallback_strategies == ["react", "direct"]
async def test_empty_fallback_strategies_uses_defaults(self):
"""空回退链回退到默认"""
from agentkit.core.rewoo import ReWOOEngine
engine = ReWOOEngine(
llm_gateway=MagicMock(spec=LLMGateway),
fallback_strategies=[],
)
assert engine._fallback_strategies == ["simplified_rewoo", "react", "direct"]
async def test_all_invalid_strategies_uses_defaults(self):
"""全部无效策略名回退到默认"""
from agentkit.core.rewoo import ReWOOEngine
engine = ReWOOEngine(
llm_gateway=MagicMock(spec=LLMGateway),
fallback_strategies=["foo", "bar"],
)
assert engine._fallback_strategies == ["simplified_rewoo", "react", "direct"]
async def test_custom_fallback_plan_exec_first(self):
"""自定义回退链plan_exec 优先,规划失败时先走 plan_exec 再走 react"""
from agentkit.core.rewoo import ReWOOEngine
# Planning fails, plan_exec also fails, react succeeds
invalid_plan = make_response(content="Not a plan")
plan_exec_fail = make_response(content="Still not a plan")
react_response = make_response(content="React answer")
gateway = make_mock_gateway([invalid_plan, plan_exec_fail, react_response])
engine = ReWOOEngine(
llm_gateway=gateway,
fallback_strategies=["plan_exec", "react"],
)
result = await engine.execute(
messages=[{"role": "user", "content": "Task"}],
)
assert result.output == "React answer"
assert result.fallback_strategy == "react"
async def test_custom_fallback_direct_only(self):
"""自定义回退链:仅 direct跳过 simplified_rewoo 和 react"""
from agentkit.core.rewoo import ReWOOEngine
# Planning fails, direct succeeds
invalid_plan = make_response(content="Not a plan")
direct_response = make_response(content="Direct answer")
gateway = make_mock_gateway([invalid_plan, direct_response])
engine = ReWOOEngine(
llm_gateway=gateway,
fallback_strategies=["direct"],
)
result = await engine.execute(
messages=[{"role": "user", "content": "Task"}],
)
assert result.output == "Direct answer"
assert result.fallback_strategy == "direct"
async def test_valid_strategies_constant(self):
"""验证 VALID_STRATEGIES 集合"""
from agentkit.core.rewoo import ReWOOEngine
assert ReWOOEngine.VALID_STRATEGIES == {"simplified_rewoo", "react", "direct", "plan_exec"}
async def test_stream_custom_fallback_react_only(self):
"""流式模式:自定义回退链仅 react"""
from agentkit.core.rewoo import ReWOOEngine
# Planning fails, react succeeds
invalid_plan = make_response(content="Not a plan")
react_response = make_response(content="React stream answer")
gateway = make_mock_gateway([invalid_plan, react_response])
engine = ReWOOEngine(
llm_gateway=gateway,
fallback_strategies=["react"],
)
events = []
async for event in engine.execute_stream(
messages=[{"role": "user", "content": "Task"}],
):
events.append(event)
event_types = [e.event_type for e in events]
assert "final_answer" in event_types
async def test_all_fallback_exhausted_raises(self):
"""所有回退策略耗尽时抛出 RuntimeError"""
from agentkit.core.rewoo import ReWOOEngine
# Planning fails, all fallbacks fail
call_count = 0
async def always_fail(**kwargs):
nonlocal call_count
call_count += 1
raise RuntimeError("LLM unavailable")
gateway = MagicMock(spec=LLMGateway)
gateway.chat = AsyncMock(side_effect=always_fail)
engine = ReWOOEngine(
llm_gateway=gateway,
fallback_strategies=["react", "direct"],
)
with pytest.raises(RuntimeError, match="All ReWOO fallback strategies exhausted"):
await engine.execute(
messages=[{"role": "user", "content": "Task"}],
)