mirror of
https://github.com/crewAIInc/crewAI.git
synced 2026-07-22 07:15:10 +00:00
feat: wire remaining interception points and document the catalog
Wires the step, agent, subsystem, and flow points onto the dispatcher: `pre_step`/`post_step`, `tool_selection`, `pre_delegation`, `retry_attempt`, `memory_write`/`memory_read`, `knowledge_retrieval`, `pre_code_execution`, `mcp_connect`, `flow_transition`, and `router_decision`, extending the typed- context module with their contexts. Each seam passes a typed context whose `payload` a hook may observe, mutate, or replace. Adds the conformance suite for these points and a new `interception-hooks` doc page with the full point/payload catalog and the interceptor contract.
This commit is contained in:
@@ -375,7 +375,8 @@
|
||||
"edge/en/learn/using-annotations",
|
||||
"edge/en/learn/execution-hooks",
|
||||
"edge/en/learn/llm-hooks",
|
||||
"edge/en/learn/tool-hooks"
|
||||
"edge/en/learn/tool-hooks",
|
||||
"edge/en/learn/interception-hooks"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -42,6 +42,14 @@ Control and monitor tool execution:
|
||||
|
||||
[View Tool Hooks Documentation →](/learn/tool-hooks)
|
||||
|
||||
<Note>
|
||||
LLM and tool hooks are two points in a larger catalog. See
|
||||
[Interception Hooks](/learn/interception-hooks) for every framework-native
|
||||
interception point (execution boundaries, steps, memory, knowledge, flow
|
||||
transitions, and more) and the shared payload-in/payload-out contract they all
|
||||
follow.
|
||||
</Note>
|
||||
|
||||
## Hook Registration Methods
|
||||
|
||||
### 1. Decorator-Based Hooks (Recommended)
|
||||
|
||||
162
docs/edge/en/learn/interception-hooks.mdx
Normal file
162
docs/edge/en/learn/interception-hooks.mdx
Normal file
@@ -0,0 +1,162 @@
|
||||
---
|
||||
title: Interception Hooks
|
||||
description: The full catalog of framework-native interception points and the payload-in/payload-out contract every hook follows
|
||||
mode: "wide"
|
||||
---
|
||||
|
||||
Interception hooks give you a single, uniform way to observe and modify CrewAI's
|
||||
runtime at well-defined points — from the moment an execution starts, through
|
||||
every model call, tool call, memory read, and flow transition, down to the final
|
||||
output. All points share one contract and one registration API.
|
||||
|
||||
The four LLM/tool hooks documented in [LLM Hooks](/learn/llm-hooks) and
|
||||
[Tool Hooks](/learn/tool-hooks) are the same mechanism. Their existing
|
||||
decorators (`@before_llm_call`, `@before_tool_call`, ...) and `return False`
|
||||
semantics keep working unchanged; interception hooks generalize the same engine
|
||||
to the rest of the framework.
|
||||
|
||||
## The contract
|
||||
|
||||
Every hook is a **synchronous** callable that receives a single typed context:
|
||||
|
||||
```python
|
||||
from crewai.hooks import on, HookAborted, InterceptionPoint
|
||||
|
||||
@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
|
||||
```
|
||||
|
||||
A hook may do any of four things:
|
||||
|
||||
| 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 |
|
||||
|
||||
### Composition, ordering, and fail-open
|
||||
|
||||
- Multiple hooks on the same point run in **registration order**, global hooks
|
||||
first, then execution-scoped hooks.
|
||||
- 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 — the same protection the legacy hooks provide.
|
||||
- When no hook is registered for a point, dispatch is a single dict lookup
|
||||
(no-op fast path), so unused points cost effectively nothing.
|
||||
|
||||
## Registering hooks
|
||||
|
||||
Use the `@on` decorator for global hooks. It mirrors the legacy decorators'
|
||||
ergonomics, including `agents=` / `tools=` filters:
|
||||
|
||||
```python
|
||||
from crewai.hooks import on, InterceptionPoint, HookAborted
|
||||
|
||||
@on(InterceptionPoint.PRE_TOOL_CALL, tools=["delete_file"])
|
||||
def guard_deletes(ctx):
|
||||
raise HookAborted(reason="file deletion is not allowed", source="policy")
|
||||
```
|
||||
|
||||
Applied to a method inside a `@CrewBase` class, `@on` registers a crew-scoped
|
||||
hook (active only while that crew runs), matching the existing crew-scoped hook
|
||||
behavior.
|
||||
|
||||
## Interception point catalog
|
||||
|
||||
`payload` is the value a hook may mutate or replace at each point.
|
||||
|
||||
### Execution boundaries
|
||||
|
||||
| Point | When | `payload` |
|
||||
|-------|------|-----------|
|
||||
| `EXECUTION_START` | A crew or flow is about to begin | inputs `dict` |
|
||||
| `INPUT` | Resolved inputs for the execution | inputs `dict` |
|
||||
| `OUTPUT` | Final result is ready | the output object |
|
||||
| `EXECUTION_END` | A crew or flow has finished | the output object |
|
||||
|
||||
### Model & tool boundaries (legacy-compatible)
|
||||
|
||||
| Point | When | `payload` |
|
||||
|-------|------|-----------|
|
||||
| `PRE_MODEL_CALL` | Before an LLM call | `LLMCallHookContext` |
|
||||
| `POST_MODEL_CALL` | After an LLM call | response |
|
||||
| `PRE_TOOL_CALL` | Before a tool runs | `ToolCallHookContext` |
|
||||
| `POST_TOOL_CALL` | After a tool runs | tool result |
|
||||
|
||||
### Step & agent points
|
||||
|
||||
| Point | When | `payload` |
|
||||
|-------|------|-----------|
|
||||
| `PRE_STEP` | Before a task or flow-method step | step input |
|
||||
| `POST_STEP` | After a task or flow-method step | step output |
|
||||
| `TOOL_SELECTION` | Tools are offered to an agent | list of tools |
|
||||
| `PRE_DELEGATION` | An agent is about to delegate | delegation input |
|
||||
| `RETRY_ATTEMPT` | An operation is about to be retried | retry input |
|
||||
|
||||
`PRE_STEP` / `POST_STEP` carry `ctx.kind` (`"task"` or `"flow_method"`) and
|
||||
`ctx.step_name`.
|
||||
|
||||
### Subsystem points
|
||||
|
||||
| Point | When | `payload` |
|
||||
|-------|------|-----------|
|
||||
| `MEMORY_WRITE` | A value is about to be stored in memory | value |
|
||||
| `MEMORY_READ` | A memory query is issued | query |
|
||||
| `KNOWLEDGE_RETRIEVAL` | A knowledge query is issued | query |
|
||||
| `PRE_CODE_EXECUTION` | Code is about to run (flow `ScriptAction`) | code string |
|
||||
| `MCP_CONNECT` | An MCP client is about to connect | connection params |
|
||||
|
||||
### Flow-specific points
|
||||
|
||||
| Point | When | `payload` |
|
||||
|-------|------|-----------|
|
||||
| `FLOW_TRANSITION` | A flow moves to its triggered methods | list of target methods |
|
||||
| `ROUTER_DECISION` | A flow router picks a route | route label |
|
||||
|
||||
## 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")
|
||||
```
|
||||
|
||||
## 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
|
||||
|
||||
```python
|
||||
import pytest
|
||||
from crewai.hooks import clear_all_hooks
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_hooks():
|
||||
clear_all_hooks()
|
||||
yield
|
||||
clear_all_hooks()
|
||||
```
|
||||
|
||||
## Related documentation
|
||||
|
||||
- [Execution Hooks Overview →](/learn/execution-hooks)
|
||||
- [LLM Call Hooks →](/learn/llm-hooks)
|
||||
- [Tool Call Hooks →](/learn/tool-hooks)
|
||||
- [Before and After Kickoff Hooks →](/learn/before-and-after-kickoff-hooks)
|
||||
@@ -656,6 +656,22 @@ class Agent(BaseAgent):
|
||||
|
||||
return result
|
||||
|
||||
def _dispatch_retry_attempt(self, e: Exception, task: Task) -> None:
|
||||
"""Fire the ``retry_attempt`` interception point before re-executing a task."""
|
||||
from crewai.hooks.contexts import RetryAttemptContext
|
||||
from crewai.hooks.dispatch import InterceptionPoint, dispatch
|
||||
|
||||
retry_ctx = RetryAttemptContext(
|
||||
agent=self,
|
||||
agent_role=getattr(self, "role", None),
|
||||
task=task,
|
||||
attempt=self._times_executed,
|
||||
max_attempts=self.max_retry_limit,
|
||||
error=e,
|
||||
payload=e,
|
||||
)
|
||||
dispatch(InterceptionPoint.RETRY_ATTEMPT, retry_ctx)
|
||||
|
||||
def _check_execution_error(self, e: Exception, task: Task) -> None:
|
||||
"""Check if an execution error should be re-raised immediately.
|
||||
|
||||
@@ -709,6 +725,7 @@ class Agent(BaseAgent):
|
||||
Result from retried execution.
|
||||
"""
|
||||
self._check_execution_error(e, task)
|
||||
self._dispatch_retry_attempt(e, task)
|
||||
return self.execute_task(task, context, tools)
|
||||
|
||||
async def _handle_execution_error_async(
|
||||
@@ -730,6 +747,7 @@ class Agent(BaseAgent):
|
||||
Result from retried execution.
|
||||
"""
|
||||
self._check_execution_error(e, task)
|
||||
self._dispatch_retry_attempt(e, task)
|
||||
return await self.aexecute_task(task, context, tools)
|
||||
|
||||
def message(self, content: str, **kwargs: Any) -> str:
|
||||
@@ -1054,6 +1072,21 @@ class Agent(BaseAgent):
|
||||
An instance of the CrewAgentExecutor class.
|
||||
"""
|
||||
raw_tools: list[BaseTool] = tools or self.tools or []
|
||||
|
||||
from crewai.hooks.contexts import ToolSelectionContext
|
||||
from crewai.hooks.dispatch import InterceptionPoint, dispatch
|
||||
|
||||
selection_ctx = ToolSelectionContext(
|
||||
agent=self,
|
||||
agent_role=getattr(self, "role", None),
|
||||
task=task,
|
||||
crew=self.crew,
|
||||
tools=raw_tools,
|
||||
payload=raw_tools,
|
||||
)
|
||||
dispatch(InterceptionPoint.TOOL_SELECTION, selection_ctx)
|
||||
raw_tools = selection_ctx.payload
|
||||
|
||||
parsed_tools = parse_tools(raw_tools)
|
||||
|
||||
prompt, stop_words, rpm_limit_fn = self._build_execution_prompt(raw_tools)
|
||||
|
||||
@@ -1687,7 +1687,20 @@ class Crew(FlowTrackable, BaseModel):
|
||||
if files_needing_tool:
|
||||
tools = self._add_file_tools(tools, files_needing_tool)
|
||||
|
||||
return tools
|
||||
from crewai.hooks.contexts import ToolSelectionContext
|
||||
from crewai.hooks.dispatch import InterceptionPoint, dispatch
|
||||
|
||||
selection_ctx = ToolSelectionContext(
|
||||
agent=agent,
|
||||
agent_role=getattr(agent, "role", None),
|
||||
task=task,
|
||||
crew=self,
|
||||
tools=tools,
|
||||
payload=tools,
|
||||
)
|
||||
dispatch(InterceptionPoint.TOOL_SELECTION, selection_ctx)
|
||||
|
||||
return selection_ctx.payload
|
||||
|
||||
def _get_agent_to_use(self, task: Task) -> BaseAgent | None:
|
||||
if self.process == Process.hierarchical:
|
||||
|
||||
@@ -2626,6 +2626,17 @@ 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)
|
||||
|
||||
# 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.
|
||||
@@ -2653,6 +2664,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
|
||||
@@ -2849,6 +2870,19 @@ class Flow(BaseModel, Generic[T], metaclass=FlowMeta):
|
||||
if isinstance(router_result, enum.Enum)
|
||||
else router_result
|
||||
)
|
||||
|
||||
from crewai.hooks.contexts import RouterDecisionContext
|
||||
from crewai.hooks.dispatch import InterceptionPoint, dispatch
|
||||
|
||||
router_ctx = RouterDecisionContext(
|
||||
flow=self,
|
||||
router_name=str(router_name),
|
||||
route=router_result,
|
||||
payload=router_result,
|
||||
)
|
||||
dispatch(InterceptionPoint.ROUTER_DECISION, router_ctx)
|
||||
router_result = router_ctx.payload
|
||||
|
||||
router_result_str = str(router_result)
|
||||
router_result_event = FlowMethodName(router_result_str)
|
||||
router_results.append(router_result_event)
|
||||
@@ -2877,6 +2911,19 @@ class Flow(BaseModel, Generic[T], metaclass=FlowMeta):
|
||||
current_trigger, router_only=False
|
||||
)
|
||||
if listeners_triggered:
|
||||
from crewai.hooks.contexts import FlowTransitionContext
|
||||
from crewai.hooks.dispatch import InterceptionPoint, dispatch
|
||||
|
||||
transition_ctx = FlowTransitionContext(
|
||||
flow=self,
|
||||
from_method=str(trigger_method),
|
||||
to_methods=[str(name) for name in listeners_triggered],
|
||||
trigger=str(current_trigger),
|
||||
payload=listeners_triggered,
|
||||
)
|
||||
dispatch(InterceptionPoint.FLOW_TRANSITION, transition_ctx)
|
||||
listeners_triggered = transition_ctx.payload
|
||||
|
||||
listener_result = router_result_payloads.get(
|
||||
str(current_trigger), result
|
||||
)
|
||||
|
||||
@@ -224,6 +224,18 @@ class ScriptAction:
|
||||
|
||||
def run(self, *args: Any, **kwargs: Any) -> Any:
|
||||
local_context = _pop_local_context(kwargs)
|
||||
|
||||
from crewai.hooks.contexts import PreCodeExecutionContext
|
||||
from crewai.hooks.dispatch import InterceptionPoint, dispatch
|
||||
|
||||
code_ctx = PreCodeExecutionContext(
|
||||
flow=self.flow,
|
||||
code=self.definition.code,
|
||||
language="python",
|
||||
payload=self.definition.code,
|
||||
)
|
||||
dispatch(InterceptionPoint.PRE_CODE_EXECUTION, code_ctx)
|
||||
|
||||
return self.handler(
|
||||
state=self.flow.state,
|
||||
outputs=outputs_by_name(
|
||||
|
||||
@@ -56,3 +56,96 @@ 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
|
||||
|
||||
|
||||
@dataclass
|
||||
class ToolSelectionContext(InterceptionContext):
|
||||
"""``tool_selection``: the set of tools offered to an agent. ``payload`` = tools list."""
|
||||
|
||||
tools: list[Any] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class PreDelegationContext(InterceptionContext):
|
||||
"""``pre_delegation``: an agent is about to delegate work. ``payload`` = delegation input."""
|
||||
|
||||
coworker: str | None = None
|
||||
delegate_to: Any = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class RetryAttemptContext(InterceptionContext):
|
||||
"""``retry_attempt``: an operation is about to be retried."""
|
||||
|
||||
attempt: int = 0
|
||||
max_attempts: int | None = None
|
||||
error: Any = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class MemoryWriteContext(InterceptionContext):
|
||||
"""``memory_write``: a value is about to be written to memory. ``payload`` = value."""
|
||||
|
||||
memory_type: str | None = None
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
class MemoryReadContext(InterceptionContext):
|
||||
"""``memory_read``: a memory query is being issued. ``payload`` = query (pre) / results (post)."""
|
||||
|
||||
memory_type: str | None = None
|
||||
query: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class KnowledgeRetrievalContext(InterceptionContext):
|
||||
"""``knowledge_retrieval``: a knowledge query. ``payload`` = query / retrieved results."""
|
||||
|
||||
query: Any = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class PreCodeExecutionContext(InterceptionContext):
|
||||
"""``pre_code_execution``: code is about to run. ``payload`` = the code string."""
|
||||
|
||||
code: str | None = None
|
||||
language: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class MCPConnectContext(InterceptionContext):
|
||||
"""``mcp_connect``: an MCP client is about to connect. ``payload`` = connection params."""
|
||||
|
||||
server_name: str | None = None
|
||||
server_params: Any = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class FlowTransitionContext(InterceptionContext):
|
||||
"""``flow_transition``: a flow is moving to triggered methods."""
|
||||
|
||||
from_method: str | None = None
|
||||
to_methods: list[str] = field(default_factory=list)
|
||||
trigger: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class RouterDecisionContext(InterceptionContext):
|
||||
"""``router_decision``: a flow router is choosing a route. ``payload`` = route label."""
|
||||
|
||||
router_name: str | None = None
|
||||
route: Any = None
|
||||
|
||||
@@ -56,6 +56,24 @@ class InterceptionPoint(str, Enum):
|
||||
PRE_TOOL_CALL = "pre_tool_call"
|
||||
POST_TOOL_CALL = "post_tool_call"
|
||||
|
||||
# Step & agent points
|
||||
PRE_STEP = "pre_step"
|
||||
POST_STEP = "post_step"
|
||||
TOOL_SELECTION = "tool_selection"
|
||||
PRE_DELEGATION = "pre_delegation"
|
||||
RETRY_ATTEMPT = "retry_attempt"
|
||||
|
||||
# Subsystem points
|
||||
MEMORY_WRITE = "memory_write"
|
||||
MEMORY_READ = "memory_read"
|
||||
KNOWLEDGE_RETRIEVAL = "knowledge_retrieval"
|
||||
PRE_CODE_EXECUTION = "pre_code_execution"
|
||||
MCP_CONNECT = "mcp_connect"
|
||||
|
||||
# Flow-specific points
|
||||
FLOW_TRANSITION = "flow_transition"
|
||||
ROUTER_DECISION = "router_decision"
|
||||
|
||||
|
||||
class HookAborted(Exception): # noqa: N818 - public contract name from OSS-86
|
||||
"""Raised by a hook (or a legacy adapter) to abort the intercepted operation.
|
||||
|
||||
@@ -145,6 +145,13 @@ class Knowledge(BaseModel):
|
||||
if self.storage is None:
|
||||
raise ValueError("Storage is not initialized.")
|
||||
|
||||
from crewai.hooks.contexts import KnowledgeRetrievalContext
|
||||
from crewai.hooks.dispatch import InterceptionPoint, dispatch
|
||||
|
||||
retrieval_ctx = KnowledgeRetrievalContext(query=query, payload=query)
|
||||
dispatch(InterceptionPoint.KNOWLEDGE_RETRIEVAL, retrieval_ctx)
|
||||
query = retrieval_ctx.payload
|
||||
|
||||
return self.storage.search(
|
||||
query,
|
||||
limit=results_limit,
|
||||
@@ -183,6 +190,13 @@ class Knowledge(BaseModel):
|
||||
if self.storage is None:
|
||||
raise ValueError("Storage is not initialized.")
|
||||
|
||||
from crewai.hooks.contexts import KnowledgeRetrievalContext
|
||||
from crewai.hooks.dispatch import InterceptionPoint, dispatch
|
||||
|
||||
retrieval_ctx = KnowledgeRetrievalContext(query=query, payload=query)
|
||||
dispatch(InterceptionPoint.KNOWLEDGE_RETRIEVAL, retrieval_ctx)
|
||||
query = retrieval_ctx.payload
|
||||
|
||||
return await self.storage.asearch(
|
||||
query,
|
||||
limit=results_limit,
|
||||
|
||||
@@ -152,6 +152,16 @@ class MCPClient:
|
||||
server_name, server_url, transport_type = self._get_server_info()
|
||||
is_reconnect = self._was_connected
|
||||
|
||||
from crewai.hooks.contexts import MCPConnectContext
|
||||
from crewai.hooks.dispatch import InterceptionPoint, dispatch
|
||||
|
||||
connect_ctx = MCPConnectContext(
|
||||
server_name=server_name,
|
||||
server_params=self.transport,
|
||||
payload=self.transport,
|
||||
)
|
||||
dispatch(InterceptionPoint.MCP_CONNECT, connect_ctx)
|
||||
|
||||
started_at = datetime.now()
|
||||
crewai_event_bus.emit(
|
||||
self,
|
||||
|
||||
@@ -466,6 +466,18 @@ class Memory(BaseModel):
|
||||
if self.read_only:
|
||||
return None
|
||||
|
||||
from crewai.hooks.contexts import MemoryWriteContext
|
||||
from crewai.hooks.dispatch import InterceptionPoint, dispatch
|
||||
|
||||
write_ctx = MemoryWriteContext(
|
||||
agent_role=agent_role,
|
||||
memory_type="unified_memory",
|
||||
metadata=metadata or {},
|
||||
payload=content,
|
||||
)
|
||||
dispatch(InterceptionPoint.MEMORY_WRITE, write_ctx)
|
||||
content = write_ctx.payload
|
||||
|
||||
# Determine effective root_scope: per-call override takes precedence
|
||||
effective_root = root_scope if root_scope is not None else self.root_scope
|
||||
|
||||
@@ -561,6 +573,18 @@ class Memory(BaseModel):
|
||||
if not contents or self.read_only:
|
||||
return []
|
||||
|
||||
from crewai.hooks.contexts import MemoryWriteContext
|
||||
from crewai.hooks.dispatch import InterceptionPoint, dispatch
|
||||
|
||||
write_ctx = MemoryWriteContext(
|
||||
agent_role=agent_role,
|
||||
memory_type="unified_memory",
|
||||
metadata=metadata or {},
|
||||
payload=contents,
|
||||
)
|
||||
dispatch(InterceptionPoint.MEMORY_WRITE, write_ctx)
|
||||
contents = write_ctx.payload
|
||||
|
||||
# Determine effective root_scope: per-call override takes precedence
|
||||
effective_root = root_scope if root_scope is not None else self.root_scope
|
||||
|
||||
@@ -712,6 +736,17 @@ class Memory(BaseModel):
|
||||
# so that the search sees all persisted records.
|
||||
self.drain_writes()
|
||||
|
||||
from crewai.hooks.contexts import MemoryReadContext
|
||||
from crewai.hooks.dispatch import InterceptionPoint, dispatch
|
||||
|
||||
read_ctx = MemoryReadContext(
|
||||
memory_type="unified_memory",
|
||||
query=query,
|
||||
payload=query,
|
||||
)
|
||||
dispatch(InterceptionPoint.MEMORY_READ, read_ctx)
|
||||
query = read_ctx.payload
|
||||
|
||||
effective_scope = scope
|
||||
if effective_scope is None and self.root_scope:
|
||||
effective_scope = self.root_scope
|
||||
|
||||
@@ -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 = post_step_ctx.payload
|
||||
|
||||
self.output = task_output
|
||||
self.end_time = datetime.datetime.now()
|
||||
|
||||
@@ -787,6 +814,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 +885,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 = post_step_ctx.payload
|
||||
|
||||
self.output = task_output
|
||||
self.end_time = datetime.datetime.now()
|
||||
|
||||
@@ -884,6 +938,32 @@ class Task(BaseModel):
|
||||
clear_task_files(self.id)
|
||||
reset_current_task_id(task_id_token)
|
||||
|
||||
def _dispatch_guardrail_retry_attempt(
|
||||
self,
|
||||
agent: BaseAgent | None,
|
||||
context: str | None,
|
||||
attempt: int,
|
||||
error: Any,
|
||||
) -> str | None:
|
||||
"""Fire ``retry_attempt`` before re-executing a task after a guardrail failure.
|
||||
|
||||
Returns the (possibly hook-modified) retry context.
|
||||
"""
|
||||
from crewai.hooks.contexts import RetryAttemptContext
|
||||
from crewai.hooks.dispatch import InterceptionPoint, dispatch
|
||||
|
||||
retry_ctx = RetryAttemptContext(
|
||||
agent=agent,
|
||||
agent_role=getattr(agent, "role", None),
|
||||
task=self,
|
||||
attempt=attempt,
|
||||
max_attempts=self.guardrail_max_retries,
|
||||
error=error,
|
||||
payload=context,
|
||||
)
|
||||
dispatch(InterceptionPoint.RETRY_ATTEMPT, retry_ctx)
|
||||
return retry_ctx.payload
|
||||
|
||||
def _post_agent_execution(self, agent: BaseAgent) -> None:
|
||||
pass
|
||||
|
||||
@@ -1317,6 +1397,13 @@ Follow these guidelines:
|
||||
color="yellow",
|
||||
)
|
||||
|
||||
context = self._dispatch_guardrail_retry_attempt(
|
||||
agent=agent,
|
||||
context=context,
|
||||
attempt=current_retry_count,
|
||||
error=guardrail_result.error,
|
||||
)
|
||||
|
||||
result = agent.execute_task(
|
||||
task=self,
|
||||
context=context,
|
||||
@@ -1427,6 +1514,13 @@ Follow these guidelines:
|
||||
color="yellow",
|
||||
)
|
||||
|
||||
context = self._dispatch_guardrail_retry_attempt(
|
||||
agent=agent,
|
||||
context=context,
|
||||
attempt=current_retry_count,
|
||||
error=guardrail_result.error,
|
||||
)
|
||||
|
||||
result = await agent.aexecute_task(
|
||||
task=self,
|
||||
context=context,
|
||||
|
||||
@@ -109,6 +109,19 @@ class BaseAgentTool(BaseTool):
|
||||
|
||||
selected_agent = agent[0]
|
||||
try:
|
||||
from crewai.hooks.contexts import PreDelegationContext
|
||||
from crewai.hooks.dispatch import InterceptionPoint, dispatch
|
||||
|
||||
delegation_ctx = PreDelegationContext(
|
||||
agent=selected_agent,
|
||||
agent_role=getattr(selected_agent, "role", None),
|
||||
coworker=sanitized_name,
|
||||
delegate_to=selected_agent,
|
||||
payload=task,
|
||||
)
|
||||
dispatch(InterceptionPoint.PRE_DELEGATION, delegation_ctx)
|
||||
task = delegation_ctx.payload
|
||||
|
||||
task_with_assigned_agent = Task(
|
||||
description=task,
|
||||
agent=selected_agent,
|
||||
|
||||
@@ -879,6 +879,22 @@ class ToolUsage:
|
||||
return ToolUsageError(
|
||||
f"{I18N_DEFAULT.errors('tool_usage_error').format(error=e)}\nMoving on then. {I18N_DEFAULT.slice('format').format(tool_names=self.tools_names)}"
|
||||
)
|
||||
|
||||
from crewai.hooks.contexts import RetryAttemptContext
|
||||
from crewai.hooks.dispatch import InterceptionPoint, dispatch
|
||||
|
||||
retry_ctx = RetryAttemptContext(
|
||||
agent=self.agent,
|
||||
agent_role=getattr(self.agent, "role", None),
|
||||
task=self.task,
|
||||
attempt=self._run_attempts,
|
||||
max_attempts=self._max_parsing_attempts,
|
||||
error=e,
|
||||
payload=tool_string,
|
||||
)
|
||||
dispatch(InterceptionPoint.RETRY_ATTEMPT, retry_ctx)
|
||||
tool_string = retry_ctx.payload
|
||||
|
||||
return self._tool_calling(tool_string)
|
||||
|
||||
def _validate_tool_input(self, tool_input: str | None) -> dict[str, Any]:
|
||||
|
||||
@@ -7,7 +7,7 @@ sees a well-shaped payload, an in-place/returned modification is honored, and a
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from crewai.flow.flow import Flow, listen, start
|
||||
from crewai.flow.flow import Flow, listen, router, start
|
||||
from crewai.hooks.dispatch import (
|
||||
HookAborted,
|
||||
InterceptionPoint,
|
||||
@@ -86,3 +86,92 @@ class TestFlowExecutionBoundaries:
|
||||
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 _RouterFlow(Flow):
|
||||
@start()
|
||||
def begin(self):
|
||||
return "begin"
|
||||
|
||||
@router(begin)
|
||||
def route(self):
|
||||
return "go_left"
|
||||
|
||||
@listen("go_left")
|
||||
def left(self):
|
||||
return "left"
|
||||
|
||||
@listen("go_right")
|
||||
def right(self):
|
||||
return "right"
|
||||
|
||||
|
||||
class TestFlowTransitionAndRouter:
|
||||
"""flow_transition and router_decision on a routed flow."""
|
||||
|
||||
def test_transition_payload_carries_from_and_to(self):
|
||||
seen: list[tuple[str | None, list[str]]] = []
|
||||
|
||||
@on(InterceptionPoint.FLOW_TRANSITION)
|
||||
def capture(ctx):
|
||||
seen.append((ctx.from_method, list(ctx.to_methods)))
|
||||
|
||||
_RouterFlow().kickoff()
|
||||
|
||||
assert any(to == ["left"] for _from, to in seen)
|
||||
|
||||
def test_router_decision_fires_with_route(self):
|
||||
routes: list[object] = []
|
||||
|
||||
@on(InterceptionPoint.ROUTER_DECISION)
|
||||
def capture(ctx):
|
||||
routes.append(ctx.route)
|
||||
|
||||
_RouterFlow().kickoff()
|
||||
assert "go_left" in routes
|
||||
|
||||
def test_router_decision_can_reroute(self):
|
||||
@on(InterceptionPoint.ROUTER_DECISION)
|
||||
def reroute(ctx):
|
||||
return "go_right"
|
||||
|
||||
landed: list[str] = []
|
||||
|
||||
@on(InterceptionPoint.PRE_STEP)
|
||||
def track(ctx):
|
||||
landed.append(ctx.step_name)
|
||||
|
||||
_RouterFlow().kickoff()
|
||||
assert "right" in landed
|
||||
assert "left" not in landed
|
||||
|
||||
Reference in New Issue
Block a user