Compare commits

..

18 Commits

Author SHA1 Message Date
Lucas Gomide
191a17391a docs: use /edge prefix for links to edge-only hooks pages
The `/en/learn/...` form still resolves against the default frozen
version, where `step-hooks` and `execution-boundary-hooks` do not exist
yet, so the link checker kept failing. Links now use the explicit
`/edge/en/learn/...` form, matching how `consuming-streams.mdx` linked
to edge-only streaming pages before they were frozen.
2026-07-14 18:16:36 -03:00
Lucas Gomide
6be349f2e7 docs: prefix hook cross-links with /en so they resolve in edge
The new edge-only hooks pages linked each other with versionless paths
like `/learn/step-hooks`, which mintlify resolves against the frozen
default version where those pages do not exist, breaking the CI link
check. Uses the `/en/learn/...` form the other edge pages already use.
2026-07-14 17:48:53 -03:00
Lucas Gomide
b8cb5dcc69 docs: group execution hooks and document all hook contexts
The hooks pages sat flat in the learn nav and only documented the LLM
and tool call contexts, with examples built on the legacy decorators.
Groups them under a collapsible "Execution Hooks" section, adds
`step-hooks` and `execution-boundary-hooks` pages covering every hook
context from source, reworks the LLM and tool pages to lead with `@on`
while keeping the decorators, and links the orphaned
`before-and-after-kickoff-hooks` page into the group.
2026-07-14 17:09:17 -03:00
Lucas Gomide
0e5d0ecfb9 feat: add step interception points and rework execution hooks docs around @on (#6518)
Some checks are pending
Build uv cache / build-cache (3.10) (push) Waiting to run
Build uv cache / build-cache (3.11) (push) Waiting to run
Build uv cache / build-cache (3.12) (push) Waiting to run
Build uv cache / build-cache (3.13) (push) Waiting to run
CodeQL Advanced / Analyze (actions) (push) Waiting to run
CodeQL Advanced / Analyze (python) (push) Waiting to run
Check Documentation Broken Links / Check broken links (push) Waiting to run
Vulnerability Scan / pip-audit (push) Waiting to run
* feat: add pre_step and post_step interception points on task execution

Introduces `StepContext` and the two step points in the dispatcher, and
wires them around agent execution in `task.py` (sync and async paths):
`pre_step` fires after `TaskStartedEvent` with the task context as
payload, `post_step` fires before `TaskCompletedEvent` with the
`TaskOutput`, and hook replacements are rebound in both directions.

* feat: wire pre_step and post_step on flow method execution

Dispatches the step points around each flow method with
kind="flow_method": `pre_step` receives the dumped call params and maps
returned edits back onto args/kwargs, `post_step` can rewrite the method
result before it is recorded. Conformance tests cover per-method firing
and output rewriting.

* docs: rework execution hooks page around the @on api

Replaces the standalone interception hooks catalog with a single
`execution-hooks.mdx` page that teaches `@on` as the primary way to
write hooks, covering the full ten-point catalog across task, flow, and
LLM execution. The legacy per-point decorators stay documented in a
closing section, and the `docs.json` navigation drops the removed page.
2026-07-14 14:47:18 -04:00
Lucas Gomide
a194f3867a 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.
2026-07-14 10:43:18 -04:00
Lucas Gomide
7d21283630 feat: add generic interception-hook dispatcher (#6516)
* feat: add generic interception-hook dispatcher

Introduces `crewai/hooks/dispatch.py` as a single engine behind every
interception point: a hook receives a typed context, may mutate or replace
its `payload`, or raise `HookAborted(reason, source)` to stop the operation.
The full `InterceptionPoint` catalog is frozen from day zero, with global and
contextvar-scoped registries, an `@on` decorator, a no-op fast path, and a
`HookDispatchedEvent` for telemetry. The four existing `before/after_llm_call`
and `before/after_tool_call` hooks become adapters over the dispatcher, so the
legacy dialect and `return False` semantics keep working unchanged while
gaining the new contract.

* fix: harden interception dispatcher against review findings

Corrects several dispatcher edge cases surfaced in review. `_default_reducer`
now reports a modification only when a `payload` is actually applied, the
`agents=` filter falls back to `agent_role` for contexts without an `agent`
object, and `unregister` resolves the filter wrapper stashed by `on` so a
filtered hook can be removed. The tool-hook runners honor the executing
agent's `verbose` flag instead of silently swallowing hook errors, and the
ReAct tool path now runs `POST_TOOL_CALL` on blocked calls to match the
native paths. Also adds abort-telemetry coverage and replaces the flaky
absolute no-op timing budget with a relative one.

* fix: honor scoped hooks on direct llm calls and register @on crew methods

Direct agent-less LLM calls short-circuited on the empty global hook list,
so hooks registered only for the current `scoped_hooks()` context never
ran; the direct-call helpers now defer to `dispatch`, which resolves
scoped hooks behind its own no-op fast path. `CrewBase` likewise only
scanned the legacy `is_*_hook` markers, so `@on(InterceptionPoint.X)`
methods were silently dropped — it now registers them on the dispatcher
with filters applied and `self` bound. Also tightens result typing across
the tool-call seams so `mypy` stays green.

* refactor: scope InterceptionPoint to the points this layer wires

The dispatcher only fires the model- and tool-call boundaries, so
`InterceptionPoint` now lists just those four rather than the full future
catalog. New points are introduced alongside the seams that dispatch them,
keeping every layer free of enum members with no live consumer. The
dispatcher unit tests that borrowed unused points as generic examples are
remapped onto the four kept points.

* test: pin per-hook fail-open at the LLM and tool seams

The dispatcher swallows a hook's exception per hook rather than around the
whole loop, so one buggy hook no longer silently skips every hook registered
after it. These seam-level tests pin that behavior through
`_setup_before_llm_call_hooks` and `run_before/after_tool_call_hooks`, and
confirm an intentional `return False` block still short-circuits later hooks.

* fix: run execution-scoped hooks on the agent executor model seams

`_setup_before/after_llm_call_hooks` only ran the executor's snapshot
hook lists, so hooks registered via `scoped_hooks()` never fired on
`PRE/POST_MODEL_CALL` during normal agent execution, while the tool
seams (which go through `dispatch`) merged them. The seams now append
the current scope's hooks after the snapshot via `get_scoped_hooks`,
matching dispatch's global-then-scoped ordering, and a scoped-only
registration no longer short-circuits the seam.
2026-07-14 10:27:34 -04:00
Lucas Gomide
6452608724 fix: after_llm_call hooks no longer break native tool execution (#6531)
* fix: don't clobber native tool-call responses in after-LLM hooks

Registering any `after_llm_call` hook broke native tool execution: the
executor invokes `_setup_after_llm_call_hooks` on the intermediate
response that carries the model's tool calls, the non-str payload was
stringified for the response-rewrite pass, and the executor then treated
that string as a final answer instead of executing the tools. Structured
payloads (neither `str` nor `BaseModel`) now pass through untouched,
mirroring the isinstance guard `_invoke_after_llm_call_hooks` already
applies on the direct-call path; hooks still fire on the follow-up
textual response.

Fixes #6529

* style: shorten the tool-call guard comment
2026-07-14 09:49:21 -04:00
Lucas Gomide
5f4ac9f407 chore: resolve pip-audit failures for click, pillow, and json-repair (#6542)
The vulnerability scan started failing when PYSEC-2026-2132 (click) and
PYSEC-2026-2253..2257 (pillow) were published on Jul 12. Both have fixed
releases within our constraints, so `uv.lock` upgrades click to 8.4.2 and
pillow to 12.3.0. A newer json-repair advisory (GHSA-xf7x-x43h-rpqh) also
surfaced; its fix is outside the `json-repair~=0.25.2` pin and 0.25.x lacks
the vulnerable `schema_repair` module, so it joins the ignore list in
`vulnerability-scan.yml` with a justification.
2026-07-14 08:34:56 -04:00
Vinicius Brasil
9d72e269e4 Remove redundant CEL text helper (#6528)
Some checks failed
CodeQL Advanced / Analyze (actions) (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Vulnerability Scan / pip-audit (push) Has been cancelled
Nightly Canary Release / Check for new commits (push) Has been cancelled
Nightly Canary Release / Build nightly packages (push) Has been cancelled
Nightly Canary Release / Publish nightly to PyPI (push) Has been cancelled
Mark stale issues and pull requests / stale (push) Has been cancelled
This commit removes the redundant CEL text helper, in favor of the
easier interpolation syntax.
2026-07-13 14:29:36 -04:00
João Moura
fb8e93be25 fix(flow): don't double-append the turn reply when a handler trims history (#6510)
Some checks failed
CodeQL Advanced / Analyze (actions) (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Vulnerability Scan / pip-audit (push) Has been cancelled
Mark stale issues and pull requests / stale (push) Has been cancelled
Nightly Canary Release / Check for new commits (push) Has been cancelled
Nightly Canary Release / Build nightly packages (push) Has been cancelled
Nightly Canary Release / Publish nightly to PyPI (push) Has been cancelled
handle_turn() (and stream_turn) decided "did the handler append its
reply?" by snapshotting the assistant-message count before kickoff and
appending the stringified result when the count came back unchanged. A
handler that appends its reply and then trims state.messages to a cap —
a normal bounded-context pattern — left the count unchanged, so the
fallback appended the reply a second time on every turn once trimming
engaged, and the duplicates then crowded real turns out of the capped
window.

Replace the count heuristic with an explicit per-turn flag:
append_assistant_message() sets _assistant_reply_appended, handle_turn
and stream_turn clear it before kickoff and only fall back when no
assistant message was appended during the turn. The now-unused
_assistant_message_count() helper is removed.

Fixes EPD-181.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 20:01:07 -07:00
João Moura
4fdb7f2bfb fix(tools)!: make tool-result caching opt-in instead of on by default (#6509)
Some checks failed
CodeQL Advanced / Analyze (actions) (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Vulnerability Scan / pip-audit (push) Has been cancelled
Nightly Canary Release / Check for new commits (push) Has been cancelled
Nightly Canary Release / Build nightly packages (push) Has been cancelled
Nightly Canary Release / Publish nightly to PyPI (push) Has been cancelled
* fix(tools)!: make tool-result caching opt-in instead of on by default

Tool-result caching defaulted to on (Crew.cache=True, and standalone
agents self-wired a CacheHandler at construction), so an LLM calling
the same tool with identical arguments twice in one run silently got
the first result back without the tool executing. For live-data tools
that is a confidently stale answer; for state-mutating tools the second
action is silently dropped.

Caching is now opt-in with the machinery unchanged:
- Crew.cache defaults to False; Crew(cache=True) restores today's
  behavior exactly (agents still default to participating when a crew
  offers its handler, and Agent(cache=False) still opts an agent out).
- Standalone agents no longer self-wire a cache; Agent(cache=True) or
  an explicit cache_handler opts in. Previously even Crew(cache=False)
  agents cached via this self-wired handler.
- Per-tool cache_function write gating is unchanged once opted in.

Existing tests that exercised the caching machinery now opt in
explicitly; new regression tests cover the default (both identical
calls execute), crew-level opt-in dedup, and agent-level wiring.

Fixes EPD-180.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(agent): don't let copy() turn the cache default into an explicit opt-in

Agent.copy() rebuilds from model_dump(), which includes the field
default cache=True, so the copy's model_fields_set contained "cache"
and _setup_agent_executor wired a CacheHandler the source agent never
opted into (Bugbot review finding). Drop "cache" from the dump when it
was not explicitly set on the source; explicit opt-ins still survive
copying.

Also sync the Crew and BaseAgent class docstrings with the new opt-in
cache semantics (CodeRabbit review findings).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(agent): preserve cache_handler-only opt-in across Agent.copy()

copy() excludes cache_handler from the rebuilt agent, so an agent that
opted into tool-result caching solely via an explicit cache_handler
lost caching after copy() (Bugbot review finding). Carry the consent as
cache=True on the copy when the source has a handler wired and hasn't
explicitly disabled caching — the copy wires its own fresh handler,
matching pre-change copy semantics (copies never shared the source's
handler instance).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(crew): offer the crew cache handler to the hierarchical manager

The hierarchical manager agent is created in _create_manager_agent,
outside the validation-time agents loop that offers the crew's cache
handler — and managers no longer self-wire a handler — so
Crew(cache=True) hierarchical runs never cached the manager's
delegation tool calls (Bugbot review finding). Offer the shared crew
handler when the crew opted in; a user-provided manager with
cache=False stays excluded via the existing set_cache_handler gate.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(agent): only construction-time cache opt-ins survive Agent.copy()

The previous copy() fix treated any wired cache_handler as consent, but
agents that merely received the crew's shared handler at kickoff
(set_cache_handler from Crew(cache=True)) never opted in themselves —
their copies must not become standalone cachers (Bugbot review
finding). Record the opt-in signal in _setup_agent_executor, which runs
at construction before any crew wiring can happen, and have copy()
consult that flag instead of inspecting cache_handler after the fact.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 17:37:36 -07:00
João Moura
bfa652a7be fix(tools): stop rewriting the authored tool description at construction (#6508)
* fix(tools): stop rewriting the authored tool description at construction

BaseTool.model_post_init silently replaced the public description field
with the LLM-facing composite ("Tool Name: ...\nTool Arguments: ...\n
Tool Description: <authored>"), breaking equality assertions on authored
text and hiding the extra prompt tokens from token-careful authors.

The authored description now survives construction as written. The
composite is composed on demand via a new formatted_description property
on BaseTool and CrewStructuredTool (shared format_description_for_llm
helper), and every prompt path that relied on the baked-in composite —
render_text_description_and_args, ToolUsage._render, and tool-usage
error messages — now renders through it, so the text the LLM sees is
unchanged.

The helper strips any pre-existing composite block before composing, so
tools deserialized from old checkpoints and adapters that still bake the
composite into the field (e.g. the crewai-tools MCP adapter) don't get
double-wrapped. BaseTool._generate_description remains as a no-op hook
because subclasses override it and model_post_init still calls it.

Fixes EPD-179.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(tools): harden composite-description handling after review

- Anchor the pre-baked-composite check to the actual three-line block
  shape instead of a naive substring match, so authored prose that
  merely mentions "Tool Description:" is never truncated (CodeRabbit /
  Bugbot review finding). Shared as
  strip_composite_description_prefix() and reused by the function-
  calling schema builder, which had the same naive split.
- Make render_text_description_and_args tolerate duck-typed tools
  without a real formatted_description string (fixes CI: step-executor
  tests pass Mock tools whose auto-created attribute is not a str).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Lorenze Jay <63378463+lorenzejay@users.noreply.github.com>
2026-07-10 20:33:57 -03:00
João Moura
b65c8487d2 fix(output): expose token usage under both names on agent and crew results (#6507)
Agent.kickoff() returned LiteAgentOutput with a plain dict at
.usage_metrics and no token_usage attribute, while Crew.kickoff()
returned CrewOutput with a UsageMetrics object at .token_usage and no
usage_metrics attribute — so a usage accessor written for one path
raised AttributeError on the other, and every consumer had to
duck-type both shapes.

Give both result types both surfaces, each name with one consistent
shape everywhere: .token_usage is a UsageMetrics object and
.usage_metrics is a plain dict, on both LiteAgentOutput and CrewOutput.
Added as read-only properties, so existing fields, serialization, and
constructors are unchanged.

Fixes EPD-178.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 20:29:34 -03:00
João Moura
a8b3ecb723 fix(agent): report per-call usage metrics on kickoff results (#6506)
* fix(agent): report per-call usage metrics on kickoff results

Agent.kickoff() populated result.usage_metrics from the LLM instance's
lifetime token accumulator, so counts grew across calls and pooled
across agents sharing one LLM object — a second agent's first turn
appeared to cost the whole preceding session.

Snapshot the accumulator when a kickoff starts and report the delta on
the result (guardrail retries included), via the new
UsageMetrics.delta_since(). The LLM instance's cumulative counters are
untouched: get_token_usage_summary() keeps lifetime totals for
crew-level aggregation, and its docstring now states that scope
explicitly. Applies to both Agent and the deprecated LiteAgent, sync
and async paths.

Fixes EPD-177.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(agent): drop lite_agent.py diff, add guardrail-retry usage test

Per review: LiteAgent's kickoff path is no longer used, so the per-call
usage snapshot only needs to live in agent/core.py — revert the
lite_agent.py changes entirely. This also removes the duplicated
_current_usage_summary helper and the instance-attr baseline CodeRabbit
flagged.

Add the requested guardrail-retry regression test: a guardrail that
rejects the first attempt and accepts the second must yield
usage_metrics covering both attempts (2x a single-attempt kickoff).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 14:17:45 -07:00
João Moura
7967b19057 fix(flow): stop replaying previous turn's intent when route_turn() returns falsy (#6505)
In conversational flows, a falsy return from an overridden route_turn()
fell back to the sticky state.last_intent from a previous turn, silently
re-running the prior turn's handler for an unhandled input.

The fallback exists for the legacy default_intents path, where
receive_user_message() classifies the intent fresh each turn. Track that
per-turn classification in _turn_classified_intent (cleared on every turn
reset) and route on it instead, so a falsy route_turn() now falls through
to the built-in answer_from_history/converse defaults and never reuses
stale routing state.

Fixes EPD-176.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Lorenze Jay <63378463+lorenzejay@users.noreply.github.com>
2026-07-10 11:44:57 -07:00
João Moura
85c467dfe2 feat(cli): run declarative flows on the TUI (headless terminal fallback) (#6484)
Some checks failed
CodeQL Advanced / Analyze (actions) (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Vulnerability Scan / pip-audit (push) Has been cancelled
Build uv cache / build-cache (3.10) (push) Has been cancelled
Build uv cache / build-cache (3.11) (push) Has been cancelled
Build uv cache / build-cache (3.12) (push) Has been cancelled
Build uv cache / build-cache (3.13) (push) Has been cancelled
* feat(cli): run declarative flows on the TUI with a headless terminal fallback

Declarative flows now run on the CrewRunApp TUI when interactive, matching
declarative crews and conversational flows. Headless contexts — CREWAI_DMN
(deploy), piped output, CI, any non-TTY — fall back to the direct-terminal
kickoff, gated by is_interactive() (folds in the CREWAI_DMN check and requires
a real TTY).

The TUI shows per-method progress: a new STEPS panel driven by flow method
events (FlowStarted / MethodExecutionStarted/Finished/Failed), each labeled
with its declarative call type (crew/agent/expression/…) read from the flow
definition. Crews/agents inside a method keep streaming in the main panel via
the existing crew/task/LLM handlers.

- crew_run_tui.py: _run_flow_worker (flow.kickoff in a thread worker; reuses
  _on_crew_done/_on_crew_failed + _stringify_output), _is_flow_run gate so crew
  rendering is byte-identical, flow-event subscriptions building _flow_steps,
  and the STEPS sidebar + flow-aware header.
- run_declarative_flow.py: is_interactive() branch → _run_declarative_flow_tui
  (EventListener, method-type map from flow._definition, crew-parity exit codes
  and deploy chaining) or the existing terminal path.

Deviation from the approved plan: gate on is_interactive() rather than
is_dmn_mode_enabled() alone, so non-TTY runs (CI/pipes/CliRunner) never launch
a TUI — this also keeps existing headless flow tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RBYGqJHC2TMC6fonFziuuh

* fix(cli): force flow events on for the TUI so STEPS renders under suppress_flow_events

Review follow-up: the STEPS panel and header are driven by flow method events
(FlowStarted / MethodExecution*), but the declarative runtime skips emitting
those when the flow declared config.suppress_flow_events. Interactive TUI runs
would then keep STEPS on "waiting…" and the header on "Starting flow…" while
nested crews still execute.

_run_declarative_flow_tui now forces flow.suppress_flow_events = False for the
interactive run (mirroring how the conversational path mutates the flow for the
TUI). The headless/terminal path never reaches this and keeps the flow's
declared setting. Regression test: test_run_declarative_flow_tui_enables_flow_events.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RBYGqJHC2TMC6fonFziuuh

* fix(cli): clear flow header's current method when a method ends

Review follow-up: the flow header keys off _current_method, which was set on
MethodExecutionStarted but never cleared on Finished/Failed. Between steps (or
after a failed method before kickoff exits) the header kept spinning the old
method name while the STEPS sidebar already showed it done/failed.

_clear_current_method now drops the header's active method when it ends,
falling back to another still-active step (methods can overlap) or none. The
header's idle fallback shows "Working…" once a step has run and "Starting
flow…" only before the first method.

Tests: test_current_method_clears_and_falls_back_across_overlap, plus a
_current_method assertion in test_flow_method_events_build_steps.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RBYGqJHC2TMC6fonFziuuh

* fix: suppress flow console panels in TUI mode; clear header agent on method change

Two review follow-ups:

1) Method panels break Textual TUI (Cursor): forcing suppress_flow_events off
   so the STEPS panel receives events also un-gated the EventListener's Rich
   flow/method panels (ConsoleFormatter.print_panel prints is_flow=True panels
   regardless of verbose), which interleave with Textual and corrupt the TUI.
   print_panel now skips is_flow panels when is_tui_mode() is set (the same
   context the TUI worker already establishes and the tracing listeners already
   honor). Non-TUI/headless flow runs are unaffected. Test:
   test_console_formatter_tui_mode.

2) Flow header showed a stale agent (CodeRabbit): _current_agent persisted
   across methods. It's now cleared when a method starts and when the active
   method changes, so the header never shows the previous method's agent until
   a new agent event arrives. Test: test_flow_method_transitions_clear_current_agent.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RBYGqJHC2TMC6fonFziuuh

* fix(cli): keep flow name over nested crews; show paused flow methods

Two review follow-ups on the flow TUI:

1) Crew kickoff renamed the flow (Cursor): CrewKickoffStartedEvent overwrote
   _crew_name / the app title with a nested `call: crew` step's crew name, so
   the post-run summary could be labeled with a child crew. The rename is now
   gated on `not _is_flow_run`, preserving the flow's name; crew runs still
   adopt the crew name. Tests: test_crew_kickoff_does_not_rename_flow_run,
   test_crew_kickoff_renames_in_crew_mode.

2) Paused methods showed active (Cursor): the TUI didn't handle
   MethodExecutionPausedEvent, so a @human_feedback pause left the STEPS
   spinner running (flow status panels are suppressed in TUI mode). It now
   marks the step "paused" (⏸, teal) and the header shows "waiting for
   feedback" instead of a spinner. Test: test_method_paused_marks_step_paused.

Note: interactively *providing* human feedback from the flow TUI is a separate
follow-up; this only makes the pause visible instead of a silent stuck spinner.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RBYGqJHC2TMC6fonFziuuh

* fix(cli): run human-feedback declarative flows on the terminal, not the TUI

Two review follow-ups, both rooted in @human_feedback methods:

- Paused flow marked complete (Cursor): async human feedback makes kickoff
  RETURN a HumanFeedbackPending marker (not raise), which _run_flow_worker
  would stringify and report as a successful completion with exit 0.
- Sync feedback breaks TUI (Cursor): default (sync) @human_feedback collects
  input via the flow runtime's Rich console.print + blocking input(), which
  interleaves with Textual and leaves the user unable to review output or
  submit feedback.

run_declarative_flow now routes any flow whose declarative definition declares
human feedback (_flow_uses_human_feedback) to the terminal path, where blocking
input and Rich prompts work natively — regardless of interactivity. Non-feedback
flows still get the TUI. Tests: test_flow_uses_human_feedback_detection,
test_human_feedback_flow_uses_terminal_even_when_interactive.

Fully interactive human feedback inside the TUI remains a separate follow-up.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RBYGqJHC2TMC6fonFziuuh

* refactor(cli): address review — Flow typing, debug logging, flow-vs-crew naming

Review follow-ups from @lucasgomide:

- Type flow helpers as Flow[Any] (via TYPE_CHECKING import) instead of Any and
  drop the defensive getattr chains — _definition is a typed PrivateAttr and
  name/suppress_flow_events are typed fields, so attribute access is safe.
- Replace the silent `except Exception: pass` blocks with logger.debug(...,
  exc_info=True) so unexpected failures are diagnosable in the field
  (_flow_method_types, _flow_uses_human_feedback, suppress_flow_events toggle).
- Flow-vs-crew naming: the flow worker now uses group="flow" (was the
  misleading "crew"), and the shared completion/failure handlers report the
  run with an entity-aware noun ("flow" vs "crew") via _run_noun.

Deferred (separate PR): the os._exit(130) hard-kill on user quit is kept as-is
to match the existing crew convention (run_crew._run_json_crew).

Tests: test_flow_done_uses_flow_wording_for_unfinished_tool; existing crew
wording tests unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RBYGqJHC2TMC6fonFziuuh

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 11:42:12 -03:00
Lorenze Jay
7baf8f9ba1 improving custom OpenAI urls (#6490)
Some checks failed
CodeQL Advanced / Analyze (actions) (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Vulnerability Scan / pip-audit (push) Has been cancelled
Check Documentation Broken Links / Check broken links (push) Has been cancelled
Nightly Canary Release / Check for new commits (push) Has been cancelled
Nightly Canary Release / Build nightly packages (push) Has been cancelled
Nightly Canary Release / Publish nightly to PyPI (push) Has been cancelled
Mark stale issues and pull requests / stale (push) Has been cancelled
* Support legacy OpenAI base URL env var

* Add custom OpenAI-compatible endpoint support

* Refactor OpenAI completion module test to restore original module state

- Added logic to save and restore the original OpenAI completion module during the test to prevent issues with class re-imports affecting subsequent tests.
- Ensured that the test checks for the presence of the module and its attributes only after the module is properly reloaded.
- Improved test reliability by avoiding potential failures due to module state changes across tests.

* addressing comments
2026-07-09 15:30:16 -07:00
Lucas Gomide
860817cbcd Drain memory writes before kickoff and flow completion events (#6497)
* fix: drain memory writes before kickoff and flow completion events

Background memory saves from the final task could still be in flight
when `CrewKickoffCompletedEvent`/`FlowFinishedEvent` fired, so telemetry
listeners tore down before `MemorySaveCompletedEvent` arrived and the
save span surfaced as "Span orphaned" errors in traces despite the
record persisting. `Crew` now drains all pending saves — including
per-agent `agent.memory` pools, which the old `finally`-only drain
missed entirely — before emitting the completion event, with the same
ordering applied to both `FlowFinishedEvent` emit paths in the flow
runtime.

* fix: address review findings on the memory drain paths

Bugbot and CodeRabbit flagged gaps in the drain coverage: the
hierarchical `manager_agent` memory pool was never drained,
`Crew.akickoff` lacked the exception-path safety net that sync
`kickoff` has, and `finalize_session_traces` emitted the deferred
session-end `FlowFinishedEvent` without draining first. Also offloads
the pre-emit drains in the flow runtime to `asyncio.to_thread` so the
blocking wait doesn't stall other coroutines sharing the event loop.

* fix: flush event bus after memory drain in flow completion paths

Bugbot flagged that flow paths went straight from the memory drain to
`FlowFinishedEvent`, while crew kickoff flushes the bus in between.
Save completion events emitted during the drain could still have
pending async handlers when flow-finished triggered trace teardown.
Adds a `crewai_event_bus.flush()` after the drain at both flow runtime
emit sites and in `finalize_session_traces`, mirroring
`Crew._create_crew_output`.
2026-07-09 15:01:14 -04:00
64 changed files with 5523 additions and 1858 deletions

View File

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

View File

@@ -1,117 +0,0 @@
---
title: "Deployment Sizing"
description: Choose the right deployment size for your crew workloads — and know when to scale up.
---
## Overview
Every CrewAI Enterprise deployment runs on a fixed resource tier called an **instance size**. The size controls how much CPU, memory, and — most importantly — how many crew runs can execute simultaneously. Choosing the wrong size is the most common cause of queue build-up, slow run starts, and OOMKilled pods.
This page explains what each size provides, how to read the signals that you've outgrown your current tier, and how to right-size for your workload.
---
## Instance Sizes
| # | Name | vCPU | Memory | Max Concurrent Runs | Storage |
|---|------|------|--------|---------------------|---------|
| 1 | Small | 1 | 2 GiB | 4 | 20 GiB |
| 2 | Regular | 2 | 4 GiB | 16 | 20 GiB |
| 3 | Large | 4 | 8 GiB | 32 | 20 GiB |
| 4 | Extra Large | 8 | 16 GiB | 64 | 100 GiB |
| 5 | Extra Extra Large | 16 | 32 GiB | 128 | 100 GiB |
| 6 | Insane Large | 32 | 64 GiB | 256 | 100 GiB |
**vCPU** and **Memory** are the total resources allocated to the deployment (web server + workers + Redis combined).
**Max Concurrent Runs** is the worker concurrency limit — the number of crew runs that can be actively executing at the same time. Runs submitted beyond this limit are queued and wait for a slot to open.
<Note>
Concurrency is per-deployment, not per-crew. If you have 10 crews deployed on a Small instance, all 10 share the same pool of 4 concurrent run slots.
</Note>
---
## What "concurrent runs" actually means
A **concurrent run** is one active kickoff of a crew — from the moment it starts executing until it completes or errors. It does not mean the number of agents running in parallel inside a single crew (that's controlled by your crew's process type and agent configuration).
**Example:** A Small deployment (concurrency = 4) with 20 incoming run requests will execute 4 runs simultaneously and queue the remaining 16. Each queued run starts as soon as a slot frees up.
---
## Symptoms of an undersized deployment
| Symptom | Likely cause |
|---------|-------------|
| Runs sit in `queued` state for a long time | Concurrency limit reached — all worker slots are occupied |
| Runs complete slowly even for simple tasks | CPU throttling — workers are competing for the same vCPU budget |
| Pods restart with `OOMKilled` | Memory limit exceeded — reduce concurrency or upgrade size |
| Builds fail or time out | Insufficient CPU/memory for the BuildKit image build step |
| High p95/p99 run latency with normal p50 | Bursty traffic hitting the concurrency ceiling |
---
## How to choose a size
### Start with your concurrency requirement
Estimate the peak number of crew runs you expect to have in-flight simultaneously. Add ~25% headroom for bursts.
| Peak concurrent runs | Recommended size |
|----------------------|-----------------|
| 13 | Small |
| 412 | Regular |
| 1325 | Large |
| 2650 | Extra Large |
| 51100 | Extra Extra Large |
| 100+ | Insane Large |
### Factor in run duration
Long-running crews (minutes to hours) hold concurrency slots for the full duration. If your crews run for 10 minutes on average and you receive 30 runs per hour, you need at least `30 × (10/60) = 5` concurrent slots — Regular or above.
### Factor in memory per run
Each concurrent run consumes memory proportional to the number of agents, the size of context windows, and any in-memory data processing. If individual runs are memory-heavy (large document processing, many parallel agents), size up even if your concurrency requirement is low.
A rough heuristic: assume **~256 MiB per concurrent run** as a baseline, then add overhead for your specific workload. On a Small instance (2 GiB total, shared with web and Redis), you have roughly 1 GiB available for workers — enough for ~4 lightweight runs, which matches the concurrency limit.
---
## Changing your deployment size
Deployment size is configurable from the **Admin Panel → Deployments → [your deployment] → Instance Size**. Changes take effect on the next deployment cycle (a rolling restart of the worker pods).
<Warning>
Downsizing a deployment that is actively processing runs will cause in-flight runs to be interrupted when the old pods are replaced. Schedule size changes during low-traffic windows.
</Warning>
---
## Monitoring utilization
Use these signals to track whether your current size is appropriate:
```bash
# Check current pod resource usage
kubectl top pods
# Watch for OOMKilled restarts
kubectl get pods -o wide
kubectl describe pod <worker-pod-name> | grep -A5 "Last State"
# Check worker queue depth (from a web pod)
kubectl exec -it deploy/crewai-web -- bin/rails runner \
"puts Sidekiq::Queue.all.map { |q| \"#{q.name}: #{q.size}\" }.join(\"\\n\")"
```
A consistently non-zero queue depth on the default queue is the clearest signal that you need more concurrency (a larger instance size).
---
## Related
- [Troubleshooting](/troubleshooting) — OOMKilled, pod restarts, build failures
- [Factory Health & Debug](/factory-health) — health check endpoint and component status
- [Aurora Instance Sizing](/deployment-guides/aws-workos-wharf-studio#aurora-instance-sizing) — database sizing to match your deployment tier

View File

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

View File

@@ -144,6 +144,18 @@ In this section, you'll find detailed examples that help you select, configure,
)
```
**Custom OpenAI-Compatible Endpoint:**
```python Code
from crewai import LLM
llm = LLM(
model="anthropic/claude-sonnet-4-6",
custom_openai=True,
base_url="https://your-gateway.example.com/v1",
api_key="your-gateway-api-key",
)
```
**Advanced Configuration:**
```python Code
from crewai import LLM

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

View File

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

View File

@@ -240,14 +240,15 @@ from crewai import LLM
# After (OpenAI-compatible mode, no LiteLLM needed):
llm = LLM(
model="openai/llama3",
model="llama3",
custom_openai=True,
base_url="http://localhost:11434/v1",
api_key="ollama" # Ollama doesn't require a real API key
)
```
<Tip>
Many local inference servers (Ollama, vLLM, LM Studio, llama.cpp) expose an OpenAI-compatible API. You can use the `openai/` prefix with a custom `base_url` to connect to any of them natively.
Many local inference servers (Ollama, vLLM, LM Studio, llama.cpp) expose an OpenAI-compatible API. You can use `custom_openai=True` with a custom `base_url` to connect to any of them natively while keeping the model ID your gateway expects.
</Tip>
### Step 4: Update your YAML configs
@@ -295,6 +296,92 @@ crewai run
uv run pytest
```
## Custom OpenAI-Compatible Endpoints
Many providers and local servers (Ollama, vLLM, LM Studio, llama.cpp, LiteLLM proxies, and hosted gateways) expose an **OpenAI-compatible** API. Instead of routing these through LiteLLM, you can talk to them directly with CrewAI's native OpenAI integration by setting `custom_openai=True`.
This is the recommended replacement for any LiteLLM provider that offers an OpenAI-compatible endpoint.
### How it works
- `custom_openai=True` forces CrewAI to use the native OpenAI SDK, regardless of the model name.
- The model ID is passed to the endpoint without validation against OpenAI's known-model list. This lets you use arbitrary model IDs your gateway expects (for example, `anthropic/claude-sonnet-4-6` served behind an OpenAI-compatible proxy). An optional leading `openai/` routing prefix is stripped.
- A base URL is **required**. CrewAI resolves it, in order, from:
1. `base_url=...`
2. `api_base=...`
3. `OPENAI_BASE_URL` environment variable
4. `OPENAI_API_BASE` environment variable (legacy)
If none are set, CrewAI raises a `ValueError` so misconfiguration fails fast instead of silently hitting `api.openai.com`.
```python
from crewai import LLM
llm = LLM(
model="anthropic/claude-sonnet-4-6", # passed through as-is
custom_openai=True,
base_url="https://your-gateway.example/v1",
api_key="your-key",
)
```
### Connect to common servers
<Tabs>
<Tab title="Ollama">
```python
from crewai import LLM
llm = LLM(
model="llama3.2:latest",
custom_openai=True,
base_url="http://localhost:11434/v1",
api_key="ollama", # Ollama ignores it, but the client requires a value
)
```
</Tab>
<Tab title="vLLM">
```python
from crewai import LLM
llm = LLM(
model="meta-llama/Meta-Llama-3.1-8B-Instruct",
custom_openai=True,
base_url="http://localhost:8000/v1",
api_key="not-needed",
)
```
</Tab>
<Tab title="LM Studio">
```python
from crewai import LLM
llm = LLM(
model="your-loaded-model",
custom_openai=True,
base_url="http://localhost:1234/v1",
api_key="lm-studio",
)
```
</Tab>
<Tab title="Env vars">
```bash
export OPENAI_BASE_URL="https://your-gateway.example/v1"
export OPENAI_API_KEY="your-key"
```
```python
from crewai import LLM
# base_url is picked up from OPENAI_BASE_URL / OPENAI_API_BASE
llm = LLM(model="anthropic/claude-sonnet-4-6", custom_openai=True)
```
</Tab>
</Tabs>
<Tip>
If you use the `openai/` prefix with a model that isn't a known OpenAI model and pass `base_url` or `api_base` directly, CrewAI automatically treats it as a custom OpenAI-compatible endpoint. Environment variables alone do not enable automatic routing for unknown models; set `custom_openai=True` when configuring the endpoint through `OPENAI_BASE_URL` or `OPENAI_API_BASE`.
</Tip>
## Quick Reference: Model String Mapping
Here are common migration paths from LiteLLM-dependent providers to native ones:
@@ -321,7 +408,8 @@ llm = LLM(model="anthropic/claude-sonnet-4-20250514") # High quality
# Ollama → OpenAI-compatible (keep using local models)
# llm = LLM(model="ollama/llama3")
llm = LLM(
model="openai/llama3",
model="llama3",
custom_openai=True,
base_url="http://localhost:11434/v1",
api_key="ollama"
)
@@ -349,6 +437,9 @@ llm = LLM(
<Accordion title="What about environment variables like OPENAI_API_KEY?">
Native providers use the same environment variables you're already familiar with. No changes needed for `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, `GEMINI_API_KEY`, etc.
</Accordion>
<Accordion title="How do I connect to Groq, Together AI, or other OpenAI-compatible providers without LiteLLM?">
Most of these providers expose an OpenAI-compatible API. Use `custom_openai=True` with their base URL and API key — see [Custom OpenAI-Compatible Endpoints](#custom-openai-compatible-endpoints). For example, Groq: `LLM(model="llama-3.1-70b-versatile", custom_openai=True, base_url="https://api.groq.com/openai/v1", api_key="...")`. The model ID is passed through untouched, so use whatever ID the provider expects.
</Accordion>
</AccordionGroup>
## Related Resources

View File

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

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

View File

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

View File

@@ -568,12 +568,32 @@ FooterKey .footer-key--key {
self._default_inputs: dict[str, Any] | None = None
self._crew_result: Any = None
self._crew_json_path: Any = None
# Declarative-flow execution state. A flow renders per-method "STEPS"
# (built from flow method events) instead of the crew task list.
self._flow_inputs: dict[str, Any] | None = None
self._flow_method_types: dict[str, str] = {}
self._flow_steps: list[dict[str, Any]] = []
self._current_method: str | None = None
self._elapsed_frozen: float | None = None
self._want_deploy: bool = False
self._trace_url: str | None = None
self._consent_screen: TraceConsentScreen | None = None
self._telemetry: Telemetry | None = None
@property
def _is_flow_run(self) -> bool:
"""True for a non-conversational declarative flow (the STEPS view).
Gates every flow-specific rendering branch so crew and conversational
paths stay byte-identical.
"""
return self._flow is not None and not self._is_conversational
@property
def _run_noun(self) -> str:
"""User-facing noun for the run — 'flow' for a declarative flow, else 'crew'."""
return "flow" if self._is_flow_run else "crew"
# ── Layout ──────────────────────────────────────────────
def compose(self) -> ComposeResult:
@@ -602,6 +622,8 @@ FooterKey .footer-key--key {
self._tick_timer = self.set_interval(1 / 8, self._tick)
if self._is_conversational and self._flow:
self._start_conversational_session()
elif self._flow:
self._run_flow_worker()
elif self._crew:
self._run_crew_worker()
elif self._crew_json_path:
@@ -681,6 +703,49 @@ FooterKey .footer-key--key {
except Exception as e:
self.call_from_thread(self._on_crew_failed, str(e))
@work(thread=True, exclusive=True, group="flow")
def _run_flow_worker(self) -> None:
from crewai.events.listeners.tracing.utils import (
set_suppress_tracing_messages,
set_tui_mode,
)
set_tui_mode(True)
set_suppress_tracing_messages(True)
try:
# A declarative flow returns either a CrewOutput (has ``.raw``) or a
# bare value (str/dict/pydantic); _stringify_output handles both.
result = self._flow.kickoff(inputs=self._flow_inputs)
output = self._stringify_output(result)
with self._lock:
self._crew_result = result
self.call_from_thread(self._on_crew_done, output)
except Exception as e:
self.call_from_thread(self._on_crew_failed, str(e))
def _set_flow_step_status(self, name: str, status: str) -> None:
"""Update a flow method step's status. Caller must hold ``self._lock``."""
for step in self._flow_steps:
if step["name"] == name:
step["status"] = status
return
def _clear_current_method(self, finished_name: str) -> None:
"""Drop the header's active method once it ends. Caller holds the lock.
Falls back to another still-active step (methods can overlap) so the
header never keeps spinning a method the STEPS list already shows as
done or failed.
"""
if self._current_method != finished_name:
return
self._current_method = next(
(s["name"] for s in self._flow_steps if s["status"] == "active"), None
)
# The active method changed; drop its agent so the header doesn't show a
# stale agent until the next method's agent event arrives.
self._current_agent = ""
def _on_crew_done(self, output: str | None) -> None:
with self._lock:
self._status = "completed"
@@ -694,13 +759,18 @@ FooterKey .footer-key--key {
for k in self._task_statuses:
if self._task_statuses[k] == "active":
self._task_statuses[k] = "done"
for step in self._flow_steps:
if step["status"] == "active":
step["status"] = "done"
now = time.time()
for entry in self._log_entries:
if entry["status"] == "running":
if entry["tool_name"] == "memory_save":
continue
entry["status"] = "timeout"
entry["error"] = "No result received before crew completed"
entry["error"] = (
f"No result received before {self._run_noun} completed"
)
entry["duration"] = now - entry["start_time"]
try:
from crewai.events.listeners.tracing.trace_listener import (
@@ -739,13 +809,18 @@ FooterKey .footer-key--key {
self._is_streaming = False
self._current_step = None
self._elapsed_frozen = time.time() - self._start_time
for step in self._flow_steps:
if step["status"] == "active":
step["status"] = "failed"
now = time.time()
for entry in self._log_entries:
if entry["status"] == "running":
if entry["tool_name"] == "memory_save":
continue
entry["status"] = "error"
entry["error"] = "No result received before crew failed"
entry["error"] = (
f"No result received before {self._run_noun} failed"
)
entry["duration"] = now - entry["start_time"]
self._tick()
self.call_later(self._focus_activity_log)
@@ -1156,6 +1231,45 @@ FooterKey .footer-key--key {
widget.update(t)
return
if self._is_flow_run:
t.append(" STEPS\n", style=f"bold {_C_PRIMARY}")
t.append("\n")
if not self._flow_steps:
t.append(" ○ waiting…\n", style=_C_DIM)
for step in self._flow_steps:
name = step["name"]
max_name = sidebar_width - 6
if len(name) > max_name:
name = name[: max_name - 1] + ""
status = step.get("status", "pending")
if status == "done":
t.append("", style=_C_GREEN)
t.append(name, style=_C_DIM)
elif status == "active":
t.append(f" {self._spinner()} ", style=_C_PRIMARY)
t.append(name, style=f"bold {_C_TEXT}")
elif status == "failed":
t.append("", style=_C_RED)
t.append(name, style=_C_RED)
elif status == "paused":
t.append("", style=_C_TEAL)
t.append(name, style=_C_TEAL)
else:
t.append("", style=_C_DIM)
t.append(name, style=_C_DIM)
if step.get("call_type"):
t.append(f" ({step['call_type']})", style=_C_DIM)
t.append("\n")
t.append("\n")
t.append(" TOKENS\n", style=f"bold {_C_PRIMARY}")
t.append("\n")
out = self._output_tokens + self._live_out_tokens
t.append(f"{self._input_tokens:,}\n", style=_C_DIM)
t.append(f"{out:,}\n", style=_C_DIM)
widget.update(t)
return
t.append(" TASKS\n", style=f"bold {_C_PRIMARY}")
t.append("\n")
@@ -1225,6 +1339,55 @@ FooterKey .footer-key--key {
widget.update(t)
return
if self._is_flow_run:
if self._status == "completed":
elapsed = self._elapsed_frozen or (time.time() - self._start_time)
t.append("", style=f"bold {_C_GREEN}")
t.append("Flow complete", style=f"bold {_C_GREEN}")
t.append(f" {elapsed:.1f}s", style=_C_DIM)
out = self._output_tokens + self._live_out_tokens
parts = []
if self._input_tokens:
parts.append(f"{self._input_tokens:,}")
if out:
parts.append(f"{out:,}")
if parts:
t.append(f" {' '.join(parts)} tokens", style=_C_DIM)
elif self._status == "failed":
t.append("", style=f"bold {_C_RED}")
t.append("Failed", style=f"bold {_C_RED}")
if self._error:
t.append(f"\n{self._error[:120]}", style=_C_RED)
elif self._current_method:
paused = any(
s["name"] == self._current_method and s["status"] == "paused"
for s in self._flow_steps
)
if paused:
t.append("", style=_C_TEAL)
t.append(self._current_method, style=f"bold {_C_TEAL}")
else:
t.append(f"{self._spinner()} ", style=_C_PRIMARY)
t.append(self._current_method, style=f"bold {_C_PRIMARY}")
call_type = self._flow_method_types.get(self._current_method)
if call_type:
t.append(f" ({call_type})", style=_C_DIM)
if paused:
t.append(" waiting for feedback", style=_C_DIM)
elif self._current_agent:
t.append("\nAgent: ", style=_C_DIM)
t.append(self._current_agent, style=f"bold {_C_TEXT}")
else:
t.append(f"{self._spinner()} ", style=_C_PRIMARY)
# "Working…" once a step has run (between/after methods);
# "Starting flow…" only before the first method.
t.append(
"Working…" if self._flow_steps else "Starting flow…",
style=_C_DIM,
)
widget.update(t)
return
if self._status == "completed":
elapsed = self._elapsed_frozen or (time.time() - self._start_time)
t.append("", style=f"bold {_C_GREEN}")
@@ -1839,6 +2002,13 @@ FooterKey .footer-key--key {
def _subscribe(self) -> None:
from crewai.events.event_bus import crewai_event_bus
from crewai.events.types.crew_events import CrewKickoffStartedEvent
from crewai.events.types.flow_events import (
FlowStartedEvent,
MethodExecutionFailedEvent,
MethodExecutionFinishedEvent,
MethodExecutionPausedEvent,
MethodExecutionStartedEvent,
)
from crewai.events.types.llm_events import (
LLMCallCompletedEvent,
LLMCallStartedEvent,
@@ -1872,13 +2042,74 @@ FooterKey .footer-key--key {
@crewai_event_bus.on(CrewKickoffStartedEvent)
def on_crew_started(source: Any, event: CrewKickoffStartedEvent) -> None:
with self._lock:
if event.crew_name:
# In flow mode the app is named for the flow; a nested crew's
# kickoff (a `call: crew` step) must not rename it.
if event.crew_name and not self._is_flow_run:
self._crew_name = event.crew_name
self.title = f"CrewAI — {event.crew_name}"
self._status = "working"
self._register_handler(CrewKickoffStartedEvent, on_crew_started)
# ── Declarative-flow method events → STEPS panel ────────
@crewai_event_bus.on(FlowStartedEvent)
def on_flow_started(source: Any, event: FlowStartedEvent) -> None:
with self._lock:
self._status = "working"
self._register_handler(FlowStartedEvent, on_flow_started)
@crewai_event_bus.on(MethodExecutionStartedEvent)
def on_method_started(source: Any, event: MethodExecutionStartedEvent) -> None:
with self._lock:
name = event.method_name
self._current_method = name
# Agent is per-method; clear it so the header doesn't show the
# previous method's agent until a new agent event arrives.
self._current_agent = ""
for step in self._flow_steps:
if step["name"] == name:
step["status"] = "active"
break
else:
self._flow_steps.append(
{
"name": name,
"call_type": self._flow_method_types.get(name),
"status": "active",
}
)
self._register_handler(MethodExecutionStartedEvent, on_method_started)
@crewai_event_bus.on(MethodExecutionFinishedEvent)
def on_method_finished(
source: Any, event: MethodExecutionFinishedEvent
) -> None:
with self._lock:
self._set_flow_step_status(event.method_name, "done")
self._clear_current_method(event.method_name)
self._register_handler(MethodExecutionFinishedEvent, on_method_finished)
@crewai_event_bus.on(MethodExecutionFailedEvent)
def on_method_failed(source: Any, event: MethodExecutionFailedEvent) -> None:
with self._lock:
self._set_flow_step_status(event.method_name, "failed")
self._clear_current_method(event.method_name)
self._register_handler(MethodExecutionFailedEvent, on_method_failed)
@crewai_event_bus.on(MethodExecutionPausedEvent)
def on_method_paused(source: Any, event: MethodExecutionPausedEvent) -> None:
# A @human_feedback method paused; flow status panels are suppressed
# in TUI mode, so surface the wait in STEPS/header instead of leaving
# a spinner. _current_method stays pointed at it.
with self._lock:
self._set_flow_step_status(event.method_name, "paused")
self._register_handler(MethodExecutionPausedEvent, on_method_paused)
@crewai_event_bus.on(TaskStartedEvent)
def on_task_started(source: Any, event: TaskStartedEvent) -> None:
with self._lock:

View File

@@ -1,9 +1,10 @@
from __future__ import annotations
import json
import logging
from pathlib import Path
import subprocess
from typing import Any
from typing import TYPE_CHECKING, Any
import click
from crewai_core.project import ProjectDefinitionError, configured_project_definition
@@ -18,6 +19,13 @@ from crewai_cli.input_prompt import (
from crewai_cli.utils import build_env_with_all_tool_credentials
if TYPE_CHECKING:
from crewai.flow.flow import Flow
logger = logging.getLogger(__name__)
def run_declarative_flow_in_project_env(
definition: str | Path, inputs: str | None = None
) -> None:
@@ -66,17 +74,182 @@ def run_declarative_flow(definition: str | Path, inputs: str | None = None) -> N
flow = load_declarative_flow(definition)
resolved_inputs = _resolve_flow_inputs(flow, provided)
# The TUI is the interactive default. Headless contexts run directly on the
# terminal: deploy/CREWAI_DMN, piped output, CI — anything without an
# interactive TTY. is_interactive() already folds in the CREWAI_DMN check.
# Human-feedback flows also run on the terminal: their methods collect input
# via the flow runtime's blocking input()/Rich prompts (and async feedback
# returns a pending marker rather than completing), neither of which the
# Textual TUI can handle correctly.
if is_interactive() and not _flow_uses_human_feedback(flow):
_run_declarative_flow_tui(flow, resolved_inputs or None)
return
try:
result = flow.kickoff(inputs=resolved_inputs or None)
except Exception as exc:
click.echo(
f"An error occurred while running the declarative flow: {exc}", err=True
f"An error occurred while running the declarative flow: {exc}",
err=True,
)
raise SystemExit(1) from exc
click.echo(_format_result(result))
def _run_declarative_flow_tui(
flow: Flow[Any], resolved_inputs: dict[str, Any] | None
) -> Any:
"""Run a declarative flow on the CrewAI TUI (the interactive default).
Mirrors the declarative-crew TUI contract (``run_crew._run_json_crew``):
a failed flow exits non-zero, a user quit ends the process so in-flight LLM
work stops, and choosing Deploy chains into the deploy command.
"""
import os
import sys
from crewai.events.event_listener import EventListener
from crewai_cli.crew_run_tui import CrewRunApp
# The flow runtime (unlike a Crew constructor) doesn't create the event
# listener, and the TUI's trace/telemetry features depend on it.
EventListener()
# The STEPS panel and header are driven by flow method events. A flow may
# declare ``config.suppress_flow_events`` (a headless/production
# optimization) which would leave STEPS stuck on "waiting…" here — so force
# emission on for the interactive TUI run. The headless path never reaches
# this and keeps the flow's declared setting.
try:
flow.suppress_flow_events = False
except Exception:
logger.debug(
"Could not disable suppress_flow_events for the flow TUI", exc_info=True
)
app = CrewRunApp(crew_name=flow.name or type(flow).__name__)
app._flow = flow
app._flow_inputs = resolved_inputs
app._flow_method_types = _flow_method_types(flow)
app.run()
_print_flow_post_tui_summary(app)
if app._status == "failed":
raise SystemExit(1)
if app._status not in ("completed", "failed"):
# User quit mid-run. kickoff runs in a thread worker that cannot be
# force-cancelled, so end the process to stop in-flight LLM and tool
# work instead of letting it burn tokens in the background.
click.secho("\n Run cancelled.", fg="yellow")
sys.stdout.flush()
os._exit(130)
if getattr(app, "_want_deploy", False):
from crewai_cli.run_crew import _chain_deploy
_chain_deploy()
return app._crew_result
def _flow_uses_human_feedback(flow: Flow[Any]) -> bool:
"""True if any declarative method declares ``@human_feedback``.
Such flows need the flow runtime's interactive stdin / Rich prompts, which
don't compose with Textual — so they run on the terminal, not the TUI.
"""
try:
return any(
method.human_feedback is not None
for method in flow._definition.methods.values()
)
except Exception:
logger.debug("Could not inspect flow for human feedback", exc_info=True)
return False
def _flow_method_types(flow: Flow[Any]) -> dict[str, str]:
"""Map each declarative method name to its ``call`` type (crew/agent/…).
Best-effort: the STEPS panel shows this as a dim label. Method events don't
carry the call type, so it's read from the flow definition up front.
"""
method_types: dict[str, str] = {}
try:
for name, method_definition in flow._definition.methods.items():
method_types[name] = method_definition.do.call
except Exception:
logger.debug("Could not derive flow method types", exc_info=True)
return method_types
def _print_flow_post_tui_summary(app: Any) -> None:
"""Print a compact result panel after the flow TUI exits."""
import time
from rich.console import Console
from rich.markdown import Markdown
from rich.padding import Padding
from rich.panel import Panel
from rich.text import Text
console = Console()
elapsed = (app._elapsed_frozen or (time.time() - app._start_time)) or 0.0
out_tokens = app._output_tokens + app._live_out_tokens
token_parts = []
if app._input_tokens:
token_parts.append(f"{app._input_tokens:,}")
if out_tokens:
token_parts.append(f"{out_tokens:,}")
token_str = " ".join(token_parts)
if token_str:
token_str += " tokens"
crewai_red = "#FF5A50"
crewai_teal = "#1F7982"
if app._status == "completed":
summary = Text()
summary.append(" ✔ Flow complete", style=f"bold {crewai_teal}")
summary.append(f" in {elapsed:.1f}s", style="dim")
if token_str:
summary.append(f" {token_str}", style="dim")
console.print(
Panel(
summary,
title=f" {app._crew_name} ",
title_align="left",
border_style=crewai_teal,
padding=(0, 1),
)
)
if app._final_output:
console.print()
console.print(Text(" Final Result", style=f"bold {crewai_teal}"))
console.print()
console.print(Padding(Markdown(app._final_output), (0, 2)))
elif app._status == "failed":
content = Text()
content.append(" ✘ Failed", style=f"bold {crewai_red}")
content.append(f" after {elapsed:.1f}s\n", style="dim")
if app._error:
content.append(f"\n {app._error}\n", style=crewai_red)
console.print(
Panel(
content,
title=f" {app._crew_name} ",
title_align="left",
border_style=crewai_red,
padding=(0, 1),
)
)
def _resolve_flow_inputs(flow: Any, provided: dict[str, Any]) -> dict[str, Any]:
"""Resolve kickoff inputs from the flow's state schema.

View File

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

View File

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

View File

@@ -6,6 +6,14 @@ from unittest.mock import Mock
import pytest
from crewai.events.event_bus import crewai_event_bus
from crewai.events.types.crew_events import CrewKickoffStartedEvent
from crewai.events.types.flow_events import (
FlowStartedEvent,
MethodExecutionFailedEvent,
MethodExecutionFinishedEvent,
MethodExecutionPausedEvent,
MethodExecutionStartedEvent,
)
from crewai.events.types.memory_events import (
MemorySaveCompletedEvent,
MemorySaveFailedEvent,
@@ -959,6 +967,31 @@ async def test_crew_done_does_not_mark_unfinished_tool_successful() -> None:
assert app._plan_step_status == {1: "failed", 2: "done", 3: "done"}
@pytest.mark.asyncio
async def test_flow_done_uses_flow_wording_for_unfinished_tool() -> None:
# The shared completion handler reports "flow" (not "crew") in flow mode.
app = CrewRunApp(crew_name="Demo Flow")
app._flow = SimpleNamespace()
async with app.run_test(size=(100, 40)) as pilot:
app._log_entries = [
{
"tool_name": "search",
"status": "running",
"args": None,
"result": None,
"error": None,
"start_time": time.time() - 2,
"duration": None,
"task_idx": 1,
}
]
app._on_crew_done("final output")
await pilot.pause()
assert app._log_entries[0]["error"] == "No result received before flow completed"
@pytest.mark.asyncio
async def test_crew_done_does_not_timeout_memory_save() -> None:
app = _app_with_plan()
@@ -1481,3 +1514,210 @@ def test_overlapping_task_logs_keep_their_own_state() -> None:
assert any(step.get("summary") == "thinking" for step in entry2["steps"])
finally:
app._unsubscribe()
# ── Declarative-flow (non-conversational) TUI support ───────
def test_is_flow_run_gating() -> None:
"""The flow-render gate must be true only for a non-conversational flow."""
crew_app = CrewRunApp(total_tasks=1)
crew_app._crew = SimpleNamespace()
assert crew_app._is_flow_run is False
conv_app = CrewRunApp(conversational=True)
conv_app._flow = SimpleNamespace()
assert conv_app._is_flow_run is False
flow_app = CrewRunApp()
flow_app._flow = SimpleNamespace()
assert flow_app._is_flow_run is True
def test_flow_method_events_build_steps() -> None:
app = CrewRunApp(crew_name="Demo")
app._flow = SimpleNamespace()
app._flow_method_types = {"research": "crew", "summarize": "agent"}
app._subscribe()
try:
_emit_event(FlowStartedEvent(flow_name="Demo"))
assert app._status == "working"
_emit_event(
MethodExecutionStartedEvent(
flow_name="Demo", method_name="research", state={}
)
)
assert app._flow_steps == [
{"name": "research", "call_type": "crew", "status": "active"}
]
assert app._current_method == "research"
_emit_event(
MethodExecutionFinishedEvent(
flow_name="Demo", method_name="research", result="ok", state={}
)
)
_emit_event(
MethodExecutionStartedEvent(
flow_name="Demo", method_name="summarize", state={}
)
)
_emit_event(
MethodExecutionFailedEvent(
flow_name="Demo",
method_name="summarize",
error=RuntimeError("boom"),
)
)
finally:
app._unsubscribe()
assert app._flow_steps == [
{"name": "research", "call_type": "crew", "status": "done"},
{"name": "summarize", "call_type": "agent", "status": "failed"},
]
# The header must not keep spinning a method that already ended.
assert app._current_method is None
def test_current_method_clears_and_falls_back_across_overlap() -> None:
app = CrewRunApp(crew_name="Demo")
app._flow = SimpleNamespace()
app._subscribe()
try:
_emit_event(
MethodExecutionStartedEvent(flow_name="Demo", method_name="a", state={})
)
_emit_event(
MethodExecutionStartedEvent(flow_name="Demo", method_name="b", state={})
)
assert app._current_method == "b"
# 'a' finishes while 'b' is still active → header stays on 'b'.
_emit_event(
MethodExecutionFinishedEvent(
flow_name="Demo", method_name="a", result=None, state={}
)
)
assert app._current_method == "b"
# 'b' finishes → nothing active left → header clears.
_emit_event(
MethodExecutionFinishedEvent(
flow_name="Demo", method_name="b", result=None, state={}
)
)
assert app._current_method is None
finally:
app._unsubscribe()
def test_flow_method_transitions_clear_current_agent() -> None:
app = CrewRunApp(crew_name="Demo")
app._flow = SimpleNamespace()
app._subscribe()
try:
_emit_event(
MethodExecutionStartedEvent(flow_name="Demo", method_name="a", state={})
)
app._current_agent = "Researcher" # an agent ran during method 'a'
# Starting a new method clears the previous method's agent.
_emit_event(
MethodExecutionStartedEvent(flow_name="Demo", method_name="b", state={})
)
assert app._current_agent == ""
app._current_agent = "Writer"
# 'b' ending switches the active method ('a' still active) → agent clears.
_emit_event(
MethodExecutionFinishedEvent(
flow_name="Demo", method_name="b", result=None, state={}
)
)
assert app._current_agent == ""
finally:
app._unsubscribe()
def test_crew_kickoff_does_not_rename_flow_run() -> None:
# A `call: crew` step must not relabel the flow with the nested crew's name.
app = CrewRunApp(crew_name="My Flow")
app._flow = SimpleNamespace()
app._subscribe()
try:
_emit_event(CrewKickoffStartedEvent(crew_name="Nested Crew", inputs=None))
assert app._crew_name == "My Flow"
assert app._status == "working"
finally:
app._unsubscribe()
def test_crew_kickoff_renames_in_crew_mode() -> None:
# Regression: crew runs still adopt the crew name from the event.
app = CrewRunApp(crew_name="Crew")
app._crew = SimpleNamespace()
app._subscribe()
try:
_emit_event(CrewKickoffStartedEvent(crew_name="Real Crew", inputs=None))
assert app._crew_name == "Real Crew"
finally:
app._unsubscribe()
def test_method_paused_marks_step_paused() -> None:
app = CrewRunApp(crew_name="Demo")
app._flow = SimpleNamespace()
app._subscribe()
try:
_emit_event(
MethodExecutionStartedEvent(flow_name="Demo", method_name="ask", state={})
)
_emit_event(
MethodExecutionPausedEvent(
flow_name="Demo",
method_name="ask",
state={},
flow_id="flow-1",
message="Need your input",
)
)
assert app._flow_steps == [
{"name": "ask", "call_type": None, "status": "paused"}
]
assert app._current_method == "ask"
finally:
app._unsubscribe()
@pytest.mark.asyncio
async def test_declarative_flow_runs_on_tui() -> None:
"""End-to-end: on_mount dispatches _run_flow_worker → flow.kickoff →
_on_crew_done, and any still-active step is swept to done on completion."""
kicked: dict[str, object] = {}
class FakeFlow:
name = "Demo Flow"
def kickoff(self, inputs=None):
kicked["inputs"] = inputs
return "flow result"
app = CrewRunApp(crew_name="Demo Flow")
app._flow = FakeFlow()
app._flow_inputs = {"topic": "AI"}
# A step left active (no Finished event) must be swept to done by _on_crew_done.
app._flow_steps = [{"name": "compute", "call_type": "expression", "status": "active"}]
async with app.run_test() as pilot:
for _ in range(100):
await pilot.pause(0.05)
if app._status == "completed":
break
assert kicked["inputs"] == {"topic": "AI"}
assert app._status == "completed"
assert app._final_output == "flow result"
assert app._crew_result == "flow result"
assert app._flow_steps[0]["status"] == "done"

View File

@@ -2,6 +2,7 @@ from __future__ import annotations
import os
from pathlib import Path
from types import SimpleNamespace
import pytest
@@ -9,6 +10,17 @@ import crewai_cli.input_prompt as input_prompt_module
import crewai_cli.run_declarative_flow as run_declarative_flow_module
@pytest.fixture(autouse=True)
def _headless_by_default(monkeypatch: pytest.MonkeyPatch) -> None:
"""Default these tests to the headless/terminal path.
``run_declarative_flow`` now launches the TUI when interactive, which can't
run under pytest; tests here assert the terminal/headless contract. Tests
that exercise TUI routing override ``is_dmn_mode_enabled`` explicitly.
"""
monkeypatch.setenv("CREWAI_DMN", "true")
FLOW_YAML = """\
schema: crewai.flow/v1
name: TestFlow
@@ -400,3 +412,202 @@ def test_id_restore_still_drops_unknown_keys(
assert resolved == {"id": "run-123"} # id kept, typo dropped
assert "Ignoring unknown input 'prospect_emai'" in captured.err
assert "Ignoring unknown input 'id'" not in captured.err
# ── TUI vs terminal (headless/deploy) routing ──────────────────────
def _install_fake_flow_app(monkeypatch, *, status, want_deploy=False):
"""Replace CrewRunApp/EventListener/summary so _run_declarative_flow_tui is
driven by a controllable fake app."""
class FakeEventListener:
pass
class FakeApp:
def __init__(self, crew_name=""):
self._crew_name = crew_name
self._status = status
self._want_deploy = want_deploy
self._crew_result = "result"
def run(self):
pass
monkeypatch.setattr(
"crewai.events.event_listener.EventListener", FakeEventListener
)
monkeypatch.setattr("crewai_cli.crew_run_tui.CrewRunApp", FakeApp)
monkeypatch.setattr(
run_declarative_flow_module, "_print_flow_post_tui_summary", lambda app: None
)
def test_run_declarative_flow_dmn_uses_terminal(
tmp_path: Path, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv("CREWAI_DMN", "true")
monkeypatch.setattr(
run_declarative_flow_module,
"_run_declarative_flow_tui",
lambda *a, **k: pytest.fail("DMN/headless mode must not launch the TUI"),
)
path = _write(tmp_path, REQUIRED_FLOW_YAML)
run_declarative_flow_module.run_declarative_flow(
str(path), '{"prospect_email":"a@b.com"}'
)
assert capsys.readouterr().out == "a@b.com\n"
def test_run_declarative_flow_interactive_uses_tui(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(run_declarative_flow_module, "is_interactive", lambda: True)
captured: dict[str, object] = {}
monkeypatch.setattr(
run_declarative_flow_module,
"_run_declarative_flow_tui",
lambda flow, resolved: captured.update(flow=flow, inputs=resolved),
)
path = _write(tmp_path, REQUIRED_FLOW_YAML)
run_declarative_flow_module.run_declarative_flow(
str(path), '{"prospect_email":"a@b.com"}'
)
assert captured["inputs"] == {"prospect_email": "a@b.com"}
assert captured["flow"] is not None
def test_run_declarative_flow_tui_failed_exits_nonzero(
monkeypatch: pytest.MonkeyPatch,
) -> None:
_install_fake_flow_app(monkeypatch, status="failed")
with pytest.raises(SystemExit) as exc_info:
run_declarative_flow_module._run_declarative_flow_tui(
SimpleNamespace(name="Flow"), None
)
assert exc_info.value.code == 1
def test_run_declarative_flow_tui_user_quit_exits_130(
monkeypatch: pytest.MonkeyPatch,
) -> None:
_install_fake_flow_app(monkeypatch, status="chatting")
exit_calls: list[int] = []
monkeypatch.setattr(os, "_exit", lambda code: exit_calls.append(code))
run_declarative_flow_module._run_declarative_flow_tui(
SimpleNamespace(name="Flow"), None
)
assert exit_calls == [130]
def test_run_declarative_flow_tui_chains_deploy(
monkeypatch: pytest.MonkeyPatch,
) -> None:
_install_fake_flow_app(monkeypatch, status="completed", want_deploy=True)
deploy_calls: list[bool] = []
monkeypatch.setattr(
"crewai_cli.run_crew._chain_deploy", lambda: deploy_calls.append(True)
)
run_declarative_flow_module._run_declarative_flow_tui(
SimpleNamespace(name="Flow"), None
)
assert deploy_calls == [True]
def test_run_declarative_flow_tui_no_deploy_when_not_requested(
monkeypatch: pytest.MonkeyPatch,
) -> None:
_install_fake_flow_app(monkeypatch, status="completed", want_deploy=False)
deploy_calls: list[bool] = []
monkeypatch.setattr(
"crewai_cli.run_crew._chain_deploy", lambda: deploy_calls.append(True)
)
run_declarative_flow_module._run_declarative_flow_tui(
SimpleNamespace(name="Flow"), None
)
assert deploy_calls == []
def test_run_declarative_flow_tui_enables_flow_events(
monkeypatch: pytest.MonkeyPatch,
) -> None:
# The STEPS panel depends on flow method events; a flow that declared
# suppress_flow_events must have it forced off for the interactive TUI run.
_install_fake_flow_app(monkeypatch, status="completed")
flow = SimpleNamespace(name="Flow", suppress_flow_events=True)
run_declarative_flow_module._run_declarative_flow_tui(flow, None)
assert flow.suppress_flow_events is False
def test_flow_uses_human_feedback_detection() -> None:
hf_flow = SimpleNamespace(
_definition=SimpleNamespace(
methods={
"ask": SimpleNamespace(human_feedback=SimpleNamespace(emit=None)),
"plain": SimpleNamespace(human_feedback=None),
}
)
)
assert run_declarative_flow_module._flow_uses_human_feedback(hf_flow) is True
no_hf = SimpleNamespace(
_definition=SimpleNamespace(
methods={"a": SimpleNamespace(human_feedback=None)}
)
)
assert run_declarative_flow_module._flow_uses_human_feedback(no_hf) is False
# No definition → False, no error.
assert run_declarative_flow_module._flow_uses_human_feedback(SimpleNamespace()) is False
def test_human_feedback_flow_uses_terminal_even_when_interactive(
tmp_path: Path, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch
) -> None:
# A human-feedback flow must run on the terminal (blocking input / Rich
# prompts) even in an interactive session, never on the TUI.
monkeypatch.setattr(run_declarative_flow_module, "is_interactive", lambda: True)
monkeypatch.setattr(
run_declarative_flow_module, "_flow_uses_human_feedback", lambda flow: True
)
monkeypatch.setattr(
run_declarative_flow_module,
"_run_declarative_flow_tui",
lambda *a, **k: pytest.fail("human-feedback flow must run on the terminal"),
)
path = _write(tmp_path, FLOW_YAML)
run_declarative_flow_module.run_declarative_flow(str(path), '{"topic":"AI"}')
assert capsys.readouterr().out == "AI\n"
def test_flow_method_types_from_definition() -> None:
flow = SimpleNamespace(
_definition=SimpleNamespace(
methods={
"fetch": SimpleNamespace(do=SimpleNamespace(call="expression")),
"research": SimpleNamespace(do=SimpleNamespace(call="crew")),
}
)
)
assert run_declarative_flow_module._flow_method_types(flow) == {
"fetch": "expression",
"research": "crew",
}
# No definition → empty map, no error.
assert run_declarative_flow_module._flow_method_types(SimpleNamespace()) == {}

View File

@@ -86,6 +86,7 @@ from crewai.skills.models import Skill as SkillModel
from crewai.state.checkpoint_config import CheckpointConfig, apply_checkpoint
from crewai.tools.agent_tools.agent_tools import AgentTools
from crewai.types.callback import SerializableCallable
from crewai.types.usage_metrics import UsageMetrics
from crewai.utilities.agent_utils import (
get_tool_names,
is_inside_event_loop,
@@ -400,10 +401,29 @@ class Agent(BaseAgent):
return self.planning_config is not None or self.planning
def _setup_agent_executor(self) -> None:
"""Initialize the agent executor with a default cache handler."""
if not self.cache_handler:
self.cache_handler = CacheHandler()
self.set_cache_handler(self.cache_handler)
"""Initialize the agent's tools handler and optional tool cache.
Tool-result caching is opt-in: a standalone agent gets a cache only
when it was constructed with an explicit ``cache=True`` or a
``cache_handler``. Agents inside a crew additionally receive the
crew's shared handler when ``Crew(cache=True)``. Without an opt-in,
repeated tool calls with identical arguments always re-execute the
tool — the safe default for live-data and state-mutating tools.
"""
# Recorded before any crew can offer its shared handler at kickoff,
# so copy() can distinguish a construction-time opt-in from runtime
# crew wiring (which must not turn copies into cachers).
self._constructor_cache_opt_in = bool(
self.cache
and (self.cache_handler is not None or "cache" in self.model_fields_set)
)
opted_in = self.cache_handler is not None or (
"cache" in self.model_fields_set and self.cache
)
if opted_in:
if not self.cache_handler:
self.cache_handler = CacheHandler()
self.set_cache_handler(self.cache_handler)
def set_knowledge(self, crew_embedder: EmbedderConfig | None = None) -> None:
"""Initialize knowledge sources with the agent or crew embedder config."""
@@ -1582,9 +1602,18 @@ class Agent(BaseAgent):
crewai_event_bus.emit(self, event=started_event)
self._kickoff_event_id = started_event.event_id
output = self._execute_and_build_output(executor, inputs, response_format)
usage_baseline = self._current_usage_summary()
output = self._execute_and_build_output(
executor, inputs, response_format, usage_baseline
)
return self._finalize_kickoff(
output, executor, inputs, response_format, messages, agent_info
output,
executor,
inputs,
response_format,
messages,
agent_info,
usage_baseline,
)
except Exception as e:
@@ -1598,6 +1627,7 @@ class Agent(BaseAgent):
response_format: type[Any] | None,
messages: str | list[LLMMessage],
agent_info: dict[str, Any],
usage_baseline: UsageMetrics | None = None,
) -> LiteAgentOutput:
"""Apply guardrails, save to memory, and emit completion event.
@@ -1608,6 +1638,8 @@ class Agent(BaseAgent):
response_format: Optional response format.
messages: The original messages.
agent_info: Agent metadata for events.
usage_baseline: Usage snapshot taken at kickoff start, so retries
report per-call usage relative to it.
Returns:
The finalized output.
@@ -1618,6 +1650,7 @@ class Agent(BaseAgent):
executor=executor,
inputs=inputs,
response_format=response_format,
usage_baseline=usage_baseline,
)
self._save_kickoff_to_memory(messages, output.raw)
@@ -1669,11 +1702,24 @@ class Agent(BaseAgent):
except Exception as e:
self._logger.log("error", f"Failed to save kickoff result to memory: {e}")
def _current_usage_summary(self) -> UsageMetrics:
"""Snapshot the cumulative usage counters backing this agent's LLM.
The counters live on the LLM instance (or the agent's token process
for non-BaseLLM models) and grow for the object's lifetime — across
calls and across agents sharing the instance. Per-call usage is the
delta between two snapshots.
"""
if isinstance(self.llm, BaseLLM):
return self.llm.get_token_usage_summary()
return self._token_process.get_summary()
def _build_output_from_result(
self,
result: dict[str, Any],
executor: AgentExecutor,
response_format: type[Any] | None = None,
usage_baseline: UsageMetrics | None = None,
) -> LiteAgentOutput:
"""Build a LiteAgentOutput from an executor result dict.
@@ -1683,6 +1729,9 @@ class Agent(BaseAgent):
result: The result dictionary from executor.invoke / invoke_async.
executor: The executor instance.
response_format: Optional response format.
usage_baseline: Usage snapshot taken at kickoff start. When given,
the output carries only this call's usage (the delta) instead
of the LLM instance's cumulative lifetime counters.
Returns:
LiteAgentOutput with raw output, formatted result, and metrics.
@@ -1727,10 +1776,9 @@ class Agent(BaseAgent):
else:
raw_output = str(output) if not isinstance(output, str) else output
if isinstance(self.llm, BaseLLM):
usage_metrics = self.llm.get_token_usage_summary()
else:
usage_metrics = self._token_process.get_summary()
usage_metrics = self._current_usage_summary()
if usage_baseline is not None:
usage_metrics = usage_metrics.delta_since(usage_baseline)
raw_str = (
raw_output
@@ -1759,20 +1807,26 @@ class Agent(BaseAgent):
executor: AgentExecutor,
inputs: dict[str, str],
response_format: type[Any] | None = None,
usage_baseline: UsageMetrics | None = None,
) -> LiteAgentOutput:
"""Execute the agent synchronously and build the output object."""
result = cast(dict[str, Any], executor.invoke(inputs))
return self._build_output_from_result(result, executor, response_format)
return self._build_output_from_result(
result, executor, response_format, usage_baseline
)
async def _execute_and_build_output_async(
self,
executor: AgentExecutor,
inputs: dict[str, str],
response_format: type[Any] | None = None,
usage_baseline: UsageMetrics | None = None,
) -> LiteAgentOutput:
"""Execute the agent asynchronously and build the output object."""
result = await executor.invoke_async(inputs)
return self._build_output_from_result(result, executor, response_format)
return self._build_output_from_result(
result, executor, response_format, usage_baseline
)
def _process_kickoff_guardrail(
self,
@@ -1781,6 +1835,7 @@ class Agent(BaseAgent):
inputs: dict[str, str],
response_format: type[Any] | None = None,
retry_count: int = 0,
usage_baseline: UsageMetrics | None = None,
) -> LiteAgentOutput:
"""Process guardrail for kickoff execution with retry logic.
@@ -1790,6 +1845,9 @@ class Agent(BaseAgent):
inputs: Input dictionary for re-execution.
response_format: Optional response format.
retry_count: Current retry count.
usage_baseline: Usage snapshot taken at kickoff start, so a
retried output reports the whole call's usage, not just the
last attempt's.
Returns:
Validated/updated output.
@@ -1827,7 +1885,9 @@ class Agent(BaseAgent):
role="user",
)
output = self._execute_and_build_output(executor, inputs, response_format)
output = self._execute_and_build_output(
executor, inputs, response_format, usage_baseline
)
return self._process_kickoff_guardrail(
output=output,
@@ -1835,6 +1895,7 @@ class Agent(BaseAgent):
inputs=inputs,
response_format=response_format,
retry_count=retry_count + 1,
usage_baseline=usage_baseline,
)
if guardrail_result.result is not None:
@@ -1897,11 +1958,18 @@ class Agent(BaseAgent):
crewai_event_bus.emit(self, event=started_event)
self._kickoff_event_id = started_event.event_id
usage_baseline = self._current_usage_summary()
output = await self._execute_and_build_output_async(
executor, inputs, response_format
executor, inputs, response_format, usage_baseline
)
return self._finalize_kickoff(
output, executor, inputs, response_format, messages, agent_info
output,
executor,
inputs,
response_format,
messages,
agent_info,
usage_baseline,
)
except Exception as e:

View File

@@ -205,7 +205,11 @@ class BaseAgent(BaseModel, ABC, metaclass=AgentMeta):
role (str): Role of the agent.
goal (str): Objective of the agent.
backstory (str): Backstory of the agent.
cache (bool): Whether the agent should use a cache for tool usage.
cache (bool): Whether the agent participates in tool-result caching
when a cache is enabled. The default (True) only permits
participation — caching activates when the crew sets cache=True
or the agent explicitly opts in with cache=True or a
cache_handler; cache=False excludes the agent entirely.
config (dict[str, Any] | None): Configuration for the agent.
verbose (bool): Verbose mode for the Agent Execution.
max_rpm (int | None): Maximum number of requests per minute for the agent execution.
@@ -254,6 +258,7 @@ class BaseAgent(BaseModel, ABC, metaclass=AgentMeta):
_logger: Logger = PrivateAttr(default_factory=lambda: Logger(verbose=False))
_rpm_controller: RPMController | None = PrivateAttr(default=None)
_request_within_rpm_limit: SerializableCallable | None = PrivateAttr(default=None)
_constructor_cache_opt_in: bool = PrivateAttr(default=False)
_original_role: str | None = PrivateAttr(default=None)
_original_goal: str | None = PrivateAttr(default=None)
_original_backstory: str | None = PrivateAttr(default=None)
@@ -267,7 +272,14 @@ class BaseAgent(BaseModel, ABC, metaclass=AgentMeta):
description="Configuration for the agent", default=None, exclude=True
)
cache: bool = Field(
default=True, description="Whether the agent should use a cache for tool usage."
default=True,
description=(
"Whether the agent participates in tool-result caching when a "
"cache is enabled. Caching itself is opt-in: it activates only "
"when the crew sets cache=True or the agent explicitly opts in "
"(cache=True or a cache_handler at construction). Set False to "
"exclude this agent even when the crew enables caching."
),
)
verbose: bool = Field(
default=False, description="Verbose mode for the Agent Execution"
@@ -716,6 +728,19 @@ class BaseAgent(BaseModel, ABC, metaclass=AgentMeta):
copied_data = self.model_dump(exclude=exclude)
copied_data = {k: v for k, v in copied_data.items() if v is not None}
# Tool-result caching distinguishes "explicitly enabled" from the
# field default via model_fields_set; don't let the dump turn the
# default into an explicit opt-in on the copy. An agent that opted
# in at construction via an explicit cache_handler (excluded from
# the dump) must stay opted in — carry the consent as cache=True so
# the copy wires its own fresh handler. A handler merely offered by
# a crew at kickoff is runtime wiring, not consent, and must not
# opt the copy in; _constructor_cache_opt_in is recorded before any
# crew wiring can happen.
if "cache" not in self.model_fields_set:
copied_data.pop("cache", None)
if self._constructor_cache_opt_in:
copied_data["cache"] = True
return type(self)(
**copied_data,
llm=existing_llm,

View File

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

View File

@@ -168,8 +168,11 @@ class Crew(FlowTrackable, BaseModel):
manager_agent: Custom agent that will be used as manager.
memory: Whether the crew should use memory to store memories of it's
execution.
cache: Whether the crew should use a cache to store the results of the
tools execution.
cache: Whether to cache tool results for the crew's agents. Off by
default; when enabled, repeated calls to the same tool with
identical arguments reuse the first result without re-executing —
avoid enabling for live-data or state-mutating tools unless they
gate writes with a cache_function.
function_calling_llm: The language model that will run the tool calling
for all the agents.
process: The process flow that the crew will follow (e.g., sequential,
@@ -216,7 +219,16 @@ class Crew(FlowTrackable, BaseModel):
_kickoff_event_id: str | None = PrivateAttr(default=None)
name: str | None = Field(default="crew")
cache: bool = Field(default=True)
cache: bool = Field(
default=False,
description=(
"Whether to cache tool results for the crew's agents. Opt-in: "
"when enabled, repeated calls to the same tool with identical "
"arguments return the first result without re-executing the "
"tool — do not enable for live-data or state-mutating tools "
"unless they set a cache_function that prevents caching."
),
)
tasks: list[Task] = Field(default_factory=list)
agents: Annotated[
list[BaseAgent],
@@ -1048,8 +1060,9 @@ class Crew(FlowTrackable, BaseModel):
)
raise
finally:
if self._memory is not None and hasattr(self._memory, "drain_writes"):
self._memory.drain_writes()
# Safety net for the exception path; the success path already
# drained in _create_crew_output before emitting completion.
self._drain_memory_writes()
clear_files(self.id)
detach(token)
crewai_event_bus._exit_runtime_scope(runtime_scope)
@@ -1260,6 +1273,9 @@ class Crew(FlowTrackable, BaseModel):
)
raise
finally:
# Safety net for the exception path; the success path already
# drained in _create_crew_output before emitting completion.
self._drain_memory_writes()
clear_files(self.id)
detach(token)
crewai_event_bus._exit_runtime_scope(runtime_scope)
@@ -1503,6 +1519,11 @@ class Crew(FlowTrackable, BaseModel):
)
self.manager_agent = manager
manager.crew = self
# The manager is created outside the agents loop that offers the
# crew's cache handler at validation time; offer it here so an
# opted-in crew (cache=True) also dedupes the manager's tool calls.
if self.cache:
manager.set_cache_handler(self._cache_handler)
def _get_execution_start_index(self, tasks: list[Task]) -> int | None:
if self.checkpoint_kickoff_event_id is None:
@@ -1841,6 +1862,38 @@ class Crew(FlowTrackable, BaseModel):
output=output.raw,
)
def _drain_memory_writes(self) -> None:
"""Block until all pending background memory saves have completed.
Covers the crew memory, per-agent memories, and the manager agent's
memory — agents save through ``agent.memory`` when set (see
``BaseAgentExecutor._save_to_memory``), so draining only
``self._memory`` can miss in-flight saves. Scope/slice views are
unwrapped to their backing ``Memory`` so each pool is drained once.
Must run before ``CrewKickoffCompletedEvent`` is emitted: listeners
(e.g. telemetry sessions) tear down on that event, and any
``MemorySaveCompletedEvent``/``MemorySaveFailedEvent`` emitted after
teardown is lost, leaving the save span orphaned.
"""
seen: set[int] = set()
candidates = [
self._memory,
self.memory,
getattr(self.manager_agent, "memory", None),
*(getattr(agent, "memory", None) for agent in self.agents),
]
for mem in candidates:
if mem is None or isinstance(mem, bool):
continue
backing = getattr(mem, "_memory", None) or mem
if id(backing) in seen:
continue
seen.add(id(backing))
drain = getattr(backing, "drain_writes", None)
if callable(drain):
drain()
def _create_crew_output(self, task_outputs: list[TaskOutput]) -> CrewOutput:
if not task_outputs:
raise ValueError("No task outputs available to create crew output.")
@@ -1853,6 +1906,32 @@ 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.
self._drain_memory_writes()
crewai_event_bus.flush()
crewai_event_bus.emit(
self,
@@ -1867,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,

View File

@@ -24,9 +24,23 @@ class CrewOutput(BaseModel):
description="Output of each task", default_factory=list
)
token_usage: UsageMetrics = Field(
description="Processed token summary", default_factory=UsageMetrics
description=(
"Processed token summary; ``usage_metrics`` exposes the same "
"data as a plain dict"
),
default_factory=UsageMetrics,
)
@property
def usage_metrics(self) -> dict[str, Any]:
"""Token usage as a plain dict.
Same attribute name and shape as ``LiteAgentOutput.usage_metrics``
(the ``Agent.kickoff()`` result), so a usage accessor written for one
result type works on both.
"""
return self.token_usage.model_dump()
@property
def json(self) -> str | None: # type: ignore[override]
if self.tasks_output[-1].output_format != OutputFormat.JSON:

View File

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

View 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

View File

@@ -211,6 +211,13 @@ To enable tracing, do any one of these:
"""Print a panel with consistent formatting if verbose is enabled."""
panel = self.create_panel(content, title, style)
if is_flow:
# A TUI (e.g. the CLI's CrewRunApp) owns the screen and renders flow
# progress in its own STEPS panel; emitting Rich panels here would
# interleave with and corrupt the TUI, so suppress them in TUI mode.
from crewai.events.listeners.tracing.utils import is_tui_mode
if is_tui_mode():
return
self.print(panel)
self.print()
else:

View File

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

View File

@@ -133,6 +133,8 @@ class _ConversationalMixin:
_pending_user_message: str | dict[str, Any] | None
_pending_intents: Sequence[str] | None
_pending_intent_llm: str | BaseLLM | None
_turn_classified_intent: str | None
_assistant_reply_appended: bool
def _clear_or_listeners(self) -> None:
pass
@@ -185,12 +187,22 @@ class _ConversationalMixin:
)
return configured_route
if state.last_intent:
turn_intent = self._turn_classified_intent
if turn_intent:
state.last_intent = turn_intent
self._emit_conversation_route_selected(
state.last_intent,
turn_intent,
previous_intent=previous_intent,
)
return state.last_intent
return turn_intent
if previous_intent:
logger.debug(
"route_turn() returned no route and no intent was classified "
"this turn; ignoring stale last_intent=%r from a previous turn "
"and falling back to built-in routing",
previous_intent,
)
if self.can_answer_from_history(context):
state.last_intent = "answer_from_history"
@@ -310,11 +322,11 @@ class _ConversationalMixin:
if "from_checkpoint" not in kickoff_kwargs:
self._reset_turn_execution_state()
assistant_count = self._assistant_message_count()
object.__setattr__(self, "_assistant_reply_appended", False)
result = self.kickoff(inputs={"id": sid}, **kickoff_kwargs)
if (
result is not None
and self._assistant_message_count() == assistant_count
and not self._assistant_reply_appended
and self._is_public_turn_result(result)
):
self.append_assistant_message(self._stringify_result(result))
@@ -387,7 +399,7 @@ class _ConversationalMixin:
if "from_checkpoint" not in kickoff_kwargs:
self._reset_turn_execution_state()
assistant_count = self._assistant_message_count()
object.__setattr__(self, "_assistant_reply_appended", False)
original_stream = bool(getattr(self, "stream", False))
original_streaming_turn = getattr(
self, "_streaming_conversation_turn", False
@@ -403,7 +415,7 @@ class _ConversationalMixin:
)
if (
result is not None
and self._assistant_message_count() == assistant_count
and not self._assistant_reply_appended
and self._is_public_turn_result(result)
):
self.append_assistant_message(self._stringify_result(result))
@@ -550,6 +562,11 @@ class _ConversationalMixin:
supply per-route descriptions, or change the default/fallback intent.
Override this method to bypass the LLM router entirely (e.g.,
permission gates before the LLM decision).
Returning a falsy value means "no routing decision": the turn falls
through to the built-in defaults (``answer_from_history`` when
configured, else ``converse``). It never replays a previous turn's
intent.
"""
config = self._conversation_config
if config is None:
@@ -618,6 +635,9 @@ class _ConversationalMixin:
metadata: dict[str, Any] | None = None,
) -> None:
"""Append a final user-visible assistant message."""
# Explicit signal for handle_turn's "did the handler reply?" check.
# A count heuristic breaks when handlers trim history mid-turn.
object.__setattr__(self, "_assistant_reply_appended", True)
state = cast(ConversationState, self.state)
state.messages.append(
ConversationMessage(
@@ -722,6 +742,7 @@ class _ConversationalMixin:
context=self.conversation_messages,
)
state.last_intent = intent
object.__setattr__(self, "_turn_classified_intent", intent)
return intent
return text
@@ -788,6 +809,10 @@ class _ConversationalMixin:
object.__setattr__(self, "_pending_intent_llm", None)
if not hasattr(self, "_streaming_conversation_turn"):
object.__setattr__(self, "_streaming_conversation_turn", False)
if not hasattr(self, "_turn_classified_intent"):
object.__setattr__(self, "_turn_classified_intent", None)
if not hasattr(self, "_assistant_reply_appended"):
object.__setattr__(self, "_assistant_reply_appended", False)
def _create_default_extension_state(self) -> ConversationState | None:
initial_state_t = getattr(self, "_initial_state_t", None)
@@ -852,6 +877,7 @@ class _ConversationalMixin:
self._method_call_counts.clear()
self._clear_or_listeners()
self._is_execution_resuming = False
object.__setattr__(self, "_turn_classified_intent", None)
def _apply_pending_conversational_turn(self) -> None:
"""Drain the stashed user message + classify if intents configured.
@@ -859,6 +885,7 @@ class _ConversationalMixin:
Called from ``Flow.kickoff_async`` AFTER persist state restore so
the appended message survives ``self.persistence.load_state(...)``.
"""
object.__setattr__(self, "_turn_classified_intent", None)
if self._pending_user_message is None:
return
@@ -1107,10 +1134,6 @@ class _ConversationalMixin:
return "public"
return "private"
def _assistant_message_count(self) -> int:
state = cast(ConversationState, self.state)
return sum(1 for message in state.messages if message.role == "assistant")
def _is_public_turn_result(self, result: Any) -> bool:
if not isinstance(result, str):
return False
@@ -1190,6 +1213,15 @@ class _ConversationalMixin:
)
from crewai.events.types.flow_events import FlowFinishedEvent
# Background memory saves must finish (and emit their completed/failed
# events) before the session-end flow_finished / batch finalization
# below tears down listeners, mirroring the non-deferred kickoff path.
# The flush then waits for those events' async bus handlers.
drain_memory_writes = getattr(self, "_drain_memory_writes", None)
if callable(drain_memory_writes):
drain_memory_writes()
crewai_event_bus.flush()
# Only emit the session-end event when a deferred flow_started is
# actually pending. ``_deferred_flow_started_event_id`` is set only by
# deferred kickoffs; when finalization was not deferred, each per-turn

View File

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

View File

@@ -956,6 +956,22 @@ class Flow(BaseModel, Generic[T], metaclass=FlowMeta):
return self.memory.remember_many(content, **kwargs)
return self.memory.remember(content, **kwargs)
def _drain_memory_writes(self) -> None:
"""Block until pending background memory saves for this flow finish.
Must run before ``FlowFinishedEvent`` is emitted: listeners (e.g.
telemetry sessions) tear down on that event, and any
``MemorySaveCompletedEvent``/``MemorySaveFailedEvent`` emitted after
teardown is lost, leaving the save span orphaned.
"""
mem = self.memory
if mem is None:
return
backing = getattr(mem, "_memory", None) or mem
drain = getattr(backing, "drain_writes", None)
if callable(drain):
drain()
def extract_memories(self, content: str) -> list[str]:
"""Extract discrete memories from content. Delegates to this flow's memory.
@@ -1460,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(
*[
@@ -1474,6 +1503,14 @@ class Flow(BaseModel, Generic[T], metaclass=FlowMeta):
not self.suppress_flow_events
and not self._should_defer_trace_finalization()
):
# Background memory saves must finish (and emit their
# completed/failed events) before flow-finished triggers
# listener teardown/finalization; the flush then waits for those
# events' async handlers, mirroring Crew._create_crew_output.
# Offloaded to a thread so the blocking waits don't stall other
# coroutines on the loop.
await asyncio.to_thread(self._drain_memory_writes)
await asyncio.to_thread(crewai_event_bus.flush)
future = crewai_event_bus.emit(
self,
FlowFinishedEvent(
@@ -2013,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(
@@ -2038,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
@@ -2273,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]
@@ -2285,6 +2371,14 @@ class Flow(BaseModel, Generic[T], metaclass=FlowMeta):
# flag is read from either the instance attribute or an extension
# definition.
if not self._should_defer_trace_finalization():
# Background memory saves must finish (and emit their
# completed/failed events) before flow-finished triggers
# listener teardown/finalization; the flush then waits for
# those events' async handlers, mirroring
# Crew._create_crew_output. Offloaded to a thread so the
# blocking waits don't stall other coroutines on the loop.
await asyncio.to_thread(self._drain_memory_writes)
await asyncio.to_thread(crewai_event_bus.flush)
future = crewai_event_bus.emit(
self,
FlowFinishedEvent(
@@ -2317,9 +2411,9 @@ class Flow(BaseModel, Generic[T], metaclass=FlowMeta):
return final_output
finally:
# Ensure all background memory saves complete before returning
if self.memory is not None and hasattr(self.memory, "drain_writes"):
self.memory.drain_writes()
# Safety net for the exception path; the success path already
# drained before emitting FlowFinishedEvent.
self._drain_memory_writes()
# Drain pending LLMCallCompletedEvent handlers before
# detaching so `flow.usage_metrics` reflects every call
# emitted during this kickoff — mirrors `Crew.kickoff()`,
@@ -2338,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)
@@ -2530,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.
@@ -2557,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

View File

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

View File

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

View File

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

View 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

View 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

View File

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

View File

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

View File

@@ -6,6 +6,7 @@ from typing import Any
from pydantic import BaseModel, Field
from crewai.types.usage_metrics import UsageMetrics
from crewai.utilities.planning_types import TodoItem
from crewai.utilities.types import LLMMessage
@@ -38,7 +39,13 @@ class LiteAgentOutput(BaseModel):
)
agent_role: str = Field(description="Role of the agent that produced this output")
usage_metrics: dict[str, Any] | None = Field(
description="Token usage metrics for this execution", default=None
description=(
"Token usage for this kickoff call only (guardrail retries "
"included), not the LLM instance's cumulative totals, as a "
"plain dict; ``token_usage`` exposes the same data as a "
"UsageMetrics object"
),
default=None,
)
messages: list[LLMMessage] = Field(
description="Messages of the agent", default_factory=list
@@ -86,6 +93,19 @@ class LiteAgentOutput(BaseModel):
return self.pydantic.model_dump()
return {}
@property
def token_usage(self) -> UsageMetrics:
"""Token usage as a ``UsageMetrics`` object.
Same attribute name and type as ``CrewOutput.token_usage``, so a
usage accessor written for one result type works on both. Returns
zeroed metrics when no usage was captured (``usage_metrics`` is
``None``).
"""
if not self.usage_metrics:
return UsageMetrics()
return UsageMetrics.model_validate(self.usage_metrics)
@property
def completed_todos(self) -> list[TodoExecutionResult]:
"""Get only the completed todos."""

View File

@@ -394,19 +394,35 @@ class LLM(BaseLLM):
"""Factory method that routes to native SDK or falls back to LiteLLM.
Routing priority:
1. If 'provider' kwarg is present, use that provider with constants
2. If only 'model' kwarg, use constants to infer provider
3. If "/" in model name:
1. If ``custom_openai=True``, force the native OpenAI provider,
overriding any explicit provider. A custom endpoint is required.
2. If ``provider`` is present, use that provider.
3. If "/" is in the model name:
- Check if prefix is a native provider (openai/anthropic/azure/bedrock/gemini)
- If yes, validate model against constants
- If valid, route to native SDK; otherwise route to LiteLLM
4. Otherwise, infer the provider from the model name.
"""
if not model or not isinstance(model, str):
raise ValueError("Model must be a non-empty string")
custom_openai = bool(kwargs.pop("custom_openai", False))
custom_openai_route = custom_openai
explicit_provider = kwargs.get("provider")
if explicit_provider:
if custom_openai:
if not cls._has_custom_openai_endpoint(kwargs):
raise ValueError(
"custom_openai=True requires base_url, api_base, "
"OPENAI_BASE_URL, or OPENAI_API_BASE"
)
provider = "openai"
use_native = True
prefix, separator, model_part = model.partition("/")
model_string = (
model_part if separator and prefix.lower() == "openai" else model
)
elif explicit_provider:
provider = explicit_provider
use_native = True
model_string = model
@@ -435,9 +451,17 @@ class LLM(BaseLLM):
canonical_provider = provider_mapping.get(prefix.lower())
if canonical_provider and cls._validate_model_in_constants(
model_part, canonical_provider
):
valid_native_model = bool(
canonical_provider
and cls._validate_model_in_constants(model_part, canonical_provider)
)
custom_openai_route = bool(
canonical_provider == "openai"
and not valid_native_model
and cls._has_custom_openai_base_url(kwargs)
)
if canonical_provider and (valid_native_model or custom_openai_route):
provider = canonical_provider
use_native = True
model_string = model_part
@@ -455,6 +479,8 @@ class LLM(BaseLLM):
try:
# Remove 'provider' from kwargs if it exists to avoid duplicate keyword argument
kwargs_copy = {k: v for k, v in kwargs.items() if k != "provider"}
if custom_openai_route:
kwargs_copy["custom_openai"] = True
return cast(
Self,
native_class(model=model_string, provider=provider, **kwargs_copy),
@@ -590,6 +616,20 @@ class LLM(BaseLLM):
return cls._matches_provider_pattern(model, provider)
@staticmethod
def _has_custom_openai_base_url(kwargs: dict[str, Any]) -> bool:
"""Return whether this call explicitly configures a custom endpoint."""
return bool(kwargs.get("base_url") or kwargs.get("api_base"))
@classmethod
def _has_custom_openai_endpoint(cls, kwargs: dict[str, Any]) -> bool:
"""Return whether a custom endpoint is configured explicitly or by env."""
return bool(
cls._has_custom_openai_base_url(kwargs)
or os.getenv("OPENAI_BASE_URL")
or os.getenv("OPENAI_API_BASE")
)
@classmethod
def _infer_provider_from_model(cls, model: str) -> str:
"""Infer the provider from the model name.

View File

@@ -966,8 +966,15 @@ class BaseLLM(BaseModel, ABC):
def get_token_usage_summary(self) -> UsageMetrics:
"""Get summary of token usage for this LLM instance.
The counters are cumulative for the lifetime of this instance: they
grow across every call made through it, including calls issued by
different agents sharing the instance. For usage scoped to a single
call, snapshot before and after and use
``UsageMetrics.delta_since`` (agent kickoff results already report
per-call usage this way).
Returns:
Dictionary with token usage totals
UsageMetrics with this instance's lifetime token usage totals.
"""
return UsageMetrics(**self._token_usage)
@@ -1000,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,
@@ -1017,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
@@ -1067,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,
@@ -1087,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

View File

@@ -232,6 +232,7 @@ class OpenAICompletion(BaseLLM):
auto_chain: bool = False
auto_chain_reasoning: bool = False
api_base: str | None = None
custom_openai: bool = False
is_o1_model: bool = False
is_gpt4_model: bool = False
@@ -245,6 +246,20 @@ class OpenAICompletion(BaseLLM):
def _normalize_openai_fields(cls, data: Any) -> Any:
if not isinstance(data, dict):
return data
if data.get("custom_openai"):
custom_base_url = (
data.get("base_url")
or data.get("api_base")
or os.getenv("OPENAI_BASE_URL")
or os.getenv("OPENAI_API_BASE")
)
if not custom_base_url:
raise ValueError(
"custom_openai=True requires base_url, api_base, "
"OPENAI_BASE_URL, or OPENAI_API_BASE"
)
if not data.get("base_url") and not data.get("api_base"):
data["base_url"] = custom_base_url
if not data.get("provider"):
data["provider"] = "openai"
data["api_key"] = data.get("api_key") or os.getenv("OPENAI_API_KEY")
@@ -355,6 +370,15 @@ class OpenAICompletion(BaseLLM):
config["seed"] = self.seed
if self.reasoning_effort is not None:
config["reasoning_effort"] = self.reasoning_effort
if self.custom_openai:
config["model"] = self.model
config["custom_openai"] = True
config["base_url"] = (
self.base_url
or self.api_base
or os.getenv("OPENAI_BASE_URL")
or os.getenv("OPENAI_API_BASE")
)
return config
def _get_client_params(self) -> dict[str, Any]:
@@ -372,6 +396,7 @@ class OpenAICompletion(BaseLLM):
"base_url": self.base_url
or self.api_base
or os.getenv("OPENAI_BASE_URL")
or os.getenv("OPENAI_API_BASE")
or None,
"timeout": self.timeout,
"max_retries": self.max_retries,

View File

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

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

View File

@@ -5,7 +5,6 @@ import asyncio
from collections.abc import Awaitable, Callable
import importlib
from inspect import Parameter, signature
import json
import threading
from typing import (
Any,
@@ -37,9 +36,9 @@ from crewai.tools.structured_tool import (
_infer_result_schema_from_callable,
_serialize_schema,
build_schema_hint,
format_description_for_llm,
)
from crewai.types.callback import SerializableCallable, _resolve_dotted_path
from crewai.utilities.pydantic_schema_utils import generate_model_description
from crewai.utilities.string_utils import sanitize_tool_name
@@ -479,15 +478,27 @@ class BaseTool(BaseModel, ABC):
f"{self.__class__.__name__}Schema", **fields
)
@property
def formatted_description(self) -> str:
"""LLM-facing composite of name, argument schema, and description.
Use this when rendering the tool into a prompt; ``description``
holds only the authored text.
"""
return format_description_for_llm(self.name, self.args_schema, self.description)
def _generate_description(self) -> None:
"""Generate the tool description with a JSON schema for arguments."""
schema = generate_model_description(self.args_schema)
args_json = json.dumps(schema["json_schema"]["schema"], indent=2)
self.description = (
f"Tool Name: {sanitize_tool_name(self.name)}\n"
f"Tool Arguments: {args_json}\n"
f"Tool Description: {self.description}"
)
"""Deprecated hook kept for backward compatibility; does nothing.
Historically this rewrote the public ``description`` field at
construction time into the LLM-facing composite (``Tool Name: …\\n
Tool Arguments: …\\nTool Description: <authored>``). The authored
``description`` is now preserved as written and the composite is
exposed separately via :attr:`formatted_description`.
``model_post_init`` still calls this so subclasses that override it
(e.g. adapters that customize the composite) keep working.
"""
_BASE_TOOL_CLS = BaseTool

View File

@@ -4,6 +4,7 @@ import asyncio
from collections.abc import Callable
import inspect
import json
import re
import textwrap
from typing import TYPE_CHECKING, Annotated, Any, cast, get_type_hints
import warnings
@@ -21,7 +22,10 @@ from pydantic import (
from typing_extensions import Self
from crewai.utilities.logger import Logger
from crewai.utilities.pydantic_schema_utils import create_model_from_schema
from crewai.utilities.pydantic_schema_utils import (
create_model_from_schema,
generate_model_description,
)
from crewai.utilities.string_utils import sanitize_tool_name
@@ -108,6 +112,70 @@ def build_schema_hint(args_schema: type[BaseModel]) -> str:
return ""
# Matches a description that IS a pre-composed LLM block (as written by
# older versions into the field, and by adapters that still bake it in).
# Anchored to the full three-line shape so authored prose that merely
# mentions "Tool Description:" is never mistaken for a composite. Greedy
# ``.*`` keeps only the text after the LAST marker, matching the historical
# split behavior for nested pre-baked blocks.
_COMPOSITE_DESCRIPTION_RE = re.compile(
r"^Tool Name:.*\nTool Arguments:.*\nTool Description:\s*",
re.DOTALL,
)
def strip_composite_description_prefix(description: str) -> str:
"""Return the authored text from a pre-composed LLM description block.
Descriptions that don't start with the composite shape are returned
unchanged.
"""
match = _COMPOSITE_DESCRIPTION_RE.match(description)
if match:
return description[match.end() :]
return description
def format_description_for_llm(
name: str,
args_schema: type[BaseModel] | None,
description: str,
) -> str:
"""Compose the LLM-facing tool description.
Combines the tool name, its argument JSON schema, and the authored
description into the prompt block agents see. The authored
``description`` field itself is never mutated — prompt rendering calls
this on demand.
Idempotent: if ``description`` already *is* a composed block (e.g. a
tool deserialized from a checkpoint written by an older version, or an
adapter that bakes the composite into the field), only the authored
text after the marker is used. The check is anchored to the composite
shape, so authored prose that merely mentions ``"Tool Description:"``
passes through untouched.
Args:
name: The tool name (sanitized for the prompt).
args_schema: The tool's argument schema, if any.
description: The authored tool description.
Returns:
The composed, LLM-facing description block.
"""
description = strip_composite_description_prefix(description)
if args_schema is not None:
schema = generate_model_description(args_schema)
args_json = json.dumps(schema["json_schema"]["schema"], indent=2)
else:
args_json = "{}"
return (
f"Tool Name: {sanitize_tool_name(name)}\n"
f"Tool Arguments: {args_json}\n"
f"Tool Description: {description}"
)
class ToolUsageLimitExceededError(Exception):
"""Exception raised when a tool has reached its maximum usage limit."""
@@ -141,6 +209,15 @@ class CrewStructuredTool(BaseModel):
_logger: Logger = PrivateAttr(default_factory=Logger)
_original_tool: Any = PrivateAttr(default=None)
@property
def formatted_description(self) -> str:
"""LLM-facing composite of name, argument schema, and description.
Use this when rendering the tool into a prompt; ``description``
holds only the authored text.
"""
return format_description_for_llm(self.name, self.args_schema, self.description)
@model_validator(mode="after")
def _validate_func(self) -> Self:
if self.func is not None:

View File

@@ -430,7 +430,7 @@ class ToolUsage:
).format(
error=e,
tool=sanitize_tool_name(tool.name),
tool_inputs=tool.description,
tool_inputs=tool.formatted_description,
)
result = ToolUsageError(
f"\n{error_message}.\nMoving on then. {I18N_DEFAULT.slice('format').format(tool_names=self.tools_names)}"
@@ -670,7 +670,7 @@ class ToolUsage:
).format(
error=e,
tool=sanitize_tool_name(tool.name),
tool_inputs=tool.description,
tool_inputs=tool.formatted_description,
)
result = ToolUsageError(
f"\n{error_message}.\nMoving on then. {I18N_DEFAULT.slice('format').format(tool_names=self.tools_names)}"
@@ -803,7 +803,7 @@ class ToolUsage:
def _render(self) -> str:
"""Render the tool name and description in plain text."""
descriptions = [tool.description for tool in self.tools]
descriptions = [tool.formatted_description for tool in self.tools]
return "\n--\n".join(descriptions)
def _function_calling(

View File

@@ -76,6 +76,38 @@ class UsageMetrics(BaseModel):
self.cache_creation_tokens += usage_metrics.cache_creation_tokens
self.successful_requests += usage_metrics.successful_requests
def delta_since(self, baseline: Self) -> Self:
"""Return the per-call usage accrued since ``baseline`` was captured.
Both objects must come from the same monotonically increasing
accumulator (e.g. an LLM instance's lifetime counters). Differences
are clamped at zero so a reset accumulator can't produce negative
usage.
Args:
baseline: A snapshot of the same accumulator taken earlier.
Returns:
A new UsageMetrics with the field-wise difference.
"""
return type(self)(
total_tokens=max(0, self.total_tokens - baseline.total_tokens),
prompt_tokens=max(0, self.prompt_tokens - baseline.prompt_tokens),
cached_prompt_tokens=max(
0, self.cached_prompt_tokens - baseline.cached_prompt_tokens
),
completion_tokens=max(
0, self.completion_tokens - baseline.completion_tokens
),
reasoning_tokens=max(0, self.reasoning_tokens - baseline.reasoning_tokens),
cache_creation_tokens=max(
0, self.cache_creation_tokens - baseline.cache_creation_tokens
),
successful_requests=max(
0, self.successful_requests - baseline.successful_requests
),
)
@classmethod
def from_provider_dict(cls, usage_data: dict[str, Any] | None) -> Self | None:
"""Normalize a provider's raw usage dict into a ``UsageMetrics``.

View File

@@ -27,7 +27,10 @@ from crewai.agents.parser import (
from crewai.llms.base_llm import BaseLLM, call_stop_override
from crewai.tools import BaseTool as CrewAITool
from crewai.tools.base_tool import BaseTool
from crewai.tools.structured_tool import CrewStructuredTool
from crewai.tools.structured_tool import (
CrewStructuredTool,
strip_composite_description_prefix,
)
from crewai.tools.tool_types import ToolResult
from crewai.utilities.errors import AgentRepositoryError
from crewai.utilities.exceptions.context_window_exceeding_exception import (
@@ -147,7 +150,14 @@ def render_text_description_and_args(
Returns:
Plain text description of tools.
"""
tool_strings = [tool.description for tool in tools]
# Fall back to the raw description for duck-typed tools (including test
# mocks) that don't provide a real formatted_description string.
tool_strings = [
formatted
if isinstance((formatted := getattr(tool, "formatted_description", None)), str)
else tool.description
for tool in tools
]
return "\n".join(tool_strings)
@@ -190,10 +200,10 @@ def convert_tools_to_openai_schema(
except Exception:
parameters = {}
# BaseTool formats description as "Tool Name: ...\nTool Arguments: ...\nTool Description: {original}"
description = tool.description
if "Tool Description:" in description:
description = description.split("Tool Description:")[-1].strip()
# Old checkpoints and some adapters bake the composed LLM block
# ("Tool Name: ...\nTool Arguments: ...\nTool Description: {authored}")
# into the description field; keep only the authored text here.
description = strip_composite_description_prefix(tool.description)
sanitized_name = sanitize_tool_name(tool.name)
@@ -1443,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)
@@ -1507,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,
@@ -1516,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:
@@ -1577,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(
@@ -1680,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:
@@ -1738,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
@@ -1752,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:

View File

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

View File

@@ -1138,3 +1138,160 @@ def test_lite_agent_memory_instance_recall_and_save_called():
mock_memory.remember_many.assert_called_once_with(
["Fact one.", "Fact two."], agent_role="Test"
)
class _FixedUsageLLM(BaseLLM):
"""Offline BaseLLM that records fixed usage (100/10 tokens) per call."""
def __init__(self):
super().__init__(model="fixed-usage-model")
def call(
self,
messages,
tools=None,
callbacks=None,
available_functions=None,
from_task=None,
from_agent=None,
response_model=None,
) -> str:
self._track_token_usage_internal(
{"prompt_tokens": 100, "completion_tokens": 10, "total_tokens": 110}
)
return "Thought: I know the answer.\nFinal Answer: fake answer"
def supports_function_calling(self) -> bool:
return False
def supports_stop_words(self) -> bool:
return False
def get_context_window_size(self) -> int:
return 4096
class TestKickoffUsageMetricsArePerCall:
"""Regression tests for EPD-177: kickoff results used to expose the LLM
instance's cumulative lifetime counters, so counts accumulated across
calls and pooled across agents sharing one LLM object.
"""
def _make_agent(self, role: str, llm: BaseLLM) -> Agent:
return Agent(
role=role,
goal="Answer questions.",
backstory="Test agent.",
llm=llm,
verbose=False,
)
def test_agents_sharing_one_llm_report_per_call_usage(self):
shared = _FixedUsageLLM()
r1 = self._make_agent("agent one", shared).kickoff("question one")
r2 = self._make_agent("agent two", shared).kickoff("question two")
assert r1.usage_metrics is not None
assert r1.usage_metrics["prompt_tokens"] > 0
# The second agent's call must not include the first agent's tokens.
assert r2.usage_metrics == r1.usage_metrics
# The shared LLM instance still exposes cumulative lifetime totals.
lifetime = shared.get_token_usage_summary()
assert lifetime.prompt_tokens == (
r1.usage_metrics["prompt_tokens"] + r2.usage_metrics["prompt_tokens"]
)
assert lifetime.successful_requests == (
r1.usage_metrics["successful_requests"]
+ r2.usage_metrics["successful_requests"]
)
def test_repeated_kickoffs_on_same_agent_report_per_call_usage(self):
agent = self._make_agent("agent", _FixedUsageLLM())
r1 = agent.kickoff("question one")
r2 = agent.kickoff("question two")
assert r1.usage_metrics is not None
assert r1.usage_metrics["prompt_tokens"] > 0
assert r2.usage_metrics == r1.usage_metrics
@pytest.mark.asyncio
async def test_async_kickoff_reports_per_call_usage(self):
shared = _FixedUsageLLM()
r1 = await self._make_agent("agent one", shared).kickoff_async("question one")
r2 = await self._make_agent("agent two", shared).kickoff_async("question two")
assert r1.usage_metrics is not None
assert r1.usage_metrics["prompt_tokens"] > 0
assert r2.usage_metrics == r1.usage_metrics
def test_guardrail_retry_usage_includes_all_attempts(self):
"""A guardrail retry re-invokes the LLM within the same kickoff, so
the result must report the whole call's usage — every attempt — not
just the last one."""
baseline = (
self._make_agent("baseline", _FixedUsageLLM())
.kickoff("question one")
.usage_metrics
)
attempts: list[str] = []
def flaky_guardrail(output):
attempts.append(output.raw)
if len(attempts) == 1:
return (False, "Please try again.")
return (True, output.raw)
agent = Agent(
role="agent",
goal="Answer questions.",
backstory="Test agent.",
llm=_FixedUsageLLM(),
guardrail=flaky_guardrail,
verbose=False,
)
result = agent.kickoff("question one")
assert len(attempts) == 2
assert result.usage_metrics["successful_requests"] == (
2 * baseline["successful_requests"]
)
assert result.usage_metrics["prompt_tokens"] == 2 * baseline["prompt_tokens"]
assert result.usage_metrics["total_tokens"] == 2 * baseline["total_tokens"]
class TestUsageMetricsDeltaSince:
def test_field_wise_difference(self):
baseline = UsageMetrics(
total_tokens=110,
prompt_tokens=100,
completion_tokens=10,
successful_requests=1,
)
current = UsageMetrics(
total_tokens=330,
prompt_tokens=300,
completion_tokens=30,
cached_prompt_tokens=5,
reasoning_tokens=7,
cache_creation_tokens=3,
successful_requests=3,
)
delta = current.delta_since(baseline)
assert delta == UsageMetrics(
total_tokens=220,
prompt_tokens=200,
completion_tokens=20,
cached_prompt_tokens=5,
reasoning_tokens=7,
cache_creation_tokens=3,
successful_requests=2,
)
def test_clamps_negative_differences_to_zero(self):
baseline = UsageMetrics(total_tokens=100, prompt_tokens=90, successful_requests=2)
delta = UsageMetrics().delta_since(baseline)
assert delta == UsageMetrics()

View File

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

View 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

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

View File

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

View File

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

View File

@@ -30,10 +30,156 @@ def test_openai_completion_is_used_when_no_provider_prefix():
llm = LLM(model="gpt-4o")
from crewai.llms.providers.openai.completion import OpenAICompletion
assert isinstance(llm, OpenAICompletion)
assert llm.__class__.__name__ == "OpenAICompletion"
assert llm.provider == "openai"
assert llm.model == "gpt-4o"
def test_custom_openai_flag_uses_native_openai_without_provider_prefix():
"""Custom OpenAI-compatible endpoints can serve arbitrary model ids."""
with patch.dict(os.environ, {"OPENAI_API_KEY": "test-key"}, clear=False):
llm = LLM(
model="anthropic/claude-sonnet-4-6",
custom_openai=True,
base_url="https://gateway.example/v1",
is_litellm=False,
)
assert llm.__class__.__name__ == "OpenAICompletion"
assert llm.is_litellm is False
assert llm.provider == "openai"
assert llm.model == "anthropic/claude-sonnet-4-6"
assert llm.base_url == "https://gateway.example/v1"
assert llm.custom_openai is True
assert "custom_openai" not in llm.additional_params
config = llm.to_config_dict()
assert config["model"] == "anthropic/claude-sonnet-4-6"
assert config["custom_openai"] is True
assert config["base_url"] == "https://gateway.example/v1"
rebuilt = LLM(**config)
assert isinstance(rebuilt, OpenAICompletion)
assert rebuilt.model == "anthropic/claude-sonnet-4-6"
assert rebuilt.base_url == "https://gateway.example/v1"
def test_custom_openai_flag_requires_custom_base_url():
"""Avoid routing arbitrary custom model ids to api.openai.com by mistake."""
with patch.dict(os.environ, {"OPENAI_API_KEY": "test-key"}, clear=True):
with pytest.raises(ValueError, match="custom_openai=True requires"):
LLM(
model="anthropic/claude-sonnet-4-6",
custom_openai=True,
is_litellm=False,
)
def test_direct_custom_openai_completion_requires_custom_base_url():
"""Direct construction must not silently fall back to api.openai.com."""
with patch.dict(os.environ, {"OPENAI_API_KEY": "test-key"}, clear=True):
with pytest.raises(ValueError, match="custom_openai=True requires"):
OpenAICompletion(
model="anthropic/claude-sonnet-4-6",
custom_openai=True,
)
def test_custom_openai_flag_strips_openai_routing_prefix():
"""The openai/ routing prefix is not part of the gateway's model id."""
with patch.dict(os.environ, {"OPENAI_API_KEY": "test-key"}, clear=False):
llm = LLM(
model="openai/anthropic/claude-sonnet-4-6",
custom_openai=True,
base_url="https://gateway.example/v1",
is_litellm=False,
)
assert isinstance(llm, OpenAICompletion)
assert llm.model == "anthropic/claude-sonnet-4-6"
def test_openai_prefixed_custom_endpoint_uses_native_sdk_for_nested_model_id():
"""Custom OpenAI-compatible endpoints may serve non-OpenAI model ids."""
with patch.dict(os.environ, {"OPENAI_API_KEY": "test-key"}, clear=False):
llm = LLM(
model="openai/anthropic/claude-sonnet-4-6",
base_url="https://gateway.example/v1",
is_litellm=False,
)
assert llm.__class__.__name__ == "OpenAICompletion"
assert llm.is_litellm is False
assert llm.provider == "openai"
assert llm.model == "anthropic/claude-sonnet-4-6"
assert llm.custom_openai is True
assert llm.base_url == "https://gateway.example/v1"
def test_explicit_custom_openai_uses_legacy_api_base_env_var():
"""Explicit custom routing supports the legacy endpoint environment variable."""
with patch.dict(
os.environ,
{
"OPENAI_API_KEY": "test-key",
"OPENAI_API_BASE": "https://gateway.example/v1",
},
clear=False,
):
os.environ.pop("OPENAI_BASE_URL", None)
llm = LLM(
model="openai/anthropic/claude-sonnet-4-6",
custom_openai=True,
is_litellm=False,
)
assert isinstance(llm, OpenAICompletion)
assert llm.is_litellm is False
assert llm.provider == "openai"
assert llm.model == "anthropic/claude-sonnet-4-6"
assert llm.custom_openai is True
def test_openai_prefixed_unknown_model_ignores_ambient_base_url_for_routing():
"""Ambient OpenAI configuration must not opt unknown models into native routing."""
with patch.dict(
os.environ,
{
"OPENAI_API_KEY": "test-key",
"OPENAI_BASE_URL": "https://gateway.example/v1",
},
clear=True,
):
with (
patch("crewai.llm._ensure_litellm", return_value=False),
pytest.raises(ImportError, match="LiteLLM fallback package"),
):
LLM(model="openai/not-a-real-openai-model")
@pytest.mark.parametrize("endpoint_field", ["api_base", "env"])
def test_custom_openai_config_preserves_resolved_endpoint(endpoint_field):
"""Serialized custom OpenAI configs can reconstruct the same endpoint."""
kwargs = {}
env = {"OPENAI_API_KEY": "test-key"}
if endpoint_field == "api_base":
kwargs["api_base"] = "https://gateway.example/v1"
else:
env["OPENAI_API_BASE"] = "https://gateway.example/v1"
with patch.dict(os.environ, env, clear=True):
llm = LLM(
model="anthropic/claude-sonnet-4-6",
custom_openai=True,
**kwargs,
)
config = llm.to_config_dict()
assert config["base_url"] == "https://gateway.example/v1"
rebuilt = LLM(**config)
assert isinstance(rebuilt, OpenAICompletion)
assert rebuilt.base_url == "https://gateway.example/v1"
@pytest.mark.vcr()
def test_openai_is_default_provider_without_explicit_llm_set_on_agent():
"""
@@ -60,14 +206,13 @@ def test_openai_is_default_provider_without_explicit_llm_set_on_agent():
def test_openai_completion_module_is_imported():
def test_openai_completion_module_is_imported(monkeypatch):
"""
Test that the completion module is properly imported when using OpenAI provider
"""
module_name = "crewai.llms.providers.openai.completion"
if module_name in sys.modules:
del sys.modules[module_name]
monkeypatch.delitem(sys.modules, module_name, raising=False)
LLM(model="gpt-4o")
@@ -421,12 +566,25 @@ def test_openai_get_client_params_with_env_var():
client_params = llm._get_client_params()
assert client_params["base_url"] == "https://env.openai.com/v1"
def test_openai_get_client_params_with_legacy_api_base_env_var():
"""
Test that _get_client_params uses OPENAI_API_BASE when OPENAI_BASE_URL is absent.
"""
with patch.dict(os.environ, {
"OPENAI_API_BASE": "https://legacy-env.openai.com/v1",
}, clear=False):
os.environ.pop("OPENAI_BASE_URL", None)
llm = OpenAICompletion(model="gpt-4o")
client_params = llm._get_client_params()
assert client_params["base_url"] == "https://legacy-env.openai.com/v1"
def test_openai_get_client_params_priority_order():
"""
Test the priority order: base_url > api_base > OPENAI_BASE_URL env var
Test the priority order: base_url > api_base > OPENAI_BASE_URL > OPENAI_API_BASE
"""
with patch.dict(os.environ, {
"OPENAI_BASE_URL": "https://env.openai.com/v1",
"OPENAI_API_BASE": "https://legacy-env.openai.com/v1",
}):
llm1 = OpenAICompletion(
model="gpt-4o",

View File

@@ -859,6 +859,7 @@ def test_cache_hitting_between_agents(researcher, writer, ceo):
crew = Crew(
agents=[ceo, researcher],
tasks=tasks,
cache=True,
)
with patch.object(CacheHandler, "read") as read:
@@ -2246,7 +2247,9 @@ def test_tools_with_custom_caching():
agent=writer2,
)
crew = Crew(agents=[writer1, writer2], tasks=[task1, task2, task3, task4])
crew = Crew(
agents=[writer1, writer2], tasks=[task1, task2, task3, task4], cache=True
)
with patch.object(
CacheHandler, "add", wraps=crew._cache_handler.add
@@ -4598,6 +4601,98 @@ def test_reset_memory_uses_full_unified_memory_reset(researcher):
reset.assert_not_called()
def test_kickoff_drains_pending_memory_saves_before_completion_event(researcher):
"""Background memory saves must finish (and emit their completion events)
before CrewKickoffCompletedEvent, otherwise listeners that tear down on
kickoff-completed (e.g. telemetry sessions) see the save span as orphaned."""
import time
from crewai.events.types.crew_events import CrewKickoffCompletedEvent
order: list[str] = []
def slow_save():
time.sleep(0.3)
order.append("save-done")
crew = Crew(
agents=[researcher],
process=Process.sequential,
tasks=[
Task(description="Task 1", expected_output="output", agent=researcher),
],
memory=True,
task_callback=lambda _output: crew._memory._submit_save(slow_save),
)
completed = threading.Event()
with crewai_event_bus.scoped_handlers():
@crewai_event_bus.on(CrewKickoffCompletedEvent)
def on_completed(_source, _event):
order.append("kickoff-completed")
completed.set()
with patch.object(Agent, "execute_task", return_value="ok"):
crew.kickoff()
assert completed.wait(timeout=5)
assert order.index("save-done") < order.index("kickoff-completed")
def test_kickoff_drains_agent_memory_saves_before_completion_event(tmp_path):
"""Agents save through their own ``agent.memory`` when set; those pools
must also be drained before CrewKickoffCompletedEvent."""
import time
from crewai.events.types.crew_events import CrewKickoffCompletedEvent
agent_memory = Memory(storage=str(tmp_path / "agent-mem"))
agent_with_memory = Agent(
role="Researcher",
goal="Research things",
backstory="Experienced researcher",
memory=agent_memory,
)
order: list[str] = []
def slow_save():
time.sleep(0.3)
order.append("save-done")
crew = Crew(
agents=[agent_with_memory],
process=Process.sequential,
tasks=[
Task(
description="Task 1",
expected_output="output",
agent=agent_with_memory,
),
],
task_callback=lambda _output: agent_memory._submit_save(slow_save),
)
completed = threading.Event()
with crewai_event_bus.scoped_handlers():
@crewai_event_bus.on(CrewKickoffCompletedEvent)
def on_completed(_source, _event):
order.append("kickoff-completed")
completed.set()
with patch.object(Agent, "execute_task", return_value="ok"):
crew.kickoff()
assert completed.wait(timeout=5)
assert order.index("save-done") < order.index("kickoff-completed")
def test_reset_knowledge_with_only_crew_knowledge(researcher, writer):
mock_ks = MagicMock(spec=Knowledge)

View File

@@ -2353,3 +2353,41 @@ def test_locked_dict_proxy_ior():
def test_locked_dict_proxy_reversed():
flow = _make_dict_flow()
assert list(reversed(flow.state.data)) == ["c", "b", "a"]
def test_flow_drains_pending_memory_saves_before_finished_event(tmp_path):
"""Background memory saves must finish (and emit their completion events)
before FlowFinishedEvent, otherwise listeners that tear down on
flow-finished (e.g. telemetry sessions) see the save span as orphaned."""
import time
from crewai.memory.unified_memory import Memory
order: list[str] = []
def slow_save():
time.sleep(0.3)
order.append("save-done")
class MemoryFlow(Flow):
@start()
def step_1(self):
self.memory._submit_save(slow_save)
return "done"
flow = MemoryFlow(memory=Memory(storage=str(tmp_path / "flow-mem")))
finished = threading.Event()
with crewai_event_bus.scoped_handlers():
@crewai_event_bus.on(FlowFinishedEvent)
def on_finished(_source, _event):
order.append("flow-finished")
finished.set()
flow.kickoff()
assert finished.wait(timeout=5)
assert order.index("save-done") < order.index("flow-finished")

View File

@@ -1555,6 +1555,180 @@ class TestConversationalFlow:
)
class TestHandleTurnReplyFallback:
"""Regression tests for EPD-181: ``handle_turn()`` decided "did the
handler append its reply?" by comparing assistant-message counts. A
handler that appends its reply AND trims history to a cap left the count
unchanged, so the fallback appended the reply a second time — every turn,
once trimming engaged. The check now uses an explicit appended-this-turn
flag.
"""
MAX_MESSAGES = 4
def _make_bot(self) -> ConversationalFlow:
max_messages = self.MAX_MESSAGES
class EchoBot(ConversationalFlow):
def route_turn(self, context: dict[str, Any]) -> str | None:
return "ECHO"
@listen("ECHO")
def echo(self) -> str:
reply = f"echo: {self.state.current_user_message or ''}"
self.append_assistant_message(reply) # handler DOES append
if len(self.state.messages) > max_messages: # ...and trims
self.state.messages = self.state.messages[-max_messages:]
return reply
return EchoBot()
def test_no_duplicate_reply_when_handler_trims_history(self) -> None:
bot = self._make_bot()
for i in range(1, 5):
bot.handle_turn(f"message {i}")
contents = [message.content for message in bot.state.messages]
assert len(contents) == len(set(contents)), (
f"duplicate reply on turn {i}: {contents}"
)
# The capped window holds the last two full turns, in order.
assert [message.content for message in bot.state.messages] == [
"message 3",
"echo: message 3",
"message 4",
"echo: message 4",
]
def test_fallback_still_appends_when_handler_does_not_reply(self) -> None:
class SilentBot(ConversationalFlow):
def route_turn(self, context: dict[str, Any]) -> str | None:
return "WORK"
@listen("WORK")
def work(self) -> str:
return "computed reply" # returns without appending
bot = SilentBot()
bot.handle_turn("hello")
assistant_messages = [
message.content
for message in bot.state.messages
if message.role == "assistant"
]
assert assistant_messages == ["computed reply"]
class TestFalsyRouteTurnFallback:
"""A falsy ``route_turn()`` must never replay a previous turn's intent.
Regression tests for EPD-176: an overridden ``route_turn()`` returning
``None`` on an unhandled input used to silently reuse the sticky
``state.last_intent`` from the *previous* turn, running the wrong handler
with no error or warning.
"""
def test_falsy_route_turn_does_not_replay_previous_turns_intent(self) -> None:
ran: list[str] = []
class Bot(ConversationalFlow):
def route_turn(self, context: dict[str, Any]) -> str | None:
message = context.get("current_user_message") or ""
if "hello" in message.lower():
return "GREETING"
return None # unhandled input -> falsy return
@listen("GREETING")
def greeting(self) -> str:
ran.append("GREETING")
reply = "Hi! I only do greetings."
self.append_assistant_message(reply)
return reply
@listen("WEATHER")
def weather(self) -> str:
ran.append("WEATHER")
reply = "It is sunny."
self.append_assistant_message(reply)
return reply
flow = Bot()
flow.handle_turn("hello there")
assert ran == ["GREETING"]
assert flow.state.last_intent == "GREETING"
flow.handle_turn("what is the meaning of life?")
assert ran == ["GREETING"], (
"an unhandled turn must not re-run the previous turn's handler"
)
# With no routing decision the turn falls through to the built-in
# 'converse' default instead of replaying the stale intent.
assert flow.state.last_intent == "converse"
assert flow.state.messages[-1].content != "Hi! I only do greetings."
def test_stale_intent_ignored_but_route_selected_event_still_emitted(
self,
) -> None:
class Bot(ConversationalFlow):
def route_turn(self, context: dict[str, Any]) -> str | None:
message = context.get("current_user_message") or ""
return "work" if "work" in message else None
@listen("work")
def do_work(self) -> str:
self.append_assistant_message("worked")
return "worked"
flow = Bot()
routes: list[ConversationRouteSelectedEvent] = []
with crewai_event_bus.scoped_handlers():
@crewai_event_bus.on(ConversationRouteSelectedEvent)
def capture(_: Any, event: ConversationRouteSelectedEvent) -> None:
routes.append(event)
flow.handle_turn("work please")
flow.handle_turn("something unrelated")
crewai_event_bus.flush()
assert [event.route for event in routes] == ["work", "converse"]
# The fallback decision still reports the prior intent for visibility.
assert routes[1].previous_intent == "work"
def test_fresh_intent_classified_this_turn_still_routes(self) -> None:
"""The legacy ``default_intents`` path classifies per turn and must
keep routing on the freshly classified intent — including when the
intent changes between turns."""
ran: list[str] = []
@ConversationConfig(
default_intents=["search", "weather"], intent_llm="gpt-4o-mini"
)
class LegacyFlow(ConversationalFlow):
@listen("search")
def handle_search(self) -> str:
ran.append("search")
self.append_assistant_message("searched")
return "searched"
@listen("weather")
def handle_weather(self) -> str:
ran.append("weather")
self.append_assistant_message("sunny")
return "sunny"
flow = LegacyFlow()
with patch.object(
flow, "_collapse_to_outcome", side_effect=["search", "weather"]
):
flow.handle_turn("look up crewai")
flow.handle_turn("how is the weather?")
assert ran == ["search", "weather"]
assert flow.state.last_intent == "weather"
class TestFlowTracingWhenSuppressed:
def test_flow_started_emitted_when_panel_events_suppressed(self) -> None:
class QuietFlow(Flow[ChatState]):

View File

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

View File

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

View File

@@ -0,0 +1,263 @@
# mypy: ignore-errors
"""Regression tests for EPD-180: tool-result caching used to be ON by default,
so an LLM calling the same tool with identical arguments twice in one run got
the first (possibly stale) result back without the tool executing — silently
wrong for live-data tools, and silently dropped actions for stateful tools.
Caching is now opt-in: ``Crew(cache=True)`` for crews, ``Agent(cache=True)``
(or an explicit ``cache_handler``) for standalone agents. The machinery —
including per-tool ``cache_function`` write gating — is unchanged once opted
in.
The end-to-end tests run fully offline: a fake OpenAI client scripts two
identical tool calls followed by a final answer, mirroring the EPD-180
clean-room repro.
"""
from openai.types.chat import ChatCompletion
from pydantic import BaseModel, Field
from crewai import LLM, Agent, Crew, Task
from crewai.agents.cache.cache_handler import CacheHandler
from crewai.tools import BaseTool
class LookupArgs(BaseModel):
city: str = Field(description="City to look up.")
def make_live_tool():
"""A tool returning a different value on every real execution."""
executions = []
class LiveLookupTool(BaseTool):
name: str = "live_lookup"
description: str = "Returns a live (time-varying) reading for a city."
args_schema: type[BaseModel] = LookupArgs
# cache_function deliberately NOT set — exercising the default.
def _run(self, city: str) -> str:
executions.append(city)
return f"reading #{len(executions)} for {city}"
return LiveLookupTool(), executions
def make_scripted_llm():
"""An offline LLM whose client scripts two identical tool calls."""
def tool_call_response(call_id: str):
return {
"index": 0,
"finish_reason": "tool_calls",
"message": {
"role": "assistant",
"content": None,
"tool_calls": [
{
"id": call_id,
"type": "function",
"function": {
"name": "live_lookup",
"arguments": '{"city": "paris"}',
},
}
],
},
}
scripted = [
tool_call_response("call_1"),
tool_call_response("call_2"), # identical name+args, new id
{
"index": 0,
"finish_reason": "stop",
"message": {"role": "assistant", "content": "Final answer: done."},
},
]
class FakeCompletions:
def __init__(self):
self.n = 0
def create(self, **params):
choice = scripted[min(self.n, len(scripted) - 1)]
self.n += 1
return ChatCompletion.model_validate(
{
"id": f"chatcmpl-fake-{self.n}",
"object": "chat.completion",
"created": 1,
"model": params.get("model", "gpt-4o"),
"choices": [choice],
"usage": {
"prompt_tokens": 10,
"completion_tokens": 5,
"total_tokens": 15,
},
}
)
class FakeClient:
def __init__(self):
self.chat = type("Chat", (), {"completions": FakeCompletions()})()
llm = LLM(model="openai/gpt-4o")
llm._client = FakeClient()
return llm
def run_crew(**crew_kwargs):
tool, executions = make_live_tool()
agent = Agent(
role="reader",
goal="Look things up.",
backstory="Test agent.",
llm=make_scripted_llm(),
tools=[tool],
verbose=False,
)
task = Task(
description="Look up paris twice and report.",
expected_output="A report.",
agent=agent,
)
crew = Crew(agents=[agent], tasks=[task], verbose=False, **crew_kwargs)
crew.kickoff()
return executions
class TestToolCachingIsOptIn:
def test_default_reexecutes_identical_tool_calls(self):
"""EPD-180: with no opt-in, both identical calls must really execute."""
executions = run_crew()
assert len(executions) == 2
def test_crew_cache_true_dedupes_identical_tool_calls(self):
"""Opting in via Crew(cache=True) restores the dedup behavior."""
executions = run_crew(cache=True)
assert len(executions) == 1
class TestAgentCacheWiring:
def _agent(self, **kwargs) -> Agent:
return Agent(
role="reader",
goal="Look things up.",
backstory="Test agent.",
**kwargs,
)
def test_standalone_agent_has_no_cache_by_default(self):
agent = self._agent()
assert agent.tools_handler.cache is None
assert agent.cache_handler is None
def test_standalone_agent_explicit_cache_true_opts_in(self):
agent = self._agent(cache=True)
assert agent.tools_handler.cache is not None
assert agent.cache_handler is not None
def test_standalone_agent_explicit_cache_handler_opts_in(self):
handler = CacheHandler()
agent = self._agent(cache_handler=handler)
assert agent.tools_handler.cache is handler
def test_explicit_cache_false_stays_off_even_with_handler(self):
agent = self._agent(cache=False, cache_handler=CacheHandler())
assert agent.tools_handler.cache is None
def test_agents_accept_a_crew_offered_handler_by_default(self):
"""``Crew(cache=True)`` offers its handler via set_cache_handler at
kickoff; agents that didn't explicitly opt out must accept it."""
agent = self._agent()
assert agent.tools_handler.cache is None
handler = CacheHandler()
agent.set_cache_handler(handler)
assert agent.tools_handler.cache is handler
def test_agents_that_opted_out_refuse_a_crew_offered_handler(self):
agent = self._agent(cache=False)
agent.set_cache_handler(CacheHandler())
assert agent.tools_handler.cache is None
def test_copy_of_default_agent_does_not_opt_in(self):
"""copy() rebuilds from model_dump(), which includes the field
default cache=True — that must not read as an explicit opt-in on
the copy (Bugbot review finding on the original PR)."""
copied = self._agent().copy()
assert copied.tools_handler.cache is None
assert copied.cache_handler is None
def test_copy_of_opted_in_agent_stays_opted_in(self):
copied = self._agent(cache=True).copy()
assert copied.tools_handler.cache is not None
def test_copy_of_handler_opted_in_agent_stays_opted_in(self):
"""An explicit cache_handler is an opt-in too; copy() excludes the
handler itself, but the consent must survive — the copy wires its
own fresh handler (Bugbot review finding on the original PR)."""
source = self._agent(cache_handler=CacheHandler())
copied = source.copy()
assert copied.tools_handler.cache is not None
assert copied.tools_handler.cache is not source.tools_handler.cache
def test_copy_of_explicit_cache_false_with_handler_stays_off(self):
copied = self._agent(cache=False, cache_handler=CacheHandler()).copy()
assert copied.tools_handler.cache is None
def test_copy_of_crew_wired_agent_does_not_opt_in(self):
"""A handler offered by a crew at kickoff (set_cache_handler) is
runtime wiring, not construction-time consent — copies of such
agents must not become standalone cachers (Bugbot review finding
on the original PR)."""
agent = self._agent()
agent.set_cache_handler(CacheHandler()) # what Crew(cache=True) does
assert agent.tools_handler.cache is not None
copied = agent.copy()
assert copied.tools_handler.cache is None
assert copied.cache_handler is None
class TestHierarchicalManagerCacheWiring:
"""The auto-created hierarchical manager is built outside the agents
loop that offers the crew's cache handler; an opted-in crew must wire
the manager too (Bugbot review finding on the original PR)."""
def _crew(self, **crew_kwargs) -> Crew:
from crewai.process import Process
agent = Agent(role="worker", goal="Do work.", backstory="Test agent.")
task = Task(description="Do the work.", expected_output="Done.")
return Crew(
agents=[agent],
tasks=[task],
process=Process.hierarchical,
manager_llm="gpt-4o",
**crew_kwargs,
)
def test_manager_gets_crew_handler_when_cache_enabled(self):
crew = self._crew(cache=True)
crew._create_manager_agent()
assert crew.manager_agent.tools_handler.cache is crew._cache_handler
def test_manager_has_no_cache_when_crew_did_not_opt_in(self):
crew = self._crew()
crew._create_manager_agent()
assert crew.manager_agent.tools_handler.cache is None
def test_user_provided_manager_with_cache_false_stays_excluded(self):
manager = Agent(
role="manager",
goal="Manage.",
backstory="Test manager.",
cache=False,
allow_delegation=True,
)
crew = self._crew(cache=True)
crew.manager_agent = manager
crew._create_manager_agent()
assert manager.tools_handler.cache is None

View File

@@ -0,0 +1,143 @@
# mypy: ignore-errors
"""Regression tests for EPD-178: token usage was exposed in different shapes
and attribute names per code path — ``Agent.kickoff()`` results carried a
plain dict at ``.usage_metrics`` (no ``token_usage`` attribute at all), while
``Crew.kickoff()`` results carried a ``UsageMetrics`` object at
``.token_usage`` (no ``usage_metrics`` attribute), so any single accessor
written for one path raised ``AttributeError`` on the other.
Both result types now expose both surfaces: ``.token_usage`` as a
``UsageMetrics`` object and ``.usage_metrics`` as a plain dict.
"""
from crewai import Agent, Crew, Task
from crewai.crews.crew_output import CrewOutput
from crewai.lite_agent_output import LiteAgentOutput
from crewai.llms.base_llm import BaseLLM
from crewai.types.usage_metrics import UsageMetrics
class _FixedUsageLLM(BaseLLM):
"""Offline BaseLLM that records fixed usage (100/10 tokens) per call."""
def __init__(self):
super().__init__(model="fixed-usage-model")
def call(
self,
messages,
tools=None,
callbacks=None,
available_functions=None,
from_task=None,
from_agent=None,
response_model=None,
) -> str:
self._track_token_usage_internal(
{"prompt_tokens": 100, "completion_tokens": 10, "total_tokens": 110}
)
return "Thought: I know the answer.\nFinal Answer: fake answer"
def supports_function_calling(self) -> bool:
return False
def supports_stop_words(self) -> bool:
return False
def get_context_window_size(self) -> int:
return 4096
class TestUsageShapeUnitParity:
def test_lite_agent_output_exposes_token_usage_object(self):
metrics = UsageMetrics(
total_tokens=110,
prompt_tokens=100,
completion_tokens=10,
successful_requests=1,
)
output = LiteAgentOutput(
agent_role="analyst", usage_metrics=metrics.model_dump()
)
assert output.token_usage == metrics
assert isinstance(output.token_usage, UsageMetrics)
def test_lite_agent_output_token_usage_zeroed_when_absent(self):
output = LiteAgentOutput(agent_role="analyst")
assert output.usage_metrics is None
assert output.token_usage == UsageMetrics()
def test_crew_output_exposes_usage_metrics_dict(self):
metrics = UsageMetrics(
total_tokens=110,
prompt_tokens=100,
completion_tokens=10,
successful_requests=1,
)
output = CrewOutput(token_usage=metrics)
assert output.usage_metrics == metrics.model_dump()
assert isinstance(output.usage_metrics, dict)
def test_both_shapes_carry_identical_keys(self):
"""The dict shape has exactly the UsageMetrics fields on both types."""
crew_dict = CrewOutput(token_usage=UsageMetrics()).usage_metrics
lite = LiteAgentOutput(
agent_role="analyst", usage_metrics=UsageMetrics().model_dump()
)
assert set(crew_dict) == set(UsageMetrics.model_fields)
assert set(lite.usage_metrics) == set(UsageMetrics.model_fields)
class TestUsageShapeEndToEnd:
"""Mirror of the EPD-178 clean-room repro, offline via a fake BaseLLM."""
@staticmethod
def _read_via_object(result) -> int:
"""Single accessor written against the CrewOutput shape."""
return result.token_usage.prompt_tokens
@staticmethod
def _read_via_dict(result) -> int:
"""Single accessor written against the LiteAgentOutput shape."""
return result.usage_metrics["prompt_tokens"]
def test_single_accessor_works_on_both_kickoff_paths(self):
agent_a = Agent(
role="analyst",
goal="Answer questions.",
backstory="Test agent.",
llm=_FixedUsageLLM(),
verbose=False,
)
result_agent = agent_a.kickoff("a question")
agent_b = Agent(
role="analyst",
goal="Answer questions.",
backstory="Test agent.",
llm=_FixedUsageLLM(),
verbose=False,
)
task = Task(
description="Answer: a question",
expected_output="A short answer.",
agent=agent_b,
)
crew = Crew(agents=[agent_b], tasks=[task], verbose=False)
result_crew = crew.kickoff()
assert isinstance(result_agent, LiteAgentOutput)
assert isinstance(result_crew, CrewOutput)
# Both accessors work on both result types and agree with each other.
for result in (result_agent, result_crew):
object_read = self._read_via_object(result)
dict_read = self._read_via_dict(result)
assert object_read == dict_read
assert object_read > 0
assert isinstance(result.token_usage, UsageMetrics)
assert isinstance(result.usage_metrics, dict)

View File

@@ -18,11 +18,16 @@ def test_creating_a_tool_using_annotation():
return question
assert my_tool.name == "Name of my tool"
assert "Tool Name: name_of_my_tool" in my_tool.description
assert "Tool Arguments:" in my_tool.description
assert '"question"' in my_tool.description
assert '"type": "string"' in my_tool.description
assert "Tool Description: Clear description for what this tool is useful for" in my_tool.description
# The authored description is preserved as written; the LLM-facing
# composite lives at formatted_description.
assert my_tool.description == (
"Clear description for what this tool is useful for, your agent will need this information to use it."
)
assert "Tool Name: name_of_my_tool" in my_tool.formatted_description
assert "Tool Arguments:" in my_tool.formatted_description
assert '"question"' in my_tool.formatted_description
assert '"type": "string"' in my_tool.formatted_description
assert "Tool Description: Clear description for what this tool is useful for" in my_tool.formatted_description
assert my_tool.args_schema.model_json_schema()["properties"] == {
"question": {"title": "Question", "type": "string"}
}
@@ -33,9 +38,10 @@ def test_creating_a_tool_using_annotation():
converted_tool = my_tool.to_structured_tool()
assert converted_tool.name == "Name of my tool"
assert "Tool Name: name_of_my_tool" in converted_tool.description
assert "Tool Arguments:" in converted_tool.description
assert '"question"' in converted_tool.description
assert converted_tool.description == my_tool.description
assert "Tool Name: name_of_my_tool" in converted_tool.formatted_description
assert "Tool Arguments:" in converted_tool.formatted_description
assert '"question"' in converted_tool.formatted_description
assert converted_tool.args_schema.model_json_schema()["properties"] == {
"question": {"title": "Question", "type": "string"}
}
@@ -56,11 +62,16 @@ def test_creating_a_tool_using_baseclass():
my_tool = MyCustomTool()
assert my_tool.name == "Name of my tool"
assert "Tool Name: name_of_my_tool" in my_tool.description
assert "Tool Arguments:" in my_tool.description
assert '"question"' in my_tool.description
assert '"type": "string"' in my_tool.description
assert "Tool Description: Clear description for what this tool is useful for" in my_tool.description
# The authored description is preserved as written; the LLM-facing
# composite lives at formatted_description.
assert my_tool.description == (
"Clear description for what this tool is useful for, your agent will need this information to use it."
)
assert "Tool Name: name_of_my_tool" in my_tool.formatted_description
assert "Tool Arguments:" in my_tool.formatted_description
assert '"question"' in my_tool.formatted_description
assert '"type": "string"' in my_tool.formatted_description
assert "Tool Description: Clear description for what this tool is useful for" in my_tool.formatted_description
assert my_tool.args_schema.model_json_schema()["properties"] == {
"question": {"title": "Question", "type": "string"}
}
@@ -69,9 +80,10 @@ def test_creating_a_tool_using_baseclass():
converted_tool = my_tool.to_structured_tool()
assert converted_tool.name == "Name of my tool"
assert "Tool Name: name_of_my_tool" in converted_tool.description
assert "Tool Arguments:" in converted_tool.description
assert '"question"' in converted_tool.description
assert converted_tool.description == my_tool.description
assert "Tool Name: name_of_my_tool" in converted_tool.formatted_description
assert "Tool Arguments:" in converted_tool.formatted_description
assert '"question"' in converted_tool.formatted_description
assert converted_tool.args_schema.model_json_schema()["properties"] == {
"question": {"title": "Question", "type": "string"}
}
@@ -695,3 +707,88 @@ class TestToolDecoratorArunValidation:
with pytest.raises(ValueError, match="validation failed"):
await async_execute.arun(wrong_arg="value")
class TestAuthoredDescriptionPreserved:
"""Regression tests for EPD-179: BaseTool.model_post_init silently
rewrote the authored ``description`` into the LLM-facing composite
(``Tool Name: …\\nTool Arguments: …\\nTool Description: <authored>``).
The authored field must survive construction as written, with the
composite exposed separately at ``formatted_description``.
"""
AUTHORED = "Returns the current temperature for a city."
def _make_tool(self) -> BaseTool:
class TempArgs(BaseModel):
city: str = Field(description="City name to look up.")
class TempTool(BaseTool):
name: str = "get_temperature"
description: str = TestAuthoredDescriptionPreserved.AUTHORED
args_schema: type[BaseModel] = TempArgs
def _run(self, city: str) -> str:
return f"22C in {city}"
return TempTool()
def test_description_equals_authored_text(self):
tool_instance = self._make_tool()
assert tool_instance.description == self.AUTHORED
def test_formatted_description_contains_composite(self):
tool_instance = self._make_tool()
formatted = tool_instance.formatted_description
assert "Tool Name: get_temperature" in formatted
assert "Tool Arguments:" in formatted
assert '"city"' in formatted
assert formatted.endswith(f"Tool Description: {self.AUTHORED}")
def test_formatted_description_tracks_later_description_edits(self):
tool_instance = self._make_tool()
tool_instance.description = "Edited description."
assert tool_instance.formatted_description.endswith(
"Tool Description: Edited description."
)
def test_prose_mentioning_the_marker_is_not_truncated(self):
"""Authored text that merely mentions "Tool Description:" must reach
the LLM untouched — only descriptions that ARE a pre-composed block
(anchored three-line shape) get stripped."""
tool_instance = self._make_tool()
prose = (
"Formats prompts. The output includes a line reading "
"'Tool Description:' followed by the tool's summary."
)
tool_instance.description = prose
assert tool_instance.formatted_description.endswith(
f"Tool Description: {prose}"
)
def test_composite_is_not_reapplied_to_prebaked_descriptions(self):
"""A description that already contains a composed block (old
checkpoints, adapters that bake the composite into the field) must
not be double-wrapped."""
tool_instance = self._make_tool()
tool_instance.description = (
"Tool Name: get_temperature\n"
'Tool Arguments: {"city": "str"}\n'
f"Tool Description: {self.AUTHORED}"
)
formatted = tool_instance.formatted_description
assert formatted.count("Tool Description:") == 1
assert formatted.endswith(f"Tool Description: {self.AUTHORED}")
def test_prompt_rendering_still_uses_composite(self):
from crewai.utilities.agent_utils import render_text_description_and_args
tool_instance = self._make_tool()
structured = tool_instance.to_structured_tool()
assert structured.description == self.AUTHORED
for candidate in (tool_instance, structured):
rendered = render_text_description_and_args([candidate])
assert "Tool Name: get_temperature" in rendered
assert "Tool Arguments:" in rendered
assert f"Tool Description: {self.AUTHORED}" in rendered

View File

@@ -0,0 +1,46 @@
"""Flow panels must be suppressed while a TUI owns the screen."""
from rich.text import Text
from crewai.events.listeners.tracing.utils import set_tui_mode
from crewai.events.utils.console_formatter import ConsoleFormatter
def _make_formatter(monkeypatch):
fmt = ConsoleFormatter(verbose=True)
calls: list[object] = []
monkeypatch.setattr(fmt, "print", lambda *a, **k: calls.append(a))
return fmt, calls
def test_flow_panel_suppressed_in_tui_mode(monkeypatch):
fmt, calls = _make_formatter(monkeypatch)
set_tui_mode(True)
try:
fmt.print_panel(Text("x"), "🌊 Flow Started", "blue", is_flow=True)
finally:
set_tui_mode(False)
assert calls == []
def test_flow_panel_prints_when_not_tui_mode(monkeypatch):
fmt, calls = _make_formatter(monkeypatch)
set_tui_mode(False)
fmt.print_panel(Text("x"), "🌊 Flow Started", "blue", is_flow=True)
# Panel + trailing blank line.
assert len(calls) == 2
def test_non_flow_panel_unaffected_by_tui_mode(monkeypatch):
# tui_mode only gates flow panels; regular panels still follow verbose.
fmt, calls = _make_formatter(monkeypatch)
set_tui_mode(True)
try:
fmt.print_panel(Text("x"), "Task", "blue", is_flow=False)
finally:
set_tui_mode(False)
assert len(calls) == 2

121
uv.lock generated
View File

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