Skip to content

feat(hooks): add optional agent-hooks governance engine - #6710

Open
prayagupa wants to merge 5 commits into
crewAIInc:mainfrom
prayagupa:feat/agent-hooks-control-engine
Open

feat(hooks): add optional agent-hooks governance engine#6710
prayagupa wants to merge 5 commits into
crewAIInc:mainfrom
prayagupa:feat/agent-hooks-control-engine

Conversation

@prayagupa

Copy link
Copy Markdown

Summary

Adds agent-hooks as an optional, framework-neutral control engine for CrewAI. Policies, content filters, approval gates, and egress guards can be registered once and applied across execution, model, and tool lifecycle points.

  • Lazily loads agent-hooks-sdk through the agent-hooks extra: See: https://github.com/responsibleai/agent-hooks/blob/main/spec/AGENT-HOOKS-0.1.md
  • Appends the injected governor after CrewAI global and scoped hooks.
  • Applies transforms to the effective target and fails closed on denial, timeout, malformed context, or engine failure.
  • Uses per-run sessions and host-owned correlation IDs for auditable pre/post records.
  • Covers direct and executor model calls plus CrewAI and native-provider tool paths, with remaining provider-specific exclusions documented explicitly.

Architecture

flowchart LR
    subgraph Runtime["CrewAI runtime"]
        B["Crew and flow boundaries"]
        M["Model call paths"]
        T["Tool call paths"]
        H["CrewAI hook seams"]
        N["Global and scoped hooks"]
        G["Injected governor<br/>authoritative final hook"]
    end

    subgraph Engine["Optional agent-hooks control engine"]
        A["Point adapter<br/>schema-valid context and correlation"]
        L["Dedicated async emitter loop"]
        I["Registered interceptors<br/>policy, filter, approval, egress"]
        V{"Combined verdict"}
        R["Interception records<br/>buffer or record sink"]
    end

    B --> H
    M --> H
    T --> H
    H --> N --> G
    G --> A --> L --> I --> V
    V -->|allow| P["Proceed unchanged"]
    V -->|transform| X["Apply effective target"]
    V -->|deny, timeout, or error| D["Fail closed"]
    V --> R
Loading

How it works

  1. use_agent_hooks(...) lazily loads the optional SDK, configures the emitter and interceptors, and installs the engine as the process governor.
  2. CrewAI global and execution-scoped hooks run first. The governor adapter is appended last for supported lifecycle points.
  3. Each adapter validates and normalizes untrusted hook data, adds per-run session and correlation metadata, and submits the context to a dedicated async emitter loop.
  4. The combined verdict either allows the action, applies the effective transformed target, or fails closed. Pre-point denial prevents the action; post-point denial replaces the result with a blocked marker.
  5. Every decision produces an auditable record available from engine.records, take_records(), or a configured record sink.

Coverage

The engine maps CrewAI execution start/end, input/output, model pre/post, and tool pre/post points to the eight agent-hooks control points. CrewAI PRE_STEP and POST_STEP remain native. Direct provider-specific async calls and direct native-provider Pydantic responses are not yet uniformly governed and are documented as exclusions.

Validation

  • uv run mypy lib/ with and without agent-hooks-sdk installed
  • uv run ruff format --check and uv run ruff check --no-fix on changed sources
  • uv lock --check
  • uv run --package crewai --with "agent-hooks-sdk==0.1.0a3" pytest lib/crewai/tests -q

Delegate crewAI's dispatch lifecycle points to the framework-neutral
agent-hooks contract via a dependency-injected governor, so policy
engines, content filters, and egress guards written once as agent-hooks
Interceptors govern the tool, model, and execution-boundary seams.

- hooks/agent_hooks_engine.py: AgentHooksEngine + use_agent_hooks(),
  lazy optional import, fail-closed emission on a dedicated event loop
- hooks/dispatch.py: set/clear/get_governor + governed_hook() injection
- utilities/agent_utils.py: govern the executor pre/post model-call seam
- hooks/__init__.py: lazy engine exports; docs + tests
…aths

The executor model-call seam now appends the same governor via
governed_hook(), so PRE_MODEL_CALL / POST_MODEL_CALL are governed on both
the dispatch and agent-executor (sync/async) paths. Flip the coverage
table to governed, replace the stale partial-coverage warning with an
accurate note, and clarify the injection in 'How it works'.
Declare `agent-hooks` as an optional-dependency extra scoped to Linux x86_64 (the only platform with a published wheel) so CI's `uv sync --all-extras` installs it and the governance tests run instead of skipping. Locks agent-hooks-sdk==0.1.0a3 and updates the install docs to `pip install "crewai[agent-hooks]"`.
…ation

- Break reference cycles in _json_safe with a <cycle> marker so a cyclic tool input cannot raise RecursionError before the decision (fail-closed).
- Wrap every per-point adapter so a context-build error fails closed (raise / blocked-result) instead of escaping dispatch and proceeding ungoverned.
- Stamp the acting agent on each emission and derive a stable pre/post tool-call id so the two contexts correlate.
- Surface model tool_calls/finish_reason at post_model_call.
- Drain pending emissions on _EmitterLoop.close() to release a blocked thread. Add regression tests.
Carry host-owned correlation IDs through model and tool seams, fail closed on invalid transforms, govern async/native/structured response paths, and make session lifecycle records schema-conformant.

Pin the optional SDK and add regression coverage for the review findings.

Signed-off-by: Prayag Upadhyay <prayag.upd@gmail.com>
@coderabbitai

coderabbitai Bot commented Jul 29, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Adds an optional agent-hooks governance engine with fail-closed interception across crewAI execution, tool calls, and model calls. It adds correlation metadata, transformed-input cache ordering, lifecycle controls, documentation, and comprehensive tests.

Changes

Agent Hooks Governance

Layer / File(s) Summary
Engine foundation and public integration
lib/crewai/pyproject.toml, lib/crewai/src/crewai/hooks/*
Adds the optional dependency, lazy exports, interception engine, lifecycle adapters, fail-closed decisions, and dispatcher governor integration.
Agent hooks documentation
docs/docs.json, docs/edge/en/learn/agent-hooks.mdx
Documents installation, interception semantics, lifecycle mappings, configuration, records, and integration behavior.
Tool-call governance and execution state
lib/crewai/src/crewai/agents/..., lib/crewai/src/crewai/llm.py, lib/crewai/src/crewai/llms/base_llm.py, lib/crewai/src/crewai/tools/..., lib/crewai/src/crewai/utilities/...
Routes tool execution through hooks, delays cache reads until transformed inputs are available, correlates calls, and tracks blocked or failed execution.
Model-call governance and correlation
lib/crewai/src/crewai/llm.py, lib/crewai/src/crewai/llms/base_llm.py, lib/crewai/src/crewai/hooks/llm_hooks.py, lib/crewai/src/crewai/utilities/agent_utils.py
Propagates request identifiers, governs structured model responses, reorders async hook gates, and applies post-call transformations.
Governance and execution validation
lib/crewai/tests/hooks/*, lib/crewai/tests/tools/*, lib/crewai/tests/utilities/*, lib/crewai/tests/agents/*
Adds coverage for engine lifecycle, interception decisions, session scoping, transformed inputs, cache behavior, and failure states.

Sequence Diagram(s)

sequenceDiagram
  participant CrewAI
  participant AgentHooksEngine
  participant InterceptionEmitter
  CrewAI->>AgentHooksEngine: invoke governed hook
  AgentHooksEngine->>InterceptionEmitter: emit interception context
  InterceptionEmitter-->>AgentHooksEngine: return verdict
  AgentHooksEngine-->>CrewAI: allow, deny, or transform execution
Loading

Suggested reviewers: lorenzejay, lucasgomide, vinibrsl

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 40.88% 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 change: adding an optional agent-hooks governance engine.
Description check ✅ Passed The description directly matches the PR changes and objectives, covering the optional governance engine, scope, and validation.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ 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.

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 438dc75b8d

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +2051 to +2054
raise ValueError(
f"Hook-modified response failed to reparse as "
f"{type(pydantic_answer).__name__}: {e}"
) from e

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Return post-model denies before reparsing models

When an executor call returns a Pydantic response_model and the agent-hooks governor denies POST_MODEL_CALL, the governor supplies a plain blocked marker string, but this path immediately tries to parse any changed hook response back into the original model. In that scenario the blocked marker is not JSON for the model, so the post-model denial turns into a ValueError instead of the fail-closed replacement response that the new post hook path is supposed to return for governed Pydantic responses.

Useful? React with 👍 / 👎.

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

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/utilities/agent_utils.py (1)

2045-2054: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Restrict strict Pydantic reparsing to governed post-model hooks.

This block runs for every registered after_llm_call hook chain, not only the optional agent-hooks governance adapter. A generic hook returning a modified string will now hard-fail on pydantic response_model output, changing the general after_llm_call hook contract. Scope this behavior to governance hooks or document/emit the breaking contract clearly for all hook consumers.

🤖 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/utilities/agent_utils.py` around lines 2045 - 2054,
Restrict the strict Pydantic reparsing in the hook-modified response path to the
governed post-model hooks handled by the agent-hooks governance adapter, rather
than every after_llm_call hook chain. Preserve the existing generic hook
contract by bypassing this model_validate_json validation for non-governance
hooks, using the surrounding hook identification or governance adapter symbol to
distinguish the paths.
🧹 Nitpick comments (1)
lib/crewai/tests/tools/test_tool_usage.py (1)

278-294: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider an autouse fixture for global hook cleanup.

Both tests hand-roll clear_* + try/finally, and each clears only the hook type it registers, so a hook leaked by another module (or by a failure between register_* and try) still bleeds in. A small autouse fixture clearing both registries before and after each test would make this suite order-independent.

Also applies to: 326-340

🤖 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/tools/test_tool_usage.py` around lines 278 - 294, Replace
the duplicated manual cleanup around the synchronous and asynchronous tool
execution tests with an autouse fixture that clears both before-tool-call and
after-tool-call hook registries before and after every test. Remove the local
clear/try/finally blocks while preserving the existing test execution paths.
🤖 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 `@docs/edge/en/learn/agent-hooks.mdx`:
- Line 84: Update docs/edge/en/learn/agent-hooks.mdx lines 84-84 to call
AgentHooksEngine.records as engine.records() when printing the auditable
records; update lines 173-175 to document engine.records() as the non-draining
accessor.

In `@lib/crewai/src/crewai/agents/crew_agent_executor.py`:
- Around line 1023-1035: Update the after-hook governance context in
_execute_single_native_tool_call and execute_single_native_tool_call so is_error
also becomes true for unresolved, non-cached tool calls where output_tool is
None. Apply this in both lib/crewai/src/crewai/agents/crew_agent_executor.py
lines 1023-1035 and lib/crewai/src/crewai/utilities/agent_utils.py lines
1730-1742, preserving each method’s existing error flags.

In `@lib/crewai/src/crewai/experimental/agent_executor.py`:
- Around line 2056-2058: Update the post-tool governance record’s is_error
calculation near call_id and was_blocked to also treat the output_tool is
None/tool-not-found path as an error. Preserve the existing hook_blocked,
max_usage_reached, and error_event_emitted conditions while ensuring a missing
tool cannot be recorded as successful.

In `@lib/crewai/src/crewai/hooks/agent_hooks_engine.py`:
- Around line 610-641: Update _active_sessions bookkeeping in
_current_session_id, _begin_session, and _finish_session to retain and validate
the owner object rather than relying solely on id(owner). Use weak references or
an equivalent identity check so garbage-collected owners cannot cause recycled
addresses to inherit stale sessions or builders, while preserving current
session stack behavior.

In `@lib/crewai/src/crewai/llm.py`:
- Around line 1768-1771: Ensure argument parsing and dictionary validation in
the tool-call flow completes before the region guarded by the before/after hook
lifecycle, or track whether `run_before_tool_call_hooks` actually ran and only
invoke the corresponding after-hooks when it did. Update both affected paths
around `tool_call.function.arguments` so parse failures cannot emit an orphan
`post_tool_call` for the call_id.

In `@lib/crewai/src/crewai/utilities/agent_utils.py`:
- Around line 1877-1896: Replace the string-type check on aborted.source in the
HookAborted handler with a dedicated sentinel marker owned by AgentHooksEngine,
and recognize only that marker as an agent-hooks abort. Preserve the existing
generic message and False return for all other sources, including custom string
sources, while retaining reason printing and ValueError propagation for the
sentinel case.

---

Outside diff comments:
In `@lib/crewai/src/crewai/utilities/agent_utils.py`:
- Around line 2045-2054: Restrict the strict Pydantic reparsing in the
hook-modified response path to the governed post-model hooks handled by the
agent-hooks governance adapter, rather than every after_llm_call hook chain.
Preserve the existing generic hook contract by bypassing this
model_validate_json validation for non-governance hooks, using the surrounding
hook identification or governance adapter symbol to distinguish the paths.

---

Nitpick comments:
In `@lib/crewai/tests/tools/test_tool_usage.py`:
- Around line 278-294: Replace the duplicated manual cleanup around the
synchronous and asynchronous tool execution tests with an autouse fixture that
clears both before-tool-call and after-tool-call hook registries before and
after every test. Remove the local clear/try/finally blocks while preserving the
existing test execution paths.
🪄 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: d2337f25-444a-4f06-99ea-e1685872d7ca

📥 Commits

Reviewing files that changed from the base of the PR and between f15844b and 438dc75.

⛔ Files ignored due to path filters (1)
  • uv.lock is excluded by !**/*.lock
📒 Files selected for processing (19)
  • docs/docs.json
  • docs/edge/en/learn/agent-hooks.mdx
  • lib/crewai/pyproject.toml
  • lib/crewai/src/crewai/agents/crew_agent_executor.py
  • lib/crewai/src/crewai/experimental/agent_executor.py
  • lib/crewai/src/crewai/hooks/__init__.py
  • lib/crewai/src/crewai/hooks/agent_hooks_engine.py
  • lib/crewai/src/crewai/hooks/dispatch.py
  • lib/crewai/src/crewai/hooks/llm_hooks.py
  • lib/crewai/src/crewai/hooks/tool_hooks.py
  • lib/crewai/src/crewai/llm.py
  • lib/crewai/src/crewai/llms/base_llm.py
  • lib/crewai/src/crewai/tools/tool_usage.py
  • lib/crewai/src/crewai/utilities/agent_utils.py
  • lib/crewai/src/crewai/utilities/tool_utils.py
  • lib/crewai/tests/agents/test_native_tool_calling.py
  • lib/crewai/tests/hooks/test_agent_hooks_engine.py
  • lib/crewai/tests/tools/test_tool_usage.py
  • lib/crewai/tests/utilities/test_agent_utils.py

```python
with use_agent_hooks(ToolAllowlist(allowed={"web_search"})) as engine:
crew.kickoff(inputs=...)
print(engine.records) # auditable InterceptionRecord list

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 | 🟡 Minor | ⚡ Quick win

Call records() in both examples. AgentHooksEngine.records is a method, so the current snippets expose a bound method rather than the auditable record list.

  • docs/edge/en/learn/agent-hooks.mdx#L84-L84: change to print(engine.records()).
  • docs/edge/en/learn/agent-hooks.mdx#L173-L175: document engine.records() as the non-draining accessor.
📍 Affects 1 file
  • docs/edge/en/learn/agent-hooks.mdx#L84-L84 (this comment)
  • docs/edge/en/learn/agent-hooks.mdx#L173-L175
🤖 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 `@docs/edge/en/learn/agent-hooks.mdx` at line 84, Update
docs/edge/en/learn/agent-hooks.mdx lines 84-84 to call AgentHooksEngine.records
as engine.records() when printing the auditable records; update lines 173-175 to
document engine.records() as the non-draining accessor.

Comment on lines 1023 to 1035
after_hook_context = ToolCallHookContext(
tool_name=func_name,
tool_input=args_dict or {},
tool_input=args_dict,
tool=structured_tool,
agent=self.agent,
task=self.task,
crew=self.crew,
tool_result=result,
raw_tool_result=raw_tool_result,
call_id=governance_call_id,
is_error=hook_blocked or max_usage_reached or error_event_emitted,
was_blocked=hook_blocked,
)

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 | ⚡ Quick win

is_error doesn't cover the "tool not found"/unmatched-tool fallthrough in either native-tool-call implementation.

In both _execute_single_native_tool_call (crew_agent_executor.py) and execute_single_native_tool_call (agent_utils.py), result/raw_tool_result default to "Tool not found" and stay that way whenever the call falls through all branches (no hook block, no cache hit, and either func_name not in available_functions or output_tool is None). None of the flags feeding is_error (hook_blocked, max_usage_reached, error_event_emitted) become True in that fallthrough, so the after-hook governance context reports is_error=False for a call that actually failed to resolve to any tool — undercounting failures for any policy/audit relying on this flag.

  • lib/crewai/src/crewai/agents/crew_agent_executor.py#L1023-L1035: extend is_error to also cover the unresolved-tool fallthrough, e.g. is_error=hook_blocked or max_usage_reached or error_event_emitted or (not from_cache and output_tool is None).
  • lib/crewai/src/crewai/utilities/agent_utils.py#L1730-L1742: apply the same fix, e.g. is_error=hook_blocked or error_event_emitted or (not from_cache and output_tool is None).
🐛 Proposed fix
# lib/crewai/src/crewai/agents/crew_agent_executor.py
-            is_error=hook_blocked or max_usage_reached or error_event_emitted,
+            is_error=(
+                hook_blocked
+                or max_usage_reached
+                or error_event_emitted
+                or (not from_cache and output_tool is None)
+            ),
# lib/crewai/src/crewai/utilities/agent_utils.py
-        is_error=hook_blocked or error_event_emitted,
+        is_error=hook_blocked or error_event_emitted or (not from_cache and output_tool is None),
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
after_hook_context = ToolCallHookContext(
tool_name=func_name,
tool_input=args_dict or {},
tool_input=args_dict,
tool=structured_tool,
agent=self.agent,
task=self.task,
crew=self.crew,
tool_result=result,
raw_tool_result=raw_tool_result,
call_id=governance_call_id,
is_error=hook_blocked or max_usage_reached or error_event_emitted,
was_blocked=hook_blocked,
)
after_hook_context = ToolCallHookContext(
tool_name=func_name,
tool_input=args_dict,
tool=structured_tool,
agent=self.agent,
task=self.task,
crew=self.crew,
tool_result=result,
raw_tool_result=raw_tool_result,
call_id=governance_call_id,
is_error=(
hook_blocked
or max_usage_reached
or error_event_emitted
or (not from_cache and output_tool is None)
),
was_blocked=hook_blocked,
)
📍 Affects 2 files
  • lib/crewai/src/crewai/agents/crew_agent_executor.py#L1023-L1035 (this comment)
  • lib/crewai/src/crewai/utilities/agent_utils.py#L1730-L1742
🤖 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/agents/crew_agent_executor.py` around lines 1023 -
1035, Update the after-hook governance context in
_execute_single_native_tool_call and execute_single_native_tool_call so is_error
also becomes true for unresolved, non-cached tool calls where output_tool is
None. Apply this in both lib/crewai/src/crewai/agents/crew_agent_executor.py
lines 1023-1035 and lib/crewai/src/crewai/utilities/agent_utils.py lines
1730-1742, preserving each method’s existing error flags.

Comment on lines +2056 to +2058
call_id=governance_call_id,
is_error=hook_blocked or max_usage_reached or error_event_emitted,
was_blocked=hook_blocked,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

is_error misses the tool-not-found path.

When output_tool is None, none of the branches run and result stays "Tool not found", yet is_error is computed only from hook_blocked or max_usage_reached or error_event_emitted. The post-tool governance record will report a successful call carrying an error string.

🐛 Proposed fix
-            is_error=hook_blocked or max_usage_reached or error_event_emitted,
+            is_error=(
+                hook_blocked
+                or max_usage_reached
+                or error_event_emitted
+                or output_tool is None
+            ),
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
call_id=governance_call_id,
is_error=hook_blocked or max_usage_reached or error_event_emitted,
was_blocked=hook_blocked,
call_id=governance_call_id,
is_error=(
hook_blocked
or max_usage_reached
or error_event_emitted
or output_tool is None
),
was_blocked=hook_blocked,
🤖 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/experimental/agent_executor.py` around lines 2056 -
2058, Update the post-tool governance record’s is_error calculation near call_id
and was_blocked to also treat the output_tool is None/tool-not-found path as an
error. Preserve the existing hook_blocked, max_usage_reached, and
error_event_emitted conditions while ensuring a missing tool cannot be recorded
as successful.

Comment on lines +610 to +641
def _current_session_id(
self, *, crew: Any = None, flow: Any = None, agent: Any = None
) -> str:
owner = crew if crew is not None else flow
if owner is not None:
with self._builders_lock:
sessions = self._active_sessions.get(id(owner))
if sessions:
return sessions[-1]
return _session_id(crew=crew, flow=flow, agent=agent)

def _begin_session(self, *, crew: Any = None, flow: Any = None) -> str:
base_id = _session_id(crew=crew, flow=flow)
session_id = f"{base_id}:{uuid.uuid4()}"
owner = crew if crew is not None else flow
if owner is not None:
with self._builders_lock:
self._active_sessions.setdefault(id(owner), []).append(session_id)
return session_id

def _finish_session(self, *, crew: Any = None, flow: Any = None) -> None:
owner = crew if crew is not None else flow
if owner is None:
return
with self._builders_lock:
sessions = self._active_sessions.get(id(owner))
if not sessions:
return
session_id = sessions.pop()
self._builders.pop(session_id, None)
if not sessions:
self._active_sessions.pop(id(owner), None)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Session bookkeeping keyed by id(owner) can collide after GC.

_active_sessions/_builders entries are only removed in _finish_session. If EXECUTION_END never fires (hard crash mid-run, or a point set that excludes EXECUTION_END), the entry survives; a later object allocated at the same address would then inherit that stale session id and its builder. Consider keying by weakref.ref(owner) (or storing the owner alongside the id and validating identity) so a recycled address cannot silently reuse another run's session.

🤖 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/hooks/agent_hooks_engine.py` around lines 610 - 641,
Update _active_sessions bookkeeping in _current_session_id, _begin_session, and
_finish_session to retain and validate the owner object rather than relying
solely on id(owner). Use weak references or an equivalent identity check so
garbage-collected owners cannot cause recycled addresses to inherit stale
sessions or builders, while preserving current session stack behavior.

Comment on lines 1768 to +1771
try:
function_args = json.loads(tool_call.function.arguments)
fn = available_functions[function_name]
if not isinstance(function_args, dict):
raise ValueError("Tool arguments must be a JSON object")

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Argument-parse failures emit a post_tool_call with no paired pre_tool_call.

json.loads / the new non-dict ValueError raise before run_before_tool_call_hooks executes, yet the except block still runs the after-hooks with this call_id. Governance sinks correlating pre/post by call_id will see an orphan post record. Either validate the arguments before entering the guarded region, or skip the after-hooks when the before-hooks never ran.

♻️ Sketch: parse before the hook-guarded region
             call_id = str(uuid.uuid4())
+            try:
+                function_args = json.loads(tool_call.function.arguments)
+                if not isinstance(function_args, dict):
+                    raise ValueError("Tool arguments must be a JSON object")
+            except Exception as e:
+                logging.error(f"Invalid arguments for '{function_name}': {e}")
+                return None
             try:
-                function_args = json.loads(tool_call.function.arguments)
-                if not isinstance(function_args, dict):
-                    raise ValueError("Tool arguments must be a JSON object")
-
                 started_at = datetime.now()

Also applies to: 1838-1852

🤖 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/llm.py` around lines 1768 - 1771, Ensure argument
parsing and dictionary validation in the tool-call flow completes before the
region guarded by the before/after hook lifecycle, or track whether
`run_before_tool_call_hooks` actually ran and only invoke the corresponding
after-hooks when it did. Update both affected paths around
`tool_call.function.arguments` so parse failures cannot emit an orphan
`post_tool_call` for the call_id.

Comment on lines +1877 to +1896
except HookAborted as aborted:
# Preserve the historical contract for anonymous hook blocks (a
# hook returning False, or a bare HookAborted): the dispatcher tags
# these with the hook callable as ``source``, so return False and
# let the caller emit its generic block message. A governance
# engine (e.g. an injected agent-hooks control engine) raises with
# a string ``source`` identifier — surface its full reason so
# customers see the policy decision. The sole caller
# (_prepare_llm_call) already documents raising ValueError on block.
if not isinstance(aborted.source, str):
if verbose:
printer.print(
content="LLM call blocked by before_llm_call hook",
color="yellow",
)
return False
reason = aborted.reason or "LLM call blocked by before_llm_call hook"
if verbose:
printer.print(
content="LLM call blocked by before_llm_call hook",
color="yellow",
)
return False
printer.print(content=reason, color="yellow")
raise ValueError(reason) from aborted

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
rg -nP -B1 -A3 'HookAborted\(' --type=py | rg -B3 'source\s*='

Repository: crewAIInc/crewAI

Length of output: 154


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== Locate agent_utils.py and HookAborted =="
git ls-files | rg '(^|/)agent_utils\.py$|hook' | head -200
rg -n "class HookAborted|HookAborted|before_llm_call|AgentHooksEngine" --type=py .

echo
echo "== agent_utils relevant section =="
sed -n '1845,1910p' lib/crewai/src/crewai/utilities/agent_utils.py | cat -n

echo
echo "== HookAborted definition and raise sites =="
for f in $(rg -l "class HookAbotted|class HookAborted" --type=py .); do
  echo "--- $f"
  sed -n '1,120p' "$f" | cat -n
done
for f in $(rg -l "HookAborted\(" --type=py .); do
  echo "--- raises in $f"
  rg -n -B2 -A4 'HookAborted\(' "$f"
done

Repository: crewAIInc/crewAI

Length of output: 9845


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== Locate agent_utils.py and HookAborted =="
git ls-files | rg '(^|/)agent_utils\.py$|hook' | head -200
rg -n "class HookAborted|HookAborted|before_llm_call|AgentHooksEngine" --type=py .

echo
echo "== agent_utils relevant section =="
sed -n '1845,1910p' lib/crewai/src/crewai/utilities/agent_utils.py | cat -n

echo
echo "== HookAborted definition and raise sites =="
for f in $(rg -l "class HookAborted" --type=py .); do
  echo "--- $f"
  sed -n '1,120p' "$f" | cat -n
done
for f in $(rg -l "HookAborted\(" --type=py .); do
  echo "--- raises in $f"
  rg -n -B2 -A4 'HookAborted\(' "$f"
done

Repository: crewAIInc/crewAI

Length of output: 9845


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== all HookAborted references =="
rg -n "HookAborted" .

echo
echo "== agent_utils.py outline around function =="
ast-grep outline lib/crewai/src/crewai/utilities/agent_utils.py --match _before_llm_call --view expanded || true
sed -n '1,220p' lib/crewai/src/crewai/utilities/agent_utils.py | cat -n
echo "--- line 1820-1910 ---"
sed -n '1820,1910p' lib/crewai/src/crewai/utilities/agent_utils.py | cat -n

echo
echo "== hook-related files containing AgentHooksEngine/HookAborted =="
rg -l "AgentHooksEngine|HookAborted" lib tests || true
for f in $(rg -l "AgentHooksEngine|HookAborted" lib tests || true); do
  echo "--- $f"
  rg -n -B3 -A8 "class HookAborted|HookAborted\(|AgentHooksEngine|raise HookAborted|source\s*=" "$f"
done

Repository: crewAIInc/crewAI

Length of output: 50372


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== HookAborted definition =="
sed -n '60,75p' lib/crewai/src/crewai/hooks/dispatch.py | cat -n
rg -n -B2 -A5 "HookAborted\(reason|HookAborted\(" lib/crewai/src/crewai/hooks/agent_hooks_engine.py lib/crewai/src/crewai/hooks/dispatch.py lib/crewai/src/crewai/hooks/llm_hooks.py lib/crewai/src/crewai/utilities

echo
echo "== targeted string-source raises in lib/scripts/tests =="
python3 - <<'PY'
import ast, pathlib
files = list(pathlib.Path('lib/crewai/src', 'lib/crewai/tests').glob('**/*.py'))
for f in files:
    try:
        tree = ast.parse(f.read_text(), filename=str(f))
    except Exception:
        continue
    for node in ast.walk(tree):
        if isinstance(node, ast.Call):
            if (isinstance(node.func, ast.Name) and node.func.id == 'HookAborted'):
                src = ast.get_source_segment(f.read_text(), node) or ''
                if 'source=' in src or src.endswith('HookAborted('):
                    print(f)
                    print(src)
                    print('---')
PY

echo
echo "== AgentHooksEngine marker/source constants =="
sed -n '130,145p' lib/crewai/src/crewai/hooks/agent_hooks_engine.py | cat -n
sed -n '680,725p' lib/crewai/src/crewai/hooks/agent_hooks_engine.py | cat -n
sed -n '755,770p' lib/crewai/src/crewai/hooks/agent_hooks_engine.py | cat -n
sed -n '795,818p' lib/crewai/src/crewai/hooks/agent_hooks_engine.py | cat -n
sed -n '838,865p' lib/crewai/src/crewai/hooks/agent_hooks_engine.py | cat -n

echo
echo "== agent_utils exception handler =="
sed -n '1838,1900p' lib/crewai/src/crewai/utilities/agent_utils.py | cat -n

Repository: crewAIInc/crewAI

Length of output: 19563


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
import ast, pathlib, re

sources = [
    pathlib.Path('lib/crewai/src'),
    pathlib.Path('lib/crewai/tests'),
]
hook_aborted_calls = []
for root in sources:
    for path in root.rglob('*.py'):
        text = path.read_text(errors='ignore')
        if 'HookAborted' not in text:
            continue
        try:
            tree = ast.parse(text, filename=str(path))
        except Exception as exc:
            print(f"# parse failure {path}: {exc}")
            continue
        for node in ast.walk(tree):
            if isinstance(node, ast.Call):
                func = node.func
                if not (isinstance(func, ast.Name) and func.id == 'HookAborted'):
                    continue
                # classify source arg shape without relying on repo runtime
                segment = re.search(r'HookAborted\s*\(', text[node.lineno-1:], re.M)
                start = (node.lineno - 1) * text.index('\n', node.lineno) + (node.col_offset - 1) if node.col_offset else 0
                segment = text[node.lineno-1:]
                snippet = segment
                # simple line-local source keyword scan; multi-line calls rare in this search.
                source_kwarg = False
                source_value = None
                for k in node.keywords:
                    if k.arg == 'source':
                        source_kwarg = True
                        # identify text slice from args/keywords
                        start_chunk = ast.get_source_segment(text, k.value)
                        # approximate literal kind from first non-space char in AST expression
                        if isinstance(k.value, ast.Constant):
                            source_value = (type(k.value.value).__name__, repr(k.value.value))
                        elif isinstance(k.value, ast.Name):
                            source_value = ('Name', k.value.id)
                        elif isinstance(k.value, ast.Attribute):
                            source_value = ('Attribute', ast.get_source_segment(text, k.value))
                        elif ast.get_source_segment(text, isinstance(k.value, ast.Attribute)):
                             pass
                if source_kwarg:
                    col = node.col_offset + idx for idx in range(len(text[node.lineno-1:]) )
                    hook_aborted_calls.append((str(path), node.lineno, node.col_offset, source_kwarg, source_value, node))

print('HookAborted call sites with kwarg source:')
for path, line, col, has_kwarg, srcvalue, node in hook_aborted_calls:
    print(f'{path}:{line}:{col}: source={srcvalue}')

print('\nCallable source literals:')
callables = []
for path, line, col, has_kwarg, srcvalue, node in hook_aborted_calls:
    if srcvalue and srcvalue[0] == 'Name' and srcvalue[1].startswith('_'):
        print('private variable:', path, line, srcvalue[1])
PY

echo
echo "== inspect _source references and dispatcher source tagging =="
sed -n '240,340p' lib/crewai/src/crewai/hooks/dispatch.py | cat -n
rg -n "_SOURCE|agent-hooks|source=.*_" lib/crewai/src/crewai/hooks/agent_hooks_engine.py lib/crewai/src/crewai/hooks/dispatch.py lib/crewai/src/crewai/utilities/agent_utils.py -A2 -B2

echo
echo "== tests around before_llm_call HookAborted source expectations =="
sed -n '620,660p' lib/crewai/tests/hooks/test_llm_hooks.py | cat -n
rg -n -B5 -A8 'before_llm_call|HookAborted|LLM call blocked|ValueError' lib/crewai/tests/hooks/test_llm_hooks.py lib/crewai/tests -g '*.py' | head -200

Repository: crewAIInc/crewAI

Length of output: 319


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== deterministic parser scan for HookAborted(source=...) =="
python3 - <<'PY'
import ast
from pathlib import Path
for root in (Path('lib/crewai/src'), Path('lib/crewai/tests')):
    for path in root.rglob('*.py'):
        try:
            tree = ast.parse(path.read_text(), filename=str(path))
        except Exception:
            continue
        for node in ast.walk(tree):
            if isinstance(node, ast.Call) and isinstance(node.func, ast.Name) and node.func.id == 'HookAborted':
                for kw in node.keywords:
                    if kw.arg == 'source':
                        print(path, node.lineno, ast.unparse(kw.value))
PY

echo
echo "== inspect dispatcher source tagging =="
sed -n '255,330p' lib/crewai/src/crewai/hooks/dispatch.py | cat -n

echo
echo "== inspect tests around before_llm_call HookAborted source expectations =="
sed -n '620,660p' lib/crewai/tests/hooks/test_llm_hooks.py | cat -n
rg -n -B5 -A8 'before_llm_call|HookAborted|LLM call blocked|ValueError' lib/crewai/tests/hooks/test_llm_hooks.py -g '*.py' | head -200

Repository: crewAIInc/crewAI

Length of output: 12407


Use a dedicated marker for agent-hooks aborts.

HookAborted.source can be "callable, string, or any object", and the dispatcher populates it from the aborting hook when it is omitted. A custom before_llm_call hook that intentionally raises HookAborted(reason="...", source="my-policy") would therefore be treated as an agent-hooks abort here and raise ValueError instead of returning False. A sentinel object used only by AgentHooksEngine would avoid this contract collision.
[low_eff er_and_high_reward ]

🤖 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/utilities/agent_utils.py` around lines 1877 - 1896,
Replace the string-type check on aborted.source in the HookAborted handler with
a dedicated sentinel marker owned by AgentHooksEngine, and recognize only that
marker as an agent-hooks abort. Preserve the existing generic message and False
return for all other sources, including custom string sources, while retaining
reason printing and ValueError propagation for the sentinel case.

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.

1 participant