Skip to content

fix: run after_llm_call hooks on async provider paths - #6737

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

fix: run after_llm_call hooks on async provider paths#6737
LHMQ878 wants to merge 2 commits into
crewAIInc:mainfrom
LHMQ878:fix/after-llm-call-hooks-async-paths

Conversation

@LHMQ878

@LHMQ878 LHMQ878 commented Jul 30, 2026

Copy link
Copy Markdown

Summary

Fixes #6736.

after_llm_call hooks never ran on acall(). The native providers invoked
_invoke_after_llm_call_hooks only from their sync handlers, so a hook registered to redact,
rewrite or audit a response was silently skipped for every async direct call — the unmodified
response reached the caller and nothing was logged.

Eight handlers were missing the call their twin makes:

provider handler before after
openai _ahandle_responses no (sync had it at :1068) yes
openai _ahandle_streaming_responses no (sync at :1376) yes
openai _ahandle_completion no (sync at :2008) yes
openai _ahandle_streaming_completion no (sync at :2298) yes
anthropic _ahandle_completion no (sync at :1104) yes
anthropic _ahandle_streaming_completion no (sync at :1307) yes
bedrock _ahandle_converse no (sync at :823) yes
bedrock _handle_streaming_converse no (async had it at :1772) yes

That last row is what shows this is drift rather than a decision about async: within one file
_ahandle_streaming_converse calls the hook and its sync twin does not, with the polarity
inverted relative to every other row.

Each call is placed where its twin already places it — after _emit_call_completed_event — and
openai's async streaming handler keeps the same isinstance(result, str) guard the sync one uses,
so structured tool-call payloads are not stringified (#6529).

_invoke_after_llm_call_hooks returns the response unchanged when from_agent is not None, so
the agent-driven path — which dispatches POST_MODEL_CALL separately via
agent_utils._setup_after_llm_call_hooks — is unaffected. No double dispatch.

Second defect in the same code

anthropic's _ahandle_completion and _ahandle_streaming_completion never called
_extract_thinking_block, so _previous_thinking_blocks stayed empty on the async path.
_format_messages_for_anthropic:851 replays that list into the assistant message on the next
turn when thinking is enabled, so the async path dropped the signed thinking block that the
sync path preserves — losing the reasoning context across turns.
test_anthropic_thinking_blocks_preserved_across_turns pins this for call() only.

Why this is worth fixing

after_llm_call is the documented interception point for the things that must not be skippable:
redacting PII before a response is stored or displayed, enforcing an output policy, and audit
logging. A guardrail that works under call() and silently stops working under acall() is
worse than one that never worked, because the failure is invisible — no exception, no warning,
and the response looks plausible.

acall() is what kickoff_async, async flows and any concurrent usage reach, so moving a
working crew from sync to async silently disabled every registered after_llm_call hook.

Type of change

  • Bug fix
  • New feature
  • Breaking change
  • Improvement

Evidence

No network — the SDK client is stubbed and the same payload is served to both paths.

Before, on main (3266932):

anthropic sync : 'REDACTED BY HOOK'                     hook fired: 1  thinking kept: [{...signature...}]
anthropic async: 'the model said something sensitive'   hook fired: 0  thinking kept: []
openai    sync : 'REDACTED BY HOOK'                     hook fired: 1
openai    async: 'the model said something sensitive'   hook fired: 0

After:

anthropic sync : 'REDACTED BY HOOK'   hook: 1  thinking: [{...signature...}]
anthropic async: 'REDACTED BY HOOK'   hook: 1  thinking: [{...signature...}]
openai    sync : 'REDACTED BY HOOK'   hook: 1
openai    async: 'REDACTED BY HOOK'   hook: 1

The hook was not merely failing to modify the response on the async path — it was never invoked,
so an observe-only hook (logging, auditing, metrics) recorded nothing either.

Tests

11 tests appended to lib/crewai/tests/hooks/test_llm_hooks.py. No network; the existing
clear_hooks autouse fixture handles isolation.

test role
TestAfterLLMCallHooksOnAsyncPaths::test_sync_call_runs_after_hook the baseline the async path is compared against (parameterised over openai + anthropic)
::test_async_call_runs_after_hook the regression — used to return the unredacted response
::test_async_call_without_hooks_is_unchanged negative control: with no hook registered the response passes through untouched
::test_async_call_hook_returning_none_keeps_response a hook returning None observes without replacing
TestAnthropicThinkingBlocksOnAsyncPath::test_sync_call_stores_thinking_blocks baseline
::test_async_call_stores_thinking_blocks the regression
::test_async_thinking_blocks_are_replayed_on_the_next_turn asserts the stored block reaches the follow-up request with its signature intact

The negative controls matter: without them, a "fix" that unconditionally rewrote the response, or
that stuffed something into _previous_thinking_blocks regardless of content, would pass.

Control experiment

Reverting the three source files and keeping the tests:

6 failed, 31 passed

FAILED TestAfterLLMCallHooksOnAsyncPaths::test_async_call_runs_after_hook[openai]
FAILED TestAfterLLMCallHooksOnAsyncPaths::test_async_call_runs_after_hook[anthropic]
FAILED TestAfterLLMCallHooksOnAsyncPaths::test_async_call_hook_returning_none_keeps_response[openai]
FAILED TestAfterLLMCallHooksOnAsyncPaths::test_async_call_hook_returning_none_keeps_response[anthropic]
FAILED TestAnthropicThinkingBlocksOnAsyncPath::test_async_call_stores_thinking_blocks
FAILED TestAnthropicThinkingBlocksOnAsyncPath::test_async_thinking_blocks_are_replayed_on_the_next_turn

Exactly the six tests that depend on the fix fail; the sync baselines, the negative control and
all 26 pre-existing tests in the file pass unchanged.

Suite runs

  • lib/crewai/tests/hooks/test_llm_hooks.py37 passed (26 pre-existing + 11 new)
  • lib/crewai/tests/llms/anthropic/ — 66 passed, 0 failed
  • lib/crewai/tests/llms/openai/ — 150 passed, 1 skipped, 0 failed
  • lib/crewai/tests/llms/bedrock/ — 35 passed, 8 skipped, 0 failed
  • ruff format --check3 files already formatted
  • ruff checkAll checks passed!

The provider runs report teardown errors on my machine (PermissionError: [WinError 32] while
shutil.rmtree removes crewai_test_storage/latest_kickoff_task_outputs.db) — a Windows sqlite
file-handle artifact in conftest.py, present on unmodified main and unrelated to this change.
No test failed.

Additional notes

Scope: azure and gemini call the hook on neither path. That is a symmetric gap — those
providers do not support direct-call hooks at all — rather than sync/async drift, so I left it
out; it needs a separate decision. Happy to follow up if wanted.

Searched open PRs and issues for after_llm_call, acall hooks, _invoke_after_llm_call_hooks
and thinking blocks async — no existing report or PR. The scan that found this and the tests
were developed with AI assistance; every claim above was verified by running it.

@coderabbitai

coderabbitai Bot commented Jul 30, 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: 739d5b24-99f9-4652-b173-04fbfd012884

📥 Commits

Reviewing files that changed from the base of the PR and between 119706a and b3c4f1a.

📒 Files selected for processing (2)
  • lib/crewai/src/crewai/llms/providers/openai/completion.py
  • lib/crewai/tests/hooks/test_llm_hooks.py

📝 Walkthrough

Walkthrough

Async OpenAI, Anthropic, and Bedrock handlers now invoke after-call hooks. Anthropic async handlers also retain thinking blocks, with regression tests covering hook behavior, structured-output fallbacks, and multi-turn thinking replay.

Changes

Async LLM provider paths

Layer / File(s) Summary
Provider post-call hook integration
lib/crewai/src/crewai/llms/providers/{openai,anthropic,bedrock}/completion.py
Finalized async and streaming responses now pass through _invoke_after_llm_call_hooks; non-string OpenAI streaming results remain unchanged.
Anthropic async thinking preservation
lib/crewai/src/crewai/llms/providers/anthropic/completion.py
Async Anthropic handlers extract thinking blocks and persist them in _previous_thinking_blocks.
Hook and thinking regression coverage
lib/crewai/tests/hooks/test_llm_hooks.py
Stub responses and tests verify hook execution, unchanged responses without hooks, None hook results, structured-output fallback handling, and replayed Anthropic thinking blocks.

Suggested reviewers: lorenzejay, lucasgomide, alex-clawd

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 62.07% 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
Title check ✅ Passed The title is concise and accurately summarizes the main async hook fix.
Description check ✅ Passed The description clearly matches the implemented async hook and Anthropic thinking-block fixes.
Linked Issues check ✅ Passed The PR implements the requested async hook calls, structured-output fallback, and Anthropic thinking-block preservation.
Out of Scope Changes check ✅ Passed The changes stay within the async hook fix and related Anthropic and streaming test coverage.
✨ 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.

The native providers invoked `_invoke_after_llm_call_hooks` only from their
sync handlers, so a hook registered to redact, rewrite or audit a response was
silently skipped for every async direct call -- the unmodified response reached
the caller with nothing logged.

Eight handlers were missing the call their twin makes: openai's four async
handlers, anthropic's two, bedrock's `_ahandle_converse`, and -- with the
polarity inverted -- bedrock's sync `_handle_streaming_converse`. That last one
sits in the same file as `_ahandle_streaming_converse`, which does call the
hook, which is what shows this is drift rather than a decision about async.

Each call is placed where its twin already places it, and the async streaming
handler for openai keeps the same `isinstance(result, str)` guard so structured
tool-call payloads are not stringified.

Also fixes a second defect in the same code: anthropic's two async handlers
never called `_extract_thinking_block`, leaving `_previous_thinking_blocks`
empty. `_format_messages_for_anthropic` replays that list into the assistant
message on the next turn when thinking is enabled, so the async path dropped
the signed thinking block that the sync path preserves.

`_invoke_after_llm_call_hooks` returns the response unchanged when `from_agent`
is not None, so the agent-driven path -- which dispatches POST_MODEL_CALL
separately via `agent_utils._setup_after_llm_call_hooks` -- is unaffected.

Fixes crewAIInc#6736
@LHMQ878
LHMQ878 force-pushed the fix/after-llm-call-hooks-async-paths branch from f065483 to 119706a Compare July 30, 2026 18:23

@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: 3

Caution

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

⚠️ Outside diff range comments (2)
lib/crewai/src/crewai/llms/providers/openai/completion.py (2)

2636-2651: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Do not apply LLM hooks to string tool results.

_finalize_streaming_response can return a string from available_functions; this isinstance guard then sends that tool output through after_llm_call hooks. That differs from the async non-streaming and sync paths and can corrupt tool results through redaction or rewriting. Hook only the finalized model-text path, or return an explicit result type indicating its origin.

🤖 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/openai/completion.py` around lines 2636
- 2651, The streaming completion flow after _finalize_streaming_response must
not apply _invoke_after_llm_call_hooks to strings originating from
available_functions. Distinguish finalized tool outputs from finalized model
text, and invoke hooks only for the model-text result while returning tool
results unchanged; keep non-string finalized responses unchanged.

2636-2651: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Hook the structured-output streaming fallback.

When response_model validation fails, the earlier branch returns accumulated_content directly, so this new tail is never reached. A direct async call can therefore return an unhooked string; route that fallback through _invoke_after_llm_call_hooks(...) before returning.

🤖 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/openai/completion.py` around lines 2636
- 2651, Update the response-model validation fallback in the streaming
completion flow so its accumulated string output is passed through
_invoke_after_llm_call_hooks(...) before returning. Ensure the direct async path
receives the same hook treatment while preserving the existing structured-result
return behavior, and use the nearby finalization logic in the completion method
as the implementation reference.
🧹 Nitpick comments (1)
lib/crewai/tests/hooks/test_llm_hooks.py (1)

869-886: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Assert the follow-up request instead of the private formatter.

Calling _format_messages_for_anthropic tests implementation details and can pass even if acall() later stops sending those blocks. Capture the second stubbed messages.create() arguments, make a second acall(), and assert its assistant content contains the signed thinking block.

🤖 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 869 - 886, Update the
test to verify the follow-up request through the public acall() flow instead of
directly calling the private _format_messages_for_anthropic method. Capture the
second messages.create() invocation from the stubbed client, perform a second
acall(), and assert that its assistant content includes the signed thinking
block with the expected type, thinking text, and signature.

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.

Inline comments:
In `@lib/crewai/src/crewai/llms/providers/anthropic/completion.py`:
- Around line 1621-1633: Update the Anthropic response handling around the
tool-use early returns so content-block extraction and
`_previous_thinking_blocks` persistence occur before any return for tool calls.
Ensure async non-streaming responses containing signed thinking blocks plus tool
calls retain those blocks for the next turn, and add a regression test covering
this combination.

In `@lib/crewai/src/crewai/llms/providers/bedrock/completion.py`:
- Around line 1429-1433: Update the empty Bedrock response handling in the
completion flow so it assigns the fallback string to the normal response
variable instead of returning early. Ensure async direct calls continue through
the shared completion/event path and reach _invoke_after_llm_call_hooks for
auditing or rewriting.

In `@lib/crewai/tests/hooks/test_llm_hooks.py`:
- Around line 717-764: Expand TestAfterLLMCallHooksOnAsyncPaths.llm and its
supporting stubs to cover Bedrock and OpenAI Responses async handlers, plus
streaming cases. Include a streaming structured tool-call result and assert
after_llm_call hooks still run while preserving the streaming type guard; retain
the existing non-streaming OpenAI Chat Completions and Anthropic coverage.

---

Outside diff comments:
In `@lib/crewai/src/crewai/llms/providers/openai/completion.py`:
- Around line 2636-2651: The streaming completion flow after
_finalize_streaming_response must not apply _invoke_after_llm_call_hooks to
strings originating from available_functions. Distinguish finalized tool outputs
from finalized model text, and invoke hooks only for the model-text result while
returning tool results unchanged; keep non-string finalized responses unchanged.
- Around line 2636-2651: Update the response-model validation fallback in the
streaming completion flow so its accumulated string output is passed through
_invoke_after_llm_call_hooks(...) before returning. Ensure the direct async path
receives the same hook treatment while preserving the existing structured-result
return behavior, and use the nearby finalization logic in the completion method
as the implementation reference.

---

Nitpick comments:
In `@lib/crewai/tests/hooks/test_llm_hooks.py`:
- Around line 869-886: Update the test to verify the follow-up request through
the public acall() flow instead of directly calling the private
_format_messages_for_anthropic method. Capture the second messages.create()
invocation from the stubbed client, perform a second acall(), and assert that
its assistant content includes the signed thinking block with the expected type,
thinking text, and signature.
🪄 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: 1d701871-86c8-4f35-82bc-e71cd5dc14a5

📥 Commits

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

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

Comment thread lib/crewai/src/crewai/llms/providers/anthropic/completion.py
Comment thread lib/crewai/src/crewai/llms/providers/bedrock/completion.py
Comment on lines +717 to +764
llm = OpenAICompletion(model="gpt-4o", api_key="stub", stream=False)
llm._client = SimpleNamespace(chat=SimpleNamespace(completions=_Completions()))
llm._async_client = SimpleNamespace(
chat=SimpleNamespace(completions=_AsyncCompletions())
)
return llm


def _stub_anthropic_llm():
from types import SimpleNamespace

from crewai.llms.providers.anthropic.completion import AnthropicCompletion

class _Messages:
def create(self, **kwargs: object) -> object:
return _anthropic_response("the model said SECRET")

class _AsyncMessages:
async def create(self, **kwargs: object) -> object:
return _anthropic_response("the model said SECRET")

llm = AnthropicCompletion(
model="claude-sonnet-4-5",
api_key="stub",
thinking={"type": "enabled", "budget_tokens": 1024},
max_tokens=2048,
stream=False,
)
llm._client = SimpleNamespace(messages=_Messages())
llm._async_client = SimpleNamespace(messages=_AsyncMessages())
return llm


class TestAfterLLMCallHooksOnAsyncPaths:
"""``acall()`` must run after_llm_call hooks exactly as ``call()`` does.

Regression: the native providers invoked ``_invoke_after_llm_call_hooks``
only from their sync handlers, so a hook registered to redact or rewrite a
response was silently skipped for every async direct call — the response
reached the caller unmodified with nothing logged.
"""

@pytest.fixture(params=["openai", "anthropic"])
def llm(self, request):
return {
"openai": _stub_openai_llm,
"anthropic": _stub_anthropic_llm,
}[request.param]()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Cover the remaining changed async handler paths.

This fixture matrix only exercises non-streaming OpenAI Chat Completions and Anthropic Messages. Add cases for Bedrock, OpenAI Responses, and streaming—including a structured tool-call result—to validate the PR’s affected-handler and streaming-type-guard contracts.

🤖 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 717 - 764, Expand
TestAfterLLMCallHooksOnAsyncPaths.llm and its supporting stubs to cover Bedrock
and OpenAI Responses async handlers, plus streaming cases. Include a streaming
structured tool-call result and assert after_llm_call hooks still run while
preserving the streaming type guard; retain the existing non-streaming OpenAI
Chat Completions and Anthropic coverage.

Source: Coding guidelines

@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/src/crewai/llms/providers/openai/completion.py (1)

2636-2651: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Hook the structured-streaming text fallback.

When structured parsing fails, Line 2560 returns accumulated_content before this string guard. That raw fallback can bypass redaction/audit hooks; preserve the BaseModel guard but route the fallback string through the hook.

Proposed fix
             except Exception as e:
                 logging.error(f"Failed to parse structured output from stream: {e}")
                 self._emit_call_completed_event(
                     response=accumulated_content,
                     ...
                 )
-                return accumulated_content
+                return self._invoke_after_llm_call_hooks(
+                    params["messages"], accumulated_content, from_agent
+                )
🤖 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/openai/completion.py` around lines 2636
- 2651, Update the structured-streaming fallback in the surrounding
response-finalization flow so the accumulated text returned when parsing fails
is passed through _invoke_after_llm_call_hooks before returning. Preserve the
existing BaseModel result guard and the current hook arguments used by the
isinstance(result, str) path, ensuring both string outcomes are audited and
redacted consistently.
🤖 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/src/crewai/llms/providers/openai/completion.py`:
- Around line 2636-2651: Update the structured-streaming fallback in the
surrounding response-finalization flow so the accumulated text returned when
parsing fails is passed through _invoke_after_llm_call_hooks before returning.
Preserve the existing BaseModel result guard and the current hook arguments used
by the isinstance(result, str) path, ensuring both string outcomes are audited
and redacted consistently.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: f9cbcfbf-54a1-4c2a-b778-740ac40f30d5

📥 Commits

Reviewing files that changed from the base of the PR and between f065483 and 119706a.

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

When `response_model.model_validate_json` raises,
`_ahandle_streaming_completion` returns the accumulated text from inside
the `response_model` branch -- upstream of the hook call this PR added at
the end of the method. So a direct async call whose structured output
failed to parse returned raw model text with `after_llm_call` never
running: nothing redacted, nothing audited.

That text is the same kind of value every other path here hands to the
hooks, and the method already reports it as an LLM_CALL immediately
before returning it, so the hook has to see it.

Only the async path is affected; the sync handler's `response_model`
branch has no equivalent raw-text fallback.

Two tests: the fallback runs the hook, and without a hook registered the
text passes through untouched. Reverting only the source change fails
exactly the first.
@LHMQ878

LHMQ878 commented Jul 30, 2026

Copy link
Copy Markdown
Author

Went through all five findings against the code. One was a real bug and is fixed in b3c4f1a; three describe pre-existing behaviour that this PR deliberately mirrors rather than introduces; one I'd rather not do as suggested.

Fixed: the structured-output streaming fallback (accumulated_content)

This one was right, and it was mine. In _ahandle_streaming_completion, when response_model.model_validate_json raises, the except returns accumulated_content from inside the response_model branch — upstream of the hook call I added at the end of the method. So a direct async call whose structured output failed to parse returned raw model text with after_llm_call never running.

What decided it: the method already reports that same string as an LLM_CALL via _emit_call_completed_event on the line before returning it. So the codebase already treats it as a model response; skipping the hooks for it was inconsistent, not intentional.

I checked whether this belonged in this PR or is pre-existing: the sync _handle_streaming_completion's response_model branch returns parsed_result or "" and has no raw-text fallback, so the gap exists only on the async path — in scope here.

Two tests. Reverting only the source change fails exactly one of them (the regression) and leaves the other (no-hook pass-through) passing.

Not changed: hooks on string tool results

That differs from the async non-streaming and sync paths and can corrupt tool results through redaction or rewriting.

The underlying concern is real — _finalize_streaming_response is documented as returning "tool execution result when available_functions is provided", and _handle_tool_execution is typed -> str | None, so isinstance(result, str) genuinely cannot tell model text from tool output.

But it does not differ from the sync path. The sync streaming path at openai/completion.py:2303 is:

if isinstance(result, str):
    return self._invoke_after_llm_call_hooks(
        params["messages"], result, from_agent
    )
return result

and my async block is byte-identical to it — I compared the two five-line blocks programmatically, not by eye. git blame puts the sync one in #6690 ("feat(tools): add WaitTool…"), not in this PR. So this PR makes the async path match the sync path; it does not create a divergence.

That leaves the question of whether the shared behaviour is wrong. I think it may well be, but fixing it means changing what the sync path does too, and the suggested remedy — "return an explicit result type indicating its origin" — changes _finalize_streaming_response's return contract, which four call sites and the executor depend on. That is a different PR from "run the hooks on async paths", and doing it here would mean this PR silently changes sync behaviour as well. Happy to open it separately if a maintainer agrees the shared behaviour is a bug.

Not changed: the Bedrock empty-response fallback

Same shape. The async early return at bedrock/completion.py:1301 is character-for-character the sync one at :696, and the async hook site I added at :1429 mirrors the pre-existing sync one at :823 — which #6690 added, with the same apology-string return upstream of it. So the async path now behaves exactly like the sync path.

There's also a substantive reason not to route it through the hook: "I apologize, but I received an empty response. Please try again." is a string this client synthesises when Bedrock returns no content. It never came from the model. Passing it to after_llm_call would report a model response that does not exist — which for an auditing hook is worse than the current gap.

On the test-coverage finding

Fair that the matrix is narrow. I'd push back on part of it though: a streaming structured-tool-call case would be asserting the isinstance guard, and per the above I don't think this PR should be pinning down behaviour it inherited from #6690 — if that guard is wrong, a test here would make it harder to change. The Bedrock and OpenAI-Responses async handlers are worth adding and I'll do that if you'd like; say the word and I'll extend the fixture matrix.

Verification

pytest lib/crewai/tests/hooks/test_llm_hooks.py — 39 passed. (Locally I have to pass -o addopts=... to drop --block-network: on Windows asyncio's event-loop setup uses socket.socketpair(), which the network guard rejects. Pre-existing async suites like tests/a2a/test_a2a_integration.py error the same way on this machine, so it isn't specific to these tests.) ruff check clean.

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.

after_llm_call hooks never run on acall(): eight native provider handlers skip _invoke_after_llm_call_hooks

2 participants