Skip to content

fix: support async before/after_kickoff_callbacks in akickoff#6482

Open
magiccao wants to merge 10 commits into
crewAIInc:mainfrom
magiccao:fix/akickoff-async-callbacks
Open

fix: support async before/after_kickoff_callbacks in akickoff#6482
magiccao wants to merge 10 commits into
crewAIInc:mainfrom
magiccao:fix/akickoff-async-callbacks

Conversation

@magiccao

@magiccao magiccao commented Jul 8, 2026

Copy link
Copy Markdown

Summary

Fixes #6481

Crew.akickoff() is a native async execution path, but before_kickoff_callbacks and after_kickoff_callbacks were invoked synchronously, silently discarding async callables and blocking the event loop on IO-bound sync callbacks.

This PR brings akickoff into alignment with the existing async callback handling for task_callback and step_callback.

Changes

crews/utils.py

Refactors prepare_kickoff by extracting three private helpers:

  • _begin_prepare_kickoff — emission counter reset + resuming flag
  • _normalize_inputs — input type validation and dict conversion
  • _finish_prepare_kickoff — event emission, file handling, input interpolation, agent setup, planning

Adds aprepare_kickoff (async counterpart): identical to prepare_kickoff except the before_kickoff_callbacks loop uses inspect.isawaitable() + await to support coroutine callbacks.

crew.py

  • akickoff: replaces prepare_kickoff(...) with await aprepare_kickoff(...)
  • akickoff: adds inspect.isawaitable check in the after_kickoff_callbacks loop

Behavior

callback before after
sync callable unchanged unchanged
async callable in akickoff now awaited now awaited
async callable in kickoff_async still not awaited ⚠️ still not awaited ⚠️

Note on kickoff_async: this PR only fixes async callback support in akickoff. kickoff_async runs kickoff() in a worker thread via asyncio.to_thread, so sync callbacks won't block the event loop — but async before/after_kickoff_callbacks are still not awaited there. Fixing kickoff_async would require a separate effort.

Test plan

  • Existing test_async_crew.py tests pass (sync callbacks unaffected)
  • test_akickoff_calls_async_before_callbacks — async before callback is awaited; modified inputs reach task interpolation
  • test_akickoff_calls_async_after_callbacks — async after callback is awaited; result remains CrewOutput
  • test_akickoff_mixed_sync_async_callbacks — mixed pipeline (sync_before → async_before → async_after → sync_after) executes in correct order and preserves the transform chain

before_kickoff_callbacks and after_kickoff_callbacks were invoked
synchronously in akickoff, silently dropping async callables and
blocking the event loop on IO-bound sync callbacks.

- Extract _begin_prepare_kickoff, _normalize_inputs, and
  _finish_prepare_kickoff helpers from prepare_kickoff to eliminate
  code duplication between the sync and async paths.
- Add aprepare_kickoff (async counterpart of prepare_kickoff) that
  awaits coroutine before-callbacks via inspect.isawaitable, consistent
  with the existing task_callback pattern in task.py.
- Use aprepare_kickoff in akickoff instead of prepare_kickoff.
- Add inspect.isawaitable check for after_kickoff_callbacks in akickoff.
- Add tests covering async before, async after, and mixed sync+async
  callback pipelines.

kickoff_async is unaffected: it wraps the entire sync kickoff in
asyncio.to_thread, so before/after callbacks already run off the
event loop.
@coderabbitai

coderabbitai Bot commented Jul 8, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 4d6bcf49-c1b2-4880-a3e8-d94eeacb4337

📥 Commits

Reviewing files that changed from the base of the PR and between d5d5460 and 6cb8486.

📒 Files selected for processing (2)
  • lib/crewai/src/crewai/crew.py
  • lib/crewai/src/crewai/crews/utils.py
🚧 Files skipped from review as they are similar to previous changes (2)
  • lib/crewai/src/crewai/crew.py
  • lib/crewai/src/crewai/crews/utils.py

📝 Walkthrough

Walkthrough

Adds async-aware handling for before_kickoff_callbacks and after_kickoff_callbacks in Crew.akickoff(). Introduces aprepare_kickoff alongside shared preparation helpers, plus tests covering async and mixed synchronous/asynchronous callback scenarios.

Changes

Async kickoff callback support

Layer / File(s) Summary
Shared kickoff preparation helpers and async variant
lib/crewai/src/crewai/crews/utils.py
Refactors kickoff preparation into shared helpers and adds aprepare_kickoff, which awaits awaitable before_kickoff_callbacks results before completing setup.
akickoff() awaits async preparation and callbacks
lib/crewai/src/crewai/crew.py
Uses awaited aprepare_kickoff and awaits awaitable after_kickoff_callbacks results before post-kickoff processing.
Async callback tests
lib/crewai/tests/crew/test_async_crew.py
Tests async before and after callbacks, input injection, and ordering across mixed callback types.

Sequence Diagram(s)

sequenceDiagram
  participant Caller
  participant Crew
  participant aprepare_kickoff
  participant BeforeCallback
  participant AfterCallback

  Caller->>Crew: akickoff(inputs)
  Crew->>apprepare_kickoff: await preparation
  aprepare_kickoff->>BeforeCallback: invoke callback
  BeforeCallback-->>apprepare_kickoff: value or awaitable
  aprepare_kickoff->>apprepare_kickoff: await awaitable result
  aprepare_kickoff-->>Crew: prepared inputs
  Crew->>Crew: execute tasks and build CrewOutput
  Crew->>AfterCallback: invoke callback(CrewOutput)
  AfterCallback-->>Crew: value or awaitable
  Crew->>Crew: await awaitable result
  Crew-->>Caller: return CrewOutput
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly matches the main change: async before/after kickoff callbacks in akickoff.
Description check ✅ Passed The description directly describes the async callback fix and related kickoff refactor.
Linked Issues check ✅ Passed The PR implements the linked issue requirements by awaiting async before/after kickoff callbacks in akickoff and preserving sync behavior.
Out of Scope Changes check ✅ Passed The helper refactor and tests are supporting changes that stay within the async callback support scope.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@ErenAta16 ErenAta16 left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same fix as #6500 and #6494 (also open), and like #6500 this one refactors prepare_kickoff into shared helpers (_begin_prepare_kickoff/_normalize_inputs/_finish_prepare_kickoff) rather than duplicating the whole function body for the new async path, so prepare_kickoff and aprepare_kickoff stay in sync automatically as the shared logic evolves — same quality bar as #6500, just a different helper decomposition. Left the fuller three-way comparison on #6500's thread.

Also flagging the same thing I noted there: the description mentions IO-bound sync callbacks blocking the event loop as motivation, but the actual fix (here and in the other two) only adds the inspect.isawaitable check for async callables — a slow sync callback still runs inline. Worth confirming that is intentionally out of scope for this PR rather than an oversight, since the title/description read as covering both.

@magiccao

magiccao commented Jul 13, 2026

Copy link
Copy Markdown
Author

@ErenAta16 Thanks for the close read. To clarify the scope: the "blocking the event loop on IO-bound sync callbacks" motivation and the async-callable fix are actually the same root cause, not two separate concerns.

In the old akickoff, async callables were silently discarded (the returned coroutine was never awaited), which left users with no non-blocking path — they were forced to write sync IO-bound callbacks that blocked the loop. By adding inspect.isawaitable() + await, users can now write async def callbacks with await inside for IO, yielding control back to the event loop:

# Before — blocks event loop (only path available)
def before(inputs):
    data = requests.get(...)  # blocks
    return {**inputs, **data}

# After — non-blocking (now actually supported)
async def before(inputs):
    data = await httpx_client.get(...)  # yields
    return {**inputs, **data}

So the blocking issue is resolved via the async path, not by offloading sync callbacks. The sync callable | unchanged row in the behavior table isn't a scope gap — sync callbacks should remain synchronous by definition; when non-blocking IO is needed, the callback should be written as async def. This is also consistent with how task_callback / step_callback already handle async callables in akickoff.

Re: the duplicate PRs (#6494, #6500) — happy to consolidate once maintainers indicate which approach they prefer; this one's helper decomposition (_begin_prepare_kickoff / _normalize_inputs / _finish_prepare_kickoff) keeps the sync and async variants sharing the same tail logic so they stay in sync as the shared logic evolves.

@ErenAta16

Copy link
Copy Markdown

That reframing holds up. The before/after example makes it clear: async callables were previously discarded outright (coroutine created, never awaited), so there was no non-blocking option at all, not even for callers willing to write async def. Once awaiting the callable is supported, the IO-bound-blocking motivation and the async-support fix collapse into the same change, and sync callbacks staying synchronous is just the callback keeping the contract it always had.

No further concern on scope from me. Agreed consolidating with #6494/#6500 is a maintainer call once they weigh in on which decomposition they'd rather carry forward.

@magiccao

Copy link
Copy Markdown
Author

Gentle nudge — #6482, #6494, and #6500 all fix the same bug (async before/after_kickoff_callbacks silently dropped in akickoff because the returned coroutine is never awaited). It'd help to converge on one.

This PR:

  • Adds aprepare_kickoff that awaits coroutine callbacks via inspect.isawaitable, mirroring the existing task_callback pattern.
  • Extracts _begin_prepare_kickoff / _normalize_inputs / _finish_prepare_kickoff so sync and async share the tail logic.
  • Adds the same awaitable check for after_kickoff_callbacks in akickoff.

Rebased on latest main to pick up the new interception hooks (#6516#6518). Wrapped the new EXECUTION_START / INPUT dispatch in two helpers (_dispatch_execution_start / _dispatch_input) called around the before-callback loop in both variants, so the shared-tail structure is preserved. Crew + hook + async crew tests pass locally.

Happy to close in favor of whichever decomposition the team prefers — just let us know. An approve from a maintainer would unblock the merge.

cc @joaomdmoura @greysonlalonde

@ErenAta16

Copy link
Copy Markdown

@magiccao On converging the three, I've now read all of them against each other. The one-line fix in crew.py is identical everywhere (inspect.isawaitable guard around the after-callback loop). The whole decision is how prepare_kickoff gets decomposed so sync and async can share it, so here's that comparison, since it's the part a maintainer would otherwise have to derive three times.

#6482 (this) #6494 #6500
decomposition _begin_prepare_kickoff / _normalize_inputs / _dispatch_execution_start / _dispatch_input / _finish_prepare_kickoff _run_before_callbacks + _prepare_kickoff_common _prepare_kickoff_impl with a normalized_inputs flag
lines removed from the shared fn 39 21 12
rebased on the new interception hooks (#6516-#6518) yes no, last touched Jul 11 no
callbacks can double-run no no yes, see below

Two things that I think settle it rather than being taste:

#6500 has a correctness issue in its sentinel. normalized_inputs=None means both "not yet applied" and "applied, result was None". A before-callback that mutates in place and returns None therefore runs twice on the async path. I simulated the control flow from that branch and confirmed it (2 invocations vs the expected 1, sync path unaffected). Fixable with a distinct sentinel object, but it's an argument against encoding "has this already happened" in a nullable parameter at all. Details on #6500.

#6494 is the stalest and predates the hook changes. It hasn't moved since Jul 11 and doesn't account for the EXECUTION_START / INPUT dispatch that landed in #6516-#6518, so it would need the same rebase work #6482 has already absorbed.

That leaves #6482 as the one that's both current and structurally sound. The granular decomposition is more churn against crews/utils.py than the others, which is the fair criticism of it, but it's churn in service of not having a "did the caller already do this?" flag, and it's the only one of the three where the shared tail is genuinely shared rather than conditionally re-entered.

I don't have write access so this isn't a formal approval, just the comparison written down once so it doesn't have to be redone. @nolanchic @ashusnapx flagging you both since it's your PRs I'm arguing against, and I'd rather be corrected than have that stand unchallenged if I've misread either.

ashusnapx added a commit to ashusnapx/crewAI that referenced this pull request Jul 22, 2026
The previous implementation used `None` as a sentinel to detect whether
the before-callback loop had started. But a callback can legitimately
return `None`, which would cause the next callback to receive the
pre-callback inputs instead of the callback's return value.

Introduces `_UNSET = object()` as a proper sentinel that no callback
can accidentally produce, eliminating the double-run bug identified in
the three-way comparison on crewAIInc#6482.
@ashusnapx

Copy link
Copy Markdown

@ErenAta16 Good catch on the sentinel issue — I went back and looked at my implementation and you're right, normalized_inputs=None was doing double duty as both "not yet applied" and "result was None". Fixed it in the latest push: introduced _UNSET = object() as a proper sentinel that no callback can accidentally produce, so the first-iteration detection is now unambiguous.

On the staleness point: I've rebased on current main and the shared-tail decomposition is in place — _normalize_inputs, _run_before_callbacks (sync path), and _prepare_kickoff_common are all called by both prepare_kickoff and aprepare_kickoff, so neither path drifts as the shared logic evolves. The async before-callback loop does the inspect.isawaitable + await inline rather than through a conditional flag, which avoids the "did the caller already do this?" problem you flagged in #6500.

Happy to consolidate with whichever approach the team picks — just wanted the sentinel fix in place so the comparison is against a structurally sound version.

dongcao added 2 commits July 22, 2026 16:26
…callbacks

Resolved conflict in lib/crewai/src/crewai/crews/utils.py: kept the
_execution_start_dispatched/_execution_end_dispatched flag pairing
introduced in crewAIInc#6607 (set False before dispatch, True after), while
preserving the helper-function refactor that returns start_ctx.payload
from _dispatch_execution_start.
@ErenAta16

Copy link
Copy Markdown

Correction to my comparison above, since @ashusnapx acted on it and I owe him an accurate record.

He read my sentinel criticism as applying to #6494 and pushed 60a7679 to "fix" it. The criticism was about #6500 only, and my table already had #6494 marked as not affected. I re-verified that before posting and it holds: #6494's original async loop always ran its own callbacks, so it never had the "did the caller already do this?" question that produces the double-run.

The _UNSET change has unfortunately introduced a different, real bug: it moves the if normalized is None: normalized = {} guard out of the loop body, and that guard is what lets a callback return None without breaking the next one. With two callbacks where the first returns None, akickoff now raises TypeError: 'NoneType' object does not support item assignment where kickoff completes fine. Details and a suggested revert on #6494.

So the updated state of the comparison:

#6482 (this) #6494 #6500
rebased on the interception hooks (#6516-#6518) yes yes, as of 60a7679 no
sync/async divergence none None-returning callback crashes async callbacks can double-run

Which leaves this PR as the only one of the three without a known sync/async behavioral difference. That wasn't the conclusion I was fishing for, I'd have been happy to be wrong about it, but two of the three now have a demonstrated divergence and this one doesn't.

The broader lesson for whoever merges: all three of these bugs are the same species, the sync and async paths agreeing on the happy path and diverging on a callback that doesn't return the dict. Worth requiring a multi-callback, returns-None test on whichever lands, since none of the three currently has one.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[BUG] before/after_kickoff_callbacks do not support async callables in akickoff

3 participants