Skip to content

Commit 973c189

Browse files
committed
Fix crewAI #6481: Add async callbacks support in akickoff with proper awaiting of async callables and async prepare_kickoff for before_kickoff_callbacks
1 parent 85d04dd commit 973c189

3 files changed

Lines changed: 336 additions & 1 deletion

File tree

lib/crewai/src/crewai/crew.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from __future__ import annotations
22

33
import asyncio
4+
import inspect
45
from collections.abc import Callable, Sequence
56
from concurrent.futures import Future
67
from copy import copy as shallow_copy
@@ -1254,7 +1255,8 @@ async def run_crew() -> None:
12541255

12551256
runtime_scope = crewai_event_bus._enter_runtime_scope()
12561257
try:
1257-
inputs = prepare_kickoff(self, inputs, input_files)
1258+
from crewai.crews.utils import aprepare_kickoff
1259+
inputs = await aprepare_kickoff(self, inputs, input_files)
12581260

12591261
if self.process == Process.sequential:
12601262
result = await self._arun_sequential_process()
@@ -1267,6 +1269,8 @@ async def run_crew() -> None:
12671269

12681270
for after_callback in self.after_kickoff_callbacks:
12691271
result = after_callback(result)
1272+
if inspect.isawaitable(result):
1273+
result = await result
12701274

12711275
result = self._post_kickoff(result)
12721276

lib/crewai/src/crewai/crews/utils.py

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from __future__ import annotations
44

55
import asyncio
6+
import inspect
67
from collections.abc import Callable, Coroutine, Iterable, Mapping
78
from typing import TYPE_CHECKING, Any
89

@@ -379,6 +380,142 @@ def prepare_kickoff(
379380
return normalized
380381

381382

383+
async def aprepare_kickoff(
384+
crew: "Crew",
385+
inputs: dict[str, Any] | None,
386+
input_files: dict[str, FileInput] | None = None,
387+
) -> dict[str, Any] | None:
388+
"""Async version of prepare_kickoff for native async kickoff (akickoff).
389+
390+
Handles before callbacks, event emission, task handler reset, input
391+
interpolation, task callbacks, agent setup, and planning. Supports
392+
async awaitable callbacks.
393+
"""
394+
from crewai.events.base_events import reset_emission_counter
395+
from crewai.events.event_bus import crewai_event_bus
396+
from crewai.events.event_context import (
397+
get_current_parent_id,
398+
reset_last_event_id,
399+
)
400+
from crewai.events.types.crew_events import CrewKickoffStartedEvent
401+
402+
resuming = crew.checkpoint_kickoff_event_id is not None
403+
404+
if not resuming and get_current_parent_id() is None:
405+
reset_emission_counter()
406+
reset_last_event_id()
407+
408+
from crewai.hooks.contexts import ExecutionStartContext, InputContext
409+
from crewai.hooks.dispatch import InterceptionPoint, dispatch
410+
411+
normalized: dict[str, Any] | None = None
412+
if inputs is not None:
413+
if not isinstance(inputs, Mapping):
414+
raise TypeError(
415+
f"inputs must be a dict or Mapping, got {type(inputs).__name__}"
416+
)
417+
normalized = dict(inputs)
418+
419+
# ``inputs`` aliases the same object as ``payload`` (not a fresh ``{}`` from
420+
# ``or``) so in-place edits to either survive read-back, per the context
421+
# contract. ``None`` inputs are preserved rather than coerced to ``{}``.
422+
start_ctx = ExecutionStartContext(
423+
crew=crew,
424+
inputs=normalized if normalized is not None else {},
425+
payload=normalized,
426+
)
427+
# Pairing flags: EXECUTION_END fires (once) only for executions whose
428+
# EXECUTION_START actually dispatched, including on the failure path.
429+
crew._execution_start_dispatched = False
430+
crew._execution_end_dispatched = False
431+
dispatch(InterceptionPoint.EXECUTION_START, start_ctx)
432+
crew._execution_start_dispatched = True
433+
normalized = start_ctx.payload
434+
435+
for before_callback in crew.before_kickoff_callbacks:
436+
if normalized is None:
437+
normalized = {}
438+
cb_result = before_callback(normalized)
439+
if inspect.isawaitable(cb_result):
440+
normalized = await cb_result
441+
else:
442+
normalized = cb_result
443+
444+
input_ctx = InputContext(
445+
crew=crew,
446+
inputs=normalized if normalized is not None else {},
447+
payload=normalized,
448+
)
449+
dispatch(InterceptionPoint.INPUT, input_ctx)
450+
normalized = input_ctx.payload
451+
452+
if resuming and crew._kickoff_event_id:
453+
if crew.verbose:
454+
from crewai.events.utils.console_formatter import ConsoleFormatter
455+
456+
fmt = ConsoleFormatter(verbose=True)
457+
content = fmt.create_status_content(
458+
"Resuming from Checkpoint",
459+
crew.name or "Crew",
460+
"bright_magenta",
461+
ID=str(crew.id),
462+
)
463+
fmt.print_panel(
464+
content, "\U0001f504 Resuming from Checkpoint", "bright_magenta"
465+
)
466+
else:
467+
started_event = CrewKickoffStartedEvent(crew_name=crew.name, inputs=normalized)
468+
crew._kickoff_event_id = started_event.event_id
469+
future = crewai_event_bus.emit(crew, started_event)
470+
if future is not None:
471+
try:
472+
future.result()
473+
except Exception: # noqa: S110
474+
pass
475+
476+
crew._task_output_handler.reset()
477+
crew._logging_color = "bold_purple"
478+
479+
_flow_files = baggage.get_baggage("flow_input_files")
480+
flow_files: dict[str, Any] = _flow_files if isinstance(_flow_files, dict) else {}
481+
482+
if normalized is not None:
483+
unpacked_files = _extract_files_from_inputs(normalized)
484+
485+
all_files = {**flow_files, **(input_files or {}), **unpacked_files}
486+
if all_files:
487+
store_files(crew.id, all_files)
488+
489+
crew._inputs = normalized
490+
crew._interpolate_inputs(normalized)
491+
else:
492+
all_files = {**flow_files, **(input_files or {})}
493+
if all_files:
494+
store_files(crew.id, all_files)
495+
crew._set_tasks_callbacks()
496+
crew._set_allow_crewai_trigger_context_for_first_task()
497+
498+
agents_to_setup: list[BaseAgent] = list(crew.agents)
499+
seen_agent_ids: set[int] = {id(agent) for agent in agents_to_setup}
500+
for task in crew.tasks:
501+
if task.agent is not None and id(task.agent) not in seen_agent_ids:
502+
agents_to_setup.append(task.agent)
503+
seen_agent_ids.add(id(task.agent))
504+
505+
setup_agents(
506+
crew,
507+
agents_to_setup,
508+
crew.embedder,
509+
crew.function_calling_llm,
510+
crew.step_callback,
511+
)
512+
513+
if crew.planning:
514+
crew._handle_crew_planning()
515+
516+
return normalized
517+
518+
382519
class StreamingContext:
383520
"""Container for streaming state and holders used during crew execution."""
384521

Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
"""Tests for async callbacks support in akickoff."""
2+
3+
import asyncio
4+
import pytest
5+
from typing import Any
6+
from unittest.mock import AsyncMock, MagicMock, patch
7+
8+
from crewai.agent import Agent
9+
from crewai.crew import Crew
10+
from crewai.task import Task
11+
from crewai.crews.crew_output import CrewOutput
12+
from crewai.tasks.task_output import TaskOutput
13+
14+
15+
@pytest.fixture
16+
def test_agent() -> Agent:
17+
"""Create a test agent."""
18+
return Agent(
19+
role="Test Agent",
20+
goal="Test goal",
21+
backstory="Test backstory",
22+
llm="gpt-4o-mini",
23+
verbose=False,
24+
)
25+
26+
27+
@pytest.fixture
28+
def test_task(test_agent: Agent) -> Task:
29+
"""Create a test task."""
30+
return Task(
31+
description="Test task description",
32+
expected_output="Test expected output",
33+
agent=test_agent,
34+
)
35+
36+
37+
class TestAsyncCallbacksSupport:
38+
"""Tests for async callback support in akickoff."""
39+
40+
@pytest.mark.asyncio
41+
@patch("crewai.task.Task.aexecute_sync", new_callable=AsyncMock)
42+
async def test_akickoff_calls_async_before_callback(
43+
self, mock_execute: AsyncMock, test_agent: Agent
44+
) -> None:
45+
"""Test that async before_callback is awaited in aprepare_kickoff."""
46+
callback_result = {"called": False}
47+
48+
async def async_before_callback(inputs: dict | None) -> dict[str, Any]:
49+
callback_result["called"] = True
50+
await asyncio.sleep(0.01)
51+
return inputs or {}
52+
53+
task = Task(
54+
description="Test task for {topic}",
55+
expected_output="Expected output for {topic}",
56+
agent=test_agent,
57+
)
58+
crew = Crew(
59+
agents=[test_agent],
60+
tasks=[task],
61+
verbose=False,
62+
before_kickoff_callbacks=[async_before_callback],
63+
)
64+
65+
mock_output = TaskOutput(
66+
description="Test task for AI",
67+
raw="Task result about AI",
68+
agent="Test Agent",
69+
)
70+
mock_execute.return_value = mock_output
71+
72+
result = await crew.akickoff(inputs={"topic": "AI"})
73+
74+
assert callback_result["called"], "Async before callback was not called"
75+
assert result is not None
76+
assert isinstance(result, CrewOutput)
77+
78+
@pytest.mark.asyncio
79+
@patch("crewai.task.Task.aexecute_sync", new_callable=AsyncMock)
80+
async def test_akickoff_calls_async_after_callback(
81+
self, mock_execute: AsyncMock, test_agent: Agent
82+
) -> None:
83+
"""Test that async after_callback is awaited in akickoff."""
84+
callback_result = {"called": False, "received": None}
85+
86+
async def async_after_callback(result: CrewOutput) -> CrewOutput:
87+
nonlocal callback_result
88+
callback_result["called"] = True
89+
callback_result["received"] = result
90+
await asyncio.sleep(0.01)
91+
return result
92+
93+
task = Task(
94+
description="Test task",
95+
expected_output="Test output",
96+
agent=test_agent,
97+
)
98+
crew = Crew(
99+
agents=[test_agent],
100+
tasks=[task],
101+
verbose=False,
102+
after_kickoff_callbacks=[async_after_callback],
103+
)
104+
105+
mock_output = TaskOutput(
106+
description="Test task",
107+
raw="Task result",
108+
agent="Test Agent",
109+
)
110+
mock_execute.return_value = mock_output
111+
112+
result = await crew.akickoff()
113+
114+
assert callback_result["called"], "Async after callback was not called"
115+
assert callback_result["received"] is not None
116+
assert isinstance(callback_result["received"], CrewOutput)
117+
118+
@pytest.mark.asyncio
119+
@patch("crewai.task.Task.aexecute_sync", new_callable=AsyncMock)
120+
async def test_akickoff_mixed_sync_and_async_callbacks(
121+
self, mock_execute: AsyncMock, test_agent: Agent
122+
) -> None:
123+
"""Test that mixed sync and async callbacks work together."""
124+
sync_result = {"called": False}
125+
async_result = {"called": False, "received": None}
126+
127+
def sync_before_callback(inputs: dict | None) -> dict:
128+
sync_result["called"] = True
129+
return inputs or {}
130+
131+
async def async_after_callback(result: CrewOutput) -> CrewOutput:
132+
nonlocal async_result
133+
async_result["called"] = True
134+
async_result["received"] = result
135+
await asyncio.sleep(0.01)
136+
return result
137+
138+
task = Task(
139+
description="Test task",
140+
expected_output="Test output",
141+
agent=test_agent,
142+
)
143+
crew = Crew(
144+
agents=[test_agent],
145+
tasks=[task],
146+
verbose=False,
147+
before_kickoff_callbacks=[sync_before_callback],
148+
after_kickoff_callbacks=[async_after_callback],
149+
)
150+
151+
mock_output = TaskOutput(
152+
description="Test task",
153+
raw="Task result",
154+
agent="Test Agent",
155+
)
156+
mock_execute.return_value = mock_output
157+
158+
result = await crew.akickoff()
159+
160+
assert sync_result["called"], "Sync before callback was not called"
161+
assert async_result["called"], "Async after callback was not called"
162+
assert async_result["received"] is not None
163+
164+
@pytest.mark.asyncio
165+
@patch("crewai.task.Task.aexecute_sync", new_callable=AsyncMock)
166+
async def test_akickoff_empty_callbacks(
167+
self, mock_execute: AsyncMock, test_agent: Agent
168+
) -> None:
169+
"""Test that empty callbacks list still works normally."""
170+
task = Task(
171+
description="Test task",
172+
expected_output="Test output",
173+
agent=test_agent,
174+
)
175+
crew = Crew(
176+
agents=[test_agent],
177+
tasks=[task],
178+
verbose=False,
179+
before_kickoff_callbacks=[],
180+
after_kickoff_callbacks=[],
181+
)
182+
183+
mock_output = TaskOutput(
184+
description="Test task",
185+
raw="Task result",
186+
agent="Test Agent",
187+
)
188+
mock_execute.return_value = mock_output
189+
190+
result = await crew.akickoff()
191+
192+
assert result is not None
193+
assert isinstance(result, CrewOutput)
194+
assert result.raw == "Task result"

0 commit comments

Comments
 (0)