mirror of
https://github.com/crewAIInc/crewAI.git
synced 2026-07-15 03:45:10 +00:00
Compare commits
3 Commits
main
...
luzk/hooks
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
191a17391a | ||
|
|
6be349f2e7 | ||
|
|
b8cb5dcc69 |
@@ -373,9 +373,17 @@
|
||||
"edge/en/learn/replay-tasks-from-latest-crew-kickoff",
|
||||
"edge/en/learn/sequential-process",
|
||||
"edge/en/learn/using-annotations",
|
||||
"edge/en/learn/execution-hooks",
|
||||
"edge/en/learn/llm-hooks",
|
||||
"edge/en/learn/tool-hooks"
|
||||
{
|
||||
"group": "Execution 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"])
|
||||
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
|
||||
@@ -84,30 +84,36 @@ class MyProjCrew:
|
||||
|
||||
## 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` |
|
||||
| `INPUT` | Resolved inputs for the execution | inputs `dict` |
|
||||
| `OUTPUT` | Final result is ready | 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` |
|
||||
| `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` |
|
||||
| `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 |
|
||||
| `POST_STEP` | After a task or flow-method step | step output |
|
||||
|
||||
@@ -147,12 +153,12 @@ def enforce_policy(ctx):
|
||||
@on(InterceptionPoint.PRE_TOOL_CALL)
|
||||
def block_dangerous_tools(ctx):
|
||||
dangerous = {"delete_file", "drop_table", "system_shutdown"}
|
||||
if ctx.payload.tool_name in dangerous:
|
||||
raise HookAborted(reason=f"{ctx.payload.tool_name} is blocked", source="safety-policy")
|
||||
if ctx.tool_name in dangerous:
|
||||
raise HookAborted(reason=f"{ctx.tool_name} is blocked", source="safety-policy")
|
||||
|
||||
@on(InterceptionPoint.PRE_MODEL_CALL)
|
||||
def iteration_limit(ctx):
|
||||
if ctx.payload.iterations > 15:
|
||||
if ctx.iterations > 15:
|
||||
raise HookAborted(reason="maximum iterations exceeded", source="loop-guard")
|
||||
```
|
||||
|
||||
@@ -161,8 +167,8 @@ def iteration_limit(ctx):
|
||||
```python
|
||||
@on(InterceptionPoint.PRE_TOOL_CALL, tools=["send_email", "make_payment"])
|
||||
def require_approval(ctx):
|
||||
response = ctx.payload.request_human_input(
|
||||
prompt=f"Approve {ctx.payload.tool_name}?",
|
||||
response = ctx.request_human_input(
|
||||
prompt=f"Approve {ctx.tool_name}?",
|
||||
default_message="Type 'yes' to approve:",
|
||||
)
|
||||
if response.lower() != "yes":
|
||||
@@ -171,8 +177,8 @@ def require_approval(ctx):
|
||||
|
||||
### Sanitizing outputs
|
||||
|
||||
A non-`None` return value replaces the payload, so transformations are plain
|
||||
return statements:
|
||||
A non-`None` return value replaces the interceptable value, so transformations
|
||||
are plain return statements:
|
||||
|
||||
```python
|
||||
import re
|
||||
@@ -182,7 +188,7 @@ def redact_keys(ctx):
|
||||
return re.sub(
|
||||
r'(api[_-]?key)["\']?\s*[:=]\s*["\']?[\w-]+',
|
||||
r"\1: [REDACTED]",
|
||||
ctx.payload,
|
||||
ctx.response,
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
```
|
||||
@@ -254,10 +260,11 @@ Differences from `@on`:
|
||||
- They cover **only** the four model/tool points — no execution boundaries, no
|
||||
steps.
|
||||
- Blocking is `return False`, with no abort reason or source attached.
|
||||
- They receive rich point-specific contexts — `LLMCallHookContext` (with full
|
||||
executor access) and `ToolCallHookContext` — the same objects `@on` exposes
|
||||
as `ctx.payload` at the `PRE_MODEL_CALL` / `PRE_TOOL_CALL` points.
|
||||
- Crew-scoping uses the `*_crew` decorator variants inside `@CrewBase` classes.
|
||||
- They receive the same rich contexts — `LLMCallHookContext` (with full
|
||||
executor access) and `ToolCallHookContext` — that `@on` hooks receive at the
|
||||
model/tool points.
|
||||
- Crew-scoping works the same way: apply the decorator to a method inside a
|
||||
`@CrewBase` class.
|
||||
- They support the same `agents=` / `tools=` filters.
|
||||
|
||||
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
|
||||
(`register_*` / `unregister_*` / `clear_*`) — see:
|
||||
|
||||
- [LLM Call Hooks →](/learn/llm-hooks)
|
||||
- [Tool Call Hooks →](/learn/tool-hooks)
|
||||
- [LLM Call Hooks →](/edge/en/learn/llm-hooks)
|
||||
- [Tool Call Hooks →](/edge/en/learn/tool-hooks)
|
||||
|
||||
## Related documentation
|
||||
|
||||
- [Before and After Kickoff Hooks →](/learn/before-and-after-kickoff-hooks)
|
||||
- [Human-in-the-Loop →](/learn/human-in-the-loop)
|
||||
- [Before and After Kickoff Hooks →](/edge/en/learn/before-and-after-kickoff-hooks)
|
||||
- [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"
|
||||
---
|
||||
|
||||
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
|
||||
|
||||
LLM hooks are executed at two critical points:
|
||||
- **Before LLM Call**: Modify messages, validate inputs, or block execution
|
||||
- **After LLM Call**: Transform responses, sanitize outputs, or modify conversation history
|
||||
LLM hooks are executed at two interception points:
|
||||
|
||||
## 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:
|
||||
- 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
|
||||
## Hook Signature
|
||||
|
||||
**Signature:**
|
||||
```python
|
||||
def before_hook(context: LLMCallHookContext) -> bool | None:
|
||||
# Return False to block execution
|
||||
# Return True or None to allow execution
|
||||
from crewai.hooks import on, HookAborted, InterceptionPoint, LLMCallHookContext
|
||||
|
||||
@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:
|
||||
- Modify or sanitize LLM responses
|
||||
- Add metadata or formatting
|
||||
- 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
|
||||
...
|
||||
```
|
||||
Blocking a call raises `ValueError("LLM call blocked by before_llm_call hook")`
|
||||
inside the executor; the `HookAborted` reason and source are recorded in
|
||||
[telemetry](/edge/en/learn/execution-hooks#telemetry).
|
||||
|
||||
## LLM Hook Context
|
||||
|
||||
@@ -54,95 +56,74 @@ The `LLMCallHookContext` object provides comprehensive access to execution state
|
||||
|
||||
```python
|
||||
class LLMCallHookContext:
|
||||
executor: CrewAgentExecutor # Full executor reference
|
||||
executor: CrewAgentExecutor | LiteAgent | None # Executor (None for direct LLM calls)
|
||||
messages: list # Mutable message list
|
||||
agent: Agent # Current agent
|
||||
task: Task # Current task
|
||||
crew: Crew # Crew instance
|
||||
llm: BaseLLM # LLM instance
|
||||
iterations: int # Current iteration count
|
||||
response: str | None # LLM response (after hooks only)
|
||||
agent: Agent | None # Current agent (None for direct LLM calls)
|
||||
task: Task | None # Current task (None for direct calls or LiteAgent)
|
||||
crew: Crew | None # Crew instance (None for direct calls or LiteAgent)
|
||||
llm: BaseLLM | None # LLM instance
|
||||
iterations: int # Current iteration count (0 for direct calls)
|
||||
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
|
||||
|
||||
**Important:** Always modify messages in-place:
|
||||
|
||||
```python
|
||||
# ✅ Correct - modify in-place
|
||||
def add_context(context: LLMCallHookContext) -> None:
|
||||
context.messages.append({"role": "system", "content": "Be concise"})
|
||||
@on(InterceptionPoint.PRE_MODEL_CALL)
|
||||
def add_context(ctx: LLMCallHookContext) -> None:
|
||||
ctx.messages.append({"role": "system", "content": "Be concise"})
|
||||
|
||||
# ❌ Wrong - replaces list reference
|
||||
def wrong_approach(context: LLMCallHookContext) -> None:
|
||||
context.messages = [{"role": "system", "content": "Be concise"}]
|
||||
# ❌ Wrong - replaces list reference and breaks the executor
|
||||
@on(InterceptionPoint.PRE_MODEL_CALL)
|
||||
def wrong_approach(ctx: LLMCallHookContext) -> None:
|
||||
ctx.messages = [{"role": "system", "content": "Be concise"}]
|
||||
```
|
||||
|
||||
## 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
|
||||
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):
|
||||
print(f"LLM call by {context.agent.role} at iteration {context.iterations}")
|
||||
return None # Allow execution
|
||||
@on(InterceptionPoint.PRE_MODEL_CALL)
|
||||
def log_llm_call(ctx):
|
||||
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
|
||||
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
|
||||
class MyProjCrew:
|
||||
@before_llm_call_crew
|
||||
def validate_inputs(self, context):
|
||||
@on(InterceptionPoint.PRE_MODEL_CALL)
|
||||
def validate_inputs(self, ctx):
|
||||
# Only applies to this crew
|
||||
if context.iterations == 0:
|
||||
print(f"Starting task: {context.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
|
||||
if ctx.iterations == 0:
|
||||
print(f"Starting task: {ctx.task.description}")
|
||||
|
||||
@crew
|
||||
def crew(self) -> Crew:
|
||||
return Crew(
|
||||
agents=self.agents,
|
||||
tasks=self.tasks,
|
||||
process=Process.sequential,
|
||||
verbose=True
|
||||
)
|
||||
return Crew(agents=self.agents, tasks=self.tasks, process=Process.sequential)
|
||||
```
|
||||
|
||||
## Common Use Cases
|
||||
@@ -150,278 +131,152 @@ class MyProjCrew:
|
||||
### 1. Iteration Limiting
|
||||
|
||||
```python
|
||||
@before_llm_call
|
||||
def limit_iterations(context: LLMCallHookContext) -> bool | None:
|
||||
max_iterations = 15
|
||||
if context.iterations > max_iterations:
|
||||
print(f"⛔ Blocked: Exceeded {max_iterations} iterations")
|
||||
return False # Block execution
|
||||
return None
|
||||
@on(InterceptionPoint.PRE_MODEL_CALL)
|
||||
def limit_iterations(ctx: LLMCallHookContext) -> None:
|
||||
if ctx.iterations > 15:
|
||||
raise HookAborted(reason="exceeded 15 iterations", source="loop-guard")
|
||||
```
|
||||
|
||||
### 2. Human Approval Gate
|
||||
|
||||
```python
|
||||
@before_llm_call
|
||||
def require_approval(context: LLMCallHookContext) -> bool | None:
|
||||
if context.iterations > 5:
|
||||
response = context.request_human_input(
|
||||
prompt=f"Iteration {context.iterations}: Approve LLM call?",
|
||||
default_message="Press Enter to approve, or type 'no' to block:"
|
||||
@on(InterceptionPoint.PRE_MODEL_CALL)
|
||||
def require_approval(ctx: LLMCallHookContext) -> None:
|
||||
if ctx.iterations > 5:
|
||||
response = ctx.request_human_input(
|
||||
prompt=f"Iteration {ctx.iterations}: Approve LLM call?",
|
||||
default_message="Press Enter to approve, or type 'no' to block:",
|
||||
)
|
||||
if response.lower() == "no":
|
||||
print("🚫 LLM call blocked by user")
|
||||
return False
|
||||
return None
|
||||
raise HookAborted(reason="blocked by user", source="approval-gate")
|
||||
```
|
||||
|
||||
### 3. Adding System Context
|
||||
|
||||
```python
|
||||
@before_llm_call
|
||||
def add_guardrails(context: LLMCallHookContext) -> None:
|
||||
# Add safety guidelines to every LLM call
|
||||
context.messages.append({
|
||||
@on(InterceptionPoint.PRE_MODEL_CALL)
|
||||
def add_guardrails(ctx: LLMCallHookContext) -> None:
|
||||
ctx.messages.append({
|
||||
"role": "system",
|
||||
"content": "Ensure responses are factual and cite sources when possible."
|
||||
})
|
||||
return None
|
||||
```
|
||||
|
||||
### 4. Response Sanitization
|
||||
|
||||
```python
|
||||
@after_llm_call
|
||||
def sanitize_sensitive_data(context: LLMCallHookContext) -> str | None:
|
||||
if not context.response:
|
||||
import re
|
||||
|
||||
@on(InterceptionPoint.POST_MODEL_CALL)
|
||||
def sanitize_sensitive_data(ctx: LLMCallHookContext) -> str | None:
|
||||
if not ctx.response:
|
||||
return None
|
||||
|
||||
# Remove sensitive patterns
|
||||
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
|
||||
sanitized = re.sub(r'\b\d{3}-\d{2}-\d{4}\b', '[SSN-REDACTED]', ctx.response)
|
||||
return re.sub(r'\b\d{4}[- ]?\d{4}[- ]?\d{4}[- ]?\d{4}\b', '[CARD-REDACTED]', sanitized)
|
||||
```
|
||||
|
||||
### 5. Cost Tracking
|
||||
### 5. Debug Logging
|
||||
|
||||
```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
|
||||
def track_token_usage(context: LLMCallHookContext) -> None:
|
||||
encoding = tiktoken.get_encoding("cl100k_base")
|
||||
total_tokens = sum(
|
||||
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
|
||||
@on(InterceptionPoint.POST_MODEL_CALL)
|
||||
def debug_response(ctx: LLMCallHookContext) -> None:
|
||||
if ctx.response:
|
||||
print(f"Response preview: {ctx.response[:100]}...")
|
||||
```
|
||||
|
||||
## Hook Management
|
||||
|
||||
### Unregistering Hooks
|
||||
|
||||
```python
|
||||
from crewai.hooks import (
|
||||
unregister_before_llm_call_hook,
|
||||
unregister_after_llm_call_hook
|
||||
InterceptionPoint,
|
||||
clear_all_hooks,
|
||||
clear_hooks,
|
||||
get_hooks,
|
||||
unregister_hook,
|
||||
)
|
||||
|
||||
# Unregister specific hook
|
||||
def my_hook(context):
|
||||
...
|
||||
# Unregister a specific hook
|
||||
unregister_hook(InterceptionPoint.PRE_MODEL_CALL, my_hook)
|
||||
|
||||
register_before_llm_call_hook(my_hook)
|
||||
# Later...
|
||||
unregister_before_llm_call_hook(my_hook) # Returns True if found
|
||||
# Clear one point, or everything (e.g. between tests)
|
||||
clear_hooks(InterceptionPoint.POST_MODEL_CALL)
|
||||
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
|
||||
from crewai.hooks import (
|
||||
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
|
||||
from crewai.hooks import before_llm_call, after_llm_call
|
||||
|
||||
@before_llm_call
|
||||
def first_hook(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):
|
||||
def validate_iteration_count(context):
|
||||
if context.iterations > 10:
|
||||
print("3. Blocking hook - execution stopped")
|
||||
return False # Subsequent hooks won't execute
|
||||
print("3. Blocking hook - execution allowed")
|
||||
return False # Block execution
|
||||
return None
|
||||
|
||||
@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
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
1. **Keep Hooks Focused**: Each hook should have a single responsibility
|
||||
2. **Avoid Heavy Computation**: Hooks execute on every LLM call
|
||||
3. **Handle Errors Gracefully**: Use try-except to prevent hook failures from breaking execution
|
||||
4. **Use Type Hints**: Leverage `LLMCallHookContext` for better IDE support
|
||||
5. **Document Hook Behavior**: Especially for blocking conditions
|
||||
6. **Test Hooks Independently**: Unit test hooks before using in production
|
||||
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)
|
||||
```
|
||||
1. **Keep hooks focused and fast** — they run on every LLM call
|
||||
2. **Modify in-place** — always mutate `ctx.messages`, never replace the list
|
||||
3. **Use type hints** — annotate with `LLMCallHookContext` for IDE support
|
||||
4. **Abort loudly** — raise `HookAborted` with a meaningful reason and source;
|
||||
any other exception is swallowed (fail-open)
|
||||
5. **Clear hooks in tests** — call `clear_all_hooks()` between test runs
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Hook Not Executing
|
||||
- Verify hook is registered before crew execution
|
||||
- Check if previous hook returned `False` (blocks subsequent hooks)
|
||||
- Ensure hook signature matches expected type
|
||||
- Verify the hook is registered before crew execution
|
||||
- Check whether an earlier hook aborted (subsequent hooks don't run)
|
||||
|
||||
### Message Modifications Not Persisting
|
||||
- Use in-place modifications: `context.messages.append()`
|
||||
- Don't replace the list: `context.messages = []`
|
||||
- Use in-place modifications: `ctx.messages.append(...)`
|
||||
- Don't replace the list: `ctx.messages = []`
|
||||
|
||||
### 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
|
||||
|
||||
## 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"
|
||||
---
|
||||
|
||||
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
|
||||
|
||||
Tool hooks are executed at two critical points:
|
||||
- **Before Tool Call**: Modify inputs, validate parameters, or block execution
|
||||
- **After Tool Call**: Transform results, sanitize outputs, or log execution details
|
||||
Tool hooks are executed at two interception points:
|
||||
|
||||
## 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:
|
||||
- Inspect and modify tool inputs
|
||||
- Block tool execution based on conditions
|
||||
- Implement approval gates for dangerous operations
|
||||
- Validate parameters
|
||||
- Log tool invocations
|
||||
## Hook Signature
|
||||
|
||||
**Signature:**
|
||||
```python
|
||||
def before_hook(context: ToolCallHookContext) -> bool | None:
|
||||
# Return False to block execution
|
||||
# Return True or None to allow execution
|
||||
from crewai.hooks import on, HookAborted, InterceptionPoint, ToolCallHookContext
|
||||
|
||||
@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:
|
||||
- Modify or sanitize tool results
|
||||
- Add metadata or formatting
|
||||
- Log execution results
|
||||
- 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
|
||||
...
|
||||
```
|
||||
When a call is blocked, the tool does not run and the agent receives
|
||||
`"Tool execution blocked by hook. Tool: <name>"` as the result — the run
|
||||
continues. `POST_TOOL_CALL` hooks still fire on blocked calls, so monitoring
|
||||
hooks see every attempt.
|
||||
|
||||
## Tool Hook Context
|
||||
|
||||
The `ToolCallHookContext` object provides comprehensive access to tool execution state:
|
||||
The `ToolCallHookContext` object provides comprehensive access to tool
|
||||
execution state:
|
||||
|
||||
```python
|
||||
class ToolCallHookContext:
|
||||
@@ -60,11 +64,18 @@ class ToolCallHookContext:
|
||||
agent: Agent | BaseAgent | None # Agent executing the tool
|
||||
task: Task | None # Current task
|
||||
crew: Crew | None # Crew instance
|
||||
tool_result: str | None # Agent-facing result string (after hooks only)
|
||||
raw_tool_result: Any | None # Raw Python result (after hooks only)
|
||||
tool_result: str | None # Agent-facing result string (POST_TOOL_CALL 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
|
||||
|
||||
@@ -72,83 +83,58 @@ For typed tool outputs, `tool_result` is the string the agent sees. By default,
|
||||
|
||||
```python
|
||||
# ✅ Correct - modify in-place
|
||||
def sanitize_input(context: ToolCallHookContext) -> None:
|
||||
context.tool_input['query'] = context.tool_input['query'].lower()
|
||||
@on(InterceptionPoint.PRE_TOOL_CALL)
|
||||
def sanitize_input(ctx: ToolCallHookContext) -> None:
|
||||
ctx.tool_input['query'] = ctx.tool_input['query'].lower()
|
||||
|
||||
# ❌ Wrong - replaces dict reference
|
||||
def wrong_approach(context: ToolCallHookContext) -> None:
|
||||
context.tool_input = {'query': 'new query'}
|
||||
# ❌ Wrong - replaces dict reference; the tool never sees it
|
||||
@on(InterceptionPoint.PRE_TOOL_CALL)
|
||||
def wrong_approach(ctx: ToolCallHookContext) -> None:
|
||||
ctx.tool_input = {'query': 'new query'}
|
||||
```
|
||||
|
||||
## 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
|
||||
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):
|
||||
print(f"Tool: {context.tool_name}")
|
||||
print(f"Input: {context.tool_input}")
|
||||
return None # Allow execution
|
||||
@on(InterceptionPoint.PRE_TOOL_CALL)
|
||||
def log_tool_call(ctx):
|
||||
print(f"Tool: {ctx.tool_name}, input: {ctx.tool_input}")
|
||||
|
||||
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
|
||||
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
|
||||
class MyProjCrew:
|
||||
@before_tool_call_crew
|
||||
def validate_tool_inputs(self, context):
|
||||
@on(InterceptionPoint.PRE_TOOL_CALL)
|
||||
def validate_tool_inputs(self, ctx):
|
||||
# Only applies to this crew
|
||||
if context.tool_name == "web_search":
|
||||
if not context.tool_input.get('query'):
|
||||
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
|
||||
if ctx.tool_name == "web_search" and not ctx.tool_input.get("query"):
|
||||
raise HookAborted(reason="empty search query", source="input-validation")
|
||||
|
||||
@crew
|
||||
def crew(self) -> Crew:
|
||||
return Crew(
|
||||
agents=self.agents,
|
||||
tasks=self.tasks,
|
||||
process=Process.sequential,
|
||||
verbose=True
|
||||
)
|
||||
return Crew(agents=self.agents, tasks=self.tasks, process=Process.sequential)
|
||||
```
|
||||
|
||||
## Common Use Cases
|
||||
@@ -156,112 +142,63 @@ class MyProjCrew:
|
||||
### 1. Safety Guardrails
|
||||
|
||||
```python
|
||||
@before_tool_call
|
||||
def safety_check(context: ToolCallHookContext) -> bool | None:
|
||||
# Block tools that could cause harm
|
||||
destructive_tools = [
|
||||
'delete_file',
|
||||
'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
|
||||
@on(InterceptionPoint.PRE_TOOL_CALL)
|
||||
def safety_check(ctx: ToolCallHookContext) -> None:
|
||||
destructive = {'delete_file', 'drop_table', 'remove_user', 'system_shutdown'}
|
||||
if ctx.tool_name in destructive:
|
||||
raise HookAborted(reason=f"{ctx.tool_name} is destructive", source="safety-policy")
|
||||
```
|
||||
|
||||
### 2. Human Approval Gate
|
||||
|
||||
```python
|
||||
@before_tool_call
|
||||
def require_approval_for_actions(context: ToolCallHookContext) -> bool | None:
|
||||
approval_required = [
|
||||
'send_email',
|
||||
'make_purchase',
|
||||
'delete_file',
|
||||
'post_message'
|
||||
]
|
||||
|
||||
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
|
||||
@on(InterceptionPoint.PRE_TOOL_CALL, tools=["send_email", "make_purchase", "delete_file"])
|
||||
def require_approval(ctx: ToolCallHookContext) -> None:
|
||||
response = ctx.request_human_input(
|
||||
prompt=f"Approve {ctx.tool_name}?",
|
||||
default_message=f"Input: {ctx.tool_input}\nType 'yes' to approve:",
|
||||
)
|
||||
if response.lower() != 'yes':
|
||||
raise HookAborted(reason="denied by operator", source="approval-gate")
|
||||
```
|
||||
|
||||
### 3. Input Validation and Sanitization
|
||||
|
||||
```python
|
||||
@before_tool_call
|
||||
def validate_and_sanitize_inputs(context: ToolCallHookContext) -> bool | None:
|
||||
# Validate search queries
|
||||
if context.tool_name == 'web_search':
|
||||
query = context.tool_input.get('query', '')
|
||||
if len(query) < 3:
|
||||
print("❌ Search query too short")
|
||||
return False
|
||||
@on(InterceptionPoint.PRE_TOOL_CALL, tools=["web_search"])
|
||||
def validate_query(ctx: ToolCallHookContext) -> None:
|
||||
query = ctx.tool_input.get('query', '')
|
||||
if len(query) < 3:
|
||||
raise HookAborted(reason="search query too short", source="input-validation")
|
||||
ctx.tool_input['query'] = query.strip().lower()
|
||||
|
||||
# Sanitize query
|
||||
context.tool_input['query'] = query.strip().lower()
|
||||
|
||||
# Validate file paths
|
||||
if context.tool_name == 'read_file':
|
||||
path = context.tool_input.get('path', '')
|
||||
if '..' in path or path.startswith('/'):
|
||||
print("❌ Invalid file path")
|
||||
return False
|
||||
|
||||
return None
|
||||
@on(InterceptionPoint.PRE_TOOL_CALL, tools=["read_file"])
|
||||
def validate_path(ctx: ToolCallHookContext) -> None:
|
||||
path = ctx.tool_input.get('path', '')
|
||||
if '..' in path or path.startswith('/'):
|
||||
raise HookAborted(reason="invalid file path", source="input-validation")
|
||||
```
|
||||
|
||||
### 4. Result Sanitization
|
||||
|
||||
```python
|
||||
@after_tool_call
|
||||
def sanitize_sensitive_data(context: ToolCallHookContext) -> str | None:
|
||||
if not context.tool_result:
|
||||
import re
|
||||
|
||||
@on(InterceptionPoint.POST_TOOL_CALL)
|
||||
def sanitize_sensitive_data(ctx: ToolCallHookContext) -> str | None:
|
||||
if not ctx.tool_result:
|
||||
return None
|
||||
|
||||
import re
|
||||
result = context.tool_result
|
||||
|
||||
# Remove API keys
|
||||
result = re.sub(
|
||||
r'(api[_-]?key|token)["\']?\s*[:=]\s*["\']?[\w-]+',
|
||||
r'\1: [REDACTED]',
|
||||
result,
|
||||
flags=re.IGNORECASE
|
||||
ctx.tool_result,
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
|
||||
# Remove email addresses
|
||||
result = re.sub(
|
||||
return re.sub(
|
||||
r'\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b',
|
||||
'[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
|
||||
@@ -270,32 +207,17 @@ def sanitize_sensitive_data(context: ToolCallHookContext) -> str | None:
|
||||
import time
|
||||
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
|
||||
def start_timer(context: ToolCallHookContext) -> None:
|
||||
context.tool_input['_start_time'] = time.time()
|
||||
return None
|
||||
@on(InterceptionPoint.PRE_TOOL_CALL)
|
||||
def start_timer(ctx: ToolCallHookContext) -> None:
|
||||
ctx.tool_input['_start_time'] = time.time()
|
||||
|
||||
@after_tool_call
|
||||
def track_tool_usage(context: ToolCallHookContext) -> None:
|
||||
start_time = context.tool_input.get('_start_time', time.time())
|
||||
duration = 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
|
||||
@on(InterceptionPoint.POST_TOOL_CALL)
|
||||
def track_tool_usage(ctx: ToolCallHookContext) -> None:
|
||||
start_time = ctx.tool_input.pop('_start_time', time.time())
|
||||
tool_stats[ctx.tool_name]['count'] += 1
|
||||
tool_stats[ctx.tool_name]['total_time'] += time.time() - start_time
|
||||
```
|
||||
|
||||
### 6. Rate Limiting
|
||||
@@ -306,298 +228,113 @@ from datetime import datetime, timedelta
|
||||
|
||||
tool_call_history = defaultdict(list)
|
||||
|
||||
@before_tool_call
|
||||
def rate_limit_tools(context: ToolCallHookContext) -> bool | None:
|
||||
tool_name = context.tool_name
|
||||
@on(InterceptionPoint.PRE_TOOL_CALL)
|
||||
def rate_limit_tools(ctx: ToolCallHookContext) -> None:
|
||||
now = datetime.now()
|
||||
|
||||
# Clean old entries (older than 1 minute)
|
||||
tool_call_history[tool_name] = [
|
||||
call_time for call_time in tool_call_history[tool_name]
|
||||
if now - call_time < timedelta(minutes=1)
|
||||
]
|
||||
|
||||
# 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
|
||||
history = tool_call_history[ctx.tool_name]
|
||||
history[:] = [t for t in history if now - t < timedelta(minutes=1)]
|
||||
if len(history) >= 10:
|
||||
raise HookAborted(reason=f"rate limit exceeded for {ctx.tool_name}",
|
||||
source="rate-limiter")
|
||||
history.append(now)
|
||||
```
|
||||
|
||||
## Hook Management
|
||||
|
||||
### Unregistering Hooks
|
||||
|
||||
```python
|
||||
from crewai.hooks import (
|
||||
unregister_before_tool_call_hook,
|
||||
unregister_after_tool_call_hook
|
||||
InterceptionPoint,
|
||||
clear_all_hooks,
|
||||
clear_hooks,
|
||||
get_hooks,
|
||||
unregister_hook,
|
||||
)
|
||||
|
||||
# Unregister specific hook
|
||||
def my_hook(context):
|
||||
...
|
||||
# Unregister a specific hook
|
||||
unregister_hook(InterceptionPoint.PRE_TOOL_CALL, my_hook)
|
||||
|
||||
register_before_tool_call_hook(my_hook)
|
||||
# Later...
|
||||
success = unregister_before_tool_call_hook(my_hook)
|
||||
print(f"Unregistered: {success}")
|
||||
# Clear one point, or everything (e.g. between tests)
|
||||
clear_hooks(InterceptionPoint.POST_TOOL_CALL)
|
||||
clear_all_hooks()
|
||||
|
||||
# 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
|
||||
from crewai.hooks import (
|
||||
clear_before_tool_call_hooks,
|
||||
clear_after_tool_call_hooks,
|
||||
clear_all_tool_call_hooks
|
||||
)
|
||||
from crewai.hooks import before_tool_call, after_tool_call
|
||||
|
||||
# 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
|
||||
def conditional_blocking(context: ToolCallHookContext) -> bool | None:
|
||||
# Only block for specific agents
|
||||
if context.agent and context.agent.role == "junior_agent":
|
||||
if context.tool_name in ['delete_file', 'send_email']:
|
||||
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
|
||||
def block_dangerous_tools(context):
|
||||
if context.tool_name in ('delete_database', 'drop_table'):
|
||||
return False # Block execution
|
||||
return None
|
||||
|
||||
@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
|
||||
```
|
||||
|
||||
### Context-Aware Input Modification
|
||||
Differences from `@on`:
|
||||
|
||||
```python
|
||||
@before_tool_call
|
||||
def enhance_tool_inputs(context: ToolCallHookContext) -> None:
|
||||
# Add context based on agent role
|
||||
if context.agent and context.agent.role == "researcher":
|
||||
if context.tool_name == 'web_search':
|
||||
# Add domain restrictions for researchers
|
||||
context.tool_input['domains'] = ['edu', 'gov', 'org']
|
||||
- **Blocking** is `return False` from a before hook — equivalent to raising
|
||||
`HookAborted`, but without a custom reason or source for telemetry. The agent
|
||||
sees the same `"Tool execution blocked by hook"` message.
|
||||
- **Signatures** are point-specific: before hooks return `bool | None`, after
|
||||
hooks return `str | None`. The context object is the same
|
||||
`ToolCallHookContext`.
|
||||
- **Filters and crew-scoping** work the same way:
|
||||
`@before_tool_call(tools=[...], agents=[...])`, and applying the decorator to
|
||||
a `@CrewBase` method scopes it to that crew.
|
||||
|
||||
# Add context based on task
|
||||
if context.task and "urgent" in context.task.description.lower():
|
||||
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
|
||||
```
|
||||
Prefer `@on` for new code; keep the legacy style where it is already in use —
|
||||
there is no behavioral penalty.
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Keep Hooks Focused**: Each hook should have a single responsibility
|
||||
2. **Avoid Heavy Computation**: Hooks execute on every tool call
|
||||
3. **Handle Errors Gracefully**: Use try-except to prevent hook failures
|
||||
4. **Use Type Hints**: Leverage `ToolCallHookContext` for better IDE support
|
||||
5. **Document Blocking Conditions**: Make it clear when/why tools are blocked
|
||||
6. **Test Hooks Independently**: Unit test hooks before using in production
|
||||
7. **Clear Hooks in Tests**: Use `clear_all_tool_call_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
|
||||
```
|
||||
1. **Keep hooks focused and fast** — they run on every tool call
|
||||
2. **Modify in-place** — always mutate `ctx.tool_input`, never replace the dict
|
||||
3. **Prefer filters over conditionals** — `tools=` / `agents=` keep hook bodies small
|
||||
4. **Abort loudly** — raise `HookAborted` with a meaningful reason and source;
|
||||
any other exception is swallowed (fail-open)
|
||||
5. **Use type hints** — annotate with `ToolCallHookContext` for IDE support
|
||||
6. **Clear hooks in tests** — call `clear_all_hooks()` between test runs
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Hook Not Executing
|
||||
- Verify hook is registered before crew execution
|
||||
- Check if previous hook returned `False` (blocks execution and subsequent hooks)
|
||||
- Ensure hook signature matches expected type
|
||||
- Verify the hook is registered before crew execution
|
||||
- Check whether an earlier hook blocked the call (subsequent pre hooks don't run)
|
||||
- Check `tools=` / `agents=` filters against the actual tool name and agent role
|
||||
|
||||
### Input Modifications Not Working
|
||||
- Use in-place modifications: `context.tool_input['key'] = value`
|
||||
- Don't replace the dict: `context.tool_input = {}`
|
||||
- Use in-place modifications: `ctx.tool_input['key'] = value`
|
||||
- Don't replace the dict: `ctx.tool_input = {}`
|
||||
|
||||
### 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
|
||||
- Ensure the tool actually returned a result
|
||||
|
||||
### Tool Blocked Unexpectedly
|
||||
- Check all before hooks for blocking conditions
|
||||
- Verify hook execution order
|
||||
- Add debug logging to identify which hook is blocking
|
||||
- Check all pre hooks for `HookAborted` / `return False` conditions
|
||||
- The abort reason and source appear on the `HookDispatchedEvent` telemetry
|
||||
|
||||
## 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