mirror of
https://github.com/crewAIInc/crewAI.git
synced 2026-09-10 18:28:01 +00:00
docs: group execution hooks and document all hook contexts (#6548)
Some checks failed
CodeQL Advanced / Analyze (actions) (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Check Documentation Broken Links / Check broken links (push) Has been cancelled
Vulnerability Scan / pip-audit (push) Has been cancelled
Build uv cache / build-cache (3.10) (push) Has been cancelled
Build uv cache / build-cache (3.11) (push) Has been cancelled
Build uv cache / build-cache (3.12) (push) Has been cancelled
Build uv cache / build-cache (3.13) (push) Has been cancelled
Nightly Canary Release / Check for new commits (push) Has been cancelled
Nightly Canary Release / Build nightly packages (push) Has been cancelled
Nightly Canary Release / Publish nightly to PyPI (push) Has been cancelled
Mark stale issues and pull requests / stale (push) Has been cancelled
Some checks failed
CodeQL Advanced / Analyze (actions) (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Check Documentation Broken Links / Check broken links (push) Has been cancelled
Vulnerability Scan / pip-audit (push) Has been cancelled
Build uv cache / build-cache (3.10) (push) Has been cancelled
Build uv cache / build-cache (3.11) (push) Has been cancelled
Build uv cache / build-cache (3.12) (push) Has been cancelled
Build uv cache / build-cache (3.13) (push) Has been cancelled
Nightly Canary Release / Check for new commits (push) Has been cancelled
Nightly Canary Release / Build nightly packages (push) Has been cancelled
Nightly Canary Release / Publish nightly to PyPI (push) Has been cancelled
Mark stale issues and pull requests / stale (push) Has been cancelled
* docs: group execution hooks and document all hook contexts The hooks pages sat flat in the learn nav and only documented the LLM and tool call contexts, with examples built on the legacy decorators. Groups them under a collapsible "Execution Hooks" section, adds `step-hooks` and `execution-boundary-hooks` pages covering every hook context from source, reworks the LLM and tool pages to lead with `@on` while keeping the decorators, and links the orphaned `before-and-after-kickoff-hooks` page into the group. * docs: prefix hook cross-links with /en so they resolve in edge The new edge-only hooks pages linked each other with versionless paths like `/learn/step-hooks`, which mintlify resolves against the frozen default version where those pages do not exist, breaking the CI link check. Uses the `/en/learn/...` form the other edge pages already use. * docs: use /edge prefix for links to edge-only hooks pages The `/en/learn/...` form still resolves against the default frozen version, where `step-hooks` and `execution-boundary-hooks` do not exist yet, so the link checker kept failing. Links now use the explicit `/edge/en/learn/...` form, matching how `consuming-streams.mdx` linked to edge-only streaming pages before they were frozen.
This commit is contained in:
@@ -373,9 +373,17 @@
|
|||||||
"edge/en/learn/replay-tasks-from-latest-crew-kickoff",
|
"edge/en/learn/replay-tasks-from-latest-crew-kickoff",
|
||||||
"edge/en/learn/sequential-process",
|
"edge/en/learn/sequential-process",
|
||||||
"edge/en/learn/using-annotations",
|
"edge/en/learn/using-annotations",
|
||||||
"edge/en/learn/execution-hooks",
|
{
|
||||||
"edge/en/learn/llm-hooks",
|
"group": "Execution Hooks",
|
||||||
"edge/en/learn/tool-hooks"
|
"pages": [
|
||||||
|
"edge/en/learn/execution-hooks",
|
||||||
|
"edge/en/learn/llm-hooks",
|
||||||
|
"edge/en/learn/tool-hooks",
|
||||||
|
"edge/en/learn/execution-boundary-hooks",
|
||||||
|
"edge/en/learn/step-hooks",
|
||||||
|
"edge/en/learn/before-and-after-kickoff-hooks"
|
||||||
|
]
|
||||||
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
163
docs/edge/en/learn/execution-boundary-hooks.mdx
Normal file
163
docs/edge/en/learn/execution-boundary-hooks.mdx
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
---
|
||||||
|
title: Execution Boundary Hooks
|
||||||
|
description: Intercept the start, inputs, output, and end of crew and flow executions with the @on decorator
|
||||||
|
mode: "wide"
|
||||||
|
---
|
||||||
|
|
||||||
|
Execution boundary hooks intercept the outermost edges of a run — before any
|
||||||
|
work starts, when inputs are resolved, when the final result is ready, and when
|
||||||
|
the execution finishes. They fire for both crews and flows and are the right
|
||||||
|
place for run-level policy checks, input rewriting, and output sanitization.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Four interception points cover the boundaries:
|
||||||
|
|
||||||
|
| Point | When | `ctx.payload` |
|
||||||
|
|-------|------|---------------|
|
||||||
|
| `EXECUTION_START` | A crew or flow is about to begin | inputs `dict` |
|
||||||
|
| `INPUT` | Resolved inputs for the execution | inputs `dict` |
|
||||||
|
| `OUTPUT` | The final result is ready | the output object |
|
||||||
|
| `EXECUTION_END` | The execution has finished | the output object |
|
||||||
|
|
||||||
|
For a crew, the output payload is a `CrewOutput`. For a flow, it is the final
|
||||||
|
flow-method result.
|
||||||
|
|
||||||
|
## Hook Signature
|
||||||
|
|
||||||
|
```python
|
||||||
|
from crewai.hooks import on, HookAborted, InterceptionPoint
|
||||||
|
|
||||||
|
@on(InterceptionPoint.EXECUTION_START)
|
||||||
|
def boundary_hook(ctx) -> Any | None:
|
||||||
|
# Mutate ctx.payload in place, or
|
||||||
|
# return a non-None value to replace it, or
|
||||||
|
# raise HookAborted(reason, source) to stop the run
|
||||||
|
return None
|
||||||
|
```
|
||||||
|
|
||||||
|
Boundary hooks follow the standard contract: proceed (`return None`), mutate in
|
||||||
|
place, replace by returning, or abort by raising
|
||||||
|
[`HookAborted`](/edge/en/learn/execution-hooks#aborting-an-operation). An abort at any
|
||||||
|
boundary propagates out of `kickoff()` with its reason.
|
||||||
|
|
||||||
|
## Context Schema
|
||||||
|
|
||||||
|
Each point receives a typed context. All contexts share the base fields:
|
||||||
|
|
||||||
|
```python
|
||||||
|
class InterceptionContext:
|
||||||
|
payload: Any # The interceptable value (see table above)
|
||||||
|
agent: Any = None # Not populated at execution boundaries
|
||||||
|
agent_role: str | None # Not populated at execution boundaries
|
||||||
|
task: Any = None # Not populated at execution boundaries
|
||||||
|
crew: Any = None # The Crew instance (crew runs only)
|
||||||
|
flow: Any = None # The Flow instance (flow runs only)
|
||||||
|
```
|
||||||
|
|
||||||
|
The per-point contexts add a named alias for the payload:
|
||||||
|
|
||||||
|
```python
|
||||||
|
class ExecutionStartContext(InterceptionContext):
|
||||||
|
inputs: dict # Same dict as payload
|
||||||
|
|
||||||
|
class InputContext(InterceptionContext):
|
||||||
|
inputs: dict # Same dict as payload
|
||||||
|
|
||||||
|
class OutputContext(InterceptionContext):
|
||||||
|
output: Any # The output object
|
||||||
|
|
||||||
|
class ExecutionEndContext(InterceptionContext):
|
||||||
|
output: Any # The output object
|
||||||
|
```
|
||||||
|
|
||||||
|
<Note>
|
||||||
|
`ctx.inputs` aliases the **original** inputs dict, so in-place edits through
|
||||||
|
either name are equivalent. If an earlier hook *replaced* the payload by
|
||||||
|
returning a new dict, only `ctx.payload` is rebound — always read and write
|
||||||
|
`ctx.payload` when hooks might chain.
|
||||||
|
</Note>
|
||||||
|
|
||||||
|
## Crew Runs vs. Flow Runs
|
||||||
|
|
||||||
|
Boundary hooks fire on both runtimes, and crew execution internally rides on a
|
||||||
|
flow runtime. During a `crew.kickoff()`, a global boundary hook therefore fires
|
||||||
|
for the crew boundary (`ctx.crew` set, `ctx.flow` `None`) **and** for the
|
||||||
|
internal flow (`ctx.flow` set, `ctx.crew` `None`). Discriminate by runtime:
|
||||||
|
|
||||||
|
```python
|
||||||
|
@on(InterceptionPoint.OUTPUT)
|
||||||
|
def crew_output_only(ctx):
|
||||||
|
if ctx.crew is None:
|
||||||
|
return None # Skip the internal flow (or a bare flow)
|
||||||
|
ctx.payload.raw = ctx.payload.raw.strip()
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Use Cases
|
||||||
|
|
||||||
|
### Policy Check at Start
|
||||||
|
|
||||||
|
```python
|
||||||
|
@on(InterceptionPoint.EXECUTION_START)
|
||||||
|
def enforce_policy(ctx):
|
||||||
|
if ctx.crew is not None and not ctx.payload.get("authorized"):
|
||||||
|
raise HookAborted(reason="unauthorized execution", source="access-control")
|
||||||
|
```
|
||||||
|
|
||||||
|
### Input Rewriting
|
||||||
|
|
||||||
|
```python
|
||||||
|
@on(InterceptionPoint.INPUT)
|
||||||
|
def add_defaults(ctx):
|
||||||
|
if ctx.crew is None:
|
||||||
|
return None
|
||||||
|
ctx.payload.setdefault("locale", "en-US")
|
||||||
|
ctx.payload["topic"] = ctx.payload["topic"].strip().lower()
|
||||||
|
```
|
||||||
|
|
||||||
|
Rewritten inputs flow into task interpolation, so the run behaves as if it was
|
||||||
|
kicked off with the modified dict.
|
||||||
|
|
||||||
|
### Output Sanitization
|
||||||
|
|
||||||
|
```python
|
||||||
|
import re
|
||||||
|
|
||||||
|
@on(InterceptionPoint.OUTPUT)
|
||||||
|
def redact_emails(ctx):
|
||||||
|
if ctx.crew is None:
|
||||||
|
return None
|
||||||
|
ctx.payload.raw = re.sub(
|
||||||
|
r"\b[\w.+-]+@[\w-]+\.[\w.]+\b", "[EMAIL-REDACTED]", ctx.payload.raw
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
`OUTPUT` runs before `EXECUTION_END`, and both see the (possibly replaced)
|
||||||
|
payload from earlier hooks; the final rewritten value is what `kickoff()`
|
||||||
|
returns.
|
||||||
|
|
||||||
|
## Ordering
|
||||||
|
|
||||||
|
For a crew run the boundary order is:
|
||||||
|
|
||||||
|
```
|
||||||
|
EXECUTION_START → before_kickoff callbacks → INPUT → tasks execute → OUTPUT → EXECUTION_END
|
||||||
|
```
|
||||||
|
|
||||||
|
Hooks at the same point run in registration order, global hooks first, then
|
||||||
|
crew-scoped hooks. Telemetry (`HookDispatchedEvent`) is emitted per dispatch.
|
||||||
|
|
||||||
|
## Managing Hooks in Tests
|
||||||
|
|
||||||
|
```python
|
||||||
|
from crewai.hooks import clear_all_hooks
|
||||||
|
|
||||||
|
clear_all_hooks() # Clears every point, including boundaries
|
||||||
|
```
|
||||||
|
|
||||||
|
## Related Documentation
|
||||||
|
|
||||||
|
- [Execution Hooks Overview →](/edge/en/learn/execution-hooks)
|
||||||
|
- [Step Hooks →](/edge/en/learn/step-hooks)
|
||||||
|
- [LLM Call Hooks →](/edge/en/learn/llm-hooks)
|
||||||
|
- [Tool Call Hooks →](/edge/en/learn/tool-hooks)
|
||||||
@@ -64,7 +64,7 @@ from crewai.hooks import on, InterceptionPoint
|
|||||||
|
|
||||||
@on(InterceptionPoint.POST_TOOL_CALL, agents=["researcher"], tools=["web_search"])
|
@on(InterceptionPoint.POST_TOOL_CALL, agents=["researcher"], tools=["web_search"])
|
||||||
def log_search_results(ctx):
|
def log_search_results(ctx):
|
||||||
print(f"search returned: {str(ctx.payload)[:80]}")
|
print(f"search returned: {(ctx.tool_result or '')[:80]}")
|
||||||
```
|
```
|
||||||
|
|
||||||
Applied to a method inside a `@CrewBase` class, `@on` registers a
|
Applied to a method inside a `@CrewBase` class, `@on` registers a
|
||||||
@@ -84,30 +84,36 @@ class MyProjCrew:
|
|||||||
|
|
||||||
## Interception point catalog
|
## Interception point catalog
|
||||||
|
|
||||||
`payload` is the value a hook may mutate or replace at each point.
|
Each family has a detailed guide covering its context schema, payload
|
||||||
|
semantics, and examples.
|
||||||
|
|
||||||
### Execution boundaries
|
### [Execution boundaries](/edge/en/learn/execution-boundary-hooks)
|
||||||
|
|
||||||
| Point | When | `payload` |
|
| Point | When | `ctx.payload` |
|
||||||
|-------|------|-----------|
|
|-------|------|---------------|
|
||||||
| `EXECUTION_START` | A crew or flow is about to begin | inputs `dict` |
|
| `EXECUTION_START` | A crew or flow is about to begin | inputs `dict` |
|
||||||
| `INPUT` | Resolved inputs for the execution | inputs `dict` |
|
| `INPUT` | Resolved inputs for the execution | inputs `dict` |
|
||||||
| `OUTPUT` | Final result is ready | the output object |
|
| `OUTPUT` | Final result is ready | the output object |
|
||||||
| `EXECUTION_END` | A crew or flow has finished | the output object |
|
| `EXECUTION_END` | A crew or flow has finished | the output object |
|
||||||
|
|
||||||
### Model & tool boundaries
|
### [Model boundaries](/edge/en/learn/llm-hooks) & [tool boundaries](/edge/en/learn/tool-hooks)
|
||||||
|
|
||||||
| Point | When | `payload` |
|
| Point | When | Hook receives |
|
||||||
|-------|------|-----------|
|
|-------|------|---------------|
|
||||||
| `PRE_MODEL_CALL` | Before an LLM call | `LLMCallHookContext` |
|
| `PRE_MODEL_CALL` | Before an LLM call | `LLMCallHookContext` |
|
||||||
| `POST_MODEL_CALL` | After an LLM call | response |
|
| `POST_MODEL_CALL` | After an LLM call | `LLMCallHookContext` (with `response` set) |
|
||||||
| `PRE_TOOL_CALL` | Before a tool runs | `ToolCallHookContext` |
|
| `PRE_TOOL_CALL` | Before a tool runs | `ToolCallHookContext` |
|
||||||
| `POST_TOOL_CALL` | After a tool runs | tool result |
|
| `POST_TOOL_CALL` | After a tool runs | `ToolCallHookContext` (with results set) |
|
||||||
|
|
||||||
### Step points
|
At these four points the hook receives the rich legacy context **directly** as
|
||||||
|
its argument — there is no separate `ctx.payload`. Mutate `ctx.messages` /
|
||||||
|
`ctx.tool_input` in place, and return a string from a post hook to replace the
|
||||||
|
response / tool result.
|
||||||
|
|
||||||
| Point | When | `payload` |
|
### [Step points](/edge/en/learn/step-hooks)
|
||||||
|-------|------|-----------|
|
|
||||||
|
| Point | When | `ctx.payload` |
|
||||||
|
|-------|------|---------------|
|
||||||
| `PRE_STEP` | Before a task or flow-method step | step input |
|
| `PRE_STEP` | Before a task or flow-method step | step input |
|
||||||
| `POST_STEP` | After a task or flow-method step | step output |
|
| `POST_STEP` | After a task or flow-method step | step output |
|
||||||
|
|
||||||
@@ -147,12 +153,12 @@ def enforce_policy(ctx):
|
|||||||
@on(InterceptionPoint.PRE_TOOL_CALL)
|
@on(InterceptionPoint.PRE_TOOL_CALL)
|
||||||
def block_dangerous_tools(ctx):
|
def block_dangerous_tools(ctx):
|
||||||
dangerous = {"delete_file", "drop_table", "system_shutdown"}
|
dangerous = {"delete_file", "drop_table", "system_shutdown"}
|
||||||
if ctx.payload.tool_name in dangerous:
|
if ctx.tool_name in dangerous:
|
||||||
raise HookAborted(reason=f"{ctx.payload.tool_name} is blocked", source="safety-policy")
|
raise HookAborted(reason=f"{ctx.tool_name} is blocked", source="safety-policy")
|
||||||
|
|
||||||
@on(InterceptionPoint.PRE_MODEL_CALL)
|
@on(InterceptionPoint.PRE_MODEL_CALL)
|
||||||
def iteration_limit(ctx):
|
def iteration_limit(ctx):
|
||||||
if ctx.payload.iterations > 15:
|
if ctx.iterations > 15:
|
||||||
raise HookAborted(reason="maximum iterations exceeded", source="loop-guard")
|
raise HookAborted(reason="maximum iterations exceeded", source="loop-guard")
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -161,8 +167,8 @@ def iteration_limit(ctx):
|
|||||||
```python
|
```python
|
||||||
@on(InterceptionPoint.PRE_TOOL_CALL, tools=["send_email", "make_payment"])
|
@on(InterceptionPoint.PRE_TOOL_CALL, tools=["send_email", "make_payment"])
|
||||||
def require_approval(ctx):
|
def require_approval(ctx):
|
||||||
response = ctx.payload.request_human_input(
|
response = ctx.request_human_input(
|
||||||
prompt=f"Approve {ctx.payload.tool_name}?",
|
prompt=f"Approve {ctx.tool_name}?",
|
||||||
default_message="Type 'yes' to approve:",
|
default_message="Type 'yes' to approve:",
|
||||||
)
|
)
|
||||||
if response.lower() != "yes":
|
if response.lower() != "yes":
|
||||||
@@ -171,8 +177,8 @@ def require_approval(ctx):
|
|||||||
|
|
||||||
### Sanitizing outputs
|
### Sanitizing outputs
|
||||||
|
|
||||||
A non-`None` return value replaces the payload, so transformations are plain
|
A non-`None` return value replaces the interceptable value, so transformations
|
||||||
return statements:
|
are plain return statements:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
import re
|
import re
|
||||||
@@ -182,7 +188,7 @@ def redact_keys(ctx):
|
|||||||
return re.sub(
|
return re.sub(
|
||||||
r'(api[_-]?key)["\']?\s*[:=]\s*["\']?[\w-]+',
|
r'(api[_-]?key)["\']?\s*[:=]\s*["\']?[\w-]+',
|
||||||
r"\1: [REDACTED]",
|
r"\1: [REDACTED]",
|
||||||
ctx.payload,
|
ctx.response,
|
||||||
flags=re.IGNORECASE,
|
flags=re.IGNORECASE,
|
||||||
)
|
)
|
||||||
```
|
```
|
||||||
@@ -254,10 +260,11 @@ Differences from `@on`:
|
|||||||
- They cover **only** the four model/tool points — no execution boundaries, no
|
- They cover **only** the four model/tool points — no execution boundaries, no
|
||||||
steps.
|
steps.
|
||||||
- Blocking is `return False`, with no abort reason or source attached.
|
- Blocking is `return False`, with no abort reason or source attached.
|
||||||
- They receive rich point-specific contexts — `LLMCallHookContext` (with full
|
- They receive the same rich contexts — `LLMCallHookContext` (with full
|
||||||
executor access) and `ToolCallHookContext` — the same objects `@on` exposes
|
executor access) and `ToolCallHookContext` — that `@on` hooks receive at the
|
||||||
as `ctx.payload` at the `PRE_MODEL_CALL` / `PRE_TOOL_CALL` points.
|
model/tool points.
|
||||||
- Crew-scoping uses the `*_crew` decorator variants inside `@CrewBase` classes.
|
- Crew-scoping works the same way: apply the decorator to a method inside a
|
||||||
|
`@CrewBase` class.
|
||||||
- They support the same `agents=` / `tools=` filters.
|
- They support the same `agents=` / `tools=` filters.
|
||||||
|
|
||||||
You might still prefer them for existing codebases that already use
|
You might still prefer them for existing codebases that already use
|
||||||
@@ -265,10 +272,10 @@ You might still prefer them for existing codebases that already use
|
|||||||
For the detailed guides — context attributes, patterns, and management APIs
|
For the detailed guides — context attributes, patterns, and management APIs
|
||||||
(`register_*` / `unregister_*` / `clear_*`) — see:
|
(`register_*` / `unregister_*` / `clear_*`) — see:
|
||||||
|
|
||||||
- [LLM Call Hooks →](/learn/llm-hooks)
|
- [LLM Call Hooks →](/edge/en/learn/llm-hooks)
|
||||||
- [Tool Call Hooks →](/learn/tool-hooks)
|
- [Tool Call Hooks →](/edge/en/learn/tool-hooks)
|
||||||
|
|
||||||
## Related documentation
|
## Related documentation
|
||||||
|
|
||||||
- [Before and After Kickoff Hooks →](/learn/before-and-after-kickoff-hooks)
|
- [Before and After Kickoff Hooks →](/edge/en/learn/before-and-after-kickoff-hooks)
|
||||||
- [Human-in-the-Loop →](/learn/human-in-the-loop)
|
- [Human-in-the-Loop →](/edge/en/learn/human-in-the-loop)
|
||||||
|
|||||||
@@ -4,49 +4,51 @@ description: Learn how to use LLM call hooks to intercept, modify, and control l
|
|||||||
mode: "wide"
|
mode: "wide"
|
||||||
---
|
---
|
||||||
|
|
||||||
LLM Call Hooks provide fine-grained control over language model interactions during agent execution. These hooks allow you to intercept LLM calls, modify prompts, transform responses, implement approval gates, and add custom logging or monitoring.
|
LLM Call Hooks provide fine-grained control over language model interactions
|
||||||
|
during agent execution. These hooks allow you to intercept LLM calls, modify
|
||||||
|
prompts, transform responses, implement approval gates, and add custom logging
|
||||||
|
or monitoring.
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
LLM hooks are executed at two critical points:
|
LLM hooks are executed at two interception points:
|
||||||
- **Before LLM Call**: Modify messages, validate inputs, or block execution
|
|
||||||
- **After LLM Call**: Transform responses, sanitize outputs, or modify conversation history
|
|
||||||
|
|
||||||
## Hook Types
|
| Point | When | Hook receives |
|
||||||
|
|-------|------|---------------|
|
||||||
|
| `PRE_MODEL_CALL` | Before every LLM call | `LLMCallHookContext` |
|
||||||
|
| `POST_MODEL_CALL` | After every LLM call | `LLMCallHookContext` (with `response` set) |
|
||||||
|
|
||||||
### Before LLM Call Hooks
|
Write them with the [`@on` decorator](/edge/en/learn/execution-hooks). The
|
||||||
|
[legacy `@before_llm_call` / `@after_llm_call` decorators](#legacy-decorators)
|
||||||
|
keep working unchanged — both styles register on the same engine and run in one
|
||||||
|
ordered chain.
|
||||||
|
|
||||||
Executed before every LLM call, these hooks can:
|
## Hook Signature
|
||||||
- Inspect and modify messages sent to the LLM
|
|
||||||
- Block LLM execution based on conditions
|
|
||||||
- Implement rate limiting or approval gates
|
|
||||||
- Add context or system messages
|
|
||||||
- Log request details
|
|
||||||
|
|
||||||
**Signature:**
|
|
||||||
```python
|
```python
|
||||||
def before_hook(context: LLMCallHookContext) -> bool | None:
|
from crewai.hooks import on, HookAborted, InterceptionPoint, LLMCallHookContext
|
||||||
# Return False to block execution
|
|
||||||
# Return True or None to allow execution
|
@on(InterceptionPoint.PRE_MODEL_CALL)
|
||||||
|
def before_hook(ctx: LLMCallHookContext) -> None:
|
||||||
|
# Mutate ctx.messages in place, or
|
||||||
|
# raise HookAborted(reason, source) to block the call
|
||||||
|
...
|
||||||
|
|
||||||
|
@on(InterceptionPoint.POST_MODEL_CALL)
|
||||||
|
def after_hook(ctx: LLMCallHookContext) -> str | None:
|
||||||
|
# Return a string to replace ctx.response
|
||||||
|
# Return None to keep the original response
|
||||||
...
|
...
|
||||||
```
|
```
|
||||||
|
|
||||||
### After LLM Call Hooks
|
Unlike the boundary and step points, the model-call points pass the rich
|
||||||
|
`LLMCallHookContext` directly as the hook argument (there is no separate
|
||||||
|
`ctx.payload`): mutate `ctx.messages` in place before the call, and return a
|
||||||
|
string to replace the response after it.
|
||||||
|
|
||||||
Executed after every LLM call, these hooks can:
|
Blocking a call raises `ValueError("LLM call blocked by before_llm_call hook")`
|
||||||
- Modify or sanitize LLM responses
|
inside the executor; the `HookAborted` reason and source are recorded in
|
||||||
- Add metadata or formatting
|
[telemetry](/edge/en/learn/execution-hooks#telemetry).
|
||||||
- Log response details
|
|
||||||
- Update conversation history
|
|
||||||
- Implement content filtering
|
|
||||||
|
|
||||||
**Signature:**
|
|
||||||
```python
|
|
||||||
def after_hook(context: LLMCallHookContext) -> str | None:
|
|
||||||
# Return modified response string
|
|
||||||
# Return None to keep original response
|
|
||||||
...
|
|
||||||
```
|
|
||||||
|
|
||||||
## LLM Hook Context
|
## LLM Hook Context
|
||||||
|
|
||||||
@@ -54,95 +56,74 @@ The `LLMCallHookContext` object provides comprehensive access to execution state
|
|||||||
|
|
||||||
```python
|
```python
|
||||||
class LLMCallHookContext:
|
class LLMCallHookContext:
|
||||||
executor: CrewAgentExecutor # Full executor reference
|
executor: CrewAgentExecutor | LiteAgent | None # Executor (None for direct LLM calls)
|
||||||
messages: list # Mutable message list
|
messages: list # Mutable message list
|
||||||
agent: Agent # Current agent
|
agent: Agent | None # Current agent (None for direct LLM calls)
|
||||||
task: Task # Current task
|
task: Task | None # Current task (None for direct calls or LiteAgent)
|
||||||
crew: Crew # Crew instance
|
crew: Crew | None # Crew instance (None for direct calls or LiteAgent)
|
||||||
llm: BaseLLM # LLM instance
|
llm: BaseLLM | None # LLM instance
|
||||||
iterations: int # Current iteration count
|
iterations: int # Current iteration count (0 for direct calls)
|
||||||
response: str | None # LLM response (after hooks only)
|
response: str | None # LLM response (POST_MODEL_CALL only)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The context also exposes `request_human_input(prompt, default_message)`, which
|
||||||
|
pauses live console updates and collects input from the terminal — useful for
|
||||||
|
approval gates.
|
||||||
|
|
||||||
### Modifying Messages
|
### Modifying Messages
|
||||||
|
|
||||||
**Important:** Always modify messages in-place:
|
**Important:** Always modify messages in-place:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# ✅ Correct - modify in-place
|
# ✅ Correct - modify in-place
|
||||||
def add_context(context: LLMCallHookContext) -> None:
|
@on(InterceptionPoint.PRE_MODEL_CALL)
|
||||||
context.messages.append({"role": "system", "content": "Be concise"})
|
def add_context(ctx: LLMCallHookContext) -> None:
|
||||||
|
ctx.messages.append({"role": "system", "content": "Be concise"})
|
||||||
|
|
||||||
# ❌ Wrong - replaces list reference
|
# ❌ Wrong - replaces list reference and breaks the executor
|
||||||
def wrong_approach(context: LLMCallHookContext) -> None:
|
@on(InterceptionPoint.PRE_MODEL_CALL)
|
||||||
context.messages = [{"role": "system", "content": "Be concise"}]
|
def wrong_approach(ctx: LLMCallHookContext) -> None:
|
||||||
|
ctx.messages = [{"role": "system", "content": "Be concise"}]
|
||||||
```
|
```
|
||||||
|
|
||||||
## Registration Methods
|
## Registration Methods
|
||||||
|
|
||||||
### 1. Global Hook Registration
|
### 1. Global Hooks
|
||||||
|
|
||||||
Register hooks that apply to all LLM calls across all crews:
|
Apply to all LLM calls across all crews. Use the `agents=` filter to scope a
|
||||||
|
hook to specific agent roles:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from crewai.hooks import register_before_llm_call_hook, register_after_llm_call_hook
|
from crewai.hooks import on, InterceptionPoint
|
||||||
|
|
||||||
def log_llm_call(context):
|
@on(InterceptionPoint.PRE_MODEL_CALL)
|
||||||
print(f"LLM call by {context.agent.role} at iteration {context.iterations}")
|
def log_llm_call(ctx):
|
||||||
return None # Allow execution
|
print(f"LLM call by {ctx.agent.role} at iteration {ctx.iterations}")
|
||||||
|
|
||||||
register_before_llm_call_hook(log_llm_call)
|
@on(InterceptionPoint.POST_MODEL_CALL, agents=["Researcher"])
|
||||||
|
def log_researcher_responses(ctx):
|
||||||
|
print(f"Response length: {len(ctx.response)}")
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. Decorator-Based Registration
|
### 2. Crew-Scoped Hooks
|
||||||
|
|
||||||
Use decorators for cleaner syntax:
|
Apply the same decorator to a method inside a `@CrewBase` class to scope the
|
||||||
|
hook to that crew only:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from crewai.hooks import before_llm_call, after_llm_call
|
from crewai.hooks import on, InterceptionPoint
|
||||||
|
|
||||||
@before_llm_call
|
|
||||||
def validate_iteration_count(context):
|
|
||||||
if context.iterations > 10:
|
|
||||||
print("⚠️ Exceeded maximum iterations")
|
|
||||||
return False # Block execution
|
|
||||||
return None
|
|
||||||
|
|
||||||
@after_llm_call
|
|
||||||
def sanitize_response(context):
|
|
||||||
if context.response and "API_KEY" in context.response:
|
|
||||||
return context.response.replace("API_KEY", "[REDACTED]")
|
|
||||||
return None
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Crew-Scoped Hooks
|
|
||||||
|
|
||||||
Register hooks for a specific crew instance:
|
|
||||||
|
|
||||||
```python
|
|
||||||
@CrewBase
|
@CrewBase
|
||||||
class MyProjCrew:
|
class MyProjCrew:
|
||||||
@before_llm_call_crew
|
@on(InterceptionPoint.PRE_MODEL_CALL)
|
||||||
def validate_inputs(self, context):
|
def validate_inputs(self, ctx):
|
||||||
# Only applies to this crew
|
# Only applies to this crew
|
||||||
if context.iterations == 0:
|
if ctx.iterations == 0:
|
||||||
print(f"Starting task: {context.task.description}")
|
print(f"Starting task: {ctx.task.description}")
|
||||||
return None
|
|
||||||
|
|
||||||
@after_llm_call_crew
|
|
||||||
def log_responses(self, context):
|
|
||||||
# Crew-specific response logging
|
|
||||||
print(f"Response length: {len(context.response)}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
@crew
|
@crew
|
||||||
def crew(self) -> Crew:
|
def crew(self) -> Crew:
|
||||||
return Crew(
|
return Crew(agents=self.agents, tasks=self.tasks, process=Process.sequential)
|
||||||
agents=self.agents,
|
|
||||||
tasks=self.tasks,
|
|
||||||
process=Process.sequential,
|
|
||||||
verbose=True
|
|
||||||
)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Common Use Cases
|
## Common Use Cases
|
||||||
@@ -150,278 +131,152 @@ class MyProjCrew:
|
|||||||
### 1. Iteration Limiting
|
### 1. Iteration Limiting
|
||||||
|
|
||||||
```python
|
```python
|
||||||
@before_llm_call
|
@on(InterceptionPoint.PRE_MODEL_CALL)
|
||||||
def limit_iterations(context: LLMCallHookContext) -> bool | None:
|
def limit_iterations(ctx: LLMCallHookContext) -> None:
|
||||||
max_iterations = 15
|
if ctx.iterations > 15:
|
||||||
if context.iterations > max_iterations:
|
raise HookAborted(reason="exceeded 15 iterations", source="loop-guard")
|
||||||
print(f"⛔ Blocked: Exceeded {max_iterations} iterations")
|
|
||||||
return False # Block execution
|
|
||||||
return None
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. Human Approval Gate
|
### 2. Human Approval Gate
|
||||||
|
|
||||||
```python
|
```python
|
||||||
@before_llm_call
|
@on(InterceptionPoint.PRE_MODEL_CALL)
|
||||||
def require_approval(context: LLMCallHookContext) -> bool | None:
|
def require_approval(ctx: LLMCallHookContext) -> None:
|
||||||
if context.iterations > 5:
|
if ctx.iterations > 5:
|
||||||
response = context.request_human_input(
|
response = ctx.request_human_input(
|
||||||
prompt=f"Iteration {context.iterations}: Approve LLM call?",
|
prompt=f"Iteration {ctx.iterations}: Approve LLM call?",
|
||||||
default_message="Press Enter to approve, or type 'no' to block:"
|
default_message="Press Enter to approve, or type 'no' to block:",
|
||||||
)
|
)
|
||||||
if response.lower() == "no":
|
if response.lower() == "no":
|
||||||
print("🚫 LLM call blocked by user")
|
raise HookAborted(reason="blocked by user", source="approval-gate")
|
||||||
return False
|
|
||||||
return None
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3. Adding System Context
|
### 3. Adding System Context
|
||||||
|
|
||||||
```python
|
```python
|
||||||
@before_llm_call
|
@on(InterceptionPoint.PRE_MODEL_CALL)
|
||||||
def add_guardrails(context: LLMCallHookContext) -> None:
|
def add_guardrails(ctx: LLMCallHookContext) -> None:
|
||||||
# Add safety guidelines to every LLM call
|
ctx.messages.append({
|
||||||
context.messages.append({
|
|
||||||
"role": "system",
|
"role": "system",
|
||||||
"content": "Ensure responses are factual and cite sources when possible."
|
"content": "Ensure responses are factual and cite sources when possible."
|
||||||
})
|
})
|
||||||
return None
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 4. Response Sanitization
|
### 4. Response Sanitization
|
||||||
|
|
||||||
```python
|
```python
|
||||||
@after_llm_call
|
import re
|
||||||
def sanitize_sensitive_data(context: LLMCallHookContext) -> str | None:
|
|
||||||
if not context.response:
|
@on(InterceptionPoint.POST_MODEL_CALL)
|
||||||
|
def sanitize_sensitive_data(ctx: LLMCallHookContext) -> str | None:
|
||||||
|
if not ctx.response:
|
||||||
return None
|
return None
|
||||||
|
sanitized = re.sub(r'\b\d{3}-\d{2}-\d{4}\b', '[SSN-REDACTED]', ctx.response)
|
||||||
# Remove sensitive patterns
|
return re.sub(r'\b\d{4}[- ]?\d{4}[- ]?\d{4}[- ]?\d{4}\b', '[CARD-REDACTED]', sanitized)
|
||||||
import re
|
|
||||||
sanitized = context.response
|
|
||||||
sanitized = re.sub(r'\b\d{3}-\d{2}-\d{4}\b', '[SSN-REDACTED]', sanitized)
|
|
||||||
sanitized = re.sub(r'\b\d{4}[- ]?\d{4}[- ]?\d{4}[- ]?\d{4}\b', '[CARD-REDACTED]', sanitized)
|
|
||||||
|
|
||||||
return sanitized
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 5. Cost Tracking
|
### 5. Debug Logging
|
||||||
|
|
||||||
```python
|
```python
|
||||||
import tiktoken
|
@on(InterceptionPoint.PRE_MODEL_CALL)
|
||||||
|
def debug_request(ctx: LLMCallHookContext) -> None:
|
||||||
|
print(f"Agent: {ctx.agent.role}, iteration {ctx.iterations}, "
|
||||||
|
f"{len(ctx.messages)} messages")
|
||||||
|
|
||||||
@before_llm_call
|
@on(InterceptionPoint.POST_MODEL_CALL)
|
||||||
def track_token_usage(context: LLMCallHookContext) -> None:
|
def debug_response(ctx: LLMCallHookContext) -> None:
|
||||||
encoding = tiktoken.get_encoding("cl100k_base")
|
if ctx.response:
|
||||||
total_tokens = sum(
|
print(f"Response preview: {ctx.response[:100]}...")
|
||||||
len(encoding.encode(msg.get("content", "")))
|
|
||||||
for msg in context.messages
|
|
||||||
)
|
|
||||||
print(f"📊 Input tokens: ~{total_tokens}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
@after_llm_call
|
|
||||||
def track_response_tokens(context: LLMCallHookContext) -> None:
|
|
||||||
if context.response:
|
|
||||||
encoding = tiktoken.get_encoding("cl100k_base")
|
|
||||||
tokens = len(encoding.encode(context.response))
|
|
||||||
print(f"📊 Response tokens: ~{tokens}")
|
|
||||||
return None
|
|
||||||
```
|
|
||||||
|
|
||||||
### 6. Debug Logging
|
|
||||||
|
|
||||||
```python
|
|
||||||
@before_llm_call
|
|
||||||
def debug_request(context: LLMCallHookContext) -> None:
|
|
||||||
print(f"""
|
|
||||||
🔍 LLM Call Debug:
|
|
||||||
- Agent: {context.agent.role}
|
|
||||||
- Task: {context.task.description[:50]}...
|
|
||||||
- Iteration: {context.iterations}
|
|
||||||
- Message Count: {len(context.messages)}
|
|
||||||
- Last Message: {context.messages[-1] if context.messages else 'None'}
|
|
||||||
""")
|
|
||||||
return None
|
|
||||||
|
|
||||||
@after_llm_call
|
|
||||||
def debug_response(context: LLMCallHookContext) -> None:
|
|
||||||
if context.response:
|
|
||||||
print(f"✅ Response Preview: {context.response[:100]}...")
|
|
||||||
return None
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Hook Management
|
## Hook Management
|
||||||
|
|
||||||
### Unregistering Hooks
|
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from crewai.hooks import (
|
from crewai.hooks import (
|
||||||
unregister_before_llm_call_hook,
|
InterceptionPoint,
|
||||||
unregister_after_llm_call_hook
|
clear_all_hooks,
|
||||||
|
clear_hooks,
|
||||||
|
get_hooks,
|
||||||
|
unregister_hook,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Unregister specific hook
|
# Unregister a specific hook
|
||||||
def my_hook(context):
|
unregister_hook(InterceptionPoint.PRE_MODEL_CALL, my_hook)
|
||||||
...
|
|
||||||
|
|
||||||
register_before_llm_call_hook(my_hook)
|
# Clear one point, or everything (e.g. between tests)
|
||||||
# Later...
|
clear_hooks(InterceptionPoint.POST_MODEL_CALL)
|
||||||
unregister_before_llm_call_hook(my_hook) # Returns True if found
|
clear_all_hooks()
|
||||||
|
|
||||||
|
# Inspect what's registered
|
||||||
|
print(len(get_hooks(InterceptionPoint.PRE_MODEL_CALL)))
|
||||||
```
|
```
|
||||||
|
|
||||||
### Clearing Hooks
|
The legacy management API (`register_before_llm_call_hook`,
|
||||||
|
`unregister_before_llm_call_hook`, `clear_before_llm_call_hooks`,
|
||||||
|
`clear_all_llm_call_hooks`, `get_before_llm_call_hooks`, and their `after_`
|
||||||
|
counterparts) operates on the same underlying registries, so either API can
|
||||||
|
manage hooks registered by the other.
|
||||||
|
|
||||||
|
## Legacy Decorators
|
||||||
|
|
||||||
|
The original per-point decorators keep working unchanged and run in the same
|
||||||
|
registration-order chain as `@on` hooks:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from crewai.hooks import (
|
from crewai.hooks import before_llm_call, after_llm_call
|
||||||
clear_before_llm_call_hooks,
|
|
||||||
clear_after_llm_call_hooks,
|
|
||||||
clear_all_llm_call_hooks
|
|
||||||
)
|
|
||||||
|
|
||||||
# Clear specific hook type
|
|
||||||
count = clear_before_llm_call_hooks()
|
|
||||||
print(f"Cleared {count} before hooks")
|
|
||||||
|
|
||||||
# Clear all LLM hooks
|
|
||||||
before_count, after_count = clear_all_llm_call_hooks()
|
|
||||||
print(f"Cleared {before_count} before and {after_count} after hooks")
|
|
||||||
```
|
|
||||||
|
|
||||||
### Listing Registered Hooks
|
|
||||||
|
|
||||||
```python
|
|
||||||
from crewai.hooks import (
|
|
||||||
get_before_llm_call_hooks,
|
|
||||||
get_after_llm_call_hooks
|
|
||||||
)
|
|
||||||
|
|
||||||
# Get current hooks
|
|
||||||
before_hooks = get_before_llm_call_hooks()
|
|
||||||
after_hooks = get_after_llm_call_hooks()
|
|
||||||
|
|
||||||
print(f"Registered: {len(before_hooks)} before, {len(after_hooks)} after")
|
|
||||||
```
|
|
||||||
|
|
||||||
## Advanced Patterns
|
|
||||||
|
|
||||||
### Conditional Hook Execution
|
|
||||||
|
|
||||||
```python
|
|
||||||
@before_llm_call
|
|
||||||
def conditional_blocking(context: LLMCallHookContext) -> bool | None:
|
|
||||||
# Only block for specific agents
|
|
||||||
if context.agent.role == "researcher" and context.iterations > 10:
|
|
||||||
return False
|
|
||||||
|
|
||||||
# Only block for specific tasks
|
|
||||||
if "sensitive" in context.task.description.lower() and context.iterations > 5:
|
|
||||||
return False
|
|
||||||
|
|
||||||
return None
|
|
||||||
```
|
|
||||||
|
|
||||||
### Context-Aware Modifications
|
|
||||||
|
|
||||||
```python
|
|
||||||
@before_llm_call
|
|
||||||
def adaptive_prompting(context: LLMCallHookContext) -> None:
|
|
||||||
# Add different context based on iteration
|
|
||||||
if context.iterations == 0:
|
|
||||||
context.messages.append({
|
|
||||||
"role": "system",
|
|
||||||
"content": "Start with a high-level overview."
|
|
||||||
})
|
|
||||||
elif context.iterations > 3:
|
|
||||||
context.messages.append({
|
|
||||||
"role": "system",
|
|
||||||
"content": "Focus on specific details and provide examples."
|
|
||||||
})
|
|
||||||
return None
|
|
||||||
```
|
|
||||||
|
|
||||||
### Chaining Hooks
|
|
||||||
|
|
||||||
```python
|
|
||||||
# Multiple hooks execute in registration order
|
|
||||||
|
|
||||||
@before_llm_call
|
@before_llm_call
|
||||||
def first_hook(context):
|
def validate_iteration_count(context):
|
||||||
print("1. First hook executed")
|
|
||||||
return None
|
|
||||||
|
|
||||||
@before_llm_call
|
|
||||||
def second_hook(context):
|
|
||||||
print("2. Second hook executed")
|
|
||||||
return None
|
|
||||||
|
|
||||||
@before_llm_call
|
|
||||||
def blocking_hook(context):
|
|
||||||
if context.iterations > 10:
|
if context.iterations > 10:
|
||||||
print("3. Blocking hook - execution stopped")
|
return False # Block execution
|
||||||
return False # Subsequent hooks won't execute
|
return None
|
||||||
print("3. Blocking hook - execution allowed")
|
|
||||||
|
@after_llm_call(agents=["Researcher"])
|
||||||
|
def sanitize_response(context):
|
||||||
|
if context.response and "API_KEY" in context.response:
|
||||||
|
return context.response.replace("API_KEY", "[REDACTED]")
|
||||||
return None
|
return None
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Differences from `@on`:
|
||||||
|
|
||||||
|
- **Blocking** is `return False` from a before hook — equivalent to raising
|
||||||
|
`HookAborted`, but without a custom reason or source for telemetry.
|
||||||
|
- **Signatures** are point-specific: before hooks return `bool | None`, after
|
||||||
|
hooks return `str | None`. The context object is the same
|
||||||
|
`LLMCallHookContext`.
|
||||||
|
- **Filters and crew-scoping** work the same way: `@before_llm_call(agents=[...])`,
|
||||||
|
and applying the decorator to a `@CrewBase` method scopes it to that crew.
|
||||||
|
|
||||||
|
Prefer `@on` for new code; keep the legacy style where it is already in use —
|
||||||
|
there is no behavioral penalty.
|
||||||
|
|
||||||
## Best Practices
|
## Best Practices
|
||||||
|
|
||||||
1. **Keep Hooks Focused**: Each hook should have a single responsibility
|
1. **Keep hooks focused and fast** — they run on every LLM call
|
||||||
2. **Avoid Heavy Computation**: Hooks execute on every LLM call
|
2. **Modify in-place** — always mutate `ctx.messages`, never replace the list
|
||||||
3. **Handle Errors Gracefully**: Use try-except to prevent hook failures from breaking execution
|
3. **Use type hints** — annotate with `LLMCallHookContext` for IDE support
|
||||||
4. **Use Type Hints**: Leverage `LLMCallHookContext` for better IDE support
|
4. **Abort loudly** — raise `HookAborted` with a meaningful reason and source;
|
||||||
5. **Document Hook Behavior**: Especially for blocking conditions
|
any other exception is swallowed (fail-open)
|
||||||
6. **Test Hooks Independently**: Unit test hooks before using in production
|
5. **Clear hooks in tests** — call `clear_all_hooks()` between test runs
|
||||||
7. **Clear Hooks in Tests**: Use `clear_all_llm_call_hooks()` between test runs
|
|
||||||
8. **Modify In-Place**: Always modify `context.messages` in-place, never replace
|
|
||||||
|
|
||||||
## Error Handling
|
|
||||||
|
|
||||||
```python
|
|
||||||
@before_llm_call
|
|
||||||
def safe_hook(context: LLMCallHookContext) -> bool | None:
|
|
||||||
try:
|
|
||||||
# Your hook logic
|
|
||||||
if some_condition:
|
|
||||||
return False
|
|
||||||
except Exception as e:
|
|
||||||
print(f"⚠️ Hook error: {e}")
|
|
||||||
# Decide: allow or block on error
|
|
||||||
return None # Allow execution despite error
|
|
||||||
```
|
|
||||||
|
|
||||||
## Type Safety
|
|
||||||
|
|
||||||
```python
|
|
||||||
from crewai.hooks import LLMCallHookContext, BeforeLLMCallHookType, AfterLLMCallHookType
|
|
||||||
|
|
||||||
# Explicit type annotations
|
|
||||||
def my_before_hook(context: LLMCallHookContext) -> bool | None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
def my_after_hook(context: LLMCallHookContext) -> str | None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Type-safe registration
|
|
||||||
register_before_llm_call_hook(my_before_hook)
|
|
||||||
register_after_llm_call_hook(my_after_hook)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
### Hook Not Executing
|
### Hook Not Executing
|
||||||
- Verify hook is registered before crew execution
|
- Verify the hook is registered before crew execution
|
||||||
- Check if previous hook returned `False` (blocks subsequent hooks)
|
- Check whether an earlier hook aborted (subsequent hooks don't run)
|
||||||
- Ensure hook signature matches expected type
|
|
||||||
|
|
||||||
### Message Modifications Not Persisting
|
### Message Modifications Not Persisting
|
||||||
- Use in-place modifications: `context.messages.append()`
|
- Use in-place modifications: `ctx.messages.append(...)`
|
||||||
- Don't replace the list: `context.messages = []`
|
- Don't replace the list: `ctx.messages = []`
|
||||||
|
|
||||||
### Response Modifications Not Working
|
### Response Modifications Not Working
|
||||||
- Return the modified string from after hooks
|
- Return the modified string from a `POST_MODEL_CALL` hook
|
||||||
- Returning `None` keeps the original response
|
- Returning `None` keeps the original response
|
||||||
|
|
||||||
## Conclusion
|
## Related Documentation
|
||||||
|
|
||||||
LLM Call Hooks provide powerful capabilities for controlling and monitoring language model interactions in CrewAI. Use them to implement safety guardrails, approval gates, logging, cost tracking, and response sanitization. Combined with proper error handling and type safety, hooks enable robust and production-ready agent systems.
|
- [Execution Hooks Overview →](/edge/en/learn/execution-hooks)
|
||||||
|
- [Tool Call Hooks →](/edge/en/learn/tool-hooks)
|
||||||
|
- [Execution Boundary Hooks →](/edge/en/learn/execution-boundary-hooks)
|
||||||
|
- [Step Hooks →](/edge/en/learn/step-hooks)
|
||||||
|
|||||||
142
docs/edge/en/learn/step-hooks.mdx
Normal file
142
docs/edge/en/learn/step-hooks.mdx
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
---
|
||||||
|
title: Step Hooks
|
||||||
|
description: Intercept task and flow-method steps with PRE_STEP and POST_STEP hooks in CrewAI
|
||||||
|
mode: "wide"
|
||||||
|
---
|
||||||
|
|
||||||
|
Step hooks intercept each unit of work inside an execution: every crew **task**
|
||||||
|
and every **flow method**. Use them to inspect or rewrite what goes into a
|
||||||
|
step, transform what comes out, or trace step-by-step progress — without
|
||||||
|
touching the level of individual LLM or tool calls.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Two interception points cover steps:
|
||||||
|
|
||||||
|
| Point | When | `ctx.payload` |
|
||||||
|
|-------|------|---------------|
|
||||||
|
| `PRE_STEP` | Before a task or flow method runs | step input (see below) |
|
||||||
|
| `POST_STEP` | After a task or flow method runs | step output (see below) |
|
||||||
|
|
||||||
|
What the payload holds depends on `ctx.kind`:
|
||||||
|
|
||||||
|
| `ctx.kind` | `PRE_STEP` payload | `POST_STEP` payload |
|
||||||
|
|------------|--------------------|---------------------|
|
||||||
|
| `"task"` | The context string passed to the agent | The `TaskOutput` object |
|
||||||
|
| `"flow_method"` | The method's parameters as a `dict` | The method's return value |
|
||||||
|
|
||||||
|
For flow methods, positional arguments appear in the params dict under `_0`,
|
||||||
|
`_1`, ... keys and keyword arguments under their own names; edits and
|
||||||
|
replacements are mapped back onto the actual call.
|
||||||
|
|
||||||
|
## Hook Signature
|
||||||
|
|
||||||
|
```python
|
||||||
|
from crewai.hooks import on, HookAborted, InterceptionPoint
|
||||||
|
|
||||||
|
@on(InterceptionPoint.PRE_STEP)
|
||||||
|
def step_hook(ctx) -> Any | None:
|
||||||
|
# Mutate ctx.payload in place, or
|
||||||
|
# return a non-None value to replace it, or
|
||||||
|
# raise HookAborted(reason, source) to stop the step
|
||||||
|
return None
|
||||||
|
```
|
||||||
|
|
||||||
|
## Context Schema
|
||||||
|
|
||||||
|
Both points receive a `StepContext`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
class StepContext(InterceptionContext):
|
||||||
|
payload: Any # Step input (pre) or step output (post)
|
||||||
|
kind: str | None # "task" or "flow_method"
|
||||||
|
step_name: str | None # Task name/description, or flow method name
|
||||||
|
output: Any # POST_STEP only: same object as payload
|
||||||
|
agent: Any # Task steps: the executing agent (else None)
|
||||||
|
agent_role: str | None # Task steps: the agent's role (else None)
|
||||||
|
task: Any # Task steps: the Task instance (else None)
|
||||||
|
crew: Any # None for step points
|
||||||
|
flow: Any # Flow-method steps: the Flow instance (else None)
|
||||||
|
```
|
||||||
|
|
||||||
|
For task steps, `step_name` is the task's `name` (falling back to its
|
||||||
|
description). For flow-method steps, it is the method name.
|
||||||
|
|
||||||
|
## Common Use Cases
|
||||||
|
|
||||||
|
### Step Tracing
|
||||||
|
|
||||||
|
```python
|
||||||
|
@on(InterceptionPoint.POST_STEP)
|
||||||
|
def trace_steps(ctx):
|
||||||
|
print(f"{ctx.kind} '{ctx.step_name}' finished")
|
||||||
|
```
|
||||||
|
|
||||||
|
### Rewriting Task Context
|
||||||
|
|
||||||
|
```python
|
||||||
|
@on(InterceptionPoint.PRE_STEP)
|
||||||
|
def inject_disclaimer(ctx):
|
||||||
|
if ctx.kind != "task":
|
||||||
|
return None
|
||||||
|
return f"{ctx.payload}\n\nNote: treat all figures as estimates."
|
||||||
|
```
|
||||||
|
|
||||||
|
### Transforming Task Output
|
||||||
|
|
||||||
|
```python
|
||||||
|
@on(InterceptionPoint.POST_STEP)
|
||||||
|
def normalize_output(ctx):
|
||||||
|
if ctx.kind != "task":
|
||||||
|
return None
|
||||||
|
ctx.payload.raw = ctx.payload.raw.strip()
|
||||||
|
```
|
||||||
|
|
||||||
|
<Note>
|
||||||
|
`POST_STEP` runs before the task's output is stored, so rewrites propagate
|
||||||
|
everywhere the output is used: downstream task context, callbacks, the final
|
||||||
|
crew output, and the task's `output_file` on disk.
|
||||||
|
</Note>
|
||||||
|
|
||||||
|
### Guarding Flow Methods
|
||||||
|
|
||||||
|
```python
|
||||||
|
@on(InterceptionPoint.PRE_STEP)
|
||||||
|
def guard_publish(ctx):
|
||||||
|
if ctx.kind == "flow_method" and ctx.step_name == "publish":
|
||||||
|
if not ctx.flow.state.get("reviewed"):
|
||||||
|
raise HookAborted(reason="publish requires review", source="review-gate")
|
||||||
|
```
|
||||||
|
|
||||||
|
### Filtering by Agent
|
||||||
|
|
||||||
|
Step hooks support the same `agents=` filter as the other points (matched
|
||||||
|
against the executing agent's role on task steps):
|
||||||
|
|
||||||
|
```python
|
||||||
|
@on(InterceptionPoint.POST_STEP, agents=["Researcher"])
|
||||||
|
def log_research_steps(ctx):
|
||||||
|
print(f"research step done: {ctx.step_name}")
|
||||||
|
```
|
||||||
|
|
||||||
|
## Aborting a Step
|
||||||
|
|
||||||
|
Raising `HookAborted` in `PRE_STEP` stops the step before any agent or method
|
||||||
|
work happens, and the abort propagates out of the execution with its reason —
|
||||||
|
it is not swallowed. Any other exception raised by a step hook is swallowed
|
||||||
|
(fail-open), like at every other point.
|
||||||
|
|
||||||
|
## Managing Hooks in Tests
|
||||||
|
|
||||||
|
```python
|
||||||
|
from crewai.hooks import clear_all_hooks
|
||||||
|
|
||||||
|
clear_all_hooks() # Clears every point, including steps
|
||||||
|
```
|
||||||
|
|
||||||
|
## Related Documentation
|
||||||
|
|
||||||
|
- [Execution Hooks Overview →](/edge/en/learn/execution-hooks)
|
||||||
|
- [Execution Boundary Hooks →](/edge/en/learn/execution-boundary-hooks)
|
||||||
|
- [LLM Call Hooks →](/edge/en/learn/llm-hooks)
|
||||||
|
- [Tool Call Hooks →](/edge/en/learn/tool-hooks)
|
||||||
@@ -4,53 +4,57 @@ description: Learn how to use tool call hooks to intercept, modify, and control
|
|||||||
mode: "wide"
|
mode: "wide"
|
||||||
---
|
---
|
||||||
|
|
||||||
Tool Call Hooks provide fine-grained control over tool execution during agent operations. These hooks allow you to intercept tool calls, modify inputs, transform outputs, implement safety checks, and add comprehensive logging or monitoring.
|
Tool Call Hooks provide fine-grained control over tool execution during agent
|
||||||
|
operations. These hooks allow you to intercept tool calls, modify inputs,
|
||||||
|
transform outputs, implement safety checks, and add comprehensive logging or
|
||||||
|
monitoring.
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
Tool hooks are executed at two critical points:
|
Tool hooks are executed at two interception points:
|
||||||
- **Before Tool Call**: Modify inputs, validate parameters, or block execution
|
|
||||||
- **After Tool Call**: Transform results, sanitize outputs, or log execution details
|
|
||||||
|
|
||||||
## Hook Types
|
| Point | When | Hook receives |
|
||||||
|
|-------|------|---------------|
|
||||||
|
| `PRE_TOOL_CALL` | Before every tool execution | `ToolCallHookContext` |
|
||||||
|
| `POST_TOOL_CALL` | After every tool execution | `ToolCallHookContext` (with results set) |
|
||||||
|
|
||||||
### Before Tool Call Hooks
|
Write them with the [`@on` decorator](/edge/en/learn/execution-hooks). The
|
||||||
|
[legacy `@before_tool_call` / `@after_tool_call` decorators](#legacy-decorators)
|
||||||
|
keep working unchanged — both styles register on the same engine and run in one
|
||||||
|
ordered chain.
|
||||||
|
|
||||||
Executed before every tool execution, these hooks can:
|
## Hook Signature
|
||||||
- Inspect and modify tool inputs
|
|
||||||
- Block tool execution based on conditions
|
|
||||||
- Implement approval gates for dangerous operations
|
|
||||||
- Validate parameters
|
|
||||||
- Log tool invocations
|
|
||||||
|
|
||||||
**Signature:**
|
|
||||||
```python
|
```python
|
||||||
def before_hook(context: ToolCallHookContext) -> bool | None:
|
from crewai.hooks import on, HookAborted, InterceptionPoint, ToolCallHookContext
|
||||||
# Return False to block execution
|
|
||||||
# Return True or None to allow execution
|
@on(InterceptionPoint.PRE_TOOL_CALL)
|
||||||
|
def before_hook(ctx: ToolCallHookContext) -> None:
|
||||||
|
# Mutate ctx.tool_input in place, or
|
||||||
|
# raise HookAborted(reason, source) to block the call
|
||||||
|
...
|
||||||
|
|
||||||
|
@on(InterceptionPoint.POST_TOOL_CALL)
|
||||||
|
def after_hook(ctx: ToolCallHookContext) -> str | None:
|
||||||
|
# Return a string to replace ctx.tool_result
|
||||||
|
# Return None to keep the original result
|
||||||
...
|
...
|
||||||
```
|
```
|
||||||
|
|
||||||
### After Tool Call Hooks
|
Unlike the boundary and step points, the tool-call points pass the rich
|
||||||
|
`ToolCallHookContext` directly as the hook argument (there is no separate
|
||||||
|
`ctx.payload`): mutate `ctx.tool_input` in place before the call, and return a
|
||||||
|
string to replace the result after it.
|
||||||
|
|
||||||
Executed after every tool execution, these hooks can:
|
When a call is blocked, the tool does not run and the agent receives
|
||||||
- Modify or sanitize tool results
|
`"Tool execution blocked by hook. Tool: <name>"` as the result — the run
|
||||||
- Add metadata or formatting
|
continues. `POST_TOOL_CALL` hooks still fire on blocked calls, so monitoring
|
||||||
- Log execution results
|
hooks see every attempt.
|
||||||
- Implement result validation
|
|
||||||
- Transform output formats
|
|
||||||
|
|
||||||
**Signature:**
|
|
||||||
```python
|
|
||||||
def after_hook(context: ToolCallHookContext) -> str | None:
|
|
||||||
# Return modified result string
|
|
||||||
# Return None to keep original result
|
|
||||||
...
|
|
||||||
```
|
|
||||||
|
|
||||||
## Tool Hook Context
|
## Tool Hook Context
|
||||||
|
|
||||||
The `ToolCallHookContext` object provides comprehensive access to tool execution state:
|
The `ToolCallHookContext` object provides comprehensive access to tool
|
||||||
|
execution state:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
class ToolCallHookContext:
|
class ToolCallHookContext:
|
||||||
@@ -60,11 +64,18 @@ class ToolCallHookContext:
|
|||||||
agent: Agent | BaseAgent | None # Agent executing the tool
|
agent: Agent | BaseAgent | None # Agent executing the tool
|
||||||
task: Task | None # Current task
|
task: Task | None # Current task
|
||||||
crew: Crew | None # Crew instance
|
crew: Crew | None # Crew instance
|
||||||
tool_result: str | None # Agent-facing result string (after hooks only)
|
tool_result: str | None # Agent-facing result string (POST_TOOL_CALL only)
|
||||||
raw_tool_result: Any | None # Raw Python result (after hooks only)
|
raw_tool_result: Any | None # Raw Python result (POST_TOOL_CALL only)
|
||||||
```
|
```
|
||||||
|
|
||||||
For typed tool outputs, `tool_result` is the string the agent sees. By default, this is JSON. If the tool uses custom formatting, it can be Markdown or another string. Use `raw_tool_result` when your hook needs the typed object or dictionary.
|
For typed tool outputs, `tool_result` is the string the agent sees. By default,
|
||||||
|
this is JSON. If the tool uses custom formatting, it can be Markdown or another
|
||||||
|
string. Use `raw_tool_result` when your hook needs the typed object or
|
||||||
|
dictionary; it is not affected by result replacement.
|
||||||
|
|
||||||
|
The context also exposes `request_human_input(prompt, default_message)`, which
|
||||||
|
pauses live console updates and collects input from the terminal — useful for
|
||||||
|
approval gates.
|
||||||
|
|
||||||
### Modifying Tool Inputs
|
### Modifying Tool Inputs
|
||||||
|
|
||||||
@@ -72,83 +83,58 @@ For typed tool outputs, `tool_result` is the string the agent sees. By default,
|
|||||||
|
|
||||||
```python
|
```python
|
||||||
# ✅ Correct - modify in-place
|
# ✅ Correct - modify in-place
|
||||||
def sanitize_input(context: ToolCallHookContext) -> None:
|
@on(InterceptionPoint.PRE_TOOL_CALL)
|
||||||
context.tool_input['query'] = context.tool_input['query'].lower()
|
def sanitize_input(ctx: ToolCallHookContext) -> None:
|
||||||
|
ctx.tool_input['query'] = ctx.tool_input['query'].lower()
|
||||||
|
|
||||||
# ❌ Wrong - replaces dict reference
|
# ❌ Wrong - replaces dict reference; the tool never sees it
|
||||||
def wrong_approach(context: ToolCallHookContext) -> None:
|
@on(InterceptionPoint.PRE_TOOL_CALL)
|
||||||
context.tool_input = {'query': 'new query'}
|
def wrong_approach(ctx: ToolCallHookContext) -> None:
|
||||||
|
ctx.tool_input = {'query': 'new query'}
|
||||||
```
|
```
|
||||||
|
|
||||||
## Registration Methods
|
## Registration Methods
|
||||||
|
|
||||||
### 1. Global Hook Registration
|
### 1. Global Hooks
|
||||||
|
|
||||||
Register hooks that apply to all tool calls across all crews:
|
Apply to all tool calls across all crews. Use `tools=` / `agents=` filters to
|
||||||
|
scope a hook:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from crewai.hooks import register_before_tool_call_hook, register_after_tool_call_hook
|
from crewai.hooks import on, HookAborted, InterceptionPoint
|
||||||
|
|
||||||
def log_tool_call(context):
|
@on(InterceptionPoint.PRE_TOOL_CALL)
|
||||||
print(f"Tool: {context.tool_name}")
|
def log_tool_call(ctx):
|
||||||
print(f"Input: {context.tool_input}")
|
print(f"Tool: {ctx.tool_name}, input: {ctx.tool_input}")
|
||||||
return None # Allow execution
|
|
||||||
|
|
||||||
register_before_tool_call_hook(log_tool_call)
|
@on(InterceptionPoint.PRE_TOOL_CALL, tools=["delete_file", "drop_table"])
|
||||||
|
def block_destructive(ctx):
|
||||||
|
raise HookAborted(reason=f"{ctx.tool_name} is not allowed", source="safety-policy")
|
||||||
|
|
||||||
|
@on(InterceptionPoint.POST_TOOL_CALL, tools=["web_search"], agents=["Researcher"])
|
||||||
|
def log_search_results(ctx):
|
||||||
|
print(f"search returned {len(ctx.tool_result or '')} chars")
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. Decorator-Based Registration
|
### 2. Crew-Scoped Hooks
|
||||||
|
|
||||||
Use decorators for cleaner syntax:
|
Apply the same decorator to a method inside a `@CrewBase` class to scope the
|
||||||
|
hook to that crew only:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from crewai.hooks import before_tool_call, after_tool_call
|
from crewai.hooks import on, InterceptionPoint
|
||||||
|
|
||||||
@before_tool_call
|
|
||||||
def block_dangerous_tools(context):
|
|
||||||
dangerous_tools = ['delete_database', 'drop_table', 'rm_rf']
|
|
||||||
if context.tool_name in dangerous_tools:
|
|
||||||
print(f"⛔ Blocked dangerous tool: {context.tool_name}")
|
|
||||||
return False # Block execution
|
|
||||||
return None
|
|
||||||
|
|
||||||
@after_tool_call
|
|
||||||
def sanitize_results(context):
|
|
||||||
if context.tool_result and "password" in context.tool_result.lower():
|
|
||||||
return context.tool_result.replace("password", "[REDACTED]")
|
|
||||||
return None
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Crew-Scoped Hooks
|
|
||||||
|
|
||||||
Register hooks for a specific crew instance:
|
|
||||||
|
|
||||||
```python
|
|
||||||
@CrewBase
|
@CrewBase
|
||||||
class MyProjCrew:
|
class MyProjCrew:
|
||||||
@before_tool_call_crew
|
@on(InterceptionPoint.PRE_TOOL_CALL)
|
||||||
def validate_tool_inputs(self, context):
|
def validate_tool_inputs(self, ctx):
|
||||||
# Only applies to this crew
|
# Only applies to this crew
|
||||||
if context.tool_name == "web_search":
|
if ctx.tool_name == "web_search" and not ctx.tool_input.get("query"):
|
||||||
if not context.tool_input.get('query'):
|
raise HookAborted(reason="empty search query", source="input-validation")
|
||||||
print("❌ Invalid search query")
|
|
||||||
return False
|
|
||||||
return None
|
|
||||||
|
|
||||||
@after_tool_call_crew
|
|
||||||
def log_tool_results(self, context):
|
|
||||||
# Crew-specific tool logging
|
|
||||||
print(f"✅ {context.tool_name} completed")
|
|
||||||
return None
|
|
||||||
|
|
||||||
@crew
|
@crew
|
||||||
def crew(self) -> Crew:
|
def crew(self) -> Crew:
|
||||||
return Crew(
|
return Crew(agents=self.agents, tasks=self.tasks, process=Process.sequential)
|
||||||
agents=self.agents,
|
|
||||||
tasks=self.tasks,
|
|
||||||
process=Process.sequential,
|
|
||||||
verbose=True
|
|
||||||
)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Common Use Cases
|
## Common Use Cases
|
||||||
@@ -156,112 +142,63 @@ class MyProjCrew:
|
|||||||
### 1. Safety Guardrails
|
### 1. Safety Guardrails
|
||||||
|
|
||||||
```python
|
```python
|
||||||
@before_tool_call
|
@on(InterceptionPoint.PRE_TOOL_CALL)
|
||||||
def safety_check(context: ToolCallHookContext) -> bool | None:
|
def safety_check(ctx: ToolCallHookContext) -> None:
|
||||||
# Block tools that could cause harm
|
destructive = {'delete_file', 'drop_table', 'remove_user', 'system_shutdown'}
|
||||||
destructive_tools = [
|
if ctx.tool_name in destructive:
|
||||||
'delete_file',
|
raise HookAborted(reason=f"{ctx.tool_name} is destructive", source="safety-policy")
|
||||||
'drop_table',
|
|
||||||
'remove_user',
|
|
||||||
'system_shutdown'
|
|
||||||
]
|
|
||||||
|
|
||||||
if context.tool_name in destructive_tools:
|
|
||||||
print(f"🛑 Blocked destructive tool: {context.tool_name}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
# Warn on sensitive operations
|
|
||||||
sensitive_tools = ['send_email', 'post_to_social_media', 'charge_payment']
|
|
||||||
if context.tool_name in sensitive_tools:
|
|
||||||
print(f"⚠️ Executing sensitive tool: {context.tool_name}")
|
|
||||||
|
|
||||||
return None
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. Human Approval Gate
|
### 2. Human Approval Gate
|
||||||
|
|
||||||
```python
|
```python
|
||||||
@before_tool_call
|
@on(InterceptionPoint.PRE_TOOL_CALL, tools=["send_email", "make_purchase", "delete_file"])
|
||||||
def require_approval_for_actions(context: ToolCallHookContext) -> bool | None:
|
def require_approval(ctx: ToolCallHookContext) -> None:
|
||||||
approval_required = [
|
response = ctx.request_human_input(
|
||||||
'send_email',
|
prompt=f"Approve {ctx.tool_name}?",
|
||||||
'make_purchase',
|
default_message=f"Input: {ctx.tool_input}\nType 'yes' to approve:",
|
||||||
'delete_file',
|
)
|
||||||
'post_message'
|
if response.lower() != 'yes':
|
||||||
]
|
raise HookAborted(reason="denied by operator", source="approval-gate")
|
||||||
|
|
||||||
if context.tool_name in approval_required:
|
|
||||||
response = context.request_human_input(
|
|
||||||
prompt=f"Approve {context.tool_name}?",
|
|
||||||
default_message=f"Input: {context.tool_input}\nType 'yes' to approve:"
|
|
||||||
)
|
|
||||||
|
|
||||||
if response.lower() != 'yes':
|
|
||||||
print(f"❌ Tool execution denied: {context.tool_name}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
return None
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3. Input Validation and Sanitization
|
### 3. Input Validation and Sanitization
|
||||||
|
|
||||||
```python
|
```python
|
||||||
@before_tool_call
|
@on(InterceptionPoint.PRE_TOOL_CALL, tools=["web_search"])
|
||||||
def validate_and_sanitize_inputs(context: ToolCallHookContext) -> bool | None:
|
def validate_query(ctx: ToolCallHookContext) -> None:
|
||||||
# Validate search queries
|
query = ctx.tool_input.get('query', '')
|
||||||
if context.tool_name == 'web_search':
|
if len(query) < 3:
|
||||||
query = context.tool_input.get('query', '')
|
raise HookAborted(reason="search query too short", source="input-validation")
|
||||||
if len(query) < 3:
|
ctx.tool_input['query'] = query.strip().lower()
|
||||||
print("❌ Search query too short")
|
|
||||||
return False
|
|
||||||
|
|
||||||
# Sanitize query
|
@on(InterceptionPoint.PRE_TOOL_CALL, tools=["read_file"])
|
||||||
context.tool_input['query'] = query.strip().lower()
|
def validate_path(ctx: ToolCallHookContext) -> None:
|
||||||
|
path = ctx.tool_input.get('path', '')
|
||||||
# Validate file paths
|
if '..' in path or path.startswith('/'):
|
||||||
if context.tool_name == 'read_file':
|
raise HookAborted(reason="invalid file path", source="input-validation")
|
||||||
path = context.tool_input.get('path', '')
|
|
||||||
if '..' in path or path.startswith('/'):
|
|
||||||
print("❌ Invalid file path")
|
|
||||||
return False
|
|
||||||
|
|
||||||
return None
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 4. Result Sanitization
|
### 4. Result Sanitization
|
||||||
|
|
||||||
```python
|
```python
|
||||||
@after_tool_call
|
import re
|
||||||
def sanitize_sensitive_data(context: ToolCallHookContext) -> str | None:
|
|
||||||
if not context.tool_result:
|
@on(InterceptionPoint.POST_TOOL_CALL)
|
||||||
|
def sanitize_sensitive_data(ctx: ToolCallHookContext) -> str | None:
|
||||||
|
if not ctx.tool_result:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
import re
|
|
||||||
result = context.tool_result
|
|
||||||
|
|
||||||
# Remove API keys
|
|
||||||
result = re.sub(
|
result = re.sub(
|
||||||
r'(api[_-]?key|token)["\']?\s*[:=]\s*["\']?[\w-]+',
|
r'(api[_-]?key|token)["\']?\s*[:=]\s*["\']?[\w-]+',
|
||||||
r'\1: [REDACTED]',
|
r'\1: [REDACTED]',
|
||||||
result,
|
ctx.tool_result,
|
||||||
flags=re.IGNORECASE
|
flags=re.IGNORECASE,
|
||||||
)
|
)
|
||||||
|
return re.sub(
|
||||||
# Remove email addresses
|
|
||||||
result = re.sub(
|
|
||||||
r'\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b',
|
r'\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b',
|
||||||
'[EMAIL-REDACTED]',
|
'[EMAIL-REDACTED]',
|
||||||
result
|
result,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Remove credit card numbers
|
|
||||||
result = re.sub(
|
|
||||||
r'\b\d{4}[- ]?\d{4}[- ]?\d{4}[- ]?\d{4}\b',
|
|
||||||
'[CARD-REDACTED]',
|
|
||||||
result
|
|
||||||
)
|
|
||||||
|
|
||||||
return result
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 5. Tool Usage Analytics
|
### 5. Tool Usage Analytics
|
||||||
@@ -270,32 +207,17 @@ def sanitize_sensitive_data(context: ToolCallHookContext) -> str | None:
|
|||||||
import time
|
import time
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
|
|
||||||
tool_stats = defaultdict(lambda: {'count': 0, 'total_time': 0, 'failures': 0})
|
tool_stats = defaultdict(lambda: {'count': 0, 'total_time': 0})
|
||||||
|
|
||||||
@before_tool_call
|
@on(InterceptionPoint.PRE_TOOL_CALL)
|
||||||
def start_timer(context: ToolCallHookContext) -> None:
|
def start_timer(ctx: ToolCallHookContext) -> None:
|
||||||
context.tool_input['_start_time'] = time.time()
|
ctx.tool_input['_start_time'] = time.time()
|
||||||
return None
|
|
||||||
|
|
||||||
@after_tool_call
|
@on(InterceptionPoint.POST_TOOL_CALL)
|
||||||
def track_tool_usage(context: ToolCallHookContext) -> None:
|
def track_tool_usage(ctx: ToolCallHookContext) -> None:
|
||||||
start_time = context.tool_input.get('_start_time', time.time())
|
start_time = ctx.tool_input.pop('_start_time', time.time())
|
||||||
duration = time.time() - start_time
|
tool_stats[ctx.tool_name]['count'] += 1
|
||||||
|
tool_stats[ctx.tool_name]['total_time'] += time.time() - start_time
|
||||||
tool_stats[context.tool_name]['count'] += 1
|
|
||||||
tool_stats[context.tool_name]['total_time'] += duration
|
|
||||||
|
|
||||||
if not context.tool_result or 'error' in context.tool_result.lower():
|
|
||||||
tool_stats[context.tool_name]['failures'] += 1
|
|
||||||
|
|
||||||
print(f"""
|
|
||||||
📊 Tool Stats for {context.tool_name}:
|
|
||||||
- Executions: {tool_stats[context.tool_name]['count']}
|
|
||||||
- Avg Time: {tool_stats[context.tool_name]['total_time'] / tool_stats[context.tool_name]['count']:.2f}s
|
|
||||||
- Failures: {tool_stats[context.tool_name]['failures']}
|
|
||||||
""")
|
|
||||||
|
|
||||||
return None
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 6. Rate Limiting
|
### 6. Rate Limiting
|
||||||
@@ -306,298 +228,113 @@ from datetime import datetime, timedelta
|
|||||||
|
|
||||||
tool_call_history = defaultdict(list)
|
tool_call_history = defaultdict(list)
|
||||||
|
|
||||||
@before_tool_call
|
@on(InterceptionPoint.PRE_TOOL_CALL)
|
||||||
def rate_limit_tools(context: ToolCallHookContext) -> bool | None:
|
def rate_limit_tools(ctx: ToolCallHookContext) -> None:
|
||||||
tool_name = context.tool_name
|
|
||||||
now = datetime.now()
|
now = datetime.now()
|
||||||
|
history = tool_call_history[ctx.tool_name]
|
||||||
# Clean old entries (older than 1 minute)
|
history[:] = [t for t in history if now - t < timedelta(minutes=1)]
|
||||||
tool_call_history[tool_name] = [
|
if len(history) >= 10:
|
||||||
call_time for call_time in tool_call_history[tool_name]
|
raise HookAborted(reason=f"rate limit exceeded for {ctx.tool_name}",
|
||||||
if now - call_time < timedelta(minutes=1)
|
source="rate-limiter")
|
||||||
]
|
history.append(now)
|
||||||
|
|
||||||
# Check rate limit (max 10 calls per minute)
|
|
||||||
if len(tool_call_history[tool_name]) >= 10:
|
|
||||||
print(f"🚫 Rate limit exceeded for {tool_name}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
# Record this call
|
|
||||||
tool_call_history[tool_name].append(now)
|
|
||||||
return None
|
|
||||||
```
|
|
||||||
|
|
||||||
### 7. Caching Tool Results
|
|
||||||
|
|
||||||
```python
|
|
||||||
import hashlib
|
|
||||||
import json
|
|
||||||
|
|
||||||
tool_cache = {}
|
|
||||||
|
|
||||||
def cache_key(tool_name: str, tool_input: dict) -> str:
|
|
||||||
"""Generate cache key from tool name and input."""
|
|
||||||
input_str = json.dumps(tool_input, sort_keys=True)
|
|
||||||
return hashlib.md5(f"{tool_name}:{input_str}".encode()).hexdigest()
|
|
||||||
|
|
||||||
@before_tool_call
|
|
||||||
def check_cache(context: ToolCallHookContext) -> bool | None:
|
|
||||||
key = cache_key(context.tool_name, context.tool_input)
|
|
||||||
if key in tool_cache:
|
|
||||||
print(f"💾 Cache hit for {context.tool_name}")
|
|
||||||
# Note: Can't return cached result from before hook
|
|
||||||
# Would need to implement this differently
|
|
||||||
return None
|
|
||||||
|
|
||||||
@after_tool_call
|
|
||||||
def cache_result(context: ToolCallHookContext) -> None:
|
|
||||||
if context.tool_result:
|
|
||||||
key = cache_key(context.tool_name, context.tool_input)
|
|
||||||
tool_cache[key] = context.tool_result
|
|
||||||
print(f"💾 Cached result for {context.tool_name}")
|
|
||||||
return None
|
|
||||||
```
|
|
||||||
|
|
||||||
### 8. Debug Logging
|
|
||||||
|
|
||||||
```python
|
|
||||||
@before_tool_call
|
|
||||||
def debug_tool_call(context: ToolCallHookContext) -> None:
|
|
||||||
print(f"""
|
|
||||||
🔍 Tool Call Debug:
|
|
||||||
- Tool: {context.tool_name}
|
|
||||||
- Agent: {context.agent.role if context.agent else 'Unknown'}
|
|
||||||
- Task: {context.task.description[:50] if context.task else 'Unknown'}...
|
|
||||||
- Input: {context.tool_input}
|
|
||||||
""")
|
|
||||||
return None
|
|
||||||
|
|
||||||
@after_tool_call
|
|
||||||
def debug_tool_result(context: ToolCallHookContext) -> None:
|
|
||||||
if context.tool_result:
|
|
||||||
result_preview = context.tool_result[:200]
|
|
||||||
print(f"✅ Result Preview: {result_preview}...")
|
|
||||||
else:
|
|
||||||
print("⚠️ No result returned")
|
|
||||||
return None
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Hook Management
|
## Hook Management
|
||||||
|
|
||||||
### Unregistering Hooks
|
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from crewai.hooks import (
|
from crewai.hooks import (
|
||||||
unregister_before_tool_call_hook,
|
InterceptionPoint,
|
||||||
unregister_after_tool_call_hook
|
clear_all_hooks,
|
||||||
|
clear_hooks,
|
||||||
|
get_hooks,
|
||||||
|
unregister_hook,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Unregister specific hook
|
# Unregister a specific hook
|
||||||
def my_hook(context):
|
unregister_hook(InterceptionPoint.PRE_TOOL_CALL, my_hook)
|
||||||
...
|
|
||||||
|
|
||||||
register_before_tool_call_hook(my_hook)
|
# Clear one point, or everything (e.g. between tests)
|
||||||
# Later...
|
clear_hooks(InterceptionPoint.POST_TOOL_CALL)
|
||||||
success = unregister_before_tool_call_hook(my_hook)
|
clear_all_hooks()
|
||||||
print(f"Unregistered: {success}")
|
|
||||||
|
# Inspect what's registered
|
||||||
|
print(len(get_hooks(InterceptionPoint.PRE_TOOL_CALL)))
|
||||||
```
|
```
|
||||||
|
|
||||||
### Clearing Hooks
|
The legacy management API (`register_before_tool_call_hook`,
|
||||||
|
`unregister_before_tool_call_hook`, `clear_before_tool_call_hooks`,
|
||||||
|
`clear_all_tool_call_hooks`, `get_before_tool_call_hooks`, and their `after_`
|
||||||
|
counterparts) operates on the same underlying registries, so either API can
|
||||||
|
manage hooks registered by the other.
|
||||||
|
|
||||||
|
## Legacy Decorators
|
||||||
|
|
||||||
|
The original per-point decorators keep working unchanged and run in the same
|
||||||
|
registration-order chain as `@on` hooks:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from crewai.hooks import (
|
from crewai.hooks import before_tool_call, after_tool_call
|
||||||
clear_before_tool_call_hooks,
|
|
||||||
clear_after_tool_call_hooks,
|
|
||||||
clear_all_tool_call_hooks
|
|
||||||
)
|
|
||||||
|
|
||||||
# Clear specific hook type
|
|
||||||
count = clear_before_tool_call_hooks()
|
|
||||||
print(f"Cleared {count} before hooks")
|
|
||||||
|
|
||||||
# Clear all tool hooks
|
|
||||||
before_count, after_count = clear_all_tool_call_hooks()
|
|
||||||
print(f"Cleared {before_count} before and {after_count} after hooks")
|
|
||||||
```
|
|
||||||
|
|
||||||
### Listing Registered Hooks
|
|
||||||
|
|
||||||
```python
|
|
||||||
from crewai.hooks import (
|
|
||||||
get_before_tool_call_hooks,
|
|
||||||
get_after_tool_call_hooks
|
|
||||||
)
|
|
||||||
|
|
||||||
# Get current hooks
|
|
||||||
before_hooks = get_before_tool_call_hooks()
|
|
||||||
after_hooks = get_after_tool_call_hooks()
|
|
||||||
|
|
||||||
print(f"Registered: {len(before_hooks)} before, {len(after_hooks)} after")
|
|
||||||
```
|
|
||||||
|
|
||||||
## Advanced Patterns
|
|
||||||
|
|
||||||
### Conditional Hook Execution
|
|
||||||
|
|
||||||
```python
|
|
||||||
@before_tool_call
|
@before_tool_call
|
||||||
def conditional_blocking(context: ToolCallHookContext) -> bool | None:
|
def block_dangerous_tools(context):
|
||||||
# Only block for specific agents
|
if context.tool_name in ('delete_database', 'drop_table'):
|
||||||
if context.agent and context.agent.role == "junior_agent":
|
return False # Block execution
|
||||||
if context.tool_name in ['delete_file', 'send_email']:
|
return None
|
||||||
print(f"❌ Junior agents cannot use {context.tool_name}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
# Only block during specific tasks
|
|
||||||
if context.task and "sensitive" in context.task.description.lower():
|
|
||||||
if context.tool_name == 'web_search':
|
|
||||||
print("❌ Web search blocked for sensitive tasks")
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
@after_tool_call(tools=["web_search"])
|
||||||
|
def sanitize_results(context):
|
||||||
|
if context.tool_result and "password" in context.tool_result.lower():
|
||||||
|
return context.tool_result.replace("password", "[REDACTED]")
|
||||||
return None
|
return None
|
||||||
```
|
```
|
||||||
|
|
||||||
### Context-Aware Input Modification
|
Differences from `@on`:
|
||||||
|
|
||||||
```python
|
- **Blocking** is `return False` from a before hook — equivalent to raising
|
||||||
@before_tool_call
|
`HookAborted`, but without a custom reason or source for telemetry. The agent
|
||||||
def enhance_tool_inputs(context: ToolCallHookContext) -> None:
|
sees the same `"Tool execution blocked by hook"` message.
|
||||||
# Add context based on agent role
|
- **Signatures** are point-specific: before hooks return `bool | None`, after
|
||||||
if context.agent and context.agent.role == "researcher":
|
hooks return `str | None`. The context object is the same
|
||||||
if context.tool_name == 'web_search':
|
`ToolCallHookContext`.
|
||||||
# Add domain restrictions for researchers
|
- **Filters and crew-scoping** work the same way:
|
||||||
context.tool_input['domains'] = ['edu', 'gov', 'org']
|
`@before_tool_call(tools=[...], agents=[...])`, and applying the decorator to
|
||||||
|
a `@CrewBase` method scopes it to that crew.
|
||||||
|
|
||||||
# Add context based on task
|
Prefer `@on` for new code; keep the legacy style where it is already in use —
|
||||||
if context.task and "urgent" in context.task.description.lower():
|
there is no behavioral penalty.
|
||||||
if context.tool_name == 'send_email':
|
|
||||||
context.tool_input['priority'] = 'high'
|
|
||||||
|
|
||||||
return None
|
|
||||||
```
|
|
||||||
|
|
||||||
### Tool Chain Monitoring
|
|
||||||
|
|
||||||
```python
|
|
||||||
tool_call_chain = []
|
|
||||||
|
|
||||||
@before_tool_call
|
|
||||||
def track_tool_chain(context: ToolCallHookContext) -> None:
|
|
||||||
tool_call_chain.append({
|
|
||||||
'tool': context.tool_name,
|
|
||||||
'timestamp': time.time(),
|
|
||||||
'agent': context.agent.role if context.agent else 'Unknown'
|
|
||||||
})
|
|
||||||
|
|
||||||
# Detect potential infinite loops
|
|
||||||
recent_calls = tool_call_chain[-5:]
|
|
||||||
if len(recent_calls) == 5 and all(c['tool'] == context.tool_name for c in recent_calls):
|
|
||||||
print(f"⚠️ Warning: {context.tool_name} called 5 times in a row")
|
|
||||||
|
|
||||||
return None
|
|
||||||
```
|
|
||||||
|
|
||||||
## Best Practices
|
## Best Practices
|
||||||
|
|
||||||
1. **Keep Hooks Focused**: Each hook should have a single responsibility
|
1. **Keep hooks focused and fast** — they run on every tool call
|
||||||
2. **Avoid Heavy Computation**: Hooks execute on every tool call
|
2. **Modify in-place** — always mutate `ctx.tool_input`, never replace the dict
|
||||||
3. **Handle Errors Gracefully**: Use try-except to prevent hook failures
|
3. **Prefer filters over conditionals** — `tools=` / `agents=` keep hook bodies small
|
||||||
4. **Use Type Hints**: Leverage `ToolCallHookContext` for better IDE support
|
4. **Abort loudly** — raise `HookAborted` with a meaningful reason and source;
|
||||||
5. **Document Blocking Conditions**: Make it clear when/why tools are blocked
|
any other exception is swallowed (fail-open)
|
||||||
6. **Test Hooks Independently**: Unit test hooks before using in production
|
5. **Use type hints** — annotate with `ToolCallHookContext` for IDE support
|
||||||
7. **Clear Hooks in Tests**: Use `clear_all_tool_call_hooks()` between test runs
|
6. **Clear hooks in tests** — call `clear_all_hooks()` between test runs
|
||||||
8. **Modify In-Place**: Always modify `context.tool_input` in-place, never replace
|
|
||||||
9. **Log Important Decisions**: Especially when blocking tool execution
|
|
||||||
10. **Consider Performance**: Cache expensive validations when possible
|
|
||||||
|
|
||||||
## Error Handling
|
|
||||||
|
|
||||||
```python
|
|
||||||
@before_tool_call
|
|
||||||
def safe_validation(context: ToolCallHookContext) -> bool | None:
|
|
||||||
try:
|
|
||||||
# Your validation logic
|
|
||||||
if not validate_input(context.tool_input):
|
|
||||||
return False
|
|
||||||
except Exception as e:
|
|
||||||
print(f"⚠️ Hook error: {e}")
|
|
||||||
# Decide: allow or block on error
|
|
||||||
return None # Allow execution despite error
|
|
||||||
```
|
|
||||||
|
|
||||||
## Type Safety
|
|
||||||
|
|
||||||
```python
|
|
||||||
from crewai.hooks import ToolCallHookContext, BeforeToolCallHookType, AfterToolCallHookType
|
|
||||||
|
|
||||||
# Explicit type annotations
|
|
||||||
def my_before_hook(context: ToolCallHookContext) -> bool | None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
def my_after_hook(context: ToolCallHookContext) -> str | None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Type-safe registration
|
|
||||||
register_before_tool_call_hook(my_before_hook)
|
|
||||||
register_after_tool_call_hook(my_after_hook)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Integration with Existing Tools
|
|
||||||
|
|
||||||
### Wrapping Existing Validation
|
|
||||||
|
|
||||||
```python
|
|
||||||
def existing_validator(tool_name: str, inputs: dict) -> bool:
|
|
||||||
"""Your existing validation function."""
|
|
||||||
# Your validation logic
|
|
||||||
return True
|
|
||||||
|
|
||||||
@before_tool_call
|
|
||||||
def integrate_validator(context: ToolCallHookContext) -> bool | None:
|
|
||||||
if not existing_validator(context.tool_name, context.tool_input):
|
|
||||||
print(f"❌ Validation failed for {context.tool_name}")
|
|
||||||
return False
|
|
||||||
return None
|
|
||||||
```
|
|
||||||
|
|
||||||
### Logging to External Systems
|
|
||||||
|
|
||||||
```python
|
|
||||||
import logging
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
@before_tool_call
|
|
||||||
def log_to_external_system(context: ToolCallHookContext) -> None:
|
|
||||||
logger.info(f"Tool call: {context.tool_name}", extra={
|
|
||||||
'tool_name': context.tool_name,
|
|
||||||
'tool_input': context.tool_input,
|
|
||||||
'agent': context.agent.role if context.agent else None
|
|
||||||
})
|
|
||||||
return None
|
|
||||||
```
|
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
### Hook Not Executing
|
### Hook Not Executing
|
||||||
- Verify hook is registered before crew execution
|
- Verify the hook is registered before crew execution
|
||||||
- Check if previous hook returned `False` (blocks execution and subsequent hooks)
|
- Check whether an earlier hook blocked the call (subsequent pre hooks don't run)
|
||||||
- Ensure hook signature matches expected type
|
- Check `tools=` / `agents=` filters against the actual tool name and agent role
|
||||||
|
|
||||||
### Input Modifications Not Working
|
### Input Modifications Not Working
|
||||||
- Use in-place modifications: `context.tool_input['key'] = value`
|
- Use in-place modifications: `ctx.tool_input['key'] = value`
|
||||||
- Don't replace the dict: `context.tool_input = {}`
|
- Don't replace the dict: `ctx.tool_input = {}`
|
||||||
|
|
||||||
### Result Modifications Not Working
|
### Result Modifications Not Working
|
||||||
- Return the modified string from after hooks
|
- Return the modified string from a `POST_TOOL_CALL` hook
|
||||||
- Returning `None` keeps the original result
|
- Returning `None` keeps the original result
|
||||||
- Ensure the tool actually returned a result
|
|
||||||
|
|
||||||
### Tool Blocked Unexpectedly
|
### Tool Blocked Unexpectedly
|
||||||
- Check all before hooks for blocking conditions
|
- Check all pre hooks for `HookAborted` / `return False` conditions
|
||||||
- Verify hook execution order
|
- The abort reason and source appear on the `HookDispatchedEvent` telemetry
|
||||||
- Add debug logging to identify which hook is blocking
|
|
||||||
|
|
||||||
## Conclusion
|
## Related Documentation
|
||||||
|
|
||||||
Tool Call Hooks provide powerful capabilities for controlling and monitoring tool execution in CrewAI. Use them to implement safety guardrails, approval gates, input validation, result sanitization, logging, and analytics. Combined with proper error handling and type safety, hooks enable secure and production-ready agent systems with comprehensive observability.
|
- [Execution Hooks Overview →](/edge/en/learn/execution-hooks)
|
||||||
|
- [LLM Call Hooks →](/edge/en/learn/llm-hooks)
|
||||||
|
- [Execution Boundary Hooks →](/edge/en/learn/execution-boundary-hooks)
|
||||||
|
- [Step Hooks →](/edge/en/learn/step-hooks)
|
||||||
|
|||||||
Reference in New Issue
Block a user