Compare commits

..

43 Commits

Author SHA1 Message Date
Vinicius Brasil
02eeefe5ea Add each composite action to FlowDefinition
Lets a definition loop over an array without writing Python. Each
iteration exposes `item` and prior steps `outputs`.

```yaml
do:
  call: each
  in: state.rows
  do:
    - normalize:
        call: tool
        ref: my_tools:NormalizeRowTool
        with: { row: "${ item }" }
    - lead_scoring:
        call: agent
        # ...
```
2026-06-14 16:05:25 -07:00
Vini Brasil
d80719df81 Add experimental crewai run --definition for flows (#6147)
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
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
Let users run a Flow from a Flow Definition YAML file or inline string
without writing Python, passing kickoff inputs as `--inputs` JSON. The
flag is gated behind an experimental warning since the definition format
may still change.
2026-06-12 22:31:05 -07:00
Vini Brasil
6ad821b157 Add expressions to FlowDefinition actions (#6145)
* Add expressions to FlowDefinition actions

Let definitions compute values without Python. A new `call: expression`
action evaluates a Common Expression Language (CEL) expression, and tool
`with:` blocks now render `${...}` CEL templates.

Example 1:

```yaml
decide:
  do:
    call: expression
    expr: "state.score >= 80 ? 'qualified' : 'nurture'"
  router: true
  emit: [qualified, nurture]
```

Example 2:

```yaml
search:
  do:
    call: tool
    ref: my.pkg:SearchTool
    with:
      search_query: "${outputs.build_query.query + ' news'}"
      max_results: "${state.limit}"
```

* Address code review comments

* Address code review comments

* Fix linting offenses

* Address code review comments

* Fix scrapgraph issue
2026-06-12 21:56:02 -07:00
Vini Brasil
2444895ca4 Implement Flow definition run tools without Python code (#6144)
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
A `do:` step can now say `call: tool` and name a CrewAI tool to run,
passing its inputs under `with:`. Before this, a definition could only
point at Python code to run.

```yaml
methods:
  search:
    start: true
    do:
      call: tool
      ref: crewai_tools:ExaSearchTool
      with:
        search_query: ai agents
```
2026-06-12 19:47:58 -07:00
Vini Brasil
bf291a7a55 Drive human feedback from the flow definition (#6133)
* Drive human feedback from the flow definition

@human_feedback previously wrapped methods with the full HITL runtime (feedback
request, outcome collapse, learn loop), so flows built from a YAML definition —
which carry no decorated callables — could not pause for or route on human
feedback.

# Conflicts:
#	lib/crewai/src/crewai/flow/persistence/decorators.py
#	lib/crewai/src/crewai/flow/runtime/__init__.py

* Address code review comments
2026-06-12 14:48:43 -07:00
Vini Brasil
64438cba37 Wire config and persistence from FlowDefinition into the runtime (#6132)
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
* Wire config and persistence from FlowDefinition into the runtime

`from_definition` was silently dropping all config fields; it now passes
`config.model_dump()` so suppress_flow_events, max_method_calls, etc.
actually apply.

Persistence is now engine-driven: `_persist_method_completion` fires
after every method using the definition's persist metadata, so
`@persist` no longer needs to wrap methods — it just stamps them.

* Address code review comments
2026-06-12 11:51:44 -07:00
Lucas Gomide
887adafd2c fix: aggregate token usage across all LLM calls (#6122)
* feat: aggregate LLM token usage at the flow level

Introduces `flow.usage_metrics`, a snapshot of every LLMCallCompletedEvent
emitted under the flow's `current_flow_id` for the duration of one kickoff
(or resume) call. Aggregation happens on the singleton event bus so it
covers crews, direct `LLM.call`s, and nested listener calls — solving the
mismatch where the SDK reported only the last crew's usage while the
Enterprise UI showed the correct full total.

Co-authored-by: Cursor <cursoragent@cursor.com>

* refactor: centralize provider key normalization in UsageMetrics

Add UsageMetrics.from_provider_dict to normalize raw LLM usage dicts
across providers (LiteLLM, native Anthropic, native Gemini, OpenAI
nested cached). BaseLLM._track_token_usage_internal and the flow-level
aggregator now share this single source of truth, so `flow.usage_metrics`
agrees with per-LLM totals on every provider — including the native
Anthropic path that emits `input_tokens`/`output_tokens` instead of
`prompt_tokens`/`completion_tokens`.

* fix: flush event bus before reading aggregated usage_metrics

`crewai_event_bus.emit` dispatches LLMCallCompletedEvent handlers on a
ThreadPoolExecutor (fire-and-forget), so a flow whose last LLM call
completes right before kickoff_async/resume_async returns can detach
the usage listener while that handler is still queued, leaving its
tokens off `flow.usage_metrics`. Match `Crew.kickoff()` and call
`crewai_event_bus.flush()` in both finally blocks so every handler
drains before the listener is detached.

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-12 12:55:22 -04:00
Rip&Tear
d3fc0d31f8 [codex] Redact file tool paths (#6134)
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
* Redact file tool paths

* Fix for pull request finding 'Empty except'

* Potential fix for pull request finding

---------
2026-06-12 15:50:40 +08:00
Vini Brasil
373dca3d04 Run flows from a definition without a Python subclass (#6104)
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
* Read flow dispatch from FlowDefinition

Store the definition in a `_definition` PrivateAttr at post-init and
convert the dispatch helpers (`_start_method_names`, `_listener_methods`,
`_start_condition`, `_listen_condition`, `_is_router`) from classmethods
to instance methods that read it. Event names now fall back to
`self._definition.name` instead of `self.__class__.__name__`.

Behavior is identical for decorator subclasses, but the engine no longer
assumes the definition comes from the class. This is the seam for
`Flow.from_definition`, where an instance runs a definition that was
loaded rather than built from a Python subclass.

* Add Flow.from_definition to run flows without a subclass

A FlowDefinition (e.g. loaded from YAML) was only usable for dispatch on
decorator-authored subclasses. Now each method definition records an
importable `module:qualname` handler ref, and `Flow.from_definition`
resolves and binds those handlers to build a runnable flow directly.

* Build flow state from FlowDefinition

Definition-driven flows previously always started with a bare dict
state.

* Replace handler string with structured FlowActionDefinition

`handler: str | None` was optional and opaque — missing handlers only
surfaced at kickoff time. `do: FlowActionDefinition` is required, so
Pydantic rejects invalid definitions at parse time.

The `call: "code"` discriminator prepares the schema for future
non-Python action types (e.g. MCP tool, crew) without touching
`FlowMethodDefinition`. Resolution logic is extracted to
`runtime/_action_resolvers.py` to keep the dispatch point isolated.

* Fix conversational start router missing required do field

FlowMethodDefinition.do became required when the handler string was
replaced with FlowActionDefinition, but _conversation_start_router still
built its fragment without it, breaking crewai import entirely.

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

* Add event scoping to flow test

* Change lib/crewai/tests/test_flow_from_definition.py

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 14:18:49 -07:00
Greyson LaLonde
21fa8e32d9 docs: update changelog and version for v1.14.7
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
2026-06-11 10:13:40 -07:00
Greyson LaLonde
f18c03cd8f feat: bump versions to 1.14.7 2026-06-11 10:06:07 -07:00
Greyson LaLonde
50b9c02272 fix(checkpoint): rebuild custom BaseLLM as concrete LLM on restore
Some checks failed
CodeQL Advanced / Analyze (actions) (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Check Documentation Broken Links / Check broken links (push) Has been cancelled
Vulnerability Scan / pip-audit (push) Has been cancelled
Mark stale issues and pull requests / stale (push) Has been cancelled
A custom BaseLLM subclass serializes with the inherited llm_type "base",
which the registry maps to the abstract BaseLLM. Restore then crashed on
cls(**value). Rebuild a concrete LLM from the saved config when the
resolved class is abstract.
2026-06-10 22:21:35 -07:00
Greyson LaLonde
c55334be5f docs: update changelog and version for v1.14.7rc2 2026-06-10 20:52:56 -07:00
Greyson LaLonde
05a2ba9ca4 feat: bump versions to 1.14.7rc2 2026-06-10 20:45:29 -07:00
Greyson LaLonde
fbafe1f0d3 fix(flow): gate restore on a flag so live snapshots don't replay as resume
Checkpoint serialization stamps checkpoint_completed_methods onto every live
Flow in RuntimeState.root, including the agent executor reused across a crew's
tasks. kickoff_async read that stamp as a restore signal, so the second task
replayed the first task's completed methods and never reached a final answer.

Gate is_restoring on _restored_from_checkpoint, set only by
_restore_from_checkpoint, and consume it single-shot.
2026-06-10 20:40:08 -07:00
Greyson LaLonde
5267c059f5 test(flow): pass show=False in test_flow_plotting to not open a browser
flow.plot defaults to show=True, which calls webbrowser.open on every run.
The test only asserts FlowPlotEvent is emitted, so disable the browser open.
2026-06-10 20:36:14 -07:00
Greyson LaLonde
243c9edc1c docs: update changelog and version for v1.14.7rc1
Some checks failed
CodeQL Advanced / Analyze (actions) (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Check Documentation Broken Links / Check broken links (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
2026-06-10 18:56:52 -07:00
Greyson LaLonde
68910b70c0 feat: bump versions to 1.14.7rc1 2026-06-10 18:50:54 -07:00
Greyson LaLonde
299782765c ci: ignore GHSA-rrmf-rvhw-rf47 (torch alias of PYSEC-2025-194)
* ci: ignore GHSA-rrmf-rvhw-rf47 (torch alias of PYSEC-2025-194)

pip-audit reports CVE-2025-3000 under its GHSA id, which the existing
PYSEC-2025-194 ignore does not match. Same advisory: memory corruption
in torch.jit.script, CVSS 1.9, local-only, no fix for torch 2.11.0.

* ci: sync GHSA-rrmf-rvhw-rf47 ignore into pre-commit pip-audit
2026-06-10 18:45:42 -07:00
Greyson LaLonde
a1f44eb272 fix(events): scope runtime state per run to bound growth and isolate concurrent runs 2026-06-10 18:39:05 -07:00
Lorenze Jay
036b032ab6 handle supporting both custom prompts (#6108)
* handle supporting both custom prompts

* handle translations

* handle deprecation warnings better
2026-06-10 17:52:53 -07:00
Lorenze Jay
f88ae54f96 fix telemetry setup on crewai-login (#6106)
* fix telemetry setup on crewai-login

* type check fix
2026-06-10 17:03:25 -07:00
Lorenze Jay
b6e5d632c1 improve convo routing cycle with one less route (#6102)
* improve one less route

* flows in flows, new agent executor causing early trace batch finalization

* addressing comments

* addressing comments pt2

* lint and typecheck fix
2026-06-10 16:49:16 -07:00
Greyson LaLonde
0d971e5bc5 feat(events): add reset_runtime_state to release accumulated bus state 2026-06-10 16:12:28 -07:00
Lucas Gomide
b3f175b56f docs: update otel images (#6103)
Some checks failed
CodeQL Advanced / Analyze (actions) (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Check Documentation Broken Links / Check broken links (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
2026-06-10 14:34:30 -04:00
Lucas Gomide
f523a7d029 docs: udpate docs to reflect new state of OpenTelemetry collector (#6100)
* docs: udpate docs to reflect new state of OpenTelemetry collector

* docs: add OTel collector and Datadog screenshots

These images are referenced by the capture_telemetry_logs guides but were
missing from the tree, which broke the link checker across all locales.

* docs: address PR review on OTel collector guide

- Clarify that OpenTelemetry Traces and Logs are separate integrations
  sharing the same fields (resolves Traces/Logs wording inconsistency)
- List regional Datadog OTLP hosts (US1/US3/US5/EU1/AP1) so users outside
  US5 can copy the right domain
2026-06-10 14:26:35 -04:00
Lorenze Jay
f214ff4b7b decouple convo logic from runtime and added a conversational_definition (#6091)
* decouple convo logic from runtime and added a conversational_definition

* type check fix

* always defer traces for convo and so fix tests to reflect that
2026-06-10 10:49:39 -07:00
Vini Brasil
a9e7c3a44f Simplify flow condition evaluation to be stateless per event (#6097)
Re-evaluate the whole `@listen`/`@router` condition tree against the set
of events seen so far, instead of tracking which AND sub-branches remain
pending.

Net effect:
* Fixes a regression where `or_()` short-circuited at the first
  satisfied branch, leaving a sibling `and_()` half-complete so a later
  trigger could spuriously re-fire the listener
* Removes the fragile per-branch pending state and `id()`-based keys
* Shrinks the evaluator to one readable predicate
2026-06-10 10:35:25 -07:00
Lucas Gomide
da8fe8c715 fix: respect suppress_flow_events for method-execution events (#6095)
Some checks failed
CodeQL Advanced / Analyze (actions) (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Check Documentation Broken Links / Check broken links (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
* fix: respect suppress_flow_events for method-execution events

* test: align suppressed-flow test with new method-event behavior
2026-06-09 17:19:25 -04:00
Greyson LaLonde
ce42994ae3 docs: update changelog and version for v1.14.7a4 2026-06-09 12:58:38 -07:00
Greyson LaLonde
820c3905e3 feat: bump versions to 1.14.7a4 2026-06-09 12:51:55 -07:00
Vini Brasil
703ffe67ee Migrate @listen/@router runtime to read from FlowDefinition (#6084)
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
* Migrate @listen/@router runtime to read from FlowDefinition

The runtime now resolves listener conditions, router status, and emit
values from `FlowMethodDefinition` instead of legacy method metadata and
the `_listeners`/`_routers`/`_router_emit` registries.

* Evaluate AND/OR listener conditions over the definition shape via
  `_evaluate_definition_condition`
* Drop the class registries and the `FlowMeta` extraction that built
  them; stop stamping `__trigger_methods__`, `__is_router__`,
  `__router_emit__`, and friends
* `@human_feedback` emit now lives only on its config

* Simplify conditionals DSL
2026-06-09 09:40:30 -07:00
Matt Aitchison
8919026326 feat(storage): pluggable default backends for memory, knowledge, rag, flow (#6079)
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
Add opt-in extension seams so an application can route memory, knowledge,
RAG, and flow persistence through a custom backend without subclassing or
threading an explicit instance through every construction site -- mirroring
the existing crewai_core.lock_store.set_lock_backend seam.

- memory:    crewai.memory.storage.factory.set_memory_storage_factory
- knowledge: crewai.knowledge.storage.factory.set_knowledge_storage_factory
- rag:       crewai.rag.factory.register_rag_client_factory (provider registry)
- flow:      crewai.flow.persistence.factory.set_flow_persistence_factory

Each construction site consults the registered factory and falls back to the
built-in default when none is set; an explicit instance always wins. Widen
Knowledge.storage and the knowledge source base classes to BaseKnowledgeStorage
(consistent with BaseAgent.knowledge_storage) so any base-interface backend
plugs in. Runtime-free tests cover each seam.
2026-06-08 21:14:13 -05:00
Greyson LaLonde
988927006c docs: update changelog and version for v1.14.7a3
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
Build uv cache / build-cache (3.12) (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.13) (push) Has been cancelled
2026-06-08 18:56:39 -07:00
Greyson LaLonde
48c1987fcf feat: bump versions to 1.14.7a3 2026-06-08 18:43:15 -07:00
Greyson LaLonde
af62b7b583 fix: expose ask_for_human_input on experimental AgentExecutor
fixes #6065
2026-06-08 17:55:19 -07:00
Greyson LaLonde
1b14e162e9 fix: resolve pip-audit CVEs (aiohttp, docling, docling-core, pip)
* fix: resolve pip-audit CVEs for aiohttp, docling, docling-core, pip

- aiohttp 3.13.4 → 3.14.0: fixes GHSA-jg22-mg44-37j8, GHSA-hg6j-4rv6-33pg
- docling 2.84.0 → 2.97.0: fixes GHSA-cjqg-rq2h-2fvj, GHSA-pj2v-ggqh-cmq2,
  GHSA-r3xg-rg9j-67fv, GHSA-q29v-xc37-wh5m
- docling-core 2.74.0 → 2.79.0: fixes GHSA-j5xp-7m2f-49jv, GHSA-jmmv-h3mp-59v8
- pip 26.1.1 → 26.1.2: fixes PYSEC-2026-196

docling-core 2.74.1+ requires pydantic-settings>=2.14.0, so the crewai pin
is loosened from ~=2.10.1 to >=2.10.1,<3. pydantic-settings resolves to
2.14.1 in the lock.

* fix: correct aiohttp CVE floor to 3.14.0 (not 3.13.5)

* test: shim AsyncStreamReaderMixin for vcrpy under aiohttp 3.14.0

aiohttp 3.14.0 removed aiohttp.streams.AsyncStreamReaderMixin (folded into
StreamReader). vcrpy's aiohttp stub still subclasses it, so vcr's patch
machinery raised AttributeError at test collection. Restore an equivalent
mixin in conftest before vcr is imported.

* test: rebuild vcrpy MockClientResponse init for aiohttp 3.14.0

aiohttp 3.14.0 added a required stream_writer kwarg to ClientResponse.__init__
and reads stream_writer.output_size when writer is None. vcrpy's
MockClientResponse doesn't pass it, raising TypeError at cassette playback.
Rebuild the super().__init__ call from the live signature (defaulting required
keyword-only args to None, with a stream_writer stub exposing output_size) so
it survives future aiohttp signature additions too.

* test: avoid deprecated get_event_loop in vcrpy aiohttp shim

asyncio.get_event_loop() emits a DeprecationWarning (and can RuntimeError)
when no current loop is set on Python 3.12+. Prefer get_running_loop() (the
real cassette-playback path always has one) and fall back to a single cached
loop in sync contexts, since the mock only stores the loop and calls
get_debug().

* fix: pull docling-core[chunking] so HierarchicalChunker imports

docling 2.97 split into docling-slim, moving the chunker's code-chunking
deps (tree-sitter, semchunk, language grammars) behind docling-core's
[chunking] extra. crewai's knowledge source imports HierarchicalChunker,
whose package __init__ eagerly imports those submodules -> ModuleNotFoundError
('tree_sitter') without the extra. Request docling-core[chunking]; carry the
extra in override-dependencies too, since overrides replace the whole
requirement and would otherwise strip it.
2026-06-08 17:45:07 -07:00
Vini Brasil
e570534f15 Migrate @start to read from FlowDefinition (#6071)
* Remove `_start_methods` and `__is_start_method__` stamping
* Add helpers to read start info from the definition
* Scan `__dict__` instead of `dir()` to find flow methods
2026-06-08 15:03:50 -07:00
Lorenze Jay
913a3abead docs: update changelog and version for v1.14.7a2 (#6055)
Some checks failed
Check Documentation Broken Links / Check broken links (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
CodeQL Advanced / Analyze (actions) (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
2026-06-05 14:19:42 -07:00
Lorenze Jay
17cfbdf95f feat: bump versions to 1.14.7a2 (#6054) 2026-06-05 14:15:43 -07:00
Lorenze Jay
8cd51fc67e Lorenze/imp/conversational flow traces (#6044)
* feat: add conversation message and route selection events

- Introduced `ConversationMessageAddedEvent` and `ConversationRouteSelectedEvent` to enhance conversational flow tracking.
- Updated event listeners to emit these events during message handling and routing decisions.
- Enhanced the `_ConversationalMixin` class to emit events for user and assistant messages, as well as selected routes.
- Added tests to verify the correct emission of these events during conversational turns.

* ensure flow started events only emiited once

* refactor(tracing): rename trace event handler methods to action event handlers

Updated the  class to replace  with  for  and  events, improving clarity in event handling.

Additionally, adjusted comments in the  class to clarify the application of pending user messages in relation to state restoration and flow scope initialization.

* fix(conversational_mixin): handle empty message index in route events

Updated the message index handling in the  class to return  when there are no messages. Added tests to ensure that route events do not reference index zero when the transcript is empty, and verified the correct emission of conversation message events during flow handling.
2026-06-05 14:10:19 -07:00
Lorenze Jay
3723f0db76 Update conversational flow docs to use handle_turn (#6053) 2026-06-05 11:04:28 -07:00
Lucas Gomide
cab3319af9 feat(otel): surface real finish_reason + sampling params + response.id on LLM events (#5945)
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(otel): surface real finish_reason + sampling params + response.id on LLM events

Companion to the OTel GenAI emitter compliance work in crewai-enterprise
(CON-172). Today the enterprise emitter reads these fields off the OSS
LLM events via `getattr(..., None)`, so it produces valid (but partial)
spans against the existing OSS surface. This change makes those fields
first-class on the events so spans can carry the real provider data.

What this adds:

- `LLMCallStartedEvent` gains the sampling-param fields the emitter needs
  for `gen_ai.request.*`: `temperature`, `top_p`, `max_tokens`, `stream`,
  `seed`, `stop_sequences`, `frequency_penalty`, `presence_penalty`, `n`.
  All optional; existing call sites keep working.
- `BaseLLM._emit_call_started_event` introspects those values off `self`
  (the LLM instance) via `getattr(..., None)` so every provider gets the
  fields propagated for free without per-provider plumbing.
- `LLMCallCompletedEvent` gains `finish_reason: str | None` and
  `response_id: str | None`. A field validator coerces any non-string
  value (MagicMock, unexpected provider object) to None so the event
  never raises on construction.
- `LLM._emit_call_completed_event` accepts both as kwargs.
- `LLM` (LiteLLM path) gets a defensive `_extract_finish_reason_and_response_id`
  helper that handles both streaming (`StreamingChoices`) and non-streaming
  (`Choices`) shapes and is wired into every completion-event emission site.
- Provider completions extract native values from their SDK responses and
  pass them through:
  - OpenAI: `_extract_responses_finish_reason_and_id` for Responses-API,
    `_extract_finish_reason_and_id` for Chat-Completions.
  - Anthropic: `_extract_finish_reason_and_id` (Messages API + streaming).
  - Bedrock: `_extract_finish_reason_and_id` (`stopReason` from converse).
  - Gemini: `_extract_finish_reason_and_id` (`finish_reason` from candidates).
  - Azure: inherits via OpenAI sub-class; adds the helper for Azure-specific
    response shapes.
  - openai_compatible: inherits from OpenAICompletion, no edits needed.

Compatibility:

- All new fields are optional with sensible defaults. No existing call
  sites need to change.
- The validator on `LLMCallCompletedEvent` swallows non-string values for
  the new fields so legacy mocks / exotic provider types don't blow up
  event construction.
- Enterprise side already reads these fields defensively, so OSS and
  enterprise can merge independently and cut on the same synchronized
  release.

Tested against the full LLM + events + provider test suite — all green;
the 14 pre-existing multimodal failures on main are unrelated and
reproduce without this diff.

* fix(bedrock): propagate finish_reason + response_id on async paths

The original commit covered every provider's sync path and Bedrock's
sync streaming path, but two Bedrock async paths still emitted
LLMCallCompletedEvent without finish_reason/response_id:

- _ahandle_converse: the final fallback emit_call_completed_event call
  was missing both fields. Added stop_reason + response_id matching the
  other emission sites in the same function.

- _ahandle_streaming_converse: response_id was never seeded from the
  initial response object, and stream_finish_reason wasn't propagated
  to the structured-output and final-text emissions. Now extracts
  response_id up front and threads stream_finish_reason through every
  completion event.

Adds a dedicated test file covering the new event fields end-to-end:
- LLMCallCompletedEvent.finish_reason / response_id Pydantic validation
  (string accepted, None default, non-string coerced to None).
- LLMCallStartedEvent sampling params (all nine fields accepted, default
  to None).
- BaseLLM._emit_call_started_event introspecting sampling params off
  self, with explicit kwargs overriding.
- BaseLLM._emit_call_completed_event passing finish_reason/response_id
  through to the event.
- LLM._extract_finish_reason_and_response_id across the LiteLLM shapes
  (non-streaming response, streaming chunk, dict, missing fields,
  non-string values, unexpected input).

* fix(otel): correct streaming finish_reason + bedrock response_id semantics

Two correctness fixes uncovered while landing the OTel finish_reason +
response_id plumbing:

- LiteLLM streaming (sync + async): `stream_options={"include_usage": True}`
  causes LiteLLM to emit a final usage-only chunk with `choices=[]`. The
  post-loop `_extract_finish_reason_and_response_id(last_chunk)` silently
  returned `(None, None)` because the last chunk has no choices, even though
  earlier chunks carried `finish_reason="stop"`. Track both fields
  incrementally inside the loop (mirroring how OpenAI/Gemini/Azure already
  handle their native streams) and use the tracked values for the
  LLMCallCompletedEvent emission and the partial-response error path.

- Bedrock Converse: `ResponseMetadata.RequestId` is an AWS infra trace id,
  not a model-level response id (semantically different from OpenAI's
  `chatcmpl-XXX`). Return None for `response_id` rather than mislead
  downstream telemetry consumers. The audit-fix's async propagation chain
  still works — None propagates through unchanged.

Adds `test_llm_streaming_finish_reason.py` pinning both the sync and async
LiteLLM streaming paths against the include_usage chunk shape.

* refactor(otel): unify LLM event introspection + drop redundant defensive code

Three cohesion cleanups uncovered during PR review, all behavior-preserving:

- LLM.call / LLM.acall in llm.py now delegate to BaseLLM._emit_call_started_event
  instead of constructing LLMCallStartedEvent inline. The base helper already
  introspects sampling params off self via getattr; the inline duplication was
  accidental, not justified, and a duplication risk if anyone adds a tenth
  OTel sampling param later.

- Extracted lib/crewai/llms/_finish_reason_utils.py:extract_choices_finish_reason_and_id
  as the shared extractor for the choices-based response shape. OpenAI Chat,
  Azure, and LiteLLM all read the same shape (response.id + choices[0].finish_reason)
  as both object attrs and dict keys. Providers with genuinely different shapes
  - Anthropic (stop_reason), Bedrock (stopReason), Gemini (protobuf enum),
  OpenAI Responses (status) - keep their own provider-specific helpers.

- Dropped redundant try/except (AttributeError, TypeError) wrappers around
  bare getattr(obj, "field", None) calls across the new extraction helpers.
  getattr with a default already suppresses AttributeError, and the inner
  isinstance / dict.get / int-coercion ops can't raise TypeError in practice.
  Kept the catches that legitimately guard against IndexError (e.g. choices[0]
  on an empty list).

Tests: 600 passed, 23 skipped, 14 pre-existing multimodal failures unchanged.
Added 12 parametrized tests for the shared helper covering object + dict
shapes, missing fields, non-string coercion, and never-raises invariants.

* chore(otel): drop dead last_chunk variable from async streaming

The streaming-fix commit (49e5581b5) replaced the post-loop
`_extract_finish_reason_and_response_id(last_chunk)` call with the
incrementally-tracked `stream_finish_reason` / `stream_response_id`,
which removed the only reader of `last_chunk` in
`_ahandle_streaming_response`. The declaration and per-iteration
assignment were left behind — harmless but confusing for future
readers because the sync sibling still legitimately uses `last_chunk`
(for usage and content fallbacks via `_handle_streaming_callbacks`).

The async path inlines its usage extraction directly inside the loop
(`chunk.model_extra.get("usage")`), so there's no fallback consumer.
Drop both lines.

Sync path untouched — `last_chunk` there is still load-bearing.

* fix(otel): coerce non-list stop_sequences to list[str] on LLMCallStartedEvent

Observed in Datadog: gen_ai.request.stop_sequences on a Gemini/Vertex
span surfaced the textproto repr of a google.protobuf.struct_pb2.ListValue
(values { string_value: "\nObservation:" }) instead of a real Sequence[str].

Root cause is upstream - a Vertex AI / Gemini code path stores the stop
list in a protobuf container (RepeatedScalarContainer or ListValue) rather
than a plain Python list. When that container reaches LLMCallStartedEvent
and then BaseLLM._emit_call_started_event hands it to the OTel SDK as a
span attribute, the SDK falls back to str(value) because the type isn't a
recognised Sequence[str] - producing the protobuf textproto string instead
of an array attribute.

* chore: fix ruff lint findings

* refactor(otel): declare sampling params on BaseLLM + honor stop overrides + dict chunk id

* fix: widen max_tokens to int | float | None + apply ruff format

* fix(otel): coerce unknown finish_reason / response_id to None instead of stringifying

* fix(otel): extract Azure stream finish_reason/id before usage-continue

Match the LiteLLM ordering so a finish_reason or response id riding on a
usage-carrying chunk isn't dropped by the early `continue`.

* fix(otel): report effective max_tokens cap + bedrock structured finish_reason
2026-06-05 07:23:38 -04:00
138 changed files with 14841 additions and 5590 deletions

View File

@@ -64,6 +64,7 @@ jobs:
--ignore-vuln PYSEC-2025-197 \
--ignore-vuln PYSEC-2025-210 \
--ignore-vuln PYSEC-2026-139 \
--ignore-vuln GHSA-rrmf-rvhw-rf47 \
--ignore-vuln PYSEC-2025-211 \
--ignore-vuln PYSEC-2025-212 \
--ignore-vuln PYSEC-2025-213 \
@@ -81,6 +82,7 @@ jobs:
# PYSEC-2025-183 - pyjwt 2.12.1: disputed weak-encryption claim; key length is application-chosen
# PYSEC-2025-189..197 - torch 2.11.0: memory-corruption/DoS in functions only reachable via untrusted models; no fix available
# PYSEC-2025-210, PYSEC-2026-139 - torch 2.11.0: profiler/deserialization issues; no fix available
# GHSA-rrmf-rvhw-rf47 - torch 2.11.0 (CVE-2025-3000, alias of PYSEC-2025-194): memory corruption in torch.jit.script, CVSS 1.9, local-only; affected <=2.12.0, no fix available. pip-audit reports it under the GHSA id so the PYSEC ignore above does not catch it.
# PYSEC-2025-211..218 - transformers 5.5.4: deserialization/code injection via malicious model checkpoints; no fix available
# 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)

View File

@@ -47,6 +47,7 @@ repos:
--ignore-vuln PYSEC-2025-197
--ignore-vuln PYSEC-2025-210
--ignore-vuln PYSEC-2026-139
--ignore-vuln GHSA-rrmf-rvhw-rf47
--ignore-vuln PYSEC-2025-211
--ignore-vuln PYSEC-2025-212
--ignore-vuln PYSEC-2025-213

View File

@@ -11,7 +11,99 @@ from typing import Any
from dotenv import load_dotenv
import pytest
from vcr.request import Request # type: ignore[import-untyped]
def _patch_vcrpy_aiohttp_compat() -> None:
"""Keep vcrpy's aiohttp stub working under aiohttp 3.14.0.
aiohttp 3.14.0 (pulled in to fix GHSA-jg22-mg44-37j8 and GHSA-hg6j-4rv6-33pg):
* removed ``aiohttp.streams.AsyncStreamReaderMixin`` (folded into ``StreamReader``),
which vcrpy's ``MockStream`` still subclasses -- vcr's patch machinery then raises
``AttributeError`` at collection time; and
* added a required ``stream_writer`` keyword-only arg to ``ClientResponse.__init__``,
which vcrpy's ``MockClientResponse`` does not pass -- raising ``TypeError`` at
cassette playback.
Restore the mixin, then rebuild ``MockClientResponse``'s ``super().__init__`` call from
the live ``ClientResponse`` signature (defaulting every required keyword-only arg to
``None``, mirroring vcrpy's original call) so it also survives future aiohttp additions.
"""
import asyncio
import inspect
from aiohttp import streams
from aiohttp.client_reqrep import ClientResponse
if not hasattr(streams, "AsyncStreamReaderMixin"):
class AsyncStreamReaderMixin:
__slots__ = ()
def __aiter__(self) -> streams.AsyncStreamIterator[bytes]:
return streams.AsyncStreamIterator(self.readline) # type: ignore[attr-defined]
def iter_chunked(self, n: int) -> streams.AsyncStreamIterator[bytes]:
return streams.AsyncStreamIterator(lambda: self.read(n)) # type: ignore[attr-defined]
def iter_any(self) -> streams.AsyncStreamIterator[bytes]:
return streams.AsyncStreamIterator(self.readany) # type: ignore[attr-defined]
def iter_chunks(self) -> streams.ChunkTupleAsyncStreamIterator:
return streams.ChunkTupleAsyncStreamIterator(self) # type: ignore[arg-type]
streams.AsyncStreamReaderMixin = AsyncStreamReaderMixin # type: ignore[attr-defined]
# Importing the stub builds MockStream/MockClientResponse, so it must run after the
# mixin is restored above.
import vcr.stubs.aiohttp_stubs as aiohttp_stubs # type: ignore[import-untyped]
if getattr(aiohttp_stubs.MockClientResponse, "_crewai_aiohttp_patched", False):
return
keyword_only = [
name
for name, param in inspect.signature(ClientResponse.__init__).parameters.items()
if param.kind is inspect.Parameter.KEYWORD_ONLY
]
class _NullStreamWriter:
# aiohttp 3.14.0 reads stream_writer.output_size in the "request already
# sent" branch (writer is None), so None is not enough -- supply a stub.
output_size = 0
fallback_loop: list[asyncio.AbstractEventLoop] = []
def _resolve_loop() -> asyncio.AbstractEventLoop:
# MockClientResponse is normally built inside aiohttp's running loop, so
# prefer that. In a sync context there is no running loop; avoid
# asyncio.get_event_loop(), which on 3.12+ emits a DeprecationWarning
# (and can RuntimeError) when no current loop is set. Use one cached
# loop instead -- the mock only stores it and calls loop.get_debug().
try:
return asyncio.get_running_loop()
except RuntimeError:
if not fallback_loop:
fallback_loop.append(asyncio.new_event_loop())
return fallback_loop[0]
def _mock_client_response_init(
self: Any, method: str, url: Any, request_info: Any = None
) -> None:
kwargs: dict[str, Any] = dict.fromkeys(keyword_only)
kwargs["request_info"] = request_info
if "loop" in kwargs:
kwargs["loop"] = _resolve_loop()
if "stream_writer" in kwargs:
kwargs["stream_writer"] = _NullStreamWriter()
ClientResponse.__init__(self, method, url, **kwargs)
aiohttp_stubs.MockClientResponse.__init__ = _mock_client_response_init
aiohttp_stubs.MockClientResponse._crewai_aiohttp_patched = True
_patch_vcrpy_aiohttp_compat()
from vcr.request import Request # type: ignore[import-untyped] # noqa: E402
try:

View File

@@ -4,6 +4,178 @@ description: "تحديثات المنتج والتحسينات وإصلاحات
icon: "clock"
mode: "wide"
---
<Update label="11 يونيو 2026">
## v1.14.7
[عرض الإصدار على GitHub](https://github.com/crewAIInc/crewAI/releases/tag/1.14.7)
## ما الذي تغير
### الميزات
- إضافة واجهات خلفية افتراضية قابلة للتوصيل للذاكرة، والمعرفة، وrag، وflow.
- عرض السبب الحقيقي للإنهاء، ومعلمات العينة، وresponse.id في أحداث LLM.
- تصنيف مشغلات DSL كزخارف واعية للمسار.
- إضافة واجهة برمجة تطبيقات الدردشة لتدفقات المحادثة.
- جعل واجهة القفل قابلة للتجاوز.
- بناء FlowDefinition من بيانات التعريف الخاصة بـ Flow DSL.
- إضافة مزود LLM من Snowflake Cortex الأصلي.
- إضافة دعم لملفات الوكلاء المدربين من crew.
### إصلاحات الأخطاء
- إصلاح نقطة التحقق لإعادة بناء BaseLLM مخصص كـ LLM ملموس عند الاستعادة.
- تقييد الاستعادة على علامة لمنع اللقطات الحية من إعادة التشغيل كاستئناف.
- تحديد حالة وقت التشغيل لكل تشغيل للحد من النمو وعزل التشغيل المتزامن.
- إصلاح إعدادات التتبع على crewai-login.
- احترام suppress_flow_events لأحداث تنفيذ الطريقة.
- استعادة [project.scripts] في حزمة crewai لتثبيت أداة uv.
- حل مشكلات CVE الخاصة بـ pip-audit لـ aiohttp وdocling وdocling-core.
- إصلاح إدخال الملفات الذي لا يعمل بشكل موثوق.
- إصلاح تاريخ نتائج أدوات Snowflake Claude غير المكتملة.
### الوثائق
- تحديث سجل التغييرات والإصدار لـ v1.14.7.
- تحديث وثائق جامع OpenTelemetry.
- تحديث دليل NVIDIA Nemotron LLM.
- إضافة دليل تكامل Databricks.
- إضافة دليل تكامل Snowflake.
### الأداء
- تحسين سرعة استيراد crewai من خلال تحميل مستندات docling بشكل كسول.
### إعادة الهيكلة
- تبسيط تقييم شروط التدفق ليكون بلا حالة لكل حدث.
- فصل منطق المحادثة عن وقت التشغيل وإضافة تعريف المحادثة.
- تقسيم `flow.py` إلى DSL، وتعريف، ووقت تشغيل.
## المساهمون
@Luzk, @alex-clawd, @devin-ai-integration[bot], @greysonlalonde, @gvieira, @jessemiller, @lorenzejay, @lucasgomide, @mattatcha, @vinibrsl
</Update>
<Update label="10 يونيو 2026">
## v1.14.7rc2
[عرض الإصدار على GitHub](https://github.com/crewAIInc/crewAI/releases/tag/1.14.7rc2)
## ما الذي تغير
### إصلاحات الأخطاء
- استعادة البوابة على علامة لمنع اللقطات الحية من إعادة التشغيل كاستئناف
### الوثائق
- تحديث سجل التغييرات والإصدار لـ v1.14.7rc1
## المساهمون
@greysonlalonde
</Update>
<Update label="10 يونيو 2026">
## v1.14.7rc1
[عرض الإصدار على GitHub](https://github.com/crewAIInc/crewAI/releases/tag/1.14.7rc1)
## ما الذي تغير
### الميزات
- إضافة `reset_runtime_state` لإطلاق حالة الحافلة المتراكمة
- التعامل مع دعم كل من الموجهات المخصصة
- فصل منطق المحادثة عن وقت التشغيل وإضافة `conversational_definition`
### إصلاحات الأخطاء
- إصلاح نطاق حالة وقت التشغيل لكل تشغيل للحد من النمو وعزل التشغيلات المتزامنة
- إصلاح إعدادات القياس عن بُعد على `crewai-login`
- إصلاح احترام `suppress_flow_events` لفعاليات تنفيذ الأساليب
### الوثائق
- تحديث صور OpenTelemetry
- تحديث الوثائق لتعكس الحالة الجديدة لجمع بيانات OpenTelemetry
- تحديث سجل التغييرات والإصدار لـ v1.14.7a4
### إعادة الهيكلة
- تبسيط تقييم شرط التدفق ليكون بلا حالة لكل حدث
- تحسين دورة توجيه المحادثة مع تقليل مسار واحد
## المساهمون
@greysonlalonde, @lorenzejay, @lucasgomide, @vinibrsl
</Update>
<Update label="9 يونيو 2026">
## v1.14.7a4
[عرض الإصدار على GitHub](https://github.com/crewAIInc/crewAI/releases/tag/1.14.7a4)
## ما الذي تغير
### الميزات
- نقل وقت التشغيل @listen/@router لقراءة من FlowDefinition
- إضافة واجهات خلفية افتراضية قابلة للتوصيل للذاكرة، والمعرفة، وrag، وflow
### الوثائق
- تحديث سجل التغييرات والإصدار لـ v1.14.7a3
## المساهمون
@greysonlalonde, @mattatcha, @vinibrsl
</Update>
<Update label="8 يونيو 2026">
## v1.14.7a3
[عرض الإصدار على GitHub](https://github.com/crewAIInc/crewAI/releases/tag/1.14.7a3)
## ما الذي تغير
### إصلاحات الأخطاء
- إصلاح تعرض `ask_for_human_input` في `AgentExecutor` التجريبي
- حل مشكلات CVEs الخاصة بـ pip-audit لـ `aiohttp`، `docling`، `docling-core`، و `pip`
### إعادة هيكلة
- نقل `@start` لقراءة من `FlowDefinition`
### الوثائق
- تحديث سجل التغييرات والإصدار لـ v1.14.7a2
## المساهمون
@greysonlalonde، @lorenzejay، @vinibrsl
</Update>
<Update label="5 يونيو 2026">
## v1.14.7a2
[عرض الإصدار على GitHub](https://github.com/crewAIInc/crewAI/releases/tag/1.14.7a2)
## ما الذي تغير
### الميزات
- إضافة دعم تتبع تدفقات المحادثة.
- تحديث وثائق تدفق المحادثة لاستخدام `handle_turn`.
- عرض السبب الحقيقي لإنهاء المحادثة، ومعلمات العينة، و`response.id` في أحداث LLM.
- تصنيف مشغلات DSL كزخارف واعية بالمسار.
- تنفيذ واجهة برمجة التطبيقات للدردشة لتدفقات المحادثة.
- جعل قفل الخلفية قابلاً للتجاوز في متجر القفل.
- تقسيم أحادي تدفق DSL إلى وحدات زخرفية مركزة.
- تسطيح استخدام ذاكرة التخزين المؤقت LiteLLM/أعداد الأسباب الفرعية في `_usage_to_dict`.
- بناء `FlowDefinition` من بيانات التعريف الخاصة بتدفق DSL.
### الوثائق
- إضافة دليل NVIDIA Nemotron LLM.
- توثيق عمليات نشر المونوريبو.
- تحديث سجل التغييرات والإصدار لـ v1.14.7a1.
## المساهمون
@alex-clawd, @gvieira, @lorenzejay, @lucasgomide, @mattatcha, @vinibrsl
</Update>
<Update label="3 يونيو 2026">
## v1.14.7a1

View File

@@ -226,6 +226,48 @@ counter=2 message='Hello from first_method - updated by second_method'
من خلال ضمان إعادة مخرجات الدالة الأخيرة وتوفير الوصول إلى الحالة، تجعل تدفقات CrewAI من السهل دمج نتائج سير عمل الذكاء الاصطناعي في التطبيقات أو الأنظمة الأكبر،
مع الحفاظ على الوصول إلى الحالة طوال تنفيذ التدفق.
## مقاييس استخدام التدفق
بعد اكتمال تنفيذ التدفق، يمكنك الوصول إلى الخاصية `usage_metrics` لعرض إجمالي استخدام التوكنات عبر **كل استدعاء لنموذج اللغة** يتم خلال التشغيل — بما في ذلك الاستدعاءات من كل فريق (Crew) ينظمه التدفق، والاستدعاءات داخل أدوات الـ Agents، والاستدعاءات المباشرة لـ `LLM.call(...)` من دوال التدفق. هذا هو المكافئ على جانب الـ SDK للإجماليات المعروضة في واجهة CrewAI Enterprise.
```python Code
from crewai import LLM
from crewai.flow.flow import Flow, listen, start
class UsageMetricsFlow(Flow):
@start()
def run_first_crew(self):
self.state.first_result = FirstCrew().crew().kickoff()
@listen(run_first_crew)
def call_llm_directly(self):
# استدعاء مباشر لنموذج اللغة — يُحسب أيضًا ضمن flow.usage_metrics
llm = LLM(model="openai/gpt-4o-mini")
self.state.summary = llm.call("لخّص النقاط الرئيسية.")
@listen(call_llm_directly)
def run_second_crew(self):
self.state.second_result = SecondCrew().crew().kickoff()
flow = UsageMetricsFlow()
flow.kickoff()
print(flow.usage_metrics)
# UsageMetrics(total_tokens=8579, prompt_tokens=6210, completion_tokens=2369,
# cached_prompt_tokens=0, reasoning_tokens=0,
# cache_creation_tokens=0, successful_requests=5)
```
<Note>
`flow.usage_metrics` **ليست** نفس `flow.kickoff().token_usage`. هذه الأخيرة
ترجع فقط `CrewOutput.token_usage` لـ **آخر** دالة `@listen` أعادت
`CrewOutput`، مما يعني أنها تعكس فقط الفريق الأخير وتتجاهل الفرق السابقة
وكذلك أي استدعاءات مباشرة لـ `LLM.call(...)`. استخدم `flow.usage_metrics`
كلما احتجت إلى الإجمالي **الكامل** للتوكنات لتنفيذ التدفق.
</Note>
كل حقل في [`UsageMetrics`](https://github.com/crewAIInc/crewAI/blob/main/lib/crewai/src/crewai/types/usage_metrics.py) المُعاد هو مجموع جميع استدعاءات نموذج اللغة التي حدثت خلال استدعاء واحد لـ `flow.kickoff()`. تتم إعادة تعيين العدادات عند الاستدعاء التالي لـ `kickoff()` (وفي كل تكرار من `kickoff_for_each`)، لذلك لن تتكرر العدّات عبر التشغيلات المتتالية. يمكن قراءة هذه الخاصية بأمان في أي وقت بعد اكتمال `kickoff()`؛ قراءتها أثناء التنفيذ تُرجع المجموع الجزئي المتراكم حتى تلك اللحظة.
## إدارة حالة التدفق
إدارة الحالة بفعالية أمر بالغ الأهمية لبناء سير عمل ذكاء اصطناعي موثوق وقابل للصيانة. توفر تدفقات CrewAI آليات قوية لإدارة الحالة غير المهيكلة والمهيكلة،

View File

@@ -24,15 +24,39 @@ mode: "wide"
1. في CrewAI AMP، انتقل إلى **Settings** > **OpenTelemetry Collectors**.
2. انقر على **Add Collector**.
3. اختر نوع التكامل — **OpenTelemetry Traces** أو **OpenTelemetry Logs**.
4. هيّئ الاتصال:
- **Endpoint** — نقطة نهاية OTLP لمجمّعك (مثل `https://otel-collector.example.com:4317`).
- **Service Name** — اسم لتعريف هذه الخدمة في منصة المراقبة.
- **Custom Headers** *(اختياري)* — أضف رؤوس المصادقة أو التوجيه كأزواج مفتاح-قيمة.
- **Certificate** *(اختياري)* — قدم شهادة TLS إذا كان مجمّعك يتطلبها.
5. انقر على **Save**.
3. اختر تكاملاً:
- **OpenTelemetry Traces** و**OpenTelemetry Logs** — صدّر إلى أي مجمّع أو واجهة خلفية متوافقة مع OTLP.
- **Datadog** — أرسل التتبعات مباشرة إلى استقبال OTLP الخاص بـ Datadog، دون الحاجة إلى مجمّع منفصل أو Datadog Agent.
4. هيّئ الاتصال. تعتمد الحقول على التكامل الذي اخترته:
<Frame>![تهيئة مجمّع OpenTelemetry](/images/crewai-otel-collector-config.png)</Frame>
<Tabs>
<Tab title="OpenTelemetry Traces / Logs">
إن **OpenTelemetry Traces** و**OpenTelemetry Logs** تكاملان منفصلان يتشاركان نفس الحقول — اختر التكامل المطابق للإشارة التي تريد تصديرها.
- **Endpoint** — نقطة نهاية OTLP لمجمّعك (مثل `https://otel-collector.example.com:4317`).
- **Service Name** — اسم لتعريف هذه الخدمة في منصة المراقبة.
- **Custom Headers** *(اختياري)* — أضف رؤوس المصادقة أو التوجيه كأزواج مفتاح-قيمة.
- **Certificate** *(اختياري)* — قدم شهادة TLS إذا كان مجمّعك يتطلبها.
<Frame>![تهيئة مجمّع OpenTelemetry](/images/crewai-otel-collector-opentelemetry.png)</Frame>
</Tab>
<Tab title="Datadog">
- **Datadog Site Domain** — مضيف OTLP لموقع Datadog الخاص بك فقط، دون بروتوكول أو مسار. يقوم CrewAI ببناء نقطة نهاية HTTPS OTLP الكاملة نيابةً عنك. استخدم المضيف المطابق لـ [موقع Datadog](https://docs.datadoghq.com/getting_started/site/) الخاص بك:
- `otlp.datadoghq.com` (US1)
- `otlp.us3.datadoghq.com` (US3)
- `otlp.us5.datadoghq.com` (US5)
- `otlp.datadoghq.eu` (EU1)
- `otlp.ap1.datadoghq.com` (AP1)
- **API Key** — مفتاح واجهة برمجة تطبيقات Datadog الخاص بك. راجع [كيفية إنشاء واحد](https://docs.datadoghq.com/account_management/api-app-keys/#api-keys).
يصدّر تكامل Datadog **التتبعات**.
<Frame>![تهيئة مجمّع Datadog](/images/crewai-otel-collector-datadog.png)</Frame>
</Tab>
</Tabs>
5. *(اختياري)* انقر على **Test Connection** للتحقق من قدرة CrewAI على الوصول إلى نقطة النهاية باستخدام بيانات الاعتماد التي قدمتها.
6. انقر على **Save**.
<Tip>
يمكنك إضافة مجمّعات متعددة — على سبيل المثال، واحد للتتبعات وآخر للسجلات، أو الإرسال إلى واجهات خلفية مختلفة لأغراض مختلفة.

View File

@@ -161,6 +161,18 @@ crew = Crew(
)
```
<Note>
يُحتفظ بـ `agent.i18n` للتوافق مع الإصدارات السابقة فقط، وقد تم إهماله. لتخصيص المطالبات أثناء التشغيل، مرّر `prompt_file` إلى `Crew`. وللوصول البرمجي المباشر إلى شرائح المطالبات، استخدم أداة i18n مباشرة:
</Note>
```python
from crewai.utilities.i18n import get_i18n
i18n = get_i18n("custom_prompts.json")
format_slice = i18n.slice("format")
tool_prompt = i18n.tools("ask_question")
```
#### الخيار 3: تعطيل مطالبات النظام لنماذج o1
```python
agent = Agent(
@@ -208,6 +220,8 @@ agent = Agent(
يدمج CrewAI بعد ذلك تخصيصاتك مع الإعدادات الافتراضية، فلا تحتاج لإعادة تعريف كل مطالبة. إليك الطريقة:
بالنسبة للكود الذي يحتاج إلى قراءة شرائح المطالبات مباشرة، استخدم `crewai.utilities.i18n.get_i18n()` مع ملف المطالبات نفسه بدلًا من قراءة `agent.i18n`.
### مثال: تخصيص أساسي للمطالبات
أنشئ ملف `custom_prompts.json` بالمطالبات التي تريد تعديلها. تأكد من إدراج جميع المطالبات عالية المستوى التي يجب أن يحتويها، وليس فقط تغييراتك:

View File

@@ -11,95 +11,83 @@ mode: "wide"
| المفهوم | التنفيذ |
|---------|---------|
| معرّف الجلسة | `kickoff(session_id=...)` → `inputs["id"]` → `state.id` |
| سطر المستخدم | `kickoff(user_message=...)` يُضاف إلى `state.messages` قبل تشغيل الرسم |
| اكتمال الجولة | `FlowFinished` لهذا **التشغيل** فقط؛ تستمر المحادثة في `kickoff` التالي |
| تتبع الجلسة | `ConversationalConfig(defer_trace_finalization=True)` + `finalize_session_traces()` |
| معرّف الجلسة | `handle_turn(..., session_id=...)` → `kickoff(inputs={"id": ...})` → `state.id` |
| سطر المستخدم | `handle_turn(message)` يضيف الرسالة إلى `state.messages` قبل تشغيل الرسم |
| اكتمال الجولة | `FlowFinished` لهذا **التشغيل** فقط؛ تستمر المحادثة في `handle_turn` التالي |
| تتبع الجلسة | `ConversationConfig(defer_trace_finalization=True)` + `finalize_session_traces()` |
## واجهات الجولات
استخدم **`flow.kickoff(user_message=..., session_id=...)`** أو **`flow.handle_turn(...)`** لكل رسالة مستخدم من REST أو WebSocket أو الاختبارات أو الواجهات المخصصة. استخدم **`flow.chat()`** عندما تريد حلقة دردشة محلية في الطرفية لـ `Flow` محادثي.
استخدم **`flow.handle_turn(message, session_id=...)`** لكل رسالة مستخدم من REST أو WebSocket أو الاختبارات أو الواجهات المخصصة. استخدم **`flow.chat()`** عندما تريد حلقة دردشة محلية في الطرفية لـ `Flow` محادثي.
لا يقبل `Flow.kickoff()` الوسيطين `user_message=` أو `session_id=`. في التدفقات المحادثية، يخزن `handle_turn()` الرسالة المعلقة ويستدعي داخلياً `kickoff(inputs={"id": session_id})`.
| API | الاستخدام |
|-----|-----------|
| `kickoff(user_message=..., session_id=...)` | كل رسالة مستخدم |
| `handle_turn(message, session_id=...)` | غلاف مريح لجولة واحدة في `Flow` محادثي |
| `chat()` | REPL محلي في الطرفية لـ `Flow` محادثي |
| `kickoff_async(...)` | نفس المعاملات؛ دخول async أصلي |
| `kickoff(inputs={...})` | تشغيل متقدم للـ flow بدون معالجة جولة محادثية |
| `ask()` | مطالبة حاجزة **داخل** خطوة واحدة |
| `@human_feedback` | الموافقة/الرفض على **مخرجات خطوة** — وليس السطر التالي |
| `ChatSession.handle_turn(...)` | طبقة نقل فوق `kickoff` |
| `ChatSession.handle_turn(...)` | طبقة نقل فوق `handle_turn` |
## بداية سريعة
```python
from uuid import uuid4
from crewai.flow import (
ChatState,
ConversationalConfig,
Flow,
listen,
or_,
persist,
router,
start,
from crewai import Flow
from crewai.flow import listen
from crewai.experimental.conversational import (
ConversationConfig,
ConversationState,
)
from crewai.flow.persistence import SQLiteFlowPersistence
class SupportFlow(Flow[ChatState]):
conversational_config = ConversationalConfig(
default_intents=["order", "help", "goodbye"],
intent_llm="gpt-4o-mini",
defer_trace_finalization=True,
)
@ConversationConfig(defer_trace_finalization=True)
class SupportFlow(Flow[ConversationState]):
conversational = True
@start()
def bootstrap(self):
if not self.state.session_ready:
self.state.session_ready = True
return "ready"
@router(bootstrap)
def route(self):
return self.state.last_intent or "help"
def route_turn(self, context):
message = (self.state.current_user_message or "").lower()
if "طلب" in message or "order" in message:
return "order"
if "وداع" in message or "goodbye" in message:
return "goodbye"
return "help"
@listen("order")
def handle_order(self):
reply = "طلبك في الطريق."
self.append_message("assistant", reply)
self.append_assistant_message(reply)
return reply
@listen("help")
def handle_help(self):
reply = "كيف يمكنني المساعدة؟"
self.append_message("assistant", reply)
self.append_assistant_message(reply)
return reply
@listen("goodbye")
def handle_goodbye(self):
reply = "وداعاً!"
self.append_message("assistant", reply)
self.append_assistant_message(reply)
return reply
@persist(SQLiteFlowPersistence("support.db"))
@listen(or_(handle_order, handle_help, handle_goodbye))
def finalize(self):
return self.state.model_dump()
session_id = str(uuid4())
flow = SupportFlow()
flow.kickoff(user_message="أين طلبي؟", session_id=session_id)
flow.kickoff(user_message="وماذا عن الإرجاع؟", session_id=session_id)
flow.finalize_session_traces()
try:
flow.handle_turn("أين طلبي؟", session_id=session_id)
flow.handle_turn("وماذا عن الإرجاع؟", session_id=session_id)
finally:
flow.finalize_session_traces()
```
## دورة حياة الجولة
كل `kickoff` مع `user_message` يشغّل:
كل `handle_turn` يشغّل:
1. **`_configure_conversational_kickoff`** — دمج `session_id` / `user_message` في `inputs` وتطبيق `ConversationalConfig`.
2. **استعادة الحالة** — عند وجود `inputs["id"]` و`@persist`.
@@ -108,7 +96,7 @@ flow.finalize_session_traces()
5. **تنفيذ الرسم** — `@start` → `@router` → معالجات `@listen`.
6. **نهاية التشغيل** — يُتخطى `flow_finished` والتتبع لكل جولة عند التأجيل؛ `Agent.kickoff()` / crews لا تغلق دفعة الأب.
استدعِ **`append_message("assistant", reply)`** في المعالجات. سطر المستخدم محفوظ عند kickoff — لا تُضفه مرة أخرى.
استدعِ **`append_assistant_message(reply)`** في المعالجات. سطر المستخدم محفوظ عبر `handle_turn` — لا تُضفه مرة أخرى.
## `ConversationalConfig` (افتراضيات على مستوى الصنف)
@@ -382,7 +370,7 @@ Routes:
4. يخزّن الموجّه قراره في `state.last_intent` (يكون مرئياً لسياق التوجيه في الجولة التالية).
5. إذا أعاد معالجك سلسلة نصية ولم يستدعِ `append_assistant_message`، فإن `handle_turn` يُلحقها نيابةً عنك.
يمكنك أيضاً استدعاء `flow.kickoff(user_message=..., session_id=...)` مباشرةً — نفس منطق الإعادة والتشغيل يعمل. `handle_turn` هو الغلاف المريح.
استدعِ `handle_turn()` لرسائل الدردشة. استدعاء `kickoff(inputs={"id": ...})` مباشرةً يشغل الرسم بدون غلاف الجولة المحادثية.
### `chat()` للـ REPL المحلي

File diff suppressed because it is too large Load Diff

View File

@@ -4,6 +4,178 @@ description: "Product updates, improvements, and bug fixes for CrewAI"
icon: "clock"
mode: "wide"
---
<Update label="Jun 11, 2026">
## v1.14.7
[View release on GitHub](https://github.com/crewAIInc/crewAI/releases/tag/1.14.7)
## What's Changed
### Features
- Add pluggable default backends for memory, knowledge, rag, and flow.
- Surface real finish_reason, sampling params, and response.id on LLM events.
- Type DSL triggers as route-aware decorators.
- Add chat API for conversational flows.
- Make locking backend overridable.
- Build FlowDefinition from Flow DSL metadata.
- Add native Snowflake Cortex LLM provider.
- Add crew trained agents file support.
### Bug Fixes
- Fix checkpoint to rebuild custom BaseLLM as concrete LLM on restore.
- Gate restore on a flag to prevent live snapshots from replaying as resume.
- Scope runtime state per run to bound growth and isolate concurrent runs.
- Fix telemetry setup on crewai-login.
- Respect suppress_flow_events for method-execution events.
- Restore [project.scripts] in crewai package for uv tool install.
- Resolve pip-audit CVEs for aiohttp, docling, and docling-core.
- Fix file input not working reliably.
- Fix Snowflake Claude incomplete tool result histories.
### Documentation
- Update changelog and version for v1.14.7.
- Update OpenTelemetry collector documentation.
- Update NVIDIA Nemotron LLM guide.
- Add Databricks integration guide.
- Add Snowflake integration guide.
### Performance
- Improve crewai import speed by lazy-loading docling imports.
### Refactoring
- Simplify flow condition evaluation to be stateless per event.
- Decouple convo logic from runtime and add a conversational_definition.
- Split `flow.py` into DSL, definition, and runtime.
## Contributors
@Luzk, @alex-clawd, @devin-ai-integration[bot], @greysonlalonde, @gvieira, @jessemiller, @lorenzejay, @lucasgomide, @mattatcha, @vinibrsl
</Update>
<Update label="Jun 10, 2026">
## v1.14.7rc2
[View release on GitHub](https://github.com/crewAIInc/crewAI/releases/tag/1.14.7rc2)
## What's Changed
### Bug Fixes
- Gate restore on a flag to prevent live snapshots from replaying as resume
### Documentation
- Update changelog and version for v1.14.7rc1
## Contributors
@greysonlalonde
</Update>
<Update label="Jun 10, 2026">
## v1.14.7rc1
[View release on GitHub](https://github.com/crewAIInc/crewAI/releases/tag/1.14.7rc1)
## What's Changed
### Features
- Add `reset_runtime_state` to release accumulated bus state
- Handle supporting both custom prompts
- Decouple conversation logic from runtime and add a `conversational_definition`
### Bug Fixes
- Fix scope of runtime state per run to bound growth and isolate concurrent runs
- Fix telemetry setup on `crewai-login`
- Fix respect for `suppress_flow_events` for method-execution events
### Documentation
- Update OpenTelemetry images
- Update documentation to reflect new state of OpenTelemetry collector
- Update changelog and version for v1.14.7a4
### Refactoring
- Simplify flow condition evaluation to be stateless per event
- Improve conversation routing cycle with one less route
## Contributors
@greysonlalonde, @lorenzejay, @lucasgomide, @vinibrsl
</Update>
<Update label="Jun 09, 2026">
## v1.14.7a4
[View release on GitHub](https://github.com/crewAIInc/crewAI/releases/tag/1.14.7a4)
## What's Changed
### Features
- Migrate @listen/@router runtime to read from FlowDefinition
- Add pluggable default backends for memory, knowledge, rag, and flow
### Documentation
- Update changelog and version for v1.14.7a3
## Contributors
@greysonlalonde, @mattatcha, @vinibrsl
</Update>
<Update label="Jun 08, 2026">
## v1.14.7a3
[View release on GitHub](https://github.com/crewAIInc/crewAI/releases/tag/1.14.7a3)
## What's Changed
### Bug Fixes
- Fix exposure of `ask_for_human_input` on experimental `AgentExecutor`
- Resolve pip-audit CVEs for `aiohttp`, `docling`, `docling-core`, and `pip`
### Refactoring
- Migrate `@start` to read from `FlowDefinition`
### Documentation
- Update changelog and version for v1.14.7a2
## Contributors
@greysonlalonde, @lorenzejay, @vinibrsl
</Update>
<Update label="Jun 05, 2026">
## v1.14.7a2
[View release on GitHub](https://github.com/crewAIInc/crewAI/releases/tag/1.14.7a2)
## What's Changed
### Features
- Add conversational flow traces support.
- Update conversational flow documentation to utilize `handle_turn`.
- Surface real `finish_reason`, sampling parameters, and `response.id` in LLM events.
- Type DSL triggers as route-aware decorators.
- Implement chat API for conversational flows.
- Make locking backend overridable in lock store.
- Split flow DSL monolith into focused decorator modules.
- Flatten LiteLLM cache/reasoning usage sub-counts in `_usage_to_dict`.
- Build `FlowDefinition` from Flow DSL metadata.
### Documentation
- Add NVIDIA Nemotron LLM guide.
- Document monorepo deployments.
- Update changelog and version for v1.14.7a1.
## Contributors
@alex-clawd, @gvieira, @lorenzejay, @lucasgomide, @mattatcha, @vinibrsl
</Update>
<Update label="Jun 03, 2026">
## v1.14.7a1

View File

@@ -226,6 +226,49 @@ After the Flow has run, you can access the final state to see the updates made b
By ensuring that the final method's output is returned and providing access to the state, CrewAI Flows make it easy to integrate the results of your AI workflows into larger applications or systems,
while also maintaining and accessing the state throughout the Flow's execution.
## Flow Usage Metrics
After a Flow execution completes, you can access the `usage_metrics` property to view aggregated token usage across **every LLM call** made during the run — including calls from every Crew the Flow orchestrated, calls inside Agent tools, and bare `LLM.call(...)` invocations from Flow methods. This is the SDK-side equivalent of the totals shown in the CrewAI Enterprise UI.
```python Code
from crewai import LLM
from crewai.flow.flow import Flow, listen, start
class UsageMetricsFlow(Flow):
@start()
def run_first_crew(self):
self.state.first_result = FirstCrew().crew().kickoff()
@listen(run_first_crew)
def call_llm_directly(self):
# Bare LLM call — still counted by flow.usage_metrics
llm = LLM(model="openai/gpt-4o-mini")
self.state.summary = llm.call("Summarize the key takeaways.")
@listen(call_llm_directly)
def run_second_crew(self):
self.state.second_result = SecondCrew().crew().kickoff()
flow = UsageMetricsFlow()
flow.kickoff()
print(flow.usage_metrics)
# UsageMetrics(total_tokens=8579, prompt_tokens=6210, completion_tokens=2369,
# cached_prompt_tokens=0, reasoning_tokens=0,
# cache_creation_tokens=0, successful_requests=5)
```
<Note>
`flow.usage_metrics` is **not** the same as `flow.kickoff().token_usage`. The
latter returns the `CrewOutput.token_usage` of the **last** `@listen` method
that returned a `CrewOutput`, which means it only reflects the final Crew and
ignores prior Crews and bare `LLM.call(...)` invocations entirely. Use
`flow.usage_metrics` whenever you need the **full** token rollup for the Flow
execution.
</Note>
Each entry in the returned [`UsageMetrics`](https://github.com/crewAIInc/crewAI/blob/main/lib/crewai/src/crewai/types/usage_metrics.py) is the sum across all LLM calls made within a single `flow.kickoff()` invocation. Counters reset on the next `kickoff()` call (or on each iteration of `kickoff_for_each`), so successive runs don't double-count. The property is safe to read at any point after `kickoff()` completes; reading it during execution returns the partial total accumulated so far.
## Flow State Management
Managing state effectively is crucial for building reliable and maintainable AI workflows. CrewAI Flows provides robust mechanisms for both unstructured and structured state management,

View File

@@ -24,15 +24,39 @@ Telemetry data follows the [OpenTelemetry GenAI semantic conventions](https://op
1. In CrewAI AMP, go to **Settings** > **OpenTelemetry Collectors**.
2. Click **Add Collector**.
3. Select an integration type — **OpenTelemetry Traces** or **OpenTelemetry Logs**.
4. Configure the connection:
- **Endpoint** — Your collector's OTLP endpoint (e.g., `https://otel-collector.example.com:4317`).
- **Service Name** — A name to identify this service in your observability platform.
- **Custom Headers** *(optional)* — Add authentication or routing headers as key-value pairs.
- **Certificate** *(optional)* — Provide a TLS certificate if your collector requires one.
5. Click **Save**.
3. Select an integration:
- **OpenTelemetry Traces** and **OpenTelemetry Logs** — export to any OTLP-compatible collector or backend.
- **Datadog** — send traces straight to Datadog's OTLP intake, no separate collector or Datadog Agent required.
4. Configure the connection. The fields depend on the integration you selected:
<Frame>![OpenTelemetry Collector Configuration](/images/crewai-otel-collector-config.png)</Frame>
<Tabs>
<Tab title="OpenTelemetry Traces / Logs">
**OpenTelemetry Traces** and **OpenTelemetry Logs** are separate integrations that share the same fields — pick the one matching the signal you want to export.
- **Endpoint** — Your collector's OTLP endpoint (e.g., `https://otel-collector.example.com:4317`).
- **Service Name** — A name to identify this service in your observability platform.
- **Custom Headers** *(optional)* — Add authentication or routing headers as key-value pairs.
- **Certificate** *(optional)* — Provide a TLS certificate if your collector requires one.
<Frame>![OpenTelemetry collector configuration](/images/crewai-otel-collector-opentelemetry.png)</Frame>
</Tab>
<Tab title="Datadog">
- **Datadog Site Domain** — Your Datadog site's OTLP host only, with no protocol or path. CrewAI builds the full HTTPS OTLP endpoint for you. Use the host that matches your [Datadog site](https://docs.datadoghq.com/getting_started/site/):
- `otlp.datadoghq.com` (US1)
- `otlp.us3.datadoghq.com` (US3)
- `otlp.us5.datadoghq.com` (US5)
- `otlp.datadoghq.eu` (EU1)
- `otlp.ap1.datadoghq.com` (AP1)
- **API Key** — Your Datadog API key. See [how to create one](https://docs.datadoghq.com/account_management/api-app-keys/#api-keys).
The Datadog integration exports **traces**.
<Frame>![Datadog collector configuration](/images/crewai-otel-collector-datadog.png)</Frame>
</Tab>
</Tabs>
5. *(optional)* Click **Test Connection** to verify CrewAI can reach the endpoint with the credentials you provided.
6. Click **Save**.
<Tip>
You can add multiple collectors — for example, one for traces and another for logs, or send to different backends for different purposes.

View File

@@ -161,6 +161,18 @@ crew = Crew(
)
```
<Note>
`agent.i18n` is maintained only for backward compatibility and is deprecated. For runtime prompt customization, pass `prompt_file` to `Crew`. For programmatic access to prompt slices, use the i18n utility directly:
</Note>
```python
from crewai.utilities.i18n import get_i18n
i18n = get_i18n("custom_prompts.json")
format_slice = i18n.slice("format")
tool_prompt = i18n.tools("ask_question")
```
#### Option 3: Disable System Prompts for o1 Models
```python
agent = Agent(
@@ -208,6 +220,8 @@ One straightforward approach is to create a JSON file for the prompts you want t
CrewAI then merges your customizations with the defaults, so you don't have to redefine every prompt. Here's how:
For code that needs to read prompt slices directly, use `crewai.utilities.i18n.get_i18n()` with the same prompt file instead of reading `agent.i18n`.
### Example: Basic Prompt Customization
Create a `custom_prompts.json` file with the prompts you want to modify. Ensure you list all top-level prompts it should contain, not just your changes:

View File

@@ -1,132 +1,121 @@
---
title: Conversational Flows
description: Build multi-turn chat apps with kickoff per turn, message history, intent routing, tracing, and WebSocket bridges.
description: Build multi-turn chat apps with handle_turn per turn, message history, intent routing, tracing, and WebSocket bridges.
icon: comments
mode: "wide"
---
## Overview
Conversational apps treat each user line as a **new flow run** with the **same session id**. CrewAI adds helpers for message history, optional intent classification, deferred tracing, UI bridges, and a local `flow.chat()` REPL for conversational flows.
Conversational apps treat each user line as a **new flow run** with the **same session id**. CrewAI adds helpers for message history, optional intent routing, deferred tracing, UI bridges, and a local `flow.chat()` REPL for conversational flows.
| Concept | Implementation |
|---------|----------------|
| Session id | `kickoff(session_id=...)` → `inputs["id"]` → `state.id` |
| User line | `kickoff(user_message=...)` appends to `state.messages` before the graph runs |
| Turn complete | `FlowFinished` for **this run** only; chat continues on the next `kickoff` |
| Full-session trace | `ConversationalConfig(defer_trace_finalization=True)` + `finalize_session_traces()` |
| Session id | `handle_turn(..., session_id=...)` → `kickoff(inputs={"id": ...})` → `state.id` |
| User line | `handle_turn(message)` appends to `state.messages` before the graph runs |
| Turn complete | `FlowFinished` for **this run** only; chat continues on the next `handle_turn` |
| Full-session trace | `ConversationConfig(defer_trace_finalization=True)` + `finalize_session_traces()` |
## Turn APIs
Use **`flow.kickoff(user_message=..., session_id=...)`** or **`flow.handle_turn(...)`** for every user message from REST, WebSocket, tests, and custom UIs. Use **`flow.chat()`** when you want a local terminal chat loop for a conversational `Flow`.
Use **`flow.handle_turn(message, session_id=...)`** for every user message from REST, WebSocket, tests, and custom UIs. Use **`flow.chat()`** when you want a local terminal chat loop for a conversational `Flow`.
`Flow.kickoff()` does **not** accept `user_message=` or `session_id=` keyword arguments. For conversational flows, `handle_turn()` stores the pending message and calls `kickoff(inputs={"id": session_id})` internally after resetting per-turn execution state.
| API | Use for |
|-----|---------|
| `kickoff(user_message=..., session_id=...)` | Each user message |
| `handle_turn(message, session_id=...)` | Ergonomic one-turn wrapper for conversational `Flow` |
| `chat()` | Local terminal REPL for conversational `Flow` |
| `kickoff_async(...)` | Same parameters; native async entry |
| `kickoff(inputs={...})` | Advanced flow execution without conversational turn handling |
| `ask()` | Blocking prompt **inside** one step (wizard, clarification) |
| `@human_feedback` | Approve/reject **a step output** — not the next chat line |
| `ChatSession.handle_turn(...)` | Transport layer over `kickoff` (SSE / WebSocket) |
| `ChatSession.handle_turn(...)` | Transport layer over `handle_turn` (SSE / WebSocket) |
## Quick start
```python
from uuid import uuid4
from crewai.flow import (
ChatState,
ConversationalConfig,
Flow,
listen,
or_,
persist,
router,
start,
from crewai import Flow
from crewai.flow import listen
from crewai.experimental.conversational import (
ConversationConfig,
ConversationState,
)
from crewai.flow.persistence import SQLiteFlowPersistence
class SupportFlow(Flow[ChatState]):
conversational_config = ConversationalConfig(
default_intents=["order", "help", "goodbye"],
intent_llm="gpt-4o-mini",
defer_trace_finalization=True,
)
@ConversationConfig(defer_trace_finalization=True)
class SupportFlow(Flow[ConversationState]):
conversational = True
@start()
def bootstrap(self):
if not self.state.session_ready:
self.state.session_ready = True
return "ready"
@router(bootstrap)
def route(self):
# last_intent set in prepare_conversational_turn when default_intents is set
return self.state.last_intent or "help"
def route_turn(self, context):
message = (self.state.current_user_message or "").lower()
if "order" in message:
return "order"
if "bye" in message or "goodbye" in message:
return "goodbye"
return "help"
@listen("order")
def handle_order(self):
reply = "Your order is on the way."
self.append_message("assistant", reply)
self.append_assistant_message(reply)
return reply
@listen("help")
def handle_help(self):
reply = "How can I help?"
self.append_message("assistant", reply)
self.append_assistant_message(reply)
return reply
@listen("goodbye")
def handle_goodbye(self):
reply = "Goodbye!"
self.append_message("assistant", reply)
self.append_assistant_message(reply)
return reply
@persist(SQLiteFlowPersistence("support.db"))
@listen(or_(handle_order, handle_help, handle_goodbye))
def finalize(self):
return self.state.model_dump()
session_id = str(uuid4())
flow = SupportFlow()
flow.kickoff(user_message="Where is my order?", session_id=session_id)
flow.kickoff(user_message="What about returns?", session_id=session_id)
flow.finalize_session_traces() # one trace link for the whole chat
try:
flow.handle_turn("Where is my order?", session_id=session_id)
flow.handle_turn("What about returns?", session_id=session_id)
finally:
flow.finalize_session_traces() # one trace link for the whole chat
```
## Turn lifecycle
Each `kickoff` with `user_message` runs this pipeline:
Each `handle_turn` runs this pipeline:
1. **`_configure_conversational_kickoff`** — merges `session_id` / `user_message` into `inputs`, applies `ConversationalConfig`, enables deferred tracing when configured.
1. **Turn setup** — stores the pending user message, resolves the session id, resets per-turn execution tracking, and calls `kickoff(inputs={"id": session_id})`.
2. **State restore** — if `inputs["id"]` exists and `@persist` is configured, loads the latest snapshot.
3. **`FlowStarted`** — emitted on the first deferred session turn only.
4. **`prepare_conversational_turn`** — appends the user message to `state.messages`, sets `last_user_message`, clears `last_intent`, optionally classifies when `intents` / `default_intents` + `intent_llm` are set.
5. **Graph execution** — `@start` → `@router` → `@listen` handlers.
4. **Pending turn hydration** — appends the user message to `state.messages`, sets `current_user_message` / `last_user_message`, and optionally classifies when `intents` / `default_intents` + `intent_llm` are set.
5. **Graph execution** — `conversation_start` → `route_conversation` → the selected `@listen` handler.
6. **End of run** — per-turn `flow_finished` and trace finalization are **skipped** when deferral is enabled; nested `Agent.kickoff()` / crews do not close the parent batch either.
Handlers should call **`append_message("assistant", reply)`** so the next turns `conversation_messages` includes assistant text. The user line is already stored at kickoff — do not append it again in handlers.
Handlers should call **`append_assistant_message(reply)`** so the next turns `conversation_messages` includes assistant text. The user line is already stored by `handle_turn` — do not append it again in handlers.
## `ConversationalConfig` (class-level defaults)
## `ConversationConfig` (class-level defaults)
Set on your `Flow` subclass as `conversational_config: ClassVar[ConversationalConfig | None]`.
Decorate your conversational `Flow` subclass with `ConversationConfig`.
| Field | Default | Purpose |
|-------|---------|---------|
| `default_intents` | `None` | Outcome labels for automatic pre-kickoff classification |
| `intent_llm` | `None` | Model for classification (required when intents are used) |
| `interactive_prompt` | `"You: "` | Prompt for `kickoff(interactive=True)` |
| `interactive_timeout` | `None` | Per-line timeout in interactive mode |
| `exit_commands` | `exit`, `quit` | Words that end interactive mode |
| `defer_trace_finalization` | `True` | Keep one trace batch open across turns |
| `system_prompt` | Framework default | System message used by the built-in `converse_turn`. |
| `llm` | `None` | Conversation LLM used by `converse_turn` and as router fallback. |
| `router` | `None` | `RouterConfig` for LLM-driven routing. |
| `intent_llm` | `None` | LLM for `intents=` / `default_intents` pre-classification. |
| `default_intents` | `None` | Outcome labels for pre-classification. |
| `defer_trace_finalization` | `True` | Keep one trace batch open across `handle_turn()` calls. |
Override per kickoff with `intents=` and `intent_llm=` keyword arguments.
Override pre-classification per turn with `handle_turn(..., intents=..., intent_llm=...)`.
## `ChatState` (recommended persisted shape)
## Lower-level `ChatState` helpers
`ChatState`, `ConversationalConfig`, and `crewai.flow.conversation` helpers are still importable for advanced orchestration, tests, or custom wrappers. They do not add `user_message=` or `session_id=` keyword arguments to `Flow.kickoff()`.
```python
from crewai.flow import ChatState
@@ -140,7 +129,7 @@ class MyChatState(ChatState):
| Field | Role |
|-------|------|
| `id` | Session UUID (same as `session_id` / `inputs["id"]`) |
| `id` | Session UUID (same as `inputs["id"]`) |
| `messages` | `list` of `{role, content}` for LLM history |
| `last_user_message` | Latest user line for this turn |
| `last_intent` | Route label after classification (if used) |
@@ -150,27 +139,26 @@ class MyChatState(ChatState):
## `Flow` conversational API
### `kickoff` / `kickoff_async` parameters
### `handle_turn` parameters
| Parameter | Purpose |
|-----------|---------|
| `user_message` | This turns text (or `{"role": "user", "content": "..."}`) |
| `message` | This turns text |
| `session_id` | Conversation UUID → `inputs["id"]` / `state.id` |
| `intents` | Outcome labels for pre-kickoff `classify_intent` |
| `intent_llm` | LLM for classification (required with `intents`) |
| `interactive` | CLI loop via `ask()` (local demos only) |
| `interactive_prompt` | Override prompt in interactive mode |
| `interactive_timeout` | Per-line `ask()` timeout |
| `exit_commands` | Words that end interactive mode |
| `inputs` | Additional state fields (merged with conversational keys) |
| `restore_from_state_id` | Fork hydration from another persisted flow |
| `**kickoff_kwargs` | Forwarded to `kickoff()` for options like `input_files`, `from_checkpoint`, and `restore_from_state_id` |
### `kickoff` parameters
`Flow.kickoff()` accepts `inputs`, `input_files`, `from_checkpoint`, and `restore_from_state_id`. Pass `inputs={"id": session_id}` when you need raw flow execution, but use `handle_turn()` when the call represents a chat message.
### Instance attributes
| Attribute | Purpose |
|-----------|---------|
| `conversational_config` | Class-level `ConversationalConfig` defaults |
| `defer_trace_finalization` | Instance flag; set automatically from config on kickoff |
| `conversational` | Set to `True` to enable the conversational graph and `handle_turn()` |
| `defer_trace_finalization` | Instance flag; set automatically from config on `handle_turn()` |
| `suppress_flow_events` | Hides console flow panels; **tracing still records** method/flow events |
| `stream` | Enable streaming; use with `ChatSession.handle_turn(..., stream=True)` |
@@ -178,7 +166,8 @@ class MyChatState(ChatState):
| Name | Description |
|------|-------------|
| `append_message(role, content, **extra)` | Append to `state.messages` (roles: `user`, `assistant`, `system`, `tool`) |
| `append_assistant_message(content)` | Append a user-visible assistant reply to `state.messages` |
| `append_message(role, content, **extra)` | Lower-level append to `state.messages` |
| `conversation_messages` | Read-only history for LLM calls |
| `classify_intent(text, outcomes, *, llm, context=None)` | Map text to one outcome (same collapse logic as `@human_feedback`) |
| `receive_user_message(text, *, outcomes=None, llm=None)` | Append user message; optionally set `last_intent` |
@@ -195,7 +184,7 @@ Importable for tests or custom orchestration:
| `normalize_kickoff_inputs(inputs, user_message=..., session_id=...)` | Merge conversational kwargs into `inputs` |
| `get_conversation_messages(flow)` | Read messages from state or internal buffer |
| `append_message(flow, role, content, **extra)` | Same as instance method |
| `prepare_conversational_turn(flow, user_message=..., intents=..., intent_llm=..., config=...)` | Turn hydration (usually called by kickoff) |
| `prepare_conversational_turn(flow, user_message=..., intents=..., intent_llm=..., config=...)` | Lower-level turn hydration for custom wrappers |
| `receive_user_message(flow, text, ...)` | Same as instance method |
| `set_state_field(flow, name, value)` | Set a field on dict or Pydantic state |
| `get_conversational_config(flow)` | Read class `conversational_config` |
@@ -203,21 +192,20 @@ Importable for tests or custom orchestration:
## Intent routing patterns
### A. Pre-classify via `ConversationalConfig` (simplest)
### A. Pre-classify via `ConversationConfig` (simplest)
Set `default_intents` and `intent_llm`. Each kickoff runs classification before your `@router`; read `self.state.last_intent` in `route()`.
Set `default_intents` and `intent_llm`. Each `handle_turn()` runs classification before routing; read `self.state.last_intent` in `route_turn()`.
### B. Classify inside `@router` (richer prompts)
### B. Classify inside `route_turn` (richer prompts)
Set `default_intents=None` so kickoff only appends the user message. In `route()`, call `classify_intent` with a custom prompt or descriptions:
Set `default_intents=None` so `handle_turn()` only appends the user message. In `route_turn()`, call `classify_intent` with a custom prompt or descriptions:
```python
@router(bootstrap)
def route(self):
def route_turn(self, context):
intent = self.classify_intent(
self._routing_prompt(self.state.last_user_message),
self._routing_prompt(self.state.current_user_message),
("GREETING", "ORDER", "RESEARCH", "GOODBYE"),
llm=self.conversational_config.intent_llm or "gpt-4o-mini",
llm="gpt-4o-mini",
)
self.state.last_intent = intent
return intent
@@ -227,7 +215,7 @@ Use **`@listen("RESEARCH")`** (or similar) for steps that run `Agent.kickoff()`
## When the flow finishes but the user keeps chatting
`FlowFinished` means **this graph run** completed. The conversation continues with another `kickoff` and the same `session_id`. `@persist` restores `messages`, flags, and context.
`FlowFinished` means **this graph run** completed. The conversation continues with another `handle_turn()` and the same `session_id`. `@persist` restores `messages`, flags, and context.
**Persist pattern:** prefer `@persist` on a **single terminal step** (for example `finalize`) rather than on the whole `Flow` class. Class-level persist saves after every method; `load_state` uses the latest row, which may be a mid-run snapshot (for example right after `bootstrap`) and miss handler updates from the same turn.
@@ -244,53 +232,53 @@ Do **not** use `@human_feedback` for follow-up chat lines unless a human must ap
changelog for breaking updates. Open issues / feedback welcome.
</Warning>
Opt into the conversational chat graph by setting `conversational = True` on a `Flow` subclass. The base `Flow` then ships a built-in `@start` / `@router` / `converse_turn` / `end_conversation` graph, manages `state.messages`, drives the router LLM, and keeps the trace batch open across turns. You write the **custom routes**; the framework owns the rest.
Opt into the conversational chat graph by setting `conversational = True` on a `Flow` subclass. The base `Flow` then ships a built-in `@start` / `@router` / `converse_turn` / `end_conversation` graph, manages `state.messages`, can drive a router LLM, and keeps the trace batch open across turns. You write the **custom routes**; the framework owns the rest.
Use this when you want a multi-turn chat with an LLM-driven router and per-route handlers without wiring the lifecycle yourself. Use `Flow[ChatState]` (the lower-level pattern above) when you need full control.
Use this when you want a multi-turn chat with a router and per-route handlers without wiring the lifecycle yourself. Use `Flow[ChatState]` (the lower-level pattern above) when you need full control.
### Quick example
```python
from crewai import LLM, Flow
from crewai import Flow
from crewai.flow import listen
from crewai.experimental.conversational import (
ConversationConfig,
ConversationState,
RouterConfig,
)
ROUTER_LLM = LLM(model="gpt-4o-mini")
@ConversationConfig(
system_prompt="A multi-agent assistant for ordinary chat and tool-backed tasks.",
llm=ROUTER_LLM,
router=RouterConfig(), # routes + descriptions auto-discovered from @listen handlers
)
@ConversationConfig(defer_trace_finalization=True)
class SupportFlow(Flow[ConversationState]):
conversational = True
def route_turn(self, context: dict) -> str | None:
message = (self.state.current_user_message or "").lower()
if "search" in message or "news" in message:
return "INTERNET_SEARCH"
if "docs" in message or "crewai" in message:
return "CREWAI_DOCS"
return "converse"
@listen("INTERNET_SEARCH")
def handle_internet_search(self) -> str:
"""Fresh web research, current news, real-time lookups."""
...
reply = "I would run the web research route here."
self.append_assistant_message(reply)
return reply
@listen("CREWAI_DOCS")
def handle_crewai_docs(self) -> str:
"""Look up the CrewAI documentation for framework/API questions."""
...
reply = "I would look up the CrewAI docs here."
self.append_assistant_message(reply)
return reply
flow = SupportFlow()
try:
flow.handle_turn("What can you do?") # routes to converse (built-in)
flow.handle_turn("What can you do?") # routes to converse
flow.handle_turn("Search the web for AI news.") # routes to INTERNET_SEARCH
flow.handle_turn("Summarize the first result.") # routes back to converse
flow.handle_turn("Check the CrewAI docs.") # routes to CREWAI_DOCS
finally:
flow.finalize_session_traces()
```
@@ -323,7 +311,21 @@ Class decorator that attaches per-class chat defaults.
### `RouterConfig` and the auto-built route catalog
```python
RouterConfig(
from typing import Literal
from pydantic import BaseModel
from crewai import LLM
from crewai.experimental.conversational import RouterConfig
class MyRoute(BaseModel):
intent: Literal["INTERNET_SEARCH", "CREWAI_DOCS", "converse"]
ROUTER_LLM = LLM(model="gpt-4o-mini")
router_config = RouterConfig(
prompt="Optional domain framing (policy, voice, persona).",
response_format=MyRoute, # optional; auto-generated otherwise
llm=ROUTER_LLM, # falls back to ConversationConfig.llm
@@ -347,6 +349,9 @@ The router prompt that gets sent to the LLM is built automatically. For each rou
So in practice, **adding a new route is `@listen("X")` + a one-line docstring**:
```python
from crewai.flow import listen
@listen("INTERNET_SEARCH")
def handle_internet_search(self) -> str:
"""Fresh web research, current news, real-time lookups."""
@@ -385,7 +390,7 @@ You can override any of these by defining a same-named handler in your subclass.
4. The router stores its decision in `state.last_intent` (visible to the next turn's router context).
5. If your handler returned a string and didn't already call `append_assistant_message`, `handle_turn` appends it for you.
You can also call `flow.kickoff(user_message=..., session_id=...)` directly the same reset/run logic fires. `handle_turn` is the ergonomic wrapper.
Call `handle_turn()` for chat messages. Calling `kickoff(inputs={"id": ...})` directly runs the flow graph without applying the conversational turn wrapper.
### `chat()` for local REPLs
@@ -422,6 +427,12 @@ For web apps, background workers, tests, and custom transports, keep using `hand
To run side effects (event bus setup, telemetry) on every routing decision, override `route_turn`:
```python
from typing import Any
from crewai import Flow
from crewai.experimental.conversational import ConversationState
class SupportFlow(Flow[ConversationState]):
conversational = True
@@ -443,7 +454,7 @@ Inside a `@listen(label)` handler, choose:
## Tracing across turns
With `defer_trace_finalization=True` (default in `ConversationalConfig`):
With `defer_trace_finalization=True` (default in `ConversationConfig`):
- **One trace batch** for the whole chat session.
- **`flow_started`** on the first turn only; **`flow_finished`** once in `finalize_session_traces()`.
@@ -455,7 +466,7 @@ flow.chat(session_id=session_id)
```
`flow.chat()` calls `finalize_session_traces()` for you. When you own the loop
with `handle_turn()` or `kickoff(...)`, call `finalize_session_traces()` when
with `handle_turn()`, call `finalize_session_traces()` when
the session ends.
`suppress_flow_events=True` only hides Rich console panels; trace and method events still emit for observability.

Binary file not shown.

After

Width:  |  Height:  |  Size: 455 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 420 KiB

View File

@@ -4,6 +4,178 @@ description: "CrewAI의 제품 업데이트, 개선 사항 및 버그 수정"
icon: "clock"
mode: "wide"
---
<Update label="2026년 6월 11일">
## v1.14.7
[GitHub 릴리스 보기](https://github.com/crewAIInc/crewAI/releases/tag/1.14.7)
## 변경 사항
### 기능
- 메모리, 지식, RAG 및 흐름에 대한 플러그 가능한 기본 백엔드를 추가했습니다.
- LLM 이벤트에서 실제 finish_reason, 샘플링 매개변수 및 response.id를 표시합니다.
- 경로 인식 장식자로서의 타입 DSL 트리거를 설정합니다.
- 대화 흐름을 위한 채팅 API를 추가했습니다.
- 잠금 백엔드를 재정의 가능하도록 만듭니다.
- Flow DSL 메타데이터에서 FlowDefinition을 빌드합니다.
- 네이티브 Snowflake Cortex LLM 공급자를 추가했습니다.
- 훈련된 에이전트 파일 지원을 추가했습니다.
### 버그 수정
- 복원 시 사용자 정의 BaseLLM을 구체적인 LLM으로 재구성하도록 체크포인트를 수정했습니다.
- 라이브 스냅샷이 재개로 재생되지 않도록 플래그를 사용하여 복원을 제한합니다.
- 실행마다 런타임 상태의 범위를 설정하여 성장을 제한하고 동시 실행을 격리합니다.
- crewai-login에서 텔레메트리 설정을 수정했습니다.
- 메서드 실행 이벤트에 대해 suppress_flow_events를 존중합니다.
- uv 도구 설치를 위해 crewai 패키지에서 [project.scripts]를 복원합니다.
- aiohttp, docling 및 docling-core에 대한 pip-audit CVE를 해결합니다.
- 파일 입력이 신뢰할 수 없게 작동하는 문제를 수정했습니다.
- Snowflake Claude의 불완전한 도구 결과 기록을 수정했습니다.
### 문서
- v1.14.7에 대한 변경 로그 및 버전을 업데이트했습니다.
- OpenTelemetry 수집기 문서를 업데이트했습니다.
- NVIDIA Nemotron LLM 가이드를 업데이트했습니다.
- Databricks 통합 가이드를 추가했습니다.
- Snowflake 통합 가이드를 추가했습니다.
### 성능
- docling 가져오기를 지연 로딩하여 crewai 가져오기 속도를 개선했습니다.
### 리팩토링
- 흐름 조건 평가를 이벤트별로 상태 비저장으로 단순화했습니다.
- 대화 논리를 런타임에서 분리하고 conversational_definition을 추가했습니다.
- `flow.py`를 DSL, 정의 및 런타임으로 분리했습니다.
## 기여자
@Luzk, @alex-clawd, @devin-ai-integration[bot], @greysonlalonde, @gvieira, @jessemiller, @lorenzejay, @lucasgomide, @mattatcha, @vinibrsl
</Update>
<Update label="2026년 6월 10일">
## v1.14.7rc2
[GitHub 릴리스 보기](https://github.com/crewAIInc/crewAI/releases/tag/1.14.7rc2)
## 변경 사항
### 버그 수정
- 라이브 스냅샷이 재개로 재생되는 것을 방지하기 위한 플래그에서 게이트 복원
### 문서
- v1.14.7rc1에 대한 변경 로그 및 버전 업데이트
## 기여자
@greysonlalonde
</Update>
<Update label="2026년 6월 10일">
## v1.14.7rc1
[GitHub 릴리스 보기](https://github.com/crewAIInc/crewAI/releases/tag/1.14.7rc1)
## 변경 사항
### 기능
- 누적된 버스 상태를 해제하기 위해 `reset_runtime_state` 추가
- 사용자 정의 프롬프트를 모두 지원하도록 처리
- 대화 논리를 런타임과 분리하고 `conversational_definition` 추가
### 버그 수정
- 실행당 런타임 상태의 범위를 수정하여 성장 제한 및 동시 실행 격리
- `crewai-login`에서 원격 측정 설정 수정
- 메서드 실행 이벤트에 대한 `suppress_flow_events` 존중 수정
### 문서
- OpenTelemetry 이미지 업데이트
- OpenTelemetry 수집기의 새로운 상태를 반영하도록 문서 업데이트
- v1.14.7a4에 대한 변경 로그 및 버전 업데이트
### 리팩토링
- 이벤트당 상태 비저장 방식으로 흐름 조건 평가 단순화
- 경로를 하나 줄여 대화 라우팅 사이클 개선
## 기여자
@greysonlalonde, @lorenzejay, @lucasgomide, @vinibrsl
</Update>
<Update label="2026년 6월 9일">
## v1.14.7a4
[GitHub 릴리스 보기](https://github.com/crewAIInc/crewAI/releases/tag/1.14.7a4)
## 변경 사항
### 기능
- @listen/@router 런타임을 FlowDefinition에서 읽도록 마이그레이션
- 메모리, 지식, rag 및 flow에 대한 플러그형 기본 백엔드 추가
### 문서
- v1.14.7a3에 대한 변경 로그 및 버전 업데이트
## 기여자
@greysonlalonde, @mattatcha, @vinibrsl
</Update>
<Update label="2026년 6월 8일">
## v1.14.7a3
[GitHub 릴리스 보기](https://github.com/crewAIInc/crewAI/releases/tag/1.14.7a3)
## 변경 사항
### 버그 수정
- 실험적인 `AgentExecutor`에서 `ask_for_human_input` 노출 문제 수정
- `aiohttp`, `docling`, `docling-core`, 및 `pip`에 대한 pip-audit CVE 해결
### 리팩토링
- `@start`를 `FlowDefinition`에서 읽도록 마이그레이션
### 문서화
- v1.14.7a2에 대한 변경 로그 및 버전 업데이트
## 기여자
@greysonlalonde, @lorenzejay, @vinibrsl
</Update>
<Update label="2026년 6월 5일">
## v1.14.7a2
[GitHub 릴리스 보기](https://github.com/crewAIInc/crewAI/releases/tag/1.14.7a2)
## 변경 사항
### 기능
- 대화 흐름 추적 지원 추가.
- `handle_turn`을 활용하도록 대화 흐름 문서 업데이트.
- LLM 이벤트에서 실제 `finish_reason`, 샘플링 매개변수 및 `response.id` 표시.
- 라우트 인식 데코레이터로서 DSL 트리거 유형 지정.
- 대화 흐름을 위한 채팅 API 구현.
- 잠금 저장소에서 백엔드 잠금 오버라이드 가능하게 설정.
- 흐름 DSL 모놀리스를 집중된 데코레이터 모듈로 분할.
- `_usage_to_dict`에서 LiteLLM 캐시/추론 사용 하위 카운트 평탄화.
- 흐름 DSL 메타데이터에서 `FlowDefinition` 구축.
### 문서
- NVIDIA Nemotron LLM 가이드 추가.
- 모노레포 배포 문서화.
- v1.14.7a1에 대한 변경 로그 및 버전 업데이트.
## 기여자
@alex-clawd, @gvieira, @lorenzejay, @lucasgomide, @mattatcha, @vinibrsl
</Update>
<Update label="2026년 6월 3일">
## v1.14.7a1

View File

@@ -221,6 +221,48 @@ Flow가 실행된 후, 이러한 메소드들에 의해 수행된 업데이트
최종 메소드의 출력이 반환되고 상태에 접근할 수 있도록 함으로써, CrewAI Flow는 AI 워크플로우의 결과를 더 큰 애플리케이션이나 시스템에 쉽게 통합할 수 있게 하며,
Flow 실행 과정 전반에 걸쳐 상태를 유지하고 접근하면서도 이를 용이하게 만듭니다.
## 플로우 사용 메트릭
Flow 실행이 완료된 후, `usage_metrics` 속성에 접근하여 실행 동안 발생한 **모든 LLM 호출**의 토큰 사용량 집계를 확인할 수 있습니다. 여기에는 Flow가 오케스트레이션한 모든 Crew의 호출, Agent의 도구 내부에서 발생한 호출, 그리고 Flow 메서드에서 직접 호출한 `LLM.call(...)`이 모두 포함됩니다. 이는 CrewAI Enterprise UI에 표시되는 총량과 동등한 SDK 측 값입니다.
```python Code
from crewai import LLM
from crewai.flow.flow import Flow, listen, start
class UsageMetricsFlow(Flow):
@start()
def run_first_crew(self):
self.state.first_result = FirstCrew().crew().kickoff()
@listen(run_first_crew)
def call_llm_directly(self):
# 직접 LLM 호출 — flow.usage_metrics에서도 집계됩니다
llm = LLM(model="openai/gpt-4o-mini")
self.state.summary = llm.call("핵심 내용을 요약해 주세요.")
@listen(call_llm_directly)
def run_second_crew(self):
self.state.second_result = SecondCrew().crew().kickoff()
flow = UsageMetricsFlow()
flow.kickoff()
print(flow.usage_metrics)
# UsageMetrics(total_tokens=8579, prompt_tokens=6210, completion_tokens=2369,
# cached_prompt_tokens=0, reasoning_tokens=0,
# cache_creation_tokens=0, successful_requests=5)
```
<Note>
`flow.usage_metrics`는 `flow.kickoff().token_usage`와 **동일하지 않습니다**.
후자는 `CrewOutput`을 반환한 **마지막** `@listen` 메서드의
`CrewOutput.token_usage`만 반환하므로, 이전에 실행된 Crew들과 Flow 메서드에서
직접 호출한 `LLM.call(...)`은 전혀 포함되지 않습니다. Flow 실행에 대한
**전체** 토큰 집계가 필요할 때는 항상 `flow.usage_metrics`를 사용하십시오.
</Note>
반환되는 [`UsageMetrics`](https://github.com/crewAIInc/crewAI/blob/main/lib/crewai/src/crewai/types/usage_metrics.py)의 각 항목은 단일 `flow.kickoff()` 실행 동안 발생한 모든 LLM 호출의 합계입니다. 다음 `kickoff()` 호출(및 `kickoff_for_each`의 각 반복)에서 카운터가 초기화되므로 연속 실행이 이중으로 집계되지 않습니다. 이 속성은 `kickoff()` 완료 후 언제든지 안전하게 읽을 수 있으며, 실행 중에 읽으면 그 시점까지 누적된 부분 합계를 반환합니다.
## 플로우 상태 관리
상태를 효과적으로 관리하는 것은 신뢰할 수 있고 유지 보수가 용이한 AI 워크플로를 구축하는 데 매우 중요합니다. CrewAI 플로우는 비정형 및 정형 상태 관리를 위한 강력한 메커니즘을 제공하여, 개발자가 자신의 애플리케이션에 가장 적합한 접근 방식을 선택할 수 있도록 합니다.

View File

@@ -24,15 +24,39 @@ CrewAI AMP는 배포에서 OpenTelemetry **트레이스**와 **로그**를 자
1. CrewAI AMP에서 **Settings** > **OpenTelemetry Collectors**로 이동합니다.
2. **Add Collector**를 클릭합니다.
3. 통합 유형을 선택합니다 — **OpenTelemetry Traces** 또는 **OpenTelemetry Logs**.
4. 연결을 구성합니다:
- **Endpoint** — 수집기의 OTLP 엔드포인트 (예: `https://otel-collector.example.com:4317`).
- **Service Name** — 관측 가능성 플랫폼에서 이 서비스를 식별하기 위한 이름.
- **Custom Headers** *(선택 사항)* — 인증 또는 라우팅 헤더를 키-값 쌍으로 추가합니다.
- **Certificate** *(선택 사항)* — 수집기에서 TLS 인증서가 필요한 경우 제공합니다.
5. **Save**를 클릭합니다.
3. 통합을 선택합니다:
- **OpenTelemetry Traces** 및 **OpenTelemetry Logs** — OTLP 호환 수집기 또는 백엔드로 내보냅니다.
- **Datadog** — 별도의 수집기나 Datadog Agent 없이 트레이스를 Datadog의 OTLP 인테이크로 직접 전송합니다.
4. 연결을 구성합니다. 필드는 선택한 통합에 따라 달라집니다:
<Frame>![OpenTelemetry 수집기 구성](/images/crewai-otel-collector-config.png)</Frame>
<Tabs>
<Tab title="OpenTelemetry Traces / Logs">
**OpenTelemetry Traces**와 **OpenTelemetry Logs**는 동일한 필드를 공유하는 별개의 통합입니다 — 내보내려는 신호에 맞는 것을 선택하세요.
- **Endpoint** — 수집기의 OTLP 엔드포인트 (예: `https://otel-collector.example.com:4317`).
- **Service Name** — 관측 가능성 플랫폼에서 이 서비스를 식별하기 위한 이름.
- **Custom Headers** *(선택 사항)* — 인증 또는 라우팅 헤더를 키-값 쌍으로 추가합니다.
- **Certificate** *(선택 사항)* — 수집기에서 TLS 인증서가 필요한 경우 제공합니다.
<Frame>![OpenTelemetry 수집기 구성](/images/crewai-otel-collector-opentelemetry.png)</Frame>
</Tab>
<Tab title="Datadog">
- **Datadog Site Domain** — Datadog 사이트의 OTLP 호스트만 입력합니다 (프로토콜이나 경로 제외). CrewAI가 전체 HTTPS OTLP 엔드포인트를 자동으로 구성합니다. [Datadog 사이트](https://docs.datadoghq.com/getting_started/site/)에 맞는 호스트를 사용하세요:
- `otlp.datadoghq.com` (US1)
- `otlp.us3.datadoghq.com` (US3)
- `otlp.us5.datadoghq.com` (US5)
- `otlp.datadoghq.eu` (EU1)
- `otlp.ap1.datadoghq.com` (AP1)
- **API Key** — Datadog API 키입니다. [키 생성 방법](https://docs.datadoghq.com/account_management/api-app-keys/#api-keys)을 참고하세요.
Datadog 통합은 **트레이스**를 내보냅니다.
<Frame>![Datadog 수집기 구성](/images/crewai-otel-collector-datadog.png)</Frame>
</Tab>
</Tabs>
5. *(선택 사항)* **Test Connection**을 클릭하여 제공한 자격 증명으로 CrewAI가 엔드포인트에 연결할 수 있는지 확인합니다.
6. **Save**를 클릭합니다.
<Tip>
여러 수집기를 추가할 수 있습니다 — 예를 들어, 트레이스용 하나와 로그용 하나를 추가하거나, 다른 목적을 위해 다른 백엔드로 전송할 수 있습니다.

View File

@@ -161,6 +161,18 @@ crew = Crew(
)
```
<Note>
`agent.i18n`은 이전 버전과의 호환성을 위해서만 유지되며 사용이 중단될 예정입니다. 런타임 프롬프트 커스터마이징에는 `Crew`에 `prompt_file`을 전달하세요. 프롬프트 슬라이스를 코드에서 직접 읽어야 한다면 i18n 유틸리티를 직접 사용하세요:
</Note>
```python
from crewai.utilities.i18n import get_i18n
i18n = get_i18n("custom_prompts.json")
format_slice = i18n.slice("format")
tool_prompt = i18n.tools("ask_question")
```
#### 옵션 3: o1 모델에 대한 시스템 프롬프트 비활성화
```python
agent = Agent(
@@ -208,6 +220,8 @@ agent = Agent(
그러면 CrewAI가 기본값과 사용자가 지정한 내용을 병합하므로, 모든 프롬프트를 다시 정의할 필요가 없습니다. 방법은 다음과 같습니다:
프롬프트 슬라이스를 코드에서 직접 읽어야 하는 경우에는 `agent.i18n`을 읽는 대신 동일한 프롬프트 파일로 `crewai.utilities.i18n.get_i18n()`을 사용하세요.
### 예시: 기본 프롬프트 커스터마이징
수정하고 싶은 프롬프트를 포함하는 `custom_prompts.json` 파일을 생성하세요. 변경 사항만이 아니라 포함해야 하는 모든 최상위 프롬프트를 반드시 나열해야 합니다:
@@ -314,4 +328,4 @@ CrewAI에서의 저수준 prompt 커스터마이제이션은 매우 맞춤화되
<Check>
이제 CrewAI에서 고급 prompt 커스터마이징을 위한 기초를 갖추었습니다. 모델별 구조나 도메인별 제약에 맞춰 적용하든, 이러한 저수준 접근 방식은 agent 상호작용을 매우 전문적으로 조정할 수 있게 해줍니다.
</Check>
</Check>

View File

@@ -11,96 +11,83 @@ mode: "wide"
| 개념 | 구현 |
|------|------|
| 세션 id | `kickoff(session_id=...)` → `inputs["id"]` → `state.id` |
| 사용자 입력 | `kickoff(user_message=...)`가 그래프 실행 전 `state.messages`에 추가 |
| 턴 완료 | `FlowFinished`는 **이번 실행**만 의미; 다음 `kickoff`로 대화 계속 |
| 세션 전체 트레이스 | `ConversationalConfig(defer_trace_finalization=True)` + `finalize_session_traces()` |
| 세션 id | `handle_turn(..., session_id=...)` → `kickoff(inputs={"id": ...})` → `state.id` |
| 사용자 입력 | `handle_turn(message)`가 그래프 실행 전 `state.messages`에 추가 |
| 턴 완료 | `FlowFinished`는 **이번 실행**만 의미; 다음 `handle_turn`로 대화 계속 |
| 세션 전체 트레이스 | `ConversationConfig(defer_trace_finalization=True)` + `finalize_session_traces()` |
## 턴 API
REST, WebSocket, 테스트, 커스텀 UI에서 오는 모든 사용자 메시지에는 **`flow.kickoff(user_message=..., session_id=...)`** 또는 **`flow.handle_turn(...)`**를 사용하세요. 대화형 `Flow`를 로컬 터미널 채팅 루프로 실행하고 싶을 때는 **`flow.chat()`**을 사용하세요.
REST, WebSocket, 테스트, 커스텀 UI에서 오는 모든 사용자 메시지에는 **`flow.handle_turn(message, session_id=...)`**를 사용하세요. 대화형 `Flow`를 로컬 터미널 채팅 루프로 실행하고 싶을 때는 **`flow.chat()`**을 사용하세요.
`Flow.kickoff()`는 `user_message=` 또는 `session_id=` 키워드 인자를 받지 않습니다. 대화형 flow에서는 `handle_turn()`이 보류 중인 메시지를 저장하고 내부적으로 `kickoff(inputs={"id": session_id})`를 호출합니다.
| API | 용도 |
|-----|------|
| `kickoff(user_message=..., session_id=...)` | 각 사용자 메시지 |
| `handle_turn(message, session_id=...)` | 대화형 `Flow`용 한 턴 편의 래퍼 |
| `chat()` | 대화형 `Flow`용 로컬 터미널 REPL |
| `kickoff_async(...)` | 동일 파라미터; 네이티브 async 진입 |
| `kickoff(inputs={...})` | 대화형 턴 처리 없이 flow를 직접 실행 |
| `ask()` | 한 스텝 **내부** 블로킹 프롬프트 (마법사, 확인) |
| `@human_feedback` | **스텝 출력** 승인/거부 — 다음 채팅 줄이 아님 |
| `ChatSession.handle_turn(...)` | `kickoff` 위의 전송 계층 (SSE / WebSocket) |
| `ChatSession.handle_turn(...)` | `handle_turn` 위의 전송 계층 (SSE / WebSocket) |
## 빠른 시작
```python
from uuid import uuid4
from crewai.flow import (
ChatState,
ConversationalConfig,
Flow,
listen,
or_,
persist,
router,
start,
from crewai import Flow
from crewai.flow import listen
from crewai.experimental.conversational import (
ConversationConfig,
ConversationState,
)
from crewai.flow.persistence import SQLiteFlowPersistence
class SupportFlow(Flow[ChatState]):
conversational_config = ConversationalConfig(
default_intents=["order", "help", "goodbye"],
intent_llm="gpt-4o-mini",
defer_trace_finalization=True,
)
@ConversationConfig(defer_trace_finalization=True)
class SupportFlow(Flow[ConversationState]):
conversational = True
@start()
def bootstrap(self):
if not self.state.session_ready:
self.state.session_ready = True
return "ready"
@router(bootstrap)
def route(self):
# default_intents 설정 시 prepare_conversational_turn에서 last_intent 설정
return self.state.last_intent or "help"
def route_turn(self, context):
message = self.state.current_user_message or ""
if "주문" in message or "order" in message.lower():
return "order"
if "안녕" in message or "goodbye" in message.lower():
return "goodbye"
return "help"
@listen("order")
def handle_order(self):
reply = "주문이 배송 중입니다."
self.append_message("assistant", reply)
self.append_assistant_message(reply)
return reply
@listen("help")
def handle_help(self):
reply = "무엇을 도와드릴까요?"
self.append_message("assistant", reply)
self.append_assistant_message(reply)
return reply
@listen("goodbye")
def handle_goodbye(self):
reply = "안녕히 가세요!"
self.append_message("assistant", reply)
self.append_assistant_message(reply)
return reply
@persist(SQLiteFlowPersistence("support.db"))
@listen(or_(handle_order, handle_help, handle_goodbye))
def finalize(self):
return self.state.model_dump()
session_id = str(uuid4())
flow = SupportFlow()
flow.kickoff(user_message="주문 어디까지 왔나요?", session_id=session_id)
flow.kickoff(user_message="반품은 어떻게 하나요?", session_id=session_id)
flow.finalize_session_traces() # 전체 대화에 대한 단일 trace 링크
try:
flow.handle_turn("주문 어디까지 왔나요?", session_id=session_id)
flow.handle_turn("반품은 어떻게 하나요?", session_id=session_id)
finally:
flow.finalize_session_traces() # 전체 대화에 대한 단일 trace 링크
```
## 턴 생명주기
`user_message`가 있는 각 `kickoff`는 다음 파이프라인을 실행합니다:
각 `handle_turn`은 다음 파이프라인을 실행합니다:
1. **`_configure_conversational_kickoff`** — `session_id` / `user_message`를 `inputs`에 병합, `ConversationalConfig` 적용, 설정 시 지연 트레이싱 활성화.
2. **상태 복원** — `inputs["id"]`가 있고 `@persist`가 설정되면 최신 스냅샷 로드.
@@ -109,7 +96,7 @@ flow.finalize_session_traces() # 전체 대화에 대한 단일 trace 링크
5. **그래프 실행** — `@start` → `@router` → `@listen` 핸들러.
6. **실행 종료** — 지연 활성화 시 턴별 `flow_finished` 및 trace 종료 **건너뜀**; 중첩 `Agent.kickoff()` / crew도 부모 batch를 닫지 않음.
핸들러는 **`append_message("assistant", reply)`**를 호출해 다음 턴의 `conversation_messages`에 어시스턴트 응답이 포함되게 하세요. 사용자 입력은 kickoff 시 이미 저장니다 — 핸들러에서 다시 추가하지 마세요.
핸들러는 **`append_assistant_message(reply)`**를 호출해 다음 턴의 `conversation_messages`에 어시스턴트 응답이 포함되게 하세요. 사용자 입력은 `handle_turn`이 이미 저장니다 — 핸들러에서 다시 추가하지 마세요.
## `ConversationalConfig` (클래스 수준 기본값)
@@ -384,7 +371,7 @@ Routes:
4. router는 결정을 `state.last_intent`에 저장합니다 (다음 턴의 router 컨텍스트에서 보입니다).
5. 핸들러가 문자열을 반환했지만 `append_assistant_message`를 직접 호출하지 않았다면, `handle_turn`이 대신 추가해 줍니다.
`flow.kickoff(user_message=..., session_id=...)`를 직접 호출해도 동일한 reset/run 로직이 동작합니다. `handle_turn`은 그 위에 얹은 편의 래퍼입니다.
채팅 메시지에는 `handle_turn()`을 호출하세요. `kickoff(inputs={"id": ...})`를 직접 호출하면 대화형 턴 래퍼 없이 flow 그래프가 실행됩니다.
### 로컬 REPL용 `chat()`

View File

@@ -4,6 +4,178 @@ description: "Atualizações de produto, melhorias e correções do CrewAI"
icon: "clock"
mode: "wide"
---
<Update label="11 jun 2026">
## v1.14.7
[Ver release no GitHub](https://github.com/crewAIInc/crewAI/releases/tag/1.14.7)
## O que Mudou
### Recursos
- Adicionar backends padrão plugáveis para memória, conhecimento, rag e fluxo.
- Exibir o verdadeiro finish_reason, parâmetros de amostragem e response.id em eventos LLM.
- Tipar os gatilhos DSL como decoradores cientes de rotas.
- Adicionar API de chat para fluxos de conversa.
- Tornar o backend de bloqueio substituível.
- Construir FlowDefinition a partir de metadados Flow DSL.
- Adicionar provedor nativo Snowflake Cortex LLM.
- Adicionar suporte a arquivos de agentes treinados pela equipe.
### Correções de Bugs
- Corrigir checkpoint para reconstruir BaseLLM personalizado como LLM concreto na restauração.
- Controlar a restauração com uma flag para evitar que snapshots ao vivo sejam reproduzidos como retomar.
- Escopar o estado de execução por execução para limitar o crescimento e isolar execuções concorrentes.
- Corrigir configuração de telemetria no crewai-login.
- Respeitar suppress_flow_events para eventos de execução de método.
- Restaurar [project.scripts] no pacote crewai para instalação da ferramenta uv.
- Resolver CVEs de pip-audit para aiohttp, docling e docling-core.
- Corrigir entrada de arquivo que não estava funcionando de forma confiável.
- Corrigir histórias de resultados de ferramentas incompletas do Snowflake Claude.
### Documentação
- Atualizar changelog e versão para v1.14.7.
- Atualizar documentação do coletor OpenTelemetry.
- Atualizar guia do LLM NVIDIA Nemotron.
- Adicionar guia de integração do Databricks.
- Adicionar guia de integração do Snowflake.
### Desempenho
- Melhorar a velocidade de importação do crewai através do carregamento preguiçoso de imports do docling.
### Refatoração
- Simplificar a avaliação de condições de fluxo para ser sem estado por evento.
- Desacoplar a lógica de conversa da execução e adicionar uma conversational_definition.
- Dividir `flow.py` em DSL, definição e execução.
## Contribuidores
@Luzk, @alex-clawd, @devin-ai-integration[bot], @greysonlalonde, @gvieira, @jessemiller, @lorenzejay, @lucasgomide, @mattatcha, @vinibrsl
</Update>
<Update label="10 jun 2026">
## v1.14.7rc2
[Ver release no GitHub](https://github.com/crewAIInc/crewAI/releases/tag/1.14.7rc2)
## O que Mudou
### Correções de Bugs
- Restauração de portão em uma flag para evitar que snapshots ao vivo sejam reproduzidos como retomar
### Documentação
- Atualizar changelog e versão para v1.14.7rc1
## Contributors
@greysonlalonde
</Update>
<Update label="10 jun 2026">
## v1.14.7rc1
[Ver release no GitHub](https://github.com/crewAIInc/crewAI/releases/tag/1.14.7rc1)
## O que Mudou
### Recursos
- Adicionar `reset_runtime_state` para liberar o estado acumulado do barramento
- Lidar com suporte a ambos os prompts personalizados
- Desacoplar a lógica de conversa do tempo de execução e adicionar uma `conversational_definition`
### Correções de Bugs
- Corrigir o escopo do estado de tempo de execução por execução para limitar o crescimento e isolar execuções concorrentes
- Corrigir a configuração de telemetria em `crewai-login`
- Corrigir o respeito a `suppress_flow_events` para eventos de execução de método
### Documentação
- Atualizar imagens do OpenTelemetry
- Atualizar a documentação para refletir o novo estado do coletor OpenTelemetry
- Atualizar o changelog e a versão para v1.14.7a4
### Refatoração
- Simplificar a avaliação da condição de fluxo para ser sem estado por evento
- Melhorar o ciclo de roteamento de conversas com uma rota a menos
## Contribuidores
@greysonlalonde, @lorenzejay, @lucasgomide, @vinibrsl
</Update>
<Update label="09 jun 2026">
## v1.14.7a4
[Ver release no GitHub](https://github.com/crewAIInc/crewAI/releases/tag/1.14.7a4)
## O Que Mudou
### Funcionalidades
- Migrar a execução @listen/@router para ler a partir de FlowDefinition
- Adicionar backends padrão plugáveis para memória, conhecimento, rag e flow
### Documentação
- Atualizar changelog e versão para v1.14.7a3
## Contributors
@greysonlalonde, @mattatcha, @vinibrsl
</Update>
<Update label="08 jun 2026">
## v1.14.7a3
[Ver release no GitHub](https://github.com/crewAIInc/crewAI/releases/tag/1.14.7a3)
## O que Mudou
### Correções de Bugs
- Corrigir a exposição de `ask_for_human_input` no `AgentExecutor` experimental
- Resolver CVEs do pip-audit para `aiohttp`, `docling`, `docling-core` e `pip`
### Refatoração
- Migrar `@start` para ler de `FlowDefinition`
### Documentação
- Atualizar o changelog e a versão para v1.14.7a2
## Contribuidores
@greysonlalonde, @lorenzejay, @vinibrsl
</Update>
<Update label="05 jun 2026">
## v1.14.7a2
[Ver release no GitHub](https://github.com/crewAIInc/crewAI/releases/tag/1.14.7a2)
## O que Mudou
### Recursos
- Adicionar suporte a rastreamentos de fluxo de conversa.
- Atualizar a documentação do fluxo de conversa para utilizar `handle_turn`.
- Exibir o real `finish_reason`, parâmetros de amostragem e `response.id` em eventos LLM.
- Tipar os gatilhos DSL como decoradores cientes de rota.
- Implementar API de chat para fluxos de conversa.
- Tornar o backend de bloqueio substituível no armazenamento de bloqueios.
- Dividir o monólito DSL de fluxo em módulos de decoradores focados.
- Achatar os subcontagens de uso de cache/razão do LiteLLM em `_usage_to_dict`.
- Construir `FlowDefinition` a partir dos metadados do Flow DSL.
### Documentação
- Adicionar guia do LLM NVIDIA Nemotron.
- Documentar implantações de monorepo.
- Atualizar changelog e versão para v1.14.7a1.
## Contribuidores
@alex-clawd, @gvieira, @lorenzejay, @lucasgomide, @mattatcha, @vinibrsl
</Update>
<Update label="03 jun 2026">
## v1.14.7a1

View File

@@ -219,6 +219,49 @@ Após o término da execução, é possível acessar o estado final e observar a
Ao garantir que a saída do método final seja retornada e oferecer acesso ao estado, o CrewAI Flows facilita a integração dos resultados dos seus workflows de IA em aplicações maiores,
além de permitir o gerenciamento e o acesso ao estado durante toda a execução do Flow.
## Métricas de Uso do Flow
Após a execução de um Flow, você pode acessar a propriedade `usage_metrics` para visualizar o consumo agregado de tokens em **todas as chamadas de LLM** realizadas durante a execução — incluindo chamadas das Crews orquestradas pelo Flow, chamadas dentro de tools de Agents, e invocações diretas de `LLM.call(...)` feitas a partir de métodos do Flow. Esse é o equivalente, do lado do SDK, ao total exibido na interface do CrewAI Enterprise.
```python Code
from crewai import LLM
from crewai.flow.flow import Flow, listen, start
class UsageMetricsFlow(Flow):
@start()
def run_first_crew(self):
self.state.first_result = FirstCrew().crew().kickoff()
@listen(run_first_crew)
def call_llm_directly(self):
# Chamada direta de LLM — também contabilizada por flow.usage_metrics
llm = LLM(model="openai/gpt-4o-mini")
self.state.summary = llm.call("Resuma os principais pontos.")
@listen(call_llm_directly)
def run_second_crew(self):
self.state.second_result = SecondCrew().crew().kickoff()
flow = UsageMetricsFlow()
flow.kickoff()
print(flow.usage_metrics)
# UsageMetrics(total_tokens=8579, prompt_tokens=6210, completion_tokens=2369,
# cached_prompt_tokens=0, reasoning_tokens=0,
# cache_creation_tokens=0, successful_requests=5)
```
<Note>
`flow.usage_metrics` **não** é o mesmo que `flow.kickoff().token_usage`. Este
último retorna apenas o `CrewOutput.token_usage` do **último** método
`@listen` que retornou um `CrewOutput`, ou seja, reflete somente a Crew
final e ignora completamente as Crews anteriores e quaisquer chamadas
diretas de `LLM.call(...)`. Use `flow.usage_metrics` sempre que precisar do
rollup **completo** de tokens da execução do Flow.
</Note>
Cada campo do [`UsageMetrics`](https://github.com/crewAIInc/crewAI/blob/main/lib/crewai/src/crewai/types/usage_metrics.py) retornado representa a soma de todas as chamadas de LLM feitas em uma única invocação de `flow.kickoff()`. Os contadores são resetados a cada novo `kickoff()` (e em cada iteração de `kickoff_for_each`), de modo que execuções sucessivas não duplicam o total. A propriedade é segura para ser lida em qualquer momento após o `kickoff()`; lê-la durante a execução retorna o total parcial acumulado até aquele instante.
## Gerenciamento de Estado em Flows
Gerenciar o estado de forma eficaz é fundamental para construir fluxos de trabalho de IA confiáveis e de fácil manutenção. O CrewAI Flows oferece mecanismos robustos para o gerenciamento de estado tanto não estruturado quanto estruturado,

View File

@@ -24,15 +24,39 @@ Os dados de telemetria seguem as [convenções semânticas GenAI do OpenTelemetr
1. No CrewAI AMP, vá para **Settings** > **OpenTelemetry Collectors**.
2. Clique em **Add Collector**.
3. Selecione um tipo de integração — **OpenTelemetry Traces** ou **OpenTelemetry Logs**.
4. Configure a conexão:
- **Endpoint** — O endpoint OTLP do seu coletor (por exemplo, `https://otel-collector.example.com:4317`).
- **Service Name** — Um nome para identificar este serviço na sua plataforma de observabilidade.
- **Custom Headers** *(opcional)* — Adicione headers de autenticação ou roteamento como pares chave-valor.
- **Certificate** *(opcional)* — Forneça um certificado TLS se o seu coletor exigir um.
5. Clique em **Save**.
3. Selecione uma integração:
- **OpenTelemetry Traces** e **OpenTelemetry Logs** — exporte para qualquer coletor ou backend compatível com OTLP.
- **Datadog** — envie traces diretamente para a ingestão OTLP do Datadog, sem precisar de um coletor separado ou do Datadog Agent.
4. Configure a conexão. Os campos dependem da integração selecionada:
<Frame>![Configuração do Coletor OpenTelemetry](/images/crewai-otel-collector-config.png)</Frame>
<Tabs>
<Tab title="OpenTelemetry Traces / Logs">
**OpenTelemetry Traces** e **OpenTelemetry Logs** são integrações separadas que compartilham os mesmos campos — escolha a que corresponde ao sinal que você quer exportar.
- **Endpoint** — O endpoint OTLP do seu coletor (por exemplo, `https://otel-collector.example.com:4317`).
- **Service Name** — Um nome para identificar este serviço na sua plataforma de observabilidade.
- **Custom Headers** *(opcional)* — Adicione headers de autenticação ou roteamento como pares chave-valor.
- **Certificate** *(opcional)* — Forneça um certificado TLS se o seu coletor exigir um.
<Frame>![Configuração do coletor OpenTelemetry](/images/crewai-otel-collector-opentelemetry.png)</Frame>
</Tab>
<Tab title="Datadog">
- **Datadog Site Domain** — Apenas o host OTLP do seu site Datadog, sem protocolo ou caminho. O CrewAI monta o endpoint HTTPS OTLP completo para você. Use o host correspondente ao seu [site Datadog](https://docs.datadoghq.com/getting_started/site/):
- `otlp.datadoghq.com` (US1)
- `otlp.us3.datadoghq.com` (US3)
- `otlp.us5.datadoghq.com` (US5)
- `otlp.datadoghq.eu` (EU1)
- `otlp.ap1.datadoghq.com` (AP1)
- **API Key** — Sua chave de API do Datadog. Veja [como criar uma](https://docs.datadoghq.com/account_management/api-app-keys/#api-keys).
A integração com o Datadog exporta **traces**.
<Frame>![Configuração do coletor Datadog](/images/crewai-otel-collector-datadog.png)</Frame>
</Tab>
</Tabs>
5. *(opcional)* Clique em **Test Connection** para verificar se o CrewAI consegue acessar o endpoint com as credenciais fornecidas.
6. Clique em **Save**.
<Tip>
Você pode adicionar múltiplos coletores — por exemplo, um para traces e outro para logs, ou enviar para diferentes backends para diferentes propósitos.

View File

@@ -161,6 +161,18 @@ crew = Crew(
)
```
<Note>
`agent.i18n` é mantido apenas para compatibilidade retroativa e está obsoleto. Para customização de prompts em tempo de execução, passe `prompt_file` para `Crew`. Para acesso programático aos slices de prompt, use diretamente o utilitário de i18n:
</Note>
```python
from crewai.utilities.i18n import get_i18n
i18n = get_i18n("custom_prompts.json")
format_slice = i18n.slice("format")
tool_prompt = i18n.tools("ask_question")
```
#### Opção 3: Desativar Prompts de Sistema para Modelos o1
```python
agent = Agent(
@@ -208,6 +220,8 @@ Uma abordagem direta é criar um arquivo JSON para os prompts que deseja sobresc
O CrewAI então mescla suas customizações com os padrões, assim você não precisa redefinir todos os prompts. Veja como:
Para código que precisa ler slices de prompt diretamente, use `crewai.utilities.i18n.get_i18n()` com o mesmo arquivo de prompts em vez de ler `agent.i18n`.
### Exemplo: Customização Básica de Prompt
Crie um arquivo `custom_prompts.json` com os prompts que deseja modificar. Certifique-se de listar todos os prompts de nível superior que ele deve conter, não apenas suas alterações:

View File

@@ -11,96 +11,83 @@ Apps conversacionais tratam cada linha do usuário como uma **nova execução do
| Conceito | Implementação |
|---------|----------------|
| Id de sessão | `kickoff(session_id=...)` → `inputs["id"]` → `state.id` |
| Linha do usuário | `kickoff(user_message=...)` acrescenta em `state.messages` antes do grafo rodar |
| Fim do turno | `FlowFinished` só para **esta execução**; o chat segue no próximo `kickoff` |
| Trace da sessão | `ConversationalConfig(defer_trace_finalization=True)` + `finalize_session_traces()` |
| Id de sessão | `handle_turn(..., session_id=...)` → `kickoff(inputs={"id": ...})` → `state.id` |
| Linha do usuário | `handle_turn(message)` acrescenta em `state.messages` antes do grafo rodar |
| Fim do turno | `FlowFinished` só para **esta execução**; o chat segue no próximo `handle_turn` |
| Trace da sessão | `ConversationConfig(defer_trace_finalization=True)` + `finalize_session_traces()` |
## APIs de turno
Use **`flow.kickoff(user_message=..., session_id=...)`** ou **`flow.handle_turn(...)`** para cada mensagem de usuário em REST, WebSocket, testes e UIs customizadas. Use **`flow.chat()`** quando quiser um loop de chat local no terminal para um `Flow` conversacional.
Use **`flow.handle_turn(message, session_id=...)`** para cada mensagem de usuário em REST, WebSocket, testes e UIs customizadas. Use **`flow.chat()`** quando quiser um loop de chat local no terminal para um `Flow` conversacional.
`Flow.kickoff()` não aceita os argumentos nomeados `user_message=` ou `session_id=`. Para flows conversacionais, `handle_turn()` guarda a mensagem pendente e chama `kickoff(inputs={"id": session_id})` internamente.
| API | Uso |
|-----|-----|
| `kickoff(user_message=..., session_id=...)` | Cada mensagem do usuário |
| `handle_turn(message, session_id=...)` | Wrapper ergonômico de um turno para `Flow` conversacional |
| `chat()` | REPL local no terminal para `Flow` conversacional |
| `kickoff_async(...)` | Mesmos parâmetros; entrada async nativa |
| `kickoff(inputs={...})` | Execução avançada do flow sem tratamento de turno conversacional |
| `ask()` | Prompt bloqueante **dentro** de um passo (wizard, esclarecimento) |
| `@human_feedback` | Aprovar/rejeitar **saída de um passo** — não a próxima linha do chat |
| `ChatSession.handle_turn(...)` | Camada de transporte sobre `kickoff` (SSE / WebSocket) |
| `ChatSession.handle_turn(...)` | Camada de transporte sobre `handle_turn` (SSE / WebSocket) |
## Início rápido
```python
from uuid import uuid4
from crewai.flow import (
ChatState,
ConversationalConfig,
Flow,
listen,
or_,
persist,
router,
start,
from crewai import Flow
from crewai.flow import listen
from crewai.experimental.conversational import (
ConversationConfig,
ConversationState,
)
from crewai.flow.persistence import SQLiteFlowPersistence
class SupportFlow(Flow[ChatState]):
conversational_config = ConversationalConfig(
default_intents=["order", "help", "goodbye"],
intent_llm="gpt-4o-mini",
defer_trace_finalization=True,
)
@ConversationConfig(defer_trace_finalization=True)
class SupportFlow(Flow[ConversationState]):
conversational = True
@start()
def bootstrap(self):
if not self.state.session_ready:
self.state.session_ready = True
return "ready"
@router(bootstrap)
def route(self):
# last_intent definido em prepare_conversational_turn quando default_intents está setado
return self.state.last_intent or "help"
def route_turn(self, context):
message = (self.state.current_user_message or "").lower()
if "pedido" in message or "order" in message:
return "order"
if "tchau" in message or "goodbye" in message:
return "goodbye"
return "help"
@listen("order")
def handle_order(self):
reply = "Seu pedido está a caminho."
self.append_message("assistant", reply)
self.append_assistant_message(reply)
return reply
@listen("help")
def handle_help(self):
reply = "Como posso ajudar?"
self.append_message("assistant", reply)
self.append_assistant_message(reply)
return reply
@listen("goodbye")
def handle_goodbye(self):
reply = "Até logo!"
self.append_message("assistant", reply)
self.append_assistant_message(reply)
return reply
@persist(SQLiteFlowPersistence("support.db"))
@listen(or_(handle_order, handle_help, handle_goodbye))
def finalize(self):
return self.state.model_dump()
session_id = str(uuid4())
flow = SupportFlow()
flow.kickoff(user_message="Onde está meu pedido?", session_id=session_id)
flow.kickoff(user_message="E as devoluções?", session_id=session_id)
flow.finalize_session_traces() # um link de trace para o chat inteiro
try:
flow.handle_turn("Onde está meu pedido?", session_id=session_id)
flow.handle_turn("E as devoluções?", session_id=session_id)
finally:
flow.finalize_session_traces() # um link de trace para o chat inteiro
```
## Ciclo de vida do turno
Cada `kickoff` com `user_message` executa este pipeline:
Cada `handle_turn` executa este pipeline:
1. **`_configure_conversational_kickoff`** — mescla `session_id` / `user_message` em `inputs`, aplica `ConversationalConfig`, habilita tracing adiado quando configurado.
2. **Restauração de estado** — se `inputs["id"]` existe e `@persist` está configurado, carrega o snapshot mais recente.
@@ -109,7 +96,7 @@ Cada `kickoff` com `user_message` executa este pipeline:
5. **Execução do grafo** — `@start` → `@router` → handlers `@listen`.
6. **Fim da execução** — `flow_finished` por turno e finalização de trace são **ignorados** com adiamento; `Agent.kickoff()` / crews aninhados também não fecham o batch pai.
Os handlers devem chamar **`append_message("assistant", reply)`** para que o próximo turno inclua a resposta do assistente. A linha do usuário já é salva no kickoff — não acrescente de novo nos handlers.
Os handlers devem chamar **`append_assistant_message(reply)`** para que o próximo turno inclua a resposta do assistente. A linha do usuário já é salva por `handle_turn` — não acrescente de novo nos handlers.
## `ConversationalConfig` (padrões em nível de classe)
@@ -385,7 +372,7 @@ Você pode sobrescrever qualquer uma definindo um handler com o mesmo nome na su
4. O router grava sua decisão em `state.last_intent` (visível para o contexto de routing do próximo turno).
5. Se seu handler retornou uma string e ainda não chamou `append_assistant_message`, `handle_turn` anexa para você.
Você também pode chamar `flow.kickoff(user_message=..., session_id=...)` diretamente — a mesma lógica de reset/run é acionada. `handle_turn` é o wrapper ergonômico.
Chame `handle_turn()` para mensagens de chat. Chamar `kickoff(inputs={"id": ...})` diretamente executa o grafo sem aplicar o wrapper de turno conversacional.
### `chat()` para REPLs locais

View File

@@ -8,7 +8,7 @@ authors = [
]
requires-python = ">=3.10, <3.14"
dependencies = [
"crewai-core==1.14.7a1",
"crewai-core==1.14.7",
"click>=8.1.7,<9",
"pydantic>=2.11.9,<2.13",
"pydantic-settings~=2.10.1",

View File

@@ -1 +1 @@
__version__ = "1.14.7a1"
__version__ = "1.14.7"

View File

@@ -26,6 +26,7 @@ from crewai_cli.remote_template.main import TemplateCommand
from crewai_cli.replay_from_task import replay_task_command
from crewai_cli.reset_memories_command import reset_memories_command
from crewai_cli.run_crew import run_crew
from crewai_cli.run_flow_definition import run_flow_definition
from crewai_cli.settings.main import SettingsCommand
from crewai_cli.task_outputs import load_task_outputs
from crewai_cli.tools.main import ToolCommand
@@ -398,8 +399,36 @@ def install(context: click.Context) -> None:
"CREWAI_TRAINED_AGENTS_FILE."
),
)
def run(trained_agents_file: str | None) -> None:
"""Run the Crew."""
@click.option(
"--definition",
type=str,
default=None,
help=(
"Experimental: path to a Flow Definition YAML/JSON file, "
"or an inline YAML/JSON string."
),
)
@click.option(
"--inputs",
type=str,
default=None,
help='Experimental: JSON object passed to flow.kickoff(), e.g. \'{"topic":"AI"}\'.',
)
def run(
trained_agents_file: str | None, definition: str | None, inputs: str | None
) -> None:
"""Run the Crew or Flow."""
if inputs is not None and definition is None:
raise click.UsageError("--inputs requires --definition")
if definition is not None:
click.secho(
"Warning: `crewai run --definition` is experimental and may change without notice.",
fg="yellow",
)
run_flow_definition(definition=definition, inputs=inputs)
return
run_crew(trained_agents_file=trained_agents_file)

View File

@@ -0,0 +1,113 @@
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
import click
def run_flow_definition(definition: str, inputs: str | None = None) -> None:
"""Run a flow from a Flow Definition YAML/JSON string or file path."""
try:
from crewai.flow.flow import Flow
from crewai.flow.flow_definition import FlowDefinition
except ImportError as exc:
click.echo(
"Running flows from definitions requires the full crewai package.",
err=True,
)
raise SystemExit(1) from exc
parsed_inputs = _parse_inputs(inputs)
definition_source = _read_definition_source(definition)
try:
flow_definition = _parse_flow_definition(FlowDefinition, definition_source)
flow = Flow.from_definition(flow_definition)
result = flow.kickoff(inputs=parsed_inputs)
except Exception as exc:
click.echo(
f"An error occurred while running the flow definition: {exc}", err=True
)
raise SystemExit(1) from exc
click.echo(_format_result(result))
def _parse_inputs(inputs: str | None) -> dict[str, Any] | None:
if inputs is None:
return None
try:
parsed = json.loads(inputs)
except json.JSONDecodeError as exc:
click.echo(f"Invalid --inputs JSON: {exc}", err=True)
raise SystemExit(1) from exc
if not isinstance(parsed, dict):
click.echo("Invalid --inputs JSON: expected an object.", err=True)
raise SystemExit(1)
return parsed
def _read_definition_source(definition: str) -> str:
path = Path(definition).expanduser()
try:
is_file = path.is_file()
except OSError as exc:
if _looks_like_inline_definition(definition):
return definition
click.echo(f"Invalid --definition path: {definition} ({exc})", err=True)
raise SystemExit(1) from exc
if is_file:
try:
return path.read_text(encoding="utf-8")
except (OSError, UnicodeError) as exc:
click.echo(
f"Unable to read --definition path {path}: {exc}",
err=True,
)
raise SystemExit(1) from exc
try:
if path.exists():
click.echo(
f"Invalid --definition path: {definition} is not a file.", err=True
)
raise SystemExit(1)
except OSError as exc:
click.echo(f"Invalid --definition path: {definition} ({exc})", err=True)
raise SystemExit(1) from exc
return definition
def _looks_like_inline_definition(definition: str) -> bool:
stripped = definition.lstrip()
return "\n" in definition or stripped.startswith(("{", "---")) or ":" in stripped
def _parse_flow_definition(flow_definition_cls: type[Any], source: str) -> Any:
if _looks_like_json(source):
return flow_definition_cls.from_json(source)
return flow_definition_cls.from_yaml(source)
def _looks_like_json(source: str) -> bool:
stripped = source.lstrip()
return stripped.startswith("{")
def _format_result(result: Any) -> str:
raw_result = getattr(result, "raw", result)
if isinstance(raw_result, str):
return raw_result
try:
return json.dumps(raw_result, default=str)
except TypeError:
return str(raw_result)

View File

@@ -5,7 +5,7 @@ description = "{{name}} using crewAI"
authors = [{ name = "Your Name", email = "you@example.com" }]
requires-python = ">=3.10,<3.14"
dependencies = [
"crewai[tools]==1.14.7a1"
"crewai[tools]==1.14.7"
]
[project.scripts]

View File

@@ -5,7 +5,7 @@ description = "{{name}} using crewAI"
authors = [{ name = "Your Name", email = "you@example.com" }]
requires-python = ">=3.10,<3.14"
dependencies = [
"crewai[tools]==1.14.7a1"
"crewai[tools]==1.14.7"
]
[project.scripts]

View File

@@ -5,7 +5,7 @@ description = "Power up your crews with {{folder_name}}"
readme = "README.md"
requires-python = ">=3.10,<3.14"
dependencies = [
"crewai[tools]==1.14.7a1"
"crewai[tools]==1.14.7"
]
[tool.crewai]

View File

@@ -13,6 +13,7 @@ from crewai_cli.cli import (
flow_add_crew,
login,
reset_memories,
run,
test,
train,
version,
@@ -119,6 +120,43 @@ def test_test_invalid_string_iterations(evaluate_crew, runner):
)
@mock.patch("crewai_cli.cli.run_crew")
def test_run_uses_project_runner_by_default(run_crew, runner):
result = runner.invoke(run)
assert result.exit_code == 0
run_crew.assert_called_once_with(trained_agents_file=None)
assert "experimental" not in result.output.lower()
@mock.patch("crewai_cli.cli.run_flow_definition")
def test_run_with_definition_uses_definition_runner(run_flow_definition, runner):
result = runner.invoke(
run,
["--definition", "flow.yaml", "--inputs", '{"topic":"AI"}'],
)
assert result.exit_code == 0
assert (
"Warning: `crewai run --definition` is experimental and may change without notice."
in result.output
)
run_flow_definition.assert_called_once_with(
definition="flow.yaml", inputs='{"topic":"AI"}'
)
@mock.patch("crewai_cli.cli.run_crew")
@mock.patch("crewai_cli.cli.run_flow_definition")
def test_run_rejects_inputs_without_definition(run_flow_definition, run_crew, runner):
result = runner.invoke(run, ["--inputs", '{"topic":"AI"}'])
assert result.exit_code == 2
assert "Error: --inputs requires --definition" in result.output
run_flow_definition.assert_not_called()
run_crew.assert_not_called()
@mock.patch("crewai_cli.cli.AuthenticationCommand")
def test_login(command, runner):
mock_auth = command.return_value

View File

@@ -0,0 +1,156 @@
from __future__ import annotations
import json
import sys
import types
import pytest
import yaml
from crewai_cli.run_flow_definition import run_flow_definition
class _FakeFlow:
def __init__(self, definition):
self.definition = definition
def kickoff(self, inputs=None):
return {
"flow": self.definition["name"],
"inputs": inputs or {},
}
class _FakeFlowFactory:
@classmethod
def from_definition(cls, definition):
return _FakeFlow(definition)
class _FakeFlowDefinition:
@classmethod
def from_yaml(cls, source):
return yaml.safe_load(source)
@classmethod
def from_json(cls, source):
return json.loads(source)
@pytest.fixture
def fake_flow_runtime(monkeypatch):
crewai_module = types.ModuleType("crewai")
flow_package = types.ModuleType("crewai.flow")
flow_module = types.ModuleType("crewai.flow.flow")
flow_definition_module = types.ModuleType("crewai.flow.flow_definition")
flow_module.Flow = _FakeFlowFactory
flow_definition_module.FlowDefinition = _FakeFlowDefinition
monkeypatch.setitem(sys.modules, "crewai", crewai_module)
monkeypatch.setitem(sys.modules, "crewai.flow", flow_package)
monkeypatch.setitem(sys.modules, "crewai.flow.flow", flow_module)
monkeypatch.setitem(
sys.modules, "crewai.flow.flow_definition", flow_definition_module
)
def _captured_json(capsys):
return json.loads(capsys.readouterr().out)
def test_run_flow_definition_reads_definition_file(
tmp_path, capsys, fake_flow_runtime
):
definition_path = tmp_path / "flow.yaml"
definition_path.write_text("schema: crewai.flow/v1\nname: TestFlow\n")
run_flow_definition(str(definition_path), '{"topic":"AI"}')
assert _captured_json(capsys) == {
"flow": "TestFlow",
"inputs": {"topic": "AI"},
}
@pytest.mark.parametrize(
("definition_source", "expected_flow_name"),
[
pytest.param(
"schema: crewai.flow/v1\nname: InlineFlow\n",
"InlineFlow",
id="inline-yaml",
),
pytest.param(
'{"schema":"crewai.flow/v1","name":"InlineJsonFlow"}',
"InlineJsonFlow",
id="inline-json",
),
pytest.param(
'{"schema":"crewai.flow/v1","name":"' + ("JsonFlow" * 500) + '"}',
"JsonFlow" * 500,
id="large-inline-json",
),
],
)
def test_run_flow_definition_accepts_inline_definitions(
definition_source, expected_flow_name, capsys, fake_flow_runtime
):
run_flow_definition(definition_source)
assert _captured_json(capsys) == {"flow": expected_flow_name, "inputs": {}}
@pytest.mark.parametrize(
("filename", "definition_source", "expected_flow_name"),
[
pytest.param(
"flow.yaml",
"schema: crewai.flow/v1\nname: YamlFileFlow\n",
"YamlFileFlow",
id="yaml-file",
),
pytest.param(
"flow.json",
'{"schema":"crewai.flow/v1","name":"JsonFlow"}',
"JsonFlow",
id="json-file",
),
],
)
def test_run_flow_definition_accepts_definition_files(
filename, definition_source, expected_flow_name, tmp_path, capsys, fake_flow_runtime
):
definition_path = tmp_path / filename
definition_path.write_text(definition_source)
run_flow_definition(str(definition_path))
assert _captured_json(capsys) == {"flow": expected_flow_name, "inputs": {}}
def test_run_flow_definition_rejects_non_object_inputs(fake_flow_runtime, capsys):
with pytest.raises(SystemExit):
run_flow_definition("name: TestFlow", '["not", "an", "object"]')
assert "Invalid --inputs JSON: expected an object." in capsys.readouterr().err
def test_run_flow_definition_reports_unreadable_file(
monkeypatch, tmp_path, capsys, fake_flow_runtime
):
definition_path = tmp_path / "flow.yaml"
definition_path.write_text("schema: crewai.flow/v1\nname: TestFlow\n")
def raise_permission_error(self, *args, **kwargs):
raise PermissionError("no access")
monkeypatch.setattr("pathlib.Path.read_text", raise_permission_error)
with pytest.raises(SystemExit):
run_flow_definition(str(definition_path))
err = capsys.readouterr().err
assert "Unable to read --definition path" in err
assert str(definition_path) in err
assert "no access" in err

View File

@@ -1 +1 @@
__version__ = "1.14.7a1"
__version__ = "1.14.7"

View File

@@ -17,7 +17,7 @@ import contextlib
import logging
import os
import threading
from typing import Any, Final
from typing import Any, ClassVar, Final
from opentelemetry import trace
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
@@ -27,7 +27,7 @@ from opentelemetry.sdk.trace.export import (
BatchSpanProcessor,
SpanExportResult,
)
from opentelemetry.trace import Span, Status, StatusCode
from opentelemetry.trace import ProxyTracerProvider, Span, Status, StatusCode
from typing_extensions import Self
@@ -72,8 +72,8 @@ class Telemetry:
and event-bus signal handlers (see ``crewai.telemetry.telemetry``).
"""
_instance = None
_lock = threading.Lock()
_instance: ClassVar[Self | None] = None
_lock: ClassVar[threading.Lock] = threading.Lock()
def __new__(cls) -> Self:
if cls._instance is None:
@@ -149,6 +149,10 @@ class Telemetry:
if self.ready and not self.trace_set:
try:
with suppress_warnings():
existing_provider = trace.get_tracer_provider()
if not isinstance(existing_provider, ProxyTracerProvider):
self.trace_set = True
return
trace.set_tracer_provider(self.provider)
self.trace_set = True
except Exception as e:

View File

@@ -13,6 +13,7 @@ from crewai_core import (
user_data,
version,
)
from opentelemetry.sdk.trace import TracerProvider
import pytest
@@ -94,3 +95,36 @@ def test_user_data_decline_blocks(
def test_unused_var_warning_silenced() -> None:
# Touch os to keep the import (used by env-var fixtures above)
assert os.environ is not None
def test_core_telemetry_skips_duplicate_tracer_provider(
monkeypatch: pytest.MonkeyPatch,
) -> None:
from crewai_core.telemetry import Telemetry
Telemetry._instance = None
monkeypatch.delenv("OTEL_SDK_DISABLED", raising=False)
monkeypatch.delenv("CREWAI_DISABLE_TELEMETRY", raising=False)
monkeypatch.delenv("CREWAI_DISABLE_TRACKING", raising=False)
monkeypatch.setattr(
"crewai_core.telemetry.trace.get_tracer_provider",
lambda: TracerProvider(),
)
called = False
def fail_if_called(provider: object) -> None:
nonlocal called
called = True
monkeypatch.setattr(
"crewai_core.telemetry.trace.set_tracer_provider",
fail_if_called,
)
telemetry = Telemetry()
telemetry.set_tracer()
assert called is False
assert telemetry.trace_set is True

View File

@@ -152,4 +152,4 @@ __all__ = [
"wrap_file_source",
]
__version__ = "1.14.7a1"
__version__ = "1.14.7"

View File

@@ -10,7 +10,7 @@ requires-python = ">=3.10, <3.14"
dependencies = [
"pytube~=15.0.0",
"requests>=2.33.0,<3",
"crewai==1.14.7a1",
"crewai==1.14.7",
"tiktoken>=0.8.0,<0.13",
"beautifulsoup4~=4.13.4",
"python-docx~=1.2.0",
@@ -63,7 +63,7 @@ spider-client = [
"spider-client>=0.1.25",
]
scrapegraph-py = [
"scrapegraph-py>=1.9.0",
"scrapegraph-py>=1.9.0,<2",
]
linkup-sdk = [
"linkup-sdk>=0.2.2",

View File

@@ -330,4 +330,4 @@ __all__ = [
"ZapierActionTools",
]
__version__ = "1.14.7a1"
__version__ = "1.14.7"

View File

@@ -22,6 +22,31 @@ logger = logging.getLogger(__name__)
_UNSAFE_PATHS_ENV = "CREWAI_TOOLS_ALLOW_UNSAFE_PATHS"
def format_path_for_display(path: str, base_dir: str | None = None) -> str:
"""Return a path label that does not expose absolute directory prefixes."""
if base_dir is None:
base_dir = os.getcwd()
try:
resolved_base = os.path.realpath(base_dir)
resolved_path = os.path.realpath(
os.path.join(resolved_base, path) if not os.path.isabs(path) else path
)
if os.path.commonpath([resolved_base, resolved_path]) == resolved_base:
return os.path.relpath(resolved_path, resolved_base)
except (OSError, ValueError) as exc:
logger.debug("Falling back to basename for display path formatting: %s", exc)
return os.path.basename(os.path.realpath(path)) or "[redacted path]"
def format_error_for_display(error: Exception) -> str:
"""Return exception details without OS-added absolute path context."""
if isinstance(error, OSError):
return error.strerror or error.__class__.__name__
return str(error)
def _is_escape_hatch_enabled() -> bool:
"""Check if the unsafe paths escape hatch is enabled."""
return os.environ.get(_UNSAFE_PATHS_ENV, "").lower() in ("true", "1", "yes")
@@ -66,8 +91,8 @@ def validate_file_path(path: str, base_dir: str | None = None) -> str:
prefix = resolved_base if resolved_base.endswith(os.sep) else resolved_base + os.sep
if not resolved_path.startswith(prefix) and resolved_path != resolved_base:
raise ValueError(
f"Path '{path}' resolves to '{resolved_path}' which is outside "
f"the allowed directory '{resolved_base}'. "
f"Path '{format_path_for_display(resolved_path, resolved_base)}' is "
f"outside the allowed directory. "
f"Set {_UNSAFE_PATHS_ENV}=true to bypass this check."
)

View File

@@ -3,7 +3,11 @@ from typing import Any
from crewai.tools import BaseTool
from pydantic import BaseModel, Field
from crewai_tools.security.safe_path import validate_file_path
from crewai_tools.security.safe_path import (
format_error_for_display,
format_path_for_display,
validate_file_path,
)
class FileReadToolSchema(BaseModel):
@@ -58,8 +62,9 @@ class FileReadTool(BaseTool):
**kwargs: Additional keyword arguments passed to BaseTool.
"""
if file_path is not None:
display_path = format_path_for_display(file_path)
kwargs["description"] = (
f"A tool that reads file content. The default file is {file_path}, but you can provide a different 'file_path' parameter to read another file. You can also specify 'start_line' and 'line_count' to read specific parts of the file."
f"A tool that reads file content. The default file is {display_path}, but you can provide a different 'file_path' parameter to read another file. You can also specify 'start_line' and 'line_count' to read specific parts of the file."
)
super().__init__(**kwargs)
@@ -78,7 +83,12 @@ class FileReadTool(BaseTool):
if file_path is None:
return "Error: No file path provided. Please provide a file path either in the constructor or as an argument."
file_path = validate_file_path(file_path)
try:
file_path = validate_file_path(file_path)
except ValueError as e:
return f"Error: Invalid file path: {e!s}"
display_path = format_path_for_display(file_path)
try:
with open(file_path, "r") as file:
if start_line == 1 and line_count is None:
@@ -98,8 +108,11 @@ class FileReadTool(BaseTool):
return "".join(selected_lines)
except FileNotFoundError:
return f"Error: File not found at path: {file_path}"
return f"Error: File not found at path: {display_path}"
except PermissionError:
return f"Error: Permission denied when trying to read file: {file_path}"
return f"Error: Permission denied when trying to read file: {display_path}"
except Exception as e:
return f"Error: Failed to read file {file_path}. {e!s}"
return (
f"Error: Failed to read file {display_path}. "
f"{format_error_for_display(e)}"
)

View File

@@ -5,6 +5,11 @@ from typing import Any
from crewai.tools import BaseTool
from pydantic import BaseModel
from crewai_tools.security.safe_path import (
format_error_for_display,
format_path_for_display,
)
def strtobool(val: str | bool) -> bool:
if isinstance(val, bool):
@@ -44,6 +49,9 @@ class FileWriterTool(BaseTool):
# itself, since that is not a valid file target.
real_directory = Path(directory).resolve()
real_filepath = Path(filepath).resolve()
display_filepath = format_path_for_display(
str(real_filepath), str(real_directory)
)
if (
not real_filepath.is_relative_to(real_directory)
or real_filepath == real_directory
@@ -56,15 +64,18 @@ class FileWriterTool(BaseTool):
kwargs["overwrite"] = strtobool(kwargs["overwrite"])
if os.path.exists(real_filepath) and not kwargs["overwrite"]:
return f"File {real_filepath} already exists and overwrite option was not passed."
return f"File {display_filepath} already exists and overwrite option was not passed."
mode = "w" if kwargs["overwrite"] else "x"
with open(real_filepath, mode) as file:
file.write(kwargs["content"])
return f"Content successfully written to {real_filepath}"
return f"Content successfully written to {display_filepath}"
except FileExistsError:
return f"File {real_filepath} already exists and overwrite option was not passed."
return f"File {display_filepath} already exists and overwrite option was not passed."
except KeyError as e:
return f"An error occurred while accessing key: {e!s}"
except Exception as e:
return f"An error occurred while writing to the file: {e!s}"
return (
"An error occurred while writing to the file: "
f"{format_error_for_display(e)}"
)

View File

@@ -1,4 +1,3 @@
import os
from unittest.mock import mock_open, patch
from crewai_tools import FileReadTool
@@ -6,21 +5,16 @@ from crewai_tools import FileReadTool
def test_file_read_tool_constructor():
"""Test FileReadTool initialization with file_path."""
test_file = "/tmp/test_file.txt"
test_content = "Hello, World!"
with open(test_file, "w") as f:
f.write(test_content)
test_file = "test_file.txt"
tool = FileReadTool(file_path=test_file)
assert tool.file_path == test_file
assert "test_file.txt" in tool.description
os.remove(test_file)
def test_file_read_tool_run():
"""Test FileReadTool _run method with file_path at runtime."""
test_file = "/tmp/test_file.txt"
test_file = "test_file.txt"
test_content = "Hello, World!"
# Use mock_open to mock file operations
@@ -36,18 +30,18 @@ def test_file_read_tool_error_handling():
result = tool._run()
assert "Error: No file path provided" in result
result = tool._run(file_path="/nonexistent/file.txt")
result = tool._run(file_path="nonexistent/file.txt")
assert "Error: File not found at path:" in result
with patch("builtins.open", side_effect=PermissionError()):
result = tool._run(file_path="/tmp/no_permission.txt")
result = tool._run(file_path="no_permission.txt")
assert "Error: Permission denied" in result
def test_file_read_tool_constructor_and_run():
"""Test FileReadTool using both constructor and runtime file paths."""
test_file1 = "/tmp/test1.txt"
test_file2 = "/tmp/test2.txt"
test_file1 = "test1.txt"
test_file2 = "test2.txt"
content1 = "File 1 content"
content2 = "File 2 content"
@@ -64,7 +58,7 @@ def test_file_read_tool_constructor_and_run():
def test_file_read_tool_chunk_reading():
"""Test FileReadTool reading specific chunks of a file."""
test_file = "/tmp/multiline_test.txt"
test_file = "multiline_test.txt"
lines = [
"Line 1\n",
"Line 2\n",
@@ -104,7 +98,7 @@ def test_file_read_tool_chunk_reading():
def test_file_read_tool_chunk_error_handling():
"""Test error handling for chunk reading."""
test_file = "/tmp/short_test.txt"
test_file = "short_test.txt"
lines = ["Line 1\n", "Line 2\n", "Line 3\n"]
file_content = "".join(lines)
@@ -122,7 +116,7 @@ def test_file_read_tool_chunk_error_handling():
def test_file_read_tool_zero_or_negative_start_line():
"""Test that start_line values of 0 or negative read from the start of the file."""
test_file = "/tmp/negative_test.txt"
test_file = "negative_test.txt"
lines = ["Line 1\n", "Line 2\n", "Line 3\n", "Line 4\n", "Line 5\n"]
file_content = "".join(lines)
@@ -150,3 +144,45 @@ def test_file_read_tool_zero_or_negative_start_line():
result = tool._run(file_path=test_file, start_line=-10, line_count=2)
expected = "".join(lines[0:2]) # Should read first 2 lines
assert result == expected
def test_file_read_tool_error_messages_do_not_disclose_absolute_paths(
tmp_path, monkeypatch
):
"""FileReadTool should redact absolute prefixes from user-visible errors."""
monkeypatch.chdir(tmp_path)
tool = FileReadTool()
target = tmp_path / "secret.txt"
result = tool._run(file_path=str(target))
assert "secret.txt" in result
assert str(tmp_path) not in result
target.touch()
with patch("builtins.open", side_effect=PermissionError()):
result = tool._run(file_path=str(target))
assert "secret.txt" in result
assert str(tmp_path) not in result
with patch(
"builtins.open",
side_effect=OSError(5, "Input/output error", str(target)),
):
result = tool._run(file_path=str(target))
assert "secret.txt" in result
assert str(tmp_path) not in result
def test_file_read_tool_invalid_path_error_does_not_disclose_workspace(
tmp_path, monkeypatch
):
"""Validation errors should not echo the resolved workspace path."""
monkeypatch.chdir(tmp_path)
outside = tmp_path.parent / "outside.txt"
result = FileReadTool()._run(file_path=str(outside))
assert "Invalid file path" in result
assert "outside.txt" in result
assert str(tmp_path) not in result
assert str(tmp_path.parent) not in result

View File

@@ -47,6 +47,8 @@ def test_basic_file_write(tool, temp_env):
assert os.path.exists(path)
assert read_file(path) == temp_env["test_content"]
assert "successfully written" in result
assert temp_env["test_file"] in result
assert temp_env["temp_dir"] not in result
def test_directory_creation(tool, temp_env):
@@ -62,6 +64,8 @@ def test_directory_creation(tool, temp_env):
assert os.path.exists(new_dir)
assert os.path.exists(path)
assert "successfully written" in result
assert temp_env["test_file"] in result
assert new_dir not in result
@pytest.mark.parametrize(
@@ -134,6 +138,8 @@ def test_file_exists_error_handling(tool, temp_env, overwrite):
)
assert "already exists and overwrite option was not passed" in result
assert temp_env["test_file"] in result
assert temp_env["temp_dir"] not in result
assert read_file(path) == "Pre-existing content"

View File

@@ -7,6 +7,7 @@ import os
import pytest
from crewai_tools.security.safe_path import (
format_path_for_display,
validate_directory_path,
validate_file_path,
validate_url,
@@ -66,6 +67,37 @@ class TestValidateFilePath:
result = validate_file_path("/etc/passwd", str(tmp_path))
assert result == os.path.realpath("/etc/passwd")
def test_rejection_message_redacts_absolute_prefixes(self, tmp_path):
outside = tmp_path.parent / "outside.txt"
with pytest.raises(ValueError) as exc_info:
validate_file_path(str(outside), str(tmp_path))
message = str(exc_info.value)
assert "outside.txt" in message
assert str(tmp_path) not in message
assert str(tmp_path.parent) not in message
class TestFormatPathForDisplay:
"""Tests for user-visible path labels."""
def test_returns_relative_path_inside_base(self, tmp_path):
nested_file = tmp_path / "nested" / "file.txt"
nested_file.parent.mkdir()
nested_file.touch()
result = format_path_for_display(str(nested_file), str(tmp_path))
assert result == os.path.join("nested", "file.txt")
def test_redacts_absolute_prefix_outside_base(self, tmp_path):
outside_file = tmp_path.parent / "outside.txt"
result = format_path_for_display(str(outside_file), str(tmp_path))
assert result == "outside.txt"
class TestValidateDirectoryPath:
"""Tests for validate_directory_path."""

View File

@@ -8,8 +8,8 @@ authors = [
]
requires-python = ">=3.10, <3.14"
dependencies = [
"crewai-core==1.14.7a1",
"crewai-cli==1.14.7a1",
"crewai-core==1.14.7",
"crewai-cli==1.14.7",
# Core Dependencies
"pydantic>=2.11.9,<2.13",
"openai>=2.30.0,<3",
@@ -33,11 +33,12 @@ dependencies = [
"appdirs~=1.4.4",
"jsonref~=1.1.0",
"json-repair~=0.25.2",
"cel-python>=0.5.0,<0.6",
"tomli-w~=1.1.0",
"tomli~=2.0.2",
"json5~=0.10.0",
"portalocker~=2.7.0",
"pydantic-settings~=2.10.1",
"pydantic-settings>=2.10.1,<3",
"httpx~=0.28.1",
"mcp~=1.26.0",
"aiosqlite~=0.21.0",
@@ -54,7 +55,7 @@ Repository = "https://github.com/crewAIInc/crewAI"
[project.optional-dependencies]
tools = [
"crewai-tools==1.14.7a1",
"crewai-tools==1.14.7",
]
embeddings = [
"tiktoken>=0.8.0,<0.13"
@@ -67,7 +68,11 @@ openpyxl = [
]
mem0 = ["mem0ai>=2.0.0,<3"]
docling = [
"docling~=2.84.0",
"docling~=2.97.0",
# docling 2.97 split into docling-slim; the chunker package (HierarchicalChunker)
# now eagerly imports code-chunking submodules that need tree-sitter/semchunk,
# which only the docling-core[chunking] extra provides.
"docling-core[chunking]>=2.74.1",
]
qdrant = [
"qdrant-client[fastembed]~=1.14.3",

View File

@@ -48,7 +48,7 @@ def _suppress_pydantic_deprecation_warnings() -> None:
_suppress_pydantic_deprecation_warnings()
__version__ = "1.14.7a1"
__version__ = "1.14.7"
_LAZY_IMPORTS: dict[str, tuple[str, str]] = {
"Memory": ("crewai.memory.unified_memory", "Memory"),

View File

@@ -46,6 +46,7 @@ from crewai.state.checkpoint_config import CheckpointConfig, _coerce_checkpoint
from crewai.tools.base_tool import BaseTool, Tool
from crewai.types.callback import SerializableCallable
from crewai.utilities.config import process_config
from crewai.utilities.i18n import I18N, get_i18n
from crewai.utilities.logger import Logger
from crewai.utilities.rpm_controller import RPMController
from crewai.utilities.string_utils import interpolate_only
@@ -81,6 +82,7 @@ _LLM_TYPE_REGISTRY: dict[str, str] = {
def _validate_llm_ref(value: Any) -> Any:
if isinstance(value, dict):
import importlib
import inspect
llm_type = value.get("llm_type")
if not llm_type or llm_type not in _LLM_TYPE_REGISTRY:
@@ -91,6 +93,12 @@ def _validate_llm_ref(value: Any) -> Any:
dotted = _LLM_TYPE_REGISTRY[llm_type]
mod_path, cls_name = dotted.rsplit(".", 1)
cls = getattr(importlib.import_module(mod_path), cls_name)
if inspect.isabstract(cls):
from crewai.llm import LLM
return LLM(
**{k: v for k, v in value.items() if v is not None and k != "llm_type"}
)
return cls(**value)
return value
@@ -186,6 +194,7 @@ class BaseAgent(BaseModel, ABC, metaclass=AgentMeta):
tools (list[Any] | None): Tools at the agent's disposal.
max_iter (int): Maximum iterations for an agent to execute a task.
agent_executor: An instance of the CrewAgentExecutor class.
i18n (I18N): Internationalization settings.
llm (Any): Language model that will run the agent.
crew (Any): Crew to which the agent belongs.
@@ -265,6 +274,14 @@ class BaseAgent(BaseModel, ABC, metaclass=AgentMeta):
_serialize_executor_ref, return_type=dict | None, when_used="json"
),
] = Field(default=None, description="An instance of the CrewAgentExecutor class.")
i18n: I18N = Field(
default_factory=get_i18n,
description="Internationalization settings.",
deprecated=(
"Agent.i18n is deprecated and will be removed in a future release. "
"Use crewai.utilities.i18n.get_i18n() or Crew(prompt_file=...) instead."
),
)
llm: Annotated[
str | BaseLLM | None,

View File

@@ -863,13 +863,6 @@ class CrewAgentExecutor(BaseAgentExecutor):
ToolUsageFinishedEvent,
ToolUsageStartedEvent,
)
from crewai.tools.file_artifact import (
artifact_scope_id,
resolve_artifact_handles,
store_if_artifact,
)
scope_id = artifact_scope_id(self.crew, self.task, self.agent)
args_dict, parse_error = parse_tool_call_args(
func_args, func_name, call_id, original_tool
@@ -903,7 +896,6 @@ class CrewAgentExecutor(BaseAgentExecutor):
tool=func_name, input=input_str
)
if cached_result is not None:
cached_result = store_if_artifact(cached_result, scope_id)
result = (
str(cached_result)
if not isinstance(cached_result, str)
@@ -968,8 +960,7 @@ class CrewAgentExecutor(BaseAgentExecutor):
result = f"Tool '{func_name}' has reached its usage limit of {original_tool.max_usage_count} times and cannot be used anymore."
elif not from_cache and func_name in available_functions:
try:
invoke_args = resolve_artifact_handles(args_dict) if args_dict else {}
raw_result = available_functions[func_name](**invoke_args)
raw_result = available_functions[func_name](**(args_dict or {}))
if self.tools_handler and self.tools_handler.cache:
should_cache = True
@@ -986,7 +977,6 @@ class CrewAgentExecutor(BaseAgentExecutor):
tool=func_name, input=input_str, output=raw_result
)
raw_result = store_if_artifact(raw_result, scope_id)
result = (
str(raw_result) if not isinstance(raw_result, str) else raw_result
)
@@ -1030,10 +1020,6 @@ class CrewAgentExecutor(BaseAgentExecutor):
color="red",
)
# An after_tool_call hook may have replaced the result with a
# FileArtifact; keep those bytes out of the message and events too.
result = store_if_artifact(result, scope_id)
if not error_event_emitted:
crewai_event_bus.emit(
self,

View File

@@ -117,8 +117,10 @@ def capture_execution_context(
)
def apply_execution_context(ctx: ExecutionContext) -> None:
def apply_execution_context(ctx: ExecutionContext | dict[str, Any]) -> None:
"""Write an ExecutionContext back into the ContextVars."""
if isinstance(ctx, dict):
ctx = ExecutionContext.model_validate(ctx)
_current_task_id.set(ctx.current_task_id)
current_flow_request_id.set(ctx.flow_request_id)
current_flow_id.set(ctx.flow_id)

View File

@@ -116,7 +116,6 @@ from crewai.tasks.task_output import TaskOutput
from crewai.tools.agent_tools.agent_tools import AgentTools
from crewai.tools.agent_tools.read_file_tool import ReadFileTool
from crewai.tools.base_tool import BaseTool
from crewai.tools.file_artifact import clear_artifact_scope
from crewai.types.callback import SerializableCallable
from crewai.types.streaming import CrewStreamingOutput
from crewai.types.usage_metrics import UsageMetrics
@@ -1014,6 +1013,7 @@ class Crew(FlowTrackable, BaseModel):
)
token = attach(baggage_ctx)
runtime_scope = crewai_event_bus._enter_runtime_scope()
try:
inputs = prepare_kickoff(self, inputs, input_files)
@@ -1048,8 +1048,8 @@ class Crew(FlowTrackable, BaseModel):
if self._memory is not None and hasattr(self._memory, "drain_writes"):
self._memory.drain_writes()
clear_files(self.id)
clear_artifact_scope(self.id)
detach(token)
crewai_event_bus._exit_runtime_scope(runtime_scope)
def _post_kickoff(self, result: CrewOutput) -> CrewOutput:
return result
@@ -1225,6 +1225,7 @@ class Crew(FlowTrackable, BaseModel):
)
token = attach(baggage_ctx)
runtime_scope = crewai_event_bus._enter_runtime_scope()
try:
inputs = prepare_kickoff(self, inputs, input_files)
@@ -1257,8 +1258,8 @@ class Crew(FlowTrackable, BaseModel):
raise
finally:
clear_files(self.id)
clear_artifact_scope(self.id)
detach(token)
crewai_event_bus._exit_runtime_scope(runtime_scope)
async def akickoff_for_each(
self,

View File

@@ -61,6 +61,8 @@ if TYPE_CHECKING:
CrewTrainStartedEvent,
)
from crewai.events.types.flow_events import (
ConversationMessageAddedEvent,
ConversationRouteSelectedEvent,
FlowCreatedEvent,
FlowEvent,
FlowFinishedEvent,
@@ -176,6 +178,8 @@ _LAZY_EVENT_MAPPING: dict[str, str] = {
"CrewTrainCompletedEvent": "crewai.events.types.crew_events",
"CrewTrainFailedEvent": "crewai.events.types.crew_events",
"CrewTrainStartedEvent": "crewai.events.types.crew_events",
"ConversationMessageAddedEvent": "crewai.events.types.flow_events",
"ConversationRouteSelectedEvent": "crewai.events.types.flow_events",
"FlowCreatedEvent": "crewai.events.types.flow_events",
"FlowEvent": "crewai.events.types.flow_events",
"FlowFinishedEvent": "crewai.events.types.flow_events",
@@ -291,6 +295,8 @@ __all__ = [
"CheckpointRestoreStartedEvent",
"CheckpointStartedEvent",
"CircularDependencyError",
"ConversationMessageAddedEvent",
"ConversationRouteSelectedEvent",
"CrewKickoffCompletedEvent",
"CrewKickoffFailedEvent",
"CrewKickoffStartedEvent",

View File

@@ -80,6 +80,17 @@ def is_replaying() -> bool:
return _replaying.get()
_runtime_state_var: contextvars.ContextVar[RuntimeState | None] = (
contextvars.ContextVar("crewai_runtime_state", default=None)
)
_registered_entity_ids_var: contextvars.ContextVar[set[int] | None] = (
contextvars.ContextVar("crewai_registered_entity_ids", default=None)
)
_runtime_scope_depth: contextvars.ContextVar[int] = contextvars.ContextVar(
"crewai_runtime_scope_depth", default=0
)
class CrewAIEventsBus:
"""Singleton event bus for handling events in CrewAI.
@@ -116,7 +127,6 @@ class CrewAIEventsBus:
_futures_lock: threading.Lock
_executor_initialized: bool
_has_pending_events: bool
_runtime_state: RuntimeState | None
def __new__(cls) -> Self:
"""Create or return the singleton instance.
@@ -151,8 +161,6 @@ class CrewAIEventsBus:
self._console = ConsoleFormatter()
self._executor_initialized = False
self._has_pending_events = False
self._runtime_state: RuntimeState | None = None
self._registered_entity_ids: set[int] = set()
def _ensure_executor_initialized(self) -> None:
"""Lazily initialize the thread pool executor and event loop.
@@ -281,6 +289,51 @@ class CrewAIEventsBus:
"""The RuntimeState currently attached to the bus, if any."""
return self._runtime_state
@property
def _runtime_state(self) -> RuntimeState | None:
return _runtime_state_var.get()
@_runtime_state.setter
def _runtime_state(self, value: RuntimeState | None) -> None:
_runtime_state_var.set(value)
@property
def _registered_entity_ids(self) -> set[int]:
ids = _registered_entity_ids_var.get()
if ids is None:
ids = set()
_registered_entity_ids_var.set(ids)
return ids
@_registered_entity_ids.setter
def _registered_entity_ids(self, value: set[int]) -> None:
_registered_entity_ids_var.set(value)
def reset_runtime_state(self) -> None:
"""Detach the RuntimeState and clear the entity registry."""
self._runtime_state = None
self._registered_entity_ids = set()
def _enter_runtime_scope(self) -> bool:
depth = _runtime_scope_depth.get()
_runtime_scope_depth.set(depth + 1)
if depth != 0:
return False
if _runtime_state_var.get() is None:
from crewai import RuntimeState
if RuntimeState is not None:
_runtime_state_var.set(RuntimeState(root=[]))
_registered_entity_ids_var.set(set())
return True
def _exit_runtime_scope(self, outermost: bool) -> None:
depth = _runtime_scope_depth.get()
_runtime_scope_depth.set(depth - 1 if depth > 0 else 0)
if outermost:
_runtime_state_var.set(None)
_registered_entity_ids_var.set(None)
def register_entity(self, entity: Any) -> None:
"""Add an entity to the RuntimeState, creating it if needed.
@@ -349,6 +402,7 @@ class CrewAIEventsBus:
source: Any,
event: BaseEvent,
handlers: SyncHandlerSet,
state: RuntimeState | None,
) -> None:
"""Call provided synchronous handlers.
@@ -356,8 +410,8 @@ class CrewAIEventsBus:
source: The emitting object
event: The event instance
handlers: Frozenset of sync handlers to call
state: The RuntimeState captured on the emitting context
"""
state = self._runtime_state
errors: list[tuple[SyncHandler, Exception]] = [
(handler, error)
for handler in handlers
@@ -376,6 +430,7 @@ class CrewAIEventsBus:
source: Any,
event: BaseEvent,
handlers: AsyncHandlerSet,
state: RuntimeState | None,
) -> None:
"""Asynchronously call provided async handlers.
@@ -383,8 +438,8 @@ class CrewAIEventsBus:
source: The object that emitted the event
event: The event instance
handlers: Frozenset of async handlers to call
state: The RuntimeState captured on the emitting context
"""
state = self._runtime_state
async def _call(handler: AsyncHandler) -> Any:
if _get_param_count(handler) >= 3:
@@ -399,7 +454,9 @@ class CrewAIEventsBus:
f"[CrewAIEventsBus] Async handler error in {getattr(handler, '__name__', handler)}: {result}"
)
async def _emit_with_dependencies(self, source: Any, event: BaseEvent) -> None:
async def _emit_with_dependencies(
self, source: Any, event: BaseEvent, state: RuntimeState | None
) -> None:
"""Emit an event with dependency-aware handler execution.
Handlers are grouped into execution levels based on their dependencies.
@@ -450,18 +507,18 @@ class CrewAIEventsBus:
if level_sync:
if event_type is LLMStreamChunkEvent:
self._call_handlers(source, event, level_sync)
self._call_handlers(source, event, level_sync, state)
else:
ctx = contextvars.copy_context()
future = self._sync_executor.submit(
ctx.run, self._call_handlers, source, event, level_sync
ctx.run, self._call_handlers, source, event, level_sync, state
)
await asyncio.get_running_loop().run_in_executor(
None, future.result
)
if level_async:
await self._acall_handlers(source, event, level_async)
await self._acall_handlers(source, event, level_async, state)
def _register_source(self, source: Any) -> None:
"""Register the source entity in RuntimeState if applicable."""
@@ -556,21 +613,23 @@ class CrewAIEventsBus:
self._ensure_executor_initialized()
self._has_pending_events = True
state = self._runtime_state
if has_dependencies:
return self._track_future(
asyncio.run_coroutine_threadsafe(
self._emit_with_dependencies(source, event),
self._emit_with_dependencies(source, event, state),
self._loop,
)
)
if sync_handlers:
if event_type is LLMStreamChunkEvent:
self._call_handlers(source, event, sync_handlers)
self._call_handlers(source, event, sync_handlers, state)
else:
ctx = contextvars.copy_context()
sync_future = self._sync_executor.submit(
ctx.run, self._call_handlers, source, event, sync_handlers
ctx.run, self._call_handlers, source, event, sync_handlers, state
)
if not async_handlers:
return self._track_future(sync_future)
@@ -578,7 +637,7 @@ class CrewAIEventsBus:
if async_handlers:
return self._track_future(
asyncio.run_coroutine_threadsafe(
self._acall_handlers(source, event, async_handlers),
self._acall_handlers(source, event, async_handlers, state),
self._loop,
)
)
@@ -590,21 +649,22 @@ class CrewAIEventsBus:
source: Any,
event: BaseEvent,
handlers: AsyncHandlerSet,
state: RuntimeState | None,
) -> None:
"""Call async handlers with the replaying flag set on the loop thread."""
token = _replaying.set(True)
try:
await self._acall_handlers(source, event, handlers)
await self._acall_handlers(source, event, handlers, state)
finally:
_replaying.reset(token)
async def _emit_with_dependencies_replaying(
self, source: Any, event: BaseEvent
self, source: Any, event: BaseEvent, state: RuntimeState | None
) -> None:
"""Dependency-aware dispatch with the replaying flag set."""
token = _replaying.set(True)
try:
await self._emit_with_dependencies(source, event)
await self._emit_with_dependencies(source, event, state)
finally:
_replaying.reset(token)
@@ -638,12 +698,13 @@ class CrewAIEventsBus:
self._ensure_executor_initialized()
self._has_pending_events = True
state = self._runtime_state
token = _replaying.set(True)
try:
if has_dependencies:
return self._track_future(
asyncio.run_coroutine_threadsafe(
self._emit_with_dependencies_replaying(source, event),
self._emit_with_dependencies_replaying(source, event, state),
self._loop,
)
)
@@ -651,7 +712,7 @@ class CrewAIEventsBus:
if sync_handlers:
ctx = contextvars.copy_context()
sync_future = self._sync_executor.submit(
ctx.run, self._call_handlers, source, event, sync_handlers
ctx.run, self._call_handlers, source, event, sync_handlers, state
)
self._track_future(sync_future)
if not async_handlers:
@@ -659,7 +720,9 @@ class CrewAIEventsBus:
return self._track_future(
asyncio.run_coroutine_threadsafe(
self._acall_handlers_replaying(source, event, async_handlers),
self._acall_handlers_replaying(
source, event, async_handlers, state
),
self._loop,
)
)
@@ -727,7 +790,9 @@ class CrewAIEventsBus:
async_handlers = self._async_handlers.get(event_type, frozenset())
if async_handlers:
await self._acall_handlers(source, event, async_handlers)
await self._acall_handlers(
source, event, async_handlers, self._runtime_state
)
def register_handler(
self,

View File

@@ -158,7 +158,6 @@ class EventListener(BaseEventListener):
trace_listener.formatter = self.formatter
def setup_listeners(self, crewai_event_bus: CrewAIEventsBus) -> None:
@crewai_event_bus.on(CCEnvEvent)
def on_cc_env(_: Any, event: CCEnvEvent) -> None:
self._telemetry.env_context_span(event.type)

View File

@@ -53,6 +53,8 @@ from crewai.events.types.crew_events import (
CrewTrainStartedEvent,
)
from crewai.events.types.flow_events import (
ConversationMessageAddedEvent,
ConversationRouteSelectedEvent,
FlowFinishedEvent,
FlowStartedEvent,
MethodExecutionFailedEvent,
@@ -154,6 +156,8 @@ EventTypes = (
| TaskStartedEvent
| TaskCompletedEvent
| TaskFailedEvent
| ConversationMessageAddedEvent
| ConversationRouteSelectedEvent
| FlowStartedEvent
| FlowFinishedEvent
| MethodExecutionStartedEvent

View File

@@ -62,6 +62,8 @@ from crewai.events.types.crew_events import (
CrewKickoffStartedEvent,
)
from crewai.events.types.flow_events import (
ConversationMessageAddedEvent,
ConversationRouteSelectedEvent,
FlowCreatedEvent,
FlowFinishedEvent,
FlowPlotEvent,
@@ -255,6 +257,18 @@ class TraceCollectionListener(BaseEventListener):
def on_method_failed(source: Any, event: MethodExecutionFailedEvent) -> None:
self._handle_trace_event("method_execution_failed", source, event)
@event_bus.on(ConversationMessageAddedEvent)
def on_conversation_message_added(
source: Any, event: ConversationMessageAddedEvent
) -> None:
self._handle_action_event("conversation_message_added", source, event)
@event_bus.on(ConversationRouteSelectedEvent)
def on_conversation_route_selected(
source: Any, event: ConversationRouteSelectedEvent
) -> None:
self._handle_action_event("conversation_route_selected", source, event)
@event_bus.on(FlowFinishedEvent)
def on_flow_finished(source: Any, event: FlowFinishedEvent) -> None:
self._handle_trace_event("flow_finished", source, event)
@@ -278,7 +292,7 @@ class TraceCollectionListener(BaseEventListener):
@event_bus.on(CrewKickoffCompletedEvent)
def on_crew_completed(source: Any, event: CrewKickoffCompletedEvent) -> None:
self._handle_trace_event("crew_kickoff_completed", source, event)
if self.batch_manager.defer_session_finalization:
if self._should_defer_session_finalization():
return
if self._nested_in_flow_execution():
return
@@ -292,7 +306,7 @@ class TraceCollectionListener(BaseEventListener):
@event_bus.on(CrewKickoffFailedEvent)
def on_crew_failed(source: Any, event: CrewKickoffFailedEvent) -> None:
self._handle_trace_event("crew_kickoff_failed", source, event)
if self.batch_manager.defer_session_finalization:
if self._should_defer_session_finalization():
return
if self._nested_in_flow_execution():
return
@@ -720,7 +734,7 @@ class TraceCollectionListener(BaseEventListener):
if not self.batch_manager.is_batch_initialized():
return
# Multi-turn flows defer batch finalization to finalize_session_traces().
if self.batch_manager.defer_session_finalization:
if self._should_defer_session_finalization():
return
self.batch_manager.finalize_batch()
@@ -731,6 +745,15 @@ class TraceCollectionListener(BaseEventListener):
return current_flow_id.get() is not None
def _should_defer_session_finalization(self) -> bool:
"""True when the active trace belongs to a deferred flow session."""
from crewai.flow.flow_context import current_flow_defer_trace_finalization
return (
self.batch_manager.defer_session_finalization
or current_flow_defer_trace_finalization.get()
)
def _flow_owns_trace_batch(self) -> bool:
"""True when an in-flight conversational flow already owns the trace batch."""
if self.batch_manager.batch_owner_type == "flow":
@@ -766,12 +789,17 @@ class TraceCollectionListener(BaseEventListener):
def _try_initialize_flow_batch_from_context(self, event: Any) -> bool:
"""Claim a flow trace batch when an action event fires inside kickoff.
When ``suppress_flow_events=True``, console panels are hidden but
``FlowStartedEvent`` and method lifecycle events still emit; if no
batch exists yet, LLM/tool events must not fall back to implicit crew
batches.
When ``suppress_flow_events=True`` (infrastructure flows such as
``AgentExecutor`` and the memory flows), flow and method lifecycle
events are not emitted, so the batch is claimed from the flow context
(``current_flow_id``) to keep LLM/tool events from falling back to an
implicit crew batch.
"""
from crewai.flow.flow_context import current_flow_id, current_flow_name
from crewai.flow.flow_context import (
current_flow_defer_trace_finalization,
current_flow_id,
current_flow_name,
)
flow_id = current_flow_id.get()
if flow_id is None:
@@ -787,6 +815,8 @@ class TraceCollectionListener(BaseEventListener):
}
self.batch_manager.batch_owner_type = "flow"
self.batch_manager.batch_owner_id = flow_id
if current_flow_defer_trace_finalization.get():
self.batch_manager.defer_session_finalization = True
self._initialize_batch(user_context, execution_metadata)
return True

View File

@@ -1,6 +1,6 @@
from typing import Any, Literal
from pydantic import BaseModel, ConfigDict
from pydantic import BaseModel, ConfigDict, field_serializer
from crewai.events.base_events import BaseEvent
@@ -57,6 +57,10 @@ class MethodExecutionFailedEvent(FlowEvent):
model_config = ConfigDict(arbitrary_types_allowed=True)
@field_serializer("error")
def _serialize_error(self, error: Exception) -> str:
return str(error)
class MethodExecutionPausedEvent(FlowEvent):
"""Event emitted when a flow method is paused waiting for human feedback.
@@ -166,6 +170,31 @@ class FlowInputReceivedEvent(FlowEvent):
type: Literal["flow_input_received"] = "flow_input_received"
class ConversationMessageAddedEvent(FlowEvent):
"""Event emitted when a conversational Flow records a message.
This gives trace consumers a first-class transcript signal instead of
requiring them to inspect the full method state payload.
"""
session_id: str
role: Literal["user", "assistant", "system", "tool"]
content: Any
message_index: int
type: Literal["conversation_message_added"] = "conversation_message_added"
class ConversationRouteSelectedEvent(FlowEvent):
"""Event emitted when a conversational Flow selects a route for a turn."""
session_id: str
route: str
user_message: str | None = None
message_index: int | None = None
previous_intent: str | None = None
type: Literal["conversation_route_selected"] = "conversation_route_selected"
class HumanFeedbackRequestedEvent(FlowEvent):
"""Event emitted when human feedback is requested.

View File

@@ -1,7 +1,7 @@
from enum import Enum
from typing import Any, Literal
from pydantic import BaseModel
from pydantic import BaseModel, field_validator
from crewai.events.base_events import BaseEvent
@@ -48,6 +48,43 @@ class LLMCallStartedEvent(LLMEventBase):
tools: list[dict[str, Any]] | None = None
callbacks: list[Any] | None = None
available_functions: dict[str, Any] | None = None
# Sampling/request parameters forwarded for OTel GenAI compliance.
# All optional so legacy emitters keep working unchanged.
temperature: float | None = None
top_p: float | None = None
max_tokens: int | float | None = None
stream: bool | None = None
seed: int | None = None
stop_sequences: list[str] | None = None
frequency_penalty: float | None = None
presence_penalty: float | None = None
n: int | None = None
@field_validator("stop_sequences", mode="before")
@classmethod
def _coerce_stop_sequences_to_str_list(cls, value: Any) -> list[str] | None:
"""Normalize stop_sequences to ``list[str] | None``.
Some providers store stop sequences in non-Python-list containers —
e.g. a Vertex AI / Gemini code path can hand back a
``google.protobuf.struct_pb2.ListValue`` or a ``RepeatedScalarContainer``.
Without coercion the OTel SDK falls back to ``str(value)`` when
``gen_ai.request.stop_sequences`` is set, producing the protobuf
textproto repr (``values { string_value: \"...\" }``) instead of a
proper ``Sequence[str]``.
A bare string is treated as a single stop sequence. Anything that
can't be iterated cleanly falls back to ``None`` rather than crashing
event construction.
"""
if value is None:
return None
if isinstance(value, str):
return [value]
try:
return [item if isinstance(item, str) else str(item) for item in value]
except TypeError:
return None
class LLMCallCompletedEvent(LLMEventBase):
@@ -58,6 +95,23 @@ class LLMCallCompletedEvent(LLMEventBase):
response: Any
call_type: LLMCallType
usage: dict[str, Any] | None = None
finish_reason: str | None = None
response_id: str | None = None
@field_validator("finish_reason", "response_id", mode="before")
@classmethod
def _coerce_non_string_to_none(cls, value: Any) -> str | None:
"""Drop non-string values so test mocks and exotic provider types
(MagicMock, protobuf enums, etc.) never crash event construction.
Provider helpers are best-effort: when extraction returns something
non-string (e.g. a ``MagicMock`` in unit tests), we treat it as
"no value" rather than raising. Downstream telemetry already
handles the missing-attribute case.
"""
if value is None or isinstance(value, str):
return value
return None
class LLMCallFailedEvent(LLMEventBase):

View File

@@ -70,11 +70,6 @@ from crewai.hooks.types import (
BeforeLLMCallHookType,
)
from crewai.tools.base_tool import BaseTool
from crewai.tools.file_artifact import (
artifact_scope_id,
resolve_artifact_handles,
store_if_artifact,
)
from crewai.tools.structured_tool import CrewStructuredTool
from crewai.utilities.agent_utils import (
_llm_stop_words_applied,
@@ -284,6 +279,16 @@ class AgentExecutor(Flow[AgentExecutorState], BaseAgentExecutor):
"""Set state messages."""
self._state.messages = value
@property
def ask_for_human_input(self) -> bool:
"""Compatibility property - returns state ask_for_human_input."""
return self._state.ask_for_human_input # type: ignore[no-any-return]
@ask_for_human_input.setter
def ask_for_human_input(self, value: bool) -> None:
"""Set state ask_for_human_input."""
self._state.ask_for_human_input = value
@start()
def generate_plan(self) -> None:
"""Generate execution plan if planning is enabled.
@@ -1767,8 +1772,6 @@ class AgentExecutor(Flow[AgentExecutorState], BaseAgentExecutor):
return parse_error
args_dict: dict[str, Any] = parsed_args or {}
scope_id = artifact_scope_id(self.crew, self.task, self.agent)
# Get agent_key for event tracking
agent_key = getattr(self.agent, "key", "unknown") if self.agent else "unknown"
@@ -1801,7 +1804,6 @@ class AgentExecutor(Flow[AgentExecutorState], BaseAgentExecutor):
tool=func_name, input=input_str
)
if cached_result is not None:
cached_result = store_if_artifact(cached_result, scope_id)
result = (
str(cached_result)
if not isinstance(cached_result, str)
@@ -1867,10 +1869,7 @@ class AgentExecutor(Flow[AgentExecutorState], BaseAgentExecutor):
if func_name in self._available_functions:
try:
tool_func = self._available_functions[func_name]
invoke_args = (
resolve_artifact_handles(args_dict) if args_dict else {}
)
raw_result = tool_func(**invoke_args)
raw_result = tool_func(**args_dict)
# Add to cache after successful execution (before string conversion)
if self.tools_handler and self.tools_handler.cache:
@@ -1885,7 +1884,6 @@ class AgentExecutor(Flow[AgentExecutorState], BaseAgentExecutor):
)
# Convert to string for message
raw_result = store_if_artifact(raw_result, scope_id)
result = (
str(raw_result)
if not isinstance(raw_result, str)
@@ -1939,10 +1937,6 @@ class AgentExecutor(Flow[AgentExecutorState], BaseAgentExecutor):
color="red",
)
# An after_tool_call hook may have replaced the result with a
# FileArtifact; keep those bytes out of the message and events too.
result = store_if_artifact(result, scope_id)
if not error_event_emitted:
crewai_event_bus.emit(
self,

View File

@@ -1,15 +1,17 @@
"""Conversational graph + helpers as a mixin for ``Flow`` (experimental).
"""Conversational graph + helpers as an experimental Flow extension.
The experimental conversational chat surface lives here as a mixin so that
``crewai.flow.runtime`` stays focused on the execution engine. ``Flow``
inherits from ``_ConversationalMixin``; the methods only register on
subclasses that opt in via ``conversational = True`` (enforced by the
``_conversational_only`` marker + ``FlowMeta`` gating in
``crewai.flow.runtime``).
The conversational chat surface remains experimental and may change before the
v2 graduation path. It lives here so ``crewai.flow.runtime`` can stay focused
on the execution engine. ``crewai.flow.flow`` composes this mixin onto the
public ``Flow`` class for backwards compatibility.
The built-in conversational graph only registers for subclasses that opt in
with ``conversational = True``. Static conversational metadata is projected
into ``FlowDefinition.conversational`` via the Python DSL builder.
Import surface:
- :class:`_ConversationalMixin` — internal; ``Flow`` mixes it in. Users
don't import it directly.
- :class:`_ConversationalMixin` — internal; the public ``Flow`` class
composes it in. Users don't import it directly.
- The data types this mixin uses live in
:mod:`crewai.experimental.conversational`.
"""
@@ -20,10 +22,15 @@ from collections.abc import Callable, Mapping, Sequence
from enum import Enum
import json
import logging
from typing import TYPE_CHECKING, Any, ClassVar, Literal, cast
from typing import TYPE_CHECKING, Any, ClassVar, Literal, TypeVar, cast
from pydantic import BaseModel, Field, create_model
from crewai.events.event_bus import crewai_event_bus
from crewai.events.types.flow_events import (
ConversationMessageAddedEvent,
ConversationRouteSelectedEvent,
)
from crewai.experimental.conversational import (
AgentMessage,
ConversationConfig,
@@ -39,26 +46,69 @@ from crewai.flow.conversation import (
get_conversation_messages,
receive_user_message as _receive_user_message,
)
from crewai.flow.dsl import listen, router, start
from crewai.flow.dsl import listen, start
from crewai.flow.dsl._utils import _method_action, _set_flow_method_definition
from crewai.flow.flow_definition import FlowMethodDefinition
from crewai.utilities.types import LLMMessage
if TYPE_CHECKING:
from crewai.flow.runtime import Flow
from crewai.llms.base_llm import BaseLLM
logger = logging.getLogger(__name__)
class _ConversationalMixin:
"""Built-in conversational graph for ``Flow`` (gated on ``conversational``).
def _iter_condition_labels(condition: Any) -> set[str]:
if isinstance(condition, str):
return {condition}
if isinstance(condition, dict):
labels: set[str] = set()
for value in condition.values():
if isinstance(value, list):
for item in value:
labels.update(_iter_condition_labels(item))
else:
labels.update(_iter_condition_labels(value))
return labels
return set()
Mixed into ``Flow`` so its execution engine (``runtime.py``) stays focused
on running graphs. The methods here only register on subclasses that set
``conversational = True``; non-chat flows see them as inert attributes.
def _conversation_start_router(func: Callable[..., Any]) -> Any:
wrapper = start()(func)
_set_flow_method_definition(
cast(Any, wrapper),
FlowMethodDefinition(do=_method_action(func), start=True, router=True),
)
return wrapper
class _ConversationalMixin:
"""Experimental conversational graph for ``Flow``.
This mixin owns chat behavior and runtime hooks. Non-chat flows see these
methods as inert attributes unless they opt in with ``conversational = True``.
"""
# === EXPERIMENTAL: conversational mode ===
# When ``conversational = True`` on a Flow subclass, this mixin's built-in
# graph registers and ``handle_turn`` / ``chat`` become chat entry points.
conversational: ClassVar[bool] = False
conversational_config: ClassVar[ConversationConfig | None] = None
builtin_routes: ClassVar[tuple[str, ...]] = ("converse", "end")
internal_routes: ClassVar[tuple[str, ...]] = ("answer_from_history",)
builtin_route_descriptions: ClassVar[dict[str, str]] = {
"converse": (
"Ordinary chat, follow-ups, summaries, clarifications, and "
"questions answerable from prior conversation history."
),
"end": ("User signals the conversation is finished (goodbye, exit, done)."),
"answer_from_history": (
"Answer directly from prior conversation history without invoking "
"tools, agents, or custom routes."
),
}
# The metaclass + state attributes referenced below live on ``Flow`` —
# this mixin is never instantiated standalone. These type-only
# declarations exist so static analyzers don't flag attribute access.
@@ -66,22 +116,15 @@ class _ConversationalMixin:
# (otherwise mypy flags "Cannot override instance variable with class
# variable" when Flow declares them as ``ClassVar``).
if TYPE_CHECKING:
conversational: ClassVar[bool]
conversational_config: ClassVar[ConversationConfig | None]
builtin_routes: ClassVar[tuple[str, ...]]
internal_routes: ClassVar[tuple[str, ...]]
builtin_route_descriptions: ClassVar[dict[str, str]]
# Registry ClassVars populated by ``FlowMeta`` at class creation.
_listeners: ClassVar[dict[Any, Any]]
# Instance attrs from ``Flow``.
state: Any
name: str | None
_completed_methods: set[Any]
_method_outputs: list[Any]
_pending_and_listeners: dict[Any, Any]
_pending_events: dict[Any, Any]
_method_call_counts: dict[Any, int]
_is_execution_resuming: bool
_conversation_messages: list[LLMMessage]
_pending_user_message: str | dict[str, Any] | None
_pending_intents: Sequence[str] | None
_pending_intent_llm: str | BaseLLM | None
@@ -92,8 +135,8 @@ class _ConversationalMixin:
def _collapse_to_outcome(
self,
feedback: str,
outcomes: tuple[str, ...],
llm: str | BaseLLM | Any,
outcomes: Sequence[str],
llm: str | BaseLLM,
) -> str:
pass
@@ -103,38 +146,60 @@ class _ConversationalMixin:
def kickoff(self, *args: Any, **kwargs: Any) -> Any:
pass
@start()
@_conversational_only
def conversation_start(self) -> str | None:
"""Internal Flow entrypoint that hands the user message to the router.
@property
def method_outputs(self) -> list[Any]:
pass
In conversational mode, ``Flow.kickoff_async`` runs all ``@start``
methods sequentially and this one is registered last, so any user
``@start`` methods (e.g. permission loading) have already finished
before the returned value triggers ``route_conversation``.
def conversation_start(self) -> str | None:
"""Return the current user message for conversational route selection.
This remains as a plain overridable helper for compatibility. It is not
registered as a Flow method; ``route_conversation`` is the synthetic
built-in start/router that begins a conversational turn.
"""
state = cast(ConversationState, self.state)
return state.current_user_message
@router(conversation_start)
@_conversation_start_router
@_conversational_only
def route_conversation(self) -> str:
"""Route the current turn to a listener label."""
if "conversation_start" not in {
str(method_name) for method_name in self._completed_methods
}:
self.conversation_start()
state = cast(ConversationState, self.state)
context = self.build_router_context()
previous_intent = state.last_intent
configured_route = self.route_turn(context)
if configured_route:
state.last_intent = configured_route
self._emit_conversation_route_selected(
configured_route,
previous_intent=previous_intent,
)
return configured_route
if state.last_intent:
self._emit_conversation_route_selected(
state.last_intent,
previous_intent=previous_intent,
)
return state.last_intent
if self.can_answer_from_history(context):
state.last_intent = "answer_from_history"
self._emit_conversation_route_selected(
"answer_from_history",
previous_intent=previous_intent,
)
return "answer_from_history"
state.last_intent = "converse"
self._emit_conversation_route_selected(
"converse",
previous_intent=previous_intent,
)
return "converse"
@listen("converse")
@@ -216,8 +281,8 @@ class _ConversationalMixin:
state = cast(ConversationState, self.state)
sid = session_id or state.id
# Stash the pending turn so ``_apply_pending_conversational_turn``
# picks it up AFTER persist restore.
# Stash the pending turn so the kickoff extension hook picks it up
# after persist restore.
self._pending_user_message = message
self._pending_intents = list(intents) if intents else None
self._pending_intent_llm = intent_llm
@@ -264,7 +329,7 @@ class _ConversationalMixin:
callers can customize prompts or exercise the loop without patching
builtins.
"""
if not getattr(type(self), "conversational", False):
if not self._is_conversational_enabled():
raise ValueError("Flow.chat() is only available on conversational flows")
exit_set = {command.lower() for command in exit_commands}
@@ -406,13 +471,61 @@ class _ConversationalMixin:
metadata: dict[str, Any] | None = None,
) -> None:
"""Append a final user-visible assistant message."""
cast(ConversationState, self.state).messages.append(
state = cast(ConversationState, self.state)
state.messages.append(
ConversationMessage(
role="assistant",
content=content,
metadata=metadata or {},
)
)
self._emit_conversation_message_added(
role="assistant",
content=content,
message_index=len(state.messages) - 1,
)
def _emit_conversation_message_added(
self,
*,
role: Literal["user", "assistant", "system", "tool"],
content: Any,
message_index: int,
) -> None:
"""Emit a compact transcript event for conversational trace views."""
state = cast(ConversationState, self.state)
crewai_event_bus.emit(
self,
ConversationMessageAddedEvent(
type="conversation_message_added",
flow_name=self.name or self.__class__.__name__,
session_id=state.id,
role=role,
content=content,
message_index=message_index,
),
)
def _emit_conversation_route_selected(
self,
route: str,
*,
previous_intent: str | None = None,
) -> None:
"""Emit the conversational routing decision for the current turn."""
state = cast(ConversationState, self.state)
crewai_event_bus.emit(
self,
ConversationRouteSelectedEvent(
type="conversation_route_selected",
flow_name=self.name or self.__class__.__name__,
session_id=state.id,
route=route,
user_message=state.current_user_message,
message_index=(len(state.messages) - 1) if state.messages else None,
previous_intent=previous_intent,
),
)
def append_message(
self,
@@ -421,14 +534,14 @@ class _ConversationalMixin:
**extra: Any,
) -> None:
"""Append a message to conversation history (legacy ChatState path)."""
_append_conversation_message(cast("Flow[Any]", self), role, content, **extra)
_append_conversation_message(cast(Any, self), role, content, **extra)
@property
def conversation_messages(self) -> list[LLMMessage]:
"""Message history from state, coerced to LLM-shaped dicts."""
return [
message_to_llm_dict(message)
for message in get_conversation_messages(cast("Flow[Any]", self))
for message in get_conversation_messages(cast(Any, self))
]
def receive_user_message(
@@ -444,9 +557,14 @@ class _ConversationalMixin:
``state.messages`` and preserve ``last_intent`` across turns.
Non-conversational flows fall through to the legacy helper.
"""
if self.conversational:
if self._is_conversational_enabled():
state = cast(ConversationState, self.state)
state.messages.append(ConversationMessage(role="user", content=text))
self._emit_conversation_message_added(
role="user",
content=text,
message_index=len(state.messages) - 1,
)
state.current_user_message = text
state.last_user_message = text
if outcomes and llm is not None:
@@ -460,9 +578,7 @@ class _ConversationalMixin:
return intent
return text
return _receive_user_message(
cast("Flow[Any]", self), text, outcomes=outcomes, llm=llm
)
return _receive_user_message(cast(Any, self), text, outcomes=outcomes, llm=llm)
def classify_intent(
self,
@@ -486,27 +602,104 @@ class _ConversationalMixin:
def _conversation_config(self) -> ConversationConfig | None:
return getattr(type(self), "conversational_config", None)
@property
def _conversation_definition(self) -> Any | None:
return self._conversation_flow_definition().conversational
def _conversation_flow_definition(self) -> Any:
flow_definition = getattr(type(self), "flow_definition", None)
if not callable(flow_definition):
raise AttributeError(
f"{type(self).__name__} does not expose flow_definition()"
)
return flow_definition()
@classmethod
def _conversational_definition(cls) -> Any | None:
flow_definition = getattr(cls, "flow_definition", None)
if not callable(flow_definition):
return None
return flow_definition().conversational
@classmethod
def _is_conversational(cls) -> bool:
definition = cls._conversational_definition()
return bool(definition and definition.enabled)
def _is_conversational_enabled(self) -> bool:
definition = self._conversation_definition
return bool(definition and definition.enabled)
def _initialize_runtime_extension_attrs(self) -> None:
if not isinstance(getattr(self, "_conversation_messages", None), list):
object.__setattr__(self, "_conversation_messages", [])
if not hasattr(self, "_pending_user_message"):
object.__setattr__(self, "_pending_user_message", None)
if not hasattr(self, "_pending_intents"):
object.__setattr__(self, "_pending_intents", None)
if not hasattr(self, "_pending_intent_llm"):
object.__setattr__(self, "_pending_intent_llm", None)
def _create_default_extension_state(self) -> ConversationState | None:
initial_state_t = getattr(self, "_initial_state_t", None)
if type(self)._is_conversational() and (
not hasattr(self, "_initial_state_t")
or isinstance(initial_state_t, TypeVar)
):
return ConversationState()
return None
def _should_apply_pending_kickoff_context(self) -> bool:
return (
type(self)._is_conversational() and self._pending_user_message is not None
)
def _apply_pending_kickoff_context(self) -> None:
self._apply_pending_conversational_turn()
def _order_start_methods_for_kickoff(
self,
start_methods: list[Any],
) -> tuple[list[Any], bool]:
if not type(self)._is_conversational():
return start_methods, False
route_conversation = "route_conversation"
if route_conversation not in {str(method) for method in start_methods}:
return start_methods, False
ordered_starts = [
method for method in start_methods if str(method) != route_conversation
]
ordered_starts.append(
next(
method for method in start_methods if str(method) == route_conversation
)
)
return ordered_starts, True
def _should_defer_trace_finalization(self) -> bool:
"""Whether per-turn ``FlowFinished`` + ``finalize_batch`` should be skipped.
True when either:
- ``flow.defer_trace_finalization`` is set on the instance, OR
- the class-level ``ConversationConfig.defer_trace_finalization``
on a conversational subclass is True.
- the static conversational definition enables deferred finalization.
Either source enables the deferred-session pattern. The caller
eventually invokes ``finalize_session_traces()`` to close the batch.
"""
if getattr(self, "defer_trace_finalization", False):
return True
config = self._conversation_config
return bool(config and config.defer_trace_finalization)
definition = self._conversation_definition
return bool(
definition and definition.enabled and definition.defer_trace_finalization
)
def _reset_turn_execution_state(self) -> None:
"""Clear per-execution tracking so the next turn re-runs the graph."""
self._completed_methods.clear()
self._method_outputs.clear()
self._pending_and_listeners.clear()
self._pending_events.clear()
self._method_call_counts.clear()
self._clear_or_listeners()
self._is_execution_resuming = False
@@ -658,11 +851,12 @@ class _ConversationalMixin:
router_config: RouterConfig | None,
) -> dict[str, str]:
label_to_method: dict[str, str] = {}
for listener_name, condition in self._listeners.items():
if isinstance(condition, tuple):
_, trigger_labels = condition
for trigger_label in trigger_labels:
label_to_method.setdefault(str(trigger_label), str(listener_name))
flow_definition = self._conversation_flow_definition()
for listener_name, method_definition in flow_definition.methods.items():
if method_definition.listen is None or method_definition.router:
continue
for trigger_label in _iter_condition_labels(method_definition.listen):
label_to_method.setdefault(trigger_label, listener_name)
routes = self._effective_routes(router_config)
overrides = (
@@ -713,21 +907,31 @@ class _ConversationalMixin:
def _valid_route_labels(self) -> set[str]:
labels: set[str] = set()
for condition in self._listeners.values():
if isinstance(condition, tuple):
_, methods = condition
labels.update(str(method) for method in methods)
flow_definition = self._conversation_flow_definition()
for method_definition in flow_definition.methods.values():
if method_definition.listen is None or method_definition.router:
continue
labels.update(_iter_condition_labels(method_definition.listen))
return labels
def _effective_routes(self, router_config: RouterConfig | None = None) -> set[str]:
custom_routes = set(router_config.routes or ()) if router_config else set()
definition = self._conversation_definition
builtin_routes = (
tuple(definition.builtin_routes)
if definition is not None
else self.builtin_routes
)
internal_routes = (
tuple(definition.internal_routes)
if definition is not None
else self.internal_routes
)
if not custom_routes:
custom_routes = (
self._valid_route_labels()
- set(self.builtin_routes)
- set(self.internal_routes)
self._valid_route_labels() - set(builtin_routes) - set(internal_routes)
)
return custom_routes | set(self.builtin_routes)
return custom_routes | set(builtin_routes)
def _default_conversation_llm(self) -> Any | None:
config = self._conversation_config
@@ -833,7 +1037,8 @@ class _ConversationalMixin:
# of warning about an empty scope stack.
started_id = getattr(self, "_deferred_flow_started_event_id", None)
if started_id:
last_output = self._method_outputs[-1] if self._method_outputs else None
method_outputs = self.method_outputs
last_output = method_outputs[-1] if method_outputs else None
restore_event_scope(((started_id, "flow_started"),))
try:
crewai_event_bus.emit(
@@ -856,12 +1061,15 @@ class _ConversationalMixin:
trace_listener = TraceCollectionListener()
batch_manager = trace_listener.batch_manager
if batch_manager.batch_owner_type == "flow":
if trace_listener.first_time_handler.is_first_time:
trace_listener.first_time_handler.mark_events_collected()
trace_listener.first_time_handler.handle_execution_completion()
else:
batch_manager.finalize_batch()
try:
if batch_manager.batch_owner_type == "flow":
if trace_listener.first_time_handler.is_first_time:
trace_listener.first_time_handler.mark_events_collected()
trace_listener.first_time_handler.handle_execution_completion()
else:
batch_manager.finalize_batch()
finally:
batch_manager.defer_session_finalization = False
__all__ = ["_ConversationalMixin"]

View File

@@ -0,0 +1,48 @@
"""Static conversational Flow definition models.
This module is part of the serializable Flow Definition contract. It should
only contain static data shapes. Experimental conversational runtime behavior
continues to live in ``crewai.experimental.conversational_mixin``.
"""
from __future__ import annotations
from typing import Any, Literal
from pydantic import BaseModel, Field
class FlowConversationalRouterDefinition(BaseModel):
"""Static conversational router configuration."""
prompt: str | None = None
response_format: Any = None
llm: Any = None
routes: list[str] | None = None
route_descriptions: dict[str, str] | None = None
default_intent: str | None = "converse"
fallback_intent: str | None = "converse"
intent_field: str = "intent"
class FlowConversationalDefinition(BaseModel):
"""Static conversational Flow configuration."""
enabled: bool = False
system_prompt: str | None = None
llm: Any = None
router: FlowConversationalRouterDefinition | None = None
answer_from_history_prompt: str | None = None
default_intents: list[str] | None = None
intent_llm: Any = None
answer_from_history_llm: Any = None
visible_agent_outputs: list[str] | Literal["all"] | None = None
defer_trace_finalization: bool = True
builtin_routes: list[str] = Field(default_factory=lambda: ["converse", "end"])
internal_routes: list[str] = Field(default_factory=lambda: ["answer_from_history"])
__all__ = [
"FlowConversationalDefinition",
"FlowConversationalRouterDefinition",
]

View File

@@ -15,10 +15,7 @@ from crewai.flow.dsl._human_feedback import (
from crewai.flow.dsl._listen import listen
from crewai.flow.dsl._router import router
from crewai.flow.dsl._start import start
from crewai.flow.dsl._utils import (
build_flow_definition as build_flow_definition,
extract_flow_definition as extract_flow_definition,
)
from crewai.flow.dsl._utils import build_flow_definition as build_flow_definition
__all__ = [

View File

@@ -1,12 +1,4 @@
"""Flow DSL condition primitives.
Type guards, the public ``or_`` / ``and_`` combinators, and the conversions
between runtime conditions, normalized conditions, and the
``FlowDefinitionCondition`` shape stored on a :class:`FlowDefinition`. These are
the lower layer of the DSL: the decorators and the definition builder
(``_utils``) build on top of them, so this module imports nothing from its
siblings.
"""
"""Flow DSL condition primitives."""
from __future__ import annotations
@@ -20,268 +12,75 @@ from crewai.flow.dsl._types import FlowTrigger
from crewai.flow.flow_definition import FlowDefinitionCondition
from crewai.flow.flow_wrappers import (
FlowCondition,
FlowConditions,
SimpleFlowCondition,
FlowConditionType,
)
from crewai.flow.types import FlowMethodName
def _is_non_string_sequence(value: Any) -> bool:
return isinstance(value, Sequence) and not isinstance(value, (str, bytes))
def is_simple_flow_condition(obj: Any) -> TypeIs[SimpleFlowCondition]:
"""Check if the object is a ``(condition_type, methods)`` tuple."""
return (
isinstance(obj, tuple)
and len(obj) == 2
and isinstance(obj[0], str)
and isinstance(obj[1], list)
)
def is_flow_condition_dict(obj: Any) -> TypeIs[FlowCondition]:
"""Check if the object matches the FlowCondition structure."""
if not isinstance(obj, dict):
return False
type_value = obj.get("type")
if type_value not in ("AND", "OR"):
return False
if "conditions" in obj:
conditions = obj["conditions"]
if not _is_non_string_sequence(conditions):
return False
for cond in conditions:
if not (
isinstance(cond, str)
or (isinstance(cond, dict) and is_flow_condition_dict(cond))
):
return False
if "methods" in obj:
methods = obj["methods"]
if not (
_is_non_string_sequence(methods)
and all(isinstance(m, str) for m in methods)
):
return False
allowed_keys = {"type", "conditions", "methods"}
if not set(obj).issubset(allowed_keys):
return False
return True
def _method_reference_name(value: Any) -> FlowMethodName | None:
name = getattr(value, "__name__", None)
if callable(value) and isinstance(name, str):
return FlowMethodName(name)
return None
def _normalize_condition(
condition: FlowConditions | FlowCondition | str,
) -> FlowCondition:
if isinstance(condition, str):
return {"type": OR_CONDITION, "conditions": [FlowMethodName(condition)]}
if is_flow_condition_dict(condition):
if "conditions" in condition:
return condition
if "methods" in condition:
normalized_methods: list[str | FlowMethodName | FlowCondition] = list(
condition["methods"]
)
return {"type": condition["type"], "conditions": normalized_methods}
return condition
if _is_non_string_sequence(condition) and all(
isinstance(item, str) or is_flow_condition_dict(item) for item in condition
):
return {"type": OR_CONDITION, "conditions": condition}
raise ValueError(f"Cannot normalize condition: {condition}")
def _extract_all_methods_recursive(
condition: str | FlowCondition | dict[str, Any] | list[Any],
flow: Any | None = None,
) -> list[FlowMethodName]:
if isinstance(condition, str):
if flow is not None:
if condition in flow._methods:
return [FlowMethodName(condition)]
return []
return [FlowMethodName(condition)]
if is_flow_condition_dict(condition):
normalized = _normalize_condition(condition)
methods = []
for sub_cond in normalized.get("conditions", []):
methods.extend(_extract_all_methods_recursive(sub_cond, flow))
return methods
if isinstance(condition, list):
methods = []
for item in condition:
methods.extend(_extract_all_methods_recursive(item, flow))
return methods
return []
def _extract_all_methods(
condition: str | FlowCondition | dict[str, Any] | list[Any],
) -> list[FlowMethodName]:
if isinstance(condition, str):
return [FlowMethodName(condition)]
if is_flow_condition_dict(condition):
normalized = _normalize_condition(condition)
cond_type = normalized.get("type", OR_CONDITION)
if cond_type == AND_CONDITION:
return [
FlowMethodName(sub_cond)
for sub_cond in normalized.get("conditions", [])
if isinstance(sub_cond, str)
]
return []
if isinstance(condition, list):
methods = []
for item in condition:
methods.extend(_extract_all_methods(item))
return methods
return []
def _condition_trigger(condition: FlowTrigger) -> FlowMethodName | FlowCondition:
if isinstance(condition, str):
return FlowMethodName(condition)
if is_flow_condition_dict(condition):
return condition
method_name = _method_reference_name(condition)
if method_name is not None:
return method_name
raise ValueError("Invalid condition")
def _condition_triggers(
conditions: Sequence[FlowTrigger],
error_message: str,
) -> FlowConditions:
try:
return [_condition_trigger(condition) for condition in conditions]
except ValueError as exc:
raise ValueError(error_message) from exc
def _definition_condition_from_runtime(condition: Any) -> FlowDefinitionCondition:
if isinstance(condition, str):
return str(condition)
method_name = _method_reference_name(condition)
if method_name is not None:
return str(method_name)
if is_flow_condition_dict(condition):
normalized = _normalize_condition(condition)
key = "and" if normalized.get("type") == AND_CONDITION else "or"
return {
key: [
_definition_condition_from_runtime(sub_condition)
for sub_condition in normalized.get("conditions", [])
]
}
if isinstance(condition, list):
return {"or": [_definition_condition_from_runtime(item) for item in condition]}
return str(condition)
_CONDITION_TYPES = (AND_CONDITION, OR_CONDITION)
def or_(*triggers: FlowTrigger) -> FlowCondition:
"""Combine multiple triggers with OR logic for flow control.
Creates a condition that is satisfied when any of the specified triggers
are met. This is used with @start, @listen, or @router decorators to create
complex triggering conditions.
Args:
triggers: Route labels, method references, or existing conditions
returned by or_() / and_().
Returns:
A condition dictionary with format {"type": "OR", "conditions": list_of_triggers}.
Raises:
ValueError: If a trigger format is invalid.
Examples:
>>> @listen(or_("success", "timeout"))
>>> def handle_completion(self):
... pass
>>> @listen(or_(and_("step1", "step2"), "step3"))
>>> def handle_nested(self):
... pass
"""
processed_triggers = _condition_triggers(triggers, "Invalid trigger in or_()")
return {"type": OR_CONDITION, "conditions": processed_triggers}
"""Return a condition that fires when any trigger fires."""
return _condition_tree(OR_CONDITION, triggers)
def and_(*triggers: FlowTrigger) -> FlowCondition:
"""Combine multiple triggers with AND logic for flow control.
Creates a condition that is satisfied only when all specified triggers
are met. This is used with @start, @listen, or @router decorators to create
complex triggering conditions.
Args:
triggers: Route labels, method references, or existing conditions
returned by or_() / and_().
Returns:
A condition dictionary with format {"type": "AND", "conditions": list_of_conditions}
where each condition can be a route label, method name, or nested condition.
Raises:
ValueError: If any trigger is invalid.
Examples:
>>> @listen(and_("validated", "processed"))
>>> def handle_complete_data(self):
... pass
>>> @listen(and_(or_("step1", "step2"), "step3"))
>>> def handle_nested(self):
... pass
"""
processed_triggers = _condition_triggers(triggers, "Invalid trigger in and_()")
return {"type": AND_CONDITION, "conditions": processed_triggers}
"""Return a condition that fires after all triggers fire."""
return _condition_tree(AND_CONDITION, triggers)
def _runtime_condition_from_definition(
condition: FlowDefinitionCondition,
) -> FlowMethodName | FlowCondition:
if isinstance(condition, str):
return FlowMethodName(condition)
if is_flow_condition_dict(condition):
return condition
def _trigger_name(value: Any) -> str | None:
if isinstance(value, str):
return value
if "and" in condition:
return {
"type": AND_CONDITION,
"conditions": [
_runtime_condition_from_definition(item)
for item in condition.get("and", [])
],
}
name = getattr(value, "__name__", None)
if callable(value) and isinstance(name, str):
return name
return None
def _is_condition(value: Any) -> TypeIs[FlowCondition]:
return (
isinstance(value, dict)
and set(value) == {"type", "conditions"}
and value["type"] in _CONDITION_TYPES
and isinstance(value["conditions"], list)
and all(
_trigger_name(condition) is not None or _is_condition(condition)
for condition in value["conditions"]
)
)
def _coerce_trigger(trigger: FlowTrigger) -> str | FlowCondition:
name = _trigger_name(trigger)
if name is not None:
return name
if _is_condition(trigger):
return trigger
raise ValueError("Invalid condition")
def _condition_tree(
condition_type: FlowConditionType,
triggers: Sequence[FlowTrigger],
) -> FlowCondition:
return {
"type": OR_CONDITION,
"conditions": [
_runtime_condition_from_definition(item) for item in condition.get("or", [])
],
"type": condition_type,
"conditions": [_coerce_trigger(trigger) for trigger in triggers],
}
def _runtime_listener_condition_from_definition(
condition: FlowDefinitionCondition,
) -> SimpleFlowCondition | FlowCondition:
runtime_condition = _runtime_condition_from_definition(condition)
if isinstance(runtime_condition, str):
return (OR_CONDITION, [FlowMethodName(str(runtime_condition))])
return runtime_condition
def _to_definition_condition(condition: FlowTrigger) -> FlowDefinitionCondition:
trigger = _coerce_trigger(condition)
if isinstance(trigger, str):
return trigger
key = trigger["type"].lower()
return {
key: [
_to_definition_condition(sub_condition)
for sub_condition in trigger["conditions"]
]
}

View File

@@ -3,11 +3,10 @@ from __future__ import annotations
from collections.abc import Callable, Sequence
from typing import TYPE_CHECKING, Any, TypeVar
from crewai.flow.flow_definition import FlowMethodDefinition
from crewai.flow.human_feedback import (
HumanFeedbackConfig,
HumanFeedbackResult,
_build_human_feedback_runtime_decorator,
_validate_human_feedback_options,
)
@@ -21,40 +20,6 @@ F = TypeVar("F", bound=Callable[..., Any])
__all__ = ["HumanFeedbackResult", "human_feedback"]
def _stamp_human_feedback_metadata(
wrapper: Any,
func: Callable[..., Any],
config: HumanFeedbackConfig,
) -> None:
for attr in [
"__is_start_method__",
"__trigger_methods__",
"__condition_type__",
"__trigger_condition__",
"__is_flow_method__",
"__flow_persistence_config__",
"__is_router__",
"__router_emit__",
"__flow_method_definition__",
]:
if hasattr(func, attr):
setattr(wrapper, attr, getattr(func, attr))
wrapper.__human_feedback_config__ = config
wrapper.__is_flow_method__ = True
if config.emit:
wrapper.__is_router__ = True
wrapper.__router_emit__ = list(config.emit)
fragment = getattr(wrapper, "__flow_method_definition__", None)
if isinstance(fragment, FlowMethodDefinition):
wrapper.__flow_method_definition__ = fragment.model_copy(
update={"router": True, "emit": list(config.emit)}
)
wrapper._human_feedback_llm = config.llm
def human_feedback(
message: str,
emit: Sequence[str] | None = None,
@@ -66,21 +31,18 @@ def human_feedback(
learn_source: str = "hitl",
learn_strict: bool = False,
) -> Callable[[F], F]:
"""Decorator for Flow methods that require human feedback."""
runtime_decorator = _build_human_feedback_runtime_decorator(
message=message,
emit=emit,
llm=llm,
default_outcome=default_outcome,
metadata=metadata,
provider=provider,
learn=learn,
learn_source=learn_source,
learn_strict=learn_strict,
"""Decorator for Flow methods that require human feedback.
The decorator is a pure metadata stamper: it records the feedback
configuration on the method, and the Flow engine collects and routes
feedback after the method completes, driven by the flow's definition.
"""
_validate_human_feedback_options(
emit=emit, llm=llm, default_outcome=default_outcome
)
config = HumanFeedbackConfig(
message=message,
emit=emit,
emit=list(emit) if emit is not None else None,
llm=llm,
default_outcome=default_outcome,
metadata=metadata,
@@ -91,8 +53,7 @@ def human_feedback(
)
def decorator(func: F) -> F:
wrapper = runtime_decorator(func)
_stamp_human_feedback_metadata(wrapper, func, config)
return wrapper
func.__human_feedback_config__ = config # type: ignore[attr-defined]
return func
return decorator

View File

@@ -3,13 +3,13 @@ from __future__ import annotations
from collections.abc import Callable
from typing import cast
from crewai.flow.dsl._conditions import _definition_condition_from_runtime
from crewai.flow.dsl._conditions import _to_definition_condition
from crewai.flow.dsl._types import FlowMethodDecorator, FlowTrigger
from crewai.flow.dsl._utils import (
P,
R,
_method_action,
_set_flow_method_definition,
_set_trigger_metadata,
)
from crewai.flow.flow_definition import FlowMethodDefinition
from crewai.flow.flow_wrappers import ListenMethod
@@ -47,9 +47,11 @@ def listen(condition: FlowTrigger) -> FlowMethodDecorator:
_set_flow_method_definition(
wrapper,
FlowMethodDefinition(listen=_definition_condition_from_runtime(condition)),
FlowMethodDefinition(
do=_method_action(func),
listen=_to_definition_condition(condition),
),
)
_set_trigger_metadata(wrapper, condition)
return wrapper
return cast(FlowMethodDecorator, decorator)

View File

@@ -14,13 +14,13 @@ from typing import (
get_type_hints,
)
from crewai.flow.dsl._conditions import _definition_condition_from_runtime
from crewai.flow.dsl._conditions import _to_definition_condition
from crewai.flow.dsl._types import FlowMethodDecorator, FlowTrigger
from crewai.flow.dsl._utils import (
P,
R,
_method_action,
_set_flow_method_definition,
_set_trigger_metadata,
)
from crewai.flow.flow_definition import FlowMethodDefinition
from crewai.flow.flow_wrappers import RouterMethod
@@ -149,18 +149,12 @@ def router(
_set_flow_method_definition(
wrapper,
FlowMethodDefinition(
listen=_definition_condition_from_runtime(condition),
do=_method_action(func),
listen=_to_definition_condition(condition),
router=True,
emit=router_events or None,
),
)
_set_trigger_metadata(wrapper, condition)
if emit is not None:
wrapper.__router_emit__ = router_events
elif router_events:
wrapper.__router_emit__ = router_events
return wrapper
return cast(FlowMethodDecorator, decorator)

View File

@@ -3,13 +3,13 @@ from __future__ import annotations
from collections.abc import Callable
from typing import cast
from crewai.flow.dsl._conditions import _definition_condition_from_runtime
from crewai.flow.dsl._conditions import _to_definition_condition
from crewai.flow.dsl._types import FlowMethodDecorator, FlowTrigger
from crewai.flow.dsl._utils import (
P,
R,
_method_action,
_set_flow_method_definition,
_set_trigger_metadata,
)
from crewai.flow.flow_definition import FlowMethodDefinition
from crewai.flow.flow_wrappers import StartMethod
@@ -54,16 +54,17 @@ def start(
def decorator(func: Callable[P, R]) -> StartMethod[P, R]:
wrapper = StartMethod(func)
if condition is not None:
_set_flow_method_definition(
wrapper,
FlowMethodDefinition(
start=_definition_condition_from_runtime(condition)
_set_flow_method_definition(
wrapper,
FlowMethodDefinition(
do=_method_action(func),
start=(
_to_definition_condition(condition)
if condition is not None
else True
),
)
_set_trigger_metadata(wrapper, condition)
else:
_set_flow_method_definition(wrapper, FlowMethodDefinition(start=True))
),
)
return wrapper
return cast(FlowMethodDecorator, decorator)

View File

@@ -1,6 +1,5 @@
from __future__ import annotations
from collections.abc import Sequence
import json
import logging
from typing import Any, ParamSpec, TypeVar
@@ -8,32 +7,23 @@ from typing import Any, ParamSpec, TypeVar
from pydantic import BaseModel
from typing_extensions import TypeIs
from crewai.flow.constants import AND_CONDITION, OR_CONDITION
from crewai.flow.dsl._conditions import (
_definition_condition_from_runtime,
_extract_all_methods,
_method_reference_name,
_runtime_listener_condition_from_definition,
is_flow_condition_dict,
)
from crewai.flow.dsl._types import FlowTrigger
from crewai.flow.flow_definition import (
FlowActionDefinition,
FlowCodeActionDefinition,
FlowConfigDefinition,
FlowConversationalDefinition,
FlowConversationalRouterDefinition,
FlowDefinition,
FlowDefinitionCondition,
FlowDefinitionDiagnostic,
FlowHumanFeedbackDefinition,
FlowMethodDefinition,
FlowPersistenceDefinition,
FlowStateDefinition,
_object_ref,
)
from crewai.flow.flow_wrappers import (
FlowMethod,
ListenMethod,
RouterMethod,
StartMethod,
)
from crewai.flow.types import FlowMethodName
P = ParamSpec("P")
@@ -42,17 +32,17 @@ R = TypeVar("R")
logger = logging.getLogger(__name__)
_FLOW_METHOD_DEFINITION_ATTR = "__flow_method_definition__"
_FLOW_METHOD_METADATA_ATTRS = [
"__conversational_only__",
"__flow_method_definition__",
"__flow_persistence_config__",
"__human_feedback_config__",
]
def is_flow_method(obj: Any) -> TypeIs[FlowMethod[Any, Any]]:
"""Check if the object carries Flow method wrapper metadata."""
return (
hasattr(obj, "__is_flow_method__")
or hasattr(obj, "__is_start_method__")
or hasattr(obj, "__trigger_methods__")
or hasattr(obj, "__is_router__")
or hasattr(obj, _FLOW_METHOD_DEFINITION_ATTR)
)
return hasattr(obj, _FLOW_METHOD_DEFINITION_ATTR)
def _should_include_flow_method(flow_class: type, method: Any) -> bool:
@@ -61,44 +51,44 @@ def _should_include_flow_method(flow_class: type, method: Any) -> bool:
return True
def _flow_method_names(values: Sequence[Any]) -> list[FlowMethodName]:
return [FlowMethodName(str(value)) for value in values]
def _is_conversational_flow(flow_class: type) -> bool:
return bool(getattr(flow_class, "conversational", False))
def _set_trigger_metadata(
wrapper: StartMethod[P, R] | ListenMethod[P, R] | RouterMethod[P, R],
condition: FlowTrigger,
) -> None:
if isinstance(condition, str):
wrapper.__trigger_methods__ = [FlowMethodName(condition)]
wrapper.__condition_type__ = OR_CONDITION
return
def _get_inherited_conversational_method(
flow_class: type,
attr_name: str,
) -> Any | None:
if not _is_conversational_flow(flow_class):
return None
if is_flow_condition_dict(condition):
if "conditions" in condition:
wrapper.__trigger_condition__ = condition
wrapper.__trigger_methods__ = _extract_all_methods(condition)
wrapper.__condition_type__ = condition["type"]
return
if "methods" in condition:
wrapper.__trigger_methods__ = _flow_method_names(condition["methods"])
wrapper.__condition_type__ = condition["type"]
return
raise ValueError("Condition dict must contain 'conditions' or 'methods'")
for base in flow_class.__mro__[1:]:
inherited = base.__dict__.get(attr_name)
if inherited is None:
continue
if getattr(inherited, "__conversational_only__", False) and is_flow_method(
inherited
):
return inherited
return None
method_name = _method_reference_name(condition)
if method_name is not None:
wrapper.__trigger_methods__ = [method_name]
wrapper.__condition_type__ = OR_CONDITION
return
raise ValueError(
"Condition must be a method, string, or a result of or_() or and_()"
)
def _stamp_inherited_conversational_metadata(
method: Any,
inherited: Any,
) -> Any:
for attr in _FLOW_METHOD_METADATA_ATTRS:
if hasattr(inherited, attr):
setattr(method, attr, getattr(inherited, attr))
return method
def _method_action(method: Any) -> FlowActionDefinition:
return FlowCodeActionDefinition(ref=f"{method.__module__}:{method.__qualname__}")
def _set_flow_method_definition(
wrapper: StartMethod[P, R] | ListenMethod[P, R] | RouterMethod[P, R],
wrapper: FlowMethod[P, R],
definition: FlowMethodDefinition,
) -> None:
setattr(wrapper, _FLOW_METHOD_DEFINITION_ATTR, definition)
@@ -113,13 +103,6 @@ def _get_flow_method_definition(method: Any) -> FlowMethodDefinition | None:
return None
def _object_ref(value: Any) -> str:
target = value if isinstance(value, type) else type(value)
module = getattr(target, "__module__", "")
qualname = getattr(target, "__qualname__", getattr(target, "__name__", ""))
return f"{module}:{qualname}" if module and qualname else repr(value)
def _is_json_serializable(value: Any) -> bool:
try:
json.dumps(value)
@@ -190,6 +173,8 @@ def _build_state_definition(
from pydantic import BaseModel as PydanticBaseModel
state_value = getattr(flow_class, "_initial_state_t", None)
if isinstance(state_value, TypeVar):
state_value = None
initial_state = getattr(flow_class, "initial_state", None)
if initial_state is not None:
state_value = initial_state
@@ -225,70 +210,25 @@ def _build_config_definition(
) -> FlowConfigDefinition:
config_field_names = set(FlowConfigDefinition.model_fields)
field_defaults = {
name: field.default
name: field.get_default(call_default_factory=True)
for name, field in getattr(flow_class, "model_fields", {}).items()
if name in config_field_names
}
values: dict[str, Any] = {}
for field_name, default in field_defaults.items():
value = getattr(flow_class, field_name, default)
values[field_name] = _serialize_static_value(
value, diagnostics, f"config.{field_name}"
)
if field_name == "input_provider":
# A string value is already a ref; only live objects degrade.
values[field_name] = (
value if value is None or isinstance(value, str) else _object_ref(value)
)
else:
values[field_name] = _serialize_static_value(
value, diagnostics, f"config.{field_name}"
)
return FlowConfigDefinition(**values)
def _condition_from_method_metadata(method: Any) -> FlowDefinitionCondition | None:
trigger_condition = getattr(method, "__trigger_condition__", None)
if trigger_condition is not None:
return _definition_condition_from_runtime(trigger_condition)
trigger_methods = getattr(method, "__trigger_methods__", None)
if trigger_methods is None:
return None
condition_type = getattr(method, "__condition_type__", OR_CONDITION)
method_names = [str(method_name) for method_name in trigger_methods]
if condition_type == AND_CONDITION:
return {"and": method_names}
if len(method_names) == 1:
return method_names[0]
return {"or": method_names}
def _flow_method_definition_from_legacy_metadata(method: Any) -> FlowMethodDefinition:
is_start = bool(getattr(method, "__is_start_method__", False))
is_router = bool(getattr(method, "__is_router__", False))
condition = _condition_from_method_metadata(method)
if not is_start:
start_value: bool | FlowDefinitionCondition | None = None
elif condition is not None:
start_value = condition
else:
start_value = True
definition = FlowMethodDefinition(
start=start_value,
listen=condition if not is_start else None,
router=is_router,
)
router_emit = getattr(method, "__router_emit__", None)
if router_emit:
definition.emit = [str(value) for value in router_emit]
return definition
def _definition_trigger_condition(
method_definition: FlowMethodDefinition,
) -> FlowDefinitionCondition | None:
if method_definition.listen is not None:
return method_definition.listen
if isinstance(method_definition.start, (str, dict)):
return method_definition.start
return None
def _build_human_feedback_definition(
method: Any,
diagnostics: list[FlowDefinitionDiagnostic],
@@ -301,38 +241,123 @@ def _build_human_feedback_definition(
return FlowHumanFeedbackDefinition(
message=str(config.message),
emit=[str(value) for value in emit] if emit is not None else None,
llm=_serialize_static_value(
getattr(config, "llm", None), diagnostics, f"{path}.llm"
),
# llm and provider stay live: the engine consumes them in-process and
# the contract degrades them to serializable forms at JSON dump time.
llm=getattr(config, "llm", None),
default_outcome=getattr(config, "default_outcome", None),
metadata=_serialize_static_value(
getattr(config, "metadata", None), diagnostics, f"{path}.metadata"
),
provider=_serialize_static_value(
getattr(config, "provider", None), diagnostics, f"{path}.provider"
),
provider=getattr(config, "provider", None),
learn=bool(getattr(config, "learn", False)),
learn_source=str(getattr(config, "learn_source", "hitl")),
learn_strict=bool(getattr(config, "learn_strict", False)),
)
def _build_persistence_definition(
value: Any,
diagnostics: list[FlowDefinitionDiagnostic],
path: str,
) -> FlowPersistenceDefinition | None:
def _build_persistence_definition(value: Any) -> FlowPersistenceDefinition | None:
config = getattr(value, "__flow_persistence_config__", None)
if config is None:
return None
persistence = getattr(config, "persistence", None)
verbose = bool(getattr(config, "verbose", False))
return FlowPersistenceDefinition(
enabled=True,
verbose=verbose,
persistence=_serialize_static_value(
persistence, diagnostics, f"{path}.persistence"
verbose=bool(getattr(config, "verbose", False)),
# The backend stays live: the engine persists through the exact
# instance the user configured; the contract degrades it to a
# serialized config at JSON dump time.
persistence=getattr(config, "persistence", None),
)
def _build_conversational_router_definition(
router_config: Any,
diagnostics: list[FlowDefinitionDiagnostic],
path: str,
) -> FlowConversationalRouterDefinition | None:
if router_config is None:
return None
routes = getattr(router_config, "routes", None)
return FlowConversationalRouterDefinition(
prompt=getattr(router_config, "prompt", None),
response_format=_serialize_static_value(
getattr(router_config, "response_format", None),
diagnostics,
f"{path}.response_format",
),
llm=_serialize_static_value(
getattr(router_config, "llm", None), diagnostics, f"{path}.llm"
),
routes=[str(route) for route in routes] if routes is not None else None,
route_descriptions=getattr(router_config, "route_descriptions", None),
default_intent=getattr(router_config, "default_intent", "converse"),
fallback_intent=getattr(router_config, "fallback_intent", "converse"),
intent_field=str(getattr(router_config, "intent_field", "intent")),
)
def _build_conversational_definition(
flow_class: type,
diagnostics: list[FlowDefinitionDiagnostic],
) -> FlowConversationalDefinition | None:
if not _is_conversational_flow(flow_class):
return None
config = getattr(flow_class, "conversational_config", None)
builtin_routes = getattr(flow_class, "builtin_routes", ("converse", "end"))
internal_routes = getattr(
flow_class,
"internal_routes",
("answer_from_history",),
)
if config is None:
return FlowConversationalDefinition(
enabled=True,
builtin_routes=[str(route) for route in builtin_routes],
internal_routes=[str(route) for route in internal_routes],
)
default_intents = getattr(config, "default_intents", None)
visible_agent_outputs = getattr(config, "visible_agent_outputs", None)
return FlowConversationalDefinition(
enabled=True,
system_prompt=getattr(config, "system_prompt", None),
llm=_serialize_static_value(
getattr(config, "llm", None), diagnostics, "conversational.llm"
),
router=_build_conversational_router_definition(
getattr(config, "router", None),
diagnostics,
"conversational.router",
),
answer_from_history_prompt=getattr(config, "answer_from_history_prompt", None),
default_intents=(
[str(intent) for intent in default_intents]
if default_intents is not None
else None
),
intent_llm=_serialize_static_value(
getattr(config, "intent_llm", None),
diagnostics,
"conversational.intent_llm",
),
answer_from_history_llm=_serialize_static_value(
getattr(config, "answer_from_history_llm", None),
diagnostics,
"conversational.answer_from_history_llm",
),
visible_agent_outputs=(
"all"
if visible_agent_outputs == "all"
else [str(output) for output in visible_agent_outputs]
if visible_agent_outputs is not None
else None
),
defer_trace_finalization=bool(
getattr(config, "defer_trace_finalization", True)
),
builtin_routes=[str(route) for route in builtin_routes],
internal_routes=[str(route) for route in internal_routes],
)
@@ -343,12 +368,11 @@ def _build_method_definition(
) -> FlowMethodDefinition:
fragment = _get_flow_method_definition(method)
if fragment is None:
method_definition = _flow_method_definition_from_legacy_metadata(method)
method_definition = FlowMethodDefinition(do=_method_action(method))
else:
method_definition = fragment.model_copy(deep=True)
if bool(getattr(method, "__is_router__", False)):
method_definition.router = True
method_definition = fragment.model_copy(
deep=True, update={"do": _method_action(method)}
)
human_feedback = _build_human_feedback_definition(
method, diagnostics, f"{path}.human_feedback"
@@ -359,21 +383,14 @@ def _build_method_definition(
method_definition.router = True
method_definition.emit = None
method_definition.persist = _build_persistence_definition(
method, diagnostics, f"{path}.persist"
)
router_emit = getattr(method, "__router_emit__", None)
if router_emit and not (human_feedback and human_feedback.emit):
if not method_definition.emit:
method_definition.emit = [str(value) for value in router_emit]
method_definition.persist = _build_persistence_definition(method)
return method_definition
def _iter_flow_methods(flow_class: type) -> dict[str, Any]:
methods: dict[str, Any] = {}
for attr_name in dir(flow_class):
for attr_name in flow_class.__dict__:
if attr_name.startswith("_"):
continue
try:
@@ -384,6 +401,29 @@ def _iter_flow_methods(flow_class: type) -> dict[str, Any]:
flow_class, attr_value
):
methods[attr_name] = attr_value
continue
inherited = _get_inherited_conversational_method(flow_class, attr_name)
if inherited is not None and callable(attr_value):
methods[attr_name] = _stamp_inherited_conversational_metadata(
attr_value, inherited
)
if _is_conversational_flow(flow_class):
for base in reversed(flow_class.__mro__[1:]):
for attr_name, raw_value in base.__dict__.items():
if attr_name.startswith("_") or attr_name in methods:
continue
if not getattr(raw_value, "__conversational_only__", False):
continue
try:
attr_value = getattr(flow_class, attr_name)
except AttributeError:
continue
if is_flow_method(attr_value) and _should_include_flow_method(
flow_class, attr_value
):
methods[attr_name] = attr_value
# A wrapped method whose name collides with a base Flow model field
# (e.g. ``checkpoint``) is absorbed by Pydantic as a field; the underlying
@@ -427,7 +467,8 @@ def _build_flow_definition_from_class(
description=description,
state=_build_state_definition(flow_class, diagnostics),
config=_build_config_definition(flow_class, diagnostics),
persist=_build_persistence_definition(flow_class, diagnostics, "persist"),
persist=_build_persistence_definition(flow_class),
conversational=_build_conversational_definition(flow_class, diagnostics),
methods=methods,
diagnostics=diagnostics,
)
@@ -442,88 +483,3 @@ def build_flow_definition(
) -> FlowDefinition:
"""Build a FlowDefinition from a Python Flow class."""
return _build_flow_definition_from_class(flow_class, namespace)
def extract_flow_definition(
namespace: dict[str, Any],
) -> tuple[list[str], dict[str, Any], set[str], dict[str, Any]]:
"""Extract the structural flow registries from a Python class namespace."""
start_methods = []
listeners = {}
router_emit = {}
routers = set()
for attr_name, attr_value in namespace.items():
if is_flow_method(attr_value):
method_definition = _get_flow_method_definition(attr_value)
if method_definition is not None:
if method_definition.is_start:
start_methods.append(attr_name)
condition = _definition_trigger_condition(method_definition)
if condition is not None:
listeners[attr_name] = _runtime_listener_condition_from_definition(
condition
)
is_router = method_definition.router or bool(
getattr(attr_value, "__is_router__", False)
)
if is_router:
routers.add(attr_name)
if method_definition.emit:
router_emit[attr_name] = [
str(value) for value in method_definition.emit
]
elif (
hasattr(attr_value, "__router_emit__")
and attr_value.__router_emit__
):
router_emit[attr_name] = attr_value.__router_emit__
else:
router_emit[attr_name] = []
continue
if hasattr(attr_value, "__is_start_method__"):
start_methods.append(attr_name)
if (
hasattr(attr_value, "__trigger_methods__")
and attr_value.__trigger_methods__ is not None
):
methods = attr_value.__trigger_methods__
condition_type = getattr(attr_value, "__condition_type__", OR_CONDITION)
if (
hasattr(attr_value, "__trigger_condition__")
and attr_value.__trigger_condition__ is not None
):
listeners[attr_name] = attr_value.__trigger_condition__
else:
listeners[attr_name] = (condition_type, methods)
if hasattr(attr_value, "__is_router__") and attr_value.__is_router__:
routers.add(attr_name)
if (
hasattr(attr_value, "__router_emit__")
and attr_value.__router_emit__
):
router_emit[attr_name] = attr_value.__router_emit__
else:
router_emit[attr_name] = []
if (
hasattr(attr_value, "__is_start_method__")
and hasattr(attr_value, "__is_router__")
and attr_value.__is_router__
):
routers.add(attr_name)
if (
hasattr(attr_value, "__router_emit__")
and attr_value.__router_emit__
):
router_emit[attr_name] = attr_value.__router_emit__
else:
router_emit[attr_name] = []
return start_methods, listeners, routers, router_emit

View File

@@ -6,15 +6,22 @@ The implementation now lives in three modules, split by concern:
``@router``, ``or_`` / ``and_``) and Python Flow class projection
- ``crewai.flow.flow_definition`` -- the serializable Flow Definition contract
- ``crewai.flow.runtime`` -- the Flow execution engine and state
- ``crewai.experimental.conversational_mixin`` -- experimental conversational
runtime extension composed onto the public ``Flow`` class
Prefer importing from those modules in new code; this module preserves the
historical ``crewai.flow.flow`` import path.
"""
from typing import Any, TypeVar
from pydantic import BaseModel
from crewai.experimental.conversational_mixin import _ConversationalMixin
from crewai.flow.dsl import and_, listen, or_, router, start
from crewai.flow.runtime import (
_INITIAL_STATE_CLASS_MARKER,
Flow,
Flow as RuntimeFlow,
FlowMeta,
FlowState,
LockedDictProxy,
@@ -23,6 +30,13 @@ from crewai.flow.runtime import (
)
T = TypeVar("T", bound=dict[str, Any] | BaseModel)
class Flow(_ConversationalMixin, RuntimeFlow[T]):
"""Public Flow class with experimental conversational extension behavior."""
__all__ = [
"_INITIAL_STATE_CLASS_MARKER",
"Flow",

View File

@@ -15,6 +15,10 @@ current_flow_id: contextvars.ContextVar[str | None] = contextvars.ContextVar(
"flow_id", default=None
)
current_flow_defer_trace_finalization: contextvars.ContextVar[bool] = (
contextvars.ContextVar("flow_defer_trace_finalization", default=False)
)
current_flow_method_name: contextvars.ContextVar[str] = contextvars.ContextVar(
"flow_method_name", default="unknown"
)

View File

@@ -11,28 +11,58 @@ from __future__ import annotations
import json
import logging
import re
from typing import Any, Literal as TypingLiteral
from pydantic import BaseModel, ConfigDict, Field
from pydantic import (
BaseModel,
ConfigDict,
Field,
RootModel,
field_serializer,
model_validator,
)
import yaml
from crewai.flow.conversational_definition import (
FlowConversationalDefinition,
FlowConversationalRouterDefinition,
)
logger = logging.getLogger(__name__)
FlowDefinitionCondition = str | dict[str, Any]
_STEP_NAME_PATTERN = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
__all__ = [
"FlowActionDefinition",
"FlowCodeActionDefinition",
"FlowConfigDefinition",
"FlowConversationalDefinition",
"FlowConversationalRouterDefinition",
"FlowDefinition",
"FlowDefinitionCondition",
"FlowDefinitionDiagnostic",
"FlowEachActionDefinition",
"FlowEachInnerActionDefinition",
"FlowExpressionActionDefinition",
"FlowHumanFeedbackDefinition",
"FlowMethodDefinition",
"FlowPersistenceDefinition",
"FlowStateDefinition",
"FlowToolActionDefinition",
]
def _object_ref(value: Any) -> str:
"""Format a class or instance as the canonical ``module:qualname`` ref."""
target = value if isinstance(value, type) else type(value)
module = getattr(target, "__module__", "")
qualname = getattr(target, "__qualname__", getattr(target, "__name__", ""))
return f"{module}:{qualname}" if module and qualname else repr(value)
class FlowDefinitionDiagnostic(BaseModel):
"""A non-fatal Flow Definition build or validation diagnostic."""
@@ -45,9 +75,10 @@ class FlowDefinitionDiagnostic(BaseModel):
class FlowStateDefinition(BaseModel):
"""Static description of a Flow state contract."""
type: TypingLiteral["dict", "pydantic", "unknown"] = "dict"
type: TypingLiteral["dict", "pydantic", "json_schema", "unknown"] = "dict"
ref: str | None = None
default: Any = None
json_schema: dict[str, Any] | None = None
default: dict[str, Any] | None = None
class FlowConfigDefinition(BaseModel):
@@ -55,22 +86,50 @@ class FlowConfigDefinition(BaseModel):
tracing: bool | None = None
stream: bool = False
memory: Any = None
input_provider: Any = None
memory: dict[str, Any] | None = None
input_provider: str | None = None
suppress_flow_events: bool = False
max_method_calls: int = 100
defer_trace_finalization: bool = False
checkpoint: bool | dict[str, Any] | None = None
class FlowPersistenceDefinition(BaseModel):
"""Static persistence configuration."""
"""Static persistence configuration.
``persistence`` may hold a live backend when the definition is built from
a decorated class — the engine then persists through the exact instance
the user configured; the JSON/YAML projection degrades it to its
serialized config.
"""
enabled: bool = False
verbose: bool = False
persistence: Any = None
@field_serializer("persistence", when_used="json")
def _serialize_persistence(self, value: Any) -> Any:
if value is None or isinstance(value, dict):
return value
if isinstance(value, BaseModel):
try:
return value.model_dump(mode="json")
except Exception:
logger.warning(
"Persistence backend %s is not fully serializable; "
"preserved import reference only.",
_object_ref(value),
)
return {"ref": _object_ref(value)}
class FlowHumanFeedbackDefinition(BaseModel):
"""Static human feedback configuration."""
"""Static human feedback configuration.
``llm`` and ``provider`` may hold live Python objects when the definition
is built from a decorated class; the JSON/YAML projection degrades them to
a serialized config (``llm``) or a ``module:qualname`` ref (``provider``).
"""
message: str
emit: list[str] | None = None
@@ -82,10 +141,120 @@ class FlowHumanFeedbackDefinition(BaseModel):
learn_source: str = "hitl"
learn_strict: bool = False
@field_serializer("llm", when_used="json")
def _serialize_llm(self, value: Any) -> dict[str, Any] | str | None:
if value is None or isinstance(value, (str, dict)):
return value
from crewai.flow.human_feedback import _serialize_llm_for_context
return _serialize_llm_for_context(value)
@field_serializer("provider", when_used="json")
def _serialize_provider(self, value: Any) -> str | None:
if value is None or isinstance(value, str):
return value
return _object_ref(value)
class FlowCodeActionDefinition(BaseModel):
"""A Flow method action that executes importable Python code."""
model_config = ConfigDict(populate_by_name=True, extra="forbid")
call: TypingLiteral["code"] = "code"
ref: str
with_: dict[str, Any] | None = Field(default=None, alias="with")
class FlowToolActionDefinition(BaseModel):
"""A Flow method action that invokes a CrewAI tool."""
model_config = ConfigDict(populate_by_name=True, extra="forbid")
call: TypingLiteral["tool"]
ref: str
with_: dict[str, Any] | None = Field(default=None, alias="with")
class FlowExpressionActionDefinition(BaseModel):
"""A Flow method action that evaluates a CEL expression."""
model_config = ConfigDict(extra="forbid")
call: TypingLiteral["expression"]
expr: str
FlowInnerActionDefinition = (
FlowCodeActionDefinition | FlowToolActionDefinition | FlowExpressionActionDefinition
)
class FlowEachInnerActionDefinition(RootModel[dict[str, FlowInnerActionDefinition]]):
"""One named action inside an ``each`` composite action."""
@property
def name(self) -> str:
return next(iter(self.root))
@property
def action(self) -> FlowInnerActionDefinition:
return next(iter(self.root.values()))
class FlowEachActionDefinition(BaseModel):
"""A composite action that runs a sequential mini-pipeline for each item."""
model_config = ConfigDict(populate_by_name=True, extra="forbid")
call: TypingLiteral["each"]
in_: str = Field(alias="in")
do: list[FlowEachInnerActionDefinition]
@model_validator(mode="before")
@classmethod
def _validate_inner_action_list(cls, data: Any) -> Any:
if not isinstance(data, dict) or "do" not in data:
return data
inner_actions = data["do"]
if not isinstance(inner_actions, list) or not inner_actions:
raise ValueError("each.do must contain at least one action")
seen: set[str] = set()
for inner_action in inner_actions:
if isinstance(inner_action, FlowEachInnerActionDefinition):
action_mapping = inner_action.root
elif isinstance(inner_action, dict):
action_mapping = inner_action
else:
raise ValueError("each.do entries must be one-key mappings")
if len(action_mapping) != 1:
raise ValueError("each.do entries must be one-key mappings")
name = next(iter(action_mapping))
_validate_step_name(name, field="each.do action names")
if name in seen:
raise ValueError(f"each.do action names must be unique: {name!r}")
seen.add(name)
return data
FlowActionDefinition = (
FlowCodeActionDefinition
| FlowToolActionDefinition
| FlowExpressionActionDefinition
| FlowEachActionDefinition
)
class FlowMethodDefinition(BaseModel):
"""Static definition of one Flow method and its execution roles."""
description: str | None = None
do: FlowActionDefinition
start: bool | FlowDefinitionCondition | None = None
listen: FlowDefinitionCondition | None = None
router: bool = False
@@ -93,6 +262,16 @@ class FlowMethodDefinition(BaseModel):
human_feedback: FlowHumanFeedbackDefinition | None = None
persist: FlowPersistenceDefinition | None = None
@model_validator(mode="after")
def _canonicalize_human_feedback_routing(self) -> FlowMethodDefinition:
# Canonical shape: a method whose human_feedback declares emit
# outcomes routes like a router, regardless of how the definition
# was authored.
if self.human_feedback is not None and self.human_feedback.emit:
self.router = True
self.emit = None
return self
@property
def is_start(self) -> bool:
"""Whether this method is a start method.
@@ -109,15 +288,24 @@ class FlowDefinition(BaseModel):
model_config = ConfigDict(populate_by_name=True, arbitrary_types_allowed=True)
schema_: str = Field(default="crewai.flow/v1", alias="schema")
schema_: TypingLiteral["crewai.flow/v1"] = Field(
default="crewai.flow/v1", alias="schema"
)
name: str
description: str | None = None
state: FlowStateDefinition | None = None
config: FlowConfigDefinition = Field(default_factory=FlowConfigDefinition)
persist: FlowPersistenceDefinition | None = None
conversational: FlowConversationalDefinition | None = None
methods: dict[str, FlowMethodDefinition] = Field(default_factory=dict)
diagnostics: list[FlowDefinitionDiagnostic] = Field(default_factory=list)
@model_validator(mode="after")
def _validate_method_names(self) -> FlowDefinition:
for method_name in self.methods:
_validate_step_name(method_name, field="Flow method names")
return self
def to_dict(self, *, exclude_none: bool = True) -> dict[str, Any]:
"""Serialize the definition to a JSON/YAML-ready dictionary."""
return self.model_dump(by_alias=True, exclude_none=exclude_none, mode="json")
@@ -260,6 +448,11 @@ def _deserialize_diagnostics(value: Any) -> list[FlowDefinitionDiagnostic]:
return [FlowDefinitionDiagnostic.model_validate(item) for item in value or []]
def _validate_step_name(name: str, *, field: str) -> None:
if not isinstance(name, str) or not _STEP_NAME_PATTERN.fullmatch(name):
raise ValueError(f"{field} must match {_STEP_NAME_PATTERN.pattern}")
def _merge_diagnostics(
*diagnostic_groups: list[FlowDefinitionDiagnostic],
) -> list[FlowDefinitionDiagnostic]:

View File

@@ -16,7 +16,6 @@ P = ParamSpec("P")
R = TypeVar("R")
FlowConditionType: TypeAlias = Literal["OR", "AND"]
SimpleFlowCondition: TypeAlias = tuple[FlowConditionType, list[FlowMethodName]]
__all__ = [
"FlowCondition",
@@ -25,7 +24,6 @@ __all__ = [
"FlowMethod",
"ListenMethod",
"RouterMethod",
"SimpleFlowCondition",
"StartMethod",
]
@@ -38,15 +36,13 @@ class FlowCondition(TypedDict, total=False):
Attributes:
type: The type of the condition.
conditions: A sequence of route labels, method names, or nested conditions.
methods: A legacy sequence of route labels or method names.
"""
type: Required[FlowConditionType]
conditions: Sequence[str | FlowMethodName | FlowCondition]
methods: Sequence[str | FlowMethodName]
conditions: Sequence[str | FlowCondition]
FlowConditions: TypeAlias = Sequence[str | FlowMethodName | FlowCondition]
FlowConditions: TypeAlias = Sequence[str | FlowCondition]
class FlowMethod(Generic[P, R]):
@@ -83,13 +79,10 @@ class FlowMethod(Generic[P, R]):
# Preserve flow-related attributes from wrapped method (e.g., from @human_feedback)
for attr in [
"__is_router__",
"__router_emit__",
"__human_feedback_config__",
"__conversational_only__", # gates registration on Flow.conversational
"__flow_persistence_config__",
"__flow_method_definition__",
"_human_feedback_llm", # Live LLM object for HITL resume
]:
if hasattr(meth, attr):
setattr(self, attr, getattr(meth, attr))
@@ -158,25 +151,10 @@ class FlowMethod(Generic[P, R]):
class StartMethod(FlowMethod[P, R]):
"""Wrapper for methods marked as flow start points."""
__is_start_method__: bool = True
__trigger_methods__: list[FlowMethodName] | None = None
__condition_type__: FlowConditionType | None = None
__trigger_condition__: FlowCondition | None = None
class ListenMethod(FlowMethod[P, R]):
"""Wrapper for methods marked as flow listeners."""
__trigger_methods__: list[FlowMethodName] | None = None
__condition_type__: FlowConditionType | None = None
__trigger_condition__: FlowCondition | None = None
class RouterMethod(FlowMethod[P, R]):
"""Wrapper for methods marked as flow routers."""
__is_router__: bool = True
__trigger_methods__: list[FlowMethodName] | None = None
__condition_type__: FlowConditionType | None = None
__trigger_condition__: FlowCondition | None = None
__router_emit__: list[str] | None = None

View File

@@ -1,8 +1,11 @@
"""Human feedback decorator for Flow methods.
"""Human feedback support for Flow methods.
This module provides the @human_feedback decorator that enables human-in-the-loop
workflows within CrewAI Flows. It allows collecting human feedback on method outputs
and optionally routing to different listeners based on the feedback.
This module backs the @human_feedback decorator that enables human-in-the-loop
workflows within CrewAI Flows. The decorator is a pure metadata stamper: it
records a :class:`HumanFeedbackConfig` on the method, the Flow definition
builder lifts it into ``FlowHumanFeedbackDefinition``, and the Flow engine
collects feedback after each decorated method completes, driven by the flow's
definition.
Supports both synchronous (blocking) and asynchronous (non-blocking) feedback
collection through the provider parameter.
@@ -55,22 +58,18 @@ Example (asynchronous with custom provider):
from __future__ import annotations
import asyncio
from collections.abc import Callable, Sequence
from dataclasses import dataclass, field
from datetime import datetime
from functools import wraps
import logging
from typing import TYPE_CHECKING, Any, TypeVar
from pydantic import BaseModel, Field
from crewai.flow.flow_wrappers import FlowMethod
if TYPE_CHECKING:
from crewai.flow.async_feedback.types import HumanFeedbackProvider
from crewai.flow.flow import Flow
from crewai.flow.runtime import Flow
from crewai.llms.base_llm import BaseLLM
@@ -160,8 +159,8 @@ class HumanFeedbackResult:
class HumanFeedbackConfig:
"""Configuration for the @human_feedback decorator.
Stores the parameters passed to the decorator for later use during
method execution and for introspection by visualization tools.
Stores the parameters passed to the decorator for later use by the
Flow definition builder and for introspection by visualization tools.
Attributes:
message: The message shown to the human when requesting feedback.
@@ -183,23 +182,6 @@ class HumanFeedbackConfig:
learn_strict: bool = False
class HumanFeedbackMethod(FlowMethod[Any, Any]):
"""Wrapper for methods decorated with @human_feedback.
This wrapper extends FlowMethod to add human feedback specific attributes
that are used by FlowMeta for routing and by visualization tools.
Attributes:
__is_router__: True when emit is specified, enabling router behavior.
__router_emit__: List of possible outcomes when acting as a router.
__human_feedback_config__: The HumanFeedbackConfig for this method.
"""
__is_router__: bool = False
__router_emit__: list[str] | None = None
__human_feedback_config__: HumanFeedbackConfig | None = None
class PreReviewResult(BaseModel):
"""Structured output from the HITL pre-review LLM call."""
@@ -221,17 +203,11 @@ class DistilledLessons(BaseModel):
)
def _build_human_feedback_runtime_decorator(
message: str,
emit: Sequence[str] | None = None,
llm: str | BaseLLM | None = "gpt-4o-mini",
default_outcome: str | None = None,
metadata: dict[str, Any] | None = None,
provider: HumanFeedbackProvider | None = None,
learn: bool = False,
learn_source: str = "hitl",
learn_strict: bool = False,
) -> Callable[[F], F]:
def _validate_human_feedback_options(
emit: Sequence[str] | None,
llm: Any,
default_outcome: str | None,
) -> None:
if emit is not None:
if not llm:
raise ValueError(
@@ -248,295 +224,139 @@ def _build_human_feedback_runtime_decorator(
elif default_outcome is not None:
raise ValueError("default_outcome requires emit to be specified.")
def decorator(func: F) -> F:
def _get_hitl_prompt(key: str) -> str:
from crewai.utilities.i18n import I18N_DEFAULT
return I18N_DEFAULT.slice(key)
def _get_hitl_prompt(key: str) -> str:
from crewai.utilities.i18n import I18N_DEFAULT
def _resolve_llm_instance() -> Any:
if llm is None:
from crewai.llm import LLM
return I18N_DEFAULT.slice(key)
return LLM(model="gpt-4o-mini")
if isinstance(llm, str):
from crewai.llm import LLM
return LLM(model=llm)
return llm # already a BaseLLM instance
def _resolve_llm_instance(llm: Any) -> Any:
from crewai.llm import LLM
def _pre_review_with_lessons(
flow_instance: Flow[Any], method_output: Any
) -> Any:
try:
mem = flow_instance.memory
if mem is None:
return method_output
query = f"human feedback lessons for {func.__name__}: {method_output!s}"
matches = mem.recall(query, source=learn_source)
if not matches:
return method_output
if llm is None:
return LLM(model="gpt-4o-mini")
if isinstance(llm, str):
return LLM(model=llm)
if isinstance(llm, dict):
deserialized = _deserialize_llm_from_context(llm)
return deserialized if deserialized is not None else LLM(model="gpt-4o-mini")
return llm # already a BaseLLM instance
lessons = "\n".join(f"- {m.record.content}" for m in matches)
llm_inst = _resolve_llm_instance()
prompt = _get_hitl_prompt("hitl_pre_review_user").format(
output=str(method_output),
lessons=lessons,
)
messages = [
{
"role": "system",
"content": _get_hitl_prompt("hitl_pre_review_system"),
},
{"role": "user", "content": prompt},
]
if getattr(llm_inst, "supports_function_calling", lambda: False)():
response = llm_inst.call(messages, response_model=PreReviewResult)
if isinstance(response, PreReviewResult):
return response.improved_output
return PreReviewResult.model_validate(response).improved_output
reviewed = llm_inst.call(messages)
return reviewed if isinstance(reviewed, str) else str(reviewed)
except Exception:
if learn_strict:
logger.warning(
"HITL pre-review failed for %s; re-raising (learn_strict=True)",
func.__name__,
exc_info=True,
)
raise
logger.warning(
"HITL pre-review failed for %s; falling back to raw output",
func.__name__,
exc_info=True,
)
return method_output
def _distill_and_store_lessons(
flow_instance: Flow[Any], method_output: Any, raw_feedback: str
) -> None:
try:
mem = flow_instance.memory
if mem is None:
return
llm_inst = _resolve_llm_instance()
prompt = _get_hitl_prompt("hitl_distill_user").format(
method_name=func.__name__,
output=str(method_output),
feedback=raw_feedback,
)
messages = [
{
"role": "system",
"content": _get_hitl_prompt("hitl_distill_system"),
},
{"role": "user", "content": prompt},
]
def _pre_review_with_lessons(
flow_instance: Flow[Any],
method_name: str,
method_output: Any,
*,
llm: Any,
learn_source: str,
learn_strict: bool,
) -> Any:
try:
mem = flow_instance.memory
if mem is None:
return method_output
query = f"human feedback lessons for {method_name}: {method_output!s}"
matches = mem.recall(query, source=learn_source)
if not matches:
return method_output
lessons: list[str] = []
if getattr(llm_inst, "supports_function_calling", lambda: False)():
response = llm_inst.call(messages, response_model=DistilledLessons)
if isinstance(response, DistilledLessons):
lessons = response.lessons
else:
lessons = DistilledLessons.model_validate(response).lessons
else:
response = llm_inst.call(messages)
if isinstance(response, str):
lessons = [
line.strip("- ").strip()
for line in response.strip().split("\n")
if line.strip() and line.strip() != "NONE"
]
if lessons:
mem.remember_many(lessons, source=learn_source) # type: ignore[union-attr]
except Exception:
if learn_strict:
logger.warning(
"HITL lesson distillation failed for %s; re-raising (learn_strict=True)",
func.__name__,
exc_info=True,
)
raise
logger.warning(
"HITL lesson distillation failed for %s; no lessons stored",
func.__name__,
exc_info=True,
)
def _build_feedback_context(
flow_instance: Flow[Any], method_output: Any
) -> tuple[Any, Any]:
from crewai.flow.async_feedback.types import PendingFeedbackContext
context = PendingFeedbackContext(
flow_id=flow_instance.flow_id or "unknown",
flow_class=f"{flow_instance.__class__.__module__}.{flow_instance.__class__.__name__}",
method_name=func.__name__,
method_output=method_output,
message=message,
emit=list(emit) if emit else None,
default_outcome=default_outcome,
metadata=metadata or {},
llm=llm if isinstance(llm, str) else _serialize_llm_for_context(llm),
lessons = "\n".join(f"- {m.record.content}" for m in matches)
llm_inst = _resolve_llm_instance(llm)
prompt = _get_hitl_prompt("hitl_pre_review_user").format(
output=str(method_output),
lessons=lessons,
)
messages = [
{
"role": "system",
"content": _get_hitl_prompt("hitl_pre_review_system"),
},
{"role": "user", "content": prompt},
]
if getattr(llm_inst, "supports_function_calling", lambda: False)():
response = llm_inst.call(messages, response_model=PreReviewResult)
if isinstance(response, PreReviewResult):
return response.improved_output
return PreReviewResult.model_validate(response).improved_output
reviewed = llm_inst.call(messages)
return reviewed if isinstance(reviewed, str) else str(reviewed)
except Exception:
if learn_strict:
logger.warning(
"HITL pre-review failed for %s; re-raising (learn_strict=True)",
method_name,
exc_info=True,
)
raise
logger.warning(
"HITL pre-review failed for %s; falling back to raw output",
method_name,
exc_info=True,
)
return method_output
effective_provider = provider
if effective_provider is None:
from crewai.flow.flow_config import flow_config
effective_provider = flow_config.hitl_provider
def _distill_and_store_lessons(
flow_instance: Flow[Any],
method_name: str,
method_output: Any,
raw_feedback: str,
*,
llm: Any,
learn_source: str,
learn_strict: bool,
) -> None:
try:
mem = flow_instance.memory
if mem is None:
return
llm_inst = _resolve_llm_instance(llm)
prompt = _get_hitl_prompt("hitl_distill_user").format(
method_name=method_name,
output=str(method_output),
feedback=raw_feedback,
)
messages = [
{
"role": "system",
"content": _get_hitl_prompt("hitl_distill_system"),
},
{"role": "user", "content": prompt},
]
return context, effective_provider
def _request_feedback(flow_instance: Flow[Any], method_output: Any) -> str:
context, effective_provider = _build_feedback_context(
flow_instance, method_output
)
if effective_provider is not None:
feedback_result = effective_provider.request_feedback(
context, flow_instance
)
if asyncio.iscoroutine(feedback_result):
raise TypeError(
f"Provider {type(effective_provider).__name__}.request_feedback() "
"returned a coroutine in a sync flow method. Use an async flow "
"method or a synchronous provider."
)
return str(feedback_result)
return flow_instance._request_human_feedback(
message=message,
output=method_output,
metadata=metadata,
emit=emit,
)
async def _request_feedback_async(
flow_instance: Flow[Any], method_output: Any
) -> str:
context, effective_provider = _build_feedback_context(
flow_instance, method_output
)
if effective_provider is not None:
feedback_result = effective_provider.request_feedback(
context, flow_instance
)
if asyncio.iscoroutine(feedback_result):
return str(await feedback_result)
return str(feedback_result)
return flow_instance._request_human_feedback(
message=message,
output=method_output,
metadata=metadata,
emit=emit,
)
def _process_feedback(
flow_instance: Flow[Any],
method_output: Any,
raw_feedback: str,
) -> HumanFeedbackResult | str:
collapsed_outcome: str | None = None
if not raw_feedback.strip():
if default_outcome:
collapsed_outcome = default_outcome
elif emit:
collapsed_outcome = emit[0]
elif emit:
if llm is not None:
collapsed_outcome = flow_instance._collapse_to_outcome(
feedback=raw_feedback,
outcomes=emit,
llm=llm,
)
else:
collapsed_outcome = emit[0]
result = HumanFeedbackResult(
output=method_output,
feedback=raw_feedback,
outcome=collapsed_outcome,
timestamp=datetime.now(),
method_name=func.__name__,
metadata=metadata or {},
)
flow_instance.human_feedback_history.append(result)
flow_instance.last_human_feedback = result
if emit:
if collapsed_outcome is None:
collapsed_outcome = default_outcome or emit[0]
result.outcome = collapsed_outcome
return collapsed_outcome
return result
if asyncio.iscoroutinefunction(func):
@wraps(func)
async def async_wrapper(self: Flow[Any], *args: Any, **kwargs: Any) -> Any:
method_output = await func(self, *args, **kwargs)
if learn and getattr(self, "memory", None) is not None:
method_output = _pre_review_with_lessons(self, method_output)
raw_feedback = await _request_feedback_async(self, method_output)
result = _process_feedback(self, method_output, raw_feedback)
if (
learn
and getattr(self, "memory", None) is not None
and raw_feedback.strip()
):
_distill_and_store_lessons(self, method_output, raw_feedback)
# Stash the real method output for final flow result when emit is set:
# result is the collapsed outcome string for routing, but we preserve the
# actual method output as the flow's final result. Uses per-method dict for
# concurrency safety and to handle None returns.
if emit:
self._human_feedback_method_outputs[func.__name__] = method_output
return result
wrapper: Any = async_wrapper
lessons: list[str] = []
if getattr(llm_inst, "supports_function_calling", lambda: False)():
response = llm_inst.call(messages, response_model=DistilledLessons)
if isinstance(response, DistilledLessons):
lessons = response.lessons
else:
lessons = DistilledLessons.model_validate(response).lessons
else:
response = llm_inst.call(messages)
if isinstance(response, str):
lessons = [
line.strip("- ").strip()
for line in response.strip().split("\n")
if line.strip() and line.strip() != "NONE"
]
@wraps(func)
def sync_wrapper(self: Flow[Any], *args: Any, **kwargs: Any) -> Any:
method_output = func(self, *args, **kwargs)
if learn and getattr(self, "memory", None) is not None:
method_output = _pre_review_with_lessons(self, method_output)
raw_feedback = _request_feedback(self, method_output)
result = _process_feedback(self, method_output, raw_feedback)
if (
learn
and getattr(self, "memory", None) is not None
and raw_feedback.strip()
):
_distill_and_store_lessons(self, method_output, raw_feedback)
# Stash the real method output for final flow result when emit is set:
# result is the collapsed outcome string for routing, but we preserve the
# actual method output as the flow's final result. Uses per-method dict for
# concurrency safety and to handle None returns.
if emit:
self._human_feedback_method_outputs[func.__name__] = method_output
return result
wrapper = sync_wrapper
return wrapper # type: ignore[no-any-return]
return decorator
if lessons:
mem.remember_many(lessons, source=learn_source) # type: ignore[union-attr]
except Exception:
if learn_strict:
logger.warning(
"HITL lesson distillation failed for %s; re-raising (learn_strict=True)",
method_name,
exc_info=True,
)
raise
logger.warning(
"HITL lesson distillation failed for %s; no lessons stored",
method_name,
exc_info=True,
)
def human_feedback(

View File

@@ -24,22 +24,20 @@ Example:
from __future__ import annotations
import asyncio
from collections.abc import Callable
import functools
import logging
from types import SimpleNamespace
from typing import TYPE_CHECKING, Any, Final, TypeVar, cast
from typing import TYPE_CHECKING, Any, Final, TypeVar
from crewai_core.printer import PRINTER
from pydantic import BaseModel
from crewai.flow.persistence.base import FlowPersistence
from crewai.flow.persistence.sqlite import SQLiteFlowPersistence
from crewai.flow.persistence.factory import default_flow_persistence
if TYPE_CHECKING:
from crewai.flow.flow import Flow
from crewai.flow.runtime import Flow
logger = logging.getLogger(__name__)
@@ -66,20 +64,6 @@ def _stamp_persistence_metadata(
)
_PRESERVED_FLOW_ATTRS: Final[tuple[str, ...]] = (
"__is_start_method__",
"__trigger_methods__",
"__condition_type__",
"__trigger_condition__",
"__is_router__",
"__router_emit__",
"__human_feedback_config__",
"__flow_persistence_config__",
"__flow_method_definition__",
"_human_feedback_llm",
)
class PersistenceDecorator:
"""Class to handle flow state persistence with consistent logging."""
@@ -170,9 +154,15 @@ def persist(
states. When applied at the method level, it persists only that method's
state.
The decorator is a pure metadata stamper: it records the persistence
configuration on the class or method, and the Flow engine saves state
after each persisted method completes, driven by the flow's definition.
Args:
persistence: Optional FlowPersistence implementation to use.
If not provided, uses SQLiteFlowPersistence.
If not provided, uses ``default_flow_persistence()`` (the
registered factory when present, else the built-in SQLite
fallback).
verbose: Whether to log persistence operations. Defaults to False.
Returns:
@@ -191,127 +181,11 @@ def persist(
"""
def decorator(target: type | Callable[..., T]) -> type | Callable[..., T]:
actual_persistence = persistence or SQLiteFlowPersistence()
actual_persistence = (
persistence if persistence is not None else default_flow_persistence()
)
if isinstance(target, type):
_stamp_persistence_metadata(target, actual_persistence, verbose)
original_init = target.__init__ # type: ignore[misc]
@functools.wraps(original_init)
def new_init(self: Any, *args: Any, **kwargs: Any) -> None:
if "persistence" not in kwargs:
kwargs["persistence"] = actual_persistence
original_init(self, *args, **kwargs)
target.__init__ = new_init # type: ignore[misc]
# Preserve original methods' decorators
original_methods = {
name: method
for name, method in target.__dict__.items()
if callable(method)
and (
hasattr(method, "__is_start_method__")
or hasattr(method, "__trigger_methods__")
or hasattr(method, "__condition_type__")
or hasattr(method, "__is_flow_method__")
or hasattr(method, "__is_router__")
)
}
for name, method in original_methods.items():
if asyncio.iscoroutinefunction(method):
# Closure captures the current name and method
def create_async_wrapper(
method_name: str, original_method: Callable[..., Any]
) -> Callable[..., Any]:
@functools.wraps(original_method)
async def method_wrapper(
self: Any, *args: Any, **kwargs: Any
) -> Any:
result = await original_method(self, *args, **kwargs)
PersistenceDecorator.persist_state(
self, method_name, actual_persistence, verbose
)
return result
return method_wrapper
wrapped = create_async_wrapper(name, method)
for attr in _PRESERVED_FLOW_ATTRS:
if hasattr(method, attr):
setattr(wrapped, attr, getattr(method, attr))
wrapped.__is_flow_method__ = True # type: ignore[attr-defined]
setattr(target, name, wrapped)
else:
def create_sync_wrapper(
method_name: str, original_method: Callable[..., Any]
) -> Callable[..., Any]:
@functools.wraps(original_method)
def method_wrapper(self: Any, *args: Any, **kwargs: Any) -> Any:
result = original_method(self, *args, **kwargs)
PersistenceDecorator.persist_state(
self, method_name, actual_persistence, verbose
)
return result
return method_wrapper
wrapped = create_sync_wrapper(name, method)
for attr in _PRESERVED_FLOW_ATTRS:
if hasattr(method, attr):
setattr(wrapped, attr, getattr(method, attr))
wrapped.__is_flow_method__ = True # type: ignore[attr-defined]
setattr(target, name, wrapped)
return target
method = target
method.__is_flow_method__ = True # type: ignore[attr-defined]
_stamp_persistence_metadata(method, actual_persistence, verbose)
if asyncio.iscoroutinefunction(method):
@functools.wraps(method)
async def method_async_wrapper(
flow_instance: Any, *args: Any, **kwargs: Any
) -> T:
method_coro = method(flow_instance, *args, **kwargs)
if asyncio.iscoroutine(method_coro):
result = await method_coro
else:
result = method_coro
PersistenceDecorator.persist_state(
flow_instance, method.__name__, actual_persistence, verbose
)
return cast(T, result)
for attr in _PRESERVED_FLOW_ATTRS:
if hasattr(method, attr):
setattr(method_async_wrapper, attr, getattr(method, attr))
method_async_wrapper.__is_flow_method__ = True # type: ignore[attr-defined]
_stamp_persistence_metadata(
method_async_wrapper, actual_persistence, verbose
)
return cast(Callable[..., T], method_async_wrapper)
@functools.wraps(method)
def method_sync_wrapper(flow_instance: Any, *args: Any, **kwargs: Any) -> T:
result = method(flow_instance, *args, **kwargs)
PersistenceDecorator.persist_state(
flow_instance, method.__name__, actual_persistence, verbose
)
return result
for attr in _PRESERVED_FLOW_ATTRS:
if hasattr(method, attr):
setattr(method_sync_wrapper, attr, getattr(method, attr))
method_sync_wrapper.__is_flow_method__ = True # type: ignore[attr-defined]
_stamp_persistence_metadata(method_sync_wrapper, actual_persistence, verbose)
return cast(Callable[..., T], method_sync_wrapper)
_stamp_persistence_metadata(target, actual_persistence, verbose)
return target
return decorator

View File

@@ -0,0 +1,60 @@
"""Pluggable default persistence backend for flows.
By default, ``@persist`` and the flow runtime persist state with
:class:`~crewai.flow.persistence.sqlite.SQLiteFlowPersistence` when no explicit
``persistence=`` is given. Registering a factory via
:func:`set_flow_persistence_factory` lets an application back flow state with a
custom :class:`~crewai.flow.persistence.base.FlowPersistence` -- a database, a
remote service, an in-memory fake for tests -- without passing a
``persistence=`` instance at every ``@persist`` / kickoff site.
This mirrors :func:`crewai_core.lock_store.set_lock_backend`: a one-time,
process-wide setter intended for application startup. Pass ``None`` to restore
the built-in SQLite default. Call :func:`default_flow_persistence` to build the
default backend (the registered factory if any, else SQLite).
"""
from __future__ import annotations
from collections.abc import Callable
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from crewai.flow.persistence.base import FlowPersistence
FlowPersistenceFactory = Callable[[], "FlowPersistence"]
_factory: FlowPersistenceFactory | None = None
def set_flow_persistence_factory(factory: FlowPersistenceFactory | None) -> None:
"""Replace the process-wide default flow persistence factory.
Intended for one-time setup at startup. Pass ``None`` to restore the
built-in ``SQLiteFlowPersistence``. Only affects flows that fall back to
the default; an explicit ``persistence=`` instance always wins.
The default is resolved at each fall-back site (``@persist`` and the
runtime's pause/resume paths), so the factory may be called more than once
for a single flow. Return instances backed by shared durable state (or a
singleton) so state saved on one call is visible to the next -- the
built-in SQLite default satisfies this by sharing one on-disk file.
"""
global _factory
_factory = factory
def default_flow_persistence() -> FlowPersistence:
"""Build the default flow persistence backend.
Returns the result of the registered factory if one is set, otherwise a
built-in :class:`~crewai.flow.persistence.sqlite.SQLiteFlowPersistence`.
"""
factory = _factory
if factory is not None:
return factory()
from crewai.flow.persistence.sqlite import SQLiteFlowPersistence
return SQLiteFlowPersistence()

View File

@@ -0,0 +1,48 @@
"""Build FlowDefinition actions into live runtime callables."""
from __future__ import annotations
from collections.abc import Callable
from typing import TYPE_CHECKING, Any
from crewai.flow.flow_definition import (
FlowActionDefinition,
FlowInnerActionDefinition,
)
from crewai.flow.runtime._actions._base import ActionHandlerRegistry
from crewai.flow.runtime._actions._code import CodeActionHandler
from crewai.flow.runtime._actions._each import EachActionHandler
from crewai.flow.runtime._actions._expression import ExpressionActionHandler
from crewai.flow.runtime._actions._tool import ToolActionHandler
if TYPE_CHECKING:
from crewai.flow.runtime import Flow
__all__ = [
"build_action",
]
_SIMPLE_ACTION_HANDLERS = (
CodeActionHandler(),
ToolActionHandler(),
ExpressionActionHandler(),
)
_SIMPLE_ACTION_REGISTRY = ActionHandlerRegistry[FlowInnerActionDefinition](
_SIMPLE_ACTION_HANDLERS
)
_ACTION_REGISTRY = ActionHandlerRegistry[FlowActionDefinition](
(
*_SIMPLE_ACTION_HANDLERS,
EachActionHandler(_SIMPLE_ACTION_REGISTRY),
)
)
def build_action(flow: Flow[Any], action: FlowActionDefinition) -> Callable[..., Any]:
"""Turn one `do:` action into the callable the flow runs for that node."""
return _ACTION_REGISTRY.build(flow, action)

View File

@@ -0,0 +1,39 @@
"""Shared action handler contracts."""
from __future__ import annotations
from collections.abc import Callable, Iterable
from typing import TYPE_CHECKING, Any, Generic, Protocol, TypeVar
from pydantic import BaseModel
if TYPE_CHECKING:
from crewai.flow.runtime import Flow
ActionT = TypeVar("ActionT", bound=BaseModel)
ResolvedAction = Callable[..., Any]
class ActionHandler(Protocol[ActionT]):
"""Handler for one concrete FlowDefinition action type."""
action_type: type[ActionT]
def build(self, flow: Flow[Any], action: ActionT) -> ResolvedAction:
"""Build the callable executed by the flow."""
class ActionHandlerRegistry(Generic[ActionT]):
"""Build action callables with an ordered set of typed handlers."""
def __init__(self, handlers: Iterable[ActionHandler[Any]]) -> None:
self._handlers = tuple(handlers)
def build(self, flow: Flow[Any], action: ActionT) -> ResolvedAction:
for handler in self._handlers:
if isinstance(action, handler.action_type):
return handler.build(flow, action)
call = getattr(action, "call", None)
raise ValueError(f"unknown call type {call!r}")

View File

@@ -0,0 +1,51 @@
"""Handler for ``call: code`` FlowDefinition actions."""
from __future__ import annotations
from collections.abc import Callable
import functools
from typing import TYPE_CHECKING, Any, cast
from crewai.flow.flow_definition import FlowCodeActionDefinition
from crewai.flow.runtime._actions._base import ResolvedAction
from crewai.flow.runtime._actions._runtime import LOCAL_CONTEXT_KWARG
from crewai.flow.runtime._expressions import render_with_block
from crewai.flow.runtime._refs import InvalidRefError, resolve_ref
if TYPE_CHECKING:
from crewai.flow.runtime import Flow
class CodeActionHandler:
"""Build importable Python callables and bind them to the running flow."""
action_type = FlowCodeActionDefinition
def build(
self, flow: Flow[Any], action: FlowCodeActionDefinition
) -> ResolvedAction:
handler = _resolve_code_handler(flow, action)
def run_code(*args: Any, **kwargs: Any) -> Any:
local_context = kwargs.pop(LOCAL_CONTEXT_KWARG, None)
if action.with_ is None:
return handler(*args, **kwargs)
return handler(
**render_with_block(flow, action.with_, local_context=local_context)
)
return functools.update_wrapper(run_code, handler)
def _resolve_code_handler(
flow: Flow[Any], action: FlowCodeActionDefinition
) -> Callable[..., Any]:
ref = action.ref
target = resolve_ref(ref, field="do")
if not callable(target):
raise InvalidRefError(f"invalid do ref {ref!r}; object is not callable")
handler = cast(Callable[..., Any], target)
if getattr(handler, "__self__", None) is None:
handler = handler.__get__(flow, type(flow))
return handler

View File

@@ -0,0 +1,73 @@
"""Handler for ``call: each`` FlowDefinition actions."""
from __future__ import annotations
from collections.abc import Callable
from typing import TYPE_CHECKING, Any
from crewai.flow.flow_definition import (
FlowEachActionDefinition,
FlowEachInnerActionDefinition,
FlowInnerActionDefinition,
)
from crewai.flow.runtime._actions._base import (
ActionHandlerRegistry,
ResolvedAction,
)
from crewai.flow.runtime._actions._runtime import (
LOCAL_CONTEXT_KWARG,
ensure_array,
invoke_callable,
)
from crewai.flow.runtime._expressions import evaluate_expression
if TYPE_CHECKING:
from crewai.flow.runtime import Flow
class EachActionHandler:
"""Build a sequential mini-pipeline for every item in an array."""
action_type = FlowEachActionDefinition
def __init__(
self, inner_registry: ActionHandlerRegistry[FlowInnerActionDefinition]
) -> None:
self._inner_registry = inner_registry
def build(
self, flow: Flow[Any], action: FlowEachActionDefinition
) -> ResolvedAction:
inner_actions = [
(inner_action.name, self._resolve_inner_action(flow, inner_action))
for inner_action in action.do
]
async def run_each(*_args: Any, **_kwargs: Any) -> list[Any]:
items = ensure_array(evaluate_expression(flow, action.in_))
results: list[Any] = []
for item in items:
local_outputs: dict[str, Any] = {}
last_output: Any = None
for name, run_inner_action in inner_actions:
last_output = await run_inner_action(
{"item": item, "outputs": local_outputs}
)
local_outputs[name] = last_output
results.append(last_output)
return results
return run_each
def _resolve_inner_action(
self, flow: Flow[Any], inner_action: FlowEachInnerActionDefinition
) -> Callable[[dict[str, Any]], Any]:
run_action = self._inner_registry.build(flow, inner_action.action)
async def run_inner_action(local_context: dict[str, Any]) -> Any:
return await invoke_callable(
run_action, **{LOCAL_CONTEXT_KWARG: local_context}
)
return run_inner_action

View File

@@ -0,0 +1,29 @@
"""Handler for ``call: expression`` FlowDefinition actions."""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from crewai.flow.flow_definition import FlowExpressionActionDefinition
from crewai.flow.runtime._actions._base import ResolvedAction
from crewai.flow.runtime._actions._runtime import LOCAL_CONTEXT_KWARG
from crewai.flow.runtime._expressions import evaluate_expression
if TYPE_CHECKING:
from crewai.flow.runtime import Flow
class ExpressionActionHandler:
"""Build CEL expression actions."""
action_type = FlowExpressionActionDefinition
def build(
self, flow: Flow[Any], action: FlowExpressionActionDefinition
) -> ResolvedAction:
def run_expression(*_args: Any, **kwargs: Any) -> Any:
local_context = kwargs.pop(LOCAL_CONTEXT_KWARG, None)
return evaluate_expression(flow, action.expr, local_context=local_context)
return run_expression

View File

@@ -0,0 +1,28 @@
"""Runtime helpers shared by action resolvers."""
from __future__ import annotations
from collections.abc import Callable
import inspect
from typing import Any
LOCAL_CONTEXT_KWARG = "__flow_definition_local_context"
async def invoke_callable(
handler: Callable[..., Any], *args: Any, **kwargs: Any
) -> Any:
if inspect.iscoroutinefunction(handler):
result = await handler(*args, **kwargs)
else:
result = handler(*args, **kwargs)
if inspect.isawaitable(result):
result = await result
return result
def ensure_array(value: Any) -> list[Any]:
if isinstance(value, list):
return value
raise ValueError("each.in must evaluate to an array")

View File

@@ -0,0 +1,52 @@
"""Handler for ``call: tool`` FlowDefinition actions."""
from __future__ import annotations
from collections.abc import Callable
import inspect
from typing import TYPE_CHECKING, Any, cast
from crewai.flow.flow_definition import FlowToolActionDefinition
from crewai.flow.runtime._actions._base import ResolvedAction
from crewai.flow.runtime._actions._runtime import LOCAL_CONTEXT_KWARG
from crewai.flow.runtime._expressions import render_with_block
from crewai.flow.runtime._refs import InvalidRefError, resolve_ref
if TYPE_CHECKING:
from crewai.flow.runtime import Flow
class ToolActionHandler:
"""Build and instantiate CrewAI tool actions."""
action_type = FlowToolActionDefinition
def build(
self, flow: Flow[Any], action: FlowToolActionDefinition
) -> ResolvedAction:
target = resolve_ref(action.ref, field="do")
from crewai.tools import BaseTool
if not (inspect.isclass(target) and issubclass(target, BaseTool)):
raise InvalidRefError(
f"invalid tool ref {action.ref!r}; expected a BaseTool class"
)
try:
tool_cls = cast(Callable[[], BaseTool], target)
tool = tool_cls()
except Exception as e:
raise InvalidRefError(
f"cannot instantiate tool ref {action.ref!r} without arguments: {e}"
) from e
tool_kwargs = action.with_ or {}
def run_tool(*_args: Any, **kwargs: Any) -> Any:
local_context = kwargs.pop(LOCAL_CONTEXT_KWARG, None)
return tool.run(
**render_with_block(flow, tool_kwargs, local_context=local_context)
)
return run_tool

View File

@@ -0,0 +1,163 @@
"""Runtime expression support for FlowDefinition CEL expressions."""
from __future__ import annotations
import dataclasses
from itertools import pairwise
import json
import re
from typing import TYPE_CHECKING, Any, cast
from pydantic import BaseModel
if TYPE_CHECKING:
from crewai.flow.runtime import Flow
_EXPRESSION_PATTERN = re.compile(r"\$\{([^{}]*)\}")
__all__ = ["FlowExpressionError", "evaluate_expression", "render_with_block"]
class FlowExpressionError(ValueError):
"""A FlowDefinition expression failed to parse or evaluate."""
def render_with_block(
flow: Flow[Any], value: Any, local_context: dict[str, Any] | None = None
) -> Any:
"""Render CEL expressions inside a FlowDefinition ``with:`` payload."""
context = _expression_context(flow, local_context=local_context)
return _render_value(value, context)
def evaluate_expression(
flow: Flow[Any], expression: str, local_context: dict[str, Any] | None = None
) -> Any:
"""Evaluate a FlowDefinition CEL expression against runtime context."""
expression = expression.strip()
if not expression:
raise FlowExpressionError("empty CEL expression")
return _eval_cel(expression, _expression_context(flow, local_context=local_context))
def _expression_context(
flow: Flow[Any], local_context: dict[str, Any] | None = None
) -> dict[str, Any]:
context = {
"state": flow._copy_and_serialize_state(),
"outputs": _outputs_by_name(flow._method_outputs),
}
if local_context:
context.update(
{key: _to_json_safe(value) for key, value in local_context.items()}
)
return context
def _outputs_by_name(method_outputs: list[Any]) -> dict[str, Any]:
outputs: dict[str, Any] = {}
for entry in method_outputs:
method = ""
output = entry
if isinstance(entry, dict) and "output" in entry:
method = str(entry.get("method", ""))
output = entry["output"]
outputs[method] = _to_json_safe(output)
return outputs
def _to_json_safe(value: Any) -> Any:
if isinstance(value, BaseModel):
return value.model_dump(mode="json")
if dataclasses.is_dataclass(value) and not isinstance(value, type):
return dataclasses.asdict(value)
if isinstance(value, dict):
return {key: _to_json_safe(item) for key, item in value.items()}
if isinstance(value, list):
return [_to_json_safe(item) for item in value]
if isinstance(value, tuple):
return [_to_json_safe(item) for item in value]
return value
def _render_value(value: Any, context: dict[str, Any]) -> Any:
if isinstance(value, str):
return _render_string(value, context)
if isinstance(value, dict):
return {key: _render_value(item, context) for key, item in value.items()}
if isinstance(value, list):
return [_render_value(item, context) for item in value]
return value
def _render_string(value: str, context: dict[str, Any]) -> Any:
matches = list(_EXPRESSION_PATTERN.finditer(value))
if not matches:
_raise_for_invalid_interpolation(value)
return value
_raise_for_literal_braces(value[: matches[0].start()])
for previous, current in pairwise(matches):
_raise_for_literal_braces(value[previous.end() : current.start()])
_raise_for_literal_braces(value[matches[-1].end() :])
if len(matches) == 1 and matches[0].span() == (0, len(value)):
expression = matches[0].group(1).strip()
if not expression:
raise FlowExpressionError("empty CEL expression in with block")
return _eval_cel(expression, context)
rendered: list[str] = []
position = 0
for match in matches:
start, end = match.span()
literal = value[position:start]
rendered.append(literal)
expression = match.group(1).strip()
if not expression:
raise FlowExpressionError("empty CEL expression in with block")
result = _eval_cel(expression, context)
rendered.append(result if isinstance(result, str) else json.dumps(result))
position = end
literal = value[position:]
rendered.append(literal)
return "".join(rendered)
def _raise_for_invalid_interpolation(value: str) -> None:
if "${" not in value:
return
raise FlowExpressionError(
"invalid CEL interpolation in with block: expressions must be enclosed "
"as ${...} and cannot contain braces"
)
def _raise_for_literal_braces(value: str) -> None:
if "{" not in value and "}" not in value:
return
raise FlowExpressionError(
"invalid CEL interpolation in with block: expressions must be enclosed "
"as ${...} and cannot contain braces"
)
def _eval_cel(expression: str, context: dict[str, Any]) -> Any:
try:
from celpy import Environment
from celpy.adapter import CELJSONEncoder, json_to_cel
from celpy.evaluation import Context
environment = Environment()
program = environment.program(environment.compile(expression))
result = program.evaluate(cast(Context, json_to_cel(context)))
return json.loads(json.dumps(result, cls=CELJSONEncoder))
except Exception as e:
raise FlowExpressionError(
f"failed to evaluate CEL expression {expression!r}: {e}"
) from e

View File

@@ -0,0 +1,38 @@
"""Resolution of ``module:qualname`` refs into live Python objects."""
from __future__ import annotations
import importlib
import inspect
from operator import attrgetter
from typing import Any
class InvalidRefError(ValueError):
"""A definition ref that cannot be resolved to a live object."""
def resolve_ref(ref: str, *, field: str) -> Any:
"""Import the object a definition's `module:qualname` ref points to."""
module_name, _, qualname = ref.partition(":")
if "<" in ref or not module_name or not qualname:
raise InvalidRefError(
f"invalid {field} ref {ref!r}; expected 'module:qualname'"
)
try:
return attrgetter(qualname)(importlib.import_module(module_name))
except (ImportError, AttributeError) as e:
raise InvalidRefError(f"unresolvable {field} ref {ref!r}") from e
def resolve_instance_ref(ref: str, *, field: str) -> Any:
"""Resolve a ref, auto-instantiating a no-arg class into an instance."""
target = resolve_ref(ref, field=field)
if not inspect.isclass(target):
return target
try:
return target()
except Exception as e:
raise InvalidRefError(
f"cannot instantiate {field} ref {ref!r} without arguments: {e}"
) from e

View File

@@ -5,15 +5,7 @@ the Flow system.
"""
from datetime import datetime
from typing import (
Annotated,
Any,
NewType,
ParamSpec,
Protocol,
TypeVar,
TypedDict,
)
from typing import Annotated, Any, NewType, ParamSpec, Protocol, TypeVar, TypedDict
from typing_extensions import NotRequired, Required
@@ -24,7 +16,7 @@ R = TypeVar("R", covariant=True)
FlowMethodName = NewType("FlowMethodName", str)
PendingListenerKey = NewType(
"PendingListenerKey",
Annotated[str, "nested flow conditions use 'listener_name:object_id'"],
Annotated[str, "listener method name, or 'start:<method>' for conditional starts"],
)

View File

@@ -13,6 +13,7 @@ from crewai.knowledge.source.string_knowledge_source import StringKnowledgeSourc
from crewai.knowledge.source.text_file_knowledge_source import (
TextFileKnowledgeSource,
)
from crewai.knowledge.storage.base_knowledge_storage import BaseKnowledgeStorage
from crewai.knowledge.storage.knowledge_storage import KnowledgeStorage
from crewai.rag.core.base_embeddings_provider import BaseEmbeddingsProvider
from crewai.rag.embeddings.types import EmbedderConfig
@@ -89,7 +90,7 @@ class Knowledge(BaseModel):
Knowledge is a collection of sources and setup for the vector store to save and query relevant context.
Args:
sources: list[BaseKnowledgeSource] = Field(default_factory=list)
storage: KnowledgeStorage | None = Field(default=None)
storage: BaseKnowledgeStorage | None = Field(default=None)
embedder: EmbedderConfig | None = None
"""
@@ -98,7 +99,7 @@ class Knowledge(BaseModel):
BeforeValidator(_resolve_knowledge_sources),
] = Field(default_factory=list)
model_config = ConfigDict(arbitrary_types_allowed=True)
storage: KnowledgeStorage | None = Field(default=None)
storage: BaseKnowledgeStorage | None = Field(default=None)
embedder: Annotated[
EmbedderConfig | None,
PlainSerializer(
@@ -112,15 +113,22 @@ class Knowledge(BaseModel):
collection_name: str,
sources: list[BaseKnowledgeSource],
embedder: EmbedderConfig | None = None,
storage: KnowledgeStorage | None = None,
storage: BaseKnowledgeStorage | None = None,
**data: object,
) -> None:
super().__init__(**data)
if storage:
if storage is not None:
self.storage = storage
else:
self.storage = KnowledgeStorage(
embedder=embedder, collection_name=collection_name
from crewai.knowledge.storage.factory import resolve_knowledge_storage
custom = resolve_knowledge_storage(embedder, collection_name)
self.storage = (
custom
if custom is not None
else KnowledgeStorage(
embedder=embedder, collection_name=collection_name
)
)
self.sources = sources
@@ -152,10 +160,9 @@ class Knowledge(BaseModel):
raise e
def reset(self) -> None:
if self.storage:
self.storage.reset()
else:
if self.storage is None:
raise ValueError("Storage is not initialized.")
self.storage.reset()
async def aquery(
self, query: list[str], results_limit: int = 5, score_threshold: float = 0.6
@@ -193,7 +200,6 @@ class Knowledge(BaseModel):
async def areset(self) -> None:
"""Reset the knowledge base asynchronously."""
if self.storage:
await self.storage.areset()
else:
if self.storage is None:
raise ValueError("Storage is not initialized.")
await self.storage.areset()

View File

@@ -5,7 +5,7 @@ from typing import Any
from pydantic import Field, field_validator
from crewai.knowledge.source.base_knowledge_source import BaseKnowledgeSource
from crewai.knowledge.storage.knowledge_storage import KnowledgeStorage
from crewai.knowledge.storage.base_knowledge_storage import BaseKnowledgeStorage
from crewai.utilities.constants import KNOWLEDGE_DIRECTORY
from crewai.utilities.logger import Logger
@@ -22,7 +22,7 @@ class BaseFileKnowledgeSource(BaseKnowledgeSource, ABC):
default_factory=list, description="The path to the file"
)
content: dict[Path, str] = Field(init=False, default_factory=dict)
storage: KnowledgeStorage | None = Field(default=None)
storage: BaseKnowledgeStorage | None = Field(default=None)
safe_file_paths: list[Path] = Field(default_factory=list)
@field_validator("file_path", "file_paths", mode="before")
@@ -70,14 +70,14 @@ class BaseFileKnowledgeSource(BaseKnowledgeSource, ABC):
def _save_documents(self) -> None:
"""Save the documents to the storage."""
if self.storage:
if self.storage is not None:
self.storage.save(self.chunks)
else:
raise ValueError("No storage found to save documents.")
async def _asave_documents(self) -> None:
"""Save the documents to the storage asynchronously."""
if self.storage:
if self.storage is not None:
await self.storage.asave(self.chunks)
else:
raise ValueError("No storage found to save documents.")

View File

@@ -4,9 +4,15 @@ from typing import Any
import numpy as np
from pydantic import BaseModel, ConfigDict, Field
from crewai.knowledge.storage.base_knowledge_storage import BaseKnowledgeStorage
from crewai.knowledge.storage.knowledge_storage import KnowledgeStorage
# ``KnowledgeStorage`` is re-exported for backwards compatibility; the ``storage``
# field below is typed to the base interface so any backend plugs in.
__all__ = ["BaseKnowledgeSource", "KnowledgeStorage"]
class BaseKnowledgeSource(BaseModel, ABC):
"""Abstract base class for knowledge sources."""
@@ -18,7 +24,7 @@ class BaseKnowledgeSource(BaseModel, ABC):
)
model_config = ConfigDict(arbitrary_types_allowed=True)
storage: KnowledgeStorage | None = Field(default=None)
storage: BaseKnowledgeStorage | None = Field(default=None)
metadata: dict[str, Any] = Field(default_factory=dict) # Currently unused
collection_name: str | None = Field(default=None)
@@ -49,7 +55,7 @@ class BaseKnowledgeSource(BaseModel, ABC):
Raises:
ValueError: If no storage is configured.
"""
if self.storage:
if self.storage is not None:
self.storage.save(self.chunks)
else:
raise ValueError("No storage found to save documents.")
@@ -66,7 +72,7 @@ class BaseKnowledgeSource(BaseModel, ABC):
Raises:
ValueError: If no storage is configured.
"""
if self.storage:
if self.storage is not None:
await self.storage.asave(self.chunks)
else:
raise ValueError("No storage found to save documents.")

View File

@@ -0,0 +1,56 @@
"""Pluggable default storage backend for knowledge collections.
By default, :class:`~crewai.knowledge.knowledge.Knowledge` builds a
:class:`~crewai.knowledge.storage.knowledge_storage.KnowledgeStorage` when no
explicit ``storage=`` is given. Registering a factory via
:func:`set_knowledge_storage_factory` lets an application back knowledge with a
custom :class:`~crewai.knowledge.storage.base_knowledge_storage.BaseKnowledgeStorage`
without subclassing ``Knowledge`` or passing a ``storage=`` instance at every
call site.
This mirrors :func:`crewai_core.lock_store.set_lock_backend`: a one-time,
process-wide setter intended for application startup. Pass ``None`` to restore
the built-in default.
"""
from __future__ import annotations
from collections.abc import Callable
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from crewai.knowledge.storage.base_knowledge_storage import BaseKnowledgeStorage
from crewai.rag.embeddings.types import EmbedderConfig
# Receives the same inputs as the built-in default -- the embedder config and
# collection name -- and returns a storage backend, or ``None`` to defer to the
# built-in ``KnowledgeStorage``.
KnowledgeStorageFactory = Callable[
["EmbedderConfig | None", "str | None"], "BaseKnowledgeStorage | None"
]
_factory: KnowledgeStorageFactory | None = None
def set_knowledge_storage_factory(factory: KnowledgeStorageFactory | None) -> None:
"""Replace the process-wide default knowledge storage factory.
Intended for one-time setup at startup. Pass ``None`` to restore the
built-in ``KnowledgeStorage``. Only affects ``Knowledge`` instances
constructed afterwards; an explicit ``storage=`` instance always wins.
"""
global _factory
_factory = factory
def resolve_knowledge_storage(
embedder: EmbedderConfig | None, collection_name: str | None
) -> BaseKnowledgeStorage | None:
"""Return the registered factory's backend, or ``None`` for the built-in.
``None`` means no factory is registered or it declined; the caller then
falls back to the built-in ``KnowledgeStorage``.
"""
factory = _factory
return factory(embedder, collection_name) if factory is not None else None

View File

@@ -23,7 +23,6 @@ from crewai.events.event_bus import crewai_event_bus
from crewai.events.types.llm_events import (
LLMCallCompletedEvent,
LLMCallFailedEvent,
LLMCallStartedEvent,
LLMCallType,
LLMStreamChunkEvent,
)
@@ -32,6 +31,7 @@ from crewai.events.types.tool_usage_events import (
ToolUsageFinishedEvent,
ToolUsageStartedEvent,
)
from crewai.llms._finish_reason_utils import extract_choices_finish_reason_and_id
from crewai.llms.base_llm import (
BaseLLM,
JsonResponseFormat,
@@ -732,6 +732,11 @@ class LLM(BaseLLM):
last_chunk = None
chunk_count = 0
usage_info = None
# Tracked across the loop: LiteLLM with include_usage emits a final
# usage-only chunk with empty choices, so the post-loop last_chunk has
# no finish_reason. Capture both incrementally instead.
stream_finish_reason: str | None = None
stream_response_id: str | None = None
accumulated_tool_args: defaultdict[int, AccumulatedToolArgs] = defaultdict(
AccumulatedToolArgs
@@ -750,6 +755,16 @@ class LLM(BaseLLM):
if isinstance(chunk, ModelResponseBase):
response_id = chunk.id
elif isinstance(chunk, dict):
response_id = chunk.get("id")
chunk_finish, chunk_id = self._extract_finish_reason_and_response_id(
chunk
)
if chunk_finish:
stream_finish_reason = chunk_finish
if chunk_id and not stream_response_id:
stream_response_id = chunk_id
try:
choices = None
@@ -922,6 +937,11 @@ class LLM(BaseLLM):
if tool_calls_list:
return tool_calls_list
finish_reason, response_id_last = (
stream_finish_reason,
stream_response_id,
)
if not tool_calls or not available_functions:
if response_model and self.is_litellm:
instructor_instance = InternalInstructor(
@@ -939,6 +959,8 @@ class LLM(BaseLLM):
from_agent=from_agent,
messages=params["messages"],
usage=usage_dict,
finish_reason=finish_reason,
response_id=response_id_last,
)
return structured_response
@@ -950,6 +972,8 @@ class LLM(BaseLLM):
from_agent=from_agent,
messages=params["messages"],
usage=usage_dict,
finish_reason=finish_reason,
response_id=response_id_last,
)
return full_response
@@ -965,6 +989,8 @@ class LLM(BaseLLM):
from_agent=from_agent,
messages=params["messages"],
usage=usage_dict,
finish_reason=finish_reason,
response_id=response_id_last,
)
return full_response
@@ -978,6 +1004,10 @@ class LLM(BaseLLM):
logging.error(f"Error in streaming response: {e!s}")
if full_response.strip():
logging.warning(f"Returning partial response despite error: {e!s}")
finish_reason, response_id_last = (
stream_finish_reason,
stream_response_id,
)
self._handle_emit_call_events(
response=full_response,
call_type=LLMCallType.LLM_CALL,
@@ -985,6 +1015,8 @@ class LLM(BaseLLM):
from_agent=from_agent,
messages=params["messages"],
usage=self._usage_to_dict(usage_info),
finish_reason=finish_reason,
response_id=response_id_last,
)
return full_response
@@ -1169,6 +1201,10 @@ class LLM(BaseLLM):
else None
)
finish_reason, response_id = self._extract_finish_reason_and_response_id(
response
)
if response_model is not None:
# When using instructor/response_model, litellm returns a Pydantic model instance
if isinstance(response, BaseModel):
@@ -1180,6 +1216,8 @@ class LLM(BaseLLM):
from_agent=from_agent,
messages=params["messages"],
usage=response_usage,
finish_reason=finish_reason,
response_id=response_id,
)
return structured_response
@@ -1216,6 +1254,8 @@ class LLM(BaseLLM):
from_agent=from_agent,
messages=params["messages"],
usage=response_usage,
finish_reason=finish_reason,
response_id=response_id,
)
return text_response
@@ -1233,6 +1273,8 @@ class LLM(BaseLLM):
from_agent=from_agent,
messages=params["messages"],
usage=response_usage,
finish_reason=finish_reason,
response_id=response_id,
)
return text_response
@@ -1310,6 +1352,10 @@ class LLM(BaseLLM):
else None
)
finish_reason, response_id = self._extract_finish_reason_and_response_id(
response
)
if response_model is not None:
if isinstance(response, BaseModel):
structured_response = response.model_dump_json()
@@ -1320,6 +1366,8 @@ class LLM(BaseLLM):
from_agent=from_agent,
messages=params["messages"],
usage=response_usage,
finish_reason=finish_reason,
response_id=response_id,
)
return structured_response
@@ -1358,6 +1406,8 @@ class LLM(BaseLLM):
from_agent=from_agent,
messages=params["messages"],
usage=response_usage,
finish_reason=finish_reason,
response_id=response_id,
)
return text_response
@@ -1375,6 +1425,8 @@ class LLM(BaseLLM):
from_agent=from_agent,
messages=params["messages"],
usage=response_usage,
finish_reason=finish_reason,
response_id=response_id,
)
return text_response
@@ -1412,12 +1464,29 @@ class LLM(BaseLLM):
params["stream"] = True
params["stream_options"] = {"include_usage": True}
response_id = None
# See sync sibling: incrementally track finish_reason/response_id so the
# usage-only final chunk doesn't wipe them.
stream_finish_reason: str | None = None
stream_response_id: str | None = None
try:
async for chunk in await litellm.acompletion(**params):
chunk_count += 1
chunk_content = None
response_id = chunk.id if isinstance(chunk, ModelResponseBase) else None
if isinstance(chunk, ModelResponseBase):
response_id = chunk.id
elif isinstance(chunk, dict):
response_id = chunk.get("id")
else:
response_id = None
chunk_finish, chunk_id = self._extract_finish_reason_and_response_id(
chunk
)
if chunk_finish:
stream_finish_reason = chunk_finish
if chunk_id and not stream_response_id:
stream_response_id = chunk_id
try:
choices = None
@@ -1525,6 +1594,10 @@ class LLM(BaseLLM):
return tool_calls_list
usage_dict = self._usage_to_dict(usage_info)
finish_reason, response_id_last = (
stream_finish_reason,
stream_response_id,
)
self._handle_emit_call_events(
response=full_response,
call_type=LLMCallType.LLM_CALL,
@@ -1532,6 +1605,8 @@ class LLM(BaseLLM):
from_agent=from_agent,
messages=params.get("messages"),
usage=usage_dict,
finish_reason=finish_reason,
response_id=response_id_last,
)
return full_response
@@ -1545,6 +1620,10 @@ class LLM(BaseLLM):
if chunk_count == 0:
raise
if full_response:
finish_reason, response_id_last = (
stream_finish_reason,
stream_response_id,
)
self._handle_emit_call_events(
response=full_response,
call_type=LLMCallType.LLM_CALL,
@@ -1552,6 +1631,8 @@ class LLM(BaseLLM):
from_agent=from_agent,
messages=params.get("messages"),
usage=self._usage_to_dict(usage_info),
finish_reason=finish_reason,
response_id=response_id_last,
)
return full_response
raise
@@ -1678,19 +1759,14 @@ class LLM(BaseLLM):
ValueError: If response format is not supported
LLMContextLengthExceededError: If input exceeds model's context limit
"""
with llm_call_context() as call_id:
crewai_event_bus.emit(
self,
event=LLMCallStartedEvent(
messages=messages,
tools=tools,
callbacks=callbacks,
available_functions=available_functions,
from_task=from_task,
from_agent=from_agent,
model=self.model,
call_id=call_id,
),
with llm_call_context():
self._emit_call_started_event(
messages=messages,
tools=tools,
callbacks=callbacks,
available_functions=available_functions,
from_task=from_task,
from_agent=from_agent,
)
self._validate_call_params()
@@ -1822,19 +1898,14 @@ class LLM(BaseLLM):
ValueError: If response format is not supported
LLMContextLengthExceededError: If input exceeds model's context limit
"""
with llm_call_context() as call_id:
crewai_event_bus.emit(
self,
event=LLMCallStartedEvent(
messages=messages,
tools=tools,
callbacks=callbacks,
available_functions=available_functions,
from_task=from_task,
from_agent=from_agent,
model=self.model,
call_id=call_id,
),
with llm_call_context():
self._emit_call_started_event(
messages=messages,
tools=tools,
callbacks=callbacks,
available_functions=available_functions,
from_task=from_task,
from_agent=from_agent,
)
self._validate_call_params()
@@ -1990,6 +2061,8 @@ class LLM(BaseLLM):
from_agent: BaseAgent | None = None,
messages: str | list[LLMMessage] | None = None,
usage: dict[str, Any] | None = None,
finish_reason: str | None = None,
response_id: str | None = None,
) -> None:
"""Handle the events for the LLM call.
@@ -2000,6 +2073,10 @@ class LLM(BaseLLM):
from_agent: Optional agent object
messages: Optional messages object
usage: Optional token usage data
finish_reason: Raw provider finish reason (e.g. "stop", "length",
"tool_calls"). Optional; downstream telemetry coerces to the
OTel GenAI enum.
response_id: Raw provider response identifier. Optional.
"""
crewai_event_bus.emit(
self,
@@ -2012,9 +2089,24 @@ class LLM(BaseLLM):
model=self.model,
call_id=get_current_call_id(),
usage=usage,
finish_reason=finish_reason,
response_id=response_id,
),
)
def _effective_max_tokens(self) -> int | float | None:
"""LiteLLM sends ``max_tokens or max_completion_tokens`` as the cap."""
return self.max_tokens or self.max_completion_tokens
@staticmethod
def _extract_finish_reason_and_response_id(
response_or_chunk: Any,
) -> tuple[str | None, str | None]:
"""LiteLLM responses/chunks share the choices-shape with OpenAI/Azure;
delegate to the shared extractor.
"""
return extract_choices_finish_reason_and_id(response_or_chunk)
def _process_message_files(self, messages: list[LLMMessage]) -> list[LLMMessage]:
"""Process files attached to messages and format for provider.

View File

@@ -0,0 +1,55 @@
"""Shared extractors for ``finish_reason`` + ``response_id`` across LLM providers.
OpenAI Chat Completions, Azure AI Inference, and LiteLLM all expose the same
choices-based response shape (``response.id`` + ``response.choices[0].finish_reason``),
both as object attributes and (for LiteLLM stream chunks) as dict keys. This
module centralises that introspection so every provider doesn't reinvent the
defensive walk. Providers with genuinely different shapes — Anthropic
(``stop_reason``), Bedrock (``stopReason``), Gemini (protobuf enum), OpenAI
Responses (``status``) — keep their own helpers.
"""
from __future__ import annotations
from typing import Any
def _as_str(value: Any) -> str | None:
return value if isinstance(value, str) else None
def extract_choices_finish_reason_and_id(
response_or_chunk: Any,
) -> tuple[str | None, str | None]:
"""Extract ``(finish_reason, response_id)`` from a choices-shaped response.
Handles both object-style (``response.id``, ``response.choices[0].finish_reason``)
and dict-style (``response["id"]``, ``response["choices"][0]["finish_reason"]``)
inputs. Returns ``(None, None)`` on any failure; never raises. Non-string
raw values are coerced to ``None`` so test mocks and exotic provider types
(MagicMock, protobuf enums, etc.) don't propagate downstream.
"""
raw_id = getattr(response_or_chunk, "id", None)
if raw_id is None and isinstance(response_or_chunk, dict):
raw_id = response_or_chunk.get("id")
response_id = _as_str(raw_id)
if isinstance(response_or_chunk, dict):
choices = response_or_chunk.get("choices")
else:
choices = getattr(response_or_chunk, "choices", None)
finish_reason: str | None = None
if choices:
try:
first = choices[0]
except (IndexError, TypeError, KeyError):
first = None
if first is not None:
if isinstance(first, dict):
raw_finish = first.get("finish_reason")
else:
raw_finish = getattr(first, "finish_reason", None)
finish_reason = _as_str(raw_finish)
return finish_reason, response_id

View File

@@ -150,6 +150,13 @@ class BaseLLM(BaseModel, ABC):
llm_type: str = "base"
model: str
temperature: float | None = None
top_p: float | None = None
max_tokens: int | float | None = None
stream: bool | None = None
seed: int | None = None
frequency_penalty: float | None = None
presence_penalty: float | None = None
n: int | None = None
api_key: str | None = None
base_url: str | None = None
provider: str = Field(default="openai")
@@ -464,6 +471,16 @@ class BaseLLM(BaseModel, ABC):
"""
return None
def _effective_max_tokens(self) -> int | float | None:
"""Token cap actually sent to the provider, for start-event telemetry.
Defaults to ``self.max_tokens``. Providers that cap generation through a
differently named field (e.g. ``max_completion_tokens`` on OpenAI/Azure,
``max_output_tokens`` on Gemini) override this so ``LLMCallStartedEvent``
reports the real limit instead of ``None``.
"""
return self.max_tokens
def _emit_call_started_event(
self,
messages: str | list[LLMMessage],
@@ -472,10 +489,38 @@ class BaseLLM(BaseModel, ABC):
available_functions: dict[str, Any] | None = None,
from_task: Task | None = None,
from_agent: BaseAgent | None = None,
temperature: float | None = None,
top_p: float | None = None,
max_tokens: int | float | None = None,
stream: bool | None = None,
seed: int | None = None,
stop_sequences: list[str] | None = None,
frequency_penalty: float | None = None,
presence_penalty: float | None = None,
n: int | None = None,
) -> None:
"""Emit LLM call started event."""
from crewai.utilities.serialization import to_serializable
if temperature is None:
temperature = self.temperature
if top_p is None:
top_p = self.top_p
if max_tokens is None:
max_tokens = self._effective_max_tokens()
if stream is None:
stream = self.stream
if seed is None:
seed = self.seed
if stop_sequences is None:
stop_sequences = self.stop_sequences or None
if frequency_penalty is None:
frequency_penalty = self.frequency_penalty
if presence_penalty is None:
presence_penalty = self.presence_penalty
if n is None:
n = self.n
crewai_event_bus.emit(
self,
event=LLMCallStartedEvent(
@@ -487,6 +532,15 @@ class BaseLLM(BaseModel, ABC):
from_agent=from_agent,
model=self.model,
call_id=get_current_call_id(),
temperature=temperature,
top_p=top_p,
max_tokens=max_tokens,
stream=stream,
seed=seed,
stop_sequences=stop_sequences,
frequency_penalty=frequency_penalty,
presence_penalty=presence_penalty,
n=n,
),
)
@@ -498,6 +552,8 @@ class BaseLLM(BaseModel, ABC):
from_agent: BaseAgent | None = None,
messages: str | list[LLMMessage] | None = None,
usage: dict[str, Any] | None = None,
finish_reason: str | None = None,
response_id: str | None = None,
) -> None:
"""Emit LLM call completed event."""
from crewai.utilities.serialization import to_serializable
@@ -513,6 +569,8 @@ class BaseLLM(BaseModel, ABC):
model=self.model,
call_id=get_current_call_id(),
usage=usage,
finish_reason=finish_reason,
response_id=response_id,
),
)
@@ -832,41 +890,17 @@ class BaseLLM(BaseModel, ABC):
Args:
usage_data: Token usage data from the API response
"""
prompt_tokens = (
usage_data.get("prompt_tokens")
or usage_data.get("prompt_token_count")
or usage_data.get("input_tokens")
or 0
)
metrics = UsageMetrics.from_provider_dict(usage_data)
if metrics is None:
return
completion_tokens = (
usage_data.get("completion_tokens")
or usage_data.get("candidates_token_count")
or usage_data.get("output_tokens")
or 0
)
cached_tokens = (
usage_data.get("cached_tokens")
or usage_data.get("cached_prompt_tokens")
or usage_data.get("cache_read_input_tokens")
or 0
)
if not cached_tokens:
prompt_details = usage_data.get("prompt_tokens_details")
if isinstance(prompt_details, dict):
cached_tokens = prompt_details.get("cached_tokens", 0) or 0
reasoning_tokens = usage_data.get("reasoning_tokens", 0) or 0
cache_creation_tokens = usage_data.get("cache_creation_tokens", 0) or 0
self._token_usage["prompt_tokens"] += prompt_tokens
self._token_usage["completion_tokens"] += completion_tokens
self._token_usage["total_tokens"] += prompt_tokens + completion_tokens
self._token_usage["successful_requests"] += 1
self._token_usage["cached_prompt_tokens"] += cached_tokens
self._token_usage["reasoning_tokens"] += reasoning_tokens
self._token_usage["cache_creation_tokens"] += cache_creation_tokens
self._token_usage["prompt_tokens"] += metrics.prompt_tokens
self._token_usage["completion_tokens"] += metrics.completion_tokens
self._token_usage["total_tokens"] += metrics.total_tokens
self._token_usage["successful_requests"] += metrics.successful_requests
self._token_usage["cached_prompt_tokens"] += metrics.cached_prompt_tokens
self._token_usage["reasoning_tokens"] += metrics.reasoning_tokens
self._token_usage["cache_creation_tokens"] += metrics.cache_creation_tokens
def get_token_usage_summary(self) -> UsageMetrics:
"""Get summary of token usage for this LLM instance.

View File

@@ -923,6 +923,8 @@ class AnthropicCompletion(BaseLLM):
usage = self._extract_anthropic_token_usage(response)
self._track_token_usage_internal(usage)
finish_reason, response_id = self._extract_finish_reason_and_id(response)
if _is_pydantic_model_class(response_model) and response.content:
if use_native_structured_output:
for block in response.content:
@@ -935,6 +937,8 @@ class AnthropicCompletion(BaseLLM):
from_agent=from_agent,
messages=params["messages"],
usage=usage,
finish_reason=finish_reason,
response_id=response_id,
)
return structured_data
else:
@@ -951,6 +955,8 @@ class AnthropicCompletion(BaseLLM):
from_agent=from_agent,
messages=params["messages"],
usage=usage,
finish_reason=finish_reason,
response_id=response_id,
)
return structured_data
@@ -973,6 +979,8 @@ class AnthropicCompletion(BaseLLM):
from_agent=from_agent,
messages=params["messages"],
usage=usage,
finish_reason=finish_reason,
response_id=response_id,
)
return list(tool_uses)
@@ -1005,6 +1013,8 @@ class AnthropicCompletion(BaseLLM):
from_agent=from_agent,
messages=params["messages"],
usage=usage,
finish_reason=finish_reason,
response_id=response_id,
)
if usage.get("total_tokens", 0) > 0:
@@ -1147,6 +1157,10 @@ class AnthropicCompletion(BaseLLM):
usage = self._extract_anthropic_token_usage(final_message)
self._track_token_usage_internal(usage)
finish_reason, final_response_id = self._extract_finish_reason_and_id(
final_message
)
if _is_pydantic_model_class(response_model):
if use_native_structured_output:
structured_data = response_model.model_validate_json(full_response)
@@ -1157,6 +1171,8 @@ class AnthropicCompletion(BaseLLM):
from_agent=from_agent,
messages=params["messages"],
usage=usage,
finish_reason=finish_reason,
response_id=final_response_id,
)
return structured_data
for block in final_message.content:
@@ -1172,6 +1188,8 @@ class AnthropicCompletion(BaseLLM):
from_agent=from_agent,
messages=params["messages"],
usage=usage,
finish_reason=finish_reason,
response_id=final_response_id,
)
return structured_data
@@ -1201,6 +1219,8 @@ class AnthropicCompletion(BaseLLM):
from_agent=from_agent,
messages=params["messages"],
usage=usage,
finish_reason=finish_reason,
response_id=final_response_id,
)
return self._invoke_after_llm_call_hooks(
@@ -1361,6 +1381,10 @@ class AnthropicCompletion(BaseLLM):
final_content = self._apply_stop_words(final_content)
finish_reason, final_response_id = self._extract_finish_reason_and_id(
final_response
)
self._emit_call_completed_event(
response=final_content,
call_type=LLMCallType.LLM_CALL,
@@ -1368,6 +1392,8 @@ class AnthropicCompletion(BaseLLM):
from_agent=from_agent,
messages=follow_up_params["messages"],
usage=follow_up_usage,
finish_reason=finish_reason,
response_id=final_response_id,
)
total_usage = {
@@ -1447,6 +1473,8 @@ class AnthropicCompletion(BaseLLM):
usage = self._extract_anthropic_token_usage(response)
self._track_token_usage_internal(usage)
finish_reason, response_id = self._extract_finish_reason_and_id(response)
if _is_pydantic_model_class(response_model) and response.content:
if use_native_structured_output:
for block in response.content:
@@ -1459,6 +1487,8 @@ class AnthropicCompletion(BaseLLM):
from_agent=from_agent,
messages=params["messages"],
usage=usage,
finish_reason=finish_reason,
response_id=response_id,
)
return structured_data
else:
@@ -1475,6 +1505,8 @@ class AnthropicCompletion(BaseLLM):
from_agent=from_agent,
messages=params["messages"],
usage=usage,
finish_reason=finish_reason,
response_id=response_id,
)
return structured_data
@@ -1495,6 +1527,8 @@ class AnthropicCompletion(BaseLLM):
from_agent=from_agent,
messages=params["messages"],
usage=usage,
finish_reason=finish_reason,
response_id=response_id,
)
return list(tool_uses)
@@ -1519,6 +1553,8 @@ class AnthropicCompletion(BaseLLM):
from_agent=from_agent,
messages=params["messages"],
usage=usage,
finish_reason=finish_reason,
response_id=response_id,
)
if usage.get("total_tokens", 0) > 0:
@@ -1647,6 +1683,10 @@ class AnthropicCompletion(BaseLLM):
usage = self._extract_anthropic_token_usage(final_message)
self._track_token_usage_internal(usage)
finish_reason, final_response_id = self._extract_finish_reason_and_id(
final_message
)
if _is_pydantic_model_class(response_model):
if use_native_structured_output:
structured_data = response_model.model_validate_json(full_response)
@@ -1657,6 +1697,8 @@ class AnthropicCompletion(BaseLLM):
from_agent=from_agent,
messages=params["messages"],
usage=usage,
finish_reason=finish_reason,
response_id=final_response_id,
)
return structured_data
for block in final_message.content:
@@ -1672,6 +1714,8 @@ class AnthropicCompletion(BaseLLM):
from_agent=from_agent,
messages=params["messages"],
usage=usage,
finish_reason=finish_reason,
response_id=final_response_id,
)
return structured_data
@@ -1701,6 +1745,8 @@ class AnthropicCompletion(BaseLLM):
from_agent=from_agent,
messages=params["messages"],
usage=usage,
finish_reason=finish_reason,
response_id=final_response_id,
)
return full_response
@@ -1753,6 +1799,10 @@ class AnthropicCompletion(BaseLLM):
final_content = self._apply_stop_words(final_content)
finish_reason, final_response_id = self._extract_finish_reason_and_id(
final_response
)
self._emit_call_completed_event(
response=final_content,
call_type=LLMCallType.LLM_CALL,
@@ -1760,6 +1810,8 @@ class AnthropicCompletion(BaseLLM):
from_agent=from_agent,
messages=follow_up_params["messages"],
usage=follow_up_usage,
finish_reason=finish_reason,
response_id=final_response_id,
)
total_usage = {
@@ -1813,6 +1865,20 @@ class AnthropicCompletion(BaseLLM):
return int(200000 * CONTEXT_WINDOW_USAGE_RATIO)
@staticmethod
def _extract_finish_reason_and_id(
message: Any,
) -> tuple[str | None, str | None]:
"""Extract raw finish_reason and response_id from an Anthropic
``Message`` / ``BetaMessage``. Anthropic exposes ``stop_reason`` (e.g.
``"end_turn"``, ``"max_tokens"``, ``"tool_use"``); we forward it raw
and let downstream telemetry map to the OTel GenAI enum.
"""
return (
getattr(message, "stop_reason", None),
getattr(message, "id", None),
)
@staticmethod
def _extract_anthropic_token_usage(
response: Message | BetaMessage,

View File

@@ -9,6 +9,7 @@ from urllib.parse import urlparse
from pydantic import BaseModel, PrivateAttr, model_validator
from typing_extensions import Self
from crewai.llms._finish_reason_utils import extract_choices_finish_reason_and_id
from crewai.llms.hooks.base import BaseInterceptor
from crewai.utilities.agent_utils import is_context_length_exceeded
from crewai.utilities.exceptions.context_window_exceeding_exception import (
@@ -783,6 +784,8 @@ class AzureCompletion(BaseLLM):
from_task: Any | None = None,
from_agent: Any | None = None,
usage: dict[str, Any] | None = None,
finish_reason: str | None = None,
response_id: str | None = None,
) -> BaseModel:
"""Validate content against response model and emit completion event.
@@ -792,6 +795,8 @@ class AzureCompletion(BaseLLM):
params: Completion parameters containing messages
from_task: Task that initiated the call
from_agent: Agent that initiated the call
finish_reason: Raw provider finish reason.
response_id: Raw provider response id.
Returns:
Validated Pydantic model instance
@@ -809,6 +814,8 @@ class AzureCompletion(BaseLLM):
from_agent=from_agent,
messages=params["messages"],
usage=usage,
finish_reason=finish_reason,
response_id=response_id,
)
return structured_data
@@ -848,6 +855,8 @@ class AzureCompletion(BaseLLM):
usage = self._extract_azure_token_usage(response)
self._track_token_usage_internal(usage)
finish_reason, response_id = self._extract_finish_reason_and_id(response)
# Without available_functions, return tool_calls so the caller (executor) handles execution
if message.tool_calls and not available_functions:
self._emit_call_completed_event(
@@ -857,6 +866,8 @@ class AzureCompletion(BaseLLM):
from_agent=from_agent,
messages=params["messages"],
usage=usage,
finish_reason=finish_reason,
response_id=response_id,
)
return list(message.tool_calls)
@@ -892,6 +903,8 @@ class AzureCompletion(BaseLLM):
from_task=from_task,
from_agent=from_agent,
usage=usage,
finish_reason=finish_reason,
response_id=response_id,
)
content = self._apply_stop_words(content)
@@ -903,6 +916,8 @@ class AzureCompletion(BaseLLM):
from_agent=from_agent,
messages=params["messages"],
usage=usage,
finish_reason=finish_reason,
response_id=response_id,
)
return self._invoke_after_llm_call_hooks(
@@ -1011,6 +1026,8 @@ class AzureCompletion(BaseLLM):
from_task: Any | None = None,
from_agent: Any | None = None,
response_model: type[BaseModel] | None = None,
finish_reason: str | None = None,
response_id: str | None = None,
) -> str | Any:
"""Finalize streaming response with usage tracking, tool execution, and events.
@@ -1039,6 +1056,8 @@ class AzureCompletion(BaseLLM):
from_task=from_task,
from_agent=from_agent,
usage=usage_data,
finish_reason=finish_reason,
response_id=response_id,
)
# Without available_functions, return tool calls in OpenAI-compatible format for the executor
@@ -1061,6 +1080,8 @@ class AzureCompletion(BaseLLM):
from_agent=from_agent,
messages=params["messages"],
usage=usage_data,
finish_reason=finish_reason,
response_id=response_id,
)
return formatted_tool_calls
@@ -1094,6 +1115,8 @@ class AzureCompletion(BaseLLM):
from_agent=from_agent,
messages=params["messages"],
usage=usage_data,
finish_reason=finish_reason,
response_id=response_id,
)
return self._invoke_after_llm_call_hooks(
@@ -1113,8 +1136,16 @@ class AzureCompletion(BaseLLM):
tool_calls: dict[int, dict[str, Any]] = {}
usage_data: dict[str, Any] | None = None
stream_finish_reason: str | None = None
stream_response_id: str | None = None
for update in self._get_sync_client().complete(**params):
if isinstance(update, StreamingChatCompletionsUpdate):
chunk_finish, chunk_id = self._extract_finish_reason_and_id(update)
if chunk_finish:
stream_finish_reason = chunk_finish
if chunk_id:
stream_response_id = chunk_id
if update.usage:
usage = update.usage
usage_data = {
@@ -1141,6 +1172,8 @@ class AzureCompletion(BaseLLM):
from_task=from_task,
from_agent=from_agent,
response_model=response_model,
finish_reason=stream_finish_reason,
response_id=stream_response_id,
)
async def _ahandle_completion(
@@ -1180,10 +1213,18 @@ class AzureCompletion(BaseLLM):
tool_calls: dict[int, dict[str, Any]] = {}
usage_data: dict[str, Any] | None = None
stream_finish_reason: str | None = None
stream_response_id: str | None = None
stream = await self._get_async_client().complete(**params)
async for update in stream:
if isinstance(update, StreamingChatCompletionsUpdate):
chunk_finish, chunk_id = self._extract_finish_reason_and_id(update)
if chunk_finish:
stream_finish_reason = chunk_finish
if chunk_id:
stream_response_id = chunk_id
if hasattr(update, "usage") and update.usage:
usage = update.usage
usage_data = {
@@ -1210,6 +1251,8 @@ class AzureCompletion(BaseLLM):
from_task=from_task,
from_agent=from_agent,
response_model=response_model,
finish_reason=stream_finish_reason,
response_id=stream_response_id,
)
def supports_function_calling(self) -> bool:
@@ -1271,6 +1314,19 @@ class AzureCompletion(BaseLLM):
return int(8192 * CONTEXT_WINDOW_USAGE_RATIO)
def _effective_max_tokens(self) -> int | float | None:
"""Azure reasoning/newer chat models cap via ``max_completion_tokens``."""
return self.max_tokens or self.max_completion_tokens
@staticmethod
def _extract_finish_reason_and_id(
response_or_update: Any,
) -> tuple[str | None, str | None]:
"""Azure ``ChatCompletions`` / ``StreamingChatCompletionsUpdate``
share the choices-shape; delegate to the shared extractor.
"""
return extract_choices_finish_reason_and_id(response_or_update)
@staticmethod
def _extract_azure_token_usage(response: ChatCompletions) -> dict[str, Any]:
"""Extract token usage and response metadata from Azure response."""

View File

@@ -677,7 +677,7 @@ class BedrockCompletion(BaseLLM):
if usage:
self._track_token_usage_internal(usage)
stop_reason = response.get("stopReason")
stop_reason, response_id = self._extract_finish_reason_and_id(response)
if stop_reason:
logging.debug(f"Response stop reason: {stop_reason}")
if stop_reason == "max_tokens":
@@ -716,6 +716,8 @@ class BedrockCompletion(BaseLLM):
from_agent=from_agent,
messages=messages,
usage=usage,
finish_reason=stop_reason,
response_id=response_id,
)
return result
except Exception as e:
@@ -738,6 +740,8 @@ class BedrockCompletion(BaseLLM):
from_agent=from_agent,
messages=messages,
usage=usage,
finish_reason=stop_reason,
response_id=response_id,
)
return non_structured_output_tool_uses
@@ -812,6 +816,8 @@ class BedrockCompletion(BaseLLM):
from_agent=from_agent,
messages=messages,
usage=usage,
finish_reason=stop_reason,
response_id=response_id,
)
return self._invoke_after_llm_call_hooks(
@@ -951,7 +957,9 @@ class BedrockCompletion(BaseLLM):
)
stream = response.get("stream")
response_id = None
_, stream_response_id = self._extract_finish_reason_and_id(response)
response_id = stream_response_id
stream_finish_reason: str | None = None
if stream:
for event in stream:
if "messageStart" in event:
@@ -1042,6 +1050,9 @@ class BedrockCompletion(BaseLLM):
result = response_model.model_validate(
function_args
)
# contentBlockStop fires before messageStop sets
# stream_finish_reason; structured output always
# completes via the tool-call path.
self._emit_call_completed_event(
response=result.model_dump_json(),
call_type=LLMCallType.LLM_CALL,
@@ -1049,6 +1060,9 @@ class BedrockCompletion(BaseLLM):
from_agent=from_agent,
messages=messages,
usage=usage_data,
finish_reason=stream_finish_reason
or "tool_use",
response_id=response_id,
)
return result # type: ignore[return-value]
except Exception as e:
@@ -1102,6 +1116,7 @@ class BedrockCompletion(BaseLLM):
tool_use_id = None
elif "messageStop" in event:
stop_reason = event["messageStop"].get("stopReason")
stream_finish_reason = stop_reason
logging.debug(f"Streaming message stopped: {stop_reason}")
if stop_reason == "max_tokens":
logging.warning(
@@ -1147,6 +1162,8 @@ class BedrockCompletion(BaseLLM):
from_agent=from_agent,
messages=messages,
usage=usage_data,
finish_reason=stream_finish_reason,
response_id=response_id,
)
return full_response
@@ -1262,7 +1279,7 @@ class BedrockCompletion(BaseLLM):
if usage:
self._track_token_usage_internal(usage)
stop_reason = response.get("stopReason")
stop_reason, response_id = self._extract_finish_reason_and_id(response)
if stop_reason:
logging.debug(f"Response stop reason: {stop_reason}")
if stop_reason == "max_tokens":
@@ -1300,6 +1317,8 @@ class BedrockCompletion(BaseLLM):
from_agent=from_agent,
messages=messages,
usage=usage,
finish_reason=stop_reason,
response_id=response_id,
)
return result
except Exception as e:
@@ -1322,6 +1341,8 @@ class BedrockCompletion(BaseLLM):
from_agent=from_agent,
messages=messages,
usage=usage,
finish_reason=stop_reason,
response_id=response_id,
)
return non_structured_output_tool_uses
@@ -1397,6 +1418,8 @@ class BedrockCompletion(BaseLLM):
from_agent=from_agent,
messages=messages,
usage=usage,
finish_reason=stop_reason,
response_id=response_id,
)
return text_content
@@ -1531,7 +1554,9 @@ class BedrockCompletion(BaseLLM):
)
stream = response.get("stream")
response_id = None
_, stream_response_id = self._extract_finish_reason_and_id(response)
response_id = stream_response_id
stream_finish_reason: str | None = None
if stream:
async for event in stream:
if "messageStart" in event:
@@ -1623,6 +1648,9 @@ class BedrockCompletion(BaseLLM):
result = response_model.model_validate(
function_args
)
# contentBlockStop fires before messageStop sets
# stream_finish_reason; structured output always
# completes via the tool-call path.
self._emit_call_completed_event(
response=result.model_dump_json(),
call_type=LLMCallType.LLM_CALL,
@@ -1630,6 +1658,9 @@ class BedrockCompletion(BaseLLM):
from_agent=from_agent,
messages=messages,
usage=usage_data,
finish_reason=stream_finish_reason
or "tool_use",
response_id=response_id,
)
return result # type: ignore[return-value]
except Exception as e:
@@ -1687,6 +1718,7 @@ class BedrockCompletion(BaseLLM):
elif "messageStop" in event:
stop_reason = event["messageStop"].get("stopReason")
stream_finish_reason = stop_reason
logging.debug(f"Streaming message stopped: {stop_reason}")
if stop_reason == "max_tokens":
logging.warning(
@@ -1733,6 +1765,8 @@ class BedrockCompletion(BaseLLM):
from_agent=from_agent,
messages=messages,
usage=usage_data,
finish_reason=stream_finish_reason,
response_id=response_id,
)
return self._invoke_after_llm_call_hooks(
@@ -1988,6 +2022,25 @@ class BedrockCompletion(BaseLLM):
return config
@staticmethod
def _extract_finish_reason_and_id(
response: Any,
) -> tuple[str | None, str | None]:
"""Extract raw finish_reason (``stopReason``) from a Bedrock Converse
response dict. Defensive — returns (None, None) on any failure.
Bedrock Converse has no model-level response id; ResponseMetadata.RequestId
is an AWS infra trace id (semantically different from OpenAI's chatcmpl-XXX),
so we omit response_id rather than mislead downstream telemetry consumers.
"""
finish_reason: str | None = None
try:
if isinstance(response, dict):
finish_reason = response.get("stopReason")
except (AttributeError, KeyError, TypeError, IndexError):
finish_reason = None
return finish_reason, None
def _handle_client_error(self, e: ClientError) -> str:
"""Handle AWS ClientError with specific error codes and return error message."""
error_code = e.response.get("Error", {}).get("Code", "Unknown")

View File

@@ -682,6 +682,8 @@ class GeminiCompletion(BaseLLM):
from_task: Any | None = None,
from_agent: Any | None = None,
usage: dict[str, Any] | None = None,
finish_reason: str | None = None,
response_id: str | None = None,
) -> BaseModel:
"""Validate content against response model and emit completion event.
@@ -691,6 +693,8 @@ class GeminiCompletion(BaseLLM):
messages_for_event: Messages to include in event
from_task: Task that initiated the call
from_agent: Agent that initiated the call
finish_reason: Raw provider finish reason.
response_id: Raw provider response id.
Returns:
Validated Pydantic model instance
@@ -708,6 +712,8 @@ class GeminiCompletion(BaseLLM):
from_agent=from_agent,
messages=messages_for_event,
usage=usage,
finish_reason=finish_reason,
response_id=response_id,
)
return structured_data
@@ -724,6 +730,8 @@ class GeminiCompletion(BaseLLM):
from_task: Any | None = None,
from_agent: Any | None = None,
usage: dict[str, Any] | None = None,
finish_reason: str | None = None,
response_id: str | None = None,
) -> str | BaseModel:
"""Finalize completion response with validation and event emission.
@@ -747,6 +755,8 @@ class GeminiCompletion(BaseLLM):
from_task=from_task,
from_agent=from_agent,
usage=usage,
finish_reason=finish_reason,
response_id=response_id,
)
self._emit_call_completed_event(
@@ -756,6 +766,8 @@ class GeminiCompletion(BaseLLM):
from_agent=from_agent,
messages=messages_for_event,
usage=usage,
finish_reason=finish_reason,
response_id=response_id,
)
return self._invoke_after_llm_call_hooks(
@@ -770,6 +782,8 @@ class GeminiCompletion(BaseLLM):
from_task: Any | None = None,
from_agent: Any | None = None,
usage: dict[str, Any] | None = None,
finish_reason: str | None = None,
response_id: str | None = None,
) -> BaseModel:
"""Validate and emit event for structured_output tool call.
@@ -795,6 +809,8 @@ class GeminiCompletion(BaseLLM):
from_agent=from_agent,
messages=self._convert_contents_to_dict(contents),
usage=usage,
finish_reason=finish_reason,
response_id=response_id,
)
return validated_data
except Exception as e:
@@ -828,6 +844,8 @@ class GeminiCompletion(BaseLLM):
Returns:
Final response content or function call result
"""
finish_reason, response_id = self._extract_finish_reason_and_id(response)
if response.candidates and (self.tools or available_functions):
candidate = response.candidates[0]
if candidate.content and candidate.content.parts:
@@ -854,6 +872,8 @@ class GeminiCompletion(BaseLLM):
from_task=from_task,
from_agent=from_agent,
usage=usage,
finish_reason=finish_reason,
response_id=response_id,
)
non_structured_output_parts = [
@@ -875,6 +895,8 @@ class GeminiCompletion(BaseLLM):
from_agent=from_agent,
messages=self._convert_contents_to_dict(contents),
usage=usage,
finish_reason=finish_reason,
response_id=response_id,
)
return non_structured_output_parts
@@ -915,6 +937,8 @@ class GeminiCompletion(BaseLLM):
from_task=from_task,
from_agent=from_agent,
usage=usage,
finish_reason=finish_reason,
response_id=response_id,
)
def _process_stream_chunk(
@@ -925,7 +949,13 @@ class GeminiCompletion(BaseLLM):
usage_data: dict[str, int] | None,
from_task: Any | None = None,
from_agent: Any | None = None,
) -> tuple[str, dict[int, dict[str, Any]], dict[str, int] | None]:
) -> tuple[
str,
dict[int, dict[str, Any]],
dict[str, int] | None,
str | None,
str | None,
]:
"""Process a single streaming chunk.
Args:
@@ -937,9 +967,13 @@ class GeminiCompletion(BaseLLM):
from_agent: Agent that initiated the call
Returns:
Tuple of (updated full_response, updated function_calls, updated usage_data)
Tuple of (updated full_response, updated function_calls, updated
usage_data, chunk finish_reason, chunk response_id).
"""
response_id = chunk.response_id if hasattr(chunk, "response_id") else None
chunk_finish_reason, chunk_response_id = self._extract_finish_reason_and_id(
chunk
)
if chunk.usage_metadata:
usage_data = self._extract_token_usage(chunk)
@@ -996,7 +1030,13 @@ class GeminiCompletion(BaseLLM):
response_id=response_id,
)
return full_response, function_calls, usage_data
return (
full_response,
function_calls,
usage_data,
chunk_finish_reason,
chunk_response_id,
)
def _finalize_streaming_response(
self,
@@ -1008,6 +1048,8 @@ class GeminiCompletion(BaseLLM):
from_task: Any | None = None,
from_agent: Any | None = None,
response_model: type[BaseModel] | None = None,
finish_reason: str | None = None,
response_id: str | None = None,
) -> str | BaseModel | list[dict[str, Any]]:
"""Finalize streaming response with usage tracking, function execution, and events.
@@ -1038,6 +1080,8 @@ class GeminiCompletion(BaseLLM):
from_task=from_task,
from_agent=from_agent,
usage=usage_data,
finish_reason=finish_reason,
response_id=response_id,
)
non_structured_output_calls = {
@@ -1058,6 +1102,8 @@ class GeminiCompletion(BaseLLM):
from_agent=from_agent,
messages=self._convert_contents_to_dict(contents),
usage=usage_data,
finish_reason=finish_reason,
response_id=response_id,
)
return raw_parts
@@ -1095,6 +1141,8 @@ class GeminiCompletion(BaseLLM):
from_task=from_task,
from_agent=from_agent,
usage=usage_data,
finish_reason=finish_reason,
response_id=response_id,
)
def _handle_completion(
@@ -1148,6 +1196,8 @@ class GeminiCompletion(BaseLLM):
full_response = ""
function_calls: dict[int, dict[str, Any]] = {}
usage_data: dict[str, int] | None = None
stream_finish_reason: str | None = None
stream_response_id: str | None = None
# The API accepts list[Content] but mypy is overly strict about variance
contents_for_api: Any = contents
@@ -1156,7 +1206,13 @@ class GeminiCompletion(BaseLLM):
contents=contents_for_api,
config=config,
):
full_response, function_calls, usage_data = self._process_stream_chunk(
(
full_response,
function_calls,
usage_data,
chunk_finish_reason,
chunk_response_id,
) = self._process_stream_chunk(
chunk=chunk,
full_response=full_response,
function_calls=function_calls,
@@ -1164,6 +1220,10 @@ class GeminiCompletion(BaseLLM):
from_task=from_task,
from_agent=from_agent,
)
if chunk_finish_reason:
stream_finish_reason = chunk_finish_reason
if chunk_response_id:
stream_response_id = chunk_response_id
return self._finalize_streaming_response(
full_response=full_response,
@@ -1174,6 +1234,8 @@ class GeminiCompletion(BaseLLM):
from_task=from_task,
from_agent=from_agent,
response_model=response_model,
finish_reason=stream_finish_reason,
response_id=stream_response_id,
)
async def _ahandle_completion(
@@ -1227,6 +1289,8 @@ class GeminiCompletion(BaseLLM):
full_response = ""
function_calls: dict[int, dict[str, Any]] = {}
usage_data: dict[str, int] | None = None
stream_finish_reason: str | None = None
stream_response_id: str | None = None
# The API accepts list[Content] but mypy is overly strict about variance
contents_for_api: Any = contents
@@ -1236,7 +1300,13 @@ class GeminiCompletion(BaseLLM):
config=config,
)
async for chunk in stream:
full_response, function_calls, usage_data = self._process_stream_chunk(
(
full_response,
function_calls,
usage_data,
chunk_finish_reason,
chunk_response_id,
) = self._process_stream_chunk(
chunk=chunk,
full_response=full_response,
function_calls=function_calls,
@@ -1244,6 +1314,10 @@ class GeminiCompletion(BaseLLM):
from_task=from_task,
from_agent=from_agent,
)
if chunk_finish_reason:
stream_finish_reason = chunk_finish_reason
if chunk_response_id:
stream_response_id = chunk_response_id
return self._finalize_streaming_response(
full_response=full_response,
@@ -1254,6 +1328,8 @@ class GeminiCompletion(BaseLLM):
from_task=from_task,
from_agent=from_agent,
response_model=response_model,
finish_reason=stream_finish_reason,
response_id=stream_response_id,
)
def supports_function_calling(self) -> bool:
@@ -1300,6 +1376,34 @@ class GeminiCompletion(BaseLLM):
return int(1048576 * CONTEXT_WINDOW_USAGE_RATIO) # 1M tokens default
def _effective_max_tokens(self) -> int | float | None:
"""Gemini caps generation via ``max_output_tokens``."""
return self.max_output_tokens or self.max_tokens
@staticmethod
def _extract_finish_reason_and_id(
response: Any,
) -> tuple[str | None, str | None]:
"""Extract raw finish_reason and response_id from a Gemini
``GenerateContentResponse``. ``finish_reason`` is the protobuf enum's
``.name`` attribute (e.g. ``"STOP"``, ``"MAX_TOKENS"``); we forward
it raw and let downstream telemetry map to the OTel GenAI enum.
"""
raw_response_id = getattr(response, "response_id", None)
response_id = raw_response_id if isinstance(raw_response_id, str) else None
finish_reason: str | None = None
candidates = getattr(response, "candidates", None)
if candidates:
try:
candidate_finish = getattr(candidates[0], "finish_reason", None)
except (IndexError, TypeError, KeyError):
candidate_finish = None
if candidate_finish is not None:
name = getattr(candidate_finish, "name", None)
finish_reason = name if isinstance(name, str) else None
return finish_reason, response_id
@staticmethod
def _extract_token_usage(response: GenerateContentResponse) -> dict[str, Any]:
"""Extract token usage and response metadata from Gemini response."""

View File

@@ -29,6 +29,7 @@ from openai.types.responses import (
from pydantic import BaseModel, PrivateAttr, model_validator
from crewai.events.types.llm_events import LLMCallType
from crewai.llms._finish_reason_utils import extract_choices_finish_reason_and_id
from crewai.llms.base_llm import BaseLLM, JsonResponseFormat, llm_call_context
from crewai.llms.hooks.base import BaseInterceptor
from crewai.llms.hooks.transport import AsyncHTTPTransport, HTTPTransport
@@ -825,6 +826,10 @@ class OpenAICompletion(BaseLLM):
usage = self._extract_responses_token_usage(response)
self._track_token_usage_internal(usage)
finish_reason, response_id = self._extract_responses_finish_reason_and_id(
response
)
if self.parse_tool_outputs:
parsed_result = self._extract_builtin_tool_outputs(response)
parsed_result.text = self._apply_stop_words(parsed_result.text)
@@ -836,6 +841,8 @@ class OpenAICompletion(BaseLLM):
from_agent=from_agent,
messages=params.get("input", []),
usage=usage,
finish_reason=finish_reason,
response_id=response_id,
)
return parsed_result
@@ -849,6 +856,8 @@ class OpenAICompletion(BaseLLM):
from_agent=from_agent,
messages=params.get("input", []),
usage=usage,
finish_reason=finish_reason,
response_id=response_id,
)
return function_calls
@@ -887,6 +896,8 @@ class OpenAICompletion(BaseLLM):
from_agent=from_agent,
messages=params.get("input", []),
usage=usage,
finish_reason=finish_reason,
response_id=response_id,
)
return structured_result
except ValueError as e:
@@ -901,6 +912,8 @@ class OpenAICompletion(BaseLLM):
from_agent=from_agent,
messages=params.get("input", []),
usage=usage,
finish_reason=finish_reason,
response_id=response_id,
)
content = self._invoke_after_llm_call_hooks(
@@ -960,6 +973,10 @@ class OpenAICompletion(BaseLLM):
usage = self._extract_responses_token_usage(response)
self._track_token_usage_internal(usage)
finish_reason, response_id = self._extract_responses_finish_reason_and_id(
response
)
if self.parse_tool_outputs:
parsed_result = self._extract_builtin_tool_outputs(response)
parsed_result.text = self._apply_stop_words(parsed_result.text)
@@ -971,6 +988,8 @@ class OpenAICompletion(BaseLLM):
from_agent=from_agent,
messages=params.get("input", []),
usage=usage,
finish_reason=finish_reason,
response_id=response_id,
)
return parsed_result
@@ -984,6 +1003,8 @@ class OpenAICompletion(BaseLLM):
from_agent=from_agent,
messages=params.get("input", []),
usage=usage,
finish_reason=finish_reason,
response_id=response_id,
)
return function_calls
@@ -1022,6 +1043,8 @@ class OpenAICompletion(BaseLLM):
from_agent=from_agent,
messages=params.get("input", []),
usage=usage,
finish_reason=finish_reason,
response_id=response_id,
)
return structured_result
except ValueError as e:
@@ -1036,6 +1059,8 @@ class OpenAICompletion(BaseLLM):
from_agent=from_agent,
messages=params.get("input", []),
usage=usage,
finish_reason=finish_reason,
response_id=response_id,
)
except NotFoundError as e:
@@ -1123,6 +1148,12 @@ class OpenAICompletion(BaseLLM):
usage = self._extract_responses_token_usage(event.response)
self._track_token_usage_internal(usage)
finish_reason, response_id = (
self._extract_responses_finish_reason_and_id(final_response)
if final_response is not None
else (None, response_id_stream)
)
if self.parse_tool_outputs and final_response:
parsed_result = self._extract_builtin_tool_outputs(final_response)
parsed_result.text = self._apply_stop_words(parsed_result.text)
@@ -1134,6 +1165,8 @@ class OpenAICompletion(BaseLLM):
from_agent=from_agent,
messages=params.get("input", []),
usage=usage,
finish_reason=finish_reason,
response_id=response_id,
)
return parsed_result
@@ -1171,6 +1204,8 @@ class OpenAICompletion(BaseLLM):
from_agent=from_agent,
messages=params.get("input", []),
usage=usage,
finish_reason=finish_reason,
response_id=response_id,
)
return structured_result
except ValueError as e:
@@ -1185,6 +1220,8 @@ class OpenAICompletion(BaseLLM):
from_agent=from_agent,
messages=params.get("input", []),
usage=usage,
finish_reason=finish_reason,
response_id=response_id,
)
return self._invoke_after_llm_call_hooks(
@@ -1248,6 +1285,12 @@ class OpenAICompletion(BaseLLM):
usage = self._extract_responses_token_usage(event.response)
self._track_token_usage_internal(usage)
finish_reason, response_id = (
self._extract_responses_finish_reason_and_id(final_response)
if final_response is not None
else (None, response_id_stream)
)
if self.parse_tool_outputs and final_response:
parsed_result = self._extract_builtin_tool_outputs(final_response)
parsed_result.text = self._apply_stop_words(parsed_result.text)
@@ -1259,6 +1302,8 @@ class OpenAICompletion(BaseLLM):
from_agent=from_agent,
messages=params.get("input", []),
usage=usage,
finish_reason=finish_reason,
response_id=response_id,
)
return parsed_result
@@ -1296,6 +1341,8 @@ class OpenAICompletion(BaseLLM):
from_agent=from_agent,
messages=params.get("input", []),
usage=usage,
finish_reason=finish_reason,
response_id=response_id,
)
return structured_result
except ValueError as e:
@@ -1310,6 +1357,8 @@ class OpenAICompletion(BaseLLM):
from_agent=from_agent,
messages=params.get("input", []),
usage=usage,
finish_reason=finish_reason,
response_id=response_id,
)
return full_response
@@ -1603,6 +1652,9 @@ class OpenAICompletion(BaseLLM):
usage = self._extract_openai_token_usage(parsed_response)
self._track_token_usage_internal(usage)
parsed_finish_reason, parsed_response_id = (
self._extract_chat_finish_reason_and_id(parsed_response)
)
parsed_object = parsed_response.choices[0].message.parsed
if parsed_object:
self._emit_call_completed_event(
@@ -1612,6 +1664,8 @@ class OpenAICompletion(BaseLLM):
from_agent=from_agent,
messages=params["messages"],
usage=usage,
finish_reason=parsed_finish_reason,
response_id=parsed_response_id,
)
return parsed_object
@@ -1625,6 +1679,9 @@ class OpenAICompletion(BaseLLM):
choice: Choice = response.choices[0]
message = choice.message
finish_reason, response_id = self._extract_chat_finish_reason_and_id(
response
)
# Without available_functions, return tool_calls so the caller (executor) handles execution
if message.tool_calls and not available_functions:
@@ -1635,6 +1692,8 @@ class OpenAICompletion(BaseLLM):
from_agent=from_agent,
messages=params["messages"],
usage=usage,
finish_reason=finish_reason,
response_id=response_id,
)
return list(message.tool_calls)
@@ -1675,6 +1734,8 @@ class OpenAICompletion(BaseLLM):
from_agent=from_agent,
messages=params["messages"],
usage=usage,
finish_reason=finish_reason,
response_id=response_id,
)
return structured_result
except ValueError as e:
@@ -1689,6 +1750,8 @@ class OpenAICompletion(BaseLLM):
from_agent=from_agent,
messages=params["messages"],
usage=usage,
finish_reason=finish_reason,
response_id=response_id,
)
if usage.get("total_tokens", 0) > 0:
@@ -1734,6 +1797,8 @@ class OpenAICompletion(BaseLLM):
available_functions: dict[str, Any] | None = None,
from_task: Any | None = None,
from_agent: Any | None = None,
finish_reason: str | None = None,
response_id: str | None = None,
) -> str | list[dict[str, Any]]:
"""Finalize a streaming response with usage tracking, tool call handling, and events.
@@ -1745,6 +1810,9 @@ class OpenAICompletion(BaseLLM):
available_functions: Available functions for tool calling.
from_task: Task that initiated the call.
from_agent: Agent that initiated the call.
finish_reason: Raw provider finish reason (e.g. "stop", "length",
"tool_calls") extracted from the last streaming chunk.
response_id: Raw provider response id from any chunk.
Returns:
Tool calls list when tools were invoked without available_functions,
@@ -1774,6 +1842,8 @@ class OpenAICompletion(BaseLLM):
from_agent=from_agent,
messages=params["messages"],
usage=usage_data,
finish_reason=finish_reason,
response_id=response_id,
)
return tool_calls_list
@@ -1817,6 +1887,8 @@ class OpenAICompletion(BaseLLM):
from_agent=from_agent,
messages=params["messages"],
usage=usage_data,
finish_reason=finish_reason,
response_id=response_id,
)
return full_response
@@ -1861,6 +1933,9 @@ class OpenAICompletion(BaseLLM):
if final_completion:
usage = self._extract_openai_token_usage(final_completion)
self._track_token_usage_internal(usage)
parsed_finish_reason, parsed_response_id = (
self._extract_chat_finish_reason_and_id(final_completion)
)
if final_completion.choices:
parsed_result = final_completion.choices[0].message.parsed
if parsed_result:
@@ -1871,6 +1946,8 @@ class OpenAICompletion(BaseLLM):
from_agent=from_agent,
messages=params["messages"],
usage=usage,
finish_reason=parsed_finish_reason,
response_id=parsed_response_id,
)
return parsed_result
@@ -1882,11 +1959,15 @@ class OpenAICompletion(BaseLLM):
)
usage_data: dict[str, Any] | None = None
stream_finish_reason: str | None = None
stream_response_id: str | None = None
for completion_chunk in completion_stream:
response_id_stream = (
completion_chunk.id if hasattr(completion_chunk, "id") else None
)
if response_id_stream:
stream_response_id = response_id_stream
if hasattr(completion_chunk, "usage") and completion_chunk.usage:
usage_data = self._extract_openai_token_usage(completion_chunk)
@@ -1897,6 +1978,9 @@ class OpenAICompletion(BaseLLM):
choice = completion_chunk.choices[0]
chunk_delta: ChoiceDelta = choice.delta
chunk_finish = getattr(choice, "finish_reason", None)
if chunk_finish:
stream_finish_reason = chunk_finish
if chunk_delta.content:
full_response += chunk_delta.content
@@ -1954,6 +2038,8 @@ class OpenAICompletion(BaseLLM):
available_functions=available_functions,
from_task=from_task,
from_agent=from_agent,
finish_reason=stream_finish_reason,
response_id=stream_response_id,
)
if isinstance(result, str):
return self._invoke_after_llm_call_hooks(
@@ -1989,6 +2075,9 @@ class OpenAICompletion(BaseLLM):
usage = self._extract_openai_token_usage(parsed_response)
self._track_token_usage_internal(usage)
parsed_finish_reason, parsed_response_id = (
self._extract_chat_finish_reason_and_id(parsed_response)
)
parsed_object = parsed_response.choices[0].message.parsed
if parsed_object:
self._emit_call_completed_event(
@@ -1998,6 +2087,8 @@ class OpenAICompletion(BaseLLM):
from_agent=from_agent,
messages=params["messages"],
usage=usage,
finish_reason=parsed_finish_reason,
response_id=parsed_response_id,
)
return parsed_object
@@ -2011,6 +2102,9 @@ class OpenAICompletion(BaseLLM):
choice: Choice = response.choices[0]
message = choice.message
finish_reason, response_id = self._extract_chat_finish_reason_and_id(
response
)
# Without available_functions, return tool_calls so the caller (executor) handles execution
if message.tool_calls and not available_functions:
@@ -2021,6 +2115,8 @@ class OpenAICompletion(BaseLLM):
from_agent=from_agent,
messages=params["messages"],
usage=usage,
finish_reason=finish_reason,
response_id=response_id,
)
return list(message.tool_calls)
@@ -2065,6 +2161,8 @@ class OpenAICompletion(BaseLLM):
from_agent=from_agent,
messages=params["messages"],
usage=usage,
finish_reason=finish_reason,
response_id=response_id,
)
return structured_result
except ValueError as e:
@@ -2079,6 +2177,8 @@ class OpenAICompletion(BaseLLM):
from_agent=from_agent,
messages=params["messages"],
usage=usage,
finish_reason=finish_reason,
response_id=response_id,
)
if usage.get("total_tokens", 0) > 0:
@@ -2130,8 +2230,12 @@ class OpenAICompletion(BaseLLM):
accumulated_content = ""
usage_data: dict[str, Any] | None = None
parsed_stream_finish_reason: str | None = None
parsed_stream_response_id: str | None = None
async for chunk in completion_stream:
response_id_stream = chunk.id if hasattr(chunk, "id") else None
if response_id_stream:
parsed_stream_response_id = response_id_stream
if hasattr(chunk, "usage") and chunk.usage:
usage_data = self._extract_openai_token_usage(chunk)
@@ -2142,6 +2246,9 @@ class OpenAICompletion(BaseLLM):
choice = chunk.choices[0]
delta: ChoiceDelta = choice.delta
chunk_finish = getattr(choice, "finish_reason", None)
if chunk_finish:
parsed_stream_finish_reason = chunk_finish
if delta.content:
accumulated_content += delta.content
@@ -2165,6 +2272,8 @@ class OpenAICompletion(BaseLLM):
from_agent=from_agent,
messages=params["messages"],
usage=usage_data,
finish_reason=parsed_stream_finish_reason,
response_id=parsed_stream_response_id,
)
return parsed_object
@@ -2177,6 +2286,8 @@ class OpenAICompletion(BaseLLM):
from_agent=from_agent,
messages=params["messages"],
usage=usage_data,
finish_reason=parsed_stream_finish_reason,
response_id=parsed_stream_response_id,
)
return accumulated_content
@@ -2185,9 +2296,13 @@ class OpenAICompletion(BaseLLM):
] = await self._get_async_client().chat.completions.create(**params)
usage_data = None
stream_finish_reason: str | None = None
stream_response_id: str | None = None
async for chunk in stream:
response_id_stream = chunk.id if hasattr(chunk, "id") else None
if response_id_stream:
stream_response_id = response_id_stream
if hasattr(chunk, "usage") and chunk.usage:
usage_data = self._extract_openai_token_usage(chunk)
@@ -2198,6 +2313,9 @@ class OpenAICompletion(BaseLLM):
choice = chunk.choices[0]
chunk_delta: ChoiceDelta = choice.delta
chunk_finish = getattr(choice, "finish_reason", None)
if chunk_finish:
stream_finish_reason = chunk_finish
if chunk_delta.content:
full_response += chunk_delta.content
@@ -2255,6 +2373,8 @@ class OpenAICompletion(BaseLLM):
available_functions=available_functions,
from_task=from_task,
from_agent=from_agent,
finish_reason=stream_finish_reason,
response_id=stream_response_id,
)
def supports_function_calling(self) -> bool:
@@ -2305,6 +2425,32 @@ class OpenAICompletion(BaseLLM):
return int(8192 * CONTEXT_WINDOW_USAGE_RATIO)
def _effective_max_tokens(self) -> int | float | None:
"""Newer OpenAI chat models cap via ``max_completion_tokens``."""
return self.max_tokens or self.max_completion_tokens
@staticmethod
def _extract_chat_finish_reason_and_id(
response: Any,
) -> tuple[str | None, str | None]:
"""ChatCompletion / ChatCompletionChunk share the choices-shape;
delegate to the shared extractor.
"""
return extract_choices_finish_reason_and_id(response)
@staticmethod
def _extract_responses_finish_reason_and_id(
response: Any,
) -> tuple[str | None, str | None]:
"""Extract finish_reason and response_id from an OpenAI Responses
API ``Response`` object. The Responses API exposes ``status`` rather
than ``finish_reason``; we forward the raw status value.
"""
return (
getattr(response, "status", None),
getattr(response, "id", None),
)
def _extract_openai_token_usage(
self, response: ChatCompletion | ChatCompletionChunk
) -> dict[str, Any]:

Some files were not shown because too many files have changed in this diff Show More