feat(hooks): add optional agent-hooks governance engine - #6710
Conversation
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>
📝 WalkthroughWalkthroughAdds 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. ChangesAgent Hooks Governance
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
Suggested reviewers: 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
There was a problem hiding this comment.
💡 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".
| raise ValueError( | ||
| f"Hook-modified response failed to reparse as " | ||
| f"{type(pydantic_answer).__name__}: {e}" | ||
| ) from e |
There was a problem hiding this comment.
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 👍 / 👎.
There was a problem hiding this comment.
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 winRestrict strict Pydantic reparsing to governed post-model hooks.
This block runs for every registered
after_llm_callhook 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 generalafter_llm_callhook 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 valueConsider 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 betweenregister_*andtry) 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
⛔ Files ignored due to path filters (1)
uv.lockis excluded by!**/*.lock
📒 Files selected for processing (19)
docs/docs.jsondocs/edge/en/learn/agent-hooks.mdxlib/crewai/pyproject.tomllib/crewai/src/crewai/agents/crew_agent_executor.pylib/crewai/src/crewai/experimental/agent_executor.pylib/crewai/src/crewai/hooks/__init__.pylib/crewai/src/crewai/hooks/agent_hooks_engine.pylib/crewai/src/crewai/hooks/dispatch.pylib/crewai/src/crewai/hooks/llm_hooks.pylib/crewai/src/crewai/hooks/tool_hooks.pylib/crewai/src/crewai/llm.pylib/crewai/src/crewai/llms/base_llm.pylib/crewai/src/crewai/tools/tool_usage.pylib/crewai/src/crewai/utilities/agent_utils.pylib/crewai/src/crewai/utilities/tool_utils.pylib/crewai/tests/agents/test_native_tool_calling.pylib/crewai/tests/hooks/test_agent_hooks_engine.pylib/crewai/tests/tools/test_tool_usage.pylib/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 |
There was a problem hiding this comment.
🎯 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 toprint(engine.records()).docs/edge/en/learn/agent-hooks.mdx#L173-L175: documentengine.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.
| 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, | ||
| ) |
There was a problem hiding this comment.
🎯 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: extendis_errorto 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.
| 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.
| call_id=governance_call_id, | ||
| is_error=hook_blocked or max_usage_reached or error_event_emitted, | ||
| was_blocked=hook_blocked, |
There was a problem hiding this comment.
🗄️ 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.
| 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.
| 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) |
There was a problem hiding this comment.
🗄️ 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.
| 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") |
There was a problem hiding this comment.
🗄️ 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.
| 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 |
There was a problem hiding this comment.
🩺 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"
doneRepository: 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"
doneRepository: 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"
doneRepository: 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 -nRepository: 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 -200Repository: 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 -200Repository: 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.
Summary
Adds
agent-hooksas 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.agent-hooks-sdkthrough theagent-hooksextra: See: https://github.com/responsibleai/agent-hooks/blob/main/spec/AGENT-HOOKS-0.1.mdArchitecture
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 --> RHow it works
use_agent_hooks(...)lazily loads the optional SDK, configures the emitter and interceptors, and installs the engine as the process governor.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-hookscontrol points. CrewAIPRE_STEPandPOST_STEPremain 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 withoutagent-hooks-sdkinstalleduv run ruff format --checkanduv run ruff check --no-fixon changed sourcesuv lock --checkuv run --package crewai --with "agent-hooks-sdk==0.1.0a3" pytest lib/crewai/tests -q