mirror of
https://github.com/crewAIInc/crewAI.git
synced 2026-07-15 11:55:09 +00:00
Compare commits
9 Commits
fix/native
...
luzk/hooks
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
191a17391a | ||
|
|
6be349f2e7 | ||
|
|
b8cb5dcc69 | ||
|
|
0e5d0ecfb9 | ||
|
|
a194f3867a | ||
|
|
7d21283630 | ||
|
|
6452608724 | ||
|
|
5f4ac9f407 | ||
|
|
9d72e269e4 |
6
.github/workflows/vulnerability-scan.yml
vendored
6
.github/workflows/vulnerability-scan.yml
vendored
@@ -74,7 +74,8 @@ jobs:
|
||||
--ignore-vuln PYSEC-2025-217 \
|
||||
--ignore-vuln PYSEC-2025-218 \
|
||||
--ignore-vuln PYSEC-2026-597 \
|
||||
--ignore-vuln GHSA-f4j7-r4q5-qw2c
|
||||
--ignore-vuln GHSA-f4j7-r4q5-qw2c \
|
||||
--ignore-vuln GHSA-xf7x-x43h-rpqh
|
||||
# Ignored CVEs:
|
||||
# PYSEC-2024-277 - joblib 1.5.3: disputed; NumpyArrayWrapper only used with trusted caches
|
||||
# PYSEC-2026-89 - markdown 3.10.2: DoS via malformed HTML; fix 3.8.1 — already past, advisory range is stale
|
||||
@@ -89,6 +90,9 @@ jobs:
|
||||
# GHSA-f4j7-r4q5-qw2c - chromadb 1.1.1 (CVE-2026-45829): pre-auth RCE via /api/v2/tenants/{tenant}/databases/{db}/collections when trust_remote_code=true.
|
||||
# Advisory: vulnerable >=1.0.0,<=1.5.9, firstPatchedVersion=none. We only use chromadb.PersistentClient (lib/crewai/src/crewai/rag/chromadb/factory.py)
|
||||
# and chromadb.utils.embedding_functions; the chromadb HTTP server is never started, so the vulnerable route is not exposed.
|
||||
# GHSA-xf7x-x43h-rpqh - json-repair 0.25.3 (published 2026-07-13): CPU DoS via circular $ref in SchemaRepairer.resolve_schema().
|
||||
# The vulnerable schema_repair module does not exist in 0.25.x (added in later releases), and CrewAI only calls
|
||||
# repair_json() without schemas. The fixed release 0.60.1 is outside the json-repair~=0.25.2 pin.
|
||||
continue-on-error: true
|
||||
|
||||
- name: Display results
|
||||
|
||||
@@ -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)
|
||||
@@ -1,525 +1,281 @@
|
||||
---
|
||||
title: Execution Hooks Overview
|
||||
description: Understanding and using execution hooks in CrewAI for fine-grained control over agent operations
|
||||
title: Execution Hooks
|
||||
description: Intercept, modify, and control CrewAI's runtime with the @on decorator - one contract covering every interception point
|
||||
mode: "wide"
|
||||
---
|
||||
|
||||
Execution Hooks provide fine-grained control over the runtime behavior of your CrewAI agents. Unlike kickoff hooks that run before and after crew execution, execution hooks intercept specific operations during agent execution, allowing you to modify behavior, implement safety checks, and add comprehensive monitoring.
|
||||
Execution hooks provide fine-grained control over the runtime behavior of your
|
||||
CrewAI agents. Unlike kickoff hooks that run before and after crew execution,
|
||||
execution hooks intercept specific operations during execution — from the moment
|
||||
a run starts, through every model call, tool call, and task or flow-method step,
|
||||
down to the final output.
|
||||
|
||||
## Types of Execution Hooks
|
||||
Hooks are written with the `@on` decorator: one registration API and one
|
||||
contract cover every interception point in the framework.
|
||||
|
||||
CrewAI provides two main categories of execution hooks:
|
||||
```python
|
||||
from crewai.hooks import on, HookAborted, InterceptionPoint
|
||||
|
||||
### 1. [LLM Call Hooks](/learn/llm-hooks)
|
||||
@on(InterceptionPoint.PRE_TOOL_CALL, tools=["delete_file"])
|
||||
def guard_deletes(ctx):
|
||||
raise HookAborted(reason="file deletion is not allowed", source="policy")
|
||||
```
|
||||
|
||||
Control and monitor language model interactions:
|
||||
- **Before LLM Call**: Modify prompts, validate inputs, implement approval gates
|
||||
- **After LLM Call**: Transform responses, sanitize outputs, update conversation history
|
||||
<Note>
|
||||
The point-specific decorators (`@before_llm_call`, `@after_tool_call`, ...) keep
|
||||
working unchanged — they are adapters over the same engine. See
|
||||
[Point-specific decorators (legacy)](#point-specific-decorators-legacy) at the
|
||||
end of this page.
|
||||
</Note>
|
||||
|
||||
**Use Cases:**
|
||||
- Iteration limiting
|
||||
- Cost tracking and token usage monitoring
|
||||
- Response sanitization and content filtering
|
||||
- Human-in-the-loop approval for LLM calls
|
||||
- Adding safety guidelines or context
|
||||
- Debug logging and request/response inspection
|
||||
## The contract
|
||||
|
||||
[View LLM Hooks Documentation →](/learn/llm-hooks)
|
||||
Every hook is a **synchronous** callable that receives a single typed context:
|
||||
|
||||
### 2. [Tool Call Hooks](/learn/tool-hooks)
|
||||
```python
|
||||
from crewai.hooks import on, HookAborted, InterceptionPoint
|
||||
|
||||
Control and monitor tool execution:
|
||||
- **Before Tool Call**: Modify inputs, validate parameters, block dangerous operations
|
||||
- **After Tool Call**: Transform results, sanitize outputs, log execution details
|
||||
@on(InterceptionPoint.INPUT)
|
||||
def add_defaults(ctx):
|
||||
# 1. Observe: read anything off the context.
|
||||
# 2. Mutate in place: change ctx.payload or nested fields directly.
|
||||
ctx.payload.setdefault("locale", "en-US")
|
||||
# 3. Or replace: return a new value to swap ctx.payload.
|
||||
# 4. Or abort: raise HookAborted(reason, source) to stop the operation.
|
||||
return None
|
||||
```
|
||||
|
||||
**Use Cases:**
|
||||
- Safety guardrails for destructive operations
|
||||
- Human approval for sensitive actions
|
||||
- Input validation and sanitization
|
||||
- Result caching and rate limiting
|
||||
- Tool usage analytics
|
||||
- Debug logging and monitoring
|
||||
A hook may do any of four things:
|
||||
|
||||
[View Tool Hooks Documentation →](/learn/tool-hooks)
|
||||
| Action | How | Effect |
|
||||
|--------|-----|--------|
|
||||
| **Proceed** | `return None` (or nothing) | Operation continues unchanged |
|
||||
| **Mutate** | Change `ctx.payload` / fields in place | Change is visible downstream |
|
||||
| **Replace** | `return new_payload` | A non-`None` return replaces `ctx.payload` |
|
||||
| **Abort** | `raise HookAborted(reason, source)` | Operation is stopped; the reason propagates |
|
||||
|
||||
## Hook Registration Methods
|
||||
## Registering hooks
|
||||
|
||||
### 1. Decorator-Based Hooks (Recommended)
|
||||
Use `@on` for global hooks. It accepts `agents=` / `tools=` filters to scope a
|
||||
hook to specific agent roles or tool names:
|
||||
|
||||
The cleanest and most Pythonic way to register hooks:
|
||||
```python
|
||||
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: {(ctx.tool_result or '')[:80]}")
|
||||
```
|
||||
|
||||
Applied to a method inside a `@CrewBase` class, `@on` registers a
|
||||
**crew-scoped** hook, active only while that crew runs:
|
||||
|
||||
```python
|
||||
from crewai import CrewBase
|
||||
from crewai.hooks import on, InterceptionPoint
|
||||
|
||||
@CrewBase
|
||||
class MyProjCrew:
|
||||
@on(InterceptionPoint.PRE_MODEL_CALL)
|
||||
def validate_inputs(self, ctx):
|
||||
# Only applies to this crew
|
||||
return None
|
||||
```
|
||||
|
||||
## Interception point catalog
|
||||
|
||||
Each family has a detailed guide covering its context schema, payload
|
||||
semantics, and examples.
|
||||
|
||||
### [Execution boundaries](/edge/en/learn/execution-boundary-hooks)
|
||||
|
||||
| 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 boundaries](/edge/en/learn/llm-hooks) & [tool boundaries](/edge/en/learn/tool-hooks)
|
||||
|
||||
| Point | When | Hook receives |
|
||||
|-------|------|---------------|
|
||||
| `PRE_MODEL_CALL` | Before an LLM call | `LLMCallHookContext` |
|
||||
| `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 | `ToolCallHookContext` (with results set) |
|
||||
|
||||
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.
|
||||
|
||||
### [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 |
|
||||
|
||||
`PRE_STEP` / `POST_STEP` carry `ctx.kind` (`"task"` or `"flow_method"`) and
|
||||
`ctx.step_name`.
|
||||
|
||||
## Aborting an operation
|
||||
|
||||
`HookAborted` carries a `reason` and an optional `source`. The `source` defaults
|
||||
to the aborting hook when omitted, which is useful for telemetry and failure
|
||||
messages:
|
||||
|
||||
```python
|
||||
@on(InterceptionPoint.EXECUTION_START)
|
||||
def enforce_policy(ctx):
|
||||
if not ctx.payload.get("authorized"):
|
||||
raise HookAborted(reason="unauthorized execution", source="access-control")
|
||||
```
|
||||
|
||||
## Composition, ordering, and fail-open
|
||||
|
||||
- Multiple hooks on the same point run in **registration order**, global hooks
|
||||
first, then execution-scoped hooks. Legacy hooks registered for the same point
|
||||
participate in the same chain.
|
||||
- The (possibly mutated) payload flows from one hook to the next.
|
||||
- `HookAborted` **propagates by design** and stops the chain.
|
||||
- Any *other* exception raised by a hook is **swallowed** (fail-open) so a single
|
||||
buggy hook can't crash a run.
|
||||
- When no hook is registered for a point, dispatch is a single dict lookup
|
||||
(no-op fast path), so unused points cost effectively nothing.
|
||||
|
||||
## Common patterns
|
||||
|
||||
### Safety guardrails
|
||||
|
||||
```python
|
||||
@on(InterceptionPoint.PRE_TOOL_CALL)
|
||||
def block_dangerous_tools(ctx):
|
||||
dangerous = {"delete_file", "drop_table", "system_shutdown"}
|
||||
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.iterations > 15:
|
||||
raise HookAborted(reason="maximum iterations exceeded", source="loop-guard")
|
||||
```
|
||||
|
||||
### Human-in-the-loop approval
|
||||
|
||||
```python
|
||||
@on(InterceptionPoint.PRE_TOOL_CALL, tools=["send_email", "make_payment"])
|
||||
def require_approval(ctx):
|
||||
response = ctx.request_human_input(
|
||||
prompt=f"Approve {ctx.tool_name}?",
|
||||
default_message="Type 'yes' to approve:",
|
||||
)
|
||||
if response.lower() != "yes":
|
||||
raise HookAborted(reason="rejected by operator", source="approval-gate")
|
||||
```
|
||||
|
||||
### Sanitizing outputs
|
||||
|
||||
A non-`None` return value replaces the interceptable value, so transformations
|
||||
are plain return statements:
|
||||
|
||||
```python
|
||||
import re
|
||||
|
||||
@on(InterceptionPoint.POST_MODEL_CALL)
|
||||
def redact_keys(ctx):
|
||||
return re.sub(
|
||||
r'(api[_-]?key)["\']?\s*[:=]\s*["\']?[\w-]+',
|
||||
r"\1: [REDACTED]",
|
||||
ctx.response,
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
```
|
||||
|
||||
### Observing steps
|
||||
|
||||
```python
|
||||
@on(InterceptionPoint.POST_STEP)
|
||||
def trace_steps(ctx):
|
||||
print(f"{ctx.kind} '{ctx.step_name}' finished")
|
||||
```
|
||||
|
||||
## Telemetry
|
||||
|
||||
Whenever a point actually dispatches to at least one hook, CrewAI emits a
|
||||
`HookDispatchedEvent` on the event bus with the point, the outcome
|
||||
(`proceeded` / `modified` / `aborted`), the hook count, the duration, and — for
|
||||
aborts — the reason and source. The no-op fast path emits nothing.
|
||||
|
||||
## Managing hooks in tests
|
||||
|
||||
Global hooks persist for the lifetime of the process. Reset them between tests:
|
||||
|
||||
```python
|
||||
import pytest
|
||||
from crewai.hooks import clear_all_hooks
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_hooks():
|
||||
clear_all_hooks()
|
||||
yield
|
||||
clear_all_hooks()
|
||||
```
|
||||
|
||||
## Best practices
|
||||
|
||||
1. **Keep hooks focused** — one clear responsibility per hook; register several
|
||||
small hooks rather than one that does everything.
|
||||
2. **Keep hooks fast** — hooks run on every dispatch of their point; avoid heavy
|
||||
computation and lazy-import heavy dependencies.
|
||||
3. **Prefer scoping** — use `agents=` / `tools=` filters and crew-scoped
|
||||
registration instead of unconditional global hooks.
|
||||
4. **Abort loudly** — raise `HookAborted` with a meaningful `reason` and
|
||||
`source`; that context surfaces in error messages and telemetry. Remember
|
||||
that any other exception is swallowed (fail-open), so don't rely on raising
|
||||
`ValueError` to stop a run.
|
||||
|
||||
## Point-specific decorators (legacy)
|
||||
|
||||
Before `@on`, LLM and tool calls were hooked with dedicated decorator pairs.
|
||||
These keep working unchanged — they are adapters over the same dispatcher, so
|
||||
they compose with `@on` hooks in the same registration-order chain:
|
||||
|
||||
```python
|
||||
from crewai.hooks import before_llm_call, after_llm_call, before_tool_call, after_tool_call
|
||||
|
||||
@before_llm_call
|
||||
def limit_iterations(context):
|
||||
"""Prevent infinite loops by limiting iterations."""
|
||||
if context.iterations > 10:
|
||||
return False # Block execution
|
||||
return None
|
||||
|
||||
@after_llm_call
|
||||
def sanitize_response(context):
|
||||
"""Remove sensitive data from LLM responses."""
|
||||
if "API_KEY" in context.response:
|
||||
return context.response.replace("API_KEY", "[REDACTED]")
|
||||
return None
|
||||
|
||||
@before_tool_call
|
||||
def block_dangerous_tools(context):
|
||||
"""Block destructive operations."""
|
||||
if context.tool_name == "delete_database":
|
||||
return False # Block execution
|
||||
return None
|
||||
|
||||
@after_tool_call
|
||||
def log_tool_result(context):
|
||||
"""Log tool execution."""
|
||||
print(f"Tool {context.tool_name} completed")
|
||||
return None
|
||||
```
|
||||
|
||||
### 2. Crew-Scoped Hooks
|
||||
|
||||
Apply hooks only to specific crew instances:
|
||||
|
||||
```python
|
||||
from crewai import CrewBase
|
||||
from crewai.project import crew
|
||||
from crewai.hooks import before_llm_call_crew, after_tool_call_crew
|
||||
|
||||
@CrewBase
|
||||
class MyProjCrew:
|
||||
@before_llm_call_crew
|
||||
def validate_inputs(self, context):
|
||||
# Only applies to this crew
|
||||
print(f"LLM call in {self.__class__.__name__}")
|
||||
return None
|
||||
|
||||
@after_tool_call_crew
|
||||
def log_results(self, context):
|
||||
# Crew-specific logging
|
||||
print(f"Tool result: {context.tool_result[:50]}...")
|
||||
return None
|
||||
|
||||
@crew
|
||||
def crew(self) -> Crew:
|
||||
return Crew(
|
||||
agents=self.agents,
|
||||
tasks=self.tasks,
|
||||
process=Process.sequential
|
||||
)
|
||||
```
|
||||
|
||||
## Hook Execution Flow
|
||||
|
||||
### LLM Call Flow
|
||||
|
||||
```
|
||||
Agent needs to call LLM
|
||||
↓
|
||||
[Before LLM Call Hooks Execute]
|
||||
├→ Hook 1: Validate iteration count
|
||||
├→ Hook 2: Add safety context
|
||||
└→ Hook 3: Log request
|
||||
↓
|
||||
If any hook returns False:
|
||||
├→ Block LLM call
|
||||
└→ Raise ValueError
|
||||
↓
|
||||
If all hooks return True/None:
|
||||
├→ LLM call proceeds
|
||||
└→ Response generated
|
||||
↓
|
||||
[After LLM Call Hooks Execute]
|
||||
├→ Hook 1: Sanitize response
|
||||
├→ Hook 2: Log response
|
||||
└→ Hook 3: Update metrics
|
||||
↓
|
||||
Final response returned
|
||||
```
|
||||
|
||||
### Tool Call Flow
|
||||
|
||||
```
|
||||
Agent needs to execute tool
|
||||
↓
|
||||
[Before Tool Call Hooks Execute]
|
||||
├→ Hook 1: Check if tool is allowed
|
||||
├→ Hook 2: Validate inputs
|
||||
└→ Hook 3: Request approval if needed
|
||||
↓
|
||||
If any hook returns False:
|
||||
├→ Block tool execution
|
||||
└→ Return error message
|
||||
↓
|
||||
If all hooks return True/None:
|
||||
├→ Tool execution proceeds
|
||||
└→ Result generated
|
||||
↓
|
||||
[After Tool Call Hooks Execute]
|
||||
├→ Hook 1: Sanitize result
|
||||
├→ Hook 2: Cache result
|
||||
└→ Hook 3: Log metrics
|
||||
↓
|
||||
Final result returned
|
||||
```
|
||||
|
||||
## Hook Context Objects
|
||||
|
||||
### LLMCallHookContext
|
||||
|
||||
Provides access to LLM execution state:
|
||||
|
||||
```python
|
||||
class LLMCallHookContext:
|
||||
executor: CrewAgentExecutor # Full executor access
|
||||
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
|
||||
response: str | None # LLM response (after hooks)
|
||||
```
|
||||
|
||||
### ToolCallHookContext
|
||||
|
||||
Provides access to tool execution state:
|
||||
|
||||
```python
|
||||
class ToolCallHookContext:
|
||||
tool_name: str # Tool being called
|
||||
tool_input: dict # Mutable input parameters
|
||||
tool: CrewStructuredTool # Tool instance
|
||||
agent: Agent | None # Agent executing
|
||||
task: Task | None # Current task
|
||||
crew: Crew | None # Crew instance
|
||||
tool_result: str | None # Agent-facing result string (after hooks)
|
||||
raw_tool_result: Any | None # Raw Python result (after hooks)
|
||||
```
|
||||
|
||||
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. `raw_tool_result` is the original Python value returned by the tool.
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Safety and Validation
|
||||
|
||||
```python
|
||||
@before_tool_call
|
||||
def safety_check(context):
|
||||
"""Block destructive operations."""
|
||||
dangerous = ['delete_file', 'drop_table', 'system_shutdown']
|
||||
if context.tool_name in dangerous:
|
||||
print(f"🛑 Blocked: {context.tool_name}")
|
||||
return False
|
||||
return None
|
||||
|
||||
@before_llm_call
|
||||
def iteration_limit(context):
|
||||
"""Prevent infinite loops."""
|
||||
if context.iterations > 15:
|
||||
print("⛔ Maximum iterations exceeded")
|
||||
return False
|
||||
return None
|
||||
```
|
||||
|
||||
### Human-in-the-Loop
|
||||
|
||||
```python
|
||||
@before_tool_call
|
||||
def require_approval(context):
|
||||
"""Require approval for sensitive operations."""
|
||||
sensitive = ['send_email', 'make_payment', 'post_message']
|
||||
|
||||
if context.tool_name in sensitive:
|
||||
response = context.request_human_input(
|
||||
prompt=f"Approve {context.tool_name}?",
|
||||
default_message="Type 'yes' to approve:"
|
||||
)
|
||||
|
||||
if response.lower() != 'yes':
|
||||
return False
|
||||
|
||||
return None
|
||||
```
|
||||
|
||||
### Monitoring and Analytics
|
||||
|
||||
```python
|
||||
from collections import defaultdict
|
||||
import time
|
||||
|
||||
metrics = defaultdict(lambda: {'count': 0, 'total_time': 0})
|
||||
|
||||
@before_tool_call
|
||||
def start_timer(context):
|
||||
context.tool_input['_start'] = time.time()
|
||||
return None
|
||||
|
||||
@after_tool_call
|
||||
def track_metrics(context):
|
||||
start = context.tool_input.get('_start', time.time())
|
||||
duration = time.time() - start
|
||||
|
||||
metrics[context.tool_name]['count'] += 1
|
||||
metrics[context.tool_name]['total_time'] += duration
|
||||
|
||||
return None
|
||||
|
||||
# View metrics
|
||||
def print_metrics():
|
||||
for tool, data in metrics.items():
|
||||
avg = data['total_time'] / data['count']
|
||||
print(f"{tool}: {data['count']} calls, {avg:.2f}s avg")
|
||||
```
|
||||
|
||||
### Response Sanitization
|
||||
|
||||
```python
|
||||
import re
|
||||
|
||||
@after_llm_call
|
||||
def sanitize_llm_response(context):
|
||||
"""Remove sensitive data from LLM responses."""
|
||||
if not context.response:
|
||||
return None
|
||||
|
||||
result = context.response
|
||||
result = re.sub(r'(api[_-]?key)["\']?\s*[:=]\s*["\']?[\w-]+',
|
||||
r'\1: [REDACTED]', result, flags=re.IGNORECASE)
|
||||
return result
|
||||
|
||||
@after_tool_call
|
||||
def sanitize_tool_result(context):
|
||||
"""Remove sensitive data from tool results."""
|
||||
if not context.tool_result:
|
||||
return None
|
||||
|
||||
result = context.tool_result
|
||||
result = re.sub(r'\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b',
|
||||
'[EMAIL-REDACTED]', result)
|
||||
return result
|
||||
```
|
||||
|
||||
## Hook Management
|
||||
|
||||
### Clearing All Hooks
|
||||
|
||||
```python
|
||||
from crewai.hooks import clear_all_global_hooks
|
||||
|
||||
# Clear all hooks at once
|
||||
result = clear_all_global_hooks()
|
||||
print(f"Cleared {result['total']} hooks")
|
||||
# Output: {'llm_hooks': (2, 1), 'tool_hooks': (1, 2), 'total': (3, 3)}
|
||||
```
|
||||
|
||||
### Clearing Specific Hook Types
|
||||
|
||||
```python
|
||||
from crewai.hooks import (
|
||||
clear_before_llm_call_hooks,
|
||||
clear_after_llm_call_hooks,
|
||||
clear_before_tool_call_hooks,
|
||||
clear_after_tool_call_hooks
|
||||
)
|
||||
|
||||
# Clear specific types
|
||||
llm_before_count = clear_before_llm_call_hooks()
|
||||
tool_after_count = clear_after_tool_call_hooks()
|
||||
```
|
||||
|
||||
### Unregistering Individual Hooks
|
||||
|
||||
```python
|
||||
from crewai.hooks import (
|
||||
unregister_before_llm_call_hook,
|
||||
unregister_after_tool_call_hook
|
||||
)
|
||||
|
||||
def my_hook(context):
|
||||
...
|
||||
|
||||
# Register
|
||||
register_before_llm_call_hook(my_hook)
|
||||
|
||||
# Later, unregister
|
||||
success = unregister_before_llm_call_hook(my_hook)
|
||||
print(f"Unregistered: {success}")
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Keep Hooks Focused
|
||||
Each hook should have a single, clear responsibility:
|
||||
|
||||
```python
|
||||
# ✅ Good - focused responsibility
|
||||
@before_tool_call
|
||||
def validate_file_path(context):
|
||||
if context.tool_name == 'read_file':
|
||||
if '..' in context.tool_input.get('path', ''):
|
||||
return False
|
||||
return None
|
||||
|
||||
# ❌ Bad - too many responsibilities
|
||||
@before_tool_call
|
||||
def do_everything(context):
|
||||
# Validation + logging + metrics + approval...
|
||||
...
|
||||
```
|
||||
|
||||
### 2. Handle Errors Gracefully
|
||||
|
||||
```python
|
||||
@before_llm_call
|
||||
def safe_hook(context):
|
||||
try:
|
||||
# Your logic
|
||||
if some_condition:
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f"Hook error: {e}")
|
||||
return None # Allow execution despite error
|
||||
```
|
||||
|
||||
### 3. Modify Context In-Place
|
||||
|
||||
```python
|
||||
# ✅ Correct - modify in-place
|
||||
@before_llm_call
|
||||
def add_context(context):
|
||||
context.messages.append({"role": "system", "content": "Be concise"})
|
||||
|
||||
# ❌ Wrong - replaces reference
|
||||
@before_llm_call
|
||||
def wrong_approach(context):
|
||||
context.messages = [{"role": "system", "content": "Be concise"}]
|
||||
```
|
||||
|
||||
### 4. Use Type Hints
|
||||
|
||||
```python
|
||||
from crewai.hooks import LLMCallHookContext, ToolCallHookContext
|
||||
|
||||
def my_llm_hook(context: LLMCallHookContext) -> bool | None:
|
||||
# IDE autocomplete and type checking
|
||||
return None
|
||||
|
||||
def my_tool_hook(context: ToolCallHookContext) -> str | None:
|
||||
return None
|
||||
```
|
||||
|
||||
### 5. Clean Up in Tests
|
||||
|
||||
```python
|
||||
import pytest
|
||||
from crewai.hooks import clear_all_global_hooks
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clean_hooks():
|
||||
"""Reset hooks before each test."""
|
||||
yield
|
||||
clear_all_global_hooks()
|
||||
```
|
||||
|
||||
## When to Use Which Hook
|
||||
|
||||
### Use LLM Hooks When:
|
||||
- Implementing iteration limits
|
||||
- Adding context or safety guidelines to prompts
|
||||
- Tracking token usage and costs
|
||||
- Sanitizing or transforming responses
|
||||
- Implementing approval gates for LLM calls
|
||||
- Debugging prompt/response interactions
|
||||
|
||||
### Use Tool Hooks When:
|
||||
- Blocking dangerous or destructive operations
|
||||
- Validating tool inputs before execution
|
||||
- Implementing approval gates for sensitive actions
|
||||
- Caching tool results
|
||||
- Tracking tool usage and performance
|
||||
- Sanitizing tool outputs
|
||||
- Rate limiting tool calls
|
||||
|
||||
### Use Both When:
|
||||
Building comprehensive observability, safety, or approval systems that need to monitor all agent operations.
|
||||
|
||||
## Alternative Registration Methods
|
||||
|
||||
### Programmatic Registration (Advanced)
|
||||
|
||||
For dynamic hook registration or when you need to register hooks programmatically:
|
||||
|
||||
```python
|
||||
from crewai.hooks import (
|
||||
register_before_llm_call_hook,
|
||||
register_after_tool_call_hook
|
||||
)
|
||||
|
||||
def my_hook(context):
|
||||
return None
|
||||
|
||||
# Register programmatically
|
||||
register_before_llm_call_hook(my_hook)
|
||||
|
||||
# Useful for:
|
||||
# - Loading hooks from configuration
|
||||
# - Conditional hook registration
|
||||
# - Plugin systems
|
||||
```
|
||||
|
||||
**Note:** For most use cases, decorators are cleaner and more maintainable.
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
1. **Keep Hooks Fast**: Hooks execute on every call - avoid heavy computation
|
||||
2. **Cache When Possible**: Store expensive validations or lookups
|
||||
3. **Be Selective**: Use crew-scoped hooks when global hooks aren't needed
|
||||
4. **Monitor Hook Overhead**: Profile hook execution time in production
|
||||
5. **Lazy Import**: Import heavy dependencies only when needed
|
||||
|
||||
## Debugging Hooks
|
||||
|
||||
### Enable Debug Logging
|
||||
|
||||
```python
|
||||
import logging
|
||||
|
||||
logging.basicConfig(level=logging.DEBUG)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@before_llm_call
|
||||
def debug_hook(context):
|
||||
logger.debug(f"LLM call: {context.agent.role}, iteration {context.iterations}")
|
||||
return None
|
||||
```
|
||||
|
||||
### Hook Execution Order
|
||||
|
||||
Hooks execute in registration order. If a before hook returns `False`, subsequent hooks don't execute:
|
||||
|
||||
```python
|
||||
# Register order matters!
|
||||
register_before_tool_call_hook(hook1) # Executes first
|
||||
register_before_tool_call_hook(hook2) # Executes second
|
||||
register_before_tool_call_hook(hook3) # Executes third
|
||||
|
||||
# If hook2 returns False:
|
||||
# - hook1 executed
|
||||
# - hook2 executed and returned False
|
||||
# - hook3 NOT executed
|
||||
# - Tool call blocked
|
||||
```
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [LLM Call Hooks →](/learn/llm-hooks) - Detailed LLM hook documentation
|
||||
- [Tool Call Hooks →](/learn/tool-hooks) - Detailed tool hook documentation
|
||||
- [Before and After Kickoff Hooks →](/learn/before-and-after-kickoff-hooks) - Crew lifecycle hooks
|
||||
- [Human-in-the-Loop →](/learn/human-in-the-loop) - Human input patterns
|
||||
|
||||
## Conclusion
|
||||
|
||||
Execution hooks provide powerful control over agent runtime behavior. Use them to implement safety guardrails, approval workflows, comprehensive monitoring, and custom business logic. Combined with proper error handling, type safety, and performance considerations, hooks enable production-ready, secure, and observable agent systems.
|
||||
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 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
|
||||
`return False` semantics, or when you want the point-specific typed signatures.
|
||||
For the detailed guides — context attributes, patterns, and management APIs
|
||||
(`register_*` / `unregister_*` / `clear_*`) — see:
|
||||
|
||||
- [LLM Call Hooks →](/edge/en/learn/llm-hooks)
|
||||
- [Tool Call Hooks →](/edge/en/learn/tool-hooks)
|
||||
|
||||
## Related documentation
|
||||
|
||||
- [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)
|
||||
|
||||
@@ -59,9 +59,9 @@ Use these expression forms correctly:
|
||||
- Raw CEL: use in `expr`. Do not wrap raw CEL in `${...}`.
|
||||
- Use `${...}` inside action mapping strings to read Flow data with CEL. Example value: `Ticket: ${state.ticket_id}`.
|
||||
- Use `state` for input data. Use `outputs.step_name` for a completed method result.
|
||||
- In action mapping strings, keep literal text outside `${...}` and interpolate each Flow value directly. Write `Ticket: ${state.ticket_id}`; do not assemble the string with CEL `+`.
|
||||
- If a value is only one `${...}` expression, the result keeps its type. Use this for numbers, booleans, objects, and lists.
|
||||
- If the string has other text, the final value is text. Non-text values become JSON. `null` becomes empty text.
|
||||
- Use `text(root, "path", "default")` for values that may be missing or null. The default is optional and is `""`.
|
||||
|
||||
Expression examples:
|
||||
|
||||
@@ -78,12 +78,6 @@ domains: "${state.domains}"
|
||||
limit: "${state.limit}"
|
||||
```
|
||||
|
||||
Use a default for missing text:
|
||||
|
||||
```yaml
|
||||
input: "Ticket ${text(state, \"ticket.id\", \"unknown\")}"
|
||||
```
|
||||
|
||||
- Crew text: use `{name}` placeholders from crew inputs. Example: `Research {topic}`.
|
||||
- Crew inputs become prompt text only when agent or task text references matching `{name}` placeholders.
|
||||
- Passing an input that is not referenced by any `{name}` placeholder does not ground the crew. If the crew needs a field, put that placeholder in an agent `goal`, task `description`, or task `expected_output`.
|
||||
@@ -111,6 +105,7 @@ Dynamic value rules:
|
||||
- Do not use fields outside the declaration schema.
|
||||
- Do not put more than one action under a method's `do`.
|
||||
- Do not make `do` a list.
|
||||
- Do not use CEL `+` to build text in action mappings. Keep the text literal and insert each dynamic value with `${...}`.
|
||||
- Do not reference `outputs.some_method` before `some_method` can run.
|
||||
- Do not set a method's `listen` to its own method name.
|
||||
- Do not use the same string for an emitted event and a method name unless the user asks for it.
|
||||
@@ -246,7 +241,7 @@ Shape:
|
||||
Fields:
|
||||
- `call` (required): must be `crew`. Action discriminator. Use crew to run an inline Crew definition. Example: `crew`
|
||||
- `with` (required): inline crew definition. Inline Crew definition to load and execute for this action. Example: `{"agents": {"researcher": {"backstory": "Knows the domain.", "goal": "Research {topic}", "role": "Researcher"}}, "name": "inline_research", "tasks": [{"agent": "researcher", "description": "Research {topic}", "expected_output": "Findings about {topic}", "name": "research_task"}]}`
|
||||
- `inputs` (optional): map of string to expression data | null; default `null`. Actual kickoff inputs passed to the Crew. Use `${...}` inside action mapping strings to read Flow data with CEL. Example value: `Ticket: ${state.ticket_id}`. Use `state` for input data. Use `outputs.step_name` for a completed method result. If a value is only one `${...}` expression, the result keeps its type. Use this for numbers, booleans, objects, and lists. If the string has other text, the final value is text. Non-text values become JSON. `null` becomes empty text. Use `text(root, "path", "default")` for values that may be missing or null. The default is optional and is `""`. The evaluated values are available to crew agent and task interpolation as `{name}` placeholders; reference each input the crew needs in agent or task text. Example: `{"topic": "${state.topic}"}`
|
||||
- `inputs` (optional): map of string to expression data | null; default `null`. Runtime inputs passed to the Crew. Insert Flow values with `${...}` and reference each input as `{name}` in agent or task text. Example: `{"topic": "${state.topic}"}`
|
||||
|
||||
#### Crew Definition (`methods.<name>.do[call=crew].with`)
|
||||
|
||||
@@ -311,7 +306,7 @@ Fields:
|
||||
- `tools` (optional): list[string | map of string to any] | null; default `null`. Tool refs or serialized tool definitions available to this agent. String refs can use CrewAI tool names, `custom:<name>`, or fully qualified `module:Class` references. Example: `["crewai_tools:SerperDevTool", "custom:file_read"]`
|
||||
- `apps` (optional): list[string] | null; default `null`. Platform apps available to this agent. Can contain app names such as `gmail` or app/action refs such as `gmail/send_email`. Example: `["gmail", "slack/send_message"]`
|
||||
- `mcps` (optional): list[string | map of string to any] | null; default `null`. MCP server refs or serialized MCP server configs available to this agent. String refs can use HTTPS URLs, connected MCP integration slugs, or refs with a `#tool_name` suffix for specific tools. Example: `["https://api.weather.com/mcp#get_current_weather", "snowflake", "stripe#list_invoices", {"cache_tools_list": true, "headers": {"Authorization": "Bearer your_token"}, "streamable": true, "url": "https://api.example.com/mcp"}]`
|
||||
- `input` (required): string. Input passed to the individual agent kickoff outside of a crew. Use one string. Use `${...}` inside action mapping strings to read Flow data with CEL. Example value: `Ticket: ${state.ticket_id}`. Use `state` for input data. Use `outputs.step_name` for a completed method result. If a value is only one `${...}` expression, the result keeps its type. Use this for numbers, booleans, objects, and lists. If the string has other text, the final value is text. Non-text values become JSON. `null` becomes empty text. Use `text(root, "path", "default")` for values that may be missing or null. The default is optional and is `""`. When an agent needs multiple fields, write one string with labels and separators, for example `Ticket ID: ${state.ticket_id}; Message: ${state.message}`. Example: `${state.ticket.body}`
|
||||
- `input` (required): string. Agent prompt template. Insert Flow values with `${...}`, for example `Ticket: ${state.ticket_id}`. Example: `${state.ticket.body}`
|
||||
|
||||
#### LLM Definition
|
||||
|
||||
@@ -349,4 +344,3 @@ Fields:
|
||||
- Crew action-level `inputs` are the Crew kickoff inputs; use CEL-wrapped strings there for runtime values.
|
||||
- Crew agent/task interpolation uses `{name}` placeholders from evaluated crew inputs.
|
||||
- Agent `with.input` must be text. Use `${outputs.method_name.raw}` or a text field like `${outputs.method_name.json_dict.summary}`.
|
||||
|
||||
|
||||
@@ -29,7 +29,9 @@ def test_create_flow_declarative_project_can_run(
|
||||
agents_md = (project_root / "AGENTS.md").read_text(encoding="utf-8")
|
||||
assert "CrewAI Flow declaration" in agents_md
|
||||
assert "schema: crewai.flow/v1" in agents_md
|
||||
assert 'text(root, "path", "default")' in agents_md
|
||||
assert "do not assemble the string with CEL `+`" in agents_md
|
||||
assert "Agent prompt template. Insert Flow values with `${...}`" in agents_md
|
||||
assert "Runtime inputs passed to the Crew" in agents_md
|
||||
assert "call: expression" in agents_md
|
||||
assert "call: tool" not in agents_md
|
||||
assert "call: script" not in agents_md
|
||||
|
||||
@@ -46,8 +46,8 @@ from crewai.hooks.llm_hooks import (
|
||||
)
|
||||
from crewai.hooks.tool_hooks import (
|
||||
ToolCallHookContext,
|
||||
get_after_tool_call_hooks,
|
||||
get_before_tool_call_hooks,
|
||||
run_after_tool_call_hooks,
|
||||
run_before_tool_call_hooks,
|
||||
)
|
||||
from crewai.types.callback import SerializableCallable
|
||||
from crewai.utilities.agent_utils import (
|
||||
@@ -951,7 +951,6 @@ class CrewAgentExecutor(BaseAgentExecutor):
|
||||
|
||||
track_delegation_if_needed(func_name, args_dict or {}, self.task)
|
||||
|
||||
hook_blocked = False
|
||||
before_hook_context = ToolCallHookContext(
|
||||
tool_name=func_name,
|
||||
tool_input=args_dict or {},
|
||||
@@ -960,19 +959,7 @@ class CrewAgentExecutor(BaseAgentExecutor):
|
||||
task=self.task,
|
||||
crew=self.crew,
|
||||
)
|
||||
before_hooks = get_before_tool_call_hooks()
|
||||
try:
|
||||
for hook in before_hooks:
|
||||
hook_result = hook(before_hook_context)
|
||||
if hook_result is False:
|
||||
hook_blocked = True
|
||||
break
|
||||
except Exception as hook_error:
|
||||
if self.agent.verbose:
|
||||
PRINTER.print(
|
||||
content=f"Error in before_tool_call hook: {hook_error}",
|
||||
color="red",
|
||||
)
|
||||
hook_blocked = run_before_tool_call_hooks(before_hook_context)
|
||||
|
||||
if hook_blocked:
|
||||
result = f"Tool execution blocked by hook. Tool: {func_name}"
|
||||
@@ -1033,19 +1020,9 @@ class CrewAgentExecutor(BaseAgentExecutor):
|
||||
tool_result=result,
|
||||
raw_tool_result=raw_tool_result,
|
||||
)
|
||||
after_hooks = get_after_tool_call_hooks()
|
||||
try:
|
||||
for after_hook in after_hooks:
|
||||
after_hook_result = after_hook(after_hook_context)
|
||||
if after_hook_result is not None:
|
||||
result = after_hook_result
|
||||
after_hook_context.tool_result = result
|
||||
except Exception as hook_error:
|
||||
if self.agent.verbose:
|
||||
PRINTER.print(
|
||||
content=f"Error in after_tool_call hook: {hook_error}",
|
||||
color="red",
|
||||
)
|
||||
modified_result = run_after_tool_call_hooks(after_hook_context)
|
||||
if modified_result is not None:
|
||||
result = modified_result
|
||||
|
||||
if not error_event_emitted:
|
||||
crewai_event_bus.emit(
|
||||
|
||||
@@ -1906,6 +1906,28 @@ class Crew(FlowTrackable, BaseModel):
|
||||
final_string_output = final_task_output.raw
|
||||
self._finish_execution(final_string_output)
|
||||
self.token_usage = self.calculate_usage_metrics()
|
||||
|
||||
from crewai.hooks.contexts import ExecutionEndContext, OutputContext
|
||||
from crewai.hooks.dispatch import InterceptionPoint, dispatch
|
||||
|
||||
crew_output = CrewOutput(
|
||||
raw=final_task_output.raw,
|
||||
pydantic=final_task_output.pydantic,
|
||||
json_dict=final_task_output.json_dict,
|
||||
tasks_output=task_outputs,
|
||||
token_usage=self.token_usage,
|
||||
)
|
||||
|
||||
output_ctx = OutputContext(crew=self, output=crew_output, payload=crew_output)
|
||||
dispatch(InterceptionPoint.OUTPUT, output_ctx)
|
||||
crew_output = cast(CrewOutput, output_ctx.payload)
|
||||
|
||||
end_ctx = ExecutionEndContext(
|
||||
crew=self, output=crew_output, payload=crew_output
|
||||
)
|
||||
dispatch(InterceptionPoint.EXECUTION_END, end_ctx)
|
||||
crew_output = cast(CrewOutput, end_ctx.payload)
|
||||
|
||||
# Ensure background memory saves finish (and emit their
|
||||
# completed/failed events) before the kickoff-completed event below
|
||||
# triggers listener teardown/finalization.
|
||||
@@ -1924,13 +1946,7 @@ class Crew(FlowTrackable, BaseModel):
|
||||
# Finalization is handled by trace listener (always initialized)
|
||||
# The batch manager checks contextvar to determine if tracing is enabled
|
||||
|
||||
return CrewOutput(
|
||||
raw=final_task_output.raw,
|
||||
pydantic=final_task_output.pydantic,
|
||||
json_dict=final_task_output.json_dict,
|
||||
tasks_output=task_outputs,
|
||||
token_usage=self.token_usage,
|
||||
)
|
||||
return crew_output
|
||||
|
||||
def _process_async_tasks(
|
||||
self,
|
||||
|
||||
@@ -278,6 +278,9 @@ def prepare_kickoff(
|
||||
reset_emission_counter()
|
||||
reset_last_event_id()
|
||||
|
||||
from crewai.hooks.contexts import ExecutionStartContext, InputContext
|
||||
from crewai.hooks.dispatch import InterceptionPoint, dispatch
|
||||
|
||||
normalized: dict[str, Any] | None = None
|
||||
if inputs is not None:
|
||||
if not isinstance(inputs, Mapping):
|
||||
@@ -286,11 +289,30 @@ def prepare_kickoff(
|
||||
)
|
||||
normalized = dict(inputs)
|
||||
|
||||
# ``inputs`` aliases the same object as ``payload`` (not a fresh ``{}`` from
|
||||
# ``or``) so in-place edits to either survive read-back, per the context
|
||||
# contract. ``None`` inputs are preserved rather than coerced to ``{}``.
|
||||
start_ctx = ExecutionStartContext(
|
||||
crew=crew,
|
||||
inputs=normalized if normalized is not None else {},
|
||||
payload=normalized,
|
||||
)
|
||||
dispatch(InterceptionPoint.EXECUTION_START, start_ctx)
|
||||
normalized = start_ctx.payload
|
||||
|
||||
for before_callback in crew.before_kickoff_callbacks:
|
||||
if normalized is None:
|
||||
normalized = {}
|
||||
normalized = before_callback(normalized)
|
||||
|
||||
input_ctx = InputContext(
|
||||
crew=crew,
|
||||
inputs=normalized if normalized is not None else {},
|
||||
payload=normalized,
|
||||
)
|
||||
dispatch(InterceptionPoint.INPUT, input_ctx)
|
||||
normalized = input_ctx.payload
|
||||
|
||||
if resuming and crew._kickoff_event_id:
|
||||
if crew.verbose:
|
||||
from crewai.events.utils.console_formatter import ConsoleFormatter
|
||||
|
||||
19
lib/crewai/src/crewai/events/types/hook_events.py
Normal file
19
lib/crewai/src/crewai/events/types/hook_events.py
Normal file
@@ -0,0 +1,19 @@
|
||||
from typing import Literal
|
||||
|
||||
from crewai.events.base_events import BaseEvent
|
||||
|
||||
|
||||
class HookDispatchedEvent(BaseEvent):
|
||||
"""Event emitted whenever an interception point dispatches to hooks.
|
||||
|
||||
Only emitted when at least one hook is registered for the point, so the
|
||||
no-op fast path stays free of event overhead.
|
||||
"""
|
||||
|
||||
type: Literal["hook_dispatched"] = "hook_dispatched"
|
||||
interception_point: str
|
||||
outcome: Literal["proceeded", "modified", "aborted"]
|
||||
hook_count: int
|
||||
duration_ms: float
|
||||
abort_reason: str | None = None
|
||||
abort_source: str | None = None
|
||||
@@ -62,8 +62,8 @@ from crewai.hooks.llm_hooks import (
|
||||
)
|
||||
from crewai.hooks.tool_hooks import (
|
||||
ToolCallHookContext,
|
||||
get_after_tool_call_hooks,
|
||||
get_before_tool_call_hooks,
|
||||
run_after_tool_call_hooks,
|
||||
run_before_tool_call_hooks,
|
||||
)
|
||||
from crewai.hooks.types import (
|
||||
AfterLLMCallHookCallable,
|
||||
@@ -1975,7 +1975,6 @@ class AgentExecutor(Flow[AgentExecutorState], BaseAgentExecutor):
|
||||
|
||||
track_delegation_if_needed(func_name, args_dict, self.task)
|
||||
|
||||
hook_blocked = False
|
||||
before_hook_context = ToolCallHookContext(
|
||||
tool_name=func_name,
|
||||
tool_input=args_dict,
|
||||
@@ -1984,19 +1983,7 @@ class AgentExecutor(Flow[AgentExecutorState], BaseAgentExecutor):
|
||||
task=self.task,
|
||||
crew=self.crew,
|
||||
)
|
||||
before_hooks = get_before_tool_call_hooks()
|
||||
try:
|
||||
for hook in before_hooks:
|
||||
hook_result = hook(before_hook_context)
|
||||
if hook_result is False:
|
||||
hook_blocked = True
|
||||
break
|
||||
except Exception as hook_error:
|
||||
if self.agent.verbose:
|
||||
PRINTER.print(
|
||||
content=f"Error in before_tool_call hook: {hook_error}",
|
||||
color="red",
|
||||
)
|
||||
hook_blocked = run_before_tool_call_hooks(before_hook_context)
|
||||
|
||||
if hook_blocked:
|
||||
result = f"Tool execution blocked by hook. Tool: {func_name}"
|
||||
@@ -2060,19 +2047,9 @@ class AgentExecutor(Flow[AgentExecutorState], BaseAgentExecutor):
|
||||
tool_result=result,
|
||||
raw_tool_result=raw_tool_result,
|
||||
)
|
||||
after_hooks = get_after_tool_call_hooks()
|
||||
try:
|
||||
for after_hook in after_hooks:
|
||||
after_hook_result = after_hook(after_hook_context)
|
||||
if after_hook_result is not None:
|
||||
result = after_hook_result
|
||||
after_hook_context.tool_result = result
|
||||
except Exception as hook_error:
|
||||
if self.agent.verbose:
|
||||
PRINTER.print(
|
||||
content=f"Error in after_tool_call hook: {hook_error}",
|
||||
color="red",
|
||||
)
|
||||
modified_result = run_after_tool_call_hooks(after_hook_context)
|
||||
if modified_result is not None:
|
||||
result = modified_result
|
||||
|
||||
if not error_event_emitted:
|
||||
crewai_event_bus.emit(
|
||||
|
||||
@@ -11,9 +11,6 @@ from crewai.utilities.serialization import to_serializable
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from celpy.celtypes import StringType
|
||||
from celpy.evaluation import CELFunction
|
||||
|
||||
from crewai.flow.runtime import Flow
|
||||
else:
|
||||
from typing_extensions import TypeAliasType
|
||||
@@ -24,29 +21,6 @@ _CEL_MACROS_WITH_LOCAL_BINDINGS = frozenset(
|
||||
)
|
||||
|
||||
|
||||
def _handle_text_custom_expression(
|
||||
root: Any, path: Any, default: Any = ""
|
||||
) -> StringType:
|
||||
from celpy.celtypes import StringType
|
||||
|
||||
fallback = StringType("" if default is None else str(default))
|
||||
current = root
|
||||
for part in str(path).split("."):
|
||||
if current is None:
|
||||
return fallback
|
||||
try:
|
||||
if isinstance(current, list):
|
||||
current = current[int(part)]
|
||||
else:
|
||||
current = current[StringType(part)]
|
||||
except (KeyError, IndexError, TypeError, ValueError):
|
||||
return fallback
|
||||
|
||||
if current is None:
|
||||
return fallback
|
||||
return StringType(_stringify_cel_value(current))
|
||||
|
||||
|
||||
def _stringify_cel_value(value: Any) -> str:
|
||||
from celpy.adapter import CELJSONEncoder
|
||||
|
||||
@@ -98,21 +72,18 @@ def _parse_template_segments(value: str) -> tuple[str | _ExpressionSegment, ...]
|
||||
return tuple(segments)
|
||||
|
||||
|
||||
_EXPRESSION_FUNCTIONS: dict[str, CELFunction] = {
|
||||
"text": _handle_text_custom_expression,
|
||||
}
|
||||
|
||||
FLOW_TEMPLATE_EXPRESSION_RULES: tuple[str, ...] = (
|
||||
"Use `${...}` inside action mapping strings to read Flow data with CEL. "
|
||||
"Example value: `Ticket: ${state.ticket_id}`.",
|
||||
"Use `state` for input data. Use `outputs.step_name` for a completed "
|
||||
"method result.",
|
||||
"In action mapping strings, keep literal text outside `${...}` and "
|
||||
"interpolate each Flow value directly. Write `Ticket: ${state.ticket_id}`; "
|
||||
"do not assemble the string with CEL `+`.",
|
||||
"If a value is only one `${...}` expression, the result keeps its type. "
|
||||
"Use this for numbers, booleans, objects, and lists.",
|
||||
"If the string has other text, the final value is text. Non-text values "
|
||||
"become JSON. `null` becomes empty text.",
|
||||
'Use `text(root, "path", "default")` for values that may be missing '
|
||||
'or null. The default is optional and is `""`.',
|
||||
)
|
||||
FLOW_TEMPLATE_EXPRESSION_CONTRACT = " ".join(FLOW_TEMPLATE_EXPRESSION_RULES)
|
||||
FLOW_TEMPLATE_EXPRESSION_EXAMPLES: dict[str, tuple[dict[str, str], ...]] = {
|
||||
@@ -125,10 +96,6 @@ FLOW_TEMPLATE_EXPRESSION_EXAMPLES: dict[str, tuple[dict[str, str], ...]] = {
|
||||
"title": "Keep a list or number type",
|
||||
"code": 'domains: "${state.domains}"\nlimit: "${state.limit}"',
|
||||
},
|
||||
{
|
||||
"title": "Use a default for missing text",
|
||||
"code": 'input: "Ticket ${text(state, \\"ticket.id\\", \\"unknown\\")}"',
|
||||
},
|
||||
),
|
||||
"json": (
|
||||
{
|
||||
@@ -141,14 +108,6 @@ FLOW_TEMPLATE_EXPRESSION_EXAMPLES: dict[str, tuple[dict[str, str], ...]] = {
|
||||
'{\n "domains": "${state.domains}",\n "limit": "${state.limit}"\n}'
|
||||
),
|
||||
},
|
||||
{
|
||||
"title": "Use a default for missing text",
|
||||
"code": (
|
||||
"{\n"
|
||||
' "input": "Ticket ${text(state, \\"ticket.id\\", \\"unknown\\")}"\n'
|
||||
"}"
|
||||
),
|
||||
},
|
||||
),
|
||||
}
|
||||
|
||||
@@ -374,8 +333,7 @@ class Expression:
|
||||
|
||||
environment = Environment()
|
||||
program = environment.program(
|
||||
Expression._compile_cel(expression, environment=environment),
|
||||
functions=_EXPRESSION_FUNCTIONS,
|
||||
Expression._compile_cel(expression, environment=environment)
|
||||
)
|
||||
result = program.evaluate(cast(Context, json_to_cel(context)))
|
||||
return json.loads(json.dumps(result, cls=CELJSONEncoder))
|
||||
|
||||
@@ -1476,6 +1476,19 @@ class Flow(BaseModel, Generic[T], metaclass=FlowMeta):
|
||||
else (resumed_method_output if emit else result)
|
||||
)
|
||||
|
||||
from crewai.hooks.contexts import ExecutionEndContext, OutputContext
|
||||
from crewai.hooks.dispatch import InterceptionPoint, dispatch
|
||||
|
||||
output_ctx = OutputContext(flow=self, output=final_result, payload=final_result)
|
||||
dispatch(InterceptionPoint.OUTPUT, output_ctx)
|
||||
final_result = output_ctx.payload
|
||||
|
||||
end_ctx = ExecutionEndContext(
|
||||
flow=self, output=final_result, payload=final_result
|
||||
)
|
||||
dispatch(InterceptionPoint.EXECUTION_END, end_ctx)
|
||||
final_result = end_ctx.payload
|
||||
|
||||
if self._event_futures:
|
||||
await asyncio.gather(
|
||||
*[
|
||||
@@ -2037,6 +2050,9 @@ class Flow(BaseModel, Generic[T], metaclass=FlowMeta):
|
||||
flow_name_token = None
|
||||
flow_defer_trace_finalization_token = None
|
||||
request_id_token = None
|
||||
# Re-published after the INPUT hook so trigger-payload injection reads
|
||||
# the hook-rewritten inputs rather than the pre-hook baggage above.
|
||||
flow_inputs_token = None
|
||||
if current_flow_id.get() is None:
|
||||
flow_id_token = current_flow_id.set(self.flow_id)
|
||||
flow_name_token = current_flow_name.set(
|
||||
@@ -2062,6 +2078,37 @@ class Flow(BaseModel, Generic[T], metaclass=FlowMeta):
|
||||
self._attach_usage_aggregation_listener()
|
||||
|
||||
try:
|
||||
from crewai.hooks.contexts import (
|
||||
ExecutionEndContext,
|
||||
ExecutionStartContext,
|
||||
InputContext,
|
||||
OutputContext,
|
||||
)
|
||||
from crewai.hooks.dispatch import InterceptionPoint, dispatch
|
||||
|
||||
# ``inputs`` aliases the same object as ``payload`` (not a fresh
|
||||
# ``{}`` from ``or``) so in-place edits survive read-back.
|
||||
start_ctx = ExecutionStartContext(
|
||||
flow=self,
|
||||
inputs=inputs if inputs is not None else {},
|
||||
payload=inputs,
|
||||
)
|
||||
dispatch(InterceptionPoint.EXECUTION_START, start_ctx)
|
||||
inputs = start_ctx.payload
|
||||
|
||||
input_ctx = InputContext(
|
||||
flow=self,
|
||||
inputs=inputs if inputs is not None else {},
|
||||
payload=inputs,
|
||||
)
|
||||
dispatch(InterceptionPoint.INPUT, input_ctx)
|
||||
inputs = input_ctx.payload
|
||||
|
||||
# Publish the resolved inputs so trigger-payload injection and other
|
||||
# baggage readers observe hook rewrites (the baggage set before the
|
||||
# hooks carried the pre-hook inputs).
|
||||
flow_inputs_token = attach(baggage.set_baggage("flow_inputs", inputs or {}))
|
||||
|
||||
# Reset flow state for fresh execution unless restoring from persistence
|
||||
is_restoring = (
|
||||
inputs and "id" in inputs and self.persistence is not None
|
||||
@@ -2297,6 +2344,21 @@ class Flow(BaseModel, Generic[T], metaclass=FlowMeta):
|
||||
method_outputs = self.method_outputs
|
||||
final_output = method_outputs[-1] if method_outputs else None
|
||||
|
||||
output_ctx = OutputContext(
|
||||
flow=self, output=final_output, payload=final_output
|
||||
)
|
||||
dispatch(InterceptionPoint.OUTPUT, output_ctx)
|
||||
final_output = output_ctx.payload
|
||||
|
||||
# EXECUTION_END runs before FlowFinishedEvent so a HookAborted
|
||||
# prevents a spurious finished signal and payload replacement is
|
||||
# honored on the emitted result and the returned value.
|
||||
end_ctx = ExecutionEndContext(
|
||||
flow=self, output=final_output, payload=final_output
|
||||
)
|
||||
dispatch(InterceptionPoint.EXECUTION_END, end_ctx)
|
||||
final_output = end_ctx.payload
|
||||
|
||||
if self._event_futures:
|
||||
await asyncio.gather(
|
||||
*[asyncio.wrap_future(f) for f in self._event_futures]
|
||||
@@ -2370,6 +2432,8 @@ class Flow(BaseModel, Generic[T], metaclass=FlowMeta):
|
||||
current_flow_name.reset(flow_name_token)
|
||||
if flow_id_token is not None:
|
||||
current_flow_id.reset(flow_id_token)
|
||||
if flow_inputs_token is not None:
|
||||
detach(flow_inputs_token)
|
||||
detach(flow_token)
|
||||
crewai_event_bus._exit_runtime_scope(runtime_scope)
|
||||
|
||||
@@ -2562,6 +2626,37 @@ class Flow(BaseModel, Generic[T], metaclass=FlowMeta):
|
||||
if future:
|
||||
self._event_futures.append(future)
|
||||
|
||||
from crewai.hooks.contexts import StepContext
|
||||
from crewai.hooks.dispatch import InterceptionPoint, dispatch
|
||||
|
||||
pre_step_ctx = StepContext(
|
||||
kind="flow_method",
|
||||
step_name=str(method_name),
|
||||
flow=self,
|
||||
payload=dumped_params,
|
||||
)
|
||||
dispatch(InterceptionPoint.PRE_STEP, pre_step_ctx)
|
||||
|
||||
# Apply hook edits/replacement of the step params back onto the
|
||||
# call. ``dumped_params`` maps positional args to ``_0, _1, ...``
|
||||
# keys and keeps kwargs by name, so reverse that mapping here.
|
||||
updated_params = pre_step_ctx.payload
|
||||
if isinstance(updated_params, dict):
|
||||
positional = sorted(
|
||||
(
|
||||
k
|
||||
for k in updated_params
|
||||
if k.startswith("_") and k[1:].isdigit()
|
||||
),
|
||||
key=lambda k: int(k[1:]),
|
||||
)
|
||||
args = tuple(updated_params[k] for k in positional)
|
||||
kwargs = {
|
||||
k: v
|
||||
for k, v in updated_params.items()
|
||||
if not (k.startswith("_") and k[1:].isdigit())
|
||||
}
|
||||
|
||||
# Set method name in context so ask() can read it without
|
||||
# stack inspection. Must happen before copy_context() so the
|
||||
# value propagates into the thread pool for sync methods.
|
||||
@@ -2589,6 +2684,16 @@ class Flow(BaseModel, Generic[T], metaclass=FlowMeta):
|
||||
method_name, method_definition.human_feedback, result
|
||||
)
|
||||
|
||||
post_step_ctx = StepContext(
|
||||
kind="flow_method",
|
||||
step_name=str(method_name),
|
||||
flow=self,
|
||||
output=result,
|
||||
payload=result,
|
||||
)
|
||||
dispatch(InterceptionPoint.POST_STEP, post_step_ctx)
|
||||
result = post_step_ctx.payload
|
||||
|
||||
self._method_outputs.append({"method": str(method_name), "output": result})
|
||||
|
||||
# For @human_feedback methods with emit, the result is the collapsed outcome
|
||||
|
||||
@@ -11,7 +11,6 @@ from jinja2 import Environment, FileSystemLoader
|
||||
import yaml
|
||||
|
||||
from crewai.flow.expressions import (
|
||||
FLOW_TEMPLATE_EXPRESSION_CONTRACT,
|
||||
FLOW_TEMPLATE_EXPRESSION_EXAMPLES,
|
||||
FLOW_TEMPLATE_EXPRESSION_RULES,
|
||||
)
|
||||
@@ -186,7 +185,14 @@ MODEL_SPECS: tuple[ModelSpec, ...] = (
|
||||
hidden=True,
|
||||
),
|
||||
ModelSpec("FlowScriptActionDefinition", "Action", "methods.<name>.do[call=script]"),
|
||||
ModelSpec("FlowToolActionDefinition", "Action", "methods.<name>.do[call=tool]"),
|
||||
ModelSpec(
|
||||
"FlowToolActionDefinition",
|
||||
"Action",
|
||||
"methods.<name>.do[call=tool]",
|
||||
descriptions={
|
||||
"with": "Tool input arguments. Insert Flow values with `${...}`.",
|
||||
},
|
||||
),
|
||||
ModelSpec(
|
||||
"FlowCrewActionDefinition",
|
||||
"Action",
|
||||
@@ -194,7 +200,7 @@ MODEL_SPECS: tuple[ModelSpec, ...] = (
|
||||
examples=True,
|
||||
descriptions={
|
||||
"call": "Action discriminator. Use crew to run an inline Crew definition.",
|
||||
"inputs": f"Actual kickoff inputs passed to the Crew. {FLOW_TEMPLATE_EXPRESSION_CONTRACT} The evaluated values are available to crew agent and task interpolation as `{{name}}` placeholders; reference each input the crew needs in agent or task text.",
|
||||
"inputs": "Runtime inputs passed to the Crew. Insert Flow values with `${...}` and reference each input as `{name}` in agent or task text.",
|
||||
},
|
||||
),
|
||||
ModelSpec(
|
||||
@@ -262,7 +268,7 @@ MODEL_SPECS: tuple[ModelSpec, ...] = (
|
||||
hidden=True,
|
||||
examples=True,
|
||||
descriptions={
|
||||
"input": f"Input passed to the individual agent kickoff outside of a crew. Use one string. {FLOW_TEMPLATE_EXPRESSION_CONTRACT} When an agent needs multiple fields, write one string with labels and separators, for example `Ticket ID: ${{state.ticket_id}}; Message: ${{state.message}}`.",
|
||||
"input": "Agent prompt template. Insert Flow values with `${...}`, for example `Ticket: ${state.ticket_id}`.",
|
||||
"llm": "Language model that runs this agent. Use an object when setting LLM options such as `max_tokens`.",
|
||||
"planning_config": "Agent planning configuration. Set `max_attempts` to limit planning refinement attempts before task execution.",
|
||||
},
|
||||
|
||||
@@ -46,6 +46,7 @@ Pick the simplest action that does the job.
|
||||
- Use `call: tool` for packaged deterministic work: API calls, searches, lookups, scoring, file work, or custom CrewAI tools.
|
||||
{% endif %}
|
||||
- Use `call: agent` for one AI worker that classifies, decides, summarizes, writes, or drafts. Put `role`, `goal`, `backstory`, and `input` under `with`. Do not add an action-level `inputs` map to an agent.
|
||||
- Repository-backed agents may set `from_repository` and omit inline `role`, `goal`, and `backstory`. Explicitly provided fields override repository values.
|
||||
- Use `call: crew` for coordinated AI work with multiple agents or tasks. Define the crew under `with`. Pass runtime values with the action-level `inputs` map.
|
||||
{% if include_each_action %}
|
||||
- Use `call: each` when the same ordered mini-pipeline must run once per item. Give every step a `name`.
|
||||
@@ -137,6 +138,7 @@ Dynamic value rules:
|
||||
- Do not use fields outside the declaration schema{% if include_tool_action %} or tool refs shown here{% endif %}.
|
||||
- Do not put more than one action under a method's `do`.
|
||||
- Do not make `do` a list.
|
||||
- Do not use CEL `+` to build text in action mappings. Keep the text literal and insert each dynamic value with `${...}`.
|
||||
- Do not reference `outputs.some_method` before `some_method` can run.
|
||||
- Do not set a method's `listen` to its own method name.
|
||||
- Do not use the same string for an emitted event and a method name unless the user asks for it.
|
||||
|
||||
@@ -6,6 +6,17 @@ from crewai.hooks.decorators import (
|
||||
before_llm_call,
|
||||
before_tool_call,
|
||||
)
|
||||
from crewai.hooks.dispatch import (
|
||||
HookAborted,
|
||||
InterceptionPoint,
|
||||
clear as clear_hooks,
|
||||
clear_all as clear_all_hooks,
|
||||
dispatch,
|
||||
get_hooks,
|
||||
on,
|
||||
register as register_hook,
|
||||
unregister as unregister_hook,
|
||||
)
|
||||
from crewai.hooks.llm_hooks import (
|
||||
LLMCallHookContext,
|
||||
clear_after_llm_call_hooks,
|
||||
@@ -74,6 +85,8 @@ def clear_all_global_hooks() -> dict[str, tuple[int, int]]:
|
||||
|
||||
|
||||
__all__ = [
|
||||
"HookAborted",
|
||||
"InterceptionPoint",
|
||||
"LLMCallHookContext",
|
||||
"ToolCallHookContext",
|
||||
"after_llm_call",
|
||||
@@ -83,20 +96,27 @@ __all__ = [
|
||||
"clear_after_llm_call_hooks",
|
||||
"clear_after_tool_call_hooks",
|
||||
"clear_all_global_hooks",
|
||||
"clear_all_hooks",
|
||||
"clear_all_llm_call_hooks",
|
||||
"clear_all_tool_call_hooks",
|
||||
"clear_before_llm_call_hooks",
|
||||
"clear_before_tool_call_hooks",
|
||||
"clear_hooks",
|
||||
"dispatch",
|
||||
"get_after_llm_call_hooks",
|
||||
"get_after_tool_call_hooks",
|
||||
"get_before_llm_call_hooks",
|
||||
"get_before_tool_call_hooks",
|
||||
"get_hooks",
|
||||
"on",
|
||||
"register_after_llm_call_hook",
|
||||
"register_after_tool_call_hook",
|
||||
"register_before_llm_call_hook",
|
||||
"register_before_tool_call_hook",
|
||||
"register_hook",
|
||||
"unregister_after_llm_call_hook",
|
||||
"unregister_after_tool_call_hook",
|
||||
"unregister_before_llm_call_hook",
|
||||
"unregister_before_tool_call_hook",
|
||||
"unregister_hook",
|
||||
]
|
||||
|
||||
71
lib/crewai/src/crewai/hooks/contexts.py
Normal file
71
lib/crewai/src/crewai/hooks/contexts.py
Normal file
@@ -0,0 +1,71 @@
|
||||
"""Typed contexts for the interception points wired in phases 2-5.
|
||||
|
||||
Each context is a dataclass whose fields are nullable and defaulted, so a field
|
||||
that is not meaningful for a given runtime (e.g. ``agent_role`` inside a flow)
|
||||
is simply ``None`` rather than an error. Every context exposes a ``payload``
|
||||
field: the interceptable value a hook may mutate in place or replace by
|
||||
returning a new value.
|
||||
|
||||
The legacy ``pre/post_model_call`` and ``pre/post_tool_call`` points keep using
|
||||
:class:`~crewai.hooks.llm_hooks.LLMCallHookContext` and
|
||||
:class:`~crewai.hooks.tool_hooks.ToolCallHookContext` for backwards
|
||||
compatibility; they are intentionally not redefined here.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass
|
||||
class InterceptionContext:
|
||||
"""Base context shared by the framework-native interception points."""
|
||||
|
||||
payload: Any = None
|
||||
agent: Any = None
|
||||
agent_role: str | None = None
|
||||
task: Any = None
|
||||
crew: Any = None
|
||||
flow: Any = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class ExecutionStartContext(InterceptionContext):
|
||||
"""``execution_start``: a crew or flow is about to begin. ``payload`` = inputs."""
|
||||
|
||||
inputs: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
class InputContext(InterceptionContext):
|
||||
"""``input``: resolved inputs for an execution. ``payload`` = inputs."""
|
||||
|
||||
inputs: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
class OutputContext(InterceptionContext):
|
||||
"""``output``: final result of a crew or flow. ``payload`` = the output object."""
|
||||
|
||||
output: Any = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class ExecutionEndContext(InterceptionContext):
|
||||
"""``execution_end``: a crew or flow has finished. ``payload`` = the output object."""
|
||||
|
||||
output: Any = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class StepContext(InterceptionContext):
|
||||
"""``pre_step`` / ``post_step``: a task or flow-method step boundary.
|
||||
|
||||
``kind`` is ``"task"`` for crew tasks and ``"flow_method"`` for flow methods.
|
||||
``payload`` is the step input (pre) or step output (post).
|
||||
"""
|
||||
|
||||
kind: str | None = None
|
||||
step_name: str | None = None
|
||||
output: Any = None
|
||||
432
lib/crewai/src/crewai/hooks/dispatch.py
Normal file
432
lib/crewai/src/crewai/hooks/dispatch.py
Normal file
@@ -0,0 +1,432 @@
|
||||
"""Generic interception-hook dispatcher.
|
||||
|
||||
This module is the single engine behind every CrewAI interception point. A hook
|
||||
receives a typed context, may mutate it in place and/or return a replacement
|
||||
payload, and may raise :class:`HookAborted` to stop the intercepted operation
|
||||
with a reason and source.
|
||||
|
||||
The four public hook families (``before/after_llm_call`` and
|
||||
``before/after_tool_call``) are adapters registered on this dispatcher, so the
|
||||
legacy dialect (``register_*``/decorators/``return False``) and the new dialect
|
||||
(``@on(point)`` / ``HookAborted``) share one ordered queue per point.
|
||||
|
||||
Design notes:
|
||||
- Global registration order is preserved; execution-scoped hooks (via
|
||||
``contextvars``) run after global ones, mirroring
|
||||
``events/event_bus.py``'s ``_runtime_state_var`` scoping pattern.
|
||||
- ``dispatch`` has a no-op fast path (a single dict lookup) when no hooks are
|
||||
registered for a point.
|
||||
- Hooks are synchronous. They may be invoked from async seams, so they must not
|
||||
block on heavy I/O (same restriction as the legacy hooks).
|
||||
- ``HookAborted`` propagates by design. Any other exception raised by a hook is
|
||||
swallowed (fail-open) to preserve the framework's protection against a buggy
|
||||
user hook.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable, Iterator
|
||||
from contextlib import contextmanager
|
||||
import contextvars
|
||||
from enum import Enum
|
||||
from functools import wraps
|
||||
import inspect
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from crewai.utilities.string_utils import sanitize_tool_name
|
||||
|
||||
|
||||
class InterceptionPoint(str, Enum):
|
||||
"""Interception points wired by this layer.
|
||||
|
||||
New points are introduced alongside the seams that dispatch them, so the
|
||||
enum only ever lists points with a live consumer.
|
||||
"""
|
||||
|
||||
# Execution-level boundaries
|
||||
EXECUTION_START = "execution_start"
|
||||
INPUT = "input"
|
||||
OUTPUT = "output"
|
||||
EXECUTION_END = "execution_end"
|
||||
|
||||
# Model / tool boundaries (legacy-compatible)
|
||||
PRE_MODEL_CALL = "pre_model_call"
|
||||
POST_MODEL_CALL = "post_model_call"
|
||||
PRE_TOOL_CALL = "pre_tool_call"
|
||||
POST_TOOL_CALL = "post_tool_call"
|
||||
|
||||
# Step points
|
||||
PRE_STEP = "pre_step"
|
||||
POST_STEP = "post_step"
|
||||
|
||||
|
||||
class HookAborted(Exception): # noqa: N818 - public contract name from OSS-86
|
||||
"""Raised by a hook (or a legacy adapter) to abort the intercepted operation.
|
||||
|
||||
Args:
|
||||
reason: Human-readable explanation of why the operation was aborted.
|
||||
source: Optional identifier of the aborting hook (callable, string, or
|
||||
any object). Used for telemetry and failure messages.
|
||||
"""
|
||||
|
||||
def __init__(self, reason: str, source: Any = None) -> None:
|
||||
super().__init__(reason)
|
||||
self.reason = reason
|
||||
self.source = source
|
||||
|
||||
|
||||
HookFn = Callable[[Any], Any]
|
||||
|
||||
# (ctx, result) -> modified? A reducer maps a hook's return value onto the
|
||||
# context using point-specific semantics. It may raise HookAborted.
|
||||
Reducer = Callable[[Any, Any], bool]
|
||||
|
||||
|
||||
_global_hooks: dict[InterceptionPoint, list[HookFn]] = {
|
||||
point: [] for point in InterceptionPoint
|
||||
}
|
||||
|
||||
_scoped_hooks_var: contextvars.ContextVar[
|
||||
dict[InterceptionPoint, list[HookFn]] | None
|
||||
] = contextvars.ContextVar("crewai_scoped_hooks", default=None)
|
||||
|
||||
|
||||
_TELEMETRY_SOURCE = object()
|
||||
|
||||
|
||||
def get_global_hook_list(point: InterceptionPoint) -> list[HookFn]:
|
||||
"""Return the live global hook list for a point.
|
||||
|
||||
The returned list object is stable for the lifetime of the process, which
|
||||
lets legacy modules alias their module-level registries to it. Mutate it in
|
||||
place (append/remove/clear); never rebind it.
|
||||
"""
|
||||
return _global_hooks[point]
|
||||
|
||||
|
||||
def register(point: InterceptionPoint, hook: HookFn) -> None:
|
||||
"""Register a global hook for an interception point."""
|
||||
_global_hooks[point].append(hook)
|
||||
|
||||
|
||||
def unregister(point: InterceptionPoint, hook: HookFn) -> bool:
|
||||
"""Unregister a specific global hook. Returns True if it was removed.
|
||||
|
||||
When ``hook`` was registered through :func:`on` with ``agents``/``tools``
|
||||
filters, the stored callable is a wrapper rather than ``hook`` itself. The
|
||||
wrapper is stashed on ``hook._registered_hook`` at registration time, so it
|
||||
can be resolved and removed here.
|
||||
"""
|
||||
hooks = _global_hooks[point]
|
||||
target = hook if hook in hooks else getattr(hook, "_registered_hook", hook)
|
||||
try:
|
||||
hooks.remove(target)
|
||||
return True
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
def get_hooks(point: InterceptionPoint) -> list[HookFn]:
|
||||
"""Return a copy of the global hooks registered for a point."""
|
||||
return _global_hooks[point].copy()
|
||||
|
||||
|
||||
def clear(point: InterceptionPoint) -> int:
|
||||
"""Clear all global hooks for a point. Returns the number cleared."""
|
||||
count = len(_global_hooks[point])
|
||||
_global_hooks[point].clear()
|
||||
return count
|
||||
|
||||
|
||||
def clear_all() -> None:
|
||||
"""Clear all global hooks across every interception point."""
|
||||
for hooks in _global_hooks.values():
|
||||
hooks.clear()
|
||||
|
||||
|
||||
@contextmanager
|
||||
def scoped_hooks(
|
||||
hooks: dict[InterceptionPoint, list[HookFn]] | None = None,
|
||||
) -> Iterator[dict[InterceptionPoint, list[HookFn]]]:
|
||||
"""Enter an execution-scoped hook registry.
|
||||
|
||||
Hooks registered inside this context (via :func:`register_scoped`) run after
|
||||
global hooks and are discarded when the context exits. Mirrors the event
|
||||
bus's scoped-handler pattern.
|
||||
"""
|
||||
scope: dict[InterceptionPoint, list[HookFn]] = hooks if hooks is not None else {}
|
||||
token = _scoped_hooks_var.set(scope)
|
||||
try:
|
||||
yield scope
|
||||
finally:
|
||||
_scoped_hooks_var.reset(token)
|
||||
|
||||
|
||||
def register_scoped(point: InterceptionPoint, hook: HookFn) -> None:
|
||||
"""Register a hook scoped to the current :func:`scoped_hooks` context."""
|
||||
scope = _scoped_hooks_var.get()
|
||||
if scope is None:
|
||||
raise RuntimeError(
|
||||
"register_scoped() called outside of a scoped_hooks() context"
|
||||
)
|
||||
scope.setdefault(point, []).append(hook)
|
||||
|
||||
|
||||
def get_scoped_hooks(point: InterceptionPoint) -> list[HookFn]:
|
||||
"""Return the hooks registered in the current execution scope for a point.
|
||||
|
||||
Used by seams that carry a pre-snapshotted hook list (e.g. the agent
|
||||
executors' per-executor LLM hook lists) so they can merge in
|
||||
execution-scoped hooks with the same snapshot-then-scoped ordering that
|
||||
:func:`dispatch` applies to global vs scoped hooks.
|
||||
"""
|
||||
scope = _scoped_hooks_var.get()
|
||||
if not scope:
|
||||
return []
|
||||
return list(scope.get(point, []))
|
||||
|
||||
|
||||
def _resolve_hooks(point: InterceptionPoint) -> list[HookFn]:
|
||||
"""Resolve the ordered hooks for a point: global first, then scoped."""
|
||||
global_hooks = _global_hooks[point]
|
||||
scope = _scoped_hooks_var.get()
|
||||
if scope:
|
||||
scoped = scope.get(point)
|
||||
if scoped:
|
||||
return [*global_hooks, *scoped]
|
||||
return global_hooks
|
||||
|
||||
|
||||
def _source_name(source: Any) -> str | None:
|
||||
"""Best-effort readable name for a hook source."""
|
||||
if source is None:
|
||||
return None
|
||||
if isinstance(source, str):
|
||||
return source
|
||||
name = getattr(source, "__name__", None)
|
||||
if isinstance(name, str):
|
||||
return name
|
||||
return type(source).__name__
|
||||
|
||||
|
||||
def _emit_telemetry(
|
||||
point: InterceptionPoint,
|
||||
outcome: str,
|
||||
hook_count: int,
|
||||
duration_ms: float,
|
||||
abort_reason: str | None,
|
||||
abort_source: str | None,
|
||||
) -> None:
|
||||
"""Emit a HookDispatchedEvent. Never raises."""
|
||||
try:
|
||||
from crewai.events.event_bus import crewai_event_bus
|
||||
from crewai.events.types.hook_events import HookDispatchedEvent
|
||||
|
||||
crewai_event_bus.emit(
|
||||
_TELEMETRY_SOURCE,
|
||||
event=HookDispatchedEvent(
|
||||
interception_point=point.value,
|
||||
outcome=outcome,
|
||||
hook_count=hook_count,
|
||||
duration_ms=duration_ms,
|
||||
abort_reason=abort_reason,
|
||||
abort_source=abort_source,
|
||||
),
|
||||
)
|
||||
except Exception: # noqa: S110 - telemetry must never break dispatch
|
||||
pass
|
||||
|
||||
|
||||
def _default_reducer(ctx: Any, result: Any) -> bool:
|
||||
"""Default payload semantics: a non-None return replaces ``ctx.payload``.
|
||||
|
||||
Only reports a modification when the payload was actually applied, so a
|
||||
context without a ``payload`` attribute does not produce a misleading
|
||||
``"modified"`` telemetry outcome.
|
||||
"""
|
||||
if result is not None and hasattr(ctx, "payload"):
|
||||
ctx.payload = result
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _invoke_hook(
|
||||
point: InterceptionPoint,
|
||||
hook: HookFn,
|
||||
ctx: Any,
|
||||
reducer: Reducer,
|
||||
verbose: bool,
|
||||
) -> bool:
|
||||
"""Run a single hook and apply its result via the reducer.
|
||||
|
||||
Returns whether the context was modified. Raises :class:`HookAborted` (with
|
||||
``source`` populated) to abort; any other exception is swallowed (fail-open).
|
||||
"""
|
||||
try:
|
||||
result = hook(ctx)
|
||||
return reducer(ctx, result)
|
||||
except HookAborted as aborted:
|
||||
if aborted.source is None:
|
||||
aborted.source = hook
|
||||
raise
|
||||
except Exception as error:
|
||||
if verbose:
|
||||
from crewai_core.printer import PRINTER
|
||||
|
||||
PRINTER.print(
|
||||
content=f"Error in {point.value} hook: {error}",
|
||||
color="yellow",
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
def run_hooks(
|
||||
point: InterceptionPoint,
|
||||
ctx: Any,
|
||||
hooks: list[HookFn],
|
||||
*,
|
||||
reducer: Reducer | None = None,
|
||||
verbose: bool = True,
|
||||
) -> Any:
|
||||
"""Execute an explicit list of hooks against a context.
|
||||
|
||||
This is the shared engine used both by :func:`dispatch` (which resolves
|
||||
global + scoped hooks) and by seams that carry a pre-snapshotted hook list
|
||||
(e.g. per-executor LLM hook lists).
|
||||
|
||||
Args:
|
||||
point: The interception point being dispatched.
|
||||
ctx: The typed context passed to each hook (mutated in place).
|
||||
hooks: The ordered hooks to run.
|
||||
reducer: Maps each hook's return value onto ``ctx``. Defaults to
|
||||
:func:`_default_reducer` (payload replacement). May raise
|
||||
:class:`HookAborted`.
|
||||
verbose: Whether to print swallowed-hook-error warnings.
|
||||
|
||||
Returns:
|
||||
The (possibly mutated) context.
|
||||
|
||||
Raises:
|
||||
HookAborted: If a hook or the reducer aborts the operation. Telemetry is
|
||||
still emitted before propagating.
|
||||
"""
|
||||
if not hooks:
|
||||
return ctx
|
||||
|
||||
active_reducer = reducer if reducer is not None else _default_reducer
|
||||
start = time.perf_counter()
|
||||
outcome = "proceeded"
|
||||
abort_reason: str | None = None
|
||||
abort_source: str | None = None
|
||||
modified = False
|
||||
|
||||
try:
|
||||
for hook in list(hooks):
|
||||
if _invoke_hook(point, hook, ctx, active_reducer, verbose):
|
||||
modified = True
|
||||
outcome = "modified" if modified else "proceeded"
|
||||
return ctx
|
||||
except HookAborted as aborted:
|
||||
outcome = "aborted"
|
||||
abort_reason = aborted.reason
|
||||
abort_source = _source_name(aborted.source)
|
||||
raise
|
||||
finally:
|
||||
_emit_telemetry(
|
||||
point,
|
||||
outcome,
|
||||
len(hooks),
|
||||
(time.perf_counter() - start) * 1000.0,
|
||||
abort_reason,
|
||||
abort_source,
|
||||
)
|
||||
|
||||
|
||||
def dispatch(
|
||||
point: InterceptionPoint,
|
||||
ctx: Any,
|
||||
*,
|
||||
reducer: Reducer | None = None,
|
||||
verbose: bool = True,
|
||||
) -> Any:
|
||||
"""Dispatch a context to all hooks registered for a point.
|
||||
|
||||
Resolves global then scoped hooks and runs them through :func:`run_hooks`.
|
||||
No-op fast path when nothing is registered.
|
||||
"""
|
||||
hooks = _resolve_hooks(point)
|
||||
if not hooks:
|
||||
return ctx
|
||||
return run_hooks(point, ctx, hooks, reducer=reducer, verbose=verbose)
|
||||
|
||||
|
||||
def _wrap_with_filters(
|
||||
func: HookFn,
|
||||
agents: list[str] | None,
|
||||
tools: list[str] | None,
|
||||
) -> HookFn:
|
||||
"""Wrap a hook so it only runs for matching agents/tools (context-shape aware)."""
|
||||
|
||||
@wraps(func)
|
||||
def filtered(ctx: Any) -> Any:
|
||||
if tools:
|
||||
tool_name = getattr(ctx, "tool_name", None)
|
||||
if tool_name is not None and tool_name not in tools:
|
||||
return None
|
||||
if agents:
|
||||
agent = getattr(ctx, "agent", None)
|
||||
role = getattr(agent, "role", None) if agent is not None else None
|
||||
if role is None:
|
||||
role = getattr(ctx, "agent_role", None)
|
||||
if role is not None and role not in agents:
|
||||
return None
|
||||
return func(ctx)
|
||||
|
||||
return filtered
|
||||
|
||||
|
||||
def on(
|
||||
point: InterceptionPoint,
|
||||
*,
|
||||
agents: list[str] | None = None,
|
||||
tools: list[str] | None = None,
|
||||
) -> Callable[[HookFn], HookFn]:
|
||||
"""Register a function as a hook for an interception point.
|
||||
|
||||
Mirrors the legacy decorators' ergonomics: supports ``agents=`` / ``tools=``
|
||||
filters and, when applied to a method inside a ``@CrewBase`` class, defers
|
||||
registration to crew initialization (crew-scoped) instead of registering
|
||||
globally.
|
||||
|
||||
Example:
|
||||
>>> @on(InterceptionPoint.PRE_TOOL_CALL, tools=["delete_file"])
|
||||
... def guard(ctx):
|
||||
... raise HookAborted("deletion not allowed")
|
||||
"""
|
||||
normalized_tools = [sanitize_tool_name(t) for t in tools] if tools else None
|
||||
|
||||
def decorator(func: HookFn) -> HookFn:
|
||||
func._interception_point = point # type: ignore[attr-defined]
|
||||
if normalized_tools:
|
||||
func._filter_tools = normalized_tools # type: ignore[attr-defined]
|
||||
if agents:
|
||||
func._filter_agents = agents # type: ignore[attr-defined]
|
||||
|
||||
params = list(inspect.signature(func).parameters.keys())
|
||||
is_method = len(params) >= 2 and params[0] == "self"
|
||||
|
||||
if not is_method:
|
||||
hook = (
|
||||
_wrap_with_filters(func, agents, normalized_tools)
|
||||
if (agents or normalized_tools)
|
||||
else func
|
||||
)
|
||||
register(point, hook)
|
||||
# Remember the actually-registered callable so unregister_hook(func)
|
||||
# can resolve the filter wrapper.
|
||||
func._registered_hook = hook # type: ignore[attr-defined]
|
||||
|
||||
return func
|
||||
|
||||
return decorator
|
||||
@@ -5,6 +5,11 @@ from typing import TYPE_CHECKING, Any, cast
|
||||
from crewai_core.printer import PRINTER
|
||||
|
||||
from crewai.events.event_listener import event_listener
|
||||
from crewai.hooks.dispatch import (
|
||||
HookAborted,
|
||||
InterceptionPoint,
|
||||
get_global_hook_list,
|
||||
)
|
||||
from crewai.hooks.types import (
|
||||
AfterLLMCallHookCallable,
|
||||
AfterLLMCallHookType,
|
||||
@@ -150,8 +155,37 @@ class LLMCallHookContext:
|
||||
event_listener.formatter.resume_live_updates()
|
||||
|
||||
|
||||
_before_llm_call_hooks: list[BeforeLLMCallHookType | BeforeLLMCallHookCallable] = []
|
||||
_after_llm_call_hooks: list[AfterLLMCallHookType | AfterLLMCallHookCallable] = []
|
||||
# The legacy registries are aliased to the generic dispatcher's global hook
|
||||
# lists for the model-call points, so legacy registrations and new-dialect
|
||||
# ``@on(InterceptionPoint.PRE_MODEL_CALL)`` hooks share one ordered queue.
|
||||
_before_llm_call_hooks: list[BeforeLLMCallHookType | BeforeLLMCallHookCallable] = (
|
||||
get_global_hook_list(InterceptionPoint.PRE_MODEL_CALL)
|
||||
)
|
||||
_after_llm_call_hooks: list[AfterLLMCallHookType | AfterLLMCallHookCallable] = (
|
||||
get_global_hook_list(InterceptionPoint.POST_MODEL_CALL)
|
||||
)
|
||||
|
||||
|
||||
def before_llm_call_reducer(context: LLMCallHookContext, result: object) -> bool:
|
||||
"""Legacy calling convention for ``pre_model_call`` hooks.
|
||||
|
||||
A ``False`` return aborts the call (mapped to :class:`HookAborted`); messages
|
||||
are modified in place, so no payload replacement occurs here.
|
||||
"""
|
||||
if result is False:
|
||||
raise HookAborted(reason="before_llm_call hook returned False")
|
||||
return False
|
||||
|
||||
|
||||
def after_llm_call_reducer(context: LLMCallHookContext, result: object) -> bool:
|
||||
"""Legacy calling convention for ``post_model_call`` hooks.
|
||||
|
||||
A non-empty string return replaces the response on the context.
|
||||
"""
|
||||
if result is not None and isinstance(result, str):
|
||||
context.response = result
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def register_before_llm_call_hook(
|
||||
|
||||
@@ -5,6 +5,12 @@ from typing import TYPE_CHECKING, Any
|
||||
from crewai_core.printer import PRINTER
|
||||
|
||||
from crewai.events.event_listener import event_listener
|
||||
from crewai.hooks.dispatch import (
|
||||
HookAborted,
|
||||
InterceptionPoint,
|
||||
dispatch,
|
||||
get_global_hook_list,
|
||||
)
|
||||
from crewai.hooks.types import (
|
||||
AfterToolCallHookCallable,
|
||||
AfterToolCallHookType,
|
||||
@@ -121,8 +127,81 @@ class ToolCallHookContext:
|
||||
event_listener.formatter.resume_live_updates()
|
||||
|
||||
|
||||
_before_tool_call_hooks: list[BeforeToolCallHookType | BeforeToolCallHookCallable] = []
|
||||
_after_tool_call_hooks: list[AfterToolCallHookType | AfterToolCallHookCallable] = []
|
||||
# The legacy registries are aliased to the generic dispatcher's global hook
|
||||
# lists for the tool-call points, so legacy registrations and new-dialect
|
||||
# ``@on(InterceptionPoint.PRE_TOOL_CALL)`` hooks share one ordered queue.
|
||||
_before_tool_call_hooks: list[BeforeToolCallHookType | BeforeToolCallHookCallable] = (
|
||||
get_global_hook_list(InterceptionPoint.PRE_TOOL_CALL)
|
||||
)
|
||||
_after_tool_call_hooks: list[AfterToolCallHookType | AfterToolCallHookCallable] = (
|
||||
get_global_hook_list(InterceptionPoint.POST_TOOL_CALL)
|
||||
)
|
||||
|
||||
|
||||
def before_tool_call_reducer(context: ToolCallHookContext, result: object) -> bool:
|
||||
"""Legacy calling convention for ``pre_tool_call`` hooks.
|
||||
|
||||
A ``False`` return blocks the call (mapped to :class:`HookAborted`); tool
|
||||
input is modified in place, so no payload replacement occurs here.
|
||||
"""
|
||||
if result is False:
|
||||
raise HookAborted(reason="before_tool_call hook returned False")
|
||||
return False
|
||||
|
||||
|
||||
def after_tool_call_reducer(context: ToolCallHookContext, result: object) -> bool:
|
||||
"""Legacy calling convention for ``post_tool_call`` hooks.
|
||||
|
||||
A non-None return replaces the tool result on the context.
|
||||
"""
|
||||
if isinstance(result, str):
|
||||
context.tool_result = result
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _hook_verbose(context: ToolCallHookContext) -> bool:
|
||||
"""Whether swallowed-hook-error warnings should be printed.
|
||||
|
||||
Mirrors the pre-dispatcher behavior where a failing tool hook surfaced a
|
||||
warning when the executing agent was verbose.
|
||||
"""
|
||||
return bool(getattr(context.agent, "verbose", False))
|
||||
|
||||
|
||||
def run_before_tool_call_hooks(context: ToolCallHookContext) -> bool:
|
||||
"""Run all ``pre_tool_call`` hooks against a context.
|
||||
|
||||
Returns:
|
||||
True if a hook blocked execution (returned False or raised
|
||||
:class:`HookAborted`), False otherwise. Tool input mutations on the
|
||||
context persist regardless.
|
||||
"""
|
||||
try:
|
||||
dispatch(
|
||||
InterceptionPoint.PRE_TOOL_CALL,
|
||||
context,
|
||||
reducer=before_tool_call_reducer,
|
||||
verbose=_hook_verbose(context),
|
||||
)
|
||||
return False
|
||||
except HookAborted:
|
||||
return True
|
||||
|
||||
|
||||
def run_after_tool_call_hooks(context: ToolCallHookContext) -> str | None:
|
||||
"""Run all ``post_tool_call`` hooks against a context.
|
||||
|
||||
Returns:
|
||||
The (possibly modified) tool result carried on the context.
|
||||
"""
|
||||
dispatch(
|
||||
InterceptionPoint.POST_TOOL_CALL,
|
||||
context,
|
||||
reducer=after_tool_call_reducer,
|
||||
verbose=_hook_verbose(context),
|
||||
)
|
||||
return context.tool_result
|
||||
|
||||
|
||||
def register_before_tool_call_hook(
|
||||
|
||||
@@ -1007,15 +1007,14 @@ class BaseLLM(BaseModel, ABC):
|
||||
|
||||
from crewai_core.printer import PRINTER
|
||||
|
||||
from crewai.hooks.dispatch import HookAborted, InterceptionPoint, dispatch
|
||||
from crewai.hooks.llm_hooks import (
|
||||
LLMCallHookContext,
|
||||
get_before_llm_call_hooks,
|
||||
before_llm_call_reducer,
|
||||
)
|
||||
|
||||
before_hooks = get_before_llm_call_hooks()
|
||||
if not before_hooks:
|
||||
return True
|
||||
|
||||
# No early global-list guard: dispatch resolves global + execution-scoped
|
||||
# hooks and has its own no-op fast path, so scoped hooks still run here.
|
||||
hook_context = LLMCallHookContext(
|
||||
executor=None,
|
||||
messages=messages,
|
||||
@@ -1024,24 +1023,19 @@ class BaseLLM(BaseModel, ABC):
|
||||
task=None,
|
||||
crew=None,
|
||||
)
|
||||
verbose = getattr(from_agent, "verbose", True) if from_agent else True
|
||||
|
||||
try:
|
||||
for hook in before_hooks:
|
||||
result = hook(hook_context)
|
||||
if result is False:
|
||||
if verbose:
|
||||
PRINTER.print(
|
||||
content="LLM call blocked by before_llm_call hook",
|
||||
color="yellow",
|
||||
)
|
||||
return False
|
||||
except Exception as e:
|
||||
if verbose:
|
||||
PRINTER.print(
|
||||
content=f"Error in before_llm_call hook: {e}",
|
||||
color="yellow",
|
||||
)
|
||||
dispatch(
|
||||
InterceptionPoint.PRE_MODEL_CALL,
|
||||
hook_context,
|
||||
reducer=before_llm_call_reducer,
|
||||
)
|
||||
except HookAborted:
|
||||
PRINTER.print(
|
||||
content="LLM call blocked by before_llm_call hook",
|
||||
color="yellow",
|
||||
)
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
@@ -1074,17 +1068,14 @@ class BaseLLM(BaseModel, ABC):
|
||||
if from_agent is not None or not isinstance(response, str):
|
||||
return response
|
||||
|
||||
from crewai_core.printer import PRINTER
|
||||
|
||||
from crewai.hooks.dispatch import InterceptionPoint, dispatch
|
||||
from crewai.hooks.llm_hooks import (
|
||||
LLMCallHookContext,
|
||||
get_after_llm_call_hooks,
|
||||
after_llm_call_reducer,
|
||||
)
|
||||
|
||||
after_hooks = get_after_llm_call_hooks()
|
||||
if not after_hooks:
|
||||
return response
|
||||
|
||||
# No early global-list guard: dispatch resolves global + execution-scoped
|
||||
# hooks and has its own no-op fast path, so scoped hooks still run here.
|
||||
hook_context = LLMCallHookContext(
|
||||
executor=None,
|
||||
messages=messages,
|
||||
@@ -1094,20 +1085,11 @@ class BaseLLM(BaseModel, ABC):
|
||||
crew=None,
|
||||
response=response,
|
||||
)
|
||||
verbose = getattr(from_agent, "verbose", True) if from_agent else True
|
||||
modified_response = response
|
||||
|
||||
try:
|
||||
for hook in after_hooks:
|
||||
result = hook(hook_context)
|
||||
if result is not None and isinstance(result, str):
|
||||
modified_response = result
|
||||
hook_context.response = modified_response
|
||||
except Exception as e:
|
||||
if verbose:
|
||||
PRINTER.print(
|
||||
content=f"Error in after_llm_call hook: {e}",
|
||||
color="yellow",
|
||||
)
|
||||
dispatch(
|
||||
InterceptionPoint.POST_MODEL_CALL,
|
||||
hook_context,
|
||||
reducer=after_llm_call_reducer,
|
||||
)
|
||||
|
||||
return modified_response
|
||||
return hook_context.response if hook_context.response is not None else response
|
||||
|
||||
@@ -668,49 +668,6 @@ class OpenAICompletion(BaseLLM):
|
||||
response_model=response_model,
|
||||
)
|
||||
|
||||
def _convert_message_to_responses_input_items(
|
||||
self, message: LLMMessage
|
||||
) -> list[dict[str, Any] | LLMMessage]:
|
||||
"""Convert a Chat-Completions-style message into Responses API input items.
|
||||
|
||||
The Responses API has no message shape for an assistant turn carrying
|
||||
``tool_calls`` or for a ``tool`` role reply - those become standalone
|
||||
``function_call`` / ``function_call_output`` input items instead. Plain
|
||||
user/assistant text messages pass through unchanged (accepted as-is by
|
||||
the Responses API's lenient "easy input message" shape).
|
||||
"""
|
||||
role = message.get("role")
|
||||
|
||||
if role == "assistant" and message.get("tool_calls"):
|
||||
items: list[dict[str, Any] | LLMMessage] = []
|
||||
if message.get("content"):
|
||||
items.append({"role": "assistant", "content": message["content"]})
|
||||
for tool_call in message["tool_calls"]:
|
||||
function = tool_call.get("function", {})
|
||||
args = function.get("arguments", "")
|
||||
items.append(
|
||||
{
|
||||
"type": "function_call",
|
||||
"call_id": tool_call.get("id") or f"call_{id(tool_call)}",
|
||||
"name": function.get("name", ""),
|
||||
"arguments": args
|
||||
if isinstance(args, str)
|
||||
else json.dumps(args),
|
||||
}
|
||||
)
|
||||
return items
|
||||
|
||||
if role == "tool":
|
||||
return [
|
||||
{
|
||||
"type": "function_call_output",
|
||||
"call_id": message.get("tool_call_id", ""),
|
||||
"output": message.get("content") or "",
|
||||
}
|
||||
]
|
||||
|
||||
return [message]
|
||||
|
||||
def _prepare_responses_params(
|
||||
self,
|
||||
messages: list[LLMMessage],
|
||||
@@ -726,7 +683,7 @@ class OpenAICompletion(BaseLLM):
|
||||
- Internally-tagged tool format (flat structure)
|
||||
"""
|
||||
instructions: str | None = self.instructions
|
||||
input_messages: list[Any] = []
|
||||
input_messages: list[LLMMessage] = []
|
||||
|
||||
for message in messages:
|
||||
if message.get("role") == "system":
|
||||
@@ -737,9 +694,7 @@ class OpenAICompletion(BaseLLM):
|
||||
else:
|
||||
instructions = content_str
|
||||
else:
|
||||
input_messages.extend(
|
||||
self._convert_message_to_responses_input_items(message)
|
||||
)
|
||||
input_messages.append(message)
|
||||
|
||||
# Prepend reasoning items for ZDR (zero-data-retention) chaining when configured
|
||||
final_input: list[Any] = []
|
||||
|
||||
@@ -452,7 +452,15 @@ def _register_crew_hooks(instance: CrewInstance, cls: type) -> None:
|
||||
)
|
||||
}
|
||||
|
||||
if not hook_methods:
|
||||
# Methods decorated with @on(InterceptionPoint.X) carry ``_interception_point``
|
||||
# instead of the legacy markers above.
|
||||
on_methods = {
|
||||
name: method
|
||||
for name, method in cls.__dict__.items()
|
||||
if hasattr(method, "_interception_point")
|
||||
}
|
||||
|
||||
if not hook_methods and not on_methods:
|
||||
return
|
||||
|
||||
from crewai.hooks import (
|
||||
@@ -588,6 +596,25 @@ def _register_crew_hooks(instance: CrewInstance, cls: type) -> None:
|
||||
("after_tool_call", after_tool_hook)
|
||||
)
|
||||
|
||||
if on_methods:
|
||||
from crewai.hooks.dispatch import (
|
||||
_wrap_with_filters,
|
||||
register as register_interception_hook,
|
||||
)
|
||||
|
||||
for on_method in on_methods.values():
|
||||
point = on_method._interception_point
|
||||
bound_hook = on_method.__get__(instance, cls)
|
||||
tools_filter = getattr(on_method, "_filter_tools", None)
|
||||
agents_filter = getattr(on_method, "_filter_agents", None)
|
||||
hook = (
|
||||
_wrap_with_filters(bound_hook, agents_filter, tools_filter)
|
||||
if (tools_filter or agents_filter)
|
||||
else bound_hook
|
||||
)
|
||||
register_interception_hook(point, hook)
|
||||
instance._registered_hook_functions.append((point.value, hook))
|
||||
|
||||
instance._hooks_being_registered = False
|
||||
|
||||
|
||||
|
||||
@@ -662,6 +662,21 @@ class Task(BaseModel):
|
||||
crewai_event_bus.emit(
|
||||
self, TaskStartedEvent(context=context, task=self)
|
||||
)
|
||||
|
||||
from crewai.hooks.contexts import StepContext
|
||||
from crewai.hooks.dispatch import InterceptionPoint, dispatch
|
||||
|
||||
pre_step_ctx = StepContext(
|
||||
kind="task",
|
||||
step_name=self.name or self.description,
|
||||
agent=agent,
|
||||
agent_role=getattr(agent, "role", None),
|
||||
task=self,
|
||||
payload=context,
|
||||
)
|
||||
dispatch(InterceptionPoint.PRE_STEP, pre_step_ctx)
|
||||
context = pre_step_ctx.payload
|
||||
|
||||
result = await agent.aexecute_task(
|
||||
task=self,
|
||||
context=context,
|
||||
@@ -718,6 +733,18 @@ class Task(BaseModel):
|
||||
guardrail=self._guardrail,
|
||||
)
|
||||
|
||||
post_step_ctx = StepContext(
|
||||
kind="task",
|
||||
step_name=self.name or self.description,
|
||||
agent=agent,
|
||||
agent_role=getattr(agent, "role", None),
|
||||
task=self,
|
||||
output=task_output,
|
||||
payload=task_output,
|
||||
)
|
||||
dispatch(InterceptionPoint.POST_STEP, post_step_ctx)
|
||||
task_output = cast(TaskOutput, post_step_ctx.payload)
|
||||
|
||||
self.output = task_output
|
||||
self.end_time = datetime.datetime.now()
|
||||
|
||||
@@ -739,10 +766,12 @@ class Task(BaseModel):
|
||||
|
||||
if self.output_file:
|
||||
content = (
|
||||
json_output
|
||||
if json_output
|
||||
task_output.json_dict
|
||||
if task_output.json_dict
|
||||
else (
|
||||
pydantic_output.model_dump_json() if pydantic_output else result
|
||||
task_output.pydantic.model_dump_json()
|
||||
if task_output.pydantic
|
||||
else task_output.raw
|
||||
)
|
||||
)
|
||||
self._save_file(content)
|
||||
@@ -787,6 +816,21 @@ class Task(BaseModel):
|
||||
crewai_event_bus.emit(
|
||||
self, TaskStartedEvent(context=context, task=self)
|
||||
)
|
||||
|
||||
from crewai.hooks.contexts import StepContext
|
||||
from crewai.hooks.dispatch import InterceptionPoint, dispatch
|
||||
|
||||
pre_step_ctx = StepContext(
|
||||
kind="task",
|
||||
step_name=self.name or self.description,
|
||||
agent=agent,
|
||||
agent_role=getattr(agent, "role", None),
|
||||
task=self,
|
||||
payload=context,
|
||||
)
|
||||
dispatch(InterceptionPoint.PRE_STEP, pre_step_ctx)
|
||||
context = pre_step_ctx.payload
|
||||
|
||||
result = agent.execute_task(
|
||||
task=self,
|
||||
context=context,
|
||||
@@ -843,6 +887,18 @@ class Task(BaseModel):
|
||||
guardrail=self._guardrail,
|
||||
)
|
||||
|
||||
post_step_ctx = StepContext(
|
||||
kind="task",
|
||||
step_name=self.name or self.description,
|
||||
agent=agent,
|
||||
agent_role=getattr(agent, "role", None),
|
||||
task=self,
|
||||
output=task_output,
|
||||
payload=task_output,
|
||||
)
|
||||
dispatch(InterceptionPoint.POST_STEP, post_step_ctx)
|
||||
task_output = cast(TaskOutput, post_step_ctx.payload)
|
||||
|
||||
self.output = task_output
|
||||
self.end_time = datetime.datetime.now()
|
||||
|
||||
@@ -864,10 +920,12 @@ class Task(BaseModel):
|
||||
|
||||
if self.output_file:
|
||||
content = (
|
||||
json_output
|
||||
if json_output
|
||||
task_output.json_dict
|
||||
if task_output.json_dict
|
||||
else (
|
||||
pydantic_output.model_dump_json() if pydantic_output else result
|
||||
task_output.pydantic.model_dump_json()
|
||||
if task_output.pydantic
|
||||
else task_output.raw
|
||||
)
|
||||
)
|
||||
self._save_file(content)
|
||||
@@ -1316,7 +1374,6 @@ Follow these guidelines:
|
||||
content=f"Guardrail {guardrail_index if guardrail_index is not None else ''} blocked (attempt {attempt + 1}/{max_attempts}), retrying due to: {guardrail_result.error}\n",
|
||||
color="yellow",
|
||||
)
|
||||
|
||||
result = agent.execute_task(
|
||||
task=self,
|
||||
context=context,
|
||||
@@ -1426,7 +1483,6 @@ Follow these guidelines:
|
||||
content=f"Guardrail {guardrail_index if guardrail_index is not None else ''} blocked (attempt {attempt + 1}/{max_attempts}), retrying due to: {guardrail_result.error}\n",
|
||||
color="yellow",
|
||||
)
|
||||
|
||||
result = await agent.aexecute_task(
|
||||
task=self,
|
||||
context=context,
|
||||
|
||||
@@ -1238,14 +1238,7 @@ def extract_tool_call_info(
|
||||
)
|
||||
func_info = tool_call.get("function", {})
|
||||
func_name = func_info.get("name", "") or tool_call.get("name", "")
|
||||
# OpenAI Responses API function_call items are flat dicts using
|
||||
# "arguments" (not "input") with no nested "function" key.
|
||||
func_args = (
|
||||
func_info.get("arguments")
|
||||
or tool_call.get("arguments")
|
||||
or tool_call.get("input")
|
||||
or {}
|
||||
)
|
||||
func_args = func_info.get("arguments") or tool_call.get("input") or {}
|
||||
return call_id, sanitize_tool_name(func_name), func_args
|
||||
return None
|
||||
|
||||
@@ -1277,15 +1270,6 @@ def is_tool_call_list(response: list[Any]) -> bool:
|
||||
# Bedrock-style
|
||||
if isinstance(first_item, dict) and "name" in first_item and "input" in first_item:
|
||||
return True
|
||||
# OpenAI Responses API-style (flat dict, no nested "function" key). This
|
||||
# intentionally accepts the same broad shape as the Bedrock check above;
|
||||
# only provider paths that return lists reach this classifier.
|
||||
if (
|
||||
isinstance(first_item, dict)
|
||||
and "name" in first_item
|
||||
and "arguments" in first_item
|
||||
):
|
||||
return True
|
||||
# Gemini-style
|
||||
if hasattr(first_item, "function_call") and first_item.function_call:
|
||||
return True
|
||||
@@ -1469,8 +1453,8 @@ def execute_single_native_tool_call(
|
||||
)
|
||||
from crewai.hooks.tool_hooks import (
|
||||
ToolCallHookContext,
|
||||
get_after_tool_call_hooks,
|
||||
get_before_tool_call_hooks,
|
||||
run_after_tool_call_hooks,
|
||||
run_before_tool_call_hooks,
|
||||
)
|
||||
|
||||
info = extract_tool_call_info(tool_call)
|
||||
@@ -1533,7 +1517,6 @@ def execute_single_native_tool_call(
|
||||
|
||||
track_delegation_if_needed(func_name, args_dict, task)
|
||||
|
||||
hook_blocked = False
|
||||
before_hook_context = ToolCallHookContext(
|
||||
tool_name=func_name,
|
||||
tool_input=args_dict,
|
||||
@@ -1542,13 +1525,7 @@ def execute_single_native_tool_call(
|
||||
task=task,
|
||||
crew=crew,
|
||||
)
|
||||
try:
|
||||
for hook in get_before_tool_call_hooks():
|
||||
if hook(before_hook_context) is False:
|
||||
hook_blocked = True
|
||||
break
|
||||
except Exception: # noqa: S110
|
||||
pass
|
||||
hook_blocked = run_before_tool_call_hooks(before_hook_context)
|
||||
|
||||
error_event_emitted = False
|
||||
if hook_blocked:
|
||||
@@ -1603,14 +1580,9 @@ def execute_single_native_tool_call(
|
||||
tool_result=result,
|
||||
raw_tool_result=raw_tool_result,
|
||||
)
|
||||
try:
|
||||
for after_hook in get_after_tool_call_hooks():
|
||||
hook_result = after_hook(after_hook_context)
|
||||
if hook_result is not None:
|
||||
result = hook_result
|
||||
after_hook_context.tool_result = result
|
||||
except Exception: # noqa: S110
|
||||
pass
|
||||
modified_result = run_after_tool_call_hooks(after_hook_context)
|
||||
if modified_result is not None:
|
||||
result = modified_result
|
||||
|
||||
if not error_event_emitted:
|
||||
crewai_event_bus.emit(
|
||||
@@ -1706,28 +1678,42 @@ def _setup_before_llm_call_hooks(
|
||||
Returns:
|
||||
True if LLM execution should proceed, False if blocked by a hook.
|
||||
"""
|
||||
if executor_context and executor_context.before_llm_call_hooks:
|
||||
from crewai.hooks.llm_hooks import LLMCallHookContext
|
||||
if executor_context:
|
||||
from crewai.hooks.dispatch import (
|
||||
HookAborted,
|
||||
InterceptionPoint,
|
||||
get_scoped_hooks,
|
||||
run_hooks,
|
||||
)
|
||||
from crewai.hooks.llm_hooks import LLMCallHookContext, before_llm_call_reducer
|
||||
|
||||
# Executor snapshot first, then execution-scoped hooks — the same
|
||||
# ordering dispatch() applies to global vs scoped hooks.
|
||||
hooks: list[Any] = [
|
||||
*executor_context.before_llm_call_hooks,
|
||||
*get_scoped_hooks(InterceptionPoint.PRE_MODEL_CALL),
|
||||
]
|
||||
if not hooks:
|
||||
return True
|
||||
|
||||
original_messages = executor_context.messages
|
||||
|
||||
hook_context = LLMCallHookContext(executor_context)
|
||||
try:
|
||||
for hook in executor_context.before_llm_call_hooks:
|
||||
result = hook(hook_context)
|
||||
if result is False:
|
||||
if verbose:
|
||||
printer.print(
|
||||
content="LLM call blocked by before_llm_call hook",
|
||||
color="yellow",
|
||||
)
|
||||
return False
|
||||
except Exception as e:
|
||||
run_hooks(
|
||||
InterceptionPoint.PRE_MODEL_CALL,
|
||||
hook_context,
|
||||
hooks,
|
||||
reducer=before_llm_call_reducer,
|
||||
verbose=verbose,
|
||||
)
|
||||
except HookAborted:
|
||||
if verbose:
|
||||
printer.print(
|
||||
content=f"Error in before_llm_call hook: {e}",
|
||||
content="LLM call blocked by before_llm_call hook",
|
||||
color="yellow",
|
||||
)
|
||||
return False
|
||||
|
||||
if not isinstance(executor_context.messages, list):
|
||||
if verbose:
|
||||
@@ -1764,8 +1750,24 @@ def _setup_after_llm_call_hooks(
|
||||
Returns:
|
||||
The potentially modified response (string or Pydantic model).
|
||||
"""
|
||||
if executor_context and executor_context.after_llm_call_hooks:
|
||||
from crewai.hooks.llm_hooks import LLMCallHookContext
|
||||
if executor_context:
|
||||
from crewai.hooks.dispatch import InterceptionPoint, get_scoped_hooks, run_hooks
|
||||
from crewai.hooks.llm_hooks import LLMCallHookContext, after_llm_call_reducer
|
||||
|
||||
# Don't stringify structured tool-call payloads: the executor would
|
||||
# treat the result as a final answer and skip tool execution (#6529).
|
||||
# Hooks still run on the follow-up textual response.
|
||||
if not isinstance(answer, (str, BaseModel)):
|
||||
return answer
|
||||
|
||||
# Executor snapshot first, then execution-scoped hooks — the same
|
||||
# ordering dispatch() applies to global vs scoped hooks.
|
||||
hooks: list[Any] = [
|
||||
*executor_context.after_llm_call_hooks,
|
||||
*get_scoped_hooks(InterceptionPoint.POST_MODEL_CALL),
|
||||
]
|
||||
if not hooks:
|
||||
return answer
|
||||
|
||||
original_messages = executor_context.messages
|
||||
|
||||
@@ -1778,18 +1780,15 @@ def _setup_after_llm_call_hooks(
|
||||
hook_response = str(answer)
|
||||
|
||||
hook_context = LLMCallHookContext(executor_context, response=hook_response)
|
||||
try:
|
||||
for hook in executor_context.after_llm_call_hooks:
|
||||
modified_response = hook(hook_context)
|
||||
if modified_response is not None and isinstance(modified_response, str):
|
||||
hook_response = modified_response
|
||||
|
||||
except Exception as e:
|
||||
if verbose:
|
||||
printer.print(
|
||||
content=f"Error in after_llm_call hook: {e}",
|
||||
color="yellow",
|
||||
)
|
||||
run_hooks(
|
||||
InterceptionPoint.POST_MODEL_CALL,
|
||||
hook_context,
|
||||
hooks,
|
||||
reducer=after_llm_call_reducer,
|
||||
verbose=verbose,
|
||||
)
|
||||
if hook_context.response is not None:
|
||||
hook_response = hook_context.response
|
||||
|
||||
if not isinstance(executor_context.messages, list):
|
||||
if verbose:
|
||||
|
||||
@@ -6,15 +6,14 @@ from crewai.agents.parser import AgentAction
|
||||
from crewai.agents.tools_handler import ToolsHandler
|
||||
from crewai.hooks.tool_hooks import (
|
||||
ToolCallHookContext,
|
||||
get_after_tool_call_hooks,
|
||||
get_before_tool_call_hooks,
|
||||
run_after_tool_call_hooks,
|
||||
run_before_tool_call_hooks,
|
||||
)
|
||||
from crewai.security.fingerprint import Fingerprint
|
||||
from crewai.tools.structured_tool import CrewStructuredTool
|
||||
from crewai.tools.tool_types import ToolResult
|
||||
from crewai.tools.tool_usage import ToolUsage, ToolUsageError
|
||||
from crewai.utilities.i18n import I18N_DEFAULT
|
||||
from crewai.utilities.logger import Logger
|
||||
from crewai.utilities.string_utils import sanitize_tool_name
|
||||
|
||||
|
||||
@@ -57,11 +56,10 @@ async def aexecute_tool_and_check_finality(
|
||||
fingerprint_context: Optional context for fingerprinting.
|
||||
crew: Optional crew instance for hook context.
|
||||
|
||||
Returns:
|
||||
Returns:
|
||||
ToolResult containing the execution result and whether it should be
|
||||
treated as a final answer.
|
||||
"""
|
||||
logger = Logger(verbose=crew.verbose if crew else False)
|
||||
tool_name_to_tool_map = {sanitize_tool_name(tool.name): tool for tool in tools}
|
||||
|
||||
if agent_key and agent_role and agent:
|
||||
@@ -102,18 +100,27 @@ async def aexecute_tool_and_check_finality(
|
||||
crew=crew,
|
||||
)
|
||||
|
||||
before_hooks = get_before_tool_call_hooks()
|
||||
try:
|
||||
for hook in before_hooks:
|
||||
result = hook(hook_context)
|
||||
if result is False:
|
||||
blocked_message = (
|
||||
f"Tool execution blocked by hook. "
|
||||
f"Tool: {tool_calling.tool_name}"
|
||||
)
|
||||
return ToolResult(blocked_message, False)
|
||||
except Exception as e:
|
||||
logger.log("error", f"Error in before_tool_call hook: {e}")
|
||||
if run_before_tool_call_hooks(hook_context):
|
||||
blocked_message = (
|
||||
f"Tool execution blocked by hook. Tool: {tool_calling.tool_name}"
|
||||
)
|
||||
# Run POST_TOOL_CALL even on a blocked call so monitoring hooks
|
||||
# still fire, matching the native tool-call paths.
|
||||
blocked_hook_context = ToolCallHookContext(
|
||||
tool_name=sanitized_tool_name,
|
||||
tool_input=tool_input,
|
||||
tool=tool,
|
||||
agent=agent,
|
||||
task=task,
|
||||
crew=crew,
|
||||
tool_result=blocked_message,
|
||||
raw_tool_result=blocked_message,
|
||||
)
|
||||
modified_result = run_after_tool_call_hooks(blocked_hook_context)
|
||||
return ToolResult(
|
||||
modified_result if modified_result is not None else blocked_message,
|
||||
False,
|
||||
)
|
||||
|
||||
tool_result = await tool_usage.ause(tool_calling, agent_action.text)
|
||||
raw_tool_result = tool_usage.get_last_raw_result(tool_result)
|
||||
@@ -129,18 +136,12 @@ async def aexecute_tool_and_check_finality(
|
||||
raw_tool_result=raw_tool_result,
|
||||
)
|
||||
|
||||
after_hooks = get_after_tool_call_hooks()
|
||||
modified_result: str = tool_result
|
||||
try:
|
||||
for after_hook in after_hooks:
|
||||
hook_result = after_hook(after_hook_context)
|
||||
if hook_result is not None:
|
||||
modified_result = hook_result
|
||||
after_hook_context.tool_result = modified_result
|
||||
except Exception as e:
|
||||
logger.log("error", f"Error in after_tool_call hook: {e}")
|
||||
modified_result = run_after_tool_call_hooks(after_hook_context)
|
||||
|
||||
return ToolResult(modified_result, tool.result_as_answer)
|
||||
return ToolResult(
|
||||
modified_result if modified_result is not None else tool_result,
|
||||
tool.result_as_answer,
|
||||
)
|
||||
|
||||
tool_result = I18N_DEFAULT.errors("wrong_tool_name").format(
|
||||
tool=sanitized_tool_name,
|
||||
@@ -181,7 +182,6 @@ def execute_tool_and_check_finality(
|
||||
Returns:
|
||||
ToolResult containing the execution result and whether it should be treated as a final answer
|
||||
"""
|
||||
logger = Logger(verbose=crew.verbose if crew else False)
|
||||
tool_name_to_tool_map = {sanitize_tool_name(tool.name): tool for tool in tools}
|
||||
|
||||
if agent_key and agent_role and agent:
|
||||
@@ -222,18 +222,27 @@ def execute_tool_and_check_finality(
|
||||
crew=crew,
|
||||
)
|
||||
|
||||
before_hooks = get_before_tool_call_hooks()
|
||||
try:
|
||||
for hook in before_hooks:
|
||||
result = hook(hook_context)
|
||||
if result is False:
|
||||
blocked_message = (
|
||||
f"Tool execution blocked by hook. "
|
||||
f"Tool: {tool_calling.tool_name}"
|
||||
)
|
||||
return ToolResult(blocked_message, False)
|
||||
except Exception as e:
|
||||
logger.log("error", f"Error in before_tool_call hook: {e}")
|
||||
if run_before_tool_call_hooks(hook_context):
|
||||
blocked_message = (
|
||||
f"Tool execution blocked by hook. Tool: {tool_calling.tool_name}"
|
||||
)
|
||||
# Run POST_TOOL_CALL even on a blocked call so monitoring hooks
|
||||
# still fire, matching the native tool-call paths.
|
||||
blocked_hook_context = ToolCallHookContext(
|
||||
tool_name=sanitized_tool_name,
|
||||
tool_input=tool_input,
|
||||
tool=tool,
|
||||
agent=agent,
|
||||
task=task,
|
||||
crew=crew,
|
||||
tool_result=blocked_message,
|
||||
raw_tool_result=blocked_message,
|
||||
)
|
||||
modified_result = run_after_tool_call_hooks(blocked_hook_context)
|
||||
return ToolResult(
|
||||
modified_result if modified_result is not None else blocked_message,
|
||||
False,
|
||||
)
|
||||
|
||||
tool_result = tool_usage.use(tool_calling, agent_action.text)
|
||||
raw_tool_result = tool_usage.get_last_raw_result(tool_result)
|
||||
@@ -249,18 +258,12 @@ def execute_tool_and_check_finality(
|
||||
raw_tool_result=raw_tool_result,
|
||||
)
|
||||
|
||||
after_hooks = get_after_tool_call_hooks()
|
||||
modified_result: str = tool_result
|
||||
try:
|
||||
for after_hook in after_hooks:
|
||||
hook_result = after_hook(after_hook_context)
|
||||
if hook_result is not None:
|
||||
modified_result = hook_result
|
||||
after_hook_context.tool_result = modified_result
|
||||
except Exception as e:
|
||||
logger.log("error", f"Error in after_tool_call hook: {e}")
|
||||
modified_result = run_after_tool_call_hooks(after_hook_context)
|
||||
|
||||
return ToolResult(modified_result, tool.result_as_answer)
|
||||
return ToolResult(
|
||||
modified_result if modified_result is not None else tool_result,
|
||||
tool.result_as_answer,
|
||||
)
|
||||
|
||||
tool_result = I18N_DEFAULT.errors("wrong_tool_name").format(
|
||||
tool=sanitized_tool_name,
|
||||
|
||||
@@ -306,6 +306,62 @@ class TestCrewScopedHooks:
|
||||
assert len(execution_log) == 1
|
||||
|
||||
|
||||
class TestCrewOnDecoratedMethods:
|
||||
"""@on(InterceptionPoint.X) methods inside @CrewBase must register.
|
||||
|
||||
Regression: CrewBase only scanned the legacy ``is_*_hook`` markers, so
|
||||
methods decorated with the generic ``@on`` decorator (which sets
|
||||
``_interception_point``) were silently dropped and never ran.
|
||||
"""
|
||||
|
||||
def test_on_decorated_method_registers_and_binds_self(self):
|
||||
from crewai.hooks import InterceptionPoint, on
|
||||
from crewai.hooks.dispatch import _resolve_hooks
|
||||
|
||||
execution_log = []
|
||||
|
||||
@CrewBase
|
||||
class TestCrew:
|
||||
def __init__(self):
|
||||
self.name = "on-crew"
|
||||
|
||||
@on(InterceptionPoint.PRE_MODEL_CALL)
|
||||
def on_pre_model(self, context):
|
||||
execution_log.append(self.name)
|
||||
|
||||
@agent
|
||||
def researcher(self):
|
||||
return Agent(role="Researcher", goal="Research", backstory="Expert")
|
||||
|
||||
@crew
|
||||
def crew(self):
|
||||
return Crew(agents=self.agents, tasks=[], verbose=False)
|
||||
|
||||
before = len(_resolve_hooks(InterceptionPoint.PRE_MODEL_CALL))
|
||||
|
||||
instance = TestCrew()
|
||||
|
||||
hooks = _resolve_hooks(InterceptionPoint.PRE_MODEL_CALL)
|
||||
assert len(hooks) == before + 1
|
||||
|
||||
assert (
|
||||
InterceptionPoint.PRE_MODEL_CALL.value,
|
||||
hooks[-1],
|
||||
) in instance._registered_hook_functions
|
||||
|
||||
mock_executor = Mock()
|
||||
mock_executor.messages = []
|
||||
mock_executor.agent = Mock(role="Test")
|
||||
mock_executor.task = Mock()
|
||||
mock_executor.crew = Mock()
|
||||
mock_executor.llm = Mock()
|
||||
mock_executor.iterations = 0
|
||||
|
||||
hooks[-1](LLMCallHookContext(executor=mock_executor))
|
||||
|
||||
assert execution_log == ["on-crew"]
|
||||
|
||||
|
||||
class TestCrewScopedHookAttributes:
|
||||
"""Test that crew-scoped hooks have correct attributes set."""
|
||||
|
||||
|
||||
296
lib/crewai/tests/hooks/test_dispatch.py
Normal file
296
lib/crewai/tests/hooks/test_dispatch.py
Normal file
@@ -0,0 +1,296 @@
|
||||
"""Unit tests for the generic interception-hook dispatcher.
|
||||
|
||||
These cover the new contract (payload-in/payload-out + HookAborted), the shared
|
||||
ordered queue between the legacy and new dialects on the four model/tool points,
|
||||
execution-scoped hooks, fail-open exception handling, telemetry, and the no-op
|
||||
fast-path overhead budget.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import time
|
||||
|
||||
from crewai.events.event_bus import crewai_event_bus
|
||||
from crewai.events.types.hook_events import HookDispatchedEvent
|
||||
from crewai.hooks.dispatch import (
|
||||
HookAborted,
|
||||
InterceptionPoint,
|
||||
clear_all,
|
||||
dispatch,
|
||||
get_hooks,
|
||||
on,
|
||||
register,
|
||||
register_scoped,
|
||||
scoped_hooks,
|
||||
unregister as unregister_hook,
|
||||
)
|
||||
from crewai.hooks.llm_hooks import (
|
||||
get_before_llm_call_hooks,
|
||||
register_before_llm_call_hook,
|
||||
)
|
||||
import pytest
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Ctx:
|
||||
payload: object = None
|
||||
tool_name: str | None = None
|
||||
agent: object = None
|
||||
agent_role: str | None = None
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clear_dispatch_registry():
|
||||
"""Ensure every test starts and ends with an empty global registry."""
|
||||
clear_all()
|
||||
yield
|
||||
clear_all()
|
||||
|
||||
|
||||
class TestDispatchContract:
|
||||
"""The core payload-in/payload-out + HookAborted contract."""
|
||||
|
||||
def test_noop_fast_path_returns_context_unchanged(self):
|
||||
ctx = _Ctx(payload="original")
|
||||
result = dispatch(InterceptionPoint.PRE_MODEL_CALL, ctx)
|
||||
assert result is ctx
|
||||
assert ctx.payload == "original"
|
||||
|
||||
def test_return_value_replaces_payload(self):
|
||||
def double(ctx):
|
||||
return ctx.payload * 2
|
||||
|
||||
register(InterceptionPoint.PRE_MODEL_CALL, double)
|
||||
ctx = _Ctx(payload="ab")
|
||||
dispatch(InterceptionPoint.PRE_MODEL_CALL, ctx)
|
||||
assert ctx.payload == "abab"
|
||||
|
||||
def test_in_place_mutation_is_honored(self):
|
||||
def mutate(ctx):
|
||||
ctx.payload.append(1)
|
||||
return None
|
||||
|
||||
register(InterceptionPoint.PRE_MODEL_CALL, mutate)
|
||||
ctx = _Ctx(payload=[])
|
||||
dispatch(InterceptionPoint.PRE_MODEL_CALL, ctx)
|
||||
assert ctx.payload == [1]
|
||||
|
||||
def test_hooks_run_in_registration_order(self):
|
||||
order: list[int] = []
|
||||
register(InterceptionPoint.PRE_MODEL_CALL, lambda ctx: order.append(1))
|
||||
register(InterceptionPoint.PRE_MODEL_CALL, lambda ctx: order.append(2))
|
||||
dispatch(InterceptionPoint.PRE_MODEL_CALL, _Ctx())
|
||||
assert order == [1, 2]
|
||||
|
||||
def test_hook_aborted_propagates_with_reason_and_source(self):
|
||||
def blocker(ctx):
|
||||
raise HookAborted(reason="nope", source="policy")
|
||||
|
||||
register(InterceptionPoint.PRE_MODEL_CALL, blocker)
|
||||
with pytest.raises(HookAborted) as exc:
|
||||
dispatch(InterceptionPoint.PRE_MODEL_CALL, _Ctx())
|
||||
assert exc.value.reason == "nope"
|
||||
assert exc.value.source == "policy"
|
||||
|
||||
def test_ordinary_exception_is_swallowed_and_later_hooks_run(self):
|
||||
ran: list[str] = []
|
||||
|
||||
def boom(ctx):
|
||||
ran.append("boom")
|
||||
raise ValueError("bug in user hook")
|
||||
|
||||
def after(ctx):
|
||||
ran.append("after")
|
||||
|
||||
register(InterceptionPoint.PRE_MODEL_CALL, boom)
|
||||
register(InterceptionPoint.PRE_MODEL_CALL, after)
|
||||
dispatch(InterceptionPoint.PRE_MODEL_CALL, _Ctx(), verbose=False)
|
||||
assert ran == ["boom", "after"]
|
||||
|
||||
|
||||
class TestOnDecorator:
|
||||
"""The @on decorator registers and filters like the legacy decorators."""
|
||||
|
||||
def test_on_registers_global_hook(self):
|
||||
@on(InterceptionPoint.POST_TOOL_CALL)
|
||||
def hook(ctx):
|
||||
return None
|
||||
|
||||
assert hook in get_hooks(InterceptionPoint.POST_TOOL_CALL)
|
||||
|
||||
def test_tool_filter_skips_non_matching_tools(self):
|
||||
seen: list[str] = []
|
||||
|
||||
@on(InterceptionPoint.PRE_TOOL_CALL, tools=["allowed_tool"])
|
||||
def hook(ctx):
|
||||
seen.append(ctx.tool_name)
|
||||
|
||||
dispatch(InterceptionPoint.PRE_TOOL_CALL, _Ctx(tool_name="other_tool"))
|
||||
dispatch(InterceptionPoint.PRE_TOOL_CALL, _Ctx(tool_name="allowed_tool"))
|
||||
assert seen == ["allowed_tool"]
|
||||
|
||||
def test_agent_filter_skips_non_matching_agents(self):
|
||||
seen: list[str] = []
|
||||
|
||||
class _Agent:
|
||||
def __init__(self, role):
|
||||
self.role = role
|
||||
|
||||
@on(InterceptionPoint.PRE_MODEL_CALL, agents=["Researcher"])
|
||||
def hook(ctx):
|
||||
seen.append(ctx.agent.role)
|
||||
|
||||
dispatch(InterceptionPoint.PRE_MODEL_CALL, _Ctx(agent=_Agent("Writer")))
|
||||
dispatch(InterceptionPoint.PRE_MODEL_CALL, _Ctx(agent=_Agent("Researcher")))
|
||||
assert seen == ["Researcher"]
|
||||
|
||||
def test_agent_filter_falls_back_to_agent_role(self):
|
||||
seen: list[str] = []
|
||||
|
||||
@on(InterceptionPoint.PRE_TOOL_CALL, agents=["Researcher"])
|
||||
def hook(ctx):
|
||||
seen.append(ctx.agent_role)
|
||||
|
||||
# No agent object, only the agent_role string (e.g. flow seams).
|
||||
dispatch(InterceptionPoint.PRE_TOOL_CALL, _Ctx(agent_role="Writer"))
|
||||
dispatch(InterceptionPoint.PRE_TOOL_CALL, _Ctx(agent_role="Researcher"))
|
||||
assert seen == ["Researcher"]
|
||||
|
||||
def test_unregister_resolves_filtered_wrapper(self):
|
||||
@on(InterceptionPoint.PRE_TOOL_CALL, tools=["allowed_tool"])
|
||||
def hook(ctx):
|
||||
return None
|
||||
|
||||
assert len(get_hooks(InterceptionPoint.PRE_TOOL_CALL)) == 1
|
||||
assert unregister_hook(InterceptionPoint.PRE_TOOL_CALL, hook) is True
|
||||
assert get_hooks(InterceptionPoint.PRE_TOOL_CALL) == []
|
||||
|
||||
|
||||
class TestSharedQueueWithLegacyDialect:
|
||||
"""Legacy registrations and @on hooks compose in one ordered queue."""
|
||||
|
||||
def test_on_and_legacy_share_pre_model_call_queue(self):
|
||||
def legacy(ctx):
|
||||
return None
|
||||
|
||||
@on(InterceptionPoint.PRE_MODEL_CALL)
|
||||
def modern(ctx):
|
||||
return None
|
||||
|
||||
register_before_llm_call_hook(legacy)
|
||||
|
||||
queue = get_before_llm_call_hooks()
|
||||
assert modern in queue
|
||||
assert legacy in queue
|
||||
# registration order preserved: modern registered before legacy
|
||||
assert queue.index(modern) < queue.index(legacy)
|
||||
|
||||
|
||||
class TestScopedHooks:
|
||||
"""Execution-scoped hooks run after globals and are discarded on exit."""
|
||||
|
||||
def test_scoped_runs_after_global_then_cleared(self):
|
||||
order: list[str] = []
|
||||
register(InterceptionPoint.POST_MODEL_CALL, lambda ctx: order.append("global"))
|
||||
|
||||
with scoped_hooks():
|
||||
register_scoped(InterceptionPoint.POST_MODEL_CALL, lambda ctx: order.append("scoped"))
|
||||
dispatch(InterceptionPoint.POST_MODEL_CALL, _Ctx())
|
||||
|
||||
# outside the scope the scoped hook is gone
|
||||
dispatch(InterceptionPoint.POST_MODEL_CALL, _Ctx())
|
||||
|
||||
assert order == ["global", "scoped", "global"]
|
||||
|
||||
|
||||
class TestTelemetry:
|
||||
"""dispatch emits a HookDispatchedEvent only when hooks ran."""
|
||||
|
||||
def test_no_event_on_empty_fast_path(self):
|
||||
events: list[HookDispatchedEvent] = []
|
||||
with crewai_event_bus.scoped_handlers():
|
||||
|
||||
@crewai_event_bus.on(HookDispatchedEvent)
|
||||
def _capture(_source, event):
|
||||
events.append(event)
|
||||
|
||||
dispatch(InterceptionPoint.PRE_MODEL_CALL, _Ctx())
|
||||
|
||||
assert events == []
|
||||
|
||||
def test_event_reports_outcome(self):
|
||||
events: list[HookDispatchedEvent] = []
|
||||
|
||||
register(InterceptionPoint.PRE_MODEL_CALL, lambda ctx: "changed")
|
||||
|
||||
with crewai_event_bus.scoped_handlers():
|
||||
|
||||
@crewai_event_bus.on(HookDispatchedEvent)
|
||||
def _capture(_source, event):
|
||||
events.append(event)
|
||||
|
||||
dispatch(InterceptionPoint.PRE_MODEL_CALL, _Ctx())
|
||||
# Telemetry handlers run on the bus's thread pool; flush so the
|
||||
# assertion doesn't race the emit.
|
||||
crewai_event_bus.flush()
|
||||
|
||||
assert len(events) == 1
|
||||
assert events[0].interception_point == "pre_model_call"
|
||||
assert events[0].outcome == "modified"
|
||||
assert events[0].hook_count == 1
|
||||
|
||||
def test_event_reports_abort_outcome(self):
|
||||
events: list[HookDispatchedEvent] = []
|
||||
|
||||
def blocker(ctx):
|
||||
raise HookAborted(reason="blocked", source="policy")
|
||||
|
||||
register(InterceptionPoint.PRE_MODEL_CALL, blocker)
|
||||
|
||||
with crewai_event_bus.scoped_handlers():
|
||||
|
||||
@crewai_event_bus.on(HookDispatchedEvent)
|
||||
def _capture(_source, event):
|
||||
events.append(event)
|
||||
|
||||
with pytest.raises(HookAborted):
|
||||
dispatch(InterceptionPoint.PRE_MODEL_CALL, _Ctx())
|
||||
crewai_event_bus.flush()
|
||||
|
||||
assert len(events) == 1
|
||||
assert events[0].interception_point == "pre_model_call"
|
||||
assert events[0].outcome == "aborted"
|
||||
assert events[0].abort_reason == "blocked"
|
||||
assert events[0].abort_source == "policy"
|
||||
|
||||
|
||||
class TestNoOpOverhead:
|
||||
"""The no-op fast path must stay cheap (a single dict lookup)."""
|
||||
|
||||
def test_noop_dispatch_overhead_is_bounded(self):
|
||||
# Relative (not absolute) budget: the no-op fast path is a dict lookup
|
||||
# plus a guard, so it should stay within a wide multiple of a bare
|
||||
# function call. This catches accidental O(n) regressions without
|
||||
# depending on absolute timing on shared CI runners.
|
||||
ctx = _Ctx()
|
||||
iterations = 100_000
|
||||
|
||||
def _baseline(_c):
|
||||
return _c
|
||||
|
||||
for _ in range(1000): # warm up both paths
|
||||
dispatch(InterceptionPoint.PRE_MODEL_CALL, ctx)
|
||||
_baseline(ctx)
|
||||
|
||||
start = time.perf_counter()
|
||||
for _ in range(iterations):
|
||||
_baseline(ctx)
|
||||
baseline = time.perf_counter() - start
|
||||
|
||||
start = time.perf_counter()
|
||||
for _ in range(iterations):
|
||||
dispatch(InterceptionPoint.PRE_MODEL_CALL, ctx)
|
||||
noop = time.perf_counter() - start
|
||||
|
||||
assert noop < baseline * 50 + 5e-3
|
||||
149
lib/crewai/tests/hooks/test_interception_conformance.py
Normal file
149
lib/crewai/tests/hooks/test_interception_conformance.py
Normal file
@@ -0,0 +1,149 @@
|
||||
"""Conformance suite for the framework-native interception points.
|
||||
|
||||
For each wired point this suite asserts the shared contract: the probe hook
|
||||
sees a well-shaped payload, an in-place/returned modification is honored, and a
|
||||
:class:`HookAborted` interrupts the step.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
from crewai.agent import Agent
|
||||
from crewai.flow.flow import Flow, listen, start
|
||||
from crewai.hooks.dispatch import (
|
||||
HookAborted,
|
||||
InterceptionPoint,
|
||||
clear_all,
|
||||
on,
|
||||
)
|
||||
from crewai.task import Task
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clear_dispatch_registry():
|
||||
clear_all()
|
||||
yield
|
||||
clear_all()
|
||||
|
||||
|
||||
class _SimpleFlow(Flow):
|
||||
@start()
|
||||
def begin(self):
|
||||
return "begin"
|
||||
|
||||
@listen(begin)
|
||||
def finish(self, _):
|
||||
return "flow-result"
|
||||
|
||||
|
||||
class TestFlowExecutionBoundaries:
|
||||
"""execution_start / input / output / execution_end on a flow."""
|
||||
|
||||
def test_all_boundary_points_fire_once(self):
|
||||
fired: list[str] = []
|
||||
|
||||
for point in (
|
||||
InterceptionPoint.EXECUTION_START,
|
||||
InterceptionPoint.INPUT,
|
||||
InterceptionPoint.OUTPUT,
|
||||
InterceptionPoint.EXECUTION_END,
|
||||
):
|
||||
|
||||
@on(point)
|
||||
def _probe(ctx, _point=point):
|
||||
fired.append(_point.value)
|
||||
|
||||
_SimpleFlow().kickoff(inputs={"seed": 1})
|
||||
|
||||
assert fired == [
|
||||
"execution_start",
|
||||
"input",
|
||||
"output",
|
||||
"execution_end",
|
||||
]
|
||||
|
||||
def test_output_modification_is_honored(self):
|
||||
@on(InterceptionPoint.OUTPUT)
|
||||
def rewrite(ctx):
|
||||
return "intercepted"
|
||||
|
||||
result = _SimpleFlow().kickoff()
|
||||
assert result == "intercepted"
|
||||
|
||||
def test_input_payload_carries_inputs(self):
|
||||
seen: dict = {}
|
||||
|
||||
@on(InterceptionPoint.INPUT)
|
||||
def capture(ctx):
|
||||
seen.update(ctx.payload or {})
|
||||
|
||||
_SimpleFlow().kickoff(inputs={"seed": 42})
|
||||
assert seen == {"seed": 42}
|
||||
|
||||
def test_abort_at_execution_start_interrupts(self):
|
||||
@on(InterceptionPoint.EXECUTION_START)
|
||||
def block(ctx):
|
||||
raise HookAborted(reason="not allowed", source="policy")
|
||||
|
||||
with pytest.raises(HookAborted) as exc:
|
||||
_SimpleFlow().kickoff()
|
||||
assert exc.value.reason == "not allowed"
|
||||
|
||||
|
||||
class TestFlowStepPoints:
|
||||
"""pre_step / post_step for flow methods (kind=flow_method)."""
|
||||
|
||||
def test_pre_and_post_step_fire_per_method(self):
|
||||
kinds: list[tuple[str, str | None]] = []
|
||||
|
||||
@on(InterceptionPoint.PRE_STEP)
|
||||
def pre(ctx):
|
||||
kinds.append(("pre", ctx.step_name))
|
||||
|
||||
@on(InterceptionPoint.POST_STEP)
|
||||
def post(ctx):
|
||||
kinds.append(("post", ctx.step_name))
|
||||
|
||||
_SimpleFlow().kickoff()
|
||||
|
||||
assert ("pre", "begin") in kinds
|
||||
assert ("post", "begin") in kinds
|
||||
assert ("pre", "finish") in kinds
|
||||
assert ("post", "finish") in kinds
|
||||
|
||||
def test_post_step_can_rewrite_method_output(self):
|
||||
@on(InterceptionPoint.POST_STEP)
|
||||
def rewrite(ctx):
|
||||
if ctx.step_name == "finish":
|
||||
return "rewritten"
|
||||
return None
|
||||
|
||||
assert _SimpleFlow().kickoff() == "rewritten"
|
||||
|
||||
|
||||
class TestTaskStepPoints:
|
||||
"""pre_step / post_step for task execution (kind=task)."""
|
||||
|
||||
def test_post_step_rewrite_is_persisted_to_output_file(
|
||||
self, tmp_path, monkeypatch
|
||||
):
|
||||
@on(InterceptionPoint.POST_STEP)
|
||||
def sanitize(ctx):
|
||||
return ctx.payload.model_copy(update={"raw": "sanitized output"})
|
||||
|
||||
monkeypatch.chdir(tmp_path)
|
||||
agent = Agent(role="Writer", goal="Write", backstory="Writes things.")
|
||||
task = Task(
|
||||
description="Write something",
|
||||
expected_output="Some text",
|
||||
output_file="output.txt",
|
||||
agent=agent,
|
||||
)
|
||||
|
||||
with patch.object(Agent, "execute_task", return_value="original output"):
|
||||
result = task.execute_sync(agent=agent)
|
||||
|
||||
assert result.raw == "sanitized output"
|
||||
assert (tmp_path / "output.txt").read_text() == "sanitized output"
|
||||
@@ -272,6 +272,40 @@ class TestLLMHooksIntegration:
|
||||
|
||||
assert result == "Original [hook1] [hook2]"
|
||||
|
||||
def test_after_hooks_do_not_clobber_native_tool_call_responses(
|
||||
self, mock_executor
|
||||
):
|
||||
"""A registered after hook must not break native tool execution.
|
||||
|
||||
Regression for crewAIInc/crewAI#6529: `_setup_after_llm_call_hooks`
|
||||
stringified structured tool-call payloads, so the executor treated the
|
||||
raw tool call as the final answer and never executed the tool. Non-str,
|
||||
non-BaseModel responses now pass through untouched; hooks still fire on
|
||||
textual responses.
|
||||
"""
|
||||
from crewai.utilities.agent_utils import _setup_after_llm_call_hooks
|
||||
|
||||
observed = []
|
||||
|
||||
def observer(context):
|
||||
observed.append(context.response)
|
||||
return None
|
||||
|
||||
register_after_llm_call_hook(observer)
|
||||
mock_executor.after_llm_call_hooks = get_after_llm_call_hooks()
|
||||
|
||||
tool_calls = [Mock()] # structured native tool-call payload
|
||||
result = _setup_after_llm_call_hooks(
|
||||
mock_executor, tool_calls, printer=Mock(), verbose=False
|
||||
)
|
||||
assert result is tool_calls
|
||||
|
||||
text = _setup_after_llm_call_hooks(
|
||||
mock_executor, "final answer", printer=Mock(), verbose=False
|
||||
)
|
||||
assert text == "final answer"
|
||||
assert observed == ["final answer"]
|
||||
|
||||
def test_unregister_before_hook(self):
|
||||
"""Test that before hooks can be unregistered."""
|
||||
def test_hook(context):
|
||||
@@ -303,6 +337,105 @@ class TestLLMHooksIntegration:
|
||||
hooks = get_before_llm_call_hooks()
|
||||
assert len(hooks) == 0
|
||||
|
||||
def test_raising_before_hook_does_not_skip_later_hooks(self, mock_executor):
|
||||
"""Fail-open is per-hook: a crashing hook must not disable its neighbors.
|
||||
|
||||
Regression guard for the dispatcher migration: previously the
|
||||
``except Exception`` wrapped the whole hook loop, so a raising hook
|
||||
silently skipped every hook registered after it. Now swallowing is
|
||||
per-hook — later hooks still run and the LLM call still proceeds.
|
||||
"""
|
||||
from crewai.utilities.agent_utils import _setup_before_llm_call_hooks
|
||||
|
||||
ran: list[str] = []
|
||||
|
||||
def crashing_hook(context):
|
||||
ran.append("crashing")
|
||||
raise ValueError("bug in user hook")
|
||||
|
||||
def later_hook(context):
|
||||
ran.append("later")
|
||||
|
||||
register_before_llm_call_hook(crashing_hook)
|
||||
register_before_llm_call_hook(later_hook)
|
||||
mock_executor.before_llm_call_hooks = get_before_llm_call_hooks()
|
||||
|
||||
proceed = _setup_before_llm_call_hooks(
|
||||
mock_executor, printer=Mock(), verbose=False
|
||||
)
|
||||
|
||||
assert ran == ["crashing", "later"]
|
||||
assert proceed is True
|
||||
|
||||
def test_scoped_hooks_fire_on_agent_executor_llm_seams(self, mock_executor):
|
||||
"""register_scoped hooks must run on the executor model seams.
|
||||
|
||||
Regression: `_setup_before/after_llm_call_hooks` only ran the
|
||||
executor's snapshot lists, so execution-scoped hooks never fired on
|
||||
PRE/POST_MODEL_CALL during normal agent execution (while tool seams,
|
||||
which go through `dispatch`, merged them). Scoped hooks run after the
|
||||
snapshot, matching dispatch's global-then-scoped ordering.
|
||||
"""
|
||||
from crewai.hooks import InterceptionPoint
|
||||
from crewai.hooks.dispatch import register_scoped, scoped_hooks
|
||||
from crewai.utilities.agent_utils import (
|
||||
_setup_after_llm_call_hooks,
|
||||
_setup_before_llm_call_hooks,
|
||||
)
|
||||
|
||||
order: list[str] = []
|
||||
|
||||
def snapshot_hook(context):
|
||||
order.append("snapshot")
|
||||
|
||||
mock_executor.before_llm_call_hooks = [snapshot_hook]
|
||||
mock_executor.after_llm_call_hooks = []
|
||||
|
||||
with scoped_hooks():
|
||||
register_scoped(
|
||||
InterceptionPoint.PRE_MODEL_CALL,
|
||||
lambda ctx: order.append("scoped_pre"),
|
||||
)
|
||||
register_scoped(
|
||||
InterceptionPoint.POST_MODEL_CALL,
|
||||
lambda ctx: order.append("scoped_post"),
|
||||
)
|
||||
|
||||
proceed = _setup_before_llm_call_hooks(
|
||||
mock_executor, printer=Mock(), verbose=False
|
||||
)
|
||||
answer = _setup_after_llm_call_hooks(
|
||||
mock_executor, "answer", printer=Mock(), verbose=False
|
||||
)
|
||||
|
||||
assert order == ["snapshot", "scoped_pre", "scoped_post"]
|
||||
assert proceed is True
|
||||
assert answer == "answer"
|
||||
|
||||
def test_intentional_block_still_short_circuits_later_hooks(self, mock_executor):
|
||||
"""A hook returning False blocks the call and skips later hooks (unchanged)."""
|
||||
from crewai.utilities.agent_utils import _setup_before_llm_call_hooks
|
||||
|
||||
ran: list[str] = []
|
||||
|
||||
def blocking_hook(context):
|
||||
ran.append("blocking")
|
||||
return False
|
||||
|
||||
def later_hook(context):
|
||||
ran.append("later")
|
||||
|
||||
register_before_llm_call_hook(blocking_hook)
|
||||
register_before_llm_call_hook(later_hook)
|
||||
mock_executor.before_llm_call_hooks = get_before_llm_call_hooks()
|
||||
|
||||
proceed = _setup_before_llm_call_hooks(
|
||||
mock_executor, printer=Mock(), verbose=False
|
||||
)
|
||||
|
||||
assert ran == ["blocking"]
|
||||
assert proceed is False
|
||||
|
||||
@pytest.mark.vcr()
|
||||
def test_lite_agent_hooks_integration_with_real_llm(self):
|
||||
"""Test that LiteAgent executes before/after LLM call hooks and prints messages correctly."""
|
||||
@@ -463,3 +596,77 @@ class TestLLMHooksIntegration:
|
||||
finally:
|
||||
unregister_before_llm_call_hook(before_hook)
|
||||
unregister_after_llm_call_hook(after_hook)
|
||||
|
||||
|
||||
class TestDirectLLMScopedHooks:
|
||||
"""Direct (agent-less) LLM calls must honor execution-scoped hooks.
|
||||
|
||||
Regression: the direct-call helpers used to short-circuit when the global
|
||||
hook list was empty, so hooks registered only for the current
|
||||
``scoped_hooks()`` context never ran on this path.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def _stub_llm():
|
||||
from crewai.llms.base_llm import BaseLLM
|
||||
|
||||
class _StubLLM(BaseLLM):
|
||||
def call(self, *args: object, **kwargs: object) -> str:
|
||||
return ""
|
||||
|
||||
return _StubLLM(model="stub")
|
||||
|
||||
def test_scoped_before_hook_runs_on_direct_call(self):
|
||||
from crewai.hooks import InterceptionPoint
|
||||
from crewai.hooks.dispatch import register_scoped, scoped_hooks
|
||||
|
||||
llm = self._stub_llm()
|
||||
seen: list[int] = []
|
||||
|
||||
with scoped_hooks():
|
||||
register_scoped(
|
||||
InterceptionPoint.PRE_MODEL_CALL,
|
||||
lambda ctx: seen.append(len(ctx.messages)),
|
||||
)
|
||||
proceed = llm._invoke_before_llm_call_hooks(
|
||||
[{"role": "user", "content": "hi"}], from_agent=None
|
||||
)
|
||||
|
||||
assert proceed is True
|
||||
assert seen == [1]
|
||||
|
||||
def test_scoped_before_hook_can_block_direct_call(self):
|
||||
from crewai.hooks import InterceptionPoint
|
||||
from crewai.hooks.dispatch import HookAborted, register_scoped, scoped_hooks
|
||||
|
||||
llm = self._stub_llm()
|
||||
|
||||
def block(ctx: LLMCallHookContext) -> None:
|
||||
raise HookAborted(reason="blocked by scoped hook")
|
||||
|
||||
with scoped_hooks():
|
||||
register_scoped(InterceptionPoint.PRE_MODEL_CALL, block)
|
||||
proceed = llm._invoke_before_llm_call_hooks(
|
||||
[{"role": "user", "content": "hi"}], from_agent=None
|
||||
)
|
||||
|
||||
assert proceed is False
|
||||
|
||||
def test_scoped_after_hook_modifies_direct_response(self):
|
||||
from crewai.hooks import InterceptionPoint
|
||||
from crewai.hooks.dispatch import register_scoped, scoped_hooks
|
||||
|
||||
llm = self._stub_llm()
|
||||
|
||||
def redact(ctx: LLMCallHookContext) -> str:
|
||||
return ctx.response.replace("SECRET", "[REDACTED]")
|
||||
|
||||
with scoped_hooks():
|
||||
register_scoped(InterceptionPoint.POST_MODEL_CALL, redact)
|
||||
result = llm._invoke_after_llm_call_hooks(
|
||||
[{"role": "user", "content": "hi"}],
|
||||
"contains SECRET",
|
||||
from_agent=None,
|
||||
)
|
||||
|
||||
assert result == "contains [REDACTED]"
|
||||
|
||||
@@ -576,6 +576,75 @@ class TestToolHooksIntegration:
|
||||
unregister_after_tool_call_hook(after_tool_call_hook)
|
||||
|
||||
|
||||
class TestPerHookFailOpen:
|
||||
"""Fail-open is per-hook: a crashing hook must not disable its neighbors.
|
||||
|
||||
Regression guards for the dispatcher migration: previously each seam's
|
||||
``except Exception`` wrapped the whole hook loop, so a raising hook
|
||||
silently skipped every hook registered after it.
|
||||
"""
|
||||
|
||||
def test_raising_before_hook_does_not_skip_later_hooks_or_block(
|
||||
self, mock_tool, mock_agent
|
||||
):
|
||||
from crewai.hooks.tool_hooks import run_before_tool_call_hooks
|
||||
|
||||
mock_agent.verbose = False
|
||||
ran: list[str] = []
|
||||
|
||||
def crashing_hook(context):
|
||||
ran.append("crashing")
|
||||
raise ValueError("bug in user hook")
|
||||
|
||||
def later_hook(context):
|
||||
ran.append("later")
|
||||
|
||||
register_before_tool_call_hook(crashing_hook)
|
||||
register_before_tool_call_hook(later_hook)
|
||||
|
||||
context = ToolCallHookContext(
|
||||
tool_name="test_tool",
|
||||
tool_input={"arg": "value"},
|
||||
tool=mock_tool,
|
||||
agent=mock_agent,
|
||||
)
|
||||
blocked = run_before_tool_call_hooks(context)
|
||||
|
||||
assert ran == ["crashing", "later"]
|
||||
assert blocked is False
|
||||
|
||||
def test_raising_after_hook_does_not_skip_later_result_rewrites(
|
||||
self, mock_tool, mock_agent
|
||||
):
|
||||
from crewai.hooks.tool_hooks import run_after_tool_call_hooks
|
||||
|
||||
mock_agent.verbose = False
|
||||
ran: list[str] = []
|
||||
|
||||
def crashing_hook(context):
|
||||
ran.append("crashing")
|
||||
raise ValueError("bug in user hook")
|
||||
|
||||
def rewriting_hook(context):
|
||||
ran.append("rewriting")
|
||||
return f"{context.tool_result} [rewritten]"
|
||||
|
||||
register_after_tool_call_hook(crashing_hook)
|
||||
register_after_tool_call_hook(rewriting_hook)
|
||||
|
||||
context = ToolCallHookContext(
|
||||
tool_name="test_tool",
|
||||
tool_input={"arg": "value"},
|
||||
tool=mock_tool,
|
||||
agent=mock_agent,
|
||||
tool_result="original",
|
||||
)
|
||||
result = run_after_tool_call_hooks(context)
|
||||
|
||||
assert ran == ["crashing", "rewriting"]
|
||||
assert result == "original [rewritten]"
|
||||
|
||||
|
||||
class TestNativeToolCallingHooksIntegration:
|
||||
"""Integration tests for hooks with native function calling (Agent and Crew)."""
|
||||
|
||||
|
||||
@@ -970,140 +970,6 @@ def test_openai_responses_api_with_system_message_extraction():
|
||||
assert result.isupper() or "HELLO" in result.upper()
|
||||
|
||||
|
||||
def test_openai_responses_api_converts_assistant_tool_calls_message():
|
||||
"""Regression: assistant messages carrying tool_calls (Chat-Completions
|
||||
shape) must become standalone function_call input items, since the
|
||||
Responses API has no message shape for an assistant tool-call turn.
|
||||
"""
|
||||
llm = OpenAICompletion(model="gpt-4o-mini", api="responses")
|
||||
|
||||
messages = [
|
||||
{"role": "user", "content": "Fetch https://example.com"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": None,
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "call_abc123",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "fetch_page",
|
||||
"arguments": '{"url": "https://example.com"}',
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
params = llm._prepare_responses_params(messages)
|
||||
|
||||
assert params["input"][0] == {"role": "user", "content": "Fetch https://example.com"}
|
||||
assert params["input"][1] == {
|
||||
"type": "function_call",
|
||||
"call_id": "call_abc123",
|
||||
"name": "fetch_page",
|
||||
"arguments": '{"url": "https://example.com"}',
|
||||
}
|
||||
|
||||
|
||||
def test_openai_responses_api_preserves_assistant_content_with_tool_calls():
|
||||
"""Assistant text must be retained when it accompanies tool calls."""
|
||||
llm = OpenAICompletion(model="gpt-4o-mini", api="responses")
|
||||
|
||||
messages = [
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "I'll fetch that page now.",
|
||||
"tool_calls": [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "fetch_page",
|
||||
"arguments": {"url": "https://example.com"},
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
params = llm._prepare_responses_params(messages)
|
||||
|
||||
assert params["input"][0] == {
|
||||
"role": "assistant",
|
||||
"content": "I'll fetch that page now.",
|
||||
}
|
||||
assert params["input"][1]["type"] == "function_call"
|
||||
assert params["input"][1]["call_id"].startswith("call_")
|
||||
assert params["input"][1]["arguments"] == '{"url": "https://example.com"}'
|
||||
|
||||
|
||||
def test_openai_responses_api_converts_tool_result_message():
|
||||
"""Regression: tool-role messages (Chat-Completions shape) must become
|
||||
function_call_output input items for the Responses API.
|
||||
"""
|
||||
llm = OpenAICompletion(model="gpt-4o-mini", api="responses")
|
||||
|
||||
messages = [
|
||||
{
|
||||
"role": "tool",
|
||||
"tool_call_id": "call_abc123",
|
||||
"name": "fetch_page",
|
||||
"content": "<html>page text</html>",
|
||||
},
|
||||
]
|
||||
|
||||
params = llm._prepare_responses_params(messages)
|
||||
|
||||
assert params["input"] == [
|
||||
{
|
||||
"type": "function_call_output",
|
||||
"call_id": "call_abc123",
|
||||
"output": "<html>page text</html>",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_openai_responses_api_multi_turn_tool_conversation_shape():
|
||||
"""Regression: a full multi-turn tool-calling conversation (user ->
|
||||
assistant tool_calls -> tool result) must convert entirely into valid
|
||||
Responses API input items, with no leftover Chat-Completions-only keys
|
||||
("tool_calls", "tool_call_id") that the Responses API would reject.
|
||||
"""
|
||||
llm = OpenAICompletion(model="gpt-4o-mini", api="responses")
|
||||
|
||||
messages = [
|
||||
{"role": "user", "content": "Fetch https://example.com"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": None,
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "call_abc123",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "fetch_page",
|
||||
"arguments": '{"url": "https://example.com"}',
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
"role": "tool",
|
||||
"tool_call_id": "call_abc123",
|
||||
"name": "fetch_page",
|
||||
"content": "<html>page text</html>",
|
||||
},
|
||||
]
|
||||
|
||||
params = llm._prepare_responses_params(messages)
|
||||
|
||||
for item in params["input"]:
|
||||
assert "tool_calls" not in item
|
||||
assert "tool_call_id" not in item
|
||||
assert params["input"][1]["type"] == "function_call"
|
||||
assert params["input"][2]["type"] == "function_call_output"
|
||||
|
||||
|
||||
@pytest.mark.vcr()
|
||||
def test_openai_responses_api_streaming():
|
||||
"""Test Responses API with streaming enabled."""
|
||||
|
||||
@@ -1348,7 +1348,15 @@ def test_skill_documents_flow_wiring():
|
||||
assert "```yaml" in skill
|
||||
assert "[Method](#method-methods)" in skill
|
||||
assert 'input: "Reviewed research: ${outputs.research_brief.raw}"' in skill
|
||||
assert 'text(root, "path", "default")' in skill
|
||||
assert "do not assemble the string with CEL `+`" in skill
|
||||
assert "Do not use CEL `+` to build text in action mappings" in skill
|
||||
assert "Agent prompt template. Insert Flow values with `${...}`" in skill
|
||||
assert (
|
||||
"Repository-backed agents may set `from_repository` and omit inline "
|
||||
"`role`, `goal`, and `backstory`" in skill
|
||||
)
|
||||
assert "Runtime inputs passed to the Crew" in skill
|
||||
assert "Tool input arguments. Insert Flow values with `${...}`" in skill
|
||||
assert "trust CrewAI defaults and omit them" in skill
|
||||
assert "#### LLM Definition" in skill
|
||||
assert "`max_tokens` (optional): integer | null; default `null`" in skill
|
||||
|
||||
@@ -1102,7 +1102,7 @@ methods:
|
||||
)
|
||||
|
||||
|
||||
def test_tool_action_renders_text_custom_expression_inputs():
|
||||
def test_tool_action_renders_interpolated_inputs():
|
||||
yaml_str = f"""
|
||||
schema: crewai.flow/v1
|
||||
name: ToolFlow
|
||||
@@ -1112,8 +1112,8 @@ methods:
|
||||
call: tool
|
||||
ref: {__name__}:StaticSearchTool
|
||||
with:
|
||||
search_query: "${{'Ticket ID: ' + text(state, 'ticket.id') + '; Subject: ' + text(state, 'ticket.subject') + '; Priority: ' + text(state, 'priority', 'unknown') + '; Message: ' + text(state, 'messages.0.body')}}"
|
||||
prefix: "${{text(state, 'ticket')}}"
|
||||
search_query: "Ticket ID: ${{state.ticket.id}}; Subject: ${{state.ticket.subject}}; Message: ${{state.messages[0].body}}"
|
||||
prefix: "${{state.prefix}}"
|
||||
start: true
|
||||
"""
|
||||
|
||||
@@ -1124,9 +1124,10 @@ methods:
|
||||
inputs={
|
||||
"ticket": {"id": 123, "subject": None},
|
||||
"messages": [{"body": "Initial report"}],
|
||||
"prefix": "ticket",
|
||||
}
|
||||
)
|
||||
== '{"id": 123, "subject": null}:Ticket ID: 123; Subject: ; Priority: unknown; Message: Initial report'
|
||||
== "ticket:Ticket ID: 123; Subject: ; Message: Initial report"
|
||||
)
|
||||
|
||||
|
||||
@@ -1319,7 +1320,7 @@ methods:
|
||||
role: Analyst
|
||||
goal: Answer questions
|
||||
backstory: Knows things.
|
||||
input: "Ticket ID: ${text(state, 'ticket.id')}; Subject: ${text(state, 'ticket.subject')}"
|
||||
input: "Ticket ID: ${state.ticket.id}; Subject: ${state.ticket.subject}"
|
||||
start: true
|
||||
"""
|
||||
|
||||
@@ -2909,37 +2910,6 @@ def test_explicit_cel_fields_accept_expression_markers():
|
||||
assert Flow.from_declaration(contents=definition).kickoff(inputs={"score": 90}) == "qualified"
|
||||
|
||||
|
||||
def test_expression_action_runs_text_custom_expression():
|
||||
definition = FlowDefinition.from_declaration(contents=
|
||||
{
|
||||
"schema": "crewai.flow/v1",
|
||||
"name": "ExpressionFlow",
|
||||
"methods": {
|
||||
"summarize": {
|
||||
"start": True,
|
||||
"do": {
|
||||
"call": "expression",
|
||||
"expr": (
|
||||
"'Ticket ID: ' + text(state, 'ticket.id') + "
|
||||
"'; Tags: ' + text(state, 'tags')"
|
||||
),
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
assert (
|
||||
Flow.from_declaration(contents=definition).kickoff(
|
||||
inputs={
|
||||
"ticket": {"id": 123},
|
||||
"tags": ["urgent", "billing"],
|
||||
}
|
||||
)
|
||||
== 'Ticket ID: 123; Tags: ["urgent", "billing"]'
|
||||
)
|
||||
|
||||
|
||||
def test_expression_local_context_recurses_into_dataclass_values():
|
||||
from crewai.flow.expressions import Expression
|
||||
|
||||
|
||||
@@ -25,8 +25,6 @@ from crewai.utilities.agent_utils import (
|
||||
_split_messages_into_chunks,
|
||||
convert_tools_to_openai_schema,
|
||||
execute_single_native_tool_call,
|
||||
extract_tool_call_info,
|
||||
is_tool_call_list,
|
||||
NativeToolCallResult,
|
||||
parse_tool_call_args,
|
||||
summarize_messages,
|
||||
@@ -983,88 +981,6 @@ class TestParallelSummarizationVCR:
|
||||
assert "report.pdf" in summary_msg["files"]
|
||||
|
||||
|
||||
class TestIsToolCallListResponsesApiShape:
|
||||
"""Regression tests: OpenAI Responses API tool-call dicts must be recognized.
|
||||
|
||||
Responses API function_call output items are flat dicts shaped
|
||||
{"id", "name", "arguments"} - no nested "function" key, and "arguments"
|
||||
instead of Anthropic/Bedrock-style "input".
|
||||
"""
|
||||
|
||||
def test_responses_api_dict_is_recognized_as_tool_call(self) -> None:
|
||||
response = [
|
||||
{
|
||||
"id": "call_abc123",
|
||||
"name": "fetch_page",
|
||||
"arguments": '{"url": "https://example.com"}',
|
||||
}
|
||||
]
|
||||
assert is_tool_call_list(response) is True
|
||||
|
||||
def test_plain_text_answer_not_misclassified(self) -> None:
|
||||
assert is_tool_call_list(["just a string, not a tool call"]) is False
|
||||
|
||||
def test_empty_list_returns_false(self) -> None:
|
||||
assert is_tool_call_list([]) is False
|
||||
|
||||
def test_chat_completions_style_still_recognized(self) -> None:
|
||||
response = [{"function": {"name": "fetch_page", "arguments": "{}"}}]
|
||||
assert is_tool_call_list(response) is True
|
||||
|
||||
def test_bedrock_anthropic_style_still_recognized(self) -> None:
|
||||
response = [{"name": "fetch_page", "input": {"url": "https://example.com"}}]
|
||||
assert is_tool_call_list(response) is True
|
||||
|
||||
|
||||
class TestExtractToolCallInfoResponsesApiShape:
|
||||
"""Regression tests: extract_tool_call_info must parse Responses API dicts."""
|
||||
|
||||
def test_responses_api_dict_extracts_real_arguments(self) -> None:
|
||||
tool_call = {
|
||||
"id": "call_abc123",
|
||||
"name": "fetch_page",
|
||||
"arguments": '{"url": "https://example.com"}',
|
||||
}
|
||||
result = extract_tool_call_info(tool_call)
|
||||
assert result is not None
|
||||
call_id, func_name, func_args = result
|
||||
assert call_id == "call_abc123"
|
||||
assert func_name == "fetch_page"
|
||||
assert func_args == '{"url": "https://example.com"}'
|
||||
|
||||
def test_responses_api_dict_does_not_return_empty_args(self) -> None:
|
||||
tool_call = {
|
||||
"id": "call_xyz",
|
||||
"name": "fetch_page",
|
||||
"arguments": '{"url": "https://example.com"}',
|
||||
}
|
||||
_, _, func_args = extract_tool_call_info(tool_call)
|
||||
assert func_args != {}
|
||||
|
||||
def test_bedrock_anthropic_style_still_uses_input(self) -> None:
|
||||
tool_call = {"name": "fetch_page", "input": {"url": "https://example.com"}}
|
||||
_, func_name, func_args = extract_tool_call_info(tool_call)
|
||||
assert func_name == "fetch_page"
|
||||
assert func_args == {"url": "https://example.com"}
|
||||
|
||||
def test_chat_completions_style_still_uses_nested_function(self) -> None:
|
||||
tool_call = {
|
||||
"id": "call_1",
|
||||
"function": {"name": "fetch_page", "arguments": "{}"},
|
||||
}
|
||||
_, func_name, func_args = extract_tool_call_info(tool_call)
|
||||
assert func_name == "fetch_page"
|
||||
assert func_args == "{}"
|
||||
|
||||
def test_non_dict_unrecognized_shape_returns_none(self) -> None:
|
||||
assert extract_tool_call_info("just a string") is None
|
||||
|
||||
def test_unrecognized_dict_shape_returns_empty_name_and_args(self) -> None:
|
||||
call_id, func_name, func_args = extract_tool_call_info({"unrelated": "data"})
|
||||
assert func_name == ""
|
||||
assert func_args == {}
|
||||
|
||||
|
||||
class TestParseToolCallArgs:
|
||||
"""Unit tests for parse_tool_call_args."""
|
||||
|
||||
|
||||
121
uv.lock
generated
121
uv.lock
generated
@@ -13,7 +13,7 @@ resolution-markers = [
|
||||
]
|
||||
|
||||
[options]
|
||||
exclude-newer = "2026-07-04T15:35:51.457693Z"
|
||||
exclude-newer = "2026-07-11T05:37:26.328112Z"
|
||||
exclude-newer-span = "P3D"
|
||||
|
||||
[options.exclude-newer-package]
|
||||
@@ -1028,14 +1028,14 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "click"
|
||||
version = "8.1.8"
|
||||
version = "8.4.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size = 226593, upload-time = "2024-12-21T18:38:44.339Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000, upload-time = "2026-06-24T17:45:15.148Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/d4/7ebdbd03970677812aac39c869717059dbb71a4cfc033ca6e5221787892c/click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2", size = 98188, upload-time = "2024-12-21T18:38:41.666Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243, upload-time = "2026-06-24T17:45:13.73Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6126,75 +6126,54 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "pillow"
|
||||
version = "12.2.0"
|
||||
version = "12.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8c/21/c2bcdd5906101a30244eaffc1b6e6ce71a31bd0742a01eb89e660ebfac2d/pillow-12.2.0.tar.gz", hash = "sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5", size = 46987819, upload-time = "2026-04-01T14:46:17.687Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/1c/3d/bb7fca845737cf9d7dbde16ed1843984665ff2e0a518f5db43e77ec540b9/pillow-12.3.0.tar.gz", hash = "sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce", size = 47025035, upload-time = "2026-07-01T11:56:38.965Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/aa/d0b28e1c811cd4d5f5c2bfe2e022292bd255ae5744a3b9ac7d6c8f72dd75/pillow-12.2.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:a4e8f36e677d3336f35089648c8955c51c6d386a13cf6ee9c189c5f5bd713a9f", size = 5354355, upload-time = "2026-04-01T14:42:15.402Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/27/8e/1d5b39b8ae2bd7650d0c7b6abb9602d16043ead9ebbfef4bc4047454da2a/pillow-12.2.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2e589959f10d9824d39b350472b92f0ce3b443c0a3442ebf41c40cb8361c5b97", size = 4695871, upload-time = "2026-04-01T14:42:18.234Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/c5/dcb7a6ca6b7d3be41a76958e90018d56c8462166b3ef223150360850c8da/pillow-12.2.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a52edc8bfff4429aaabdf4d9ee0daadbbf8562364f940937b941f87a4290f5ff", size = 6269734, upload-time = "2026-04-01T14:42:20.608Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/f1/aa1bb13b2f4eba914e9637893c73f2af8e48d7d4023b9d3750d4c5eb2d0c/pillow-12.2.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:975385f4776fafde056abb318f612ef6285b10a1f12b8570f3647ad0d74b48ec", size = 8076080, upload-time = "2026-04-01T14:42:23.095Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/2a/8c79d6a53169937784604a8ae8d77e45888c41537f7f6f65ed1f407fe66d/pillow-12.2.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd9c0c7a0c681a347b3194c500cb1e6ca9cab053ea4d82a5cf45b6b754560136", size = 6382236, upload-time = "2026-04-01T14:42:25.82Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/42/bbcb6051030e1e421d103ce7a8ecadf837aa2f39b8f82ef1a8d37c3d4ebc/pillow-12.2.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:88d387ff40b3ff7c274947ed3125dedf5262ec6919d83946753b5f3d7c67ea4c", size = 7070220, upload-time = "2026-04-01T14:42:28.68Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/e1/c2a7d6dd8cfa6b231227da096fd2d58754bab3603b9d73bf609d3c18b64f/pillow-12.2.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:51c4167c34b0d8ba05b547a3bb23578d0ba17b80a5593f93bd8ecb123dd336a3", size = 6493124, upload-time = "2026-04-01T14:42:31.579Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/41/7c8617da5d32e1d2f026e509484fdb6f3ad7efaef1749a0c1928adbb099e/pillow-12.2.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:34c0d99ecccea270c04882cb3b86e7b57296079c9a4aff88cb3b33563d95afaa", size = 7194324, upload-time = "2026-04-01T14:42:34.615Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/de/a777627e19fd6d62f84070ee1521adde5eeda4855b5cf60fe0b149118bca/pillow-12.2.0-cp310-cp310-win32.whl", hash = "sha256:b85f66ae9eb53e860a873b858b789217ba505e5e405a24b85c0464822fe88032", size = 6376363, upload-time = "2026-04-01T14:42:37.19Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/34/fc4cb5204896465842767b96d250c08410f01f2f28afc43b257de842eed5/pillow-12.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:673aa32138f3e7531ccdbca7b3901dba9b70940a19ccecc6a37c77d5fdeb05b5", size = 7083523, upload-time = "2026-04-01T14:42:39.62Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/a0/32852d36bc7709f14dc3f64f929a275e958ad8c19a6deba9610d458e28b3/pillow-12.2.0-cp310-cp310-win_arm64.whl", hash = "sha256:3e080565d8d7c671db5802eedfb438e5565ffa40115216eabb8cd52d0ecce024", size = 2463318, upload-time = "2026-04-01T14:42:42.063Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/e1/748f5663efe6edcfc4e74b2b93edfb9b8b99b67f21a854c3ae416500a2d9/pillow-12.2.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:8be29e59487a79f173507c30ddf57e733a357f67881430449bb32614075a40ab", size = 5354347, upload-time = "2026-04-01T14:42:44.255Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/a1/d5ff69e747374c33a3b53b9f98cca7889fce1fd03d79cdc4e1bccc6c5a87/pillow-12.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:71cde9a1e1551df7d34a25462fc60325e8a11a82cc2e2f54578e5e9a1e153d65", size = 4695873, upload-time = "2026-04-01T14:42:46.452Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/21/e3fbdf54408a973c7f7f89a23b2cb97a7ef30c61ab4142af31eee6aebc88/pillow-12.2.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f490f9368b6fc026f021db16d7ec2fbf7d89e2edb42e8ec09d2c60505f5729c7", size = 6280168, upload-time = "2026-04-01T14:42:49.228Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/f1/00b7278c7dd52b17ad4329153748f87b6756ec195ff786c2bdf12518337d/pillow-12.2.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8bd7903a5f2a4545f6fd5935c90058b89d30045568985a71c79f5fd6edf9b91e", size = 8088188, upload-time = "2026-04-01T14:42:51.735Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ad/cf/220a5994ef1b10e70e85748b75649d77d506499352be135a4989c957b701/pillow-12.2.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3997232e10d2920a68d25191392e3a4487d8183039e1c74c2297f00ed1c50705", size = 6394401, upload-time = "2026-04-01T14:42:54.343Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/bd/e51a61b1054f09437acfbc2ff9106c30d1eb76bc1453d428399946781253/pillow-12.2.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e74473c875d78b8e9d5da2a70f7099549f9eb37ded4e2f6a463e60125bccd176", size = 7079655, upload-time = "2026-04-01T14:42:56.954Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/3d/45132c57d5fb4b5744567c3817026480ac7fc3ce5d4c47902bc0e7f6f853/pillow-12.2.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:56a3f9c60a13133a98ecff6197af34d7824de9b7b38c3654861a725c970c197b", size = 6503105, upload-time = "2026-04-01T14:42:59.847Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/2e/9df2fc1e82097b1df3dce58dc43286aa01068e918c07574711fcc53e6fb4/pillow-12.2.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:90e6f81de50ad6b534cab6e5aef77ff6e37722b2f5d908686f4a5c9eba17a909", size = 7203402, upload-time = "2026-04-01T14:43:02.664Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/2e/2941e42858ebb67e50ae741473de81c2984e6eff7b397017623c676e2e8d/pillow-12.2.0-cp311-cp311-win32.whl", hash = "sha256:8c984051042858021a54926eb597d6ee3012393ce9c181814115df4c60b9a808", size = 6378149, upload-time = "2026-04-01T14:43:05.274Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/69/42/836b6f3cd7f3e5fa10a1f1a5420447c17966044c8fbf589cc0452d5502db/pillow-12.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:6e6b2a0c538fc200b38ff9eb6628228b77908c319a005815f2dde585a0664b60", size = 7082626, upload-time = "2026-04-01T14:43:08.557Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/88/549194b5d6f1f494b485e493edc6693c0a16f4ada488e5bd974ed1f42fad/pillow-12.2.0-cp311-cp311-win_arm64.whl", hash = "sha256:9a8a34cc89c67a65ea7437ce257cea81a9dad65b29805f3ecee8c8fe8ff25ffe", size = 2463531, upload-time = "2026-04-01T14:43:10.743Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/58/be/7482c8a5ebebbc6470b3eb791812fff7d5e0216c2be3827b30b8bb6603ed/pillow-12.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2d192a155bbcec180f8564f693e6fd9bccff5a7af9b32e2e4bf8c9c69dbad6b5", size = 5308279, upload-time = "2026-04-01T14:43:13.246Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/95/0a351b9289c2b5cbde0bacd4a83ebc44023e835490a727b2a3bd60ddc0f4/pillow-12.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f3f40b3c5a968281fd507d519e444c35f0ff171237f4fdde090dd60699458421", size = 4695490, upload-time = "2026-04-01T14:43:15.584Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/af/4e8e6869cbed569d43c416fad3dc4ecb944cb5d9492defaed89ddd6fe871/pillow-12.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:03e7e372d5240cc23e9f07deca4d775c0817bffc641b01e9c3af208dbd300987", size = 6284462, upload-time = "2026-04-01T14:43:18.268Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/9e/c05e19657fd57841e476be1ab46c4d501bffbadbafdc31a6d665f8b737b6/pillow-12.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b86024e52a1b269467a802258c25521e6d742349d760728092e1bc2d135b4d76", size = 8094744, upload-time = "2026-04-01T14:43:20.716Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/54/1789c455ed10176066b6e7e6da1b01e50e36f94ba584dc68d9eebfe9156d/pillow-12.2.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7371b48c4fa448d20d2714c9a1f775a81155050d383333e0a6c15b1123dda005", size = 6398371, upload-time = "2026-04-01T14:43:23.443Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/43/e3/fdc657359e919462369869f1c9f0e973f353f9a9ee295a39b1fea8ee1a77/pillow-12.2.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62f5409336adb0663b7caa0da5c7d9e7bdbaae9ce761d34669420c2a801b2780", size = 7087215, upload-time = "2026-04-01T14:43:26.758Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/f8/2f6825e441d5b1959d2ca5adec984210f1ec086435b0ed5f52c19b3b8a6e/pillow-12.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:01afa7cf67f74f09523699b4e88c73fb55c13346d212a59a2db1f86b0a63e8c5", size = 6509783, upload-time = "2026-04-01T14:43:29.56Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/f9/029a27095ad20f854f9dba026b3ea6428548316e057e6fc3545409e86651/pillow-12.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc3d34d4a8fbec3e88a79b92e5465e0f9b842b628675850d860b8bd300b159f5", size = 7212112, upload-time = "2026-04-01T14:43:32.091Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/42/025cfe05d1be22dbfdb4f264fe9de1ccda83f66e4fc3aac94748e784af04/pillow-12.2.0-cp312-cp312-win32.whl", hash = "sha256:58f62cc0f00fd29e64b29f4fd923ffdb3859c9f9e6105bfc37ba1d08994e8940", size = 6378489, upload-time = "2026-04-01T14:43:34.601Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/7b/25a221d2c761c6a8ae21bfa3874988ff2583e19cf8a27bf2fee358df7942/pillow-12.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:7f84204dee22a783350679a0333981df803dac21a0190d706a50475e361c93f5", size = 7084129, upload-time = "2026-04-01T14:43:37.213Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/e1/542a474affab20fd4a0f1836cb234e8493519da6b76899e30bcc5d990b8b/pillow-12.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:af73337013e0b3b46f175e79492d96845b16126ddf79c438d7ea7ff27783a414", size = 2463612, upload-time = "2026-04-01T14:43:39.421Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/01/53d10cf0dbad820a8db274d259a37ba50b88b24768ddccec07355382d5ad/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:8297651f5b5679c19968abefd6bb84d95fe30ef712eb1b2d9b2d31ca61267f4c", size = 4100837, upload-time = "2026-04-01T14:43:41.506Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/98/f3a6657ecb698c937f6c76ee564882945f29b79bad496abcba0e84659ec5/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:50d8520da2a6ce0af445fa6d648c4273c3eeefbc32d7ce049f22e8b5c3daecc2", size = 4176528, upload-time = "2026-04-01T14:43:43.773Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/69/bc/8986948f05e3ea490b8442ea1c1d4d990b24a7e43d8a51b2c7d8b1dced36/pillow-12.2.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:766cef22385fa1091258ad7e6216792b156dc16d8d3fa607e7545b2b72061f1c", size = 3640401, upload-time = "2026-04-01T14:43:45.87Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/46/6c717baadcd62bc8ed51d238d521ab651eaa74838291bda1f86fe1f864c9/pillow-12.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5d2fd0fa6b5d9d1de415060363433f28da8b1526c1c129020435e186794b3795", size = 5308094, upload-time = "2026-04-01T14:43:48.438Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/43/905a14a8b17fdb1ccb58d282454490662d2cb89a6bfec26af6d3520da5ec/pillow-12.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:56b25336f502b6ed02e889f4ece894a72612fe885889a6e8c4c80239ff6e5f5f", size = 4695402, upload-time = "2026-04-01T14:43:51.292Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/dd/42107efcb777b16fa0393317eac58f5b5cf30e8392e266e76e51cff28c3d/pillow-12.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f1c943e96e85df3d3478f7b691f229887e143f81fedab9b20205349ab04d73ed", size = 6280005, upload-time = "2026-04-01T14:43:54.242Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/68/b93e09e5e8549019e61acf49f65b1a8530765a7f812c77a7461bca7e4494/pillow-12.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:03f6fab9219220f041c74aeaa2939ff0062bd5c364ba9ce037197f4c6d498cd9", size = 8090669, upload-time = "2026-04-01T14:43:57.335Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/6e/3ccb54ce8ec4ddd1accd2d89004308b7b0b21c4ac3d20fa70af4760a4330/pillow-12.2.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cdfebd752ec52bf5bb4e35d9c64b40826bc5b40a13df7c3cda20a2c03a0f5ed", size = 6395194, upload-time = "2026-04-01T14:43:59.864Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/ee/21d4e8536afd1a328f01b359b4d3997b291ffd35a237c877b331c1c3b71c/pillow-12.2.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eedf4b74eda2b5a4b2b2fb4c006d6295df3bf29e459e198c90ea48e130dc75c3", size = 7082423, upload-time = "2026-04-01T14:44:02.74Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/5f/e9f86ab0146464e8c133fe85df987ed9e77e08b29d8d35f9f9f4d6f917ba/pillow-12.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:00a2865911330191c0b818c59103b58a5e697cae67042366970a6b6f1b20b7f9", size = 6505667, upload-time = "2026-04-01T14:44:05.381Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/1e/409007f56a2fdce61584fd3acbc2bbc259857d555196cedcadc68c015c82/pillow-12.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1e1757442ed87f4912397c6d35a0db6a7b52592156014706f17658ff58bbf795", size = 7208580, upload-time = "2026-04-01T14:44:08.39Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/c4/7349421080b12fb35414607b8871e9534546c128a11965fd4a7002ccfbee/pillow-12.2.0-cp313-cp313-win32.whl", hash = "sha256:144748b3af2d1b358d41286056d0003f47cb339b8c43a9ea42f5fea4d8c66b6e", size = 6375896, upload-time = "2026-04-01T14:44:11.197Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/82/8a3739a5e470b3c6cbb1d21d315800d8e16bff503d1f16b03a4ec3212786/pillow-12.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:390ede346628ccc626e5730107cde16c42d3836b89662a115a921f28440e6a3b", size = 7081266, upload-time = "2026-04-01T14:44:13.947Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/25/f968f618a062574294592f668218f8af564830ccebdd1fa6200f598e65c5/pillow-12.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:8023abc91fba39036dbce14a7d6535632f99c0b857807cbbbf21ecc9f4717f06", size = 2463508, upload-time = "2026-04-01T14:44:16.312Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/a4/b342930964e3cb4dce5038ae34b0eab4653334995336cd486c5a8c25a00c/pillow-12.2.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:042db20a421b9bafecc4b84a8b6e444686bd9d836c7fd24542db3e7df7baad9b", size = 5309927, upload-time = "2026-04-01T14:44:18.89Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/de/23198e0a65a9cf06123f5435a5d95cea62a635697f8f03d134d3f3a96151/pillow-12.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd025009355c926a84a612fecf58bb315a3f6814b17ead51a8e48d3823d9087f", size = 4698624, upload-time = "2026-04-01T14:44:21.115Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/01/a6/1265e977f17d93ea37aa28aa81bad4fa597933879fac2520d24e021c8da3/pillow-12.2.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88ddbc66737e277852913bd1e07c150cc7bb124539f94c4e2df5344494e0a612", size = 6321252, upload-time = "2026-04-01T14:44:23.663Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/83/5982eb4a285967baa70340320be9f88e57665a387e3a53a7f0db8231a0cd/pillow-12.2.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d362d1878f00c142b7e1a16e6e5e780f02be8195123f164edf7eddd911eefe7c", size = 8126550, upload-time = "2026-04-01T14:44:26.772Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/48/6ffc514adce69f6050d0753b1a18fd920fce8cac87620d5a31231b04bfc5/pillow-12.2.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c727a6d53cb0018aadd8018c2b938376af27914a68a492f59dfcaca650d5eea", size = 6433114, upload-time = "2026-04-01T14:44:29.615Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/a3/f9a77144231fb8d40ee27107b4463e205fa4677e2ca2548e14da5cf18dce/pillow-12.2.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:efd8c21c98c5cc60653bcb311bef2ce0401642b7ce9d09e03a7da87c878289d4", size = 7115667, upload-time = "2026-04-01T14:44:32.773Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/fc/ac4ee3041e7d5a565e1c4fd72a113f03b6394cc72ab7089d27608f8aaccb/pillow-12.2.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9f08483a632889536b8139663db60f6724bfcb443c96f1b18855860d7d5c0fd4", size = 6538966, upload-time = "2026-04-01T14:44:35.252Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/a8/27fb307055087f3668f6d0a8ccb636e7431d56ed0750e07a60547b1e083e/pillow-12.2.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dac8d77255a37e81a2efcbd1fc05f1c15ee82200e6c240d7e127e25e365c39ea", size = 7238241, upload-time = "2026-04-01T14:44:37.875Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ad/4b/926ab182c07fccae9fcb120043464e1ff1564775ec8864f21a0ebce6ac25/pillow-12.2.0-cp313-cp313t-win32.whl", hash = "sha256:ee3120ae9dff32f121610bb08e4313be87e03efeadfc6c0d18f89127e24d0c24", size = 6379592, upload-time = "2026-04-01T14:44:40.336Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/c4/f9e476451a098181b30050cc4c9a3556b64c02cf6497ea421ac047e89e4b/pillow-12.2.0-cp313-cp313t-win_amd64.whl", hash = "sha256:325ca0528c6788d2a6c3d40e3568639398137346c3d6e66bb61db96b96511c98", size = 7085542, upload-time = "2026-04-01T14:44:43.251Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/a4/285f12aeacbe2d6dc36c407dfbbe9e96d4a80b0fb710a337f6d2ad978c75/pillow-12.2.0-cp313-cp313t-win_arm64.whl", hash = "sha256:2e5a76d03a6c6dcef67edabda7a52494afa4035021a79c8558e14af25313d453", size = 2465765, upload-time = "2026-04-01T14:44:45.996Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/b7/2437044fb910f499610356d1352e3423753c98e34f915252aafecc64889f/pillow-12.2.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0538bd5e05efec03ae613fd89c4ce0368ecd2ba239cc25b9f9be7ed426b0af1f", size = 5273969, upload-time = "2026-04-01T14:45:55.538Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/f4/8316e31de11b780f4ac08ef3654a75555e624a98db1056ecb2122d008d5a/pillow-12.2.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:394167b21da716608eac917c60aa9b969421b5dcbbe02ae7f013e7b85811c69d", size = 4659674, upload-time = "2026-04-01T14:45:58.093Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/37/664fca7201f8bb2aa1d20e2c3d5564a62e6ae5111741966c8319ca802361/pillow-12.2.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5d04bfa02cc2d23b497d1e90a0f927070043f6cbf303e738300532379a4b4e0f", size = 5288479, upload-time = "2026-04-01T14:46:01.141Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/62/5b0ed78fce87346be7a5cfcfaaad91f6a1f98c26f86bdbafa2066c647ef6/pillow-12.2.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0c838a5125cee37e68edec915651521191cef1e6aa336b855f495766e77a366e", size = 7032230, upload-time = "2026-04-01T14:46:03.874Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/28/ec0fc38107fc32536908034e990c47914c57cd7c5a3ece4d8d8f7ffd7e27/pillow-12.2.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a6c9fa44005fa37a91ebfc95d081e8079757d2e904b27103f4f5fa6f0bf78c0", size = 5355404, upload-time = "2026-04-01T14:46:06.33Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/8b/51b0eddcfa2180d60e41f06bd6d0a62202b20b59c68f5a132e615b75aecf/pillow-12.2.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:25373b66e0dd5905ed63fa3cae13c82fbddf3079f2c8bf15c6fb6a35586324c1", size = 6002215, upload-time = "2026-04-01T14:46:08.83Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/60/5382c03e1970de634027cee8e1b7d39776b778b81812aaf45b694dfe9e28/pillow-12.2.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:bfa9c230d2fe991bed5318a5f119bd6780cda2915cca595393649fc118ab895e", size = 7080946, upload-time = "2026-04-01T14:46:11.734Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/c2/669d88644cddb1485bd9534e63e8cf476c8e51cb3c3a1297677023505c0e/pillow-12.3.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:6c0016e7b354317c4e9e525b937ac8596c38d2d232b419529b9cd7a1cd46e39a", size = 5392418, upload-time = "2026-07-01T11:53:27.808Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/ba/3762f376a2948e3036488d773a146e0ae6ecc2ca03ac20e2615bd0b2ba02/pillow-12.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:bcc33feacfaefce60c12fd500a277533bdc02b10a19f7f6d348763d8140bbba7", size = 4785287, upload-time = "2026-07-01T11:53:29.761Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/50/b5d688cc9c52d4482f3d5bcab6ce20bc2a74a85d2343841c907444a3be2c/pillow-12.3.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5594fc43d548a7ed94949d139aa1341b270f1863f11cfd37f5a6c8b778a6b67f", size = 6253754, upload-time = "2026-07-01T11:53:32.298Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/89/36f4cd76cf4baf05c50ababb976249153f18c959171c7f6ba09a6f217260/pillow-12.3.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f0606c8bf2cdefea14a43530f7657cbbb7ecf1c4222512492ef4a4434a9501ec", size = 6925605, upload-time = "2026-07-01T11:53:34.487Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/c0/4de58cf6633b9e3a6061ef4be6fb91fc3c90b812ece886f531e3c523d777/pillow-12.3.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:85f998ea1848bc6757289e739cfbdda3a04adfd58b02fc018ce54d754a5ce468", size = 6327788, upload-time = "2026-07-01T11:53:36.433Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/3c/14d53682a19550dbbaf3b598f807d5457646c510805a44c7d7891cd1cd1a/pillow-12.3.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:25b9b82bb22e6e2b3cd07b39c68b7b862001226cb3dff7130d1cb914121b39ed", size = 7036288, upload-time = "2026-07-01T11:53:38.712Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/38/1d/36279e3c77efe034e4cc2b0393ee74ffdb5a62391dacbf9b916154f5f0b8/pillow-12.3.0-cp310-cp310-win32.whl", hash = "sha256:37dc8f7bbb66efe481bb60defacef820c950c24713fb44962ed6aa2a50966de1", size = 6472396, upload-time = "2026-07-01T11:53:40.781Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/7c/8fa0039574c476d7c6fa57dd7c32a130436877c6ec1e5ce1cc8ec44878c1/pillow-12.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:300557495eb45ebb8aec96c2da9c4be642fbf7cd937278b4013ba894ea8eb0eb", size = 7226887, upload-time = "2026-07-01T11:53:42.764Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/17/e324be141d173c1c919428066c3259f21c1b8982e564e01a4a81e96dbdcf/pillow-12.3.0-cp310-cp310-win_arm64.whl", hash = "sha256:514435a37670e3e5e08f3945b68718b6ed329bb84367777e16f9f4dfe1e61a0f", size = 2568039, upload-time = "2026-07-01T11:53:45.372Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/c8/0a78b0e02d7ac54bc03e5321c9220da52f0c2ea83b21f7c40e7f3169c502/pillow-12.3.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:00808c5e14ef63ac5161091d242999076604ff74b883423a11e5d7bbb38bf756", size = 5392415, upload-time = "2026-07-01T11:53:47.162Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/5b/a02d30018abd97ced9f5a6c63d28597694a00d066516b9c1c6de45859fc9/pillow-12.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:37d6d0a00072fd2948eb22bce7e1475f34569d90c87c59f7a2ec59541b77f7a6", size = 4785266, upload-time = "2026-07-01T11:53:49.079Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/98/766667a4be768150a202836acd9fad19c06824ca86c4286d3cf6b274964e/pillow-12.3.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bcb46e2f9feff8d06323983bd83ed00c201fdcab3d74973e7072a889b3979fcd", size = 6263814, upload-time = "2026-07-01T11:53:51.32Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/2d/ede717bc1144f63886c21fd349bb95860b0d1a21149ff16f2bb362b612b6/pillow-12.3.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23d27a3e0307ec2244cc51e7287b919aa68d097504ebe19df4e76a98a3eea5bd", size = 6934408, upload-time = "2026-07-01T11:53:53.487Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/48/9c58b685e69d49c31af6c8eb9012055fab7e665785165c84796e2c73ce72/pillow-12.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4f883547d4b7f0495ebe7056b0cc2aea76094e7a4abc8e933540f3271df27d9c", size = 6337160, upload-time = "2026-07-01T11:53:55.457Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/fa/dc2a5c0ba6df93f67c31d34b808b7ce440b40cdbf96f0b81cde1d1e6fa93/pillow-12.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:236ff70b9312fb68943c703aa842ca6a758abfa45ac187a5e7c1452e96ef72b5", size = 7045172, upload-time = "2026-07-01T11:53:57.736Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/a5/444817a4d4c4c2417df00513086ca196f388d8f9ef40c2e4ccd1ad1af54b/pillow-12.3.0-cp311-cp311-win32.whl", hash = "sha256:10e41f0fbf1eec8cfd234b8fe17a4caac7c9d0db4c204d3c173a8f9f6ef3232b", size = 6472232, upload-time = "2026-07-01T11:53:59.767Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/c6/4bad1b18d132a50b27e1365e1ab163616f7a5bb56d330f66f9d1d9d4f9d4/pillow-12.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:8e95e1385e4998ae9694eeaa4730ba5457ff61185b3a55e2e7bea0880aef452a", size = 7233653, upload-time = "2026-07-01T11:54:02.066Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/16/00f91ab7760dc842f5aad55217e80fc4a7067a0604535249bc8a2d6d9870/pillow-12.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:ebaea975e03d3141d9d3a507df75c9b3ec90fa9d2ffd07567b3a978d9d790b26", size = 2568195, upload-time = "2026-07-01T11:54:04.622Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/bf/fb3ebff8ddcb76aac5a01389251bbbb9519922a9b520d8247c1ca864a25d/pillow-12.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ba09209fbe443b4acccebe845d8a138b89a8f4fbaeedd44953490b5315d5e965", size = 5345969, upload-time = "2026-07-01T11:54:06.397Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/66/9a386a92561f402389a4fc70c18838bf6d35eb5eb5c6850b4b2dc64f5048/pillow-12.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffd0c5368496f41b0944be820fcb7a838aa6e623d250b01acf2643939c3f99d7", size = 4780323, upload-time = "2026-07-01T11:54:09.351Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/27/ac8f99618ffd3dde21db0f4d4b1d2ab00c0880595bfd17df103f7f39fd0c/pillow-12.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9c7f76c0673154f044e9d78c8655fb4213f6ca31a836df48b40fe5d187717b9", size = 6266838, upload-time = "2026-07-01T11:54:11.71Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/21/a35af28dcc61f37ed850a2d64c65c701321dfbf25085e469d5559360cbbf/pillow-12.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:78cb2c6865a35ab8ff8b75fd122f6033b92a62c82801110e48ddd6c936a45d91", size = 6940830, upload-time = "2026-07-01T11:54:13.732Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/51/8b08617af3ad95e33ce6d7dd2c99ed6c8298f7fb131636303956be022e25/pillow-12.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e491916b378fba47242221bb9ead245211b70d504f495d105d17b14a24b4907c", size = 6344383, upload-time = "2026-07-01T11:54:15.756Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/72/cf78ac9780bb93c28328f408973845a309d4d145041665f734572ced1b52/pillow-12.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0dd2064cbc55aaec028ef5fbb60fa47bb6c3e7918e07ff17935284b227a9d2df", size = 7052934, upload-time = "2026-07-01T11:54:17.721Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/20/25e0f4dc178a6bc0696793720055519a0de89e7661dae886992decbd2f81/pillow-12.3.0-cp312-cp312-win32.whl", hash = "sha256:dbce0b29841537a2fa4a214c2bbf14de3587c9680caa9b4e217568472490b28f", size = 6472684, upload-time = "2026-07-01T11:54:19.839Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/89/da2f7971a317f83d807fdd4065c0af40208e59e692cc43d315a71a0e96d1/pillow-12.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a2b55dd6b2a4c4b7d87ffa56bdb33fdc5fdb9a462173861a7bc097f17d91cb09", size = 7227137, upload-time = "2026-07-01T11:54:22.025Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/47/4845a0a6c0dbf1db8456bd9fc791f13c5ced7ced20606d08a0aacfd25b49/pillow-12.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:331b624368d4f1d069149002f25f44bc61c8919ce8ddb3c45bdad8f6e2d89510", size = 2568267, upload-time = "2026-07-01T11:54:24.051Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/ac/31fb64e1e7efb5a4b50cd3d92049ba89ac6e4d8d3bb6a74e15048ca3353e/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:21900ce7ba264168cd50defae43cd75d25c833ad4ad6e73ffc5596d12e25ac89", size = 4161684, upload-time = "2026-07-01T11:54:25.934Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/b4/9805e23d2b4d77842b468513841fda254ee42f0289d25088340e4ff46e2d/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4e8c2a84d977f50b9daed6eeaf3baef67d00d5d74d932288f02cb94518ee3ace", size = 4255487, upload-time = "2026-07-01T11:54:27.935Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/39/ecf519435a200c693fe053a6ee4d835b41cf963a4dfc2551c4e637cb2a71/pillow-12.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:ae26d61dfa7a47befdc7572b521024e8745f3d809bd95ca9505a7bba9ef849ec", size = 3696433, upload-time = "2026-07-01T11:54:29.813Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/92/2fc3ffad878ae8dd5469ec1bc8eb83b71f48e13efdf68f02709003982a32/pillow-12.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7a743ff716f746fc19a9557f60dab1600d4613255f8a7aeb3cdde4db7eb15a66", size = 5345889, upload-time = "2026-07-01T11:54:31.97Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/76/8803c13605b763d33d156c4678fc77f8443389c0c51c8aef707bb02015f4/pillow-12.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d69141514cc30b774ceea5e3ed3a6635c8d8a96edf664689b890f4089111fb35", size = 4780109, upload-time = "2026-07-01T11:54:34.026Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/01/e18aff37cb0b4aac47ac90f016d347a49aca667ef97f190b06ac2aabc928/pillow-12.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f7401aebd7f581d7f83a439d87d474999317ee099218e5ad25d125290990ba65", size = 6263736, upload-time = "2026-07-01T11:54:36.131Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/62/de5bdd77d935331f4f802edc11e4d82950f642caad6cb2f949837b8560e2/pillow-12.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0847a763afefb695bc912d7c131e7e0632d4edc1d8698f58ddabec8e46b8b6d3", size = 6937129, upload-time = "2026-07-01T11:54:38.216Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/4d/105627a13300c5e0df1d174230b32fd1273062c96f7745fd552b945d1e1d/pillow-12.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:571b9fcb07b97ef3a492028fb3d2dc0993ca23a06138b0315286566d29ef718a", size = 6339562, upload-time = "2026-07-01T11:54:40.354Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/1d/f13de01a553988ab895ba1c722e06cf3144d4f57656fd5b81b6d881f1179/pillow-12.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:756c768d0c9c2955feb7a56c37ea24aea2e369f8d36a88da270b6a9f19e62b5e", size = 7049439, upload-time = "2026-07-01T11:54:42.489Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/f9/066794cca041b969964f779ee5fa66a9498bbf34248ac39c5d7954e4198f/pillow-12.3.0-cp313-cp313-win32.whl", hash = "sha256:a876864214e136f0eb367788dbd7df045f4806801518e2cfe9e13229cfe06d8f", size = 6473287, upload-time = "2026-07-01T11:54:44.9Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a6/9b/7a58e61d62be561da3a356fe2384d4059a6345fc130e23ef1c36a5b81d24/pillow-12.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:1cca606cd25738df4ed873d5ad46bbdb3d83b5cbca291f6b4ff13a4df6b0bbe8", size = 7239691, upload-time = "2026-07-01T11:54:47.141Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/b0/c4ed4f0ef8f8fa5ee8351537db6650bb8189f7e118842978dd6589065692/pillow-12.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:b629de27fda84b42cde7edef0d85f13b958b47f6e9bbcbba9b673c562a89bd8b", size = 2568185, upload-time = "2026-07-01T11:54:49.137Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/18/2e8b40223153ccbc60df07f9e8928dc0c76202aa4e55ae9f53962b6510d6/pillow-12.3.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:b3c777e849237620b022f7f297dd67705f9f5cf1685f09f02e46f93e92725468", size = 5302510, upload-time = "2026-07-01T11:56:25.736Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/3e/51fabf59d5ab801ceab709453d3ab6b180083496579549de4c45ced6528a/pillow-12.3.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:b343699e8308bdc51978310e1c959c584e7869cc8c40780058c87da7781a1e94", size = 4736058, upload-time = "2026-07-01T11:56:28.041Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/20/22fe9384b7949e25fb1293bcfc84fb82590ff4ea6b37c95b24d26d793d86/pillow-12.3.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbd139c8447d25dd750ab79ee274cc5e1fe80fc56340ab10b18a195e1b6eca3e", size = 5237776, upload-time = "2026-07-01T11:56:30.263Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/08/14/f6ba68107680ffa74b39985f3f30884e41318fbc4250caa423c79b4788bb/pillow-12.3.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e7e480451b9fa137494bccd3a7d69adbe8ac65a87d97be61e11f1b1050a5bac3", size = 5860358, upload-time = "2026-07-01T11:56:32.68Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/54/0169bc772ec491108b62f644f8ecf1fe5d8ae5ebafde2ee2142210166903/pillow-12.3.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:04f01d28a6aaff387bf842a13be313df23ba0597a44f1a976c9feb3c6ff4711a", size = 7231786, upload-time = "2026-07-01T11:56:35.046Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
Reference in New Issue
Block a user