Skip to content

Commit e43c7de

Browse files
fix: add idx for task ordering, tests
1 parent 8ef9fe2 commit e43c7de

File tree

3 files changed

+220
-17
lines changed

3 files changed

+220
-17
lines changed

lib/crewai/src/crewai/crew.py

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1017,10 +1017,26 @@ def _handle_crew_planning(self) -> None:
10171017
tasks=self.tasks, planning_agent_llm=self.planning_llm
10181018
)._handle_crew_planning()
10191019

1020-
for task, step_plan in zip(
1021-
self.tasks, result.list_of_plans_per_task, strict=False
1022-
):
1023-
task.description += step_plan.plan
1020+
plan_map: dict[int, str] = {}
1021+
for step_plan in result.list_of_plans_per_task:
1022+
if step_plan.task_number in plan_map:
1023+
self._logger.log(
1024+
"warning",
1025+
f"Duplicate plan for Task Number {step_plan.task_number}, "
1026+
"using the first plan",
1027+
)
1028+
else:
1029+
plan_map[step_plan.task_number] = step_plan.plan
1030+
1031+
for idx, task in enumerate(self.tasks):
1032+
task_number = idx + 1
1033+
if task_number in plan_map:
1034+
task.description += plan_map[task_number]
1035+
else:
1036+
self._logger.log(
1037+
"warning",
1038+
f"No plan found for Task Number {task_number}",
1039+
)
10241040

10251041
def _store_execution_log(
10261042
self,

lib/crewai/src/crewai/utilities/planning_handler.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,12 @@
1515
class PlanPerTask(BaseModel):
1616
"""Represents a plan for a specific task."""
1717

18-
task: str = Field(..., description="The task for which the plan is created")
18+
task_number: int = Field(
19+
description="The 1-indexed task number this plan corresponds to",
20+
ge=1,
21+
)
22+
task: str = Field(description="The task for which the plan is created")
1923
plan: str = Field(
20-
...,
2124
description="The step by step plan on how the agents can execute their tasks using the available tools with mastery",
2225
)
2326

lib/crewai/tests/utilities/test_planning_handler.py

Lines changed: 195 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
1-
from unittest.mock import patch
1+
"""Tests for the planning handler module."""
2+
3+
from unittest.mock import MagicMock, patch
24

35
import pytest
6+
47
from crewai.agent import Agent
8+
from crewai.crew import Crew
59
from crewai.knowledge.source.string_knowledge_source import StringKnowledgeSource
610
from crewai.task import Task
711
from crewai.tasks.task_output import TaskOutput
@@ -13,7 +17,7 @@
1317
)
1418

1519

16-
class InternalCrewPlanner:
20+
class TestInternalCrewPlanner:
1721
@pytest.fixture
1822
def crew_planner(self):
1923
tasks = [
@@ -49,9 +53,9 @@ def crew_planner_different_llm(self):
4953

5054
def test_handle_crew_planning(self, crew_planner):
5155
list_of_plans_per_task = [
52-
PlanPerTask(task="Task1", plan="Plan 1"),
53-
PlanPerTask(task="Task2", plan="Plan 2"),
54-
PlanPerTask(task="Task3", plan="Plan 3"),
56+
PlanPerTask(task_number=1, task="Task1", plan="Plan 1"),
57+
PlanPerTask(task_number=2, task="Task2", plan="Plan 2"),
58+
PlanPerTask(task_number=3, task="Task3", plan="Plan 3"),
5559
]
5660
with patch.object(Task, "execute_sync") as execute:
5761
execute.return_value = TaskOutput(
@@ -97,12 +101,12 @@ def test_create_tasks_summary(self, crew_planner):
97101
# Knowledge field should not be present when empty
98102
assert '"agent_knowledge"' not in tasks_summary
99103

100-
@patch("crewai.knowledge.storage.knowledge_storage.chromadb")
101-
def test_create_tasks_summary_with_knowledge_and_tools(self, mock_chroma):
104+
@patch("crewai.knowledge.knowledge.Knowledge.add_sources")
105+
@patch("crewai.knowledge.storage.knowledge_storage.KnowledgeStorage")
106+
def test_create_tasks_summary_with_knowledge_and_tools(
107+
self, mock_storage, mock_add_sources
108+
):
102109
"""Test task summary generation with both knowledge and tools present."""
103-
# Mock ChromaDB collection
104-
mock_collection = mock_chroma.return_value.get_or_create_collection.return_value
105-
mock_collection.add.return_value = None
106110

107111
# Create mock tools with proper string descriptions and structured tool support
108112
class MockTool(BaseTool):
@@ -166,7 +170,9 @@ def test_handle_crew_planning_different_llm(self, crew_planner_different_llm):
166170
description="Description",
167171
agent="agent",
168172
pydantic=PlannerTaskPydanticOutput(
169-
list_of_plans_per_task=[PlanPerTask(task="Task1", plan="Plan 1")]
173+
list_of_plans_per_task=[
174+
PlanPerTask(task_number=1, task="Task1", plan="Plan 1")
175+
]
170176
),
171177
)
172178
result = crew_planner_different_llm._handle_crew_planning()
@@ -177,3 +183,181 @@ def test_handle_crew_planning_different_llm(self, crew_planner_different_llm):
177183
crew_planner_different_llm.tasks
178184
)
179185
execute.assert_called_once()
186+
187+
def test_plan_per_task_requires_task_number(self):
188+
"""Test that PlanPerTask model requires task_number field."""
189+
with pytest.raises(ValueError):
190+
PlanPerTask(task="Task1", plan="Plan 1")
191+
192+
def test_plan_per_task_with_task_number(self):
193+
"""Test PlanPerTask model with task_number field."""
194+
plan = PlanPerTask(task_number=5, task="Task5", plan="Plan for task 5")
195+
assert plan.task_number == 5
196+
assert plan.task == "Task5"
197+
assert plan.plan == "Plan for task 5"
198+
199+
200+
class TestCrewPlanningIntegration:
201+
"""Tests for Crew._handle_crew_planning integration with task_number matching."""
202+
203+
def test_crew_planning_with_out_of_order_plans(self):
204+
"""Test that plans are correctly matched to tasks even when returned out of order.
205+
206+
This test verifies the fix for issue #3953 where plans returned by the LLM
207+
in a different order than the tasks would be incorrectly assigned.
208+
"""
209+
agent1 = Agent(role="Agent 1", goal="Goal 1", backstory="Backstory 1")
210+
agent2 = Agent(role="Agent 2", goal="Goal 2", backstory="Backstory 2")
211+
agent3 = Agent(role="Agent 3", goal="Goal 3", backstory="Backstory 3")
212+
213+
task1 = Task(
214+
description="First task description",
215+
expected_output="Output 1",
216+
agent=agent1,
217+
)
218+
task2 = Task(
219+
description="Second task description",
220+
expected_output="Output 2",
221+
agent=agent2,
222+
)
223+
task3 = Task(
224+
description="Third task description",
225+
expected_output="Output 3",
226+
agent=agent3,
227+
)
228+
229+
crew = Crew(
230+
agents=[agent1, agent2, agent3],
231+
tasks=[task1, task2, task3],
232+
planning=True,
233+
)
234+
235+
out_of_order_plans = [
236+
PlanPerTask(task_number=3, task="Task 3", plan=" [PLAN FOR TASK 3]"),
237+
PlanPerTask(task_number=1, task="Task 1", plan=" [PLAN FOR TASK 1]"),
238+
PlanPerTask(task_number=2, task="Task 2", plan=" [PLAN FOR TASK 2]"),
239+
]
240+
241+
mock_planner_result = PlannerTaskPydanticOutput(
242+
list_of_plans_per_task=out_of_order_plans
243+
)
244+
245+
with patch.object(
246+
CrewPlanner, "_handle_crew_planning", return_value=mock_planner_result
247+
):
248+
crew._handle_crew_planning()
249+
250+
assert "[PLAN FOR TASK 1]" in task1.description
251+
assert "[PLAN FOR TASK 2]" in task2.description
252+
assert "[PLAN FOR TASK 3]" in task3.description
253+
254+
assert "[PLAN FOR TASK 3]" not in task1.description
255+
assert "[PLAN FOR TASK 1]" not in task2.description
256+
assert "[PLAN FOR TASK 2]" not in task3.description
257+
258+
def test_crew_planning_with_missing_plan(self):
259+
"""Test that missing plans are handled gracefully with a warning."""
260+
agent1 = Agent(role="Agent 1", goal="Goal 1", backstory="Backstory 1")
261+
agent2 = Agent(role="Agent 2", goal="Goal 2", backstory="Backstory 2")
262+
263+
task1 = Task(
264+
description="First task description",
265+
expected_output="Output 1",
266+
agent=agent1,
267+
)
268+
task2 = Task(
269+
description="Second task description",
270+
expected_output="Output 2",
271+
agent=agent2,
272+
)
273+
274+
crew = Crew(
275+
agents=[agent1, agent2],
276+
tasks=[task1, task2],
277+
planning=True,
278+
)
279+
280+
original_task1_desc = task1.description
281+
original_task2_desc = task2.description
282+
283+
incomplete_plans = [
284+
PlanPerTask(task_number=1, task="Task 1", plan=" [PLAN FOR TASK 1]"),
285+
]
286+
287+
mock_planner_result = PlannerTaskPydanticOutput(
288+
list_of_plans_per_task=incomplete_plans
289+
)
290+
291+
with patch.object(
292+
CrewPlanner, "_handle_crew_planning", return_value=mock_planner_result
293+
):
294+
crew._handle_crew_planning()
295+
296+
assert "[PLAN FOR TASK 1]" in task1.description
297+
298+
assert task2.description == original_task2_desc
299+
300+
def test_crew_planning_preserves_original_description(self):
301+
"""Test that planning appends to the original task description."""
302+
agent = Agent(role="Agent 1", goal="Goal 1", backstory="Backstory 1")
303+
304+
task = Task(
305+
description="Original task description",
306+
expected_output="Output 1",
307+
agent=agent,
308+
)
309+
310+
crew = Crew(
311+
agents=[agent],
312+
tasks=[task],
313+
planning=True,
314+
)
315+
316+
plans = [
317+
PlanPerTask(task_number=1, task="Task 1", plan=" - Additional plan steps"),
318+
]
319+
320+
mock_planner_result = PlannerTaskPydanticOutput(list_of_plans_per_task=plans)
321+
322+
with patch.object(
323+
CrewPlanner, "_handle_crew_planning", return_value=mock_planner_result
324+
):
325+
crew._handle_crew_planning()
326+
327+
assert "Original task description" in task.description
328+
assert "Additional plan steps" in task.description
329+
330+
def test_crew_planning_with_duplicate_task_numbers(self):
331+
"""Test that duplicate task numbers use the first plan and log a warning."""
332+
agent = Agent(role="Agent 1", goal="Goal 1", backstory="Backstory 1")
333+
334+
task = Task(
335+
description="Task description",
336+
expected_output="Output 1",
337+
agent=agent,
338+
)
339+
340+
crew = Crew(
341+
agents=[agent],
342+
tasks=[task],
343+
planning=True,
344+
)
345+
346+
# Two plans with the same task_number - should use the first one
347+
duplicate_plans = [
348+
PlanPerTask(task_number=1, task="Task 1", plan=" [FIRST PLAN]"),
349+
PlanPerTask(task_number=1, task="Task 1", plan=" [SECOND PLAN]"),
350+
]
351+
352+
mock_planner_result = PlannerTaskPydanticOutput(
353+
list_of_plans_per_task=duplicate_plans
354+
)
355+
356+
with patch.object(
357+
CrewPlanner, "_handle_crew_planning", return_value=mock_planner_result
358+
):
359+
crew._handle_crew_planning()
360+
361+
# Should use the first plan, not the second
362+
assert "[FIRST PLAN]" in task.description
363+
assert "[SECOND PLAN]" not in task.description

0 commit comments

Comments
 (0)