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:
Lucas Gomide
2026-07-13 15:18:57 -03:00
parent 852a3ec006
commit ece53929b8
16 changed files with 661 additions and 3 deletions

View File

@@ -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"
]
},
{

View File

@@ -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)

View 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)

View File

@@ -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)

View File

@@ -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:

View File

@@ -2629,6 +2629,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.
@@ -2656,6 +2667,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
@@ -2852,6 +2873,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)
@@ -2880,6 +2914,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
)

View File

@@ -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(

View File

@@ -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

View File

@@ -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.

View File

@@ -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,

View File

@@ -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,

View File

@@ -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

View File

@@ -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,

View File

@@ -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,

View File

@@ -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]:

View File

@@ -8,7 +8,7 @@ against these guarantees.
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,
@@ -87,3 +87,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