Skip to content

fix: run before_llm_call hooks on async provider paths - #6740

Open
LHMQ878 wants to merge 3 commits into
crewAIInc:mainfrom
LHMQ878:fix/before-llm-call-hooks-async-paths
Open

fix: run before_llm_call hooks on async provider paths#6740
LHMQ878 wants to merge 3 commits into
crewAIInc:mainfrom
LHMQ878:fix/before-llm-call-hooks-async-paths

Conversation

@LHMQ878

@LHMQ878 LHMQ878 commented Jul 30, 2026

Copy link
Copy Markdown

Summary

before_llm_call hooks never ran on the async acall() path of any native provider. Because before_llm_call is the interception point that can abort a call, a hook returning False did not merely fail to observe the request — the request was actually issued and billed.

This is the before counterpart to #6736 / #6737 (after_llm_call on async paths), and the more severe half: an after hook that is skipped can only fail to rewrite a response, whereas a skipped before hook means a call the user explicitly blocked still reached the provider.

Fixes #6739

Root cause

Coverage of _invoke_before_llm_call_hooks on main (ebe0082):

provider sync call() async acall() (before) async acall() (after this PR)
openai/completion.py :465 ❌ missing :603
anthropic/completion.py :377 ❌ missing :452
bedrock/completion.py :380 ❌ missing :513
gemini/completion.py :316 ❌ missing :402
azure/completion.py :524 ❌ missing :606

llms/base_llm.py (the LiteLLM fallback) already had the guard on both paths, so users on the fallback path were unaffected -- which is likely why this went unnoticed.

Correction (2026-07-31): that sentence was wrong, in a way that understates the bug. llms/base_llm.py only defines _invoke_before_llm_call_hooks; it has no callers, and its acall() is raise NotImplementedError. The LiteLLM fallback is crewai/llm.py, and there call() guards both hook families (:1876, :1904) while acall() at :1959 guards neither. So the fallback has the same hole as the native providers rather than being the safe path, and nothing in this PR fixes it.

Probe on unmodified origin/main (ebe0082), with litellm.completion / acompletion stubbed so the observable is whether a request left the process:

blocking before hook (returns False) + rewriting after hook:
  call   before:1 after:0  issued:no     -> ValueError: LLM call blocked by before_llm_call hook
  acall  before:0 after:0  issued:async  -> 'the model said something sensitive'

LLM(model=..., is_litellm=True) is required to reach it -- without the flag the constructor returns a native provider, which is part of why I read the fallback as covered.

I am leaving that fix out of this PR rather than adding it: it is a different file and a different seam from the five provider acall() bodies under review here, and this PR already has review history against its current shape. It will follow separately. Flagging it here because "users on the fallback path were unaffected" is a claim a reviewer could reasonably act on, and it is not true.

Proof, before any fix was written

A no-network probe that swaps the provider client for a stub recording whether a request was issued, so the observable is the HTTP call itself rather than the returned string:

anthropic call()       hook fired: 1  request issued: no    -> ValueError: LLM call blocked by before_llm_call hook
anthropic acall()      hook fired: 0  request issued: ['anthropic-async']  -> returned 'LEAKED'
openai    call()       hook fired: 1  request issued: no    -> ValueError: LLM call blocked by before_llm_call hook
openai    acall()      hook fired: 0  request issued: ['openai-async']     -> returned 'LEAKED'

After this PR, all four rows are identical:

anthropic call()       hook fired: 1  request issued: no    -> ValueError: LLM call blocked by before_llm_call hook
anthropic acall()      hook fired: 1  request issued: no    -> ValueError: LLM call blocked by before_llm_call hook
openai    call()       hook fired: 1  request issued: no    -> ValueError: LLM call blocked by before_llm_call hook
openai    acall()      hook fired: 1  request issued: no    -> ValueError: LLM call blocked by before_llm_call hook

The change

Each acall() now runs its own sync twin's guard, verbatim, at the equivalent point in the body — right after the provider-specific message formatting and before the request parameters are assembled:

if not self._invoke_before_llm_call_hooks(formatted_messages, from_agent):
    raise ValueError("LLM call blocked by before_llm_call hook")

Gemini needs the extra line its sync path uses at :310, because its formatted payload is Content objects rather than dicts:

messages_for_hooks = self._convert_contents_to_dict(formatted_content)

Deliberately not changed:

  • No new hook-dispatch helper, no shared base-class wrapper. Each provider formats messages differently and the sync paths already encode the right shape; copying the sync guard keeps the two twins textually comparable, which is what makes the next drift of this kind visible in review.
  • No double dispatch on the agent path. _invoke_before_llm_call_hooks (llms/base_llm.py:981) returns True immediately when from_agent is not None, so the agent-executor dispatch remains the single source and adding the call to acall() cannot fire hooks twice.
  • Azure's responses delegate returns before this block when self.api == "responses", delegating to an OpenAICompletion — which now carries its own guard, so that path is covered too.

Tests

lib/crewai/tests/hooks/test_llm_hooks.py, new class TestBeforeLLMCallHooksOnAsyncPaths, parametrised over openai and anthropic (10 tests total). The stubbed clients append to an issued list, so every assertion is about whether the request went out, not just what was returned.

test asserts
test_sync_call_is_blocked_by_before_hook baseline: sync path blocks, issued == []
test_async_call_is_blocked_by_before_hook the regression: acall() raises and issued == []
test_async_call_runs_before_hook_that_allows a non-aborting hook observes the messages and the call proceeds (issued == ["async"])
test_async_call_without_hooks_is_unchanged negative control: with no hooks registered acall() behaves exactly as before
test_async_call_before_hook_can_mutate_messages the list the hook receives via ctx.messages is the one handed to the provider

Control experiment

Reverting only the five source files and keeping the tests fails exactly the async-blocking tests and nothing else:

6 failed, 30 passed

The 6 are the three async-blocking assertions × 2 providers. The sync baseline, the negative control and all 26 pre-existing tests in the file pass unchanged — the tests are pinned to this bug, not to incidental behaviour.

Verification

suite result
tests/hooks/test_llm_hooks.py 36 passed (26 pre-existing + 10 new)
tests/llms/anthropic + tests/llms/openai 216 passed, 1 skipped, 0 failed
tests/llms/bedrock + google + azure + llms/hooks 244 passed, 10 skipped, 0 failed
ruff format --check / ruff check on all 6 changed files clean

The provider suites additionally report Windows-only teardown errors (not failures) — PermissionError: [WinError 32] from shutil.rmtree on crewai_test_storage/latest_kickoff_task_outputs.db in conftest.py:223. These reproduce on clean main and are unrelated to this change; there are zero test failures.

before_llm_call is the only interception point that can abort an LLM
call, but none of the five native providers invoked it from acall().
A hook returning False therefore did not block anything on the async
path -- the request was still issued and billed. Coverage on main:

  provider    sync call()  async acall()
  openai      :465         missing
  anthropic   :377         missing
  bedrock     :380         missing
  gemini      :316         missing
  azure       :524         missing

base_llm.py (the LiteLLM fallback) had the guard on both paths, so
only users on the native providers were affected.

Each acall() now runs its own sync twin's guard verbatim, right after
the provider-specific message formatting. Gemini additionally needs
_convert_contents_to_dict, as its sync path does, because its payload
is Content objects rather than dicts.

No double dispatch on the agent path: _invoke_before_llm_call_hooks
returns True immediately when from_agent is not None, so the
agent-executor dispatch stays the single source.

This is the before counterpart to the after_llm_call fix in crewAIInc#6737,
and the more severe half: a skipped after hook can only fail to
rewrite a response, while a skipped before hook means a call the user
explicitly blocked went out anyway.

Adds TestBeforeLLMCallHooksOnAsyncPaths, parametrised over openai and
anthropic. The stubbed clients record whether a request was issued, so
the assertions cover the actual leak rather than the returned value.
Includes a negative control proving no-hook behaviour is unchanged.
Reverting only the source files fails exactly the three async-blocking
tests on both providers (6 failed / 30 passed).
@coderabbitai

coderabbitai Bot commented Jul 30, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Native Anthropic, Azure, Bedrock, Gemini, and OpenAI async completion paths now run before_llm_call hooks before provider requests. Tests cover blocked calls, allowed async calls, and hook-mutated messages.

Changes

Async LLM hook gating

Layer / File(s) Summary
Provider async hook gates
lib/crewai/src/crewai/llms/providers/*/completion.py
Async completion methods invoke before-call hooks after formatting messages and raise ValueError when a hook blocks execution.
Async hook regression coverage
lib/crewai/tests/hooks/test_llm_hooks.py
Provider stubs and parameterized tests verify request suppression, allowed async responses, and mutated messages across Anthropic, OpenAI, Bedrock, Gemini, and Azure.

Sequence Diagram(s)

sequenceDiagram
  participant Caller
  participant ProviderCompletion
  participant BeforeLLMCallHook
  participant ProviderClient
  Caller->>ProviderCompletion: acall(messages)
  ProviderCompletion->>BeforeLLMCallHook: invoke with formatted messages
  BeforeLLMCallHook-->>ProviderCompletion: allow or block
  ProviderCompletion->>ProviderClient: issue async completion request
Loading

Suggested reviewers: lucasgomide

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 38.89% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The PR implements #6739 by adding before_llm_call gating to all five native async provider paths and adding regression tests.
Out of Scope Changes check ✅ Passed The changes stay focused on async before_llm_call hook parity and related tests, with no unrelated additions.
Title check ✅ Passed The title clearly and concisely summarizes the main change: enabling before_llm_call hooks on asynchronous provider paths.
Description check ✅ Passed The description is directly related to the changes, explaining the async hook bug, implementation, scope, tests, and verification results.
✨ 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.

@coderabbitai coderabbitai Bot 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.

Actionable comments posted: 2

🧹 Nitpick comments (1)
lib/crewai/tests/hooks/test_llm_hooks.py (1)

718-751: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Mutation test doesn't verify the mutated message reaches the provider request.

create/acreate stubs discard **_kwargs, so test_async_call_before_hook_can_mutate_messages only proves ctx.messages was appended to, not that the appended message was included in the payload sent to create(). Capturing _kwargs in the stub and asserting on the captured messages/input would make this a stronger regression guard against the actual behavior it aims to protect.

♻️ Proposed strengthening of the stub and assertion
 def _stub_openai_llm(issued: list[str]) -> Any:
     """A real OpenAICompletion whose SDK client records every request."""
     from crewai.llms.providers.openai.completion import OpenAICompletion

     llm = OpenAICompletion(model="gpt-4o", api_key="stub", stream=False)

-    def create(**_kwargs: Any) -> Any:
+    def create(**kwargs: Any) -> Any:
         issued.append("sync")
+        issued_kwargs.append(kwargs)
         return _openai_response()

-    async def acreate(**_kwargs: Any) -> Any:
+    async def acreate(**kwargs: Any) -> Any:
         issued.append("async")
+        issued_kwargs.append(kwargs)
         return _openai_response()

Also applies to: 829-845

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/crewai/tests/hooks/test_llm_hooks.py` around lines 718 - 751, Strengthen
the hook mutation tests by having the create/acreate stubs in
_stub_anthropic_llm and _stub_openai_llm capture their **_kwargs payloads
instead of discarding them. Update
test_async_call_before_hook_can_mutate_messages and the corresponding test near
the second stub to assert that the mutated message appears in the provider
request’s messages or input field, while preserving the existing sync/async
tracking.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@lib/crewai/src/crewai/llms/providers/bedrock/completion.py`:
- Around line 513-516: Ensure recursive Bedrock follow-up requests are also
checked by _invoke_before_llm_call_hooks, preferably at the shared async request
boundary or immediately before _ahandle_converse() issues its recursive converse
call. Preserve blocking via the existing ValueError behavior, and add a
regression test covering a hook that blocks based on follow-up messages or tool
results.

In `@lib/crewai/tests/hooks/test_llm_hooks.py`:
- Around line 754-845: Extend TestBeforeLLMCallHooksOnAsyncPaths.provider and
its supporting stubs to include Bedrock, Gemini, and Azure alongside OpenAI and
Anthropic, ensuring each provider exercises the existing async blocking,
allowing, mutation, and no-hook tests while verifying blocked calls issue no
request.

---

Nitpick comments:
In `@lib/crewai/tests/hooks/test_llm_hooks.py`:
- Around line 718-751: Strengthen the hook mutation tests by having the
create/acreate stubs in _stub_anthropic_llm and _stub_openai_llm capture their
**_kwargs payloads instead of discarding them. Update
test_async_call_before_hook_can_mutate_messages and the corresponding test near
the second stub to assert that the mutated message appears in the provider
request’s messages or input field, while preserving the existing sync/async
tracking.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: dc34ec28-c7ff-4ec8-a68c-a620fcc320c1

📥 Commits

Reviewing files that changed from the base of the PR and between ebe0082 and 2a41814.

📒 Files selected for processing (6)
  • lib/crewai/src/crewai/llms/providers/anthropic/completion.py
  • lib/crewai/src/crewai/llms/providers/azure/completion.py
  • lib/crewai/src/crewai/llms/providers/bedrock/completion.py
  • lib/crewai/src/crewai/llms/providers/gemini/completion.py
  • lib/crewai/src/crewai/llms/providers/openai/completion.py
  • lib/crewai/tests/hooks/test_llm_hooks.py

Comment on lines +513 to +516
if not self._invoke_before_llm_call_hooks(
formatted_messages, from_agent
):
raise ValueError("LLM call blocked by before_llm_call hook")

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift

Guard recursive Bedrock follow-up requests too.

acall() gates only the initial request. After tool execution, _ahandle_converse() recursively issues another async converse request without invoking _invoke_before_llm_call_hooks; a hook that blocks based on tool results or follow-up messages can therefore be bypassed. Apply the guard at the shared async request boundary or immediately before the recursive call, and add a regression test.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/crewai/src/crewai/llms/providers/bedrock/completion.py` around lines 513
- 516, Ensure recursive Bedrock follow-up requests are also checked by
_invoke_before_llm_call_hooks, preferably at the shared async request boundary
or immediately before _ahandle_converse() issues its recursive converse call.
Preserve blocking via the existing ValueError behavior, and add a regression
test covering a hook that blocks based on follow-up messages or tool results.

Comment thread lib/crewai/tests/hooks/test_llm_hooks.py Outdated
The regression class this PR closes is per-provider -- each acall() had
its own missing guard -- so testing two providers left the other three
asserting nothing. TestBeforeLLMCallHooksOnAsyncPaths is now parametrised
over openai, anthropic, bedrock, gemini and azure, all built the same way:
a real completion object whose SDK client is a stub appending to an
`issued` list, so each assertion is about whether the request went out.

Two provider-specific details:

- Bedrock's acall() raises NotImplementedError before reaching the hook
  when aiobotocore is absent, and aiobotocore is an optional extra. The
  gate under test is upstream of any AWS I/O and the client is a stub, so
  the stub builder patches AIOBOTOCORE_AVAILABLE rather than depending on
  the extra being installed. _async_client_initialized is preset so
  _ensure_async_client returns the stub instead of entering the exit stack.
- Gemini serves both paths from one client with the async surface under
  .aio, matching _get_async_client delegating to _get_sync_client.

test_async_call_runs_before_hook_that_allows now locates the prompt inside
the last message instead of comparing to one provider's content layout,
since the formatted shape differs (plain string, Converse content blocks,
Gemini part dicts).

Control experiment: reverting only the five source files fails exactly the
three async-blocking tests on all five providers -- 15 failed / 36 passed,
with the sync baseline, the negative control and all 26 pre-existing tests
in the file passing unchanged.
@LHMQ878

LHMQ878 commented Jul 30, 2026

Copy link
Copy Markdown
Author

Went through both findings against the code. The test-coverage one was right and is fixed in ae92404; the Bedrock recursion one describes a real gap that is pre-existing and identical on the sync path, so I'd rather not widen this PR into it.

Fixed: coverage for all five providers

TestBeforeLLMCallHooksOnAsyncPaths is parametrized only over ["openai", "anthropic"]

Correct, and it matters more here than it usually would: the regression class is per-provider -- each acall() had its own independently missing guard -- so two providers of five left three of the source changes asserting nothing. ae92404 parametrises over all five, all built the same way (a real completion object whose SDK client is a stub appending to an issued list, so the assertion stays "was the request issued").

Two provider-specific details worth noting for review:

  • Bedrock raises NotImplementedError at completion.py:492 before reaching the hook when aiobotocore is absent, and that is an optional extra. The gate under test is upstream of any AWS I/O and the client is a stub, so the builder patches AIOBOTOCORE_AVAILABLE and presets _async_client_initialized rather than depending on the extra being installed.
  • Gemini serves both paths from one client with the async surface under .aio, matching _get_async_client delegating to _get_sync_client at :141-143.

test_async_call_runs_before_hook_that_allows now locates the prompt inside the last message instead of comparing against one provider's content layout, since the formatted shape genuinely differs (plain string vs Converse content blocks vs Gemini part dicts).

Control experiment, rerun at five providers: reverting only the five source files fails exactly the three async-blocking tests on every provider -- 15 failed / 36 passed -- with the sync baseline, the negative control and all 26 pre-existing tests in the file passing unchanged.

Not changed: the recursive Bedrock follow-up

After tool execution, _ahandle_converse() recursively issues another async converse request without invoking _invoke_before_llm_call_hooks

The behaviour is real. _ahandle_converse appends the toolResult message and recurses at :1404 and :1713, and the guard I added sits in acall(), which the recursion does not re-enter -- so a hook that would block based on tool results does not see the follow-up request.

Two reasons it does not belong in this PR:

  1. It is not async-specific. _handle_converse recurses identically at :802 and :1112, and the sync path has never gated those either. So this is not the sync/async drift this PR exists to close -- it is a symmetric gap present on both paths since the recursion was written. Fixing only the async half here would create drift in the opposite direction.
  2. Bedrock is the only provider that does this. I checked the other four: openai, anthropic, gemini and azure all execute tools and return through _process_*_response without re-issuing a request from inside the handler. So the fix is not "apply the guard at the shared async request boundary" -- there is no shared boundary to apply it at, and the change would be Bedrock-local design work about whether a tool-result turn counts as a new call for hook purposes.

That second question is a real one and I don't think it should be answered silently inside a drift fix. Happy to file it separately with the line references above if maintainers agree it's worth closing; my read is that gating the follow-up turn is probably right, since a before_llm_call hook exists precisely to inspect what is about to be sent and tool results are attacker-influenced content. But it is a behaviour change for both paths, not a parity repair.

@coderabbitai coderabbitai Bot 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.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
lib/crewai/tests/hooks/test_llm_hooks.py (1)

718-724: 🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Assert that mutated messages reach the provider payload.

All five async stubs discard their request kwargs, while the mutation test only checks the same in-memory ctx.messages list after the hook runs. It would pass even if acall() ignored the mutation before invoking the provider. Capture each stub’s async request arguments and assert that "and be brief" is present in the actual provider payload.

As per coding guidelines, unit tests for new functionality should focus on observable behavior rather than implementation details.

Also applies to: 737-743, 785-791, 831-837, 873-879, 973-989

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/crewai/tests/hooks/test_llm_hooks.py` around lines 718 - 724, Update the
async provider stubs at create/acreate sites, including the additional listed
cases, to capture their request kwargs instead of discarding them. Extend the
mutation test to inspect the captured provider payload after the hook runs and
assert that the mutated message contains “and be brief,” verifying observable
data sent to the provider rather than only ctx.messages.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@lib/crewai/tests/hooks/test_llm_hooks.py`:
- Around line 718-724: Update the async provider stubs at create/acreate sites,
including the additional listed cases, to capture their request kwargs instead
of discarding them. Extend the mutation test to inspect the captured provider
payload after the hook runs and assert that the mutated message contains “and be
brief,” verifying observable data sent to the provider rather than only
ctx.messages.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 5735e79b-29d6-437e-8afc-55c24ef503d7

📥 Commits

Reviewing files that changed from the base of the PR and between 2a41814 and ae92404.

📒 Files selected for processing (1)
  • lib/crewai/tests/hooks/test_llm_hooks.py

The async tests only inspected ctx.messages after the hook ran, which is
the same list object the hook appended to. That assertion holds whatever
acall() does with it afterwards, so it would have passed even if the
guard were handed a copy and the mutation dropped -- exactly the failure
mode the tests exist to catch.

The provider stubs now record their request kwargs instead of discarding
them, and the mutation tests assert against what the stubbed client was
actually sent:

  test_async_before_hook_mutation_reaches_the_provider_as_on_sync
      pins async propagation to whatever the same provider's sync path
      does. Whether a mutation reaches the payload at all is a
      pre-existing per-provider property: four providers hand the guard
      the payload list itself, while Gemini converts its Content objects
      to dicts first and so hands over a copy on *both* paths. Asserting
      async == sync states the invariant this PR owns -- acall() gates
      and forwards exactly as call() does -- without claiming Gemini's
      copy is right.

  test_async_before_hook_mutation_reaches_the_provider
      the symmetry check cannot distinguish "both propagate" from
      "neither does", so this pins the four propagating providers by
      name. Gemini is skipped, with its sync-path asymmetry named in the
      skip reason rather than papered over.

test_async_call_runs_before_hook_that_allows additionally asserts the
prompt is in the recorded payload, so the hook is known to observe the
request that is really about to go out.

Reverting only the five provider files fails 18 tests -- both mutation
tests on the four propagating providers, plus the blocking and
allowing tests on all five -- and no sync test. 55 passed / 1 skipped
with the fix in place.

Signed-off-by: LHMQ878 <LHMQ878@users.noreply.github.com>
@LHMQ878

LHMQ878 commented Jul 30, 2026

Copy link
Copy Markdown
Author

The payload-assertion finding is correct, and thanks for pushing on it — the test was weaker than I thought. Fixed in 9a9a9f9.

The gap was real. test_async_call_before_hook_can_mutate_messages asserted on captured[0][-1], and captured[0] is the list the hook appended to. That assertion holds no matter what acall() does with the list afterwards, so it would have passed if the guard were handed a copy and the mutation silently dropped — precisely the failure the test was supposed to catch. Its docstring ("the hook's messages list is the one handed to the provider") claimed something it never checked.

What I did. The provider stubs now record their request kwargs via a small _Recorder instead of discarding them, and the mutation tests assert against what the stubbed client actually received. That turned up something I want to flag rather than quietly encode:

Whether a before_llm_call mutation reaches the payload is a pre-existing, per-provider property, not something this PR decides. Measured on this branch:

provider mutation reaches payload (sync call()) (async acall())
openai yes yes
anthropic yes yes
azure yes yes
bedrock yes yes
gemini no no

Gemini differs because its guard is fed _convert_contents_to_dict(formatted_content) — its payload is types.Content objects, not dicts, so the hook necessarily gets a converted copy. That is what main's sync path at gemini/completion.py:314 already does; I mirrored it in acall() deliberately, and it means a hook can inspect and block on Gemini but cannot rewrite its payload. Worth fixing, but it is upstream of this change and identical on both paths, so I have left it alone rather than widening the PR.

So the tests are split to say exactly that, and no more:

  • test_async_before_hook_mutation_reaches_the_provider_as_on_sync — pins async propagation to whatever the same provider's sync path does. This is the invariant this PR owns: acall() gates and forwards exactly as call() does. It fails if the async guard is ever wired to a different list than its sync twin, without asserting Gemini's copy is correct.
  • test_async_before_hook_mutation_reaches_the_provider — the symmetry check above cannot distinguish "both propagate" from "neither does", so this pins the four propagating providers by name, with Gemini skipped and the reason stated in the skip message rather than papered over.

test_async_call_runs_before_hook_that_allows also now asserts the prompt is present in the recorded payload, so the hook is known to observe the request that is really about to be issued.

Discrimination check. Reverting only the five provider source files (tests untouched) fails 18 tests and no sync test: both mutation tests on the four propagating providers (8), plus the blocking and the allowing test on all five (10). With the fix in place: 55 passed, 1 skipped in tests/hooks/test_llm_hooks.py.

On the Bedrock recursion finding, my position from the earlier reply stands: _ahandle_converse()'s follow-up request genuinely is unguarded, but _handle_converse() has the identical gap on the sync path, so it is not a regression this PR introduces and fixing it here would mean changing sync behaviour too. Happy to do it as a follow-up if you'd like it — it is a one-line guard per path plus a tool-loop test fixture.

@coderabbitai coderabbitai Bot 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.

🧹 Nitpick comments (1)
lib/crewai/tests/hooks/test_llm_hooks.py (1)

968-991: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Use a distinctive prompt for payload propagation checks.

_Recorder.payload_text is repr(self.payloads), so a field name containing "hi" would satisfy assert "hi" in rec.payload_text without the prompt being present. The Gemini fixtures use the thinking_config field, and the existing async mutation checks use "and be brief" for this exact reason. Use the same distinctive string here instead of "hi".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/crewai/tests/hooks/test_llm_hooks.py` around lines 968 - 991, Update
test_async_call_runs_before_hook_that_allows to use the distinctive prompt
string “and be brief” instead of “hi” in the llm.acall invocation and
corresponding assertions, including the seen message and rec.payload_text
checks, so payload propagation cannot pass due to a field name containing “hi”.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@lib/crewai/tests/hooks/test_llm_hooks.py`:
- Around line 968-991: Update test_async_call_runs_before_hook_that_allows to
use the distinctive prompt string “and be brief” instead of “hi” in the
llm.acall invocation and corresponding assertions, including the seen message
and rec.payload_text checks, so payload propagation cannot pass due to a field
name containing “hi”.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: dd10941d-64b2-4e34-8057-50f219d7b8a1

📥 Commits

Reviewing files that changed from the base of the PR and between ae92404 and 9a9a9f9.

📒 Files selected for processing (1)
  • lib/crewai/tests/hooks/test_llm_hooks.py

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.

before_llm_call hooks never run on async acall(), so a blocking hook fails to block the request

1 participant