Skip to content

Commit a194f38

Browse files
authored
feat: wire execution-boundary interception points (#6517)
* feat: wire execution-boundary interception points Adds the typed interception contexts (`crewai/hooks/contexts.py`) and wires the `execution_start`, `input`, `output`, and `execution_end` points for both crews and flows through the dispatcher. `prepare_kickoff` and `Flow.kickoff_async` fire `execution_start`/`input` so a hook can rewrite resolved inputs before the run, while `Crew._create_crew_output` and the flow tail fire `output`/`execution_end` so the final result can be observed or replaced. Closes the eight critical-path points without touching the legacy hooks. * fix: correct execution-boundary hook ordering and input aliasing Reworks the crew and flow boundary seams flagged in review. `OUTPUT` and `EXECUTION_END` now run before the completion event (`CrewKickoffCompletedEvent` and `FlowFinishedEvent`) so a `HookAborted` no longer leaves a spurious completed signal and a returned payload replacement is honored on the emitted and returned result. Boundary contexts alias `inputs` to the same object as `payload` instead of a fresh dict from `or`, so in-place edits survive read-back. Flows re-publish the resolved inputs into `flow_inputs` baggage after the `INPUT` hook so trigger-payload injection observes hook rewrites, and a resumed flow now dispatches `OUTPUT`/`EXECUTION_END` on its completion path. * chore: drop redundant seam comments from execution-boundary wiring Removes two inline comments narrating the OUTPUT/EXECUTION_END dispatch ordering in `crew.py` and the flow runtime, plus a stray sentence about enterprise adapters in the conformance-suite docstring. Comment-only cleanup, no behavior change. * fix: keep crew output typed across boundary hook dispatch `_create_crew_output` reassigned `crew_output` from the hook contexts' `payload`, which is typed `Any`, so mypy flagged `no-any-return` at the function's return. Cast the payload back to `CrewOutput` after each dispatch and split the `ExecutionEndContext` construction to satisfy `ruff format`'s line-length limit.
1 parent 7d21283 commit a194f38

6 files changed

Lines changed: 262 additions & 10 deletions

File tree

lib/crewai/src/crewai/crew.py

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1906,6 +1906,28 @@ def _create_crew_output(self, task_outputs: list[TaskOutput]) -> CrewOutput:
19061906
final_string_output = final_task_output.raw
19071907
self._finish_execution(final_string_output)
19081908
self.token_usage = self.calculate_usage_metrics()
1909+
1910+
from crewai.hooks.contexts import ExecutionEndContext, OutputContext
1911+
from crewai.hooks.dispatch import InterceptionPoint, dispatch
1912+
1913+
crew_output = CrewOutput(
1914+
raw=final_task_output.raw,
1915+
pydantic=final_task_output.pydantic,
1916+
json_dict=final_task_output.json_dict,
1917+
tasks_output=task_outputs,
1918+
token_usage=self.token_usage,
1919+
)
1920+
1921+
output_ctx = OutputContext(crew=self, output=crew_output, payload=crew_output)
1922+
dispatch(InterceptionPoint.OUTPUT, output_ctx)
1923+
crew_output = cast(CrewOutput, output_ctx.payload)
1924+
1925+
end_ctx = ExecutionEndContext(
1926+
crew=self, output=crew_output, payload=crew_output
1927+
)
1928+
dispatch(InterceptionPoint.EXECUTION_END, end_ctx)
1929+
crew_output = cast(CrewOutput, end_ctx.payload)
1930+
19091931
# Ensure background memory saves finish (and emit their
19101932
# completed/failed events) before the kickoff-completed event below
19111933
# triggers listener teardown/finalization.
@@ -1924,13 +1946,7 @@ def _create_crew_output(self, task_outputs: list[TaskOutput]) -> CrewOutput:
19241946
# Finalization is handled by trace listener (always initialized)
19251947
# The batch manager checks contextvar to determine if tracing is enabled
19261948

1927-
return CrewOutput(
1928-
raw=final_task_output.raw,
1929-
pydantic=final_task_output.pydantic,
1930-
json_dict=final_task_output.json_dict,
1931-
tasks_output=task_outputs,
1932-
token_usage=self.token_usage,
1933-
)
1949+
return crew_output
19341950

19351951
def _process_async_tasks(
19361952
self,

lib/crewai/src/crewai/crews/utils.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -278,6 +278,9 @@ def prepare_kickoff(
278278
reset_emission_counter()
279279
reset_last_event_id()
280280

281+
from crewai.hooks.contexts import ExecutionStartContext, InputContext
282+
from crewai.hooks.dispatch import InterceptionPoint, dispatch
283+
281284
normalized: dict[str, Any] | None = None
282285
if inputs is not None:
283286
if not isinstance(inputs, Mapping):
@@ -286,11 +289,30 @@ def prepare_kickoff(
286289
)
287290
normalized = dict(inputs)
288291

292+
# ``inputs`` aliases the same object as ``payload`` (not a fresh ``{}`` from
293+
# ``or``) so in-place edits to either survive read-back, per the context
294+
# contract. ``None`` inputs are preserved rather than coerced to ``{}``.
295+
start_ctx = ExecutionStartContext(
296+
crew=crew,
297+
inputs=normalized if normalized is not None else {},
298+
payload=normalized,
299+
)
300+
dispatch(InterceptionPoint.EXECUTION_START, start_ctx)
301+
normalized = start_ctx.payload
302+
289303
for before_callback in crew.before_kickoff_callbacks:
290304
if normalized is None:
291305
normalized = {}
292306
normalized = before_callback(normalized)
293307

308+
input_ctx = InputContext(
309+
crew=crew,
310+
inputs=normalized if normalized is not None else {},
311+
payload=normalized,
312+
)
313+
dispatch(InterceptionPoint.INPUT, input_ctx)
314+
normalized = input_ctx.payload
315+
294316
if resuming and crew._kickoff_event_id:
295317
if crew.verbose:
296318
from crewai.events.utils.console_formatter import ConsoleFormatter

lib/crewai/src/crewai/flow/runtime/__init__.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1476,6 +1476,19 @@ async def _resume_async_body(self, feedback: str = "") -> Any:
14761476
else (resumed_method_output if emit else result)
14771477
)
14781478

1479+
from crewai.hooks.contexts import ExecutionEndContext, OutputContext
1480+
from crewai.hooks.dispatch import InterceptionPoint, dispatch
1481+
1482+
output_ctx = OutputContext(flow=self, output=final_result, payload=final_result)
1483+
dispatch(InterceptionPoint.OUTPUT, output_ctx)
1484+
final_result = output_ctx.payload
1485+
1486+
end_ctx = ExecutionEndContext(
1487+
flow=self, output=final_result, payload=final_result
1488+
)
1489+
dispatch(InterceptionPoint.EXECUTION_END, end_ctx)
1490+
final_result = end_ctx.payload
1491+
14791492
if self._event_futures:
14801493
await asyncio.gather(
14811494
*[
@@ -2037,6 +2050,9 @@ async def kickoff_async(
20372050
flow_name_token = None
20382051
flow_defer_trace_finalization_token = None
20392052
request_id_token = None
2053+
# Re-published after the INPUT hook so trigger-payload injection reads
2054+
# the hook-rewritten inputs rather than the pre-hook baggage above.
2055+
flow_inputs_token = None
20402056
if current_flow_id.get() is None:
20412057
flow_id_token = current_flow_id.set(self.flow_id)
20422058
flow_name_token = current_flow_name.set(
@@ -2062,6 +2078,37 @@ async def kickoff_async(
20622078
self._attach_usage_aggregation_listener()
20632079

20642080
try:
2081+
from crewai.hooks.contexts import (
2082+
ExecutionEndContext,
2083+
ExecutionStartContext,
2084+
InputContext,
2085+
OutputContext,
2086+
)
2087+
from crewai.hooks.dispatch import InterceptionPoint, dispatch
2088+
2089+
# ``inputs`` aliases the same object as ``payload`` (not a fresh
2090+
# ``{}`` from ``or``) so in-place edits survive read-back.
2091+
start_ctx = ExecutionStartContext(
2092+
flow=self,
2093+
inputs=inputs if inputs is not None else {},
2094+
payload=inputs,
2095+
)
2096+
dispatch(InterceptionPoint.EXECUTION_START, start_ctx)
2097+
inputs = start_ctx.payload
2098+
2099+
input_ctx = InputContext(
2100+
flow=self,
2101+
inputs=inputs if inputs is not None else {},
2102+
payload=inputs,
2103+
)
2104+
dispatch(InterceptionPoint.INPUT, input_ctx)
2105+
inputs = input_ctx.payload
2106+
2107+
# Publish the resolved inputs so trigger-payload injection and other
2108+
# baggage readers observe hook rewrites (the baggage set before the
2109+
# hooks carried the pre-hook inputs).
2110+
flow_inputs_token = attach(baggage.set_baggage("flow_inputs", inputs or {}))
2111+
20652112
# Reset flow state for fresh execution unless restoring from persistence
20662113
is_restoring = (
20672114
inputs and "id" in inputs and self.persistence is not None
@@ -2297,6 +2344,21 @@ async def kickoff_async(
22972344
method_outputs = self.method_outputs
22982345
final_output = method_outputs[-1] if method_outputs else None
22992346

2347+
output_ctx = OutputContext(
2348+
flow=self, output=final_output, payload=final_output
2349+
)
2350+
dispatch(InterceptionPoint.OUTPUT, output_ctx)
2351+
final_output = output_ctx.payload
2352+
2353+
# EXECUTION_END runs before FlowFinishedEvent so a HookAborted
2354+
# prevents a spurious finished signal and payload replacement is
2355+
# honored on the emitted result and the returned value.
2356+
end_ctx = ExecutionEndContext(
2357+
flow=self, output=final_output, payload=final_output
2358+
)
2359+
dispatch(InterceptionPoint.EXECUTION_END, end_ctx)
2360+
final_output = end_ctx.payload
2361+
23002362
if self._event_futures:
23012363
await asyncio.gather(
23022364
*[asyncio.wrap_future(f) for f in self._event_futures]
@@ -2370,6 +2432,8 @@ async def kickoff_async(
23702432
current_flow_name.reset(flow_name_token)
23712433
if flow_id_token is not None:
23722434
current_flow_id.reset(flow_id_token)
2435+
if flow_inputs_token is not None:
2436+
detach(flow_inputs_token)
23732437
detach(flow_token)
23742438
crewai_event_bus._exit_runtime_scope(runtime_scope)
23752439

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
"""Typed contexts for the interception points wired in phases 2-5.
2+
3+
Each context is a dataclass whose fields are nullable and defaulted, so a field
4+
that is not meaningful for a given runtime (e.g. ``agent_role`` inside a flow)
5+
is simply ``None`` rather than an error. Every context exposes a ``payload``
6+
field: the interceptable value a hook may mutate in place or replace by
7+
returning a new value.
8+
9+
The legacy ``pre/post_model_call`` and ``pre/post_tool_call`` points keep using
10+
:class:`~crewai.hooks.llm_hooks.LLMCallHookContext` and
11+
:class:`~crewai.hooks.tool_hooks.ToolCallHookContext` for backwards
12+
compatibility; they are intentionally not redefined here.
13+
"""
14+
15+
from __future__ import annotations
16+
17+
from dataclasses import dataclass, field
18+
from typing import Any
19+
20+
21+
@dataclass
22+
class InterceptionContext:
23+
"""Base context shared by the framework-native interception points."""
24+
25+
payload: Any = None
26+
agent: Any = None
27+
agent_role: str | None = None
28+
task: Any = None
29+
crew: Any = None
30+
flow: Any = None
31+
32+
33+
@dataclass
34+
class ExecutionStartContext(InterceptionContext):
35+
"""``execution_start``: a crew or flow is about to begin. ``payload`` = inputs."""
36+
37+
inputs: dict[str, Any] = field(default_factory=dict)
38+
39+
40+
@dataclass
41+
class InputContext(InterceptionContext):
42+
"""``input``: resolved inputs for an execution. ``payload`` = inputs."""
43+
44+
inputs: dict[str, Any] = field(default_factory=dict)
45+
46+
47+
@dataclass
48+
class OutputContext(InterceptionContext):
49+
"""``output``: final result of a crew or flow. ``payload`` = the output object."""
50+
51+
output: Any = None
52+
53+
54+
@dataclass
55+
class ExecutionEndContext(InterceptionContext):
56+
"""``execution_end``: a crew or flow has finished. ``payload`` = the output object."""
57+
58+
output: Any = None

lib/crewai/src/crewai/hooks/dispatch.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,11 +41,15 @@ class InterceptionPoint(str, Enum):
4141
"""Interception points wired by this layer.
4242
4343
New points are introduced alongside the seams that dispatch them, so the
44-
enum only ever lists points with a live consumer. This layer ships the
45-
model- and tool-call boundaries, which back the legacy
46-
``before/after_llm_call`` and ``before/after_tool_call`` hooks.
44+
enum only ever lists points with a live consumer.
4745
"""
4846

47+
# Execution-level boundaries
48+
EXECUTION_START = "execution_start"
49+
INPUT = "input"
50+
OUTPUT = "output"
51+
EXECUTION_END = "execution_end"
52+
4953
# Model / tool boundaries (legacy-compatible)
5054
PRE_MODEL_CALL = "pre_model_call"
5155
POST_MODEL_CALL = "post_model_call"
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
"""Conformance suite for the framework-native interception points.
2+
3+
For each wired point this suite asserts the shared contract: the probe hook
4+
sees a well-shaped payload, an in-place/returned modification is honored, and a
5+
:class:`HookAborted` interrupts the step.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
from crewai.flow.flow import Flow, listen, start
11+
from crewai.hooks.dispatch import (
12+
HookAborted,
13+
InterceptionPoint,
14+
clear_all,
15+
on,
16+
)
17+
import pytest
18+
19+
20+
@pytest.fixture(autouse=True)
21+
def clear_dispatch_registry():
22+
clear_all()
23+
yield
24+
clear_all()
25+
26+
27+
class _SimpleFlow(Flow):
28+
@start()
29+
def begin(self):
30+
return "begin"
31+
32+
@listen(begin)
33+
def finish(self, _):
34+
return "flow-result"
35+
36+
37+
class TestFlowExecutionBoundaries:
38+
"""execution_start / input / output / execution_end on a flow."""
39+
40+
def test_all_boundary_points_fire_once(self):
41+
fired: list[str] = []
42+
43+
for point in (
44+
InterceptionPoint.EXECUTION_START,
45+
InterceptionPoint.INPUT,
46+
InterceptionPoint.OUTPUT,
47+
InterceptionPoint.EXECUTION_END,
48+
):
49+
50+
@on(point)
51+
def _probe(ctx, _point=point):
52+
fired.append(_point.value)
53+
54+
_SimpleFlow().kickoff(inputs={"seed": 1})
55+
56+
assert fired == [
57+
"execution_start",
58+
"input",
59+
"output",
60+
"execution_end",
61+
]
62+
63+
def test_output_modification_is_honored(self):
64+
@on(InterceptionPoint.OUTPUT)
65+
def rewrite(ctx):
66+
return "intercepted"
67+
68+
result = _SimpleFlow().kickoff()
69+
assert result == "intercepted"
70+
71+
def test_input_payload_carries_inputs(self):
72+
seen: dict = {}
73+
74+
@on(InterceptionPoint.INPUT)
75+
def capture(ctx):
76+
seen.update(ctx.payload or {})
77+
78+
_SimpleFlow().kickoff(inputs={"seed": 42})
79+
assert seen == {"seed": 42}
80+
81+
def test_abort_at_execution_start_interrupts(self):
82+
@on(InterceptionPoint.EXECUTION_START)
83+
def block(ctx):
84+
raise HookAborted(reason="not allowed", source="policy")
85+
86+
with pytest.raises(HookAborted) as exc:
87+
_SimpleFlow().kickoff()
88+
assert exc.value.reason == "not allowed"

0 commit comments

Comments
 (0)