Compare commits

...

34 Commits

Author SHA1 Message Date
theCyberTech
0cc43b2720 feat: replace advisory pip-audit with blocking vuln process
New vulnerability scan process:
1. Run pip-audit without ignores on every PR
2. Classify vulns as direct or transitive (checks against all monorepo pyproject.toml files)
3. Direct vulns: auto-fix with pip-audit --fix and commit the bump to the PR branch
4. Transitive vulns: add to ignore list and create a GitHub issue for tracking
5. Re-run pip-audit with transitive ignores — PR passes only if direct vulns are resolved
6. Scheduled runs also validate that previously ignored vulns are still unfixable

Removes continue-on-error: true so the action actually blocks.
2026-06-06 15:48:26 +08:00
Lorenze Jay
913a3abead docs: update changelog and version for v1.14.7a2 (#6055)
Some checks are pending
CodeQL Advanced / Analyze (python) (push) Waiting to run
CodeQL Advanced / Analyze (actions) (push) Waiting to run
Check Documentation Broken Links / Check broken links (push) Waiting to run
Vulnerability Scan / pip-audit (push) Waiting to run
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
* 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
Vini Brasil
906cd9769d feat(flow): type DSL triggers as route-aware decorators (#6042)
Some checks failed
CodeQL Advanced / Analyze (actions) (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Vulnerability Scan / pip-audit (push) Has been cancelled
Check Documentation Broken Links / Check broken links (push) Has been cancelled
Nightly Canary Release / Check for new commits (push) Has been cancelled
Nightly Canary Release / Build nightly packages (push) Has been cancelled
Nightly Canary Release / Publish nightly to PyPI (push) Has been cancelled
Mark stale issues and pull requests / stale (push) Has been cancelled
Centralize FlowTrigger and FlowMethodDecorator so start/listen/router and the boolean trigger helpers share one authoring contract. This preserves decorated method signatures for static checking while allowing route-label strings in nested FlowCondition data.

Export the shared typing helpers for static analyzers, use an explicit Protocol body, align condition validation with Sequence-backed condition data, and drop the stale call-arg ignore exposed by the signature-preserving decorators.

Update the flow guide to use or_(...) for multi-label listeners.
2026-06-04 18:07:49 -03:00
Lorenze Jay
14ce97d787 chat api for convo flows (#6034)
* Add conversational Flow chat helper

* Document conversational flow chat APIs in translations

* Stringify conversational chat REPL output
2026-06-04 13:36:48 -07:00
Matt Aitchison
f3a15a4f07 feat(lock_store): make locking backend overridable (#6015)
* feat(lock_store): make locking backend overridable

Allow the centralised lock factory to use a pluggable backend instead of
the hardcoded Redis/file selection. Backends are resolved with precedence
override > CREWAI_LOCK_FACTORY env > built-in default:

- set_lock_backend()/reset_lock_backend() and a scoped lock_backend()
  context manager for programmatic overrides
- CREWAI_LOCK_FACTORY="module:callable" env import-path, resolved lazily
  and cached, with clear errors on malformed or non-callable specs
- LockBackend Protocol documenting the contract (raw name in, context
  manager out; backend owns its namespacing)

Default Redis/file behavior is unchanged when nothing is overridden.

* refactor(lock_store): use explicit body for LockBackend protocol method

Replace the no-op `...` body with `raise NotImplementedError` to satisfy
the CodeQL ineffectual-statement check while keeping the Protocol
structural-typing only.

* refactor(lock_store): drop scoped lock_backend context manager

Keep the backend overridable via set_lock_backend/reset_lock_backend and
the CREWAI_LOCK_FACTORY env path, but remove the scoped lock_backend()
context manager. It was speculative surface and the only thread-unsafe
piece (racy save/restore of the module global); nothing depends on it.

* refactor(lock_store): drop reset_lock_backend alias

reset_lock_backend() was just set_lock_backend(None); callers use that
directly. Clearing the override is documented on set_lock_backend.

* style(lock_store): apply ruff format

* refactor(lock_store): simplify overridable backend to a single setter

Reduce the override surface to just set_lock_backend(): lock() uses the
custom backend when one is set, otherwise the unchanged Redis/file default.

Drop the CREWAI_LOCK_FACTORY env import-path, the runtime_checkable
Protocol, the precedence resolver, and the getter — a custom backend is
now any callable(name, *, timeout) -> context manager, registered in
process.

* fix(lock_store): snapshot backend to avoid check-then-call race

Read the module-global backend once into a local before the None check
and the call, so a concurrent set_lock_backend(None) cannot make lock()
invoke None.

* docs(lock_store): clarify name handling for custom backends

The default namespaces the lock name; custom backends receive it
verbatim. Correct the lock() docstring which implied namespacing always
happens.

* docs(lock_store): note set_lock_backend is for one-time startup setup
2026-06-04 13:28:31 -05:00
Vini Brasil
75dad212a2 Split flow DSL monolith into focused decorator modules (#6040)
The Flow DSL lived in one 1033-line `dsl.py` that mixed every decorator
(`@start`/`@listen`/`@router`), the `human_feedback` decorator,
condition combinators, and FlowDefinition extraction helpers in a single
file.

Split it into a `dsl/` package where each decorator gets its own module
(`start.py` 68 lines, `listen.py` 55, `router.py` 164,
`human_feedback.py` 98) and the shared extraction/condition helpers stay
in `utils.py`. The public API is re-exported from `dsl/__init__.py`, so
import paths are unchanged.

This is simpler because each decorator is now read and changed in
isolation instead of scanning a 1000-line file to find one of them, and
router-specific annotation parsing no longer sits next to unrelated
start/listen logic.
2026-06-04 15:02:06 -03:00
alex-clawd
aed69237d4 docs: add NVIDIA Nemotron LLM guide (#6037)
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
2026-06-04 09:22:41 -03:00
Vini Brasil
051fa0c1cb Build FlowDefinition from Flow DSL metadata (#6017)
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
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
* Build FlowDefinition from Flow DSL metadata

Introduce `FlowDefinition`, a serializable model built from the Flow
DSL's runtime metadata. It becomes the structural contract for Flow
methods, triggers, routers, state, and configuration.

The visualization layer is the first consumer: `flow_structure` and
`build_flow_structure` now project from the definition instead of
re-introspecting the class. The runner still executes from live
registries, but the definition gives future runners a single static
contract to read.

This replaces AST source parsing for router return values, crew
references, and state schema with runtime metadata plus explicit
`@router(paths=...)` or `Literal`/`Enum` return hints. AST parsing was
fragile and could silently fail for dynamic or non-inspectable methods.

The refactor removes obsolete introspection and serializer code:

* Delete `flow_serializer.py`, `flow/utils.py`, and
  `visualization/schema.py`
* Move flow structure modeling into `flow_definition.py`
* Simplify visualization building around the static definition contract

* Format files
2026-06-03 18:02:56 -03:00
Gui Vieira
73d20fb0c3 Document monorepo deployments (#6018)
* Document monorepo deployments

* Add localized monorepo docs
2026-06-03 17:01:10 -03:00
Lucas Gomide
d09e3f4544 feat: flatten LiteLLM cache/reasoning usage sub-counts in _usage_to_dict (#6033)
LiteLLM returns provider usage as-is, nesting cache-read / cache-creation /
reasoning counts under provider-specific shapes (e.g.
prompt_tokens_details.cached_tokens, Anthropic-style cache_read_input_tokens).
Surface them as flat cached_prompt_tokens / reasoning_tokens /
cache_creation_tokens keys so the span pipeline can read them; prompt /
completion / total token counts are left untouched.
2026-06-03 15:13:30 -04:00
Lorenze Jay
1357491f0d Lorenze/feat/conversational flows (#5896)
* feat: add conversational flows documentation and chat session support

- Introduced a new guide for building multi-turn chat applications using , detailing session management and message handling.
- Added  class to facilitate chat interactions, including streaming support and event handling.
- Implemented  for class-level defaults and improved input normalization for conversational turns.
- Enhanced event listeners to manage flow events and tracing more effectively, including support for nested crew executions.
- Added tests for conversational flow helpers and kickoff parameters to ensure functionality and reliability.

* linted

* feat: enhance flow event tracing and session management

- Updated TraceCollectionListener to handle nested flows without re-claiming parent session batches.
- Ensured that method execution events are always emitted for tracing, regardless of flow event suppression.
- Improved finalization logic for flow trace batches to respect session deferral flags.
- Added tests to verify that method execution events are emitted correctly when flow events are suppressed and that deferred session finalization is respected in nested flows.

* updated docs

* feat: introduce experimental conversational flow framework

- Added a new module for conversational flow, including classes for managing conversation state, messages, and events.
- Implemented  and  for structured intent handling and routing.
- Enhanced the  class to support turn-oriented conversational applications with built-in routing and message handling.
- Updated  to include new classes in the public API.
- Added tests to validate the functionality of the new conversational flow features.

* handled docs

* feat(flow): enhance conversational flow handling and tracing

- Introduced support for deferred multi-turn tracing to maintain continuous event sequences.
- Updated  method to delegate to restored checkpoint flows, improving session management.
- Added tests to validate the new tracing behavior and ensure correct event handling in conversational flows.

* fix multimodal test

* better conversational

* adjusted prompt

* drop unused

* fix test

* refactor: rename  to  and update related documentation

This commit refactors the  class to  for clarity and consistency across the codebase. The documentation has been updated to reflect this change, ensuring that references to the new  class are accurate. Additionally, the alias for legacy imports is maintained for backward compatibility. The changes enhance the overall structure and readability of the conversational flow implementation.

* fix test

* adding experimetnal indicators

* fix test and reloaded cassettes

* cleanup ConversationalFlow class

* addressing double finalization and fixed tests

* improve on emphemeral tracing and adddressing comments
2026-06-03 11:53:16 -07:00
Lorenze Jay
ea88904d35 docs: update changelog and version for v1.14.7a1 (#6032) 2026-06-03 10:40:43 -07:00
Lorenze Jay
be3cf62b63 feat: bump versions to 1.14.7a1 (#6031) 2026-06-03 10:30:33 -07:00
Greyson LaLonde
68cdd44520 fix(cli): restore [project.scripts] in crewai package for uv tool install 2026-06-03 09:50:39 -07:00
Greyson LaLonde
7676b0937c fix(deps): bump authlib to >=1.6.12 to patch PYSEC-2026-188 2026-06-03 09:45:59 -07:00
Greyson LaLonde
ee707028db chore: remove testing pdf from root
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
2026-06-02 17:53:26 -07:00
Lorenze Jay
770d1b284f Lorenze/fix/file input not working reliably (#6020)
* fix filesystem

* Refine commit message formatting

* fix for async kickoffs

* added suggestion
2026-06-02 17:14:51 -07:00
alex-clawd
b047c96756 Handle Snowflake Claude stringified tool calls (#6008)
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
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
* Handle Snowflake Claude stringified tool calls

* Fix Snowflake tool id type narrowing

* Extract Snowflake tool result text in summaries

* Bump PyJWT for vulnerability scan

---------

Co-authored-by: João Moura <joaomdmoura@gmail.com>
2026-06-02 19:37:18 -03:00
Greyson LaLonde
d37af0d404 perf(knowledge): lazy-load docling imports to speed up crewai import 2026-06-02 15:16:48 -07:00
Greyson LaLonde
c81b4fe11e fix(deps): bump pyjwt to >=2.13.0 to patch CVEs 2026-06-02 10:01:53 -07:00
Lorenze Jay
a9cb7867bb Add crew trained agents file support (#6012)
* Add crew trained agents file support

* Add crew trained agents file support
2026-06-02 09:38:34 -07:00
Jesse Miller
383ae66b55 docs: add Databricks integration guide (#6001)
Some checks failed
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
CodeQL Advanced / Analyze (python) (push) Has been cancelled
CodeQL Advanced / Analyze (actions) (push) Has been cancelled
Check Documentation Broken Links / Check broken links (push) Has been cancelled
Vulnerability Scan / pip-audit (push) Has been cancelled
* docs: add Databricks integration guide to enterprise integrations

Add documentation for connecting CrewAI agents to Databricks via the
Databricks managed MCP servers. Highlights Genie, Databricks SQL, Unity
Catalog Functions, and Vector Search, each configured as a separate MCP
connection, and covers OAuth/PAT setup. Includes ko, pt-BR, and ar
translations and registers the page in all docs.json navigation blocks.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: use locale-specific slugs for Databricks nav entries

Add databricks integration entries to pt-BR, ko, and ar nav blocks
using locale-specific prefixes instead of only having en/ entries.

Co-authored-by: Luzk <2128595+Luzk@users.noreply.github.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Iris <iris@crewai.com>
Co-authored-by: Luzk <2128595+Luzk@users.noreply.github.com>
Co-authored-by: Greyson LaLonde <greyson.r.lalonde@gmail.com>
Co-authored-by: Lucas Gomide <lucaslg200@gmail.com>
2026-06-02 09:43:05 -04:00
alex-clawd
774fd871a8 Fix Snowflake Claude incomplete tool result histories (#6006)
* Fix Snowflake Claude incomplete tool result histories

* Filter Snowflake Claude preserved tool results
2026-06-02 09:11:59 -03:00
alex-clawd
4a0769d97c Add native Snowflake Cortex LLM provider (#6005) 2026-06-02 08:10:13 -03:00
Greyson LaLonde
fee5b3e395 fix(devtools): point template bumper at lib/cli templates dir 2026-06-02 02:02:12 -07:00
devin-ai-integration[bot]
3010f1286f chore: widen click dependency constraint to allow 8.2+
Addresses #6002
2026-06-02 00:06:25 -07:00
Greyson LaLonde
e53a676c04 fix(flow): re-arm multi-source or_ listeners across router-driven cycles
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
The previous discard-after-body approach cleared the gate mid-wave, so
a slow parallel @start finishing after the listener body could re-fire
the same multi-source or_ listener. Re-arm only when a router emits a
signal that matches the listener's condition; parallel @start paths
never reach that branch and the race gate keeps protecting them.

Closes #5972
2026-06-01 15:24:58 -07:00
Vini Brasil
1aba9fe415 Split flow.py into DSL, definition, and runtime (#5997)
This commit separates the monolithic `flow.py` into three modules, each
with one job:

- `dsl.py` - the Python DSL for flows (@start/@listen/@router, or_/and_)
- `flow_definition.py` - the structural model extracted from the DSL
- `runtime.py` - the execution engine and state for flows

This phase moves code only and should not have any breaking changes.
2026-06-01 18:37:10 -03:00
Greyson LaLonde
4dafb05735 chore(deps): bump uv to >=0.11.15 and ignore unfixable chromadb CVE
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
uv 0.11.7 -> 0.11.17 patches GHSA-4gg8-gxpx-9rph. chromadb has no
patched release for GHSA-f4j7-r4q5-qw2c (server-only pre-auth RCE,
not reachable in our embedded use); ignore until upstream ships a fix.
2026-06-01 00:10:19 -07:00
Jesse Miller
5cdc420c50 docs: add Snowflake integration guide (#5977)
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
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
* docs: add Snowflake integration guide to enterprise integrations

Add documentation for connecting CrewAI agents to Snowflake via the
Snowflake-managed MCP server. Highlights Cortex Analyst, Cortex Search,
and SQL execution, and covers OAuth/PAT setup. Registers the page in
all docs.json navigation blocks.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: add Snowflake integration page for ko, ar, pt-BR

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Iris Clawd <iris@crewai.com>
2026-05-29 15:03:55 -04:00
143 changed files with 18281 additions and 7613 deletions

View File

@@ -9,7 +9,9 @@ on:
- cron: '0 9 * * 1'
permissions:
contents: read
contents: write
pull-requests: write
issues: write
jobs:
pip-audit:
@@ -18,7 +20,7 @@ jobs:
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
with:
persist-credentials: false
persist-credentials: ${{ github.event_name == 'pull_request' }}
- name: Restore global uv cache
id: cache-restore
@@ -46,42 +48,197 @@ jobs:
run: uv pip install pip-audit
- name: Run pip-audit
id: audit
run: |
uv run pip-audit --desc --aliases --skip-editable --format json --output pip-audit-report.json \
--ignore-vuln PYSEC-2024-277 \
--ignore-vuln PYSEC-2026-89 \
--ignore-vuln PYSEC-2026-97 \
--ignore-vuln PYSEC-2025-148 \
--ignore-vuln PYSEC-2025-183 \
--ignore-vuln PYSEC-2025-189 \
--ignore-vuln PYSEC-2025-190 \
--ignore-vuln PYSEC-2025-191 \
--ignore-vuln PYSEC-2025-192 \
--ignore-vuln PYSEC-2025-193 \
--ignore-vuln PYSEC-2025-194 \
--ignore-vuln PYSEC-2025-195 \
--ignore-vuln PYSEC-2025-196 \
--ignore-vuln PYSEC-2025-197 \
--ignore-vuln PYSEC-2025-210 \
--ignore-vuln PYSEC-2026-139 \
--ignore-vuln PYSEC-2025-211 \
--ignore-vuln PYSEC-2025-212 \
--ignore-vuln PYSEC-2025-213 \
--ignore-vuln PYSEC-2025-214 \
--ignore-vuln PYSEC-2025-215 \
--ignore-vuln PYSEC-2025-216 \
--ignore-vuln PYSEC-2025-217 \
--ignore-vuln PYSEC-2025-218
# Ignored CVEs:
# PYSEC-2024-277 - joblib 1.5.3: disputed; NumpyArrayWrapper only used with trusted caches
# PYSEC-2026-89 - markdown 3.10.2: DoS via malformed HTML; fix 3.8.1 — already past, advisory range is stale
# PYSEC-2026-97 - nltk 3.9.4: arbitrary file read in filestring(); no fix available
# PYSEC-2025-148 - onnx 1.21.0: path traversal in save_external_data; no fix available
# 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
# PYSEC-2025-211..218 - transformers 5.5.4: deserialization/code injection via malicious model checkpoints; no fix available
continue-on-error: true
uv run pip-audit --desc --aliases --skip-editable --format json --output pip-audit-report.json || true
# Intentionally ignore exit code — we parse the JSON ourselves below.
- name: Classify vulnerabilities
id: classify
run: |
set -euo pipefail
python3 << 'PYEOF'
import json, sys, glob, re
from pathlib import Path
# Collect direct deps from all pyproject.toml files in the monorepo
try:
import tomllib
except ImportError:
import tomli as tomllib
direct_deps = set()
for toml_path in glob.glob("**/pyproject.toml", recursive=True):
if "templates/" in toml_path or "node_modules/" in toml_path:
continue
try:
with open(toml_path, "rb") as f:
data = tomllib.load(f)
except Exception:
continue
project = data.get("project", {})
for dep_str in project.get("dependencies", []):
name = re.split(r"[><=!~\[]", dep_str)[0].strip().lower()
direct_deps.add(name)
for group_deps in project.get("optional-dependencies", {}).values():
for dep_str in group_deps:
name = re.split(r"[><=!~\[]", dep_str)[0].strip().lower()
direct_deps.add(name)
for group_deps in data.get("dependency-groups", {}).values():
if isinstance(group_deps, list):
for dep_str in group_deps:
if isinstance(dep_str, str):
name = re.split(r"[><=!~\[]", dep_str)[0].strip().lower()
direct_deps.add(name)
# Load pip-audit report
try:
with open("pip-audit-report.json") as f:
report = json.load(f)
except FileNotFoundError:
print("::error::pip-audit report not found")
sys.exit(1)
deps = report.get("dependencies", [])
vulns = [d for d in deps if d.get("vulns")]
if not vulns:
print("No vulnerabilities found")
Path("direct_vulns.txt").write_text("")
Path("transitive_vulns.txt").write_text("")
Path("transitive_ids.txt").write_text("")
sys.exit(0)
direct_vulns = []
transitive_vulns = []
transitive_ids = []
for dep in vulns:
name = dep["name"]
version = dep["version"]
is_direct = name.lower() in direct_deps
for v in dep["vulns"]:
entry = f"{name}=={version} ({v['id']})"
if is_direct:
direct_vulns.append(entry)
else:
transitive_vulns.append(entry)
transitive_ids.append(v['id'])
Path("direct_vulns.txt").write_text("\n".join(direct_vulns) if direct_vulns else "")
Path("transitive_vulns.txt").write_text("\n".join(transitive_vulns) if transitive_vulns else "")
Path("transitive_ids.txt").write_text("\n".join(transitive_ids) if transitive_ids else "")
print(f"Direct: {len(direct_vulns)}, Transitive: {len(transitive_vulns)}")
for v in direct_vulns:
print(f" DIRECT: {v}")
for v in transitive_vulns:
print(f" TRANSITIVE: {v}")
PYEOF
# Set outputs
if [ -s direct_vulns.txt ]; then
echo "has_direct=true" >> "$GITHUB_OUTPUT"
else
echo "has_direct=false" >> "$GITHUB_OUTPUT"
fi
if [ -s transitive_vulns.txt ]; then
echo "has_transitive=true" >> "$GITHUB_OUTPUT"
else
echo "has_transitive=false" >> "$GITHUB_OUTPUT"
fi
- name: Attempt fix for direct vulnerabilities
if: github.event_name == 'pull_request' && steps.classify.outputs.has_direct == 'true'
id: fix
run: |
set -euo pipefail
echo "Attempting to fix direct vulnerabilities..."
cat direct_vulns.txt
# Try pip-audit --fix to bump direct deps
uv run pip-audit --fix --skip-editable 2>&1 || true
# Check if uv.lock changed
if git diff --quiet uv.lock; then
echo "fixed=false" >> "$GITHUB_OUTPUT"
echo "::warning::Could not auto-fix direct vulnerabilities. Manual intervention required."
else
echo "fixed=true" >> "$GITHUB_OUTPUT"
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add uv.lock
git commit -m "fix: bump dependencies to resolve security vulnerabilities
Auto-fixed by vulnerability-scan workflow.
Resolved: $(cat direct_vulns.txt | tr '\n' ', ')"
git push
fi
- name: Add transitive vulns to ignore list and create issues
if: steps.classify.outputs.has_transitive == 'true'
id: ignore
run: |
set -euo pipefail
# Build --ignore-vuln flags from transitive vuln IDs
IGNORE_FLAGS=""
while IFS= read -r vuln_id; do
if [ -n "$vuln_id" ]; then
IGNORE_FLAGS="$IGNORE_FLAGS --ignore-vuln $vuln_id"
fi
done < transitive_ids.txt
echo "ignore_flags=$IGNORE_FLAGS" >> "$GITHUB_OUTPUT"
# Create GitHub issues for transitive vulns
while IFS= read -r line; do
if [ -z "$line" ]; then continue; fi
VULN_ID=$(echo "$line" | grep -oE '[A-Z]+-[0-9]+-[0-9]+|GHSA-[a-z0-9-]+' || true)
PKG=$(echo "$line" | cut -d'=' -f1)
# Check if issue already exists
EXISTING=$(gh issue list --label "security,transitive-vuln" --state open --json title \
--jq ".[] | select(.title | contains(\"$VULN_ID\"))" || true)
if [ -z "$EXISTING" ]; then
gh issue create \
--title "🔒 Transitive vulnerability: $VULN_ID in $PKG" \
--label "security,transitive-vuln" \
--body "## Transitive Dependency Vulnerability
**Package:** \`$line\`
**Vulnerability:** $VULN_ID
**Status:** No fix available upstream
This vulnerability is in a transitive dependency and cannot be fixed directly. It has been added to the pip-audit ignore list until an upstream fix is available.
### Action Required
- [ ] Monitor upstream for a fix
- [ ] Remove from ignore list once fixed
- [ ] Close this issue when resolved
_Auto-created by vulnerability-scan workflow._"
fi
done < <(cat transitive_vulns.txt)
- name: Re-run pip-audit with transitive ignores
if: steps.classify.outputs.has_transitive == 'true'
id: audit-final
run: |
IGNORE_FLAGS="${{ steps.ignore.outputs.ignore_flags }}"
eval uv run pip-audit --desc --aliases --skip-editable --format json \
--output pip-audit-report.json \
$IGNORE_FLAGS
- name: Fail if direct vulnerabilities remain unfixed
if: steps.classify.outputs.has_direct == 'true' && steps.fix.outputs.fixed != 'true'
run: |
echo "::error::Direct vulnerabilities found that could not be auto-fixed:"
cat direct_vulns.txt
echo ""
echo "Fix these manually or run: pip-audit --fix"
exit 1
- name: Display results
if: always()
@@ -91,23 +248,8 @@ jobs:
echo '```json' >> $GITHUB_STEP_SUMMARY
cat pip-audit-report.json | python3 -m json.tool >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
# Fail if vulnerabilities found
python3 -c "
import json, sys
with open('pip-audit-report.json') as f:
data = json.load(f)
vulns = [d for d in data.get('dependencies', []) if d.get('vulns')]
if vulns:
print(f'::error::Found vulnerabilities in {len(vulns)} package(s)')
for v in vulns:
for vuln in v['vulns']:
print(f' - {v[\"name\"]}=={v[\"version\"]}: {vuln[\"id\"]}')
sys.exit(1)
print('No known vulnerabilities found')
"
else
echo "::error::pip-audit failed to produce a report. Check the pip-audit step logs."
exit 1
echo "::error::pip-audit failed to produce a report."
fi
- name: Upload pip-audit report
@@ -126,4 +268,3 @@ jobs:
~/.local/share/uv
.venv
key: uv-main-py3.11-${{ hashFiles('uv.lock') }}

View File

@@ -28,7 +28,34 @@ repos:
hooks:
- id: pip-audit
name: pip-audit
entry: bash -c 'source .venv/bin/activate && uv run pip-audit --skip-editable --ignore-vuln CVE-2026-3219' --
# Keep this ignore list in sync with .github/workflows/vulnerability-scan.yml.
entry: >-
bash -c 'source .venv/bin/activate && uv run pip-audit --skip-editable
--ignore-vuln PYSEC-2024-277
--ignore-vuln PYSEC-2026-89
--ignore-vuln PYSEC-2026-97
--ignore-vuln PYSEC-2025-148
--ignore-vuln PYSEC-2025-183
--ignore-vuln PYSEC-2025-189
--ignore-vuln PYSEC-2025-190
--ignore-vuln PYSEC-2025-191
--ignore-vuln PYSEC-2025-192
--ignore-vuln PYSEC-2025-193
--ignore-vuln PYSEC-2025-194
--ignore-vuln PYSEC-2025-195
--ignore-vuln PYSEC-2025-196
--ignore-vuln PYSEC-2025-197
--ignore-vuln PYSEC-2025-210
--ignore-vuln PYSEC-2026-139
--ignore-vuln PYSEC-2025-211
--ignore-vuln PYSEC-2025-212
--ignore-vuln PYSEC-2025-213
--ignore-vuln PYSEC-2025-214
--ignore-vuln PYSEC-2025-215
--ignore-vuln PYSEC-2025-216
--ignore-vuln PYSEC-2025-217
--ignore-vuln PYSEC-2025-218
--ignore-vuln GHSA-f4j7-r4q5-qw2c' --
language: system
pass_filenames: false
stages: [pre-push, manual]

View File

@@ -4,6 +4,67 @@ description: "تحديثات المنتج والتحسينات وإصلاحات
icon: "clock"
mode: "wide"
---
<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
[عرض الإصدار على GitHub](https://github.com/crewAIInc/crewAI/releases/tag/1.14.7a1)
## ما الذي تغير
### الميزات
- إضافة دعم ملفات الوكلاء المدربين
- إضافة مزود LLM الأصلي لـ Snowflake Cortex
- إضافة دليل تكامل Databricks
- إضافة دليل تكامل Snowflake
### إصلاحات الأخطاء
- إصلاح CLI عن طريق استعادة `[project.scripts]` في حزمة crewai لتثبيت أداة UV
- حل مشكلات موثوقية إدخال الملفات
- إصلاح تاريخ نتائج الأدوات غير المكتملة في Snowflake Claude
- التعامل مع استدعاءات الأدوات الممثلة كسلاسل لـ Snowflake Claude
- إعادة تفعيل مستمعي `or_` متعدد المصادر عبر دورات مدفوعة بالموجه
### الأداء
- تحسين سرعة استيراد crewai عن طريق تحميل استيرادات docling بشكل كسول
### إعادة هيكلة
- تقسيم `flow.py` إلى DSL، تعريف، وتشغيل
## المساهمون
@Luzk, @alex-clawd, @devin-ai-integration[bot], @greysonlalonde, @jessemiller, @lorenzejay, @vinibrsl
</Update>
<Update label="28 مايو 2026">
## v1.14.6

View File

@@ -107,7 +107,7 @@ mode: "wide"
</Tabs>
<Info>
يوفر CrewAI تكاملات SDK أصلية لـ OpenAI و Anthropic و Google (Gemini API) و Azure و AWS Bedrock -- لا حاجة لتثبيت إضافي بخلاف الملحقات الخاصة بالمزود (مثل `uv add "crewai[openai]"`).
يوفر CrewAI تكاملات SDK أصلية لـ OpenAI و Anthropic و Google (Gemini API) و Azure و AWS Bedrock و Snowflake Cortex -- لا حاجة لتثبيت إضافي بخلاف الملحقات الخاصة بالمزود (مثل `uv add "crewai[openai]"`).
جميع المزودين الآخرين مدعومون بواسطة **LiteLLM**. إذا كنت تخطط لاستخدام أي منهم، أضفه كتبعية لمشروعك:
```bash
@@ -291,6 +291,55 @@ mode: "wide"
```
</Accordion>
<Accordion title="Snowflake Cortex">
يوفر CrewAI تكاملًا أصليًا مع Snowflake Cortex REST API عبر endpoint Chat Completions المتوافق مع OpenAI. تستخدم نماذج `snowflake/...` هذا المسار بدون fallback إلى LiteLLM. يدعم Snowflake Cortex في CrewAI حاليًا Chat Completions فقط، لذلك استخدم وضع `api` الافتراضي ولا تضبط `api="responses"`.
```toml Code
# Required
SNOWFLAKE_PAT=<your-programmatic-access-token>
SNOWFLAKE_ACCOUNT_URL=https://<account-identifier>.snowflakecomputing.com
# Alternative account configuration
SNOWFLAKE_ACCOUNT=<account-identifier>
```
**الاستخدام الأساسي:**
```python Code
from crewai import LLM
llm = LLM(
model="snowflake/openai-gpt-4.1",
temperature=0.7,
max_completion_tokens=1024,
)
```
**نماذج Claude على Cortex:**
```python Code
from crewai import LLM
llm = LLM(
model="snowflake/claude-sonnet-4-5",
max_completion_tokens=1024,
stream=True,
)
```
**متغيرات البيئة المدعومة:**
- `SNOWFLAKE_PAT` أو `SNOWFLAKE_TOKEN` أو `SNOWFLAKE_JWT`: الرمز المستخدم كاعتماد Bearer
- `SNOWFLAKE_ACCOUNT_URL`: عنوان URL الكامل لحساب Snowflake
- `SNOWFLAKE_ACCOUNT` أو `SNOWFLAKE_ACCOUNT_ID` أو `SNOWFLAKE_ACCOUNT_IDENTIFIER`: معرف الحساب المستخدم لبناء URL
تستخدم طلبات Snowflake REST الدور الافتراضي للمستخدم. تأكد من أن هذا الدور لديه `SNOWFLAKE.CORTEX_USER` أو `SNOWFLAKE.CORTEX_REST_API_USER`. لا يتطلب endpoint Cortex REST Chat Completions معاملات database أو schema أو warehouse أو role صريح.
**الميزات:**
- اختيار provider أصلي باستخدام `model="snowflake/<model-name>"`
- Chat Completions مع streaming وبدونه فقط؛ `api="responses"` غير مدعوم
- تتبع استخدام الرموز
- استدعاء الدوال لنماذج OpenAI و Claude المستضافة في Snowflake
- إزالة assistant prefill النهائي غير الصالح تلقائيًا لنماذج Claude في Snowflake
</Accordion>
<Accordion title="Anthropic">
يوفر CrewAI تكاملًا أصليًا مع Anthropic من خلال Anthropic Python SDK.

View File

@@ -164,6 +164,12 @@ crewai deploy remove <deployment_id>
![اختيار المستودع](/images/enterprise/select-repo.png)
</Frame>
<Tip>
إذا كان Crew أو Flow داخل مجلد فرعي في monorepo، فوسّع **Advanced**
وعيّن دليل عمل قبل النشر. راجع
[النشر من Monorepo](/ar/enterprise/guides/monorepo-deployments).
</Tip>
</Step>
<Step title="تعيين متغيرات البيئة">

View File

@@ -0,0 +1,220 @@
---
title: "النشر من Monorepo"
description: "انشر Crew أو Flow من مجلد فرعي داخل مستودع أكبر"
icon: "folder-tree"
mode: "wide"
---
<Note>
استخدم دليل عمل عندما يكون Crew أو Flow داخل مستودع أكبر. يتحقق CrewAI AMP
من الأتمتة ويبنيها ويشغلها من ذلك المجلد الفرعي بدلاً من جذر المستودع.
</Note>
## متى تستخدم ذلك
يكون النشر من monorepo مفيداً عندما يحتوي مستودع واحد على عدة أتمتات أو حزم
مشتركة أو كود تطبيقات آخر:
```text
company-ai/
|-- uv.lock
|-- packages/
| `-- shared_tools/
`-- crews/
|-- support_agent/
| |-- pyproject.toml
| `-- src/
| `-- support_agent/
| |-- main.py
| `-- crew.py
`-- research_flow/
|-- pyproject.toml
`-- src/
`-- research_flow/
`-- main.py
```
لنشر `support_agent`، اضبط دليل العمل على:
```text
crews/support_agent
```
لا يزال AMP يجلب المستودع كاملاً أو يرفعه، لكنه يتعامل مع المجلد المحدد كجذر
مشروع الأتمتة.
## ما الذي يتحكم به دليل العمل
عند تعيين دليل عمل، يستخدم AMP ذلك المجلد من أجل:
- التحقق من المشروع، بما في ذلك `pyproject.toml` و`src/` ونقطة دخول Crew أو Flow
- تثبيت الاعتماديات باستخدام `uv`
- دليل العمل للعملية قيد التشغيل
- متغير البيئة `CREW_ROOT_DIR`
ترك الحقل فارغاً يحافظ على السلوك الحالي ويستخدم جذر المستودع.
## المصادر المدعومة
يمكنك تعيين دليل عمل عند إنشاء نشر من:
- مستودع GitHub متصل
- مستودع Git مكوّن في AMP
- رفع ملف ZIP
<Info>
اضبط أدلة العمل من واجهة AMP على الويب. لا يطلب تدفق CLI
`crewai deploy create` هذا الحقل.
</Info>
يمكنك أيضاً إضافة دليل العمل أو تغييره في نشر موجود من صفحة **Settings** الخاصة
بالنشر. يسري التغيير في النشر التالي.
<Warning>
لا يمكن استخدام أدلة العمل وauto-deploy معاً. إذا كان للنشر دليل عمل، يتم
تعطيل auto-deploy لذلك النشر. أوقف auto-deploy قبل تعيين دليل عمل.
</Warning>
## إعداد نشر جديد
<Steps>
<Step title="افتح Deploy from Code">
في CrewAI AMP، أنشئ نشراً جديداً واختر المصدر: GitHub أو Git Repository أو
رفع ZIP.
</Step>
<Step title="اختر المستودع أو الفرع أو ملف ZIP">
اختر المستودع والفرع اللذين يحتويان على monorepo، أو ارفع ملف ZIP يحتوي
جذره على محتويات monorepo.
</Step>
<Step title="افتح الإعدادات المتقدمة">
وسّع قسم **Advanced** في نموذج النشر.
</Step>
<Step title="أدخل دليل العمل">
أدخل المسار من جذر المستودع إلى مشروع Crew أو Flow:
```text
crews/support_agent
```
لا تضف شرطة مائلة في البداية.
</Step>
<Step title="انشر">
أضف أي متغيرات بيئة مطلوبة، ثم ابدأ النشر.
</Step>
</Steps>
## إعداد نشر موجود
<Steps>
<Step title="افتح إعدادات النشر">
انتقل إلى الأتمتة في AMP وافتح **Settings**.
</Step>
<Step title="أوقف auto-deploy إذا لزم الأمر">
إذا كان auto-deploy مفعلاً، أوقفه أولاً. لا يكون حقل دليل العمل متاحاً
أثناء تشغيل auto-deploy.
</Step>
<Step title="عيّن دليل العمل">
في **Basic settings**، أدخل مسار المجلد الفرعي، مثل:
```text
crews/support_agent
```
</Step>
<Step title="أعد النشر">
احفظ الإعداد وأعد نشر الأتمتة. سيتم استخدام دليل العمل الجديد في النشر
التالي.
</Step>
</Steps>
## قواعد المسار
يجب أن يكون دليل العمل مساراً نسبياً داخل جذر المستودع أو ZIP.
| القاعدة | المثال |
|---------|--------|
| استخدم مساراً نسبياً | `crews/support_agent` |
| لا تبدأ بـ `/` | `/crews/support_agent` غير صالح |
| لا تستخدم مقاطع المسار `.` أو `..` | `crews/../support_agent` غير صالح |
| استخدم الأحرف والأرقام والشرطات والشرطات السفلية والنقاط والشرطات المائلة فقط | `crews/support agent` غير صالح |
| اجعل المسار 255 حرفاً أو أقل | يتم رفض المسارات الأطول |
يزيل AMP المسافات البيضاء في البداية والنهاية، ويضغط الشرطات المائلة المتكررة،
ويزيل الشرطة المائلة النهائية. تستخدم القيمة الفارغة جذر المستودع.
## ملفات القفل وUV Workspaces
يجب أن يحتوي المجلد المحدد على `pyproject.toml` ودليل `src/` الخاصين بالأتمتة.
يمكن أن يوجد ملف `uv.lock` أو `poetry.lock` إما في المجلد المحدد أو في جذر
المستودع.
يدعم هذا التخطيطين الشائعين في monorepo:
<Tabs>
<Tab title="ملف قفل المشروع">
```text
company-ai/
`-- crews/
`-- support_agent/
|-- pyproject.toml
|-- uv.lock
`-- src/
`-- support_agent/
`-- main.py
```
</Tab>
<Tab title="ملف قفل workspace">
```text
company-ai/
|-- uv.lock
|-- packages/
| `-- shared_tools/
`-- crews/
`-- support_agent/
|-- pyproject.toml
`-- src/
`-- support_agent/
`-- main.py
```
</Tab>
</Tabs>
<Tip>
إذا كانت الأتمتة تستورد حزماً مشتركة من مكان آخر في monorepo، فصرّح بهذه
الحزم في `pyproject.toml` باستخدام إعدادات UV workspace أو path أو source.
يشغل AMP الأتمتة من المجلد المحدد، لذلك يجب تثبيت الكود المشترك كاعتمادية
بدلاً من الاعتماد على وجود جذر المستودع في Python path.
</Tip>
## استكشاف الأخطاء وإصلاحها
### لم يتم العثور على دليل العمل
تحقق من أن المسار نسبي إلى جذر المستودع أو ZIP. بالنسبة لرفع ZIP، يجب أن
تتضمن محتويات ZIP مسار دليل العمل تماماً كما أدخلته.
### pyproject.toml مفقود
يجب أن يشير دليل العمل إلى مجلد مشروع Crew أو Flow، وليس فقط إلى مجلد أب
يحتوي على عدة مشاريع.
### uv.lock أو poetry.lock مفقود
اعمل commit لملف قفل إما في مجلد المشروع المحدد أو في جذر المستودع. بالنسبة
إلى UV workspaces، يتم دعم إبقاء `uv.lock` في جذر workspace.
### Auto-Deploy غير متاح
يتم تعطيل auto-deploy أثناء تعيين دليل عمل. استخدم إعادة النشر اليدوية أو شغّل
إعادة النشر من CI/CD باستخدام AMP API.
<Card title="النشر على AMP" icon="rocket" href="/ar/enterprise/guides/deploy-to-amp">
تابع دليل النشر بعد اختيار دليل عمل monorepo.
</Card>

View File

@@ -0,0 +1,123 @@
---
title: تكامل Databricks
description: "اربط وكلاء CrewAI بـ Databricks Genie وSQL وUnity Catalog Functions وVector Search عبر خوادم MCP المُدارة من Databricks."
icon: "layer-group"
mode: "wide"
---
## نظرة عامة
اربط وكلاء CrewAI مباشرةً بمساحة عمل Databricks الخاصة بك عبر [خوادم MCP المُدارة من Databricks](https://docs.databricks.com/aws/en/generative-ai/mcp/managed-mcp). يتيح تكامل Databricks لوكلائك طرح أسئلة بلغة طبيعية باستخدام **Genie**، وتنفيذ **SQL** خاضع للحوكمة، واستدعاء **Unity Catalog Functions**، واسترجاع المستندات باستخدام **Vector Search** — كل ذلك دون كتابة أو استضافة أي كود موصِّل، مع تطبيق أذونات Unity Catalog في كل استدعاء.
في الخلفية، يُعدّ تكامل Databricks غلافًا مُدارًا حول دعم [خوادم MCP المخصصة](/ar/enterprise/guides/custom-mcp-server) في CrewAI. تكشف Databricks عن كل قدرة كنقطة نهاية [Model Context Protocol](https://modelcontextprotocol.io/) خاصة بها، ويتصل بها CrewAI بأمان نيابةً عنك. ولأن كل خادم يُضاف بشكل منفصل، يمكنك تفعيل القدرات التي تحتاجها فرقك (crews) بالضبط.
## القدرات الرئيسية
<CardGroup cols={2}>
<Card title="Genie" icon="comments">
اطرح أسئلة بلغة طبيعية واحصل على إجابات مستندة إلى بياناتك باستخدام [Genie](https://docs.databricks.com/aws/en/genie/)، الذي يستعلم من Genie Spaces وUnity Catalog ويوفّر روابط تعود إلى واجهة Databricks.
</Card>
<Card title="Databricks SQL" icon="database">
نفّذ SQL خاضعًا للحوكمة على مستودعات Databricks لديك للاستعلام عن البيانات وتحويلها وإنشاء خطوط أنابيب البيانات مباشرةً من وكلائك.
</Card>
<Card title="Unity Catalog Functions" icon="function">
استدعِ [دوال Unity Catalog](https://docs.databricks.com/aws/en/udf/unity-catalog) لتنفيذ SQL مُعرّف مسبقًا ومنطق أعمال مخصّص كأدوات قابلة لإعادة الاستخدام وخاضعة للحوكمة.
</Card>
<Card title="Vector Search" icon="magnifying-glass">
استرجع المستندات ذات الصلة لسير عمل RAG والمعرفة من فهارس [Mosaic AI Vector Search](https://docs.databricks.com/aws/en/generative-ai/vector-search) باستخدام التشابه الدلالي.
</Card>
</CardGroup>
تعمل جميع الخوادم خلف Unity AI Gateway وتطبّق ضوابط الوصول في Unity Catalog، بحيث لا يرى وكلاؤك سوى البيانات والأدوات المصرَّح لهم باستخدامها.
## المتطلبات المسبقة
قبل استخدام تكامل Databricks، تأكّد من توفّر ما يلي:
- حساب [CrewAI AMP](https://app.crewai.com) باشتراك نشط
- مساحة عمل Databricks تحتوي على القدرات التي تريد كشفها (Genie Spaces، مستودعات SQL، دوال Unity Catalog، أو فهارس Vector Search)
- [امتيازات Unity Catalog](https://docs.databricks.com/aws/en/data-governance/unity-catalog) المناسبة على الكائنات الأساسية
- اسم مضيف مساحة عمل Databricks الخاص بك (مثال: `your-workspace.cloud.databricks.com`)
## خوادم MCP المُدارة من Databricks
تنشر Databricks خادم MCP مُدارًا منفصلًا لكل قدرة. يكشف CrewAI عنها كاتصالات فردية، يُهيَّأ كل منها باستخدام مضيف مساحة العمل ومعرّفات Unity Catalog ذات الصلة. تتبع نقاط النهاية الأنماط التالية:
| الخادم | الوظيفة | نمط عنوان MCP |
|--------|---------|---------------|
| **Genie** | أسئلة وأجوبة بلغة طبيعية على Genie Space | `https://<workspace-hostname>/api/2.0/mcp/genie/{genie_space_id}` |
| **Databricks SQL** | تنفيذ SQL على مستودعاتك | `https://<workspace-hostname>/api/2.0/mcp/sql` |
| **Unity Catalog Functions** | تشغيل دوال UC المسجّلة | `https://<workspace-hostname>/api/2.0/mcp/functions/{catalog}/{schema}` |
| **Vector Search** | الاستعلام من فهرس Vector Search | `https://<workspace-hostname>/api/2.0/mcp/vector-search/{catalog}/{schema}` |
<Note>
لا حاجة لإنشاء عناوين URL هذه يدويًا — يُنشئ CrewAI كل نقطة نهاية من مضيف مساحة العمل والمعرّفات (Genie Space ID، أو catalog/schema) التي تقدّمها عند تهيئة الاتصال. للاطّلاع على المواصفات الكاملة وأحدث تفاصيل نقاط النهاية، راجع [وثائق MCP المُدارة من Databricks](https://docs.databricks.com/aws/en/generative-ai/mcp/managed-mcp).
</Note>
## ربط Databricks في CrewAI AMP
<Frame>
<img src="/images/enterprise/databricks-configure.png" alt="تهيئة خادم MCP مُدار من Databricks في CrewAI AMP" />
</Frame>
تظهر كل قدرة من قدرات Databricks — **Databricks Genie** و**Databricks SQL** و**Databricks Unity Catalog Functions** و**Databricks Vector Search** — كخادم MCP خاص بها ضمن مجموعة Databricks في صفحة **Tools & Integrations**. هيّئ ما تحتاجه:
<Steps>
<Step title="افتح Tools & Integrations">
انتقل إلى **Tools & Integrations** في الشريط الجانبي الأيسر في CrewAI AMP وحدِّد مجموعة **Databricks** في قائمة Connections. سترى خوادم Genie وSQL وUnity Catalog Functions وVector Search مُدرجة أسفلها.
</Step>
<Step title="هيّئ خادمًا">
انقر على **Configure** بجوار القدرة التي تريد تفعيلها وقدّم تفاصيل الاتصال الخاصة بها:
- **Workspace Host** — اسم مضيف مساحة عمل Databricks الخاص بك (مثال: `my-workspace.cloud.databricks.com`).
- **Genie** — **Genie Space ID** المراد الاستعلام عنه.
- **Unity Catalog Functions** — الـ **catalog** والـ **schema** اللذان يحتويان على دوالك.
- **Vector Search** — الـ **catalog** والـ **schema** اللذان يحتويان على الفهرس.
- **Databricks SQL** — لا توجد معرّفات إضافية؛ تُنفَّذ الاستعلامات على مستودعات SQL في مساحة عملك.
</Step>
<Step title="اختر طريقة المصادقة">
اختر كيف يصادق CrewAI على Databricks. يُوصى باستخدام **OAuth**.
- **Use OAuth** — اتصل بأمان باستخدام OAuth 2.0. يصادق كل مستخدم على حدة، وتُصدر Databricks رموزًا (tokens) محدّدة النطاق للقدرة (`genie` أو `sql` أو `unity-catalog` أو `vector-search`). يتولّى CrewAI تدفّق التفويض ويُجدّد الرموز تلقائيًا.
- **Use personal access token** — صادِق باستخدام [رمز وصول شخصي من Databricks](https://docs.databricks.com/aws/en/dev-tools/auth/pat). استخدم هوية بأقل الامتيازات للحدّ من التعرّض.
</Step>
<Step title="صادِق">
أكمل المصادقة. بمجرد الاتصال، تصبح أدوات الخادم متاحة لفرقك. كرّر العملية لأي قدرات Databricks أخرى تريد تفعيلها.
</Step>
</Steps>
<Tip>
لأن كل قدرة هي اتصال منفصل، يمكنك المزج والمطابقة — على سبيل المثال، فعّل Genie وVector Search لفريق بحث، مع حجز SQL وUnity Catalog Functions لفريق هندسة البيانات. تتيح لك إعدادات الرؤية (Visibility) التحكّم في أعضاء الفريق الذين يمكنهم استخدام كل منها.
</Tip>
## استخدام أدوات Databricks في فرقك
بمجرد الاتصال، تظهر الأدوات التي يكشفها كل خادم MCP جنبًا إلى جنب مع الاتصالات المدمجة في صفحة **Tools & Integrations**. يمكنك:
- **إسناد الأدوات إلى الوكلاء** في فرقك تمامًا مثل أي أداة أخرى في CrewAI.
- **إدارة الرؤية** للتحكّم في أعضاء الفريق الذين يمكنهم استخدام كل اتصال.
- **تعديل أو إزالة** أي اتصال في أي وقت من قائمة Connections.
يمكن لوكلائك الآن طلب إجابات مستندة من Genie، وتنفيذ SQL على مستودعاتك، واستدعاء دوال Unity Catalog، والبحث في فهارس Vector Search — مع تدفّق النتائج تلقائيًا إلى استدلالهم.
<Warning>
تطبّق Databricks الحوكمة عبر Unity Catalog وUnity AI Gateway: لا يمكن للمستخدم اكتشاف الأدوات واستدعاؤها إلا تلك المصرَّح بها لهوية مساحة عمله. إذا فشل استدعاء أداة، فتأكّد من أن المستخدم المتصل (أو هوية الرمز) يمتلك امتيازات Unity Catalog المطلوبة على Genie Space أو المستودع أو الدالة أو الفهرس. تُنفَّذ بعض استعلامات Genie وSQL بشكل غير متزامن وقد تستغرق لحظة لإرجاع النتائج.
</Warning>
## مزيد من المعلومات
<CardGroup cols={2}>
<Card title="خوادم MCP المُدارة من Databricks" icon="layer-group" href="https://docs.databricks.com/aws/en/generative-ai/mcp/managed-mcp">
وثائق Databricks الرسمية لخوادم MCP المُدارة Genie وSQL وUnity Catalog Functions وVector Search.
</Card>
<Card title="خوادم MCP المخصصة في CrewAI" icon="plug" href="/ar/enterprise/guides/custom-mcp-server">
تعرّف على كيفية اتصال CrewAI بأي خادم MCP، وهو الأساس الذي يُبنى عليه تكامل Databricks.
</Card>
</CardGroup>
<Card title="بحاجة إلى مساعدة؟" icon="headset" href="mailto:support@crewai.com">
تواصل مع فريق الدعم للحصول على المساعدة في تهيئة تكامل Databricks أو في حل المشكلات.
</Card>

View File

@@ -0,0 +1,134 @@
---
title: تكامل Snowflake
description: "ربط وكلاء CrewAI بـ Snowflake Cortex Analyst و Cortex Search وتنفيذ SQL من خلال خادم MCP المُدار من Snowflake."
icon: "snowflake"
mode: "wide"
---
## نظرة عامة
اربط وكلاء CrewAI مباشرة ببيانات Snowflake الخاصة بك من خلال [خادم MCP المُدار من Snowflake](https://docs.snowflake.com/en/user-guide/snowflake-cortex/cortex-agents-mcp). يتيح تكامل Snowflake لوكلائك الاستعلام عن البيانات المنظمة باستخدام **Cortex Analyst**، والبحث في البيانات غير المنظمة باستخدام **Cortex Search**، وتنفيذ SQL مُدار على مستودعات البيانات الخاصة بك — كل ذلك دون كتابة أو استضافة أي كود للموصّل.
داخلياً، تكامل Snowflake هو غلاف مُدار حول دعم [Custom MCP Server](/ar/enterprise/guides/custom-mcp-server) في CrewAI. يكشف Snowflake عن قدرات Cortex AI الخاصة به من خلال نقطة نهاية [Model Context Protocol](https://modelcontextprotocol.io/)، ويتصل CrewAI بها بشكل آمن نيابةً عنك. أي أداة تكشفها على جانب Snowflake — Cortex Analyst أو Cortex Search أو تنفيذ SQL أو Cortex Agents أو أدواتك المخصصة — تصبح متاحة لطواقمك.
## القدرات الرئيسية
<CardGroup cols={3}>
<Card title="Cortex Analyst" icon="chart-bar">
اطرح أسئلة بلغة طبيعية ودع [Cortex Analyst](https://docs.snowflake.com/en/user-guide/snowflake-cortex/cortex-analyst) يولّد وينفذ SQL على بياناتك **المنظمة** باستخدام نماذج دلالية غنية.
</Card>
<Card title="Cortex Search" icon="magnifying-glass">
استرجع البيانات **غير المنظمة** ذات الصلة لسير عمل RAG والمعرفة باستخدام [Cortex Search](https://docs.snowflake.com/en/user-guide/snowflake-cortex/cortex-search/cortex-search-overview)، خدمة البحث المُدارة بالكامل من Snowflake.
</Card>
<Card title="تنفيذ SQL" icon="database">
نفّذ استعلامات SQL مُدارة مباشرة على مستودعات Snowflake الخاصة بك، مع وضع القراءة فقط القابل للتكوين، والمهلات الزمنية، واختيار المستودع.
</Card>
</CardGroup>
نظراً لأن التكامل يكشف عن أي أدوات ينشرها خادم MCP الخاص بك، يمكنك أيضاً كشف **Cortex Agents** و**الأدوات المخصصة** (الدوال المعرّفة من المستخدم والإجراءات المخزّنة) لوكلاء CrewAI.
## المتطلبات الأساسية
قبل استخدام تكامل Snowflake، تأكد من توفر ما يلي:
- حساب [CrewAI AMP](https://app.crewai.com) مع اشتراك فعّال
- حساب Snowflake مع إمكانية الوصول إلى ميزات Cortex AI
- [خادم MCP مُدار من Snowflake](https://docs.snowflake.com/en/user-guide/snowflake-cortex/cortex-agents-mcp) مُكوّن بالأدوات التي تريد كشفها
- صلاحيات Snowflake المناسبة (USAGE/SELECT) على خادم MCP والكائنات الأساسية
## إعداد خادم Snowflake MCP
يعمل خادم MCP المُدار من Snowflake داخل حساب Snowflake الخاص بك ويحدد الأدوات المتاحة للعملاء الخارجيين مثل CrewAI. أنشئ واحداً باستخدام أمر [`CREATE MCP SERVER`](https://docs.snowflake.com/en/sql-reference/sql/create-mcp-server)، مع سرد خدمات Cortex Search وعروض Cortex Analyst الدلالية وأدوات SQL التي تريد كشفها.
```sql
CREATE MCP SERVER my_mcp_server
FROM SPECIFICATION $$
tools:
- name: "sales_analyst"
type: "CORTEX_ANALYST"
identifier: "MY_DATABASE.MY_SCHEMA.sales_semantic_view"
description: "Answer questions about sales metrics"
- name: "docs_search"
type: "CORTEX_SEARCH_SERVICE_QUERY"
identifier: "MY_DATABASE.MY_SCHEMA.support_docs_search"
description: "Search internal support documentation"
- name: "run_sql"
type: "SQL_EXECUTION"
description: "Execute read-only SQL queries"
$$;
```
<Note>
تتبع نقطة نهاية MCP التنسيق `https://<account_URL>/api/v2/databases/{database}/schemas/{schema}/mcp-servers/{name}`. يبني CrewAI هذا العنوان تلقائياً من **عنوان URL للحساب** و**قاعدة البيانات** و**المخطط** و**اسم خادم MCP** الذي تقدمه عند تكوين التكامل.
</Note>
للمواصفات الكاملة — بما في ذلك Cortex Agents والأدوات المخصصة وحدود حجم الاستجابة وخيارات الحوكمة — راجع [وثائق خادم MCP المُدار من Snowflake](https://docs.snowflake.com/en/user-guide/snowflake-cortex/cortex-agents-mcp).
## ربط Snowflake في CrewAI AMP
<Frame>
<img src="/images/enterprise/snowflake-configure.png" alt="تكوين تكامل Snowflake في CrewAI AMP" />
</Frame>
<Steps>
<Step title="فتح الأدوات والتكاملات">
انتقل إلى **الأدوات والتكاملات** في الشريط الجانبي الأيسر لـ CrewAI AMP، وابحث عن **Snowflake** في قائمة التطبيقات، وافتح لوحة التكوين الخاصة به.
</Step>
<Step title="تقديم تفاصيل الاتصال">
املأ حقول الاتصال التي يستخدمها CrewAI للوصول إلى خادم Snowflake MCP الخاص بك:
| الحقل | مطلوب | الوصف |
|-------|-------|-------|
| **الاسم** | نعم | اسم وصفي لهذا الاتصال (القيمة الافتراضية `Snowflake`). |
| **الوصف** | لا | ملخص اختياري لما يوفره هذا الاتصال. |
| **عنوان URL للحساب** | نعم | عنوان URL لحساب Snowflake الخاص بك، مثل `xy12345.us-east-1.snowflakecomputing.com`. |
| **قاعدة البيانات** | نعم | قاعدة البيانات التي تحتوي على خادم MCP الخاص بك (مثل `MY_DATABASE`). |
| **المخطط** | نعم | المخطط الذي يحتوي على خادم MCP الخاص بك (مثل `MY_SCHEMA`). |
| **اسم خادم MCP** | نعم | اسم كائن خادم MCP الذي أنشأته في Snowflake (مثل `MY_MCP_SERVER`). |
</Step>
<Step title="اختيار طريقة المصادقة">
اختر كيفية مصادقة CrewAI مع Snowflake. يُوصى باستخدام **OAuth**.
- **استخدام OAuth** — اتصل بشكل آمن باستخدام OAuth 2.0 للمصادقة القائمة على الرموز دون مشاركة بيانات الاعتماد الخاصة بك. يتعامل CrewAI مع تدفق التفويض الكامل ويجدد الرموز تلقائياً. انسخ **عنوان URI لإعادة التوجيه** المعروض في النموذج (`https://oauth.crewai.com/oauth/add`) وسجّله كعنوان URI لإعادة التوجيه المعتمد في [تكامل أمان OAuth](https://docs.snowflake.com/en/user-guide/oauth-custom) في Snowflake.
- **استخدام رمز وصول شخصي** — المصادقة باستخدام [رمز وصول برمجي](https://docs.snowflake.com/en/user-guide/programmatic-access-tokens) مُنشأ من إعدادات حساب Snowflake الخاص بك. قم بتعيين دور بأقل صلاحيات للرمز للحد من التعرض.
</Step>
<Step title="المصادقة">
انقر على **المصادقة**. بالنسبة لـ OAuth، ستتم إعادة توجيهك إلى Snowflake لتفويض الوصول. بمجرد المصادقة، يظهر خادم Snowflake في قائمة الاتصالات وتصبح أدواته متاحة لطواقمك.
</Step>
</Steps>
<Tip>
مع OAuth، يتم مصادقة كل مستخدم بشكل فردي وتُنفّذ الاستعلامات بدور `DEFAULT_ROLE` الخاص به في Snowflake. تأكد من أن المستخدمين المتصلين لديهم دور ومستودع افتراضي محدد (`ALTER USER <username> SET DEFAULT_ROLE = '<role>' DEFAULT_WAREHOUSE = '<warehouse>'`) حتى تتوفر موارد الحوسبة لأدوات Cortex Analyst و SQL.
</Tip>
## استخدام أدوات Snowflake في طواقمك
بمجرد الاتصال، تظهر الأدوات التي يكشفها خادم MCP الخاص بك إلى جانب الاتصالات المدمجة في صفحة **الأدوات والتكاملات**. يمكنك:
- **تعيين الأدوات للوكلاء** في طواقمك تماماً مثل أي أداة CrewAI أخرى.
- **إدارة الرؤية** للتحكم في أعضاء الفريق الذين يمكنهم استخدام الاتصال.
- **تعديل أو إزالة** الاتصال في أي وقت من قائمة الاتصالات.
يمكن لوكلائك الآن سؤال Cortex Analyst عن المقاييس، وتشغيل Cortex Search على مستنداتك، وتنفيذ SQL — مع تدفق النتائج تلقائياً إلى استدلالهم.
<Warning>
يفرض Snowflake الحوكمة على خادم MCP: يحدد التحكم في الوصول القائم على الأدوار الأدوات التي يمكن للمستخدم اكتشافها واستدعاؤها، وتنطبق حدود على حجم الاستجابة وعدد الأدوات (بحد أقصى 50 لكل خادم) وعمق التكرار. إذا فشل استدعاء أداة، تأكد من أن دور المستخدم المتصل لديه الصلاحيات المطلوبة على خادم MCP والكائنات الأساسية.
</Warning>
## معرفة المزيد
<CardGroup cols={2}>
<Card title="خادم MCP المُدار من Snowflake" icon="snowflake" href="https://docs.snowflake.com/en/user-guide/snowflake-cortex/cortex-agents-mcp">
الوثائق الرسمية من Snowflake لإنشاء وإدارة خادم MCP.
</Card>
<Card title="خوادم Custom MCP في CrewAI" icon="plug" href="/ar/enterprise/guides/custom-mcp-server">
تعرّف على كيفية اتصال CrewAI بأي خادم MCP، الأساس الذي يبني عليه تكامل Snowflake.
</Card>
</CardGroup>
<Card title="تحتاج مساعدة؟" icon="headset" href="mailto:support@crewai.com">
تواصل مع فريق الدعم للحصول على المساعدة في تكامل Snowflake أو استكشاف الأخطاء وإصلاحها.
</Card>

View File

@@ -0,0 +1,473 @@
---
title: تدفقات المحادثة
description: أنشئ تطبيقات دردشة متعددة الجولات مع kickoff لكل جولة وسجل الرسائل وتوجيه النية والتتبع وجسور WebSocket.
icon: comments
mode: "wide"
---
## نظرة عامة
تعامل التطبيقات المحادثية مع كل سطر من المستخدم كـ **تشغيل flow جديد** بنفس **معرّف الجلسة**. توفر CrewAI مساعدات لسجل الرسائل وتصنيف النية الاختياري وتأجيل التتبع وجسور الواجهة، إضافة إلى REPL محلي `flow.chat()` للتدفقات المحادثية.
| المفهوم | التنفيذ |
|---------|---------|
| معرّف الجلسة | `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.handle_turn(message, session_id=...)`** لكل رسالة مستخدم من REST أو WebSocket أو الاختبارات أو الواجهات المخصصة. استخدم **`flow.chat()`** عندما تريد حلقة دردشة محلية في الطرفية لـ `Flow` محادثي.
لا يقبل `Flow.kickoff()` الوسيطين `user_message=` أو `session_id=`. في التدفقات المحادثية، يخزن `handle_turn()` الرسالة المعلقة ويستدعي داخلياً `kickoff(inputs={"id": session_id})`.
| API | الاستخدام |
|-----|-----------|
| `handle_turn(message, session_id=...)` | غلاف مريح لجولة واحدة في `Flow` محادثي |
| `chat()` | REPL محلي في الطرفية لـ `Flow` محادثي |
| `kickoff(inputs={...})` | تشغيل متقدم للـ flow بدون معالجة جولة محادثية |
| `ask()` | مطالبة حاجزة **داخل** خطوة واحدة |
| `@human_feedback` | الموافقة/الرفض على **مخرجات خطوة** — وليس السطر التالي |
| `ChatSession.handle_turn(...)` | طبقة نقل فوق `handle_turn` |
## بداية سريعة
```python
from uuid import uuid4
from crewai import Flow
from crewai.flow import listen
from crewai.experimental.conversational import (
ConversationConfig,
ConversationState,
)
@ConversationConfig(defer_trace_finalization=True)
class SupportFlow(Flow[ConversationState]):
conversational = True
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_assistant_message(reply)
return reply
@listen("help")
def handle_help(self):
reply = "كيف يمكنني المساعدة؟"
self.append_assistant_message(reply)
return reply
@listen("goodbye")
def handle_goodbye(self):
reply = "وداعاً!"
self.append_assistant_message(reply)
return reply
session_id = str(uuid4())
flow = SupportFlow()
try:
flow.handle_turn("أين طلبي؟", session_id=session_id)
flow.handle_turn("وماذا عن الإرجاع؟", session_id=session_id)
finally:
flow.finalize_session_traces()
```
## دورة حياة الجولة
كل `handle_turn` يشغّل:
1. **`_configure_conversational_kickoff`** — دمج `session_id` / `user_message` في `inputs` وتطبيق `ConversationalConfig`.
2. **استعادة الحالة** — عند وجود `inputs["id"]` و`@persist`.
3. **`FlowStarted`** — في أول جولة للجلسة المؤجلة فقط.
4. **`prepare_conversational_turn`** — إضافة رسالة المستخدم و`last_user_message` وتصنيف اختياري.
5. **تنفيذ الرسم** — `@start` → `@router` → معالجات `@listen`.
6. **نهاية التشغيل** — يُتخطى `flow_finished` والتتبع لكل جولة عند التأجيل؛ `Agent.kickoff()` / crews لا تغلق دفعة الأب.
استدعِ **`append_assistant_message(reply)`** في المعالجات. سطر المستخدم محفوظ عبر `handle_turn` — لا تُضفه مرة أخرى.
## `ConversationalConfig` (افتراضيات على مستوى الصنف)
عيّن على صنف `Flow` كـ `conversational_config: ClassVar[ConversationalConfig | None]`.
| الحقل | الافتراضي | الغرض |
|-------|-----------|--------|
| `default_intents` | `None` | تسميات outcome للتصنيف التلقائي قبل kickoff |
| `intent_llm` | `None` | نموذج التصنيف (مطلوب عند وجود intents) |
| `interactive_prompt` | `"You: "` | مطالبة `kickoff(interactive=True)` |
| `interactive_timeout` | `None` | مهلة لكل سطر في الوضع التفاعلي |
| `exit_commands` | `exit`, `quit` | كلمات إنهاء الوضع التفاعلي |
| `defer_trace_finalization` | `True` | إبقاء دفعة trace واحدة مفتوحة بين الجولات |
يمكن التجاوز لكل kickoff عبر `intents=` و`intent_llm=`.
## `ChatState` (شكل الحالة الموصى به للحفظ)
```python
from crewai.flow import ChatState
class MyChatState(ChatState):
# موروث: id, messages, last_user_message, last_intent, session_ready
research_turn_count: int = 0
custom_flag: bool = False
```
| الحقل | الدور |
|-------|------|
| `id` | UUID الجلسة (مثل `session_id` / `inputs["id"]`) |
| `messages` | قائمة `{role, content}` لسجل LLM |
| `last_user_message` | آخر سطر مستخدم في هذه الجولة |
| `last_intent` | تسمية المسار بعد التصنيف (إن وُجد) |
| `session_ready` | علم bootstrap لمرة واحدة |
`ConversationalInputs` هو `TypedDict` لـ `kickoff(inputs={...})`: `id`, `user_message`, `last_intent`.
## API المحادثة على `Flow`
### معاملات `kickoff` / `kickoff_async`
| المعامل | الغرض |
|---------|--------|
| `user_message` | نص هذه الجولة (أو `{"role": "user", "content": "..."}`) |
| `session_id` | UUID المحادثة → `inputs["id"]` / `state.id` |
| `intents` | تسميات outcome لـ `classify_intent` قبل kickoff |
| `intent_llm` | LLM للتصنيف (مطلوب مع `intents`) |
| `interactive` | حلقة CLI عبر `ask()` (للعروض المحلية فقط) |
| `interactive_prompt` | مطالبة الوضع التفاعلي |
| `interactive_timeout` | مهلة `ask()` لكل سطر |
| `exit_commands` | كلمات إنهاء الوضع التفاعلي |
| `inputs` | حقول حالة إضافية |
| `restore_from_state_id` | استنساخ من flow محفوظ آخر |
### سمات المثيل
| السمة | الغرض |
|-------|--------|
| `conversational_config` | افتراضيات `ConversationalConfig` على مستوى الصنف |
| `defer_trace_finalization` | علم المثيل؛ يُضبط تلقائياً من config عند kickoff |
| `suppress_flow_events` | يخفي لوحات console؛ **التتبع يُسجّل** |
| `stream` | بث؛ مع `ChatSession.handle_turn(..., stream=True)` |
### طرق وخصائص
| الاسم | الوصف |
|------|--------|
| `append_message(role, content, **extra)` | إضافة إلى `state.messages` |
| `conversation_messages` | سجل للقراءة فقط لاستدعاءات LLM |
| `classify_intent(text, outcomes, *, llm, context=None)` | تعيين outcome |
| `receive_user_message(text, *, outcomes=None, llm=None)` | إضافة رسالة مستخدم؛ `last_intent` اختياري |
| `finalize_session_traces()` | إصدار `flow_finished` المؤجل وإنهاء دفعة trace |
| `_should_defer_trace_finalization()` | هل يُؤجل إنهاء trace لكل جولة |
| `input_history` | سجل تدقيق مطالبات وردود `ask()` |
### مساعدات الوحدة (`crewai.flow.conversation`)
| الدالة | الوصف |
|--------|--------|
| `normalize_kickoff_inputs(...)` | دمج kwargs المحادثة في `inputs` |
| `get_conversation_messages(flow)` | قراءة الرسائل من الحالة أو المخزن |
| `append_message(flow, ...)` | مثل طريقة المثيل |
| `prepare_conversational_turn(flow, ...)` | تهيئة الجولة (عادةً kickoff يستدعيها) |
| `receive_user_message(flow, ...)` | مثل طريقة المثيل |
| `set_state_field(flow, name, value)` | تعيين حقل dict أو Pydantic |
| `get_conversational_config(flow)` | قراءة `conversational_config` |
| `input_history_to_messages(entries)` | تحويل `input_history` لصيغة رسائل LLM |
## أنماط توجيه النية
### أ. تصنيف مسبق عبر `ConversationalConfig` (الأبسط)
عيّن `default_intents` و`intent_llm`. كل kickoff يصنّف قبل `@router`؛ اقرأ `self.state.last_intent` في `route()`.
### ب. تصنيف داخل `@router` (مطالبات أغنى)
عيّن `default_intents=None` ليضيف kickoff الرسالة فقط. في `route()` استدعِ `classify_intent`:
```python
@router(bootstrap)
def route(self):
intent = self.classify_intent(
self._routing_prompt(self.state.last_user_message),
("GREETING", "ORDER", "RESEARCH", "GOODBYE"),
llm=self.conversational_config.intent_llm or "gpt-4o-mini",
)
self.state.last_intent = intent
return intent
```
للبحث على الويب أو أدوات متعددة الخطوات استخدم **`@listen("RESEARCH")`** مع `Agent.kickoff()` وأدوات — وليس `LLM.call()` فقط.
## عندما ينتهي الـ flow ويستمر المستخدم
`FlowFinished` يعني أن **تنفيذ الرسم هذا** اكتمل. تستمر المحادثة بـ `kickoff` آخر ونفس `session_id`. `@persist` يستعيد `messages` والأعلام والسياق.
**نمط الحفظ:** يُفضّل `@persist` على **خطوة نهائية واحدة** (مثل `finalize`) وليس على صنف `Flow` بالكامل. الحفظ على مستوى الصنف بعد كل method قد يفقد تحديثات المعالجات في نفس الجولة.
لا تستخدم `@human_feedback` لأسطر المتابعة في الدردشة إلا عند الحاجة لموافقة بشرية على مخرجات خطوة محددة.
## `Flow` المحادثاتي (تجريبي)
<Warning>
**ميزة تجريبية.** سطح `Flow` المحادثاتي (`conversational = True`،
`handle_turn`، `ConversationConfig`، `RouterConfig`،
`ConversationState`، الرسم البياني المدمج والمساعدات) يقع تحت
`crewai.experimental` وقد يتغير شكله قبل التخرج. ثبّت إصدار CrewAI إذا
كنت تعتمد على سلوك محدد، وراقب changelog للتحديثات الكاسرة. الملاحظات
والمشاكل مرحب بها.
</Warning>
فعّل الرسم المحادثاتي بتعيين `conversational = True` على صنف فرعي من `Flow`. عندئذٍ يُظهر `Flow` الأساسي رسم `@start` / `@router` / `converse_turn` / `end_conversation` مدمجاً، ويدير `state.messages`، ويُشغّل LLM التوجيه، ويبقي دفعة trace مفتوحة عبر الجولات. أنت تكتب **المسارات المخصصة** فقط؛ والإطار يتولى الباقي.
استخدمه عندما تريد دردشة متعددة الجولات مع موجّه قائم على LLM ومعالجات لكل مسار دون توصيل دورة الحياة يدوياً. استخدم `Flow[ChatState]` (النمط الأدنى مستوى في الأعلى) عندما تحتاج تحكماً كاملاً.
### مثال سريع
```python
from crewai import LLM, 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(), # المسارات + الأوصاف تُكتشف تلقائياً من معالجات @listen
)
class SupportFlow(Flow[ConversationState]):
conversational = True
@listen("INTERNET_SEARCH")
def handle_internet_search(self) -> str:
"""Fresh web research, current news, real-time lookups."""
...
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."""
...
self.append_assistant_message(reply)
return reply
flow = SupportFlow()
try:
flow.handle_turn("ماذا يمكنك أن تفعل؟") # يوجَّه إلى converse (مدمج)
flow.handle_turn("ابحث في الويب عن أخبار الذكاء الاصطناعي.") # يوجَّه إلى INTERNET_SEARCH
flow.handle_turn("لخص النتيجة الأولى.") # يعود إلى converse
finally:
flow.finalize_session_traces()
```
للدردشة المحلية في الطرفية، استخدم `chat()`:
```python
def kickoff() -> None:
SupportFlow().chat()
```
يلف `chat()` استدعاءات `handle_turn()` داخل REPL، ويخرج عند `exit` / `quit`، ويتجاهل الأسطر الفارغة افتراضياً، ويستدعي `finalize_session_traces()` عند انتهاء الجلسة.
### `ConversationConfig`
مزخرف صنف يُلحق افتراضيات الدردشة على مستوى الصنف.
| الحقل | الافتراضي | الغرض |
|-------|-----------|-------|
| `system_prompt` | `slices.conversational_system_prompt` من i18n | رسالة system يستخدمها `converse_turn` المدمج. مرر `""` للتعطيل التام. |
| `llm` | `None` | LLM المحادثة (يستخدمه `converse_turn` وكاحتياطي للموجّه). |
| `router` | `None` | `RouterConfig` للتوجيه عبر LLM. بدونه، يسقط الـ flow دائماً إلى `converse`. |
| `answer_from_history_prompt` | افتراضي الإطار | رسالة system للمسار الاختياري `answer_from_history`. |
| `answer_from_history_llm` | `None` | يُفعّل الاختصار `answer_from_history` عند تعيينه. |
| `intent_llm` | `None` | LLM لمسار التصنيف المسبق القديم `intents=`/`default_intents`. |
| `default_intents` | `None` | تسميات النتائج للتصنيف المسبق القديم. |
| `visible_agent_outputs` | `None` | `"all"` أو قائمة بأسماء الـ agents الذين تُرفع مخرجاتهم من `append_agent_result()` إلى رسائل عامة. |
| `defer_trace_finalization` | `True` | يبقي دفعة trace واحدة مفتوحة عبر استدعاءات `handle_turn()`. |
### `RouterConfig` وفهرس المسارات المُولَّد تلقائياً
```python
RouterConfig(
prompt="تأطير اختياري للنطاق (سياسة، صوت، شخصية).",
response_format=MyRoute, # اختياري؛ يُولَّد تلقائياً عند الإغفال
llm=ROUTER_LLM, # يسقط إلى ConversationConfig.llm
routes=["INTERNET_SEARCH", "CREWAI_DOCS"], # اختياري؛ يُستنتج من المستمعين
route_descriptions={
"INTERNET_SEARCH": "تجاوز الـ docstring لهذا المسار فقط.",
},
default_intent="converse", # يُستخدم عند فشل LLM أو غيابه
fallback_intent="converse", # يُستخدم عندما يعيد LLM مساراً غير صالح
intent_field="intent",
)
```
تُبنى رسالة الموجّه إلى LLM تلقائياً. لكل مسار يختار الإطار وصفاً بهذا الترتيب من الأولوية:
1. `RouterConfig.route_descriptions[label]` — تجاوز صريح.
2. `Flow.builtin_route_descriptions[label]` — نص جاهز من الإطار لـ `converse` و`end` و`answer_from_history` (مصاغ لـ LLM التوجيه).
3. أول سطر غير فارغ من docstring معالج `@listen(label)`.
4. فارغ (المسار يظهر في الفهرس بلا وصف).
عملياً، **إضافة مسار جديد = `@listen("X")` + docstring من سطر واحد**:
```python
@listen("INTERNET_SEARCH")
def handle_internet_search(self) -> str:
"""Fresh web research, current news, real-time lookups."""
...
```
…وسيرى LLM التوجيه:
```
Routes:
- CREWAI_DOCS: Look up the CrewAI documentation for framework/API questions.
- INTERNET_SEARCH: Fresh web research, current news, real-time lookups.
- converse: Ordinary chat, follow-ups, summaries, clarifications…
- end: User signals the conversation is finished (goodbye, exit, done).
```
`RouterConfig.prompt` مخصص لـ **تأطير النطاق** (شخصية المساعد، قواعد العمل، النبرة). فهرس المسارات يُبنى تلقائياً — لا تُدرج المسارات في `prompt`؛ سيختل التزامن لحظة إضافة معالج جديد.
### المسارات المدمجة
| المسار | المعالج | الغرض |
|--------|---------|-------|
| `converse` | `converse_turn` | معالج الدردشة الافتراضي. يستدعي `ConversationConfig.llm` بـ system prompt + التاريخ القانوني للرسائل. |
| `end` | `end_conversation` | يضبط `state.ended = True` ويُصدر رد إنهاء. |
| `answer_from_history` | `answer_from_history_turn` | اختياري. يُوجَّه إليه عندما يكون `ConversationConfig.answer_from_history_llm` مُعيَّناً ويمكن الإجابة على الرسالة من التاريخ فقط. |
يمكنك تجاوز أي من هذه بتعريف معالج بنفس الاسم في الصنف الفرعي.
### دلالات `handle_turn()`
`flow.handle_turn(message)` يُشغّل جولة واحدة:
1. يعيد ضبط تعقّب التنفيذ لكل جولة (`_completed_methods`, `_method_outputs`) ليُعاد تشغيل الرسم — بدون ذلك، استدعاءات `kickoff` المتكررة على نفس النسخة ستُحدث دائرة قصر من الجولة الثانية لأن `Flow.kickoff_async` يعتبر `inputs={"id": ...}` استعادة من نقطة تفتيش.
2. يُلحق رسالة المستخدم بـ `state.messages` ويضبط `current_user_message` / `last_user_message`. يُحافَظ على `last_intent` **من الجولة السابقة** كي يستخدمها LLM التوجيه كإشارة.
3. يُشغّل `conversation_start` → `route_conversation` → معالج `@listen` المختار.
4. يخزّن الموجّه قراره في `state.last_intent` (يكون مرئياً لسياق التوجيه في الجولة التالية).
5. إذا أعاد معالجك سلسلة نصية ولم يستدعِ `append_assistant_message`، فإن `handle_turn` يُلحقها نيابةً عنك.
استدعِ `handle_turn()` لرسائل الدردشة. استدعاء `kickoff(inputs={"id": ...})` مباشرةً يشغل الرسم بدون غلاف الجولة المحادثية.
### `chat()` للـ REPL المحلي
`flow.chat()` هو غلاف الطرفية الجاهز فوق `handle_turn()`:
```python
flow = SupportFlow()
flow.chat()
```
يتولى الحلقة المحلية الشائعة:
1. يطلب رسالة من المستخدم.
2. يتوقف عند `exit` / `quit` أو `EOFError` أو `KeyboardInterrupt`.
3. يستدعي `handle_turn(message, session_id=...)`.
4. يطبع نتيجة المساعد.
5. ينهي traces الجلسة المؤجلة داخل كتلة `finally`.
خصص سلوك الطرفية عبر I/O قابل للحقن:
```python
flow.chat(
session_id="demo-session",
prompt="You: ",
assistant_prefix="Assistant: ",
exit_commands=("exit", "quit", "bye"),
)
```
لتطبيقات الويب والـ workers الخلفية والاختبارات ووسائط النقل المخصصة، استمر في استخدام `handle_turn()` مباشرةً.
### سلوك موجّه مخصص
لتشغيل آثار جانبية (إعداد ناقل أحداث، قياس عن بُعد) في كل قرار توجيه، تجاوز `route_turn`:
```python
class SupportFlow(Flow[ConversationState]):
conversational = True
def route_turn(self, context: dict[str, Any]) -> str | None:
self.event_bus = MyBus(self)
return super().route_turn(context)
```
لتجاوز موجّه LLM واختيار مسار برمجياً، أعد سلسلة نصية من `route_turn`؛ إعادة `None` تسقط إلى `_route_with_config(...)`.
### `append_assistant_message` و`append_agent_result`
داخل معالج `@listen(label)`، اختر:
- `self.append_assistant_message(text)` — يضيف جولة مساعد مرئية للمستخدم إلى `state.messages`. سيراها `converse_turn` في الجولة التالية.
- `self.append_agent_result(agent_name, result, visibility="private")` — يسجّل حدثاً منظماً في `state.events` وموضوعاً في `state.agent_threads[agent_name]`. الرؤية العامة تستدعي `append_assistant_message` أيضاً. استخدم النتائج الخاصة للعمل الجانبي الذي يجب ألا يلوث التاريخ القانوني.
يمكن لـ `ConversationConfig.visible_agent_outputs` رفع النتائج الخاصة لـ agents محددين إلى عامة عالمياً (`"all"` أو قائمة بالأسماء).
## التتبع عبر الجولات
مع `defer_trace_finalization=True` (افتراضي في `ConversationalConfig`):
- **دفعة trace واحدة** لجلسة الدردشة.
- **`flow_started`** في الجولة الأولى فقط؛ **`flow_finished`** مرة في `finalize_session_traces()`.
- **`kickoff` لكل جولة** لا يطبع "Trace batch finalized".
- **العمل المتداخل** (`Agent.kickoff()`, crews, Exa) يُلحق بدفعة **الأب**؛ flow داخلي من `AgentExecutor` لا يغلق دفعة الجلسة مبكراً.
```python
flow.chat(session_id=session_id)
```
`flow.chat()` يستدعي `finalize_session_traces()` نيابةً عنك. عندما تملك الحلقة عبر `handle_turn()` أو `kickoff(...)`، استدعِ `finalize_session_traces()` عند انتهاء الجلسة.
`suppress_flow_events=True` يخفي لوحات Rich فقط؛ أحداث trace والـ methods تُصدر.
### دورة حياة trace لـ `Flow` المحادثاتي
يستخدم [`Flow` المحادثاتي](#flow-المحادثاتي-تجريبي) التجريبي نفس دورة حياة tracing: `defer_trace_finalization` افتراضياً `True`، فيبقي كل `handle_turn()` أثر الجلسة مفتوحاً. أنهِ دوماً عند نهاية الجلسة — لُف حلقتك بـ `try/finally` واستدعِ `flow.finalize_session_traces()` عند الخروج. بدون ذلك، تبقى الدفعة مفتوحة وقد لا تُصدَّر آخر محادثة أبداً.
## البث
اضبط `stream = True` على صنف `Flow`. عندئذٍ يُصدر `kickoff(...)` أحداث `assistant_delta` (وما يرتبط بها) عبر ناقل الأحداث القياسي.
## الاستيراد
```python
from crewai.flow import (
ChatState,
ConversationalConfig,
ConversationalInputs,
Flow,
listen,
persist,
router,
start,
)
```
## مراجع
- [إتقان إدارة حالة Flow](/ar/guides/flows/mastering-flow-state)
- [أنشئ أول Flow](/ar/guides/flows/first-flow)
- Demo: `lib/crewai/runner_conversational_flow_simple.py` — REPL بسيط مع `RESEARCH` ووكيل Exa

View File

@@ -272,6 +272,7 @@ crewai flow plot
3. استكشف دوال `and_` و`or_` لتنفيذ متوازٍ أكثر تعقيدًا
4. اربط Flow بواجهات API خارجية وقواعد بيانات وواجهات مستخدم
5. ادمج عدة Crews متخصصة في Flow واحد
6. أنشئ تطبيقات دردشة متعددة الجولات مع [تدفقات المحادثة](/ar/guides/flows/conversational-flows) (`kickoff` لكل رسالة، `ChatSession`، تأجيل التتبع)
<Check>
تهانينا! لقد بنيت بنجاح أول CrewAI Flow يجمع بين الكود العادي واستدعاءات LLM المباشرة ومعالجة Crew لإنشاء دليل شامل. هذه المهارات الأساسية تمكّنك من إنشاء تطبيقات AI متطورة بشكل متزايد.

View File

@@ -20,6 +20,8 @@ mode: "wide"
5. **توسيع تطبيقاتك** - دعم سير العمل المعقدة بتنظيم بيانات مناسب
6. **تمكين التطبيقات الحوارية** - تخزين والوصول إلى سجل المحادثات للتفاعلات الواعية بالسياق
للدردشة متعددة الجولات (`kickoff` لكل سطر مستخدم، `ChatState`، توجيه النية، تأجيل التتبع، و`ChatSession`)، راجع [تدفقات المحادثة](/ar/guides/flows/conversational-flows).
## أساسيات إدارة الحالة
### نهجان لإدارة الحالة

File diff suppressed because it is too large Load Diff

View File

@@ -4,6 +4,67 @@ description: "Product updates, improvements, and bug fixes for CrewAI"
icon: "clock"
mode: "wide"
---
<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 release on GitHub](https://github.com/crewAIInc/crewAI/releases/tag/1.14.7a1)
## What's Changed
### Features
- Add crew trained agents file support
- Add native Snowflake Cortex LLM provider
- Add Databricks integration guide
- Add Snowflake integration guide
### Bug Fixes
- Fix CLI by restoring `[project.scripts]` in crewai package for UV tool install
- Resolve file input reliability issues
- Fix incomplete tool result histories in Snowflake Claude
- Handle stringified tool calls for Snowflake Claude
- Re-arm multi-source `or_` listeners across router-driven cycles
### Performance
- Improve crewai import speed by lazy-loading docling imports
### Refactoring
- Split `flow.py` into DSL, definition, and runtime
## Contributors
@Luzk, @alex-clawd, @devin-ai-integration[bot], @greysonlalonde, @jessemiller, @lorenzejay, @vinibrsl
</Update>
<Update label="May 28, 2026">
## v1.14.6

View File

@@ -107,7 +107,7 @@ There are different places in CrewAI code where you can specify the model to use
</Tabs>
<Info>
CrewAI provides native SDK integrations for OpenAI, Anthropic, Google (Gemini API), Azure, and AWS Bedrock — no extra install needed beyond the provider-specific extras (e.g. `uv add "crewai[openai]"`).
CrewAI provides native SDK integrations for OpenAI, Anthropic, Google (Gemini API), Azure, AWS Bedrock, and Snowflake Cortex — no extra install needed beyond the provider-specific extras (e.g. `uv add "crewai[openai]"`).
All other providers are powered by **LiteLLM**. If you plan to use any of them, add it as a dependency to your project:
```bash
@@ -291,6 +291,55 @@ In this section, you'll find detailed examples that help you select, configure,
```
</Accordion>
<Accordion title="Snowflake Cortex">
CrewAI provides native integration with the Snowflake Cortex REST API through its OpenAI-compatible Chat Completions endpoint. This avoids LiteLLM fallback for `snowflake/...` models. Snowflake Cortex currently supports Chat Completions only in CrewAI, so use the default `api` mode and do not set `api="responses"`.
```toml Code
# Required
SNOWFLAKE_PAT=<your-programmatic-access-token>
SNOWFLAKE_ACCOUNT_URL=https://<account-identifier>.snowflakecomputing.com
# Alternative account configuration
SNOWFLAKE_ACCOUNT=<account-identifier>
```
**Basic Usage:**
```python Code
from crewai import LLM
llm = LLM(
model="snowflake/openai-gpt-4.1",
temperature=0.7,
max_completion_tokens=1024,
)
```
**Claude Models on Cortex:**
```python Code
from crewai import LLM
llm = LLM(
model="snowflake/claude-sonnet-4-5",
max_completion_tokens=1024,
stream=True,
)
```
**Supported Environment Variables:**
- `SNOWFLAKE_PAT`, `SNOWFLAKE_TOKEN`, or `SNOWFLAKE_JWT`: token used as the Bearer credential
- `SNOWFLAKE_ACCOUNT_URL`: full Snowflake account URL
- `SNOWFLAKE_ACCOUNT`, `SNOWFLAKE_ACCOUNT_ID`, or `SNOWFLAKE_ACCOUNT_IDENTIFIER`: account identifier used to build the account URL
Snowflake REST requests use the user's default Snowflake role. Make sure that role has `SNOWFLAKE.CORTEX_USER` or `SNOWFLAKE.CORTEX_REST_API_USER`. Database, schema, warehouse, and explicit role parameters are not required by the Cortex REST Chat Completions endpoint.
**Features:**
- Native provider selection with `model="snowflake/<model-name>"`
- Streaming and non-streaming Chat Completions only; `api="responses"` is not supported
- Token usage tracking
- Function calling for Snowflake-hosted OpenAI and Claude models
- Automatic removal of invalid trailing assistant prefill for Snowflake Claude models
</Accordion>
<Accordion title="Anthropic">
CrewAI provides native integration with Anthropic through the Anthropic Python SDK.
@@ -903,6 +952,61 @@ In this section, you'll find detailed examples that help you select, configure,
```
</Accordion>
<Accordion title="NVIDIA Nemotron">
NVIDIA Nemotron models are designed for demanding agentic workloads, including complex reasoning, long-context analysis, tool use, multilingual tasks, and high-stakes RAG.
The `NVIDIA-Nemotron-3-Ultra-550B-A55B-NVFP4` model is a frontier-scale open-weight model from NVIDIA with 550B total parameters and 55B active parameters. It uses a LatentMoE architecture that combines Mamba-2, MoE, Attention, and Multi-Token Prediction (MTP), and supports context lengths up to 1M tokens.
<Info>
`NVIDIA-Nemotron-3-Ultra-550B-A55B-NVFP4` is a very large model. NVIDIA lists minimum serving requirements of 4x GB200, 4x B200, 4x GB300, 4x B300, or 8x H100 GPUs. For most CrewAI users, the recommended path is to use NVIDIA NIM or another OpenAI-compatible hosted endpoint rather than running it locally.
</Info>
**Hosted NVIDIA NIM usage:**
```toml Code
NVIDIA_API_KEY=<your-api-key>
```
```python Code
from crewai import LLM
llm = LLM(
model="nvidia_nim/nvidia/nvidia-nemotron-3-ultra-550b-a55b",
temperature=0.2,
max_tokens=4096,
)
```
**Self-hosted OpenAI-compatible endpoint:**
```python Code
from crewai import LLM
llm = LLM(
model="openai/nvidia-nemotron-3-ultra-550b-a55b-nvfp4",
base_url="https://your-nemotron-endpoint.example.com/v1",
api_key="your-api-key",
temperature=0.2,
max_tokens=4096,
)
```
**Model details:**
| Model | Context Window | Best For |
|-------|----------------|----------|
| `nvidia/NVIDIA-Nemotron-3-Ultra-550B-A55B-NVFP4` | Up to 1M tokens | Frontier reasoning, complex agentic workflows, long-context analysis, tool use, multilingual reasoning, and high-stakes RAG |
**Supported languages:** English, French, Spanish, Italian, German, Japanese, Korean, Hindi, Brazilian Portuguese, and Chinese.
**Reasoning mode:** Nemotron 3 Ultra supports configurable reasoning via its chat template using `enable_thinking=True` or `enable_thinking=False`. If you are using a hosted endpoint, check your provider's documentation for how that flag is exposed.
For model details, license, and deployment guidance, see the [NVIDIA Nemotron 3 Ultra model card](https://huggingface.co/nvidia/NVIDIA-Nemotron-3-Ultra-550B-A55B-NVFP4).
**Note:** Hosted NVIDIA NIM usage uses LiteLLM. Add it as a dependency to your project:
```bash
uv add 'crewai[litellm]'
```
</Accordion>
<Accordion title="Local NVIDIA NIM Deployed using WSL2">
NVIDIA NIM enables you to run powerful LLMs locally on your Windows machine using WSL2 (Windows Subsystem for Linux).

View File

@@ -187,7 +187,7 @@ flowchart TD
- **Filename Requirement:** Ensure that the filename ends with `.pkl`. The code will raise a `ValueError` if this condition is not met.
- **Error Handling:** The code handles subprocess errors and unexpected exceptions, providing error messages to the user.
- Trained guidance is applied at prompt time; it does not modify your Python/YAML agent configuration.
- Agents automatically load trained suggestions from a file named `trained_agents_data.pkl` located in the current working directory. If you trained to a different filename, either rename it to `trained_agents_data.pkl` before running, or adjust the loader in code.
- Agents automatically load trained suggestions from a file named `trained_agents_data.pkl` located in the current working directory. If you trained to a different filename, pass that path with `Crew(trained_agents_file="my_custom_trained.pkl")`, set `CREWAI_TRAINED_AGENTS_FILE`, or use `crewai run -f my_custom_trained.pkl`.
- You can change the output filename when calling `crewai train` with `-f/--filename`. Absolute paths are supported if you want to save outside the CWD.
It is important to note that the training process may take some time, depending on the complexity of your agents and will also require your feedback on each iteration.

View File

@@ -164,6 +164,12 @@ You need to push your crew to a GitHub repository. If you haven't created a crew
![Select Repository](/images/enterprise/select-repo.png)
</Frame>
<Tip>
If your Crew or Flow is inside a monorepo subfolder, expand **Advanced**
and set a working directory before deploying. See
[Monorepo Deployments](/en/enterprise/guides/monorepo-deployments).
</Tip>
</Step>
<Step title="Set Environment Variables">

View File

@@ -0,0 +1,225 @@
---
title: "Monorepo Deployments"
description: "Deploy a Crew or Flow from a subfolder in a larger repository"
icon: "folder-tree"
mode: "wide"
---
<Note>
Use a working directory when your Crew or Flow lives inside a larger
repository. CrewAI AMP validates, builds, tests, and runs the automation from
that subfolder instead of the repository root.
</Note>
## When to Use This
Monorepo deployments are useful when one repository contains multiple
automations, shared packages, or other application code:
```text
company-ai/
|-- uv.lock
|-- packages/
| `-- shared_tools/
`-- crews/
|-- support_agent/
| |-- pyproject.toml
| `-- src/
| `-- support_agent/
| |-- main.py
| `-- crew.py
`-- research_flow/
|-- pyproject.toml
`-- src/
`-- research_flow/
`-- main.py
```
To deploy `support_agent`, set the working directory to:
```text
crews/support_agent
```
AMP still pulls or uploads the whole repository, but it treats the selected
folder as the automation project root.
## What the Working Directory Controls
When a working directory is set, AMP uses that folder for:
- Project validation, including `pyproject.toml`, `src/`, and the Crew or Flow entry point
- Dependency installation with `uv`
- The running process working directory
- The `CREW_ROOT_DIR` environment variable
Leaving the field empty keeps the existing behavior and uses the repository
root.
## Supported Sources
You can set a working directory when creating a deployment from:
- A connected GitHub repository
- A Git repository configured in AMP
- A ZIP upload
<Info>
Configure working directories in the AMP web interface. The
`crewai deploy create` CLI flow does not prompt for this field.
</Info>
You can also add or change the working directory on an existing deployment from
the deployment's **Settings** page. The change takes effect on the next deploy.
<Warning>
Working directories and auto-deploy cannot be used together. If a deployment
has a working directory, auto-deploy is disabled for that deployment. Turn
auto-deploy off before setting a working directory.
</Warning>
## Configure a New Deployment
<Steps>
<Step title="Open Deploy from Code">
In CrewAI AMP, create a new deployment and choose your source: GitHub, Git
Repository, or ZIP upload.
</Step>
<Step title="Select the repository, branch, or ZIP file">
Choose the repository and branch that contain your monorepo, or upload a ZIP
file whose root contains the monorepo contents.
</Step>
<Step title="Open Advanced settings">
Expand the **Advanced** section in the deploy form.
</Step>
<Step title="Enter the working directory">
Enter the path from the repository root to the Crew or Flow project:
```text
crews/support_agent
```
Do not include a leading slash.
</Step>
<Step title="Deploy">
Add any required environment variables, then start the deployment.
</Step>
</Steps>
## Configure an Existing Deployment
<Steps>
<Step title="Open the deployment settings">
Go to your automation in AMP and open **Settings**.
</Step>
<Step title="Turn off auto-deploy if needed">
If auto-deploy is enabled, disable it first. The working directory field is
unavailable while auto-deploy is on.
</Step>
<Step title="Set the working directory">
In **Basic settings**, enter the subfolder path, such as:
```text
crews/support_agent
```
</Step>
<Step title="Redeploy">
Save the setting and redeploy the automation. The new working directory is
used on the next deploy.
</Step>
</Steps>
## Path Rules
The working directory must be a relative path inside the repository or ZIP root.
| Rule | Example |
|------|---------|
| Use a relative path | `crews/support_agent` |
| Do not start with `/` | `/crews/support_agent` is invalid |
| Do not use `.` or `..` path segments | `crews/../support_agent` is invalid |
| Use only letters, numbers, dashes, underscores, dots, and forward slashes | `crews/support agent` is invalid |
| Keep the path at 255 characters or fewer | Longer paths are rejected |
AMP trims leading and trailing whitespace, collapses repeated slashes, and
removes trailing slashes. A blank value uses the repository root.
## Lock Files and UV Workspaces
The selected folder must contain the automation's `pyproject.toml` and `src/`
directory. A `uv.lock` or `poetry.lock` file can live either in the selected
folder or at the repository root.
This supports both common monorepo layouts:
<Tabs>
<Tab title="Project lock file">
```text
company-ai/
`-- crews/
`-- support_agent/
|-- pyproject.toml
|-- uv.lock
`-- src/
`-- support_agent/
`-- main.py
```
</Tab>
<Tab title="Workspace lock file">
```text
company-ai/
|-- uv.lock
|-- packages/
| `-- shared_tools/
`-- crews/
`-- support_agent/
|-- pyproject.toml
`-- src/
`-- support_agent/
`-- main.py
```
</Tab>
</Tabs>
<Tip>
If your automation imports shared packages from elsewhere in the monorepo,
declare those packages in `pyproject.toml` using UV workspace, path, or source
configuration. AMP runs the automation from the selected folder, so shared
code should be installed as a dependency instead of relying on the repository
root being on the Python path.
</Tip>
## Troubleshooting
### Working Directory Not Found
Check that the path is relative to the repository or ZIP root. For ZIP uploads,
the ZIP contents must include the working directory path exactly as entered.
### Missing pyproject.toml
The working directory should point to the Crew or Flow project folder, not just
to a parent folder that contains several projects.
### Missing uv.lock or poetry.lock
Commit a lock file either in the selected project folder or in the repository
root. For UV workspaces, keeping `uv.lock` at the workspace root is supported.
### Auto-Deploy Is Unavailable
Auto-deploy is disabled while a working directory is set. Use manual redeploys
or trigger redeployments from CI/CD with the AMP API instead.
<Card title="Deploy to AMP" icon="rocket" href="/en/enterprise/guides/deploy-to-amp">
Continue with the deployment guide after choosing your monorepo working
directory.
</Card>

View File

@@ -0,0 +1,123 @@
---
title: Databricks Integration
description: "Connect CrewAI agents to Databricks Genie, SQL, Unity Catalog Functions, and Vector Search through Databricks managed MCP servers."
icon: "layer-group"
mode: "wide"
---
## Overview
Connect your CrewAI agents directly to your Databricks workspace through [Databricks managed MCP servers](https://docs.databricks.com/aws/en/generative-ai/mcp/managed-mcp). The Databricks integration lets your agents ask natural-language questions with **Genie**, run governed **SQL**, call **Unity Catalog Functions**, and retrieve documents with **Vector Search** — all without writing or hosting any connector code, and with Unity Catalog permissions enforced on every call.
Under the hood, the Databricks integration is a managed wrapper around CrewAI's [Custom MCP Server](/en/enterprise/guides/custom-mcp-server) support. Databricks exposes each capability as its own [Model Context Protocol](https://modelcontextprotocol.io/) endpoint, and CrewAI connects to them securely on your behalf. Because each server is added separately, you can enable exactly the capabilities your crews need.
## Key Capabilities
<CardGroup cols={2}>
<Card title="Genie" icon="comments">
Ask questions in plain language and get grounded answers from your data with [Genie](https://docs.databricks.com/aws/en/genie/), which queries Genie Spaces and Unity Catalog and links back to the Databricks UI.
</Card>
<Card title="Databricks SQL" icon="database">
Run governed SQL against your Databricks warehouses to query, transform, and author data pipelines directly from your agents.
</Card>
<Card title="Unity Catalog Functions" icon="function">
Invoke [Unity Catalog functions](https://docs.databricks.com/aws/en/udf/unity-catalog) to run predefined SQL and custom business logic as governed, reusable tools.
</Card>
<Card title="Vector Search" icon="magnifying-glass">
Retrieve relevant documents for RAG and knowledge workflows from [Mosaic AI Vector Search](https://docs.databricks.com/aws/en/generative-ai/vector-search) indexes using semantic similarity.
</Card>
</CardGroup>
Every server runs behind the Unity AI Gateway and enforces Unity Catalog access controls, so your agents only ever see the data and tools they're permitted to use.
## Prerequisites
Before using the Databricks integration, ensure you have:
- A [CrewAI AMP](https://app.crewai.com) account with an active subscription
- A Databricks workspace with the capabilities you want to expose (Genie Spaces, SQL warehouses, Unity Catalog functions, or Vector Search indexes)
- Appropriate [Unity Catalog privileges](https://docs.databricks.com/aws/en/data-governance/unity-catalog) on the underlying objects
- Your Databricks workspace hostname (e.g. `your-workspace.cloud.databricks.com`)
## Databricks Managed MCP Servers
Databricks publishes a separate managed MCP server for each capability. CrewAI exposes these as individual connections, each configured with your workspace host and the relevant Unity Catalog identifiers. The endpoints follow these patterns:
| Server | What it does | MCP URL pattern |
|--------|--------------|-----------------|
| **Genie** | Natural-language Q&A over a Genie Space | `https://<workspace-hostname>/api/2.0/mcp/genie/{genie_space_id}` |
| **Databricks SQL** | Execute SQL against your warehouses | `https://<workspace-hostname>/api/2.0/mcp/sql` |
| **Unity Catalog Functions** | Run registered UC functions | `https://<workspace-hostname>/api/2.0/mcp/functions/{catalog}/{schema}` |
| **Vector Search** | Query a Vector Search index | `https://<workspace-hostname>/api/2.0/mcp/vector-search/{catalog}/{schema}` |
<Note>
You don't construct these URLs by hand — CrewAI builds each endpoint from the workspace host and identifiers (Genie Space ID, or catalog/schema) you provide when configuring the connection. For the full specification and the latest endpoint details, see the [Databricks managed MCP documentation](https://docs.databricks.com/aws/en/generative-ai/mcp/managed-mcp).
</Note>
## Connecting Databricks in CrewAI AMP
<Frame>
<img src="/images/enterprise/databricks-configure.png" alt="Configure a Databricks managed MCP server in CrewAI AMP" />
</Frame>
Each Databricks capability — **Databricks Genie**, **Databricks SQL**, **Databricks Unity Catalog Functions**, and **Databricks Vector Search** — appears as its own MCP server under the Databricks group on the **Tools & Integrations** page. Configure the ones you need:
<Steps>
<Step title="Open Tools & Integrations">
Navigate to **Tools & Integrations** in the left sidebar of CrewAI AMP and locate the **Databricks** group in the Connections list. You'll see the Genie, SQL, Unity Catalog Functions, and Vector Search servers listed beneath it.
</Step>
<Step title="Configure a server">
Click **Configure** next to the capability you want to enable and provide its connection details:
- **Workspace Host** — your Databricks workspace hostname (e.g. `my-workspace.cloud.databricks.com`).
- **Genie** — the **Genie Space ID** to query.
- **Unity Catalog Functions** — the **catalog** and **schema** that contain your functions.
- **Vector Search** — the **catalog** and **schema** that contain your index.
- **Databricks SQL** — no additional identifiers; queries run against your workspace's SQL warehouses.
</Step>
<Step title="Choose an authentication method">
Select how CrewAI authenticates to Databricks. **OAuth** is recommended.
- **Use OAuth** — Connect securely using OAuth 2.0. Each user authenticates individually, and Databricks issues tokens scoped to the capability (`genie`, `sql`, `unity-catalog`, or `vector-search`). CrewAI handles the authorization flow and refreshes tokens automatically.
- **Use personal access token** — Authenticate with a [Databricks personal access token](https://docs.databricks.com/aws/en/dev-tools/auth/pat). Use a least-privileged identity to limit exposure.
</Step>
<Step title="Authenticate">
Complete authentication. Once connected, the server's tools become available to your crews. Repeat for any other Databricks capabilities you want to enable.
</Step>
</Steps>
<Tip>
Because each capability is a separate connection, you can mix and match — for example, enable Genie and Vector Search for a research crew while reserving SQL and Unity Catalog Functions for a data-engineering crew. Visibility settings let you control which team members can use each one.
</Tip>
## Using Databricks Tools in Your Crews
Once connected, the tools each MCP server exposes appear alongside built-in connections on the **Tools & Integrations** page. You can:
- **Assign tools to agents** in your crews just like any other CrewAI tool.
- **Manage visibility** to control which team members can use each connection.
- **Edit or remove** any connection at any time from the Connections list.
Your agents can now ask Genie for grounded answers, run SQL against your warehouses, call Unity Catalog functions, and search Vector Search indexes — with results flowing back into their reasoning automatically.
<Warning>
Databricks enforces governance through Unity Catalog and the Unity AI Gateway: a user can only discover and invoke tools their workspace identity is permitted to use. If a tool call fails, confirm the connecting user (or token identity) has the required Unity Catalog privileges on the Genie Space, warehouse, function, or index. Some Genie and SQL queries run asynchronously and may take a moment to return results.
</Warning>
## Learn More
<CardGroup cols={2}>
<Card title="Databricks Managed MCP Servers" icon="layer-group" href="https://docs.databricks.com/aws/en/generative-ai/mcp/managed-mcp">
Official Databricks documentation for the managed Genie, SQL, Unity Catalog Functions, and Vector Search MCP servers.
</Card>
<Card title="Custom MCP Servers in CrewAI" icon="plug" href="/en/enterprise/guides/custom-mcp-server">
Learn how CrewAI connects to any MCP server, the foundation the Databricks integration builds on.
</Card>
</CardGroup>
<Card title="Need Help?" icon="headset" href="mailto:support@crewai.com">
Contact our support team for assistance with the Databricks integration or troubleshooting.
</Card>

View File

@@ -0,0 +1,134 @@
---
title: Snowflake Integration
description: "Connect CrewAI agents to Snowflake Cortex Analyst, Cortex Search, and SQL execution through the Snowflake-managed MCP server."
icon: "snowflake"
mode: "wide"
---
## Overview
Connect your CrewAI agents directly to your Snowflake data through the [Snowflake-managed MCP server](https://docs.snowflake.com/en/user-guide/snowflake-cortex/cortex-agents-mcp). The Snowflake integration lets your agents query structured data with **Cortex Analyst**, search unstructured data with **Cortex Search**, and run governed SQL against your warehouses — all without writing or hosting any connector code.
Under the hood, the Snowflake integration is a managed wrapper around CrewAI's [Custom MCP Server](/en/enterprise/guides/custom-mcp-server) support. Snowflake exposes its Cortex AI capabilities through a [Model Context Protocol](https://modelcontextprotocol.io/) endpoint, and CrewAI connects to it securely on your behalf. Any tool you expose on the Snowflake side — Cortex Analyst, Cortex Search, SQL execution, Cortex Agents, or your own custom tools — becomes available to your crews.
## Key Capabilities
<CardGroup cols={3}>
<Card title="Cortex Analyst" icon="chart-bar">
Ask questions in natural language and let [Cortex Analyst](https://docs.snowflake.com/en/user-guide/snowflake-cortex/cortex-analyst) generate and run SQL against your **structured** data using rich semantic models.
</Card>
<Card title="Cortex Search" icon="magnifying-glass">
Retrieve relevant **unstructured** data for RAG and knowledge workflows with [Cortex Search](https://docs.snowflake.com/en/user-guide/snowflake-cortex/cortex-search/cortex-search-overview), Snowflake's fully managed search service.
</Card>
<Card title="SQL Execution" icon="database">
Run governed SQL queries directly against your Snowflake warehouses, with configurable read-only mode, timeouts, and warehouse selection.
</Card>
</CardGroup>
Because the integration surfaces whatever tools your MCP server publishes, you can also expose **Cortex Agents** and **custom tools** (user-defined functions and stored procedures) to your CrewAI agents.
## Prerequisites
Before using the Snowflake integration, ensure you have:
- A [CrewAI AMP](https://app.crewai.com) account with an active subscription
- A Snowflake account with access to Cortex AI features
- A [Snowflake-managed MCP server](https://docs.snowflake.com/en/user-guide/snowflake-cortex/cortex-agents-mcp) configured with the tools you want to expose
- Appropriate Snowflake privileges (USAGE/SELECT) on the MCP server and its underlying objects
## Setting Up the Snowflake MCP Server
The Snowflake-managed MCP server runs inside your Snowflake account and defines which tools are available to external clients like CrewAI. Create one with the [`CREATE MCP SERVER`](https://docs.snowflake.com/en/sql-reference/sql/create-mcp-server) command, listing the Cortex Search services, Cortex Analyst semantic views, and SQL tools you want to expose.
```sql
CREATE MCP SERVER my_mcp_server
FROM SPECIFICATION $$
tools:
- name: "sales_analyst"
type: "CORTEX_ANALYST"
identifier: "MY_DATABASE.MY_SCHEMA.sales_semantic_view"
description: "Answer questions about sales metrics"
- name: "docs_search"
type: "CORTEX_SEARCH_SERVICE_QUERY"
identifier: "MY_DATABASE.MY_SCHEMA.support_docs_search"
description: "Search internal support documentation"
- name: "run_sql"
type: "SQL_EXECUTION"
description: "Execute read-only SQL queries"
$$;
```
<Note>
The MCP endpoint follows the format `https://<account_URL>/api/v2/databases/{database}/schemas/{schema}/mcp-servers/{name}`. CrewAI builds this URL automatically from the **Account URL**, **Database**, **Schema**, and **MCP Server Name** you provide when configuring the integration.
</Note>
For the complete specification — including Cortex Agents, custom tools, response-size limits, and governance options — see the [Snowflake-managed MCP server documentation](https://docs.snowflake.com/en/user-guide/snowflake-cortex/cortex-agents-mcp).
## Connecting Snowflake in CrewAI AMP
<Frame>
<img src="/images/enterprise/snowflake-configure.png" alt="Configure Snowflake integration in CrewAI AMP" />
</Frame>
<Steps>
<Step title="Open Tools & Integrations">
Navigate to **Tools & Integrations** in the left sidebar of CrewAI AMP, find **Snowflake** in the list of applications, and open its configuration panel.
</Step>
<Step title="Provide connection details">
Fill in the connection fields that CrewAI uses to reach your Snowflake MCP server:
| Field | Required | Description |
|-------|----------|-------------|
| **Name** | Yes | A descriptive name for this connection (defaults to `Snowflake`). |
| **Description** | No | An optional summary of what this connection provides. |
| **Account URL** | Yes | Your Snowflake account URL, e.g. `xy12345.us-east-1.snowflakecomputing.com`. |
| **Database** | Yes | The database that contains your MCP server (e.g. `MY_DATABASE`). |
| **Schema** | Yes | The schema that contains your MCP server (e.g. `MY_SCHEMA`). |
| **MCP Server Name** | Yes | The name of the MCP server object you created in Snowflake (e.g. `MY_MCP_SERVER`). |
</Step>
<Step title="Choose an authentication method">
Select how CrewAI authenticates to Snowflake. **OAuth** is recommended.
- **Use OAuth** — Connect securely using OAuth 2.0 for token-based authentication without sharing your credentials. CrewAI handles the full authorization flow and refreshes tokens automatically. Copy the **Redirect URI** shown in the form (`https://oauth.crewai.com/oauth/add`) and register it as an authorized redirect URI in your Snowflake [OAuth security integration](https://docs.snowflake.com/en/user-guide/oauth-custom).
- **Use personal access token** — Authenticate using a [programmatic access token](https://docs.snowflake.com/en/user-guide/programmatic-access-tokens) generated from your Snowflake account settings. Assign a least-privileged role to the token to limit exposure.
</Step>
<Step title="Authenticate">
Click **Authenticate**. For OAuth, you'll be redirected to Snowflake to authorize access. Once authenticated, the Snowflake server appears in your Connections and its tools become available to your crews.
</Step>
</Steps>
<Tip>
With OAuth, each user authenticates individually and queries run with their Snowflake `DEFAULT_ROLE`. Make sure connecting users have a default role and warehouse set (`ALTER USER <username> SET DEFAULT_ROLE = '<role>' DEFAULT_WAREHOUSE = '<warehouse>'`) so Cortex Analyst and SQL tools have compute to run on.
</Tip>
## Using Snowflake Tools in Your Crews
Once connected, the tools your MCP server exposes appear alongside built-in connections on the **Tools & Integrations** page. You can:
- **Assign tools to agents** in your crews just like any other CrewAI tool.
- **Manage visibility** to control which team members can use the connection.
- **Edit or remove** the connection at any time from the Connections list.
Your agents can now ask Cortex Analyst for metrics, run Cortex Search over your documents, and execute SQL — with results flowing back into their reasoning automatically.
<Warning>
Snowflake enforces governance on the MCP server: role-based access control determines which tools a user can discover and invoke, and limits apply to response size, tool count (max 50 per server), and recursion depth. If a tool call fails, confirm the connecting user's role has the required privileges on the MCP server and its underlying objects.
</Warning>
## Learn More
<CardGroup cols={2}>
<Card title="Snowflake-managed MCP Server" icon="snowflake" href="https://docs.snowflake.com/en/user-guide/snowflake-cortex/cortex-agents-mcp">
Official Snowflake documentation for creating and governing the MCP server.
</Card>
<Card title="Custom MCP Servers in CrewAI" icon="plug" href="/en/enterprise/guides/custom-mcp-server">
Learn how CrewAI connects to any MCP server, the foundation the Snowflake integration builds on.
</Card>
</CardGroup>
<Card title="Need Help?" icon="headset" href="mailto:support@crewai.com">
Contact our support team for assistance with the Snowflake integration or troubleshooting.
</Card>

View File

@@ -172,7 +172,7 @@ Flows are ideal when:
```python
# Example: Customer Support Flow with structured processing
from crewai.flow.flow import Flow, listen, router, start
from crewai.flow.flow import Flow, listen, or_, router, start
from pydantic import BaseModel
from typing import List, Dict
@@ -238,7 +238,7 @@ class CustomerSupportFlow(Flow[SupportTicketState]):
# Additional category handlers...
@listen("billing", "account_access", "technical_issue", "feature_request", "other")
@listen(or_("billing", "account_access", "technical_issue", "feature_request", "other"))
def resolve_ticket(self, resolution_info):
# Final resolution step
self.state.resolution = f"Issue resolved: {resolution_info}"

View File

@@ -0,0 +1,501 @@
---
title: Conversational Flows
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 routing, deferred tracing, UI bridges, and a local `flow.chat()` REPL for conversational flows.
| Concept | Implementation |
|---------|----------------|
| 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.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 |
|-----|---------|
| `handle_turn(message, session_id=...)` | Ergonomic one-turn wrapper for conversational `Flow` |
| `chat()` | Local terminal REPL for conversational `Flow` |
| `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 `handle_turn` (SSE / WebSocket) |
## Quick start
```python
from uuid import uuid4
from crewai import Flow
from crewai.flow import listen
from crewai.experimental.conversational import (
ConversationConfig,
ConversationState,
)
@ConversationConfig(defer_trace_finalization=True)
class SupportFlow(Flow[ConversationState]):
conversational = True
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_assistant_message(reply)
return reply
@listen("help")
def handle_help(self):
reply = "How can I help?"
self.append_assistant_message(reply)
return reply
@listen("goodbye")
def handle_goodbye(self):
reply = "Goodbye!"
self.append_assistant_message(reply)
return reply
session_id = str(uuid4())
flow = SupportFlow()
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 `handle_turn` runs this pipeline:
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. **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_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.
## `ConversationConfig` (class-level defaults)
Decorate your conversational `Flow` subclass with `ConversationConfig`.
| Field | Default | Purpose |
|-------|---------|---------|
| `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 pre-classification per turn with `handle_turn(..., intents=..., intent_llm=...)`.
## 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
class MyChatState(ChatState):
# Inherited: id, messages, last_user_message, last_intent, session_ready
research_turn_count: int = 0
custom_flag: bool = False
```
| Field | Role |
|-------|------|
| `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) |
| `session_ready` | One-time bootstrap flag (permissions, caches, etc.) |
`ConversationalInputs` is a `TypedDict` for conventional `kickoff(inputs={...})` keys: `id`, `user_message`, `last_intent`.
## `Flow` conversational API
### `handle_turn` parameters
| Parameter | Purpose |
|-----------|---------|
| `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`) |
| `**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` | 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)` |
### Methods and properties
| Name | Description |
|------|-------------|
| `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` |
| `finalize_session_traces()` | Emit deferred `flow_finished` and finalize the session trace batch |
| `_should_defer_trace_finalization()` | Whether this flow defers per-turn trace finalization |
| `input_history` | Audit trail of `ask()` prompts and responses |
### Module helpers (`crewai.flow.conversation`)
Importable for tests or custom orchestration:
| Function | Description |
|----------|-------------|
| `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=...)` | 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` |
| `input_history_to_messages(entries)` | Convert `input_history` to LLM message format |
## Intent routing patterns
### A. Pre-classify via `ConversationConfig` (simplest)
Set `default_intents` and `intent_llm`. Each `handle_turn()` runs classification before routing; read `self.state.last_intent` in `route_turn()`.
### B. Classify inside `route_turn` (richer prompts)
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
def route_turn(self, context):
intent = self.classify_intent(
self._routing_prompt(self.state.current_user_message),
("GREETING", "ORDER", "RESEARCH", "GOODBYE"),
llm="gpt-4o-mini",
)
self.state.last_intent = intent
return intent
```
Use **`@listen("RESEARCH")`** (or similar) for steps that run `Agent.kickoff()` with tools — not bare `LLM.call()` — when you need web research or multi-step tool use.
## When the flow finishes but the user keeps chatting
`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.
Do **not** use `@human_feedback` for follow-up chat lines unless a human must approve a specific step output before it is shown.
## Conversational `Flow` (experimental)
<Warning>
**This is an experimental feature.** The conversational `Flow` surface
(`conversational = True`, `handle_turn`, `ConversationConfig`,
`RouterConfig`, `ConversationState`, the built-in graph + helpers) lives
under `crewai.experimental` and may change shape before it graduates.
Pin your CrewAI version if you depend on specific behavior, and watch the
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`, 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 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 Flow
from crewai.flow import listen
from crewai.experimental.conversational import (
ConversationConfig,
ConversationState,
)
@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
flow.handle_turn("Search the web for AI news.") # routes to INTERNET_SEARCH
flow.handle_turn("Check the CrewAI docs.") # routes to CREWAI_DOCS
finally:
flow.finalize_session_traces()
```
For a local terminal chat, use `chat()`:
```python
def kickoff() -> None:
SupportFlow().chat()
```
`chat()` wraps `handle_turn()` in a REPL, exits on `exit` / `quit`, skips blank lines by default, and calls `finalize_session_traces()` when the session ends.
### `ConversationConfig`
Class decorator that attaches per-class chat defaults.
| Field | Default | Purpose |
|-------|---------|---------|
| `system_prompt` | `slices.conversational_system_prompt` from i18n | System message used by the built-in `converse_turn`. Pass `""` to opt out entirely. |
| `llm` | `None` | Conversation LLM (used by `converse_turn` and as router fallback). |
| `router` | `None` | `RouterConfig` for LLM-driven routing. Without it, the flow always falls through to `converse`. |
| `answer_from_history_prompt` | Framework default | System message for the optional `answer_from_history` route. |
| `answer_from_history_llm` | `None` | Enables the `answer_from_history` short-circuit when set. |
| `intent_llm` | `None` | LLM for legacy `intents=`/`default_intents` pre-classification. |
| `default_intents` | `None` | Outcome labels for legacy pre-classification. |
| `visible_agent_outputs` | `None` | `"all"`, or a list of agent names whose `append_agent_result()` calls should be promoted to public assistant messages. |
| `defer_trace_finalization` | `True` | Keep one trace batch open across `handle_turn()` calls. |
### `RouterConfig` and the auto-built route catalog
```python
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
routes=["INTERNET_SEARCH", "CREWAI_DOCS"], # optional; inferred from listeners
route_descriptions={
"INTERNET_SEARCH": "Override the docstring for this one route.",
},
default_intent="converse", # used when LLM call fails or no LLM available
fallback_intent="converse", # used when LLM returns an invalid route
intent_field="intent",
)
```
The router prompt that gets sent to the LLM is built automatically. For each route the framework picks a description with this precedence:
1. `RouterConfig.route_descriptions[label]` — explicit override.
2. `Flow.builtin_route_descriptions[label]` — framework-canned text for `converse`, `end`, `answer_from_history` (phrased for the router LLM).
3. First non-empty line of the `@listen(label)` handler's docstring.
4. Empty (the route is listed without a description).
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."""
...
```
…and the router LLM sees:
```
Routes:
- CREWAI_DOCS: Look up the CrewAI documentation for framework/API questions.
- INTERNET_SEARCH: Fresh web research, current news, real-time lookups.
- converse: Ordinary chat, follow-ups, summaries, clarifications…
- end: User signals the conversation is finished (goodbye, exit, done).
```
`RouterConfig.prompt` is for **domain framing** (assistant persona, business rules, voice). The route catalog is auto-built — don't list routes in `prompt`; they'll drift the moment you add a handler.
### Built-in routes
| Route | Handler | Purpose |
|-------|---------|---------|
| `converse` | `converse_turn` | Default chat handler. Calls `ConversationConfig.llm` with the system prompt + canonical message history. |
| `end` | `end_conversation` | Sets `state.ended = True` and emits a terminator reply. |
| `answer_from_history` | `answer_from_history_turn` | Optional. Routes here when `ConversationConfig.answer_from_history_llm` is set and the message can be answered from existing history. |
You can override any of these by defining a same-named handler in your subclass.
### `handle_turn()` semantics
`flow.handle_turn(message)` runs one turn:
1. Resets per-execution tracking (`_completed_methods`, `_method_outputs`) so the graph re-runs — without this, repeated `kickoff` calls on the same flow instance would short-circuit on turn 2+ because `Flow.kickoff_async` treats `inputs={"id": ...}` as a checkpoint restore.
2. Appends the user message to `state.messages`, sets `current_user_message` / `last_user_message`. `last_intent` is **preserved from the prior turn** so the router LLM can use it as a signal.
3. Runs `conversation_start` → `route_conversation` → the chosen `@listen` handler.
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.
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
`flow.chat()` is the batteries-included terminal wrapper around `handle_turn()`:
```python
flow = SupportFlow()
flow.chat()
```
It handles the common local loop:
1. Prompts for a user message.
2. Stops on `exit` / `quit`, `EOFError`, or `KeyboardInterrupt`.
3. Calls `handle_turn(message, session_id=...)`.
4. Prints the assistant result.
5. Finalizes deferred session traces in a `finally` block.
Customize the terminal behavior with injectable I/O:
```python
flow.chat(
session_id="demo-session",
prompt="You: ",
assistant_prefix="Assistant: ",
exit_commands=("exit", "quit", "bye"),
)
```
For web apps, background workers, tests, and custom transports, keep using `handle_turn()` directly.
### Custom router behavior
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
def route_turn(self, context: dict[str, Any]) -> str | None:
self.event_bus = MyBus(self)
return super().route_turn(context)
```
To bypass the LLM router entirely and pick a route programmatically, return a string from `route_turn`; returning `None` falls back to `_route_with_config(...)`.
### `append_assistant_message` and `append_agent_result`
Inside a `@listen(label)` handler, choose:
- `self.append_assistant_message(text)` — adds a user-visible assistant turn to `state.messages`. The next turn's `converse_turn` sees it.
- `self.append_agent_result(agent_name, result, visibility="private")` — records a structured event in `state.events` and a thread in `state.agent_threads[agent_name]`. Public visibility also calls `append_assistant_message` for you. Use private results for scratch work that shouldn't pollute the canonical history.
`ConversationConfig.visible_agent_outputs` can promote specific agents' private results to public globally (`"all"`, or a list of agent names).
## Tracing across turns
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()`.
- **Per-turn** `kickoff` does not print “Trace batch finalized”.
- **Nested work** (`Agent.kickoff()`, crews, Exa tools) appends to the **parent** batch; inner `AgentExecutor` flows do not close the session batch early.
```python
flow.chat(session_id=session_id)
```
`flow.chat()` calls `finalize_session_traces()` for you. When you own the loop
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.
### Conversational `Flow` trace lifecycle
The experimental [conversational `Flow`](#conversational-flow-experimental) uses the same tracing lifecycle: `defer_trace_finalization` defaults to `True`, so each `handle_turn()` keeps the session trace open. Always finalize at the end of the session — wrap your REPL/loop in `try/finally` and call `flow.finalize_session_traces()` on exit. Without it, the trace batch stays open and the final conversation may never export.
## Streaming
Set `stream = True` on the `Flow` class. `kickoff(...)` will then emit `assistant_delta` (and related) events through the standard event bus.
## Imports
```python
from crewai.flow import (
ChatState,
ConversationalConfig,
ConversationalInputs,
Flow,
listen,
persist,
router,
start,
)
```
## See also
- [Mastering Flow State Management](/en/guides/flows/mastering-flow-state) — persistence, Pydantic state, `@persist`
- [Build Your First Flow](/en/guides/flows/first-flow) — flow basics
- Demo: `lib/crewai/runner_conversational_flow_simple.py` — minimal REPL with `RESEARCH` + Exa agent

View File

@@ -617,6 +617,7 @@ Now that you've built your first flow, you can:
3. Explore the `and_` and `or_` functions for more complex parallel execution
4. Connect your flow to external APIs, databases, or user interfaces
5. Combine multiple specialized crews in a single flow
6. Build multi-turn chat apps with [Conversational Flows](/en/guides/flows/conversational-flows) (`kickoff` per message, `ChatSession`, deferred tracing)
<Check>
Congratulations! You've successfully built your first CrewAI Flow that combines regular code, direct LLM calls, and crew-based processing to create a comprehensive guide. These foundational skills enable you to create increasingly sophisticated AI applications that can tackle complex, multi-stage problems through a combination of procedural control and collaborative intelligence.

View File

@@ -22,6 +22,8 @@ Effective state management enables you to:
5. **Scale your applications** - Support complex workflows with proper data organization
6. **Enable conversational applications** - Store and access conversation history for context-aware AI interactions
For multi-turn chat (`kickoff` per user line, `ChatState`, intent routing, deferred tracing, and `ChatSession`), see [Conversational Flows](/en/guides/flows/conversational-flows).
Let's explore how to leverage these capabilities effectively.
## State Management Fundamentals

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 MiB

View File

@@ -4,6 +4,67 @@ description: "CrewAI의 제품 업데이트, 개선 사항 및 버그 수정"
icon: "clock"
mode: "wide"
---
<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
[GitHub 릴리스 보기](https://github.com/crewAIInc/crewAI/releases/tag/1.14.7a1)
## 변경 사항
### 기능
- 승무원 교육을 받은 에이전트 파일 지원 추가
- 네이티브 Snowflake Cortex LLM 공급자 추가
- Databricks 통합 가이드 추가
- Snowflake 통합 가이드 추가
### 버그 수정
- UV 도구 설치를 위한 crewai 패키지에서 `[project.scripts]` 복원하여 CLI 수정
- 파일 입력 신뢰성 문제 해결
- Snowflake Claude에서 불완전한 도구 결과 기록 수정
- Snowflake Claude를 위한 문자열화된 도구 호출 처리
- 라우터 주도 사이클 전반에 걸쳐 다중 소스 `or_` 리스너 재장착
### 성능
- docling 가져오기를 지연 로딩하여 crewai 가져오기 속도 개선
### 리팩토링
- `flow.py`를 DSL, 정의 및 런타임으로 분할
## 기여자
@Luzk, @alex-clawd, @devin-ai-integration[bot], @greysonlalonde, @jessemiller, @lorenzejay, @vinibrsl
</Update>
<Update label="2026년 5월 28일">
## v1.14.6

View File

@@ -106,7 +106,7 @@ CrewAI 코드 내에는 사용할 모델을 지정할 수 있는 여러 위치
</Tabs>
<Info>
CrewAI는 OpenAI, Anthropic, Google (Gemini API), Azure, AWS Bedrock에 대해 네이티브 SDK 통합을 제공합니다 — 제공자별 extras(예: `uv add "crewai[openai]"`) 외에 추가 설치가 필요하지 않습니다.
CrewAI는 OpenAI, Anthropic, Google (Gemini API), Azure, AWS Bedrock, Snowflake Cortex에 대해 네이티브 SDK 통합을 제공합니다 — 제공자별 extras(예: `uv add "crewai[openai]"`) 외에 추가 설치가 필요하지 않습니다.
그 외 모든 제공자는 **LiteLLM**을 통해 지원됩니다. 이를 사용하려면 프로젝트에 의존성으로 추가하세요:
```bash
@@ -230,6 +230,55 @@ CrewAI는 고유한 기능, 인증 방법, 모델 역량을 제공하는 다양
```
</Accordion>
<Accordion title="Snowflake Cortex">
CrewAI는 OpenAI 호환 Chat Completions 엔드포인트를 통해 Snowflake Cortex REST API와 네이티브로 통합됩니다. `snowflake/...` 모델은 LiteLLM fallback 없이 사용됩니다. CrewAI에서 Snowflake Cortex는 현재 Chat Completions만 지원하므로 기본 `api` 모드를 사용하고 `api="responses"`를 설정하지 마세요.
```toml Code
# Required
SNOWFLAKE_PAT=<your-programmatic-access-token>
SNOWFLAKE_ACCOUNT_URL=https://<account-identifier>.snowflakecomputing.com
# Alternative account configuration
SNOWFLAKE_ACCOUNT=<account-identifier>
```
**기본 사용법:**
```python Code
from crewai import LLM
llm = LLM(
model="snowflake/openai-gpt-4.1",
temperature=0.7,
max_completion_tokens=1024,
)
```
**Cortex의 Claude 모델:**
```python Code
from crewai import LLM
llm = LLM(
model="snowflake/claude-sonnet-4-5",
max_completion_tokens=1024,
stream=True,
)
```
**지원 환경 변수:**
- `SNOWFLAKE_PAT`, `SNOWFLAKE_TOKEN`, 또는 `SNOWFLAKE_JWT`: Bearer 자격 증명으로 사용할 토큰
- `SNOWFLAKE_ACCOUNT_URL`: 전체 Snowflake 계정 URL
- `SNOWFLAKE_ACCOUNT`, `SNOWFLAKE_ACCOUNT_ID`, 또는 `SNOWFLAKE_ACCOUNT_IDENTIFIER`: 계정 URL을 만들 계정 식별자
Snowflake REST 요청은 사용자의 기본 Snowflake role을 사용합니다. 해당 role에 `SNOWFLAKE.CORTEX_USER` 또는 `SNOWFLAKE.CORTEX_REST_API_USER`가 있는지 확인하세요. Cortex REST Chat Completions 엔드포인트에는 database, schema, warehouse, 명시적 role 파라미터가 필요하지 않습니다.
**기능:**
- `model="snowflake/<model-name>"`을 통한 네이티브 provider 선택
- Streaming 및 non-streaming Chat Completions만 지원; `api="responses"`는 지원되지 않음
- 토큰 사용량 추적
- Snowflake 호스팅 OpenAI 및 Claude 모델의 함수 호출
- Snowflake Claude 모델에서 유효하지 않은 마지막 assistant prefill 자동 제거
</Accordion>
<Accordion title="Anthropic">
```toml Code
# Required

View File

@@ -163,6 +163,12 @@ Crew를 GitHub 저장소에 푸시해야 합니다. 아직 Crew를 만들지 않
![Select Repository](/images/enterprise/select-repo.png)
</Frame>
<Tip>
Crew 또는 Flow가 모노레포 하위 폴더 안에 있다면 배포 전에
**Advanced**를 펼치고 작업 디렉터리를 설정하세요.
[모노레포 배포](/ko/enterprise/guides/monorepo-deployments)를 참조하세요.
</Tip>
</Step>
<Step title="환경 변수 설정하기">
@@ -440,4 +446,4 @@ type = "flow"
<Card title="도움이 필요하신가요?" icon="headset" href="mailto:support@crewai.com">
배포 문제 또는 AMP 플랫폼에 대한 문의 사항이 있으시면 지원팀에 연락해 주세요.
</Card>
</Card>

View File

@@ -0,0 +1,222 @@
---
title: "모노레포 배포"
description: "더 큰 저장소의 하위 폴더에서 Crew 또는 Flow 배포하기"
icon: "folder-tree"
mode: "wide"
---
<Note>
Crew 또는 Flow가 더 큰 저장소 안에 있을 때 작업 디렉터리를 사용하세요.
CrewAI AMP는 저장소 루트 대신 해당 하위 폴더에서 자동화를 검증, 빌드,
실행합니다.
</Note>
## 사용 시점
모노레포 배포는 하나의 저장소에 여러 자동화, 공유 패키지 또는 다른 애플리케이션
코드가 함께 있을 때 유용합니다:
```text
company-ai/
|-- uv.lock
|-- packages/
| `-- shared_tools/
`-- crews/
|-- support_agent/
| |-- pyproject.toml
| `-- src/
| `-- support_agent/
| |-- main.py
| `-- crew.py
`-- research_flow/
|-- pyproject.toml
`-- src/
`-- research_flow/
`-- main.py
```
`support_agent`를 배포하려면 작업 디렉터리를 다음과 같이 설정합니다:
```text
crews/support_agent
```
AMP는 여전히 전체 저장소를 가져오거나 업로드하지만, 선택한 폴더를 자동화
프로젝트 루트로 처리합니다.
## 작업 디렉터리가 제어하는 항목
작업 디렉터리가 설정되면 AMP는 해당 폴더를 다음 용도로 사용합니다:
- `pyproject.toml`, `src/`, Crew 또는 Flow 진입점을 포함한 프로젝트 검증
- `uv`를 사용한 종속성 설치
- 실행 중인 프로세스의 작업 디렉터리
- `CREW_ROOT_DIR` 환경 변수
필드를 비워 두면 기존 동작이 유지되며 저장소 루트를 사용합니다.
## 지원되는 소스
다음 소스에서 배포를 만들 때 작업 디렉터리를 설정할 수 있습니다:
- 연결된 GitHub 저장소
- AMP에 구성된 Git 저장소
- ZIP 업로드
<Info>
작업 디렉터리는 AMP 웹 인터페이스에서 구성하세요.
`crewai deploy create` CLI 흐름은 이 필드를 묻지 않습니다.
</Info>
기존 배포의 **Settings** 페이지에서도 작업 디렉터리를 추가하거나 변경할 수
있습니다. 변경 사항은 다음 배포부터 적용됩니다.
<Warning>
작업 디렉터리와 auto-deploy는 함께 사용할 수 없습니다. 배포에 작업
디렉터리가 설정되어 있으면 해당 배포의 auto-deploy가 비활성화됩니다.
작업 디렉터리를 설정하기 전에 auto-deploy를 끄세요.
</Warning>
## 새 배포 구성
<Steps>
<Step title="Deploy from Code 열기">
CrewAI AMP에서 새 배포를 만들고 소스를 선택합니다: GitHub, Git
Repository 또는 ZIP 업로드.
</Step>
<Step title="저장소, 브랜치 또는 ZIP 파일 선택">
모노레포가 들어 있는 저장소와 브랜치를 선택하거나, 루트에 모노레포 내용이
포함된 ZIP 파일을 업로드합니다.
</Step>
<Step title="고급 설정 열기">
배포 양식에서 **Advanced** 섹션을 펼칩니다.
</Step>
<Step title="작업 디렉터리 입력">
저장소 루트에서 Crew 또는 Flow 프로젝트까지의 경로를 입력합니다:
```text
crews/support_agent
```
앞에 슬래시를 붙이지 마세요.
</Step>
<Step title="배포">
필요한 환경 변수를 추가한 다음 배포를 시작합니다.
</Step>
</Steps>
## 기존 배포 구성
<Steps>
<Step title="배포 설정 열기">
AMP에서 자동화로 이동한 뒤 **Settings**를 엽니다.
</Step>
<Step title="필요한 경우 auto-deploy 끄기">
auto-deploy가 활성화되어 있으면 먼저 끄세요. auto-deploy가 켜져 있는
동안에는 작업 디렉터리 필드를 사용할 수 없습니다.
</Step>
<Step title="작업 디렉터리 설정">
**Basic settings**에서 다음과 같은 하위 폴더 경로를 입력합니다:
```text
crews/support_agent
```
</Step>
<Step title="다시 배포">
설정을 저장하고 자동화를 다시 배포합니다. 새 작업 디렉터리는 다음 배포부터
사용됩니다.
</Step>
</Steps>
## 경로 규칙
작업 디렉터리는 저장소 또는 ZIP 루트 안의 상대 경로여야 합니다.
| 규칙 | 예시 |
|------|------|
| 상대 경로를 사용합니다 | `crews/support_agent` |
| `/`로 시작하지 않습니다 | `/crews/support_agent`는 유효하지 않습니다 |
| `.` 또는 `..` 경로 세그먼트를 사용하지 않습니다 | `crews/../support_agent`는 유효하지 않습니다 |
| 문자, 숫자, 하이픈, 밑줄, 점, 슬래시만 사용합니다 | `crews/support agent`는 유효하지 않습니다 |
| 경로는 255자 이하로 유지합니다 | 더 긴 경로는 거부됩니다 |
AMP는 앞뒤 공백을 제거하고, 반복된 슬래시를 하나로 줄이며, 끝의 슬래시를
제거합니다. 빈 값은 저장소 루트를 사용합니다.
## Lock 파일과 UV 워크스페이스
선택한 폴더에는 자동화의 `pyproject.toml`과 `src/` 디렉터리가 있어야
합니다. `uv.lock` 또는 `poetry.lock` 파일은 선택한 폴더나 저장소 루트에
둘 수 있습니다.
이 방식은 일반적인 두 가지 모노레포 레이아웃을 모두 지원합니다:
<Tabs>
<Tab title="프로젝트 lock 파일">
```text
company-ai/
`-- crews/
`-- support_agent/
|-- pyproject.toml
|-- uv.lock
`-- src/
`-- support_agent/
`-- main.py
```
</Tab>
<Tab title="워크스페이스 lock 파일">
```text
company-ai/
|-- uv.lock
|-- packages/
| `-- shared_tools/
`-- crews/
`-- support_agent/
|-- pyproject.toml
`-- src/
`-- support_agent/
`-- main.py
```
</Tab>
</Tabs>
<Tip>
자동화가 모노레포의 다른 위치에 있는 공유 패키지를 가져온다면, UV
workspace, path 또는 source 설정을 사용해 해당 패키지를 `pyproject.toml`에
선언하세요. AMP는 선택한 폴더에서 자동화를 실행하므로, 저장소 루트가
Python path에 있다고 가정하기보다 공유 코드를 종속성으로 설치해야 합니다.
</Tip>
## 문제 해결
### 작업 디렉터리를 찾을 수 없음
경로가 저장소 또는 ZIP 루트를 기준으로 한 상대 경로인지 확인하세요. ZIP
업로드의 경우 ZIP 내용에 입력한 작업 디렉터리 경로가 정확히 포함되어야 합니다.
### pyproject.toml 누락
작업 디렉터리는 여러 프로젝트를 담은 상위 폴더가 아니라 Crew 또는 Flow 프로젝트
폴더를 가리켜야 합니다.
### uv.lock 또는 poetry.lock 누락
선택한 프로젝트 폴더 또는 저장소 루트에 lock 파일을 커밋하세요. UV
워크스페이스의 경우 `uv.lock`을 워크스페이스 루트에 두는 방식이 지원됩니다.
### Auto-Deploy를 사용할 수 없음
작업 디렉터리가 설정되어 있으면 auto-deploy가 비활성화됩니다. 수동 재배포를
사용하거나 AMP API로 CI/CD에서 재배포를 트리거하세요.
<Card title="AMP에 배포하기" icon="rocket" href="/ko/enterprise/guides/deploy-to-amp">
모노레포 작업 디렉터리를 선택한 뒤 배포 가이드를 계속 진행하세요.
</Card>

View File

@@ -0,0 +1,123 @@
---
title: Databricks 연동
description: "Databricks 관리형 MCP 서버를 통해 CrewAI 에이전트를 Databricks Genie, SQL, Unity Catalog Functions, Vector Search에 연결하세요."
icon: "layer-group"
mode: "wide"
---
## 개요
[Databricks 관리형 MCP 서버](https://docs.databricks.com/aws/en/generative-ai/mcp/managed-mcp)를 통해 CrewAI 에이전트를 Databricks 워크스페이스에 직접 연결하세요. Databricks 연동을 사용하면 에이전트가 **Genie**로 자연어 질문을 하고, 거버넌스가 적용된 **SQL**을 실행하며, **Unity Catalog Functions**를 호출하고, **Vector Search**로 문서를 검색할 수 있습니다. 커넥터 코드를 작성하거나 호스팅할 필요가 없으며, 모든 호출에 Unity Catalog 권한이 적용됩니다.
내부적으로 Databricks 연동은 CrewAI의 [커스텀 MCP 서버](/ko/enterprise/guides/custom-mcp-server) 지원을 감싼 관리형 래퍼입니다. Databricks는 각 기능을 개별 [Model Context Protocol](https://modelcontextprotocol.io/) 엔드포인트로 노출하며, CrewAI가 사용자를 대신해 안전하게 연결합니다. 각 서버를 개별적으로 추가하므로 크루에 필요한 기능만 정확히 활성화할 수 있습니다.
## 주요 기능
<CardGroup cols={2}>
<Card title="Genie" icon="comments">
[Genie](https://docs.databricks.com/aws/en/genie/)로 자연어 질문을 하고 데이터 기반의 근거 있는 답변을 받으세요. Genie는 Genie Spaces와 Unity Catalog를 조회하고 Databricks UI로 연결되는 링크를 제공합니다.
</Card>
<Card title="Databricks SQL" icon="database">
에이전트에서 직접 Databricks 웨어하우스에 거버넌스가 적용된 SQL을 실행하여 데이터를 조회, 변환하고 데이터 파이프라인을 작성하세요.
</Card>
<Card title="Unity Catalog Functions" icon="function">
[Unity Catalog 함수](https://docs.databricks.com/aws/en/udf/unity-catalog)를 호출하여 사전 정의된 SQL과 맞춤형 비즈니스 로직을 거버넌스가 적용된 재사용 가능한 도구로 실행하세요.
</Card>
<Card title="Vector Search" icon="magnifying-glass">
[Mosaic AI Vector Search](https://docs.databricks.com/aws/en/generative-ai/vector-search) 인덱스에서 의미 유사도를 사용해 RAG 및 지식 워크플로우에 필요한 관련 문서를 검색하세요.
</Card>
</CardGroup>
모든 서버는 Unity AI Gateway 뒤에서 실행되며 Unity Catalog 접근 제어를 적용하므로, 에이전트는 허용된 데이터와 도구만 볼 수 있습니다.
## 사전 준비사항
Databricks 연동을 사용하기 전에 다음을 준비해야 합니다:
- 활성 구독이 있는 [CrewAI AMP](https://app.crewai.com) 계정
- 노출하려는 기능이 있는 Databricks 워크스페이스(Genie Spaces, SQL 웨어하우스, Unity Catalog 함수 또는 Vector Search 인덱스)
- 기본 객체에 대한 적절한 [Unity Catalog 권한](https://docs.databricks.com/aws/en/data-governance/unity-catalog)
- Databricks 워크스페이스 호스트명(예: `your-workspace.cloud.databricks.com`)
## Databricks 관리형 MCP 서버
Databricks는 각 기능마다 별도의 관리형 MCP 서버를 게시합니다. CrewAI는 이를 개별 연결로 노출하며, 각 연결은 워크스페이스 호스트와 관련 Unity Catalog 식별자로 구성됩니다. 엔드포인트는 다음 패턴을 따릅니다:
| 서버 | 기능 | MCP URL 패턴 |
|------|------|--------------|
| **Genie** | Genie Space에 대한 자연어 Q&A | `https://<workspace-hostname>/api/2.0/mcp/genie/{genie_space_id}` |
| **Databricks SQL** | 웨어하우스에 SQL 실행 | `https://<workspace-hostname>/api/2.0/mcp/sql` |
| **Unity Catalog Functions** | 등록된 UC 함수 실행 | `https://<workspace-hostname>/api/2.0/mcp/functions/{catalog}/{schema}` |
| **Vector Search** | Vector Search 인덱스 조회 | `https://<workspace-hostname>/api/2.0/mcp/vector-search/{catalog}/{schema}` |
<Note>
이러한 URL을 직접 만들 필요는 없습니다. CrewAI는 연결을 구성할 때 입력한 워크스페이스 호스트와 식별자(Genie Space ID 또는 catalog/schema)로 각 엔드포인트를 생성합니다. 전체 사양과 최신 엔드포인트 세부 정보는 [Databricks 관리형 MCP 문서](https://docs.databricks.com/aws/en/generative-ai/mcp/managed-mcp)를 참고하세요.
</Note>
## CrewAI AMP에서 Databricks 연결하기
<Frame>
<img src="/images/enterprise/databricks-configure.png" alt="CrewAI AMP에서 Databricks 관리형 MCP 서버 구성" />
</Frame>
각 Databricks 기능(**Databricks Genie**, **Databricks SQL**, **Databricks Unity Catalog Functions**, **Databricks Vector Search**)은 **Tools & Integrations** 페이지의 Databricks 그룹 아래에 별도의 MCP 서버로 표시됩니다. 필요한 것을 구성하세요:
<Steps>
<Step title="Tools & Integrations 열기">
CrewAI AMP 왼쪽 사이드바에서 **Tools & Integrations**로 이동하여 Connections 목록에서 **Databricks** 그룹을 찾습니다. 그 아래에 Genie, SQL, Unity Catalog Functions, Vector Search 서버가 나열됩니다.
</Step>
<Step title="서버 구성하기">
활성화하려는 기능 옆의 **Configure**를 클릭하고 연결 세부 정보를 입력합니다:
- **Workspace Host** — Databricks 워크스페이스 호스트명(예: `my-workspace.cloud.databricks.com`).
- **Genie** — 조회할 **Genie Space ID**.
- **Unity Catalog Functions** — 함수가 포함된 **catalog**와 **schema**.
- **Vector Search** — 인덱스가 포함된 **catalog**와 **schema**.
- **Databricks SQL** — 추가 식별자가 필요 없으며, 쿼리는 워크스페이스의 SQL 웨어하우스에서 실행됩니다.
</Step>
<Step title="인증 방법 선택하기">
CrewAI가 Databricks에 인증하는 방법을 선택합니다. **OAuth**를 권장합니다.
- **Use OAuth** — OAuth 2.0으로 안전하게 연결합니다. 각 사용자가 개별적으로 인증하며, Databricks는 해당 기능(`genie`, `sql`, `unity-catalog` 또는 `vector-search`)으로 범위가 지정된 토큰을 발급합니다. CrewAI가 인증 흐름을 처리하고 토큰을 자동으로 갱신합니다.
- **Use personal access token** — [Databricks 개인 액세스 토큰](https://docs.databricks.com/aws/en/dev-tools/auth/pat)으로 인증합니다. 노출을 제한하려면 최소 권한 ID를 사용하세요.
</Step>
<Step title="인증하기">
인증을 완료합니다. 연결되면 해당 서버의 도구를 크루에서 사용할 수 있습니다. 활성화하려는 다른 Databricks 기능에 대해서도 반복합니다.
</Step>
</Steps>
<Tip>
각 기능이 별도의 연결이므로 자유롭게 조합할 수 있습니다. 예를 들어 리서치 크루에는 Genie와 Vector Search를 활성화하고, 데이터 엔지니어링 크루에는 SQL과 Unity Catalog Functions를 사용하도록 할 수 있습니다. 가시성(Visibility) 설정으로 각 기능을 사용할 수 있는 팀원을 제어할 수 있습니다.
</Tip>
## 크루에서 Databricks 도구 사용하기
연결되면 각 MCP 서버가 노출하는 도구가 **Tools & Integrations** 페이지의 기본 제공 연결과 함께 표시됩니다. 다음을 할 수 있습니다:
- 다른 CrewAI 도구와 마찬가지로 크루의 에이전트에 **도구 할당**.
- 각 연결을 사용할 수 있는 팀원을 제어하는 **가시성 관리**.
- Connections 목록에서 언제든지 연결 **편집 또는 제거**.
이제 에이전트는 Genie에 근거 있는 답변을 요청하고, 웨어하우스에 SQL을 실행하며, Unity Catalog 함수를 호출하고, Vector Search 인덱스를 검색할 수 있으며, 그 결과가 자동으로 추론에 반영됩니다.
<Warning>
Databricks는 Unity Catalog와 Unity AI Gateway를 통해 거버넌스를 적용합니다. 사용자는 워크스페이스 ID에 허용된 도구만 검색하고 호출할 수 있습니다. 도구 호출이 실패하면 연결하는 사용자(또는 토큰 ID)가 Genie Space, 웨어하우스, 함수 또는 인덱스에 필요한 Unity Catalog 권한을 가지고 있는지 확인하세요. 일부 Genie 및 SQL 쿼리는 비동기로 실행되어 결과를 반환하는 데 시간이 걸릴 수 있습니다.
</Warning>
## 더 알아보기
<CardGroup cols={2}>
<Card title="Databricks 관리형 MCP 서버" icon="layer-group" href="https://docs.databricks.com/aws/en/generative-ai/mcp/managed-mcp">
관리형 Genie, SQL, Unity Catalog Functions, Vector Search MCP 서버에 대한 공식 Databricks 문서입니다.
</Card>
<Card title="CrewAI의 커스텀 MCP 서버" icon="plug" href="/ko/enterprise/guides/custom-mcp-server">
Databricks 연동의 기반이 되는, CrewAI가 모든 MCP 서버에 연결하는 방법을 알아보세요.
</Card>
</CardGroup>
<Card title="도움이 필요하신가요?" icon="headset" href="mailto:support@crewai.com">
Databricks 연동 구성 또는 문제 해결에 대한 지원이 필요하면 지원팀에 문의하세요.
</Card>

View File

@@ -0,0 +1,134 @@
---
title: Snowflake 연동
description: "Snowflake 관리형 MCP 서버를 통해 CrewAI 에이전트를 Snowflake Cortex Analyst, Cortex Search 및 SQL 실행에 연결합니다."
icon: "snowflake"
mode: "wide"
---
## 개요
[Snowflake 관리형 MCP 서버](https://docs.snowflake.com/en/user-guide/snowflake-cortex/cortex-agents-mcp)를 통해 CrewAI 에이전트를 Snowflake 데이터에 직접 연결하세요. Snowflake 연동을 사용하면 에이전트가 **Cortex Analyst**로 구조화된 데이터를 쿼리하고, **Cortex Search**로 비구조화된 데이터를 검색하며, 커넥터 코드를 작성하거나 호스팅할 필요 없이 웨어하우스에 대해 관리되는 SQL을 실행할 수 있습니다.
내부적으로 Snowflake 연동은 CrewAI의 [Custom MCP Server](/ko/enterprise/guides/custom-mcp-server) 지원을 기반으로 하는 관리형 래퍼입니다. Snowflake는 [Model Context Protocol](https://modelcontextprotocol.io/) 엔드포인트를 통해 Cortex AI 기능을 노출하며, CrewAI가 이를 안전하게 연결합니다. Snowflake 측에서 노출하는 모든 도구 — Cortex Analyst, Cortex Search, SQL 실행, Cortex Agents 또는 사용자 정의 도구 — 가 크루에서 사용할 수 있게 됩니다.
## 주요 기능
<CardGroup cols={3}>
<Card title="Cortex Analyst" icon="chart-bar">
자연어로 질문하고 [Cortex Analyst](https://docs.snowflake.com/en/user-guide/snowflake-cortex/cortex-analyst)가 풍부한 시맨틱 모델을 사용하여 **구조화된** 데이터에 대해 SQL을 생성하고 실행하도록 합니다.
</Card>
<Card title="Cortex Search" icon="magnifying-glass">
Snowflake의 완전 관리형 검색 서비스인 [Cortex Search](https://docs.snowflake.com/en/user-guide/snowflake-cortex/cortex-search/cortex-search-overview)를 사용하여 RAG 및 지식 워크플로우를 위한 관련 **비구조화된** 데이터를 검색합니다.
</Card>
<Card title="SQL 실행" icon="database">
구성 가능한 읽기 전용 모드, 타임아웃 및 웨어하우스 선택을 통해 Snowflake 웨어하우스에 대해 관리되는 SQL 쿼리를 직접 실행합니다.
</Card>
</CardGroup>
연동이 MCP 서버가 게시하는 도구를 노출하므로, **Cortex Agents** 및 **사용자 정의 도구**(사용자 정의 함수 및 저장 프로시저)도 CrewAI 에이전트에 노출할 수 있습니다.
## 사전 준비 사항
Snowflake 연동을 사용하기 전에 다음을 확인하십시오:
- 활성 구독이 있는 [CrewAI AMP](https://app.crewai.com) 계정
- Cortex AI 기능에 액세스할 수 있는 Snowflake 계정
- 노출하려는 도구가 구성된 [Snowflake 관리형 MCP 서버](https://docs.snowflake.com/en/user-guide/snowflake-cortex/cortex-agents-mcp)
- MCP 서버 및 기본 객체에 대한 적절한 Snowflake 권한(USAGE/SELECT)
## Snowflake MCP 서버 설정
Snowflake 관리형 MCP 서버는 Snowflake 계정 내에서 실행되며 CrewAI와 같은 외부 클라이언트에서 사용할 수 있는 도구를 정의합니다. [`CREATE MCP SERVER`](https://docs.snowflake.com/en/sql-reference/sql/create-mcp-server) 명령을 사용하여 노출하려는 Cortex Search 서비스, Cortex Analyst 시맨틱 뷰 및 SQL 도구를 나열하여 생성합니다.
```sql
CREATE MCP SERVER my_mcp_server
FROM SPECIFICATION $$
tools:
- name: "sales_analyst"
type: "CORTEX_ANALYST"
identifier: "MY_DATABASE.MY_SCHEMA.sales_semantic_view"
description: "Answer questions about sales metrics"
- name: "docs_search"
type: "CORTEX_SEARCH_SERVICE_QUERY"
identifier: "MY_DATABASE.MY_SCHEMA.support_docs_search"
description: "Search internal support documentation"
- name: "run_sql"
type: "SQL_EXECUTION"
description: "Execute read-only SQL queries"
$$;
```
<Note>
MCP 엔드포인트는 `https://<account_URL>/api/v2/databases/{database}/schemas/{schema}/mcp-servers/{name}` 형식을 따릅니다. CrewAI는 연동 구성 시 제공하는 **계정 URL**, **데이터베이스**, **스키마** 및 **MCP 서버 이름**을 사용하여 이 URL을 자동으로 구성합니다.
</Note>
Cortex Agents, 사용자 정의 도구, 응답 크기 제한 및 거버넌스 옵션을 포함한 전체 사양은 [Snowflake 관리형 MCP 서버 문서](https://docs.snowflake.com/en/user-guide/snowflake-cortex/cortex-agents-mcp)를 참조하세요.
## CrewAI AMP에서 Snowflake 연결
<Frame>
<img src="/images/enterprise/snowflake-configure.png" alt="CrewAI AMP에서 Snowflake 연동 구성" />
</Frame>
<Steps>
<Step title="도구 및 연동 열기">
CrewAI AMP 왼쪽 사이드바에서 **도구 및 연동**으로 이동하고, 애플리케이션 목록에서 **Snowflake**를 찾아 구성 패널을 엽니다.
</Step>
<Step title="연결 세부 정보 제공">
CrewAI가 Snowflake MCP 서버에 연결하는 데 사용하는 연결 필드를 채웁니다:
| 필드 | 필수 | 설명 |
|------|------|------|
| **이름** | 예 | 이 연결의 설명적 이름(기본값: `Snowflake`). |
| **설명** | 아니오 | 이 연결이 제공하는 내용에 대한 선택적 요약. |
| **계정 URL** | 예 | Snowflake 계정 URL, 예: `xy12345.us-east-1.snowflakecomputing.com`. |
| **데이터베이스** | 예 | MCP 서버가 포함된 데이터베이스(예: `MY_DATABASE`). |
| **스키마** | 예 | MCP 서버가 포함된 스키마(예: `MY_SCHEMA`). |
| **MCP 서버 이름** | 예 | Snowflake에서 생성한 MCP 서버 객체의 이름(예: `MY_MCP_SERVER`). |
</Step>
<Step title="인증 방법 선택">
CrewAI가 Snowflake에 인증하는 방법을 선택합니다. **OAuth**가 권장됩니다.
- **OAuth 사용** — 자격 증명을 공유하지 않고 토큰 기반 인증을 위해 OAuth 2.0을 사용하여 안전하게 연결합니다. CrewAI가 전체 인증 흐름을 처리하고 자동으로 토큰을 갱신합니다. 양식에 표시된 **리디렉트 URI**(`https://oauth.crewai.com/oauth/add`)를 복사하여 Snowflake [OAuth 보안 연동](https://docs.snowflake.com/en/user-guide/oauth-custom)에 인증된 리디렉트 URI로 등록하세요.
- **개인 액세스 토큰 사용** — Snowflake 계정 설정에서 생성한 [프로그래밍 방식 액세스 토큰](https://docs.snowflake.com/en/user-guide/programmatic-access-tokens)을 사용하여 인증합니다. 노출을 제한하기 위해 토큰에 최소 권한 역할을 할당하세요.
</Step>
<Step title="인증">
**인증**을 클릭합니다. OAuth의 경우 Snowflake로 리디렉션되어 액세스를 승인합니다. 인증되면 Snowflake 서버가 연결 목록에 나타나고 해당 도구를 크루에서 사용할 수 있게 됩니다.
</Step>
</Steps>
<Tip>
OAuth를 사용하면 각 사용자가 개별적으로 인증하며 쿼리는 해당 Snowflake `DEFAULT_ROLE`로 실행됩니다. 연결하는 사용자에게 기본 역할과 웨어하우스가 설정되어 있는지 확인하세요(`ALTER USER <username> SET DEFAULT_ROLE = '<role>' DEFAULT_WAREHOUSE = '<warehouse>'`). 그래야 Cortex Analyst 및 SQL 도구에 실행할 컴퓨팅이 있습니다.
</Tip>
## 크루에서 Snowflake 도구 사용
연결되면 MCP 서버가 노출하는 도구가 **도구 및 연동** 페이지에서 기본 연결과 함께 표시됩니다. 다음을 수행할 수 있습니다:
- 다른 CrewAI 도구처럼 크루의 **에이전트에 도구를 할당**합니다.
- **가시성을 관리**하여 어떤 팀원이 연결을 사용할 수 있는지 제어합니다.
- 연결 목록에서 언제든지 연결을 **편집하거나 제거**합니다.
이제 에이전트가 Cortex Analyst에 메트릭을 요청하고, 문서에 대해 Cortex Search를 실행하고, SQL을 실행할 수 있으며 — 결과가 자동으로 추론에 반영됩니다.
<Warning>
Snowflake는 MCP 서버에 거버넌스를 적용합니다: 역할 기반 액세스 제어가 사용자가 발견하고 호출할 수 있는 도구를 결정하며, 응답 크기, 도구 수(서버당 최대 50개) 및 재귀 깊이에 제한이 적용됩니다. 도구 호출이 실패하면 연결하는 사용자의 역할에 MCP 서버 및 기본 객체에 대한 필수 권한이 있는지 확인하세요.
</Warning>
## 자세히 알아보기
<CardGroup cols={2}>
<Card title="Snowflake 관리형 MCP 서버" icon="snowflake" href="https://docs.snowflake.com/en/user-guide/snowflake-cortex/cortex-agents-mcp">
MCP 서버를 생성하고 관리하기 위한 공식 Snowflake 문서.
</Card>
<Card title="CrewAI의 Custom MCP 서버" icon="plug" href="/ko/enterprise/guides/custom-mcp-server">
CrewAI가 모든 MCP 서버에 연결하는 방법을 알아보세요. Snowflake 연동이 기반으로 하는 기초입니다.
</Card>
</CardGroup>
<Card title="도움이 필요하신가요?" icon="headset" href="mailto:support@crewai.com">
Snowflake 연동 또는 문제 해결에 대해 지원팀에 문의하세요.
</Card>

View File

@@ -0,0 +1,474 @@
---
title: 대화형 Flow
description: 턴마다 kickoff, 메시지 기록, 의도 라우팅, 트레이싱, WebSocket 브리지로 멀티턴 채팅 앱을 만듭니다.
icon: comments
mode: "wide"
---
## 개요
대화형 앱은 각 사용자 입력을 **동일한 세션 id**로 **새 flow 실행**으로 처리합니다. CrewAI는 메시지 기록, 선택적 의도 분류, 지연 트레이싱, UI 브리지, 그리고 대화형 flow용 로컬 `flow.chat()` REPL을 제공합니다.
| 개념 | 구현 |
|------|------|
| 세션 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.handle_turn(message, session_id=...)`**를 사용하세요. 대화형 `Flow`를 로컬 터미널 채팅 루프로 실행하고 싶을 때는 **`flow.chat()`**을 사용하세요.
`Flow.kickoff()`는 `user_message=` 또는 `session_id=` 키워드 인자를 받지 않습니다. 대화형 flow에서는 `handle_turn()`이 보류 중인 메시지를 저장하고 내부적으로 `kickoff(inputs={"id": session_id})`를 호출합니다.
| API | 용도 |
|-----|------|
| `handle_turn(message, session_id=...)` | 대화형 `Flow`용 한 턴 편의 래퍼 |
| `chat()` | 대화형 `Flow`용 로컬 터미널 REPL |
| `kickoff(inputs={...})` | 대화형 턴 처리 없이 flow를 직접 실행 |
| `ask()` | 한 스텝 **내부** 블로킹 프롬프트 (마법사, 확인) |
| `@human_feedback` | **스텝 출력** 승인/거부 — 다음 채팅 줄이 아님 |
| `ChatSession.handle_turn(...)` | `handle_turn` 위의 전송 계층 (SSE / WebSocket) |
## 빠른 시작
```python
from uuid import uuid4
from crewai import Flow
from crewai.flow import listen
from crewai.experimental.conversational import (
ConversationConfig,
ConversationState,
)
@ConversationConfig(defer_trace_finalization=True)
class SupportFlow(Flow[ConversationState]):
conversational = True
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_assistant_message(reply)
return reply
@listen("help")
def handle_help(self):
reply = "무엇을 도와드릴까요?"
self.append_assistant_message(reply)
return reply
@listen("goodbye")
def handle_goodbye(self):
reply = "안녕히 가세요!"
self.append_assistant_message(reply)
return reply
session_id = str(uuid4())
flow = SupportFlow()
try:
flow.handle_turn("주문 어디까지 왔나요?", session_id=session_id)
flow.handle_turn("반품은 어떻게 하나요?", session_id=session_id)
finally:
flow.finalize_session_traces() # 전체 대화에 대한 단일 trace 링크
```
## 턴 생명주기
각 `handle_turn`은 다음 파이프라인을 실행합니다:
1. **`_configure_conversational_kickoff`** — `session_id` / `user_message`를 `inputs`에 병합, `ConversationalConfig` 적용, 설정 시 지연 트레이싱 활성화.
2. **상태 복원** — `inputs["id"]`가 있고 `@persist`가 설정되면 최신 스냅샷 로드.
3. **`FlowStarted`** — 지연 세션의 첫 턴에서만 발생.
4. **`prepare_conversational_turn`** — 사용자 메시지를 `state.messages`에 추가, `last_user_message` 설정, `last_intent` 초기화, `intents` / `default_intents` + `intent_llm` 설정 시 분류.
5. **그래프 실행** — `@start` → `@router` → `@listen` 핸들러.
6. **실행 종료** — 지연 활성화 시 턴별 `flow_finished` 및 trace 종료 **건너뜀**; 중첩 `Agent.kickoff()` / crew도 부모 batch를 닫지 않음.
핸들러는 **`append_assistant_message(reply)`**를 호출해 다음 턴의 `conversation_messages`에 어시스턴트 응답이 포함되게 하세요. 사용자 입력은 `handle_turn`이 이미 저장합니다 — 핸들러에서 다시 추가하지 마세요.
## `ConversationalConfig` (클래스 수준 기본값)
`Flow` 서브클래스에 `conversational_config: ClassVar[ConversationalConfig | None]`로 설정합니다.
| 필드 | 기본값 | 목적 |
|------|--------|------|
| `default_intents` | `None` | kickoff 전 자동 분류용 outcome 라벨 |
| `intent_llm` | `None` | 분류용 모델 (intent 사용 시 필수) |
| `interactive_prompt` | `"You: "` | `kickoff(interactive=True)` 프롬프트 |
| `interactive_timeout` | `None` | 대화형 모드 줄 단위 타임아웃 |
| `exit_commands` | `exit`, `quit` | 대화형 모드 종료 단어 |
| `defer_trace_finalization` | `True` | 턴 간 하나의 trace batch 유지 |
`intents=` 및 `intent_llm=` 키워드로 kickoff마다 재정의할 수 있습니다.
## `ChatState` (권장 persist 형태)
```python
from crewai.flow import ChatState
class MyChatState(ChatState):
# 상속: id, messages, last_user_message, last_intent, session_ready
research_turn_count: int = 0
custom_flag: bool = False
```
| 필드 | 역할 |
|------|------|
| `id` | 세션 UUID (`session_id` / `inputs["id"]`와 동일) |
| `messages` | LLM 기록용 `{role, content}` 리스트 |
| `last_user_message` | 이번 턴의 최신 사용자 입력 |
| `last_intent` | 분류 후 라우트 라벨 (사용 시) |
| `session_ready` | 일회성 bootstrap 플래그 |
`ConversationalInputs`는 `kickoff(inputs={...})`용 `TypedDict`: `id`, `user_message`, `last_intent`.
## `Flow` 대화 API
### `kickoff` / `kickoff_async` 파라미터
| 파라미터 | 목적 |
|----------|------|
| `user_message` | 이번 턴 텍스트 (또는 `{"role": "user", "content": "..."}`) |
| `session_id` | 대화 UUID → `inputs["id"]` / `state.id` |
| `intents` | kickoff 전 `classify_intent`용 outcome 라벨 |
| `intent_llm` | 분류 LLM (`intents`와 함께 필수) |
| `interactive` | `ask()` CLI 루프 (로컬 데모 전용) |
| `interactive_prompt` | 대화형 모드 프롬프트 |
| `interactive_timeout` | 줄 단위 `ask()` 타임아웃 |
| `exit_commands` | 대화형 모드 종료 단어 |
| `inputs` | 추가 상태 필드 |
| `restore_from_state_id` | 다른 persist flow에서 fork 복원 |
### 인스턴스 속성
| 속성 | 목적 |
|------|------|
| `conversational_config` | 클래스 수준 `ConversationalConfig` |
| `defer_trace_finalization` | 인스턴스 플래그; kickoff 시 config에서 자동 설정 |
| `suppress_flow_events` | 콘솔 flow 패널 숨김; **트레이싱은 계속 기록** |
| `stream` | 스트리밍; `ChatSession.handle_turn(..., stream=True)`와 함께 |
### 메서드 및 프로퍼티
| 이름 | 설명 |
|------|------|
| `append_message(role, content, **extra)` | `state.messages`에 추가 |
| `conversation_messages` | LLM 호출용 읽기 전용 기록 |
| `classify_intent(text, outcomes, *, llm, context=None)` | outcome 매핑 (`@human_feedback`와 동일 collapse) |
| `receive_user_message(text, *, outcomes=None, llm=None)` | 사용자 메시지 추가; 선택적 `last_intent` |
| `finalize_session_traces()` | 지연 `flow_finished` 발생 및 세션 trace batch 종료 |
| `_should_defer_trace_finalization()` | 턴별 trace 종료 지연 여부 |
| `input_history` | `ask()` 프롬프트/응답 감사 기록 |
### 모듈 헬퍼 (`crewai.flow.conversation`)
테스트 또는 커스텀 오케스트레이션용:
| 함수 | 설명 |
|------|------|
| `normalize_kickoff_inputs(...)` | 대화 kwargs를 `inputs`에 병합 |
| `get_conversation_messages(flow)` | 상태 또는 내부 버퍼에서 메시지 읽기 |
| `append_message(flow, ...)` | 인스턴스 메서드와 동일 |
| `prepare_conversational_turn(flow, ...)` | 턴 수화 (보통 kickoff가 호출) |
| `receive_user_message(flow, ...)` | 인스턴스 메서드와 동일 |
| `set_state_field(flow, name, value)` | dict 또는 Pydantic 상태 필드 설정 |
| `get_conversational_config(flow)` | 클래스 `conversational_config` 읽기 |
| `input_history_to_messages(entries)` | `input_history`를 LLM 메시지 형식으로 |
## 의도 라우팅 패턴
### A. `ConversationalConfig`로 사전 분류 (가장 단순)
`default_intents`와 `intent_llm` 설정. 각 kickoff가 `@router` 전에 분류; `route()`에서 `self.state.last_intent` 읽기.
### B. `@router` 내부에서 분류 (풍부한 프롬프트)
`default_intents=None`으로 kickoff는 메시지만 추가. `route()`에서 커스텀 프롬프트로 `classify_intent` 호출:
```python
@router(bootstrap)
def route(self):
intent = self.classify_intent(
self._routing_prompt(self.state.last_user_message),
("GREETING", "ORDER", "RESEARCH", "GOODBYE"),
llm=self.conversational_config.intent_llm or "gpt-4o-mini",
)
self.state.last_intent = intent
return intent
```
웹 리서치나 다단계 tool이 필요하면 **`@listen("RESEARCH")`** 등에서 `Agent.kickoff()`와 tool 사용 — 단순 `LLM.call()` 대신.
## flow가 끝났지만 사용자는 계속 대화할 때
`FlowFinished`는 **이번 그래프 실행**이 완료됨을 의미합니다. 같은 `session_id`로 또 다른 `kickoff`로 대화가 이어집니다. `@persist`가 `messages`, 플래그, 컨텍스트를 복원합니다.
**Persist 패턴:** 전체 `Flow` 클래스보다 **단일 종료 스텝**(예: `finalize`)에 `@persist`를 두는 것이 좋습니다. 클래스 수준 persist는 매 메서드 후 저장하며, `load_state`는 최신 행을 사용해 같은 턴의 핸들러 업데이트를 놓칠 수 있습니다.
후속 채팅 줄에 `@human_feedback`를 쓰지 마세요. 특정 스텝 출력을 사람이 승인해야 할 때만 사용하세요.
## 대화형 `Flow` (실험적)
<Warning>
**실험적 기능입니다.** 대화형 `Flow`의 API 표면(`conversational = True`,
`handle_turn`, `ConversationConfig`, `RouterConfig`, `ConversationState`,
내장 그래프와 헬퍼)은 `crewai.experimental` 하위에 있으며 정식 출시
전까지 변경될 수 있습니다. 특정 동작에 의존한다면 CrewAI 버전을 고정하고
변경 사항이 있는지 changelog를 확인하세요. 피드백과 이슈 환영합니다.
</Warning>
`Flow` 서브클래스에 `conversational = True`를 지정하면 대화형 챗 그래프가 활성화됩니다. 베이스 `Flow`가 `@start` / `@router` / `converse_turn` / `end_conversation` 그래프를 노출하고, `state.messages`를 관리하며, router LLM을 구동하고, 턴 간 trace 배치를 열린 상태로 유지합니다. 여러분은 **커스텀 라우트**만 작성하면 되고, 나머지는 프레임워크가 담당합니다.
LLM 기반 라우터와 라우트별 핸들러로 멀티턴 챗을 만들고 싶지만 라이프사이클을 직접 배선하고 싶지 않을 때 사용하세요. 완전한 제어가 필요하면 위의 `Flow[ChatState]`로 내려가세요.
### 빠른 예제
```python
from crewai import LLM, 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(), # 라우트 + 설명은 @listen 핸들러에서 자동 발견
)
class SupportFlow(Flow[ConversationState]):
conversational = True
@listen("INTERNET_SEARCH")
def handle_internet_search(self) -> str:
"""Fresh web research, current news, real-time lookups."""
...
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."""
...
self.append_assistant_message(reply)
return reply
flow = SupportFlow()
try:
flow.handle_turn("뭘 할 수 있어?") # converse(빌트인)로 라우팅
flow.handle_turn("AI 뉴스를 웹에서 찾아줘.") # INTERNET_SEARCH로 라우팅
flow.handle_turn("첫 번째 결과를 요약해줘.") # 다시 converse로 라우팅
finally:
flow.finalize_session_traces()
```
로컬 터미널 채팅에는 `chat()`을 사용하세요:
```python
def kickoff() -> None:
SupportFlow().chat()
```
`chat()`은 `handle_turn()`을 REPL로 감싸고, `exit` / `quit`에서 종료하며, 기본적으로 빈 줄을 건너뛰고, 세션이 끝날 때 `finalize_session_traces()`를 호출합니다.
### `ConversationConfig`
클래스 단위의 챗 기본값을 부착하는 클래스 데코레이터입니다.
| 필드 | 기본값 | 목적 |
|------|--------|------|
| `system_prompt` | i18n `slices.conversational_system_prompt` | 빌트인 `converse_turn`이 사용하는 system 메시지. 빈 문자열(`""`)을 전달하면 system 메시지를 끕니다. |
| `llm` | `None` | 대화용 LLM (빌트인 `converse_turn`이 사용하고 router 폴백도 됨). |
| `router` | `None` | LLM 기반 라우팅을 위한 `RouterConfig`. 없으면 항상 `converse`로 떨어집니다. |
| `answer_from_history_prompt` | 프레임워크 기본값 | 선택적인 `answer_from_history` 라우트용 system 메시지. |
| `answer_from_history_llm` | `None` | 설정되면 `answer_from_history` 단축 경로가 활성화됩니다. |
| `intent_llm` | `None` | 레거시 `intents=`/`default_intents` 사전 분류용 LLM. |
| `default_intents` | `None` | 레거시 사전 분류용 outcome 레이블. |
| `visible_agent_outputs` | `None` | `"all"` 또는 `append_agent_result()` 결과를 사용자에게 공개로 승격할 에이전트 이름 목록. |
| `defer_trace_finalization` | `True` | `handle_turn()` 호출들 사이에서 하나의 trace 배치를 열어 둡니다. |
### `RouterConfig`와 자동 생성되는 라우트 카탈로그
```python
RouterConfig(
prompt="선택적인 도메인 프레이밍 (정책, 톤, 페르소나).",
response_format=MyRoute, # 선택; 없으면 자동 생성
llm=ROUTER_LLM, # ConversationConfig.llm으로 폴백
routes=["INTERNET_SEARCH", "CREWAI_DOCS"], # 선택; 리스너에서 추론
route_descriptions={
"INTERNET_SEARCH": "이 라우트만 docstring 대신 사용할 설명.",
},
default_intent="converse", # LLM 호출 실패 또는 LLM 없음일 때 사용
fallback_intent="converse", # LLM이 잘못된 라우트를 반환할 때 사용
intent_field="intent",
)
```
router에 전달되는 프롬프트는 자동으로 만들어집니다. 각 라우트의 설명은 다음 우선순위로 결정됩니다:
1. `RouterConfig.route_descriptions[label]` — 명시적 오버라이드.
2. `Flow.builtin_route_descriptions[label]` — `converse`, `end`, `answer_from_history`용 프레임워크 캐닝 텍스트 (router LLM용으로 다듬어진 문구).
3. `@listen(label)` 핸들러 docstring의 첫 줄(비어있지 않은 줄).
4. 빈 문자열 (라우트만 카탈로그에 등장하고 설명은 없음).
실제 사용에서 **새 라우트를 추가하는 방법은 `@listen("X")` + 한 줄짜리 docstring**입니다:
```python
@listen("INTERNET_SEARCH")
def handle_internet_search(self) -> str:
"""Fresh web research, current news, real-time lookups."""
...
```
…그러면 router LLM은 다음을 봅니다:
```
Routes:
- CREWAI_DOCS: Look up the CrewAI documentation for framework/API questions.
- INTERNET_SEARCH: Fresh web research, current news, real-time lookups.
- converse: Ordinary chat, follow-ups, summaries, clarifications…
- end: User signals the conversation is finished (goodbye, exit, done).
```
`RouterConfig.prompt`는 **도메인 프레이밍** (어시스턴트 페르소나, 비즈니스 규칙, 톤)을 위한 자리입니다. 라우트 카탈로그는 자동 생성되니 `prompt` 안에 라우트 목록을 넣지 마세요. 핸들러를 추가하는 순간 동기화가 깨집니다.
### 빌트인 라우트
| 라우트 | 핸들러 | 목적 |
|--------|--------|------|
| `converse` | `converse_turn` | 기본 챗 핸들러. system prompt + 정식 메시지 히스토리와 함께 `ConversationConfig.llm`을 호출합니다. |
| `end` | `end_conversation` | `state.ended = True`로 설정하고 종료 응답을 보냅니다. |
| `answer_from_history` | `answer_from_history_turn` | 선택적. `ConversationConfig.answer_from_history_llm`이 설정되어 있고 메시지를 히스토리만으로 답할 수 있을 때 라우팅됩니다. |
서브클래스에 같은 이름의 핸들러를 정의하면 어떤 것이든 오버라이드할 수 있습니다.
### `handle_turn()` 시맨틱
`flow.handle_turn(message)`는 한 턴을 실행합니다:
1. 그래프가 다시 실행되도록 턴 단위 실행 추적(`_completed_methods`, `_method_outputs`)을 초기화합니다 — 이게 없으면 동일 인스턴스에서 반복 `kickoff` 호출 시 `Flow.kickoff_async`가 `inputs={"id": ...}`를 체크포인트 복원으로 간주해 2번째 턴부터 단락 회로가 발생합니다.
2. 사용자 메시지를 `state.messages`에 추가하고 `current_user_message` / `last_user_message`를 설정합니다. `last_intent`는 **이전 턴 값이 유지**되어 router LLM이 신호로 활용할 수 있습니다.
3. `conversation_start` → `route_conversation` → 선택된 `@listen` 핸들러 순으로 실행됩니다.
4. router는 결정을 `state.last_intent`에 저장합니다 (다음 턴의 router 컨텍스트에서 보입니다).
5. 핸들러가 문자열을 반환했지만 `append_assistant_message`를 직접 호출하지 않았다면, `handle_turn`이 대신 추가해 줍니다.
채팅 메시지에는 `handle_turn()`을 호출하세요. `kickoff(inputs={"id": ...})`를 직접 호출하면 대화형 턴 래퍼 없이 flow 그래프가 실행됩니다.
### 로컬 REPL용 `chat()`
`flow.chat()`은 `handle_turn()` 위에 얹은 바로 쓸 수 있는 터미널 래퍼입니다:
```python
flow = SupportFlow()
flow.chat()
```
일반적인 로컬 루프를 처리합니다:
1. 사용자 메시지를 입력받습니다.
2. `exit` / `quit`, `EOFError`, `KeyboardInterrupt`에서 멈춥니다.
3. `handle_turn(message, session_id=...)`를 호출합니다.
4. 어시스턴트 결과를 출력합니다.
5. `finally` 블록에서 지연된 세션 trace를 finalize합니다.
주입 가능한 I/O로 터미널 동작을 커스터마이즈할 수 있습니다:
```python
flow.chat(
session_id="demo-session",
prompt="You: ",
assistant_prefix="Assistant: ",
exit_commands=("exit", "quit", "bye"),
)
```
웹 앱, 백그라운드 worker, 테스트, 커스텀 transport에서는 계속 `handle_turn()`을 직접 사용하세요.
### 커스텀 router 동작
매 라우팅 결정마다 사이드 이펙트(이벤트 버스 셋업, 텔레메트리)를 실행하려면 `route_turn`을 오버라이드하세요:
```python
class SupportFlow(Flow[ConversationState]):
conversational = True
def route_turn(self, context: dict[str, Any]) -> str | None:
self.event_bus = MyBus(self)
return super().route_turn(context)
```
LLM router를 우회해 프로그램적으로 라우트를 선택하려면 `route_turn`에서 문자열을 반환하세요. `None`을 반환하면 `_route_with_config(...)`로 떨어집니다.
### `append_assistant_message`와 `append_agent_result`
`@listen(label)` 핸들러 안에서 두 가지 중 선택하세요:
- `self.append_assistant_message(text)` — 사용자에게 보이는 어시스턴트 턴을 `state.messages`에 추가합니다. 다음 턴의 `converse_turn`이 이 내용을 보게 됩니다.
- `self.append_agent_result(agent_name, result, visibility="private")` — 구조화된 이벤트를 `state.events`에, 스레드를 `state.agent_threads[agent_name]`에 기록합니다. public 가시성은 자동으로 `append_assistant_message`도 호출합니다. 정식 히스토리를 더럽히지 말아야 할 임시 작업에는 private을 쓰세요.
`ConversationConfig.visible_agent_outputs`로 특정 에이전트의 private 결과를 전역적으로 public으로 승격할 수 있습니다 (`"all"` 또는 이름 리스트).
## 턴 간 트레이싱
`defer_trace_finalization=True` (`ConversationalConfig` 기본값):
- 채팅 세션 전체에 **하나의 trace batch**.
- 첫 턴에만 **`flow_started`**; `finalize_session_traces()`에서 **`flow_finished`** 한 번.
- 턴별 `kickoff`는 “Trace batch finalized”를 출력하지 않음.
- **중첩 작업** (`Agent.kickoff()`, crew, Exa tool)은 **부모** batch에 추가; 내부 `AgentExecutor` flow가 세션 batch를 조기 종료하지 않음.
```python
flow.chat(session_id=session_id)
```
`flow.chat()`이 `finalize_session_traces()`를 대신 호출합니다. `handle_turn()`이나 `kickoff(...)`로 직접 루프를 소유하는 경우, 세션이 끝날 때 `finalize_session_traces()`를 호출하세요.
`suppress_flow_events=True`는 Rich 콘솔 패널만 숨깁니다. trace 및 method 이벤트는 계속 발생합니다.
### 대화형 `Flow` trace 수명 주기
실험적 [대화형 `Flow`](#대화형-flow-실험적)는 동일한 tracing 수명 주기를 따릅니다. `defer_trace_finalization` 기본값이 `True`이므로 각 `handle_turn()`이 세션 trace를 열어 둡니다. 세션 끝에서 항상 finalize하세요 — REPL/루프를 `try/finally`로 감싸고 종료 시 `flow.finalize_session_traces()`를 호출하세요. 호출하지 않으면 batch가 열린 채 남아 마지막 대화가 export되지 않을 수 있습니다.
## 스트리밍
`Flow` 클래스에 `stream = True`. `kickoff(...)`가 표준 이벤트 버스를 통해 `assistant_delta` 등 이벤트를 발생시킵니다.
## import
```python
from crewai.flow import (
ChatState,
ConversationalConfig,
ConversationalInputs,
Flow,
listen,
persist,
router,
start,
)
```
## 참고
- [Flow 상태 관리 마스터하기](/ko/guides/flows/mastering-flow-state)
- [첫 Flow 만들기](/ko/guides/flows/first-flow)
- 데모: `lib/crewai/runner_conversational_flow_simple.py`

View File

@@ -607,6 +607,7 @@ result = ContentCrew().crew().kickoff(inputs={
3. 더 복잡한 병렬 실행을 위해 `and_` 및 `or_` 함수를 탐색해 보세요.
4. flow를 외부 API, 데이터베이스 또는 사용자 인터페이스에 연결해 보세요.
5. 여러 전문화된 crew를 하나의 flow에서 결합해 보세요.
6. [대화형 Flow](/ko/guides/flows/conversational-flows)로 멀티턴 채팅 앱 구축 (`kickoff` per message, `ChatSession`, 지연 트레이싱)
<Check>
축하합니다! 정규 코드, 직접적인 LLM 호출, crew 기반 처리를 결합하여 포괄적인 가이드를 생성하는 첫 번째 CrewAI Flow를 성공적으로 구축하셨습니다. 이러한 기초적인 역량을 바탕으로 절차적 제어와 협업적 인텔리전스를 결합하여 복잡하고 다단계의 문제를 해결할 수 있는 점점 더 정교한 AI 애플리케이션을 만들 수 있습니다.

View File

@@ -22,6 +22,8 @@ State 관리는 모든 고급 AI 워크플로우의 중추입니다. CrewAI Flow
5. **애플리케이션 확장** - 적절한 데이터 조직을 통해 복잡한 워크플로를 지원할 수 있습니다.
6. **대화형 애플리케이션 활성화** - 컨텍스트 기반 AI 상호작용을 위해 대화 내역을 저장하고 접근할 수 있습니다.
멀티턴 채팅(`kickoff` per user line, `ChatState`, 의도 라우팅, 지연 트레이싱, `ChatSession`)은 [대화형 Flow](/ko/guides/flows/conversational-flows)를 참고하세요.
이러한 기능을 효과적으로 활용하는 방법을 살펴보겠습니다.
## 상태 관리 기본 사항

View File

@@ -4,6 +4,67 @@ description: "Atualizações de produto, melhorias e correções do CrewAI"
icon: "clock"
mode: "wide"
---
<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
[Ver release no GitHub](https://github.com/crewAIInc/crewAI/releases/tag/1.14.7a1)
## O Que Mudou
### Funcionalidades
- Adicionar suporte a arquivos de agentes treinados da equipe
- Adicionar provedor nativo Snowflake Cortex LLM
- Adicionar guia de integração com Databricks
- Adicionar guia de integração com Snowflake
### Correções de Bugs
- Corrigir CLI restaurando `[project.scripts]` no pacote crewai para instalação da ferramenta UV
- Resolver problemas de confiabilidade na entrada de arquivos
- Corrigir históricos de resultados de ferramentas incompletos no Snowflake Claude
- Lidar com chamadas de ferramentas em formato de string para Snowflake Claude
- Re-armar ouvintes `or_` de múltiplas fontes em ciclos controlados por roteadores
### Desempenho
- Melhorar a velocidade de importação do crewai através do carregamento preguiçoso de importações do docling
### Refatoração
- Dividir `flow.py` em DSL, definição e tempo de execução
## Contributors
@Luzk, @alex-clawd, @devin-ai-integration[bot], @greysonlalonde, @jessemiller, @lorenzejay, @vinibrsl
</Update>
<Update label="28 mai 2026">
## v1.14.6

View File

@@ -106,7 +106,7 @@ Existem diferentes locais no código do CrewAI onde você pode especificar o mod
</Tabs>
<Info>
O CrewAI oferece integrações nativas via SDK para OpenAI, Anthropic, Google (Gemini API), Azure e AWS Bedrock — sem necessidade de instalação extra além dos extras específicos do provedor (ex.: `uv add "crewai[openai]"`).
O CrewAI oferece integrações nativas via SDK para OpenAI, Anthropic, Google (Gemini API), Azure, AWS Bedrock e Snowflake Cortex — sem necessidade de instalação extra além dos extras específicos do provedor (ex.: `uv add "crewai[openai]"`).
Todos os outros provedores são alimentados pelo **LiteLLM**. Se você planeja usar algum deles, adicione-o como dependência ao seu projeto:
```bash
@@ -230,6 +230,55 @@ Nesta seção, você encontrará exemplos detalhados que ajudam a selecionar, co
```
</Accordion>
<Accordion title="Snowflake Cortex">
O CrewAI oferece integração nativa com a API REST do Snowflake Cortex pelo endpoint Chat Completions compatível com OpenAI. Isso evita fallback para LiteLLM em modelos `snowflake/...`. Atualmente, o Snowflake Cortex no CrewAI oferece suporte apenas a Chat Completions, então use o modo `api` padrão e não defina `api="responses"`.
```toml Code
# Obrigatório
SNOWFLAKE_PAT=<your-programmatic-access-token>
SNOWFLAKE_ACCOUNT_URL=https://<account-identifier>.snowflakecomputing.com
# Configuração alternativa da conta
SNOWFLAKE_ACCOUNT=<account-identifier>
```
**Uso básico:**
```python Code
from crewai import LLM
llm = LLM(
model="snowflake/openai-gpt-4.1",
temperature=0.7,
max_completion_tokens=1024,
)
```
**Modelos Claude no Cortex:**
```python Code
from crewai import LLM
llm = LLM(
model="snowflake/claude-sonnet-4-5",
max_completion_tokens=1024,
stream=True,
)
```
**Variáveis de ambiente suportadas:**
- `SNOWFLAKE_PAT`, `SNOWFLAKE_TOKEN` ou `SNOWFLAKE_JWT`: token usado como credencial Bearer
- `SNOWFLAKE_ACCOUNT_URL`: URL completa da conta Snowflake
- `SNOWFLAKE_ACCOUNT`, `SNOWFLAKE_ACCOUNT_ID` ou `SNOWFLAKE_ACCOUNT_IDENTIFIER`: identificador da conta usado para montar a URL
As requisições REST do Snowflake usam a role padrão do usuário. Garanta que essa role tenha `SNOWFLAKE.CORTEX_USER` ou `SNOWFLAKE.CORTEX_REST_API_USER`. Parâmetros de banco de dados, schema, warehouse e role explícita não são exigidos pelo endpoint Cortex REST Chat Completions.
**Recursos:**
- Seleção nativa com `model="snowflake/<model-name>"`
- Chat Completions com e sem streaming apenas; `api="responses"` não é compatível
- Rastreamento de uso de tokens
- Chamadas de função para modelos OpenAI e Claude hospedados no Snowflake
- Remoção automática de prefill final de assistant inválido para modelos Claude no Snowflake
</Accordion>
<Accordion title="Anthropic">
```toml Code
# Obrigatório

View File

@@ -163,6 +163,12 @@ Você precisa enviar seu crew para um repositório do GitHub. Caso ainda não te
![Selecionar Repositório](/images/enterprise/select-repo.png)
</Frame>
<Tip>
Se seu Crew ou Flow estiver dentro de uma subpasta de monorepo, expanda
**Advanced** e defina um diretório de trabalho antes de implantar. Consulte
[Implantações em Monorepo](/pt-BR/enterprise/guides/monorepo-deployments).
</Tip>
</Step>
<Step title="Definir as Variáveis de Ambiente">
@@ -441,4 +447,4 @@ type = "flow"
<Card title="Precisa de Ajuda?" icon="headset" href="mailto:support@crewai.com">
Entre em contato com nossa equipe de suporte para ajuda com questões de
implantação ou dúvidas sobre a plataforma AMP.
</Card>
</Card>

View File

@@ -0,0 +1,230 @@
---
title: "Implantações em Monorepo"
description: "Implante um Crew ou Flow a partir de uma subpasta em um repositório maior"
icon: "folder-tree"
mode: "wide"
---
<Note>
Use um diretório de trabalho quando seu Crew ou Flow estiver dentro de um
repositório maior. O CrewAI AMP valida, faz o build e executa a automação a
partir dessa subpasta em vez da raiz do repositório.
</Note>
## Quando Usar
Implantações em monorepo são úteis quando um repositório contém múltiplas
automações, pacotes compartilhados ou outro código de aplicação:
```text
company-ai/
|-- uv.lock
|-- packages/
| `-- shared_tools/
`-- crews/
|-- support_agent/
| |-- pyproject.toml
| `-- src/
| `-- support_agent/
| |-- main.py
| `-- crew.py
`-- research_flow/
|-- pyproject.toml
`-- src/
`-- research_flow/
`-- main.py
```
Para implantar `support_agent`, defina o diretório de trabalho como:
```text
crews/support_agent
```
O AMP ainda baixa ou recebe o repositório inteiro, mas trata a pasta
selecionada como a raiz do projeto da automação.
## O Que o Diretório de Trabalho Controla
Quando um diretório de trabalho é definido, o AMP usa essa pasta para:
- Validação do projeto, incluindo `pyproject.toml`, `src/` e o ponto de entrada do Crew ou Flow
- Instalação de dependências com `uv`
- O diretório de trabalho do processo em execução
- A variável de ambiente `CREW_ROOT_DIR`
Deixar o campo vazio mantém o comportamento existente e usa a raiz do
repositório.
## Fontes Suportadas
Você pode definir um diretório de trabalho ao criar uma implantação a partir de:
- Um repositório GitHub conectado
- Um repositório Git configurado no AMP
- Um upload de ZIP
<Info>
Configure diretórios de trabalho na interface web do AMP. O fluxo
`crewai deploy create` da CLI não solicita esse campo.
</Info>
Você também pode adicionar ou alterar o diretório de trabalho de uma implantação
existente pela página **Settings** da implantação. A alteração passa a valer no
próximo deploy.
<Warning>
Diretórios de trabalho e auto-deploy não podem ser usados juntos. Se uma
implantação tiver um diretório de trabalho, o auto-deploy fica desabilitado
para ela. Desative o auto-deploy antes de definir um diretório de trabalho.
</Warning>
## Configurar uma Nova Implantação
<Steps>
<Step title="Abra Deploy from Code">
No CrewAI AMP, crie uma nova implantação e escolha sua fonte: GitHub, Git
Repository ou upload de ZIP.
</Step>
<Step title="Selecione o repositório, branch ou arquivo ZIP">
Escolha o repositório e a branch que contêm seu monorepo, ou envie um ZIP
cuja raiz contenha os arquivos do monorepo.
</Step>
<Step title="Abra as configurações avançadas">
Expanda a seção **Advanced** no formulário de deploy.
</Step>
<Step title="Informe o diretório de trabalho">
Informe o caminho da raiz do repositório até o projeto Crew ou Flow:
```text
crews/support_agent
```
Não inclua uma barra inicial.
</Step>
<Step title="Implante">
Adicione as variáveis de ambiente necessárias e inicie a implantação.
</Step>
</Steps>
## Configurar uma Implantação Existente
<Steps>
<Step title="Abra as configurações da implantação">
Acesse sua automação no AMP e abra **Settings**.
</Step>
<Step title="Desative o auto-deploy, se necessário">
Se o auto-deploy estiver habilitado, desative-o primeiro. O campo de
diretório de trabalho fica indisponível enquanto o auto-deploy está ativo.
</Step>
<Step title="Defina o diretório de trabalho">
Em **Basic settings**, informe o caminho da subpasta, como:
```text
crews/support_agent
```
</Step>
<Step title="Reimplante">
Salve a configuração e reimplante a automação. O novo diretório de trabalho
será usado no próximo deploy.
</Step>
</Steps>
## Regras de Caminho
O diretório de trabalho deve ser um caminho relativo dentro da raiz do
repositório ou do ZIP.
| Regra | Exemplo |
|-------|---------|
| Use um caminho relativo | `crews/support_agent` |
| Não comece com `/` | `/crews/support_agent` é inválido |
| Não use segmentos de caminho `.` ou `..` | `crews/../support_agent` é inválido |
| Use apenas letras, números, hifens, underscores, pontos e barras | `crews/support agent` é inválido |
| Mantenha o caminho com 255 caracteres ou menos | Caminhos maiores são rejeitados |
O AMP remove espaços em branco no início e no fim, reduz barras repetidas e
remove barras finais. Um valor em branco usa a raiz do repositório.
## Arquivos Lock e Workspaces UV
A pasta selecionada deve conter o `pyproject.toml` e o diretório `src/` da
automação. Um arquivo `uv.lock` ou `poetry.lock` pode ficar na pasta selecionada
ou na raiz do repositório.
Isso oferece suporte aos dois layouts comuns de monorepo:
<Tabs>
<Tab title="Arquivo lock do projeto">
```text
company-ai/
`-- crews/
`-- support_agent/
|-- pyproject.toml
|-- uv.lock
`-- src/
`-- support_agent/
`-- main.py
```
</Tab>
<Tab title="Arquivo lock do workspace">
```text
company-ai/
|-- uv.lock
|-- packages/
| `-- shared_tools/
`-- crews/
`-- support_agent/
|-- pyproject.toml
`-- src/
`-- support_agent/
`-- main.py
```
</Tab>
</Tabs>
<Tip>
Se sua automação importar pacotes compartilhados de outro lugar do monorepo,
declare esses pacotes no `pyproject.toml` usando configuração de workspace,
caminho ou source do UV. O AMP executa a automação a partir da pasta
selecionada, então o código compartilhado deve ser instalado como dependência
em vez de depender da raiz do repositório no Python path.
</Tip>
## Solução de Problemas
### Diretório de Trabalho Não Encontrado
Verifique se o caminho é relativo à raiz do repositório ou do ZIP. Para uploads
de ZIP, o conteúdo do ZIP deve incluir exatamente o caminho informado como
diretório de trabalho.
### pyproject.toml Ausente
O diretório de trabalho deve apontar para a pasta do projeto Crew ou Flow, não
apenas para uma pasta pai que contém vários projetos.
### uv.lock ou poetry.lock Ausente
Faça commit de um arquivo lock na pasta do projeto selecionada ou na raiz do
repositório. Para workspaces UV, manter `uv.lock` na raiz do workspace é
suportado.
### Auto-Deploy Indisponível
O auto-deploy fica desabilitado enquanto um diretório de trabalho está definido.
Use reimplantações manuais ou acione reimplantações a partir de CI/CD com a API
do AMP.
<Card title="Deploy para AMP" icon="rocket" href="/pt-BR/enterprise/guides/deploy-to-amp">
Continue com o guia de implantação depois de escolher o diretório de trabalho
do monorepo.
</Card>

View File

@@ -0,0 +1,123 @@
---
title: Integração com Databricks
description: "Conecte agentes CrewAI ao Databricks Genie, SQL, Unity Catalog Functions e Vector Search por meio dos servidores MCP gerenciados do Databricks."
icon: "layer-group"
mode: "wide"
---
## Visão geral
Conecte seus agentes CrewAI diretamente ao seu workspace do Databricks por meio dos [servidores MCP gerenciados do Databricks](https://docs.databricks.com/aws/en/generative-ai/mcp/managed-mcp). A integração com o Databricks permite que seus agentes façam perguntas em linguagem natural com o **Genie**, executem **SQL** governado, chamem **Unity Catalog Functions** e recuperem documentos com o **Vector Search** — tudo sem escrever ou hospedar qualquer código de conector, e com as permissões do Unity Catalog aplicadas em cada chamada.
Nos bastidores, a integração com o Databricks é um wrapper gerenciado sobre o suporte a [Servidores MCP personalizados](/pt-BR/enterprise/guides/custom-mcp-server) do CrewAI. O Databricks expõe cada recurso como seu próprio endpoint do [Model Context Protocol](https://modelcontextprotocol.io/), e o CrewAI se conecta a eles com segurança em seu nome. Como cada servidor é adicionado separadamente, você pode habilitar exatamente os recursos de que suas crews precisam.
## Principais recursos
<CardGroup cols={2}>
<Card title="Genie" icon="comments">
Faça perguntas em linguagem natural e obtenha respostas fundamentadas em seus dados com o [Genie](https://docs.databricks.com/aws/en/genie/), que consulta Genie Spaces e o Unity Catalog e fornece links de volta para a interface do Databricks.
</Card>
<Card title="Databricks SQL" icon="database">
Execute SQL governado nos seus warehouses do Databricks para consultar, transformar e criar pipelines de dados diretamente a partir dos seus agentes.
</Card>
<Card title="Unity Catalog Functions" icon="function">
Invoque [funções do Unity Catalog](https://docs.databricks.com/aws/en/udf/unity-catalog) para executar SQL predefinido e lógica de negócio personalizada como ferramentas governadas e reutilizáveis.
</Card>
<Card title="Vector Search" icon="magnifying-glass">
Recupere documentos relevantes para fluxos de RAG e de conhecimento a partir de índices do [Mosaic AI Vector Search](https://docs.databricks.com/aws/en/generative-ai/vector-search) usando similaridade semântica.
</Card>
</CardGroup>
Todos os servidores são executados por trás do Unity AI Gateway e aplicam os controles de acesso do Unity Catalog, de modo que seus agentes só enxergam os dados e as ferramentas que têm permissão para usar.
## Pré-requisitos
Antes de usar a integração com o Databricks, certifique-se de ter:
- Uma conta [CrewAI AMP](https://app.crewai.com) com assinatura ativa
- Um workspace do Databricks com os recursos que você deseja expor (Genie Spaces, warehouses SQL, funções do Unity Catalog ou índices do Vector Search)
- [Privilégios apropriados do Unity Catalog](https://docs.databricks.com/aws/en/data-governance/unity-catalog) nos objetos subjacentes
- O hostname do seu workspace do Databricks (ex.: `your-workspace.cloud.databricks.com`)
## Servidores MCP gerenciados do Databricks
O Databricks publica um servidor MCP gerenciado separado para cada recurso. O CrewAI os expõe como conexões individuais, cada uma configurada com o host do seu workspace e os identificadores relevantes do Unity Catalog. Os endpoints seguem estes padrões:
| Servidor | O que faz | Padrão de URL MCP |
|----------|-----------|-------------------|
| **Genie** | Perguntas e respostas em linguagem natural sobre um Genie Space | `https://<workspace-hostname>/api/2.0/mcp/genie/{genie_space_id}` |
| **Databricks SQL** | Executa SQL nos seus warehouses | `https://<workspace-hostname>/api/2.0/mcp/sql` |
| **Unity Catalog Functions** | Executa funções UC registradas | `https://<workspace-hostname>/api/2.0/mcp/functions/{catalog}/{schema}` |
| **Vector Search** | Consulta um índice do Vector Search | `https://<workspace-hostname>/api/2.0/mcp/vector-search/{catalog}/{schema}` |
<Note>
Você não precisa construir essas URLs manualmente — o CrewAI cria cada endpoint a partir do host do workspace e dos identificadores (Genie Space ID, ou catalog/schema) que você fornece ao configurar a conexão. Para a especificação completa e os detalhes mais recentes dos endpoints, consulte a [documentação de MCP gerenciado do Databricks](https://docs.databricks.com/aws/en/generative-ai/mcp/managed-mcp).
</Note>
## Conectando o Databricks no CrewAI AMP
<Frame>
<img src="/images/enterprise/databricks-configure.png" alt="Configurar um servidor MCP gerenciado do Databricks no CrewAI AMP" />
</Frame>
Cada recurso do Databricks — **Databricks Genie**, **Databricks SQL**, **Databricks Unity Catalog Functions** e **Databricks Vector Search** — aparece como seu próprio servidor MCP no grupo Databricks da página **Tools & Integrations**. Configure os que você precisar:
<Steps>
<Step title="Abra Tools & Integrations">
Navegue até **Tools & Integrations** na barra lateral esquerda do CrewAI AMP e localize o grupo **Databricks** na lista de Connections. Você verá os servidores Genie, SQL, Unity Catalog Functions e Vector Search listados abaixo dele.
</Step>
<Step title="Configure um servidor">
Clique em **Configure** ao lado do recurso que deseja habilitar e forneça os detalhes da conexão:
- **Workspace Host** — o hostname do seu workspace do Databricks (ex.: `my-workspace.cloud.databricks.com`).
- **Genie** — o **Genie Space ID** a ser consultado.
- **Unity Catalog Functions** — o **catalog** e o **schema** que contêm suas funções.
- **Vector Search** — o **catalog** e o **schema** que contêm seu índice.
- **Databricks SQL** — sem identificadores adicionais; as consultas são executadas nos warehouses SQL do seu workspace.
</Step>
<Step title="Escolha um método de autenticação">
Selecione como o CrewAI se autentica no Databricks. **OAuth** é recomendado.
- **Use OAuth** — Conecte-se com segurança usando OAuth 2.0. Cada usuário se autentica individualmente, e o Databricks emite tokens com escopo para o recurso (`genie`, `sql`, `unity-catalog` ou `vector-search`). O CrewAI gerencia o fluxo de autorização e renova os tokens automaticamente.
- **Use personal access token** — Autentique-se com um [token de acesso pessoal do Databricks](https://docs.databricks.com/aws/en/dev-tools/auth/pat). Use uma identidade com privilégios mínimos para limitar a exposição.
</Step>
<Step title="Autentique">
Conclua a autenticação. Uma vez conectado, as ferramentas do servidor ficam disponíveis para suas crews. Repita para qualquer outro recurso do Databricks que você queira habilitar.
</Step>
</Steps>
<Tip>
Como cada recurso é uma conexão separada, você pode combiná-los livremente — por exemplo, habilitar Genie e Vector Search para uma crew de pesquisa e reservar SQL e Unity Catalog Functions para uma crew de engenharia de dados. As configurações de visibilidade permitem controlar quais membros da equipe podem usar cada um.
</Tip>
## Usando as ferramentas do Databricks nas suas crews
Uma vez conectado, as ferramentas que cada servidor MCP expõe aparecem junto às conexões integradas na página **Tools & Integrations**. Você pode:
- **Atribuir ferramentas aos agentes** nas suas crews, como qualquer outra ferramenta do CrewAI.
- **Gerenciar a visibilidade** para controlar quais membros da equipe podem usar cada conexão.
- **Editar ou remover** qualquer conexão a qualquer momento na lista de Connections.
Seus agentes agora podem pedir respostas fundamentadas ao Genie, executar SQL nos seus warehouses, chamar funções do Unity Catalog e pesquisar índices do Vector Search — com os resultados retornando automaticamente ao raciocínio deles.
<Warning>
O Databricks aplica governança por meio do Unity Catalog e do Unity AI Gateway: um usuário só pode descobrir e invocar ferramentas que a identidade do seu workspace tem permissão para usar. Se uma chamada de ferramenta falhar, confirme se o usuário (ou a identidade do token) que está conectando tem os privilégios necessários do Unity Catalog no Genie Space, warehouse, função ou índice. Algumas consultas do Genie e do SQL são executadas de forma assíncrona e podem levar um momento para retornar resultados.
</Warning>
## Saiba mais
<CardGroup cols={2}>
<Card title="Servidores MCP gerenciados do Databricks" icon="layer-group" href="https://docs.databricks.com/aws/en/generative-ai/mcp/managed-mcp">
Documentação oficial do Databricks para os servidores MCP gerenciados Genie, SQL, Unity Catalog Functions e Vector Search.
</Card>
<Card title="Servidores MCP personalizados no CrewAI" icon="plug" href="/pt-BR/enterprise/guides/custom-mcp-server">
Saiba como o CrewAI se conecta a qualquer servidor MCP, a base sobre a qual a integração com o Databricks é construída.
</Card>
</CardGroup>
<Card title="Precisa de ajuda?" icon="headset" href="mailto:support@crewai.com">
Entre em contato com nossa equipe de suporte para obter ajuda com a configuração da integração com o Databricks ou com a solução de problemas.
</Card>

View File

@@ -0,0 +1,134 @@
---
title: Integração com Snowflake
description: "Conecte agentes CrewAI ao Snowflake Cortex Analyst, Cortex Search e execução SQL através do servidor MCP gerenciado pelo Snowflake."
icon: "snowflake"
mode: "wide"
---
## Visão Geral
Conecte seus agentes CrewAI diretamente aos seus dados no Snowflake através do [servidor MCP gerenciado pelo Snowflake](https://docs.snowflake.com/en/user-guide/snowflake-cortex/cortex-agents-mcp). A integração com o Snowflake permite que seus agentes consultem dados estruturados com **Cortex Analyst**, pesquisem dados não estruturados com **Cortex Search** e executem SQL governado nos seus warehouses — tudo sem escrever ou hospedar nenhum código de conector.
Internamente, a integração com o Snowflake é um wrapper gerenciado em torno do suporte a [Custom MCP Server](/pt-BR/enterprise/guides/custom-mcp-server) do CrewAI. O Snowflake expõe suas capacidades de Cortex AI através de um endpoint [Model Context Protocol](https://modelcontextprotocol.io/), e o CrewAI se conecta a ele de forma segura em seu nome. Qualquer ferramenta que você exponha no lado do Snowflake — Cortex Analyst, Cortex Search, execução SQL, Cortex Agents ou suas próprias ferramentas personalizadas — fica disponível para suas crews.
## Capacidades Principais
<CardGroup cols={3}>
<Card title="Cortex Analyst" icon="chart-bar">
Faça perguntas em linguagem natural e deixe o [Cortex Analyst](https://docs.snowflake.com/en/user-guide/snowflake-cortex/cortex-analyst) gerar e executar SQL nos seus dados **estruturados** usando modelos semânticos ricos.
</Card>
<Card title="Cortex Search" icon="magnifying-glass">
Recupere dados **não estruturados** relevantes para fluxos de trabalho de RAG e conhecimento com o [Cortex Search](https://docs.snowflake.com/en/user-guide/snowflake-cortex/cortex-search/cortex-search-overview), o serviço de busca totalmente gerenciado do Snowflake.
</Card>
<Card title="Execução SQL" icon="database">
Execute consultas SQL governadas diretamente nos seus warehouses Snowflake, com modo somente leitura configurável, timeouts e seleção de warehouse.
</Card>
</CardGroup>
Como a integração expõe quaisquer ferramentas que seu servidor MCP publica, você também pode expor **Cortex Agents** e **ferramentas personalizadas** (funções definidas pelo usuário e stored procedures) para seus agentes CrewAI.
## Pré-requisitos
Antes de usar a integração com o Snowflake, certifique-se de que você tenha:
- Uma conta [CrewAI AMP](https://app.crewai.com) com assinatura ativa
- Uma conta Snowflake com acesso aos recursos de Cortex AI
- Um [servidor MCP gerenciado pelo Snowflake](https://docs.snowflake.com/en/user-guide/snowflake-cortex/cortex-agents-mcp) configurado com as ferramentas que você deseja expor
- Privilégios Snowflake apropriados (USAGE/SELECT) no servidor MCP e seus objetos subjacentes
## Configurando o Servidor Snowflake MCP
O servidor MCP gerenciado pelo Snowflake é executado dentro da sua conta Snowflake e define quais ferramentas estão disponíveis para clientes externos como o CrewAI. Crie um com o comando [`CREATE MCP SERVER`](https://docs.snowflake.com/en/sql-reference/sql/create-mcp-server), listando os serviços Cortex Search, visualizações semânticas do Cortex Analyst e ferramentas SQL que você deseja expor.
```sql
CREATE MCP SERVER my_mcp_server
FROM SPECIFICATION $$
tools:
- name: "sales_analyst"
type: "CORTEX_ANALYST"
identifier: "MY_DATABASE.MY_SCHEMA.sales_semantic_view"
description: "Answer questions about sales metrics"
- name: "docs_search"
type: "CORTEX_SEARCH_SERVICE_QUERY"
identifier: "MY_DATABASE.MY_SCHEMA.support_docs_search"
description: "Search internal support documentation"
- name: "run_sql"
type: "SQL_EXECUTION"
description: "Execute read-only SQL queries"
$$;
```
<Note>
O endpoint MCP segue o formato `https://<account_URL>/api/v2/databases/{database}/schemas/{schema}/mcp-servers/{name}`. O CrewAI constrói esta URL automaticamente a partir do **URL da Conta**, **Banco de Dados**, **Schema** e **Nome do Servidor MCP** que você fornece ao configurar a integração.
</Note>
Para a especificação completa — incluindo Cortex Agents, ferramentas personalizadas, limites de tamanho de resposta e opções de governança — consulte a [documentação do servidor MCP gerenciado pelo Snowflake](https://docs.snowflake.com/en/user-guide/snowflake-cortex/cortex-agents-mcp).
## Conectando o Snowflake no CrewAI AMP
<Frame>
<img src="/images/enterprise/snowflake-configure.png" alt="Configurar integração Snowflake no CrewAI AMP" />
</Frame>
<Steps>
<Step title="Abrir Ferramentas e Integrações">
Navegue até **Ferramentas e Integrações** na barra lateral esquerda do CrewAI AMP, encontre **Snowflake** na lista de aplicações e abra seu painel de configuração.
</Step>
<Step title="Fornecer detalhes da conexão">
Preencha os campos de conexão que o CrewAI usa para acessar seu servidor Snowflake MCP:
| Campo | Obrigatório | Descrição |
|-------|-------------|-----------|
| **Nome** | Sim | Um nome descritivo para esta conexão (padrão: `Snowflake`). |
| **Descrição** | Não | Um resumo opcional do que esta conexão fornece. |
| **URL da Conta** | Sim | A URL da sua conta Snowflake, ex.: `xy12345.us-east-1.snowflakecomputing.com`. |
| **Banco de Dados** | Sim | O banco de dados que contém seu servidor MCP (ex.: `MY_DATABASE`). |
| **Schema** | Sim | O schema que contém seu servidor MCP (ex.: `MY_SCHEMA`). |
| **Nome do Servidor MCP** | Sim | O nome do objeto de servidor MCP que você criou no Snowflake (ex.: `MY_MCP_SERVER`). |
</Step>
<Step title="Escolher um método de autenticação">
Selecione como o CrewAI se autentica no Snowflake. **OAuth** é recomendado.
- **Usar OAuth** — Conecte-se de forma segura usando OAuth 2.0 para autenticação baseada em tokens sem compartilhar suas credenciais. O CrewAI gerencia todo o fluxo de autorização e renova os tokens automaticamente. Copie o **URI de Redirecionamento** mostrado no formulário (`https://oauth.crewai.com/oauth/add`) e registre-o como um URI de redirecionamento autorizado na sua [integração de segurança OAuth](https://docs.snowflake.com/en/user-guide/oauth-custom) do Snowflake.
- **Usar token de acesso pessoal** — Autentique usando um [token de acesso programático](https://docs.snowflake.com/en/user-guide/programmatic-access-tokens) gerado nas configurações da sua conta Snowflake. Atribua uma role com privilégios mínimos ao token para limitar a exposição.
</Step>
<Step title="Autenticar">
Clique em **Autenticar**. Para OAuth, você será redirecionado ao Snowflake para autorizar o acesso. Após autenticado, o servidor Snowflake aparece na sua lista de Conexões e suas ferramentas ficam disponíveis para suas crews.
</Step>
</Steps>
<Tip>
Com OAuth, cada usuário se autentica individualmente e as consultas são executadas com seu `DEFAULT_ROLE` do Snowflake. Certifique-se de que os usuários que se conectam tenham uma role e warehouse padrão definidos (`ALTER USER <username> SET DEFAULT_ROLE = '<role>' DEFAULT_WAREHOUSE = '<warehouse>'`) para que as ferramentas Cortex Analyst e SQL tenham capacidade de computação para execução.
</Tip>
## Usando Ferramentas Snowflake nas Suas Crews
Uma vez conectado, as ferramentas que seu servidor MCP expõe aparecem junto com as conexões integradas na página **Ferramentas e Integrações**. Você pode:
- **Atribuir ferramentas a agentes** nas suas crews como qualquer outra ferramenta CrewAI.
- **Gerenciar visibilidade** para controlar quais membros do time podem usar a conexão.
- **Editar ou remover** a conexão a qualquer momento na lista de Conexões.
Seus agentes agora podem solicitar métricas ao Cortex Analyst, executar Cortex Search nos seus documentos e executar SQL — com os resultados fluindo automaticamente para o raciocínio deles.
<Warning>
O Snowflake impõe governança no servidor MCP: o controle de acesso baseado em roles determina quais ferramentas um usuário pode descobrir e invocar, e limites se aplicam ao tamanho da resposta, contagem de ferramentas (máximo de 50 por servidor) e profundidade de recursão. Se uma chamada de ferramenta falhar, confirme que a role do usuário conectado possui os privilégios necessários no servidor MCP e seus objetos subjacentes.
</Warning>
## Saiba Mais
<CardGroup cols={2}>
<Card title="Servidor MCP Gerenciado pelo Snowflake" icon="snowflake" href="https://docs.snowflake.com/en/user-guide/snowflake-cortex/cortex-agents-mcp">
Documentação oficial do Snowflake para criar e governar o servidor MCP.
</Card>
<Card title="Servidores Custom MCP no CrewAI" icon="plug" href="/pt-BR/enterprise/guides/custom-mcp-server">
Saiba como o CrewAI se conecta a qualquer servidor MCP, a base sobre a qual a integração Snowflake é construída.
</Card>
</CardGroup>
<Card title="Precisa de Ajuda?" icon="headset" href="mailto:support@crewai.com">
Entre em contato com nossa equipe de suporte para obter ajuda com a integração Snowflake ou solução de problemas.
</Card>

View File

@@ -0,0 +1,475 @@
---
title: Flows Conversacionais
description: Crie apps de chat multi-turno com kickoff por turno, histórico de mensagens, roteamento de intenção, tracing e pontes WebSocket.
icon: comments
mode: "wide"
---
## Visão geral
Apps conversacionais tratam cada linha do usuário como uma **nova execução do flow** com o **mesmo id de sessão**. A CrewAI oferece helpers para histórico de mensagens, classificação opcional de intenção, tracing adiado, pontes para UI e um REPL local `flow.chat()` para flows conversacionais.
| Conceito | Implementação |
|---------|----------------|
| 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.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 |
|-----|-----|
| `handle_turn(message, session_id=...)` | Wrapper ergonômico de um turno para `Flow` conversacional |
| `chat()` | REPL local no terminal para `Flow` conversacional |
| `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 `handle_turn` (SSE / WebSocket) |
## Início rápido
```python
from uuid import uuid4
from crewai import Flow
from crewai.flow import listen
from crewai.experimental.conversational import (
ConversationConfig,
ConversationState,
)
@ConversationConfig(defer_trace_finalization=True)
class SupportFlow(Flow[ConversationState]):
conversational = True
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_assistant_message(reply)
return reply
@listen("help")
def handle_help(self):
reply = "Como posso ajudar?"
self.append_assistant_message(reply)
return reply
@listen("goodbye")
def handle_goodbye(self):
reply = "Até logo!"
self.append_assistant_message(reply)
return reply
session_id = str(uuid4())
flow = SupportFlow()
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 `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.
3. **`FlowStarted`** — emitido apenas no primeiro turno da sessão adiada.
4. **`prepare_conversational_turn`** — acrescenta a mensagem do usuário em `state.messages`, define `last_user_message`, limpa `last_intent`, classifica opcionalmente quando `intents` / `default_intents` + `intent_llm` estão definidos.
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_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)
Defina na subclasse de `Flow` como `conversational_config: ClassVar[ConversationalConfig | None]`.
| Campo | Padrão | Propósito |
|-------|---------|-----------|
| `default_intents` | `None` | Rótulos de outcome para classificação automática antes do kickoff |
| `intent_llm` | `None` | Modelo para classificação (obrigatório quando há intents) |
| `interactive_prompt` | `"You: "` | Prompt para `kickoff(interactive=True)` |
| `interactive_timeout` | `None` | Timeout por linha no modo interativo |
| `exit_commands` | `exit`, `quit` | Palavras que encerram o modo interativo |
| `defer_trace_finalization` | `True` | Manter um batch de trace aberto entre turnos |
Sobrescreva por kickoff com `intents=` e `intent_llm=`.
## `ChatState` (formato persistido recomendado)
```python
from crewai.flow import ChatState
class MyChatState(ChatState):
# Herdados: id, messages, last_user_message, last_intent, session_ready
research_turn_count: int = 0
custom_flag: bool = False
```
| Campo | Função |
|-------|--------|
| `id` | UUID da sessão (igual a `session_id` / `inputs["id"]`) |
| `messages` | `list` de `{role, content}` para histórico de LLM |
| `last_user_message` | Última linha do usuário neste turno |
| `last_intent` | Rótulo de rota após classificação (se usado) |
| `session_ready` | Flag de bootstrap único (permissões, caches, etc.) |
`ConversationalInputs` é um `TypedDict` para `kickoff(inputs={...})`: `id`, `user_message`, `last_intent`.
## API conversacional em `Flow`
### Parâmetros de `kickoff` / `kickoff_async`
| Parâmetro | Propósito |
|-----------|-----------|
| `user_message` | Texto deste turno (ou `{"role": "user", "content": "..."}`) |
| `session_id` | UUID da conversa → `inputs["id"]` / `state.id` |
| `intents` | Rótulos de outcome para `classify_intent` antes do kickoff |
| `intent_llm` | LLM para classificação (obrigatório com `intents`) |
| `interactive` | Loop CLI via `ask()` (só demos locais) |
| `interactive_prompt` | Prompt no modo interativo |
| `interactive_timeout` | Timeout de `ask()` por linha |
| `exit_commands` | Palavras que encerram o modo interativo |
| `inputs` | Campos extras de estado (mesclados com chaves conversacionais) |
| `restore_from_state_id` | Hidratação fork de outro flow persistido |
### Atributos de instância
| Atributo | Propósito |
|-----------|-----------|
| `conversational_config` | Padrões `ConversationalConfig` em nível de classe |
| `defer_trace_finalization` | Flag de instância; definida automaticamente a partir do config no kickoff |
| `suppress_flow_events` | Oculta painéis Rich no console; **tracing ainda registra** eventos |
| `stream` | Habilita streaming; use com `ChatSession.handle_turn(..., stream=True)` |
### Métodos e propriedades
| Nome | Descrição |
|------|-------------|
| `append_message(role, content, **extra)` | Acrescenta em `state.messages` (roles: `user`, `assistant`, `system`, `tool`) |
| `conversation_messages` | Histórico somente leitura para chamadas LLM |
| `classify_intent(text, outcomes, *, llm, context=None)` | Mapeia texto a um outcome (mesma lógica de `@human_feedback`) |
| `receive_user_message(text, *, outcomes=None, llm=None)` | Acrescenta mensagem do usuário; opcionalmente define `last_intent` |
| `finalize_session_traces()` | Emite `flow_finished` adiado e finaliza o batch de trace da sessão |
| `_should_defer_trace_finalization()` | Se este flow adia finalização de trace por turno |
| `input_history` | Trilha de auditoria de prompts e respostas de `ask()` |
### Helpers do módulo (`crewai.flow.conversation`)
Importáveis para testes ou orquestração customizada:
| Função | Descrição |
|----------|-------------|
| `normalize_kickoff_inputs(inputs, user_message=..., session_id=...)` | Mescla kwargs conversacionais em `inputs` |
| `get_conversation_messages(flow)` | Lê mensagens do estado ou buffer interno |
| `append_message(flow, role, content, **extra)` | Igual ao método de instância |
| `prepare_conversational_turn(flow, ...)` | Hidratação do turno (geralmente chamado pelo kickoff) |
| `receive_user_message(flow, text, ...)` | Igual ao método de instância |
| `set_state_field(flow, name, value)` | Define campo em estado dict ou Pydantic |
| `get_conversational_config(flow)` | Lê `conversational_config` da classe |
| `input_history_to_messages(entries)` | Converte `input_history` para formato de mensagens LLM |
## Padrões de roteamento de intenção
### A. Pré-classificar via `ConversationalConfig` (mais simples)
Defina `default_intents` e `intent_llm`. Cada kickoff classifica antes do `@router`; leia `self.state.last_intent` em `route()`.
### B. Classificar dentro do `@router` (prompts mais ricos)
Defina `default_intents=None` para o kickoff só acrescentar a mensagem. Em `route()`, chame `classify_intent` com prompt ou descrições customizadas:
```python
@router(bootstrap)
def route(self):
intent = self.classify_intent(
self._routing_prompt(self.state.last_user_message),
("GREETING", "ORDER", "RESEARCH", "GOODBYE"),
llm=self.conversational_config.intent_llm or "gpt-4o-mini",
)
self.state.last_intent = intent
return intent
```
Use **`@listen("RESEARCH")`** (ou similar) para passos com `Agent.kickoff()` e ferramentas — não `LLM.call()` puro — quando precisar de pesquisa web ou uso multi-etapa de tools.
## Quando o flow termina mas o usuário continua conversando
`FlowFinished` significa que **esta execução do grafo** terminou. A conversa segue com outro `kickoff` e o mesmo `session_id`. `@persist` restaura `messages`, flags e contexto.
**Padrão de persistência:** prefira `@persist` em um **único passo terminal** (por exemplo `finalize`) em vez de na classe `Flow` inteira. Persist em nível de classe salva após cada método; `load_state` usa a linha mais recente, que pode ser snapshot no meio da execução e perder atualizações dos handlers no mesmo turno.
Não use `@human_feedback` para linhas de chat de follow-up, a menos que um humano precise aprovar uma saída específica antes de exibi-la.
## `Flow` conversacional (experimental)
<Warning>
**Funcionalidade experimental.** A superfície do `Flow` conversacional
(`conversational = True`, `handle_turn`, `ConversationConfig`,
`RouterConfig`, `ConversationState`, o grafo embutido + helpers) vive em
`crewai.experimental` e pode mudar de formato antes de graduar. Fixe a
versão do CrewAI se depende de comportamento específico e acompanhe o
changelog para mudanças quebradoras. Feedback / issues bem-vindos.
</Warning>
Habilite o grafo conversacional definindo `conversational = True` em uma subclasse de `Flow`. O `Flow` base passa a expor um grafo embutido `@start` / `@router` / `converse_turn` / `end_conversation`, gerencia `state.messages`, dirige o LLM de roteamento e mantém o batch de trace aberto entre os turnos. Você escreve as **rotas customizadas**; o framework cuida do resto.
Use isto quando quiser um chat multi-turno com router LLM e handlers por rota sem cablar o ciclo de vida na mão. Use `Flow[ChatState]` (o padrão de mais baixo nível acima) quando precisar de controle total.
### Exemplo rápido
```python
from crewai import LLM, 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(), # rotas + descrições auto-descobertas pelos handlers @listen
)
class SupportFlow(Flow[ConversationState]):
conversational = True
@listen("INTERNET_SEARCH")
def handle_internet_search(self) -> str:
"""Fresh web research, current news, real-time lookups."""
...
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."""
...
self.append_assistant_message(reply)
return reply
flow = SupportFlow()
try:
flow.handle_turn("O que você pode fazer?") # roteia para converse (built-in)
flow.handle_turn("Pesquise na web por notícias de IA.") # roteia para INTERNET_SEARCH
flow.handle_turn("Resuma o primeiro resultado.") # volta para converse
finally:
flow.finalize_session_traces()
```
Para um chat local no terminal, use `chat()`:
```python
def kickoff() -> None:
SupportFlow().chat()
```
`chat()` envolve `handle_turn()` em um REPL, sai com `exit` / `quit`, ignora linhas em branco por padrão e chama `finalize_session_traces()` quando a sessão termina.
### `ConversationConfig`
Decorador de classe que anexa os defaults de chat por classe.
| Campo | Padrão | Propósito |
|-------|--------|-----------|
| `system_prompt` | `slices.conversational_system_prompt` (i18n) | System message usado pelo `converse_turn` embutido. Passe `""` para desativar totalmente. |
| `llm` | `None` | LLM de conversa (usado pelo `converse_turn` e como fallback do router). |
| `router` | `None` | `RouterConfig` para roteamento por LLM. Sem ele, o flow sempre cai em `converse`. |
| `answer_from_history_prompt` | padrão do framework | System message para a rota opcional `answer_from_history`. |
| `answer_from_history_llm` | `None` | Habilita o atalho `answer_from_history` quando definido. |
| `intent_llm` | `None` | LLM para o caminho legado `intents=`/`default_intents`. |
| `default_intents` | `None` | Labels de outcome para pré-classificação legada. |
| `visible_agent_outputs` | `None` | `"all"` ou lista de nomes de agentes cujos `append_agent_result()` devem virar mensagens públicas. |
| `defer_trace_finalization` | `True` | Mantém um único batch de trace aberto entre chamadas de `handle_turn()`. |
### `RouterConfig` e o catálogo de rotas auto-gerado
```python
RouterConfig(
prompt="Enquadramento de domínio opcional (política, voz, persona).",
response_format=MyRoute, # opcional; auto-gerado caso contrário
llm=ROUTER_LLM, # usa ConversationConfig.llm como fallback
routes=["INTERNET_SEARCH", "CREWAI_DOCS"], # opcional; inferido dos listeners
route_descriptions={
"INTERNET_SEARCH": "Sobrescreve a docstring só desta rota.",
},
default_intent="converse", # usado quando a chamada ao LLM falha ou não há LLM
fallback_intent="converse", # usado quando o LLM retorna rota inválida
intent_field="intent",
)
```
O prompt do router é montado automaticamente. Para cada rota o framework escolhe a descrição nesta precedência:
1. `RouterConfig.route_descriptions[label]` — override explícito.
2. `Flow.builtin_route_descriptions[label]` — texto canônico do framework para `converse`, `end`, `answer_from_history` (otimizado para o LLM de routing).
3. Primeira linha não vazia da docstring do handler `@listen(label)`.
4. Vazio (a rota aparece no catálogo sem descrição).
Na prática, **adicionar uma rota é `@listen("X")` + uma docstring de uma linha**:
```python
@listen("INTERNET_SEARCH")
def handle_internet_search(self) -> str:
"""Fresh web research, current news, real-time lookups."""
...
```
…e o LLM de routing vê:
```
Routes:
- CREWAI_DOCS: Look up the CrewAI documentation for framework/API questions.
- INTERNET_SEARCH: Fresh web research, current news, real-time lookups.
- converse: Ordinary chat, follow-ups, summaries, clarifications…
- end: User signals the conversation is finished (goodbye, exit, done).
```
`RouterConfig.prompt` é para **enquadramento de domínio** (persona do assistente, regras de negócio, voz). O catálogo de rotas é auto-gerado — não liste rotas em `prompt`; elas vão sair de sincronia assim que você adicionar um handler.
### Rotas embutidas
| Rota | Handler | Propósito |
|------|---------|-----------|
| `converse` | `converse_turn` | Handler de chat padrão. Chama `ConversationConfig.llm` com o system prompt + histórico canônico. |
| `end` | `end_conversation` | Define `state.ended = True` e emite uma resposta de encerramento. |
| `answer_from_history` | `answer_from_history_turn` | Opcional. Cai aqui quando `ConversationConfig.answer_from_history_llm` está definido e a mensagem pode ser respondida só pelo histórico. |
Você pode sobrescrever qualquer uma definindo um handler com o mesmo nome na subclasse.
### Semântica de `handle_turn()`
`flow.handle_turn(message)` roda um turno:
1. Reseta o tracking por execução (`_completed_methods`, `_method_outputs`) para o grafo re-rodar — sem isso, chamadas repetidas de `kickoff` na mesma instância dariam curto-circuito no turno 2+ porque `Flow.kickoff_async` trata `inputs={"id": ...}` como restauração de checkpoint.
2. Anexa a mensagem do usuário em `state.messages`, define `current_user_message` / `last_user_message`. `last_intent` é **preservado do turno anterior** para que o LLM de routing possa usá-lo como sinal.
3. Roda `conversation_start` → `route_conversation` → o handler `@listen` escolhido.
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ê.
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
`flow.chat()` é o wrapper de terminal pronto para uso em cima de `handle_turn()`:
```python
flow = SupportFlow()
flow.chat()
```
Ele cobre o loop local comum:
1. Solicita uma mensagem do usuário.
2. Para com `exit` / `quit`, `EOFError` ou `KeyboardInterrupt`.
3. Chama `handle_turn(message, session_id=...)`.
4. Imprime o resultado do assistente.
5. Finaliza traces de sessão adiados em um bloco `finally`.
Customize o comportamento do terminal com I/O injetável:
```python
flow.chat(
session_id="demo-session",
prompt="You: ",
assistant_prefix="Assistant: ",
exit_commands=("exit", "quit", "bye"),
)
```
Para apps web, workers em background, testes e transportes customizados, continue usando `handle_turn()` diretamente.
### Comportamento customizado do router
Para rodar efeitos colaterais (setup de event bus, telemetria) em toda decisão de routing, sobrescreva `route_turn`:
```python
class SupportFlow(Flow[ConversationState]):
conversational = True
def route_turn(self, context: dict[str, Any]) -> str | None:
self.event_bus = MyBus(self)
return super().route_turn(context)
```
Para ignorar o router LLM e escolher uma rota programaticamente, retorne uma string de `route_turn`; retornar `None` cai no `_route_with_config(...)`.
### `append_assistant_message` e `append_agent_result`
Dentro de um handler `@listen(label)`, escolha:
- `self.append_assistant_message(text)` — adiciona um turno de assistente visível ao usuário em `state.messages`. O `converse_turn` do próximo turno vai vê-lo.
- `self.append_agent_result(agent_name, result, visibility="private")` — registra um evento estruturado em `state.events` e uma thread em `state.agent_threads[agent_name]`. Visibilidade pública também chama `append_assistant_message` automaticamente. Use resultados privados para trabalho de bastidor que não deve poluir o histórico canônico.
`ConversationConfig.visible_agent_outputs` pode promover globalmente os resultados privados de agentes específicos para públicos (`"all"` ou lista de nomes).
## Tracing entre turnos
Com `defer_trace_finalization=True` (padrão em `ConversationalConfig`):
- **Um batch de trace** para toda a sessão de chat.
- **`flow_started`** só no primeiro turno; **`flow_finished`** uma vez em `finalize_session_traces()`.
- **`kickoff` por turno** não exibe “Trace batch finalized”.
- **Trabalho aninhado** (`Agent.kickoff()`, crews, tools Exa) acrescenta ao batch **pai**; flows internos de `AgentExecutor` não fecham o batch da sessão cedo.
```python
flow.chat(session_id=session_id)
```
`flow.chat()` chama `finalize_session_traces()` para você. Quando você controla o loop com `handle_turn()` ou `kickoff(...)`, chame `finalize_session_traces()` quando a sessão terminar.
`suppress_flow_events=True` só oculta painéis do console; eventos de trace e método ainda são emitidos.
### Ciclo de vida de trace do `Flow` conversacional
O [`Flow` conversacional](#flow-conversacional-experimental) experimental usa o mesmo ciclo de vida de tracing: `defer_trace_finalization` é `True` por padrão, então cada `handle_turn()` mantém o trace da sessão aberto. Sempre finalize ao fim da sessão — envolva seu loop em `try/finally` e chame `flow.finalize_session_traces()` na saída. Sem isso, o batch fica aberto e a última conversa pode nunca ser exportada.
## Streaming
Defina `stream = True` na classe `Flow`. `kickoff(...)` então emitirá `assistant_delta` (e eventos relacionados) pelo event bus padrão.
## Imports
```python
from crewai.flow import (
ChatState,
ConversationalConfig,
ConversationalInputs,
Flow,
listen,
persist,
router,
start,
)
```
## Veja também
- [Dominando o Gerenciamento de Estado em Flows](/pt-BR/guides/flows/mastering-flow-state) — persistência, estado Pydantic, `@persist`
- [Construa Seu Primeiro Flow](/pt-BR/guides/flows/first-flow) — fundamentos de flow
- Demo: `lib/crewai/runner_conversational_flow_simple.py` — REPL mínimo com `RESEARCH` + agente Exa

View File

@@ -614,6 +614,7 @@ Agora que você construiu seu primeiro flow, pode:
3. Explorar as funções `and_` e `or_` para execuções paralelas e mais complexas
4. Conectar seu flow a APIs externas, bancos de dados ou interfaces de usuário
5. Combinar múltiplos crews especializados em um único flow
6. Criar apps de chat multi-turn com [Flows conversacionais](/pt-BR/guides/flows/conversational-flows) (`kickoff` por mensagem, `ChatSession`, tracing adiado)
<Check>
Parabéns! Você construiu seu primeiro CrewAI Flow que combina código regular, chamadas diretas a LLM e processamento baseado em crews para criar um guia abrangente. Essas habilidades fundamentais permitem criar aplicações de IA cada vez mais sofisticadas, capazes de resolver problemas complexos de múltiplas etapas por meio de controle procedural e inteligência colaborativa.

View File

@@ -22,6 +22,8 @@ Um gerenciamento de estado efetivo possibilita que você:
5. **Escalone suas aplicações** Ofereça suporte a workflows complexos com organização apropriada dos dados
6. **Habilite aplicações conversacionais** Armazene e acesse o histórico da conversa para interações de IA com contexto
Para chat multi-turn (`kickoff` por linha do usuário, `ChatState`, roteamento por intenção, tracing adiado e `ChatSession`), veja [Flows conversacionais](/pt-BR/guides/flows/conversational-flows).
Vamos explorar como aproveitar essas capacidades de forma eficiente.
## Fundamentos do Gerenciamento de Estado

View File

@@ -8,14 +8,14 @@ authors = [
]
requires-python = ">=3.10, <3.14"
dependencies = [
"crewai-core==1.14.6",
"click~=8.1.7",
"crewai-core==1.14.7a2",
"click>=8.1.7,<9",
"pydantic>=2.11.9,<2.13",
"pydantic-settings~=2.10.1",
"appdirs~=1.4.4",
"cryptography>=42.0",
"httpx~=0.28.1",
"pyjwt>=2.9.0,<3",
"pyjwt>=2.13.0,<3",
"rich>=13.7.1",
"tomli~=2.0.2",
"tomli-w~=1.1.0",

View File

@@ -1 +1 @@
__version__ = "1.14.6"
__version__ = "1.14.7a2"

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.5a2"
"crewai[tools]==1.14.7a2"
]
[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.5a2"
"crewai[tools]==1.14.7a2"
]
[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.5a2"
"crewai[tools]==1.14.7a2"
]
[tool.crewai]

View File

@@ -0,0 +1,131 @@
"""Tests for click dependency compatibility.
Regression tests for https://github.com/crewAIInc/crewAI/issues/6002
The click dependency was previously pinned to ~=8.1.7 (i.e. >=8.1.7,<8.2.0)
which prevented users from upgrading to click 8.2+ as required by their
security policies. The constraint has been widened to >=8.1.7,<9 to allow
newer click 8.x releases while still guarding against a future major version
break.
"""
from importlib.metadata import requires
from pathlib import Path
import click
import pytest
from click.testing import CliRunner
from packaging.requirements import Requirement
# ---------------------------------------------------------------------------
# Verify the runtime click version satisfies the declared constraint
# ---------------------------------------------------------------------------
def _get_click_requirement_from_pyproject(package_dir: str) -> Requirement:
"""Parse the click requirement directly from a pyproject.toml file."""
import tomli
pyproject_path = Path(__file__).resolve().parents[3] / package_dir / "pyproject.toml"
with open(pyproject_path, "rb") as f:
data = tomli.load(f)
deps = data["project"]["dependencies"]
for dep in deps:
req = Requirement(dep)
if req.name == "click":
return req
raise ValueError(f"click not found in {pyproject_path}")
@pytest.mark.parametrize(
"package_dir",
[
"lib/crewai",
"lib/cli",
"lib/devtools",
],
)
def test_click_constraint_allows_8_3_3(package_dir: str):
"""The declared click constraint must accept click 8.3.3 (issue #6002)."""
req = _get_click_requirement_from_pyproject(package_dir)
# packaging's Requirement.specifier supports `__contains__` for version checks
assert "8.3.3" in req.specifier, (
f"{package_dir}: click constraint {req.specifier} does not allow 8.3.3"
)
@pytest.mark.parametrize(
"package_dir",
[
"lib/crewai",
"lib/cli",
"lib/devtools",
],
)
def test_click_constraint_allows_8_1_7(package_dir: str):
"""The declared click constraint must still accept the original minimum (8.1.7)."""
req = _get_click_requirement_from_pyproject(package_dir)
assert "8.1.7" in req.specifier, (
f"{package_dir}: click constraint {req.specifier} does not allow 8.1.7"
)
@pytest.mark.parametrize(
"package_dir",
[
"lib/crewai",
"lib/cli",
"lib/devtools",
],
)
def test_click_constraint_rejects_next_major(package_dir: str):
"""The declared click constraint must reject click 9.0.0."""
req = _get_click_requirement_from_pyproject(package_dir)
assert "9.0.0" not in req.specifier, (
f"{package_dir}: click constraint {req.specifier} should not allow 9.0.0"
)
# ---------------------------------------------------------------------------
# Verify the installed click version works with the CLI
# ---------------------------------------------------------------------------
def test_click_version_is_compatible():
"""The installed click version must be within the 8.x range."""
major = int(click.__version__.split(".")[0])
assert major == 8, f"Expected click 8.x, got {click.__version__}"
def test_cli_runner_works_with_installed_click():
"""Smoke-test: CliRunner from the installed click can invoke a trivial command."""
@click.command()
@click.option("--name", default="world")
def hello(name: str) -> None:
click.echo(f"Hello {name}!")
runner = CliRunner()
result = runner.invoke(hello, ["--name", "crewai"])
assert result.exit_code == 0
assert "Hello crewai!" in result.output
def test_cli_group_works_with_installed_click():
"""Smoke-test: click.group, click.option, click.argument all work."""
@click.group()
def grp() -> None:
pass
@grp.command()
@click.argument("task")
@click.option("--verbose", is_flag=True)
def run(task: str, verbose: bool) -> None:
if verbose:
click.echo(f"Running {task} (verbose)")
else:
click.echo(f"Running {task}")
runner = CliRunner()
result = runner.invoke(grp, ["run", "test-task", "--verbose"])
assert result.exit_code == 0
assert "Running test-task (verbose)" in result.output

View File

@@ -13,7 +13,7 @@ dependencies = [
"httpx~=0.28.1",
"packaging>=23.0",
"portalocker~=2.7.0",
"pyjwt>=2.9.0,<3",
"pyjwt>=2.13.0,<3",
"pydantic>=2.11.9,<2.13",
"rich>=13.7.1",
"opentelemetry-api~=1.34.0",

View File

@@ -1 +1 @@
__version__ = "1.14.6"
__version__ = "1.14.7a2"

View File

@@ -1,14 +1,18 @@
"""Centralised lock factory.
If ``REDIS_URL`` is set and the ``redis`` package is installed, locks are
distributed via ``portalocker.RedisLock``. Otherwise, falls back to the
standard file-based ``portalocker.Lock`` in the system temp dir.
By default, if ``REDIS_URL`` is set and the ``redis`` package is installed,
locks are distributed via ``portalocker.RedisLock``. Otherwise, falls back to
the standard file-based ``portalocker.Lock`` in the system temp dir.
The backend can be replaced via :func:`set_lock_backend` to plug in a custom
locking strategy (e.g. a different distributed lock service, or an in-process
lock for tests).
"""
from __future__ import annotations
from collections.abc import Iterator
from contextlib import contextmanager
from collections.abc import Callable, Iterator
from contextlib import AbstractContextManager, contextmanager
from functools import lru_cache
from hashlib import md5
import logging
@@ -30,6 +34,25 @@ _REDIS_URL: str | None = os.environ.get("REDIS_URL")
_DEFAULT_TIMEOUT: Final[int] = 120
# A backend is called as ``backend(name, timeout=...)`` and returns a context
# manager that holds the lock while the ``with`` block runs.
LockBackend = Callable[..., AbstractContextManager[None]]
# ``None`` means use the built-in Redis/file selection.
_backend: LockBackend | None = None
def set_lock_backend(backend: LockBackend | None) -> None:
"""Replace the process-wide locking backend used by :func:`lock`.
Intended for one-time setup at startup. Pass ``None`` to restore the
built-in Redis/file default. In-flight :func:`lock` calls keep the backend
they started with, but swapping backends while other threads acquire locks
is otherwise unsynchronised.
"""
global _backend
_backend = backend
def _redis_available() -> bool:
"""Return True if redis is installed and REDIS_URL is set."""
@@ -58,10 +81,19 @@ def lock(name: str, *, timeout: float = _DEFAULT_TIMEOUT) -> Iterator[None]:
"""Acquire a named lock, yielding while it is held.
Args:
name: A human-readable lock name (e.g. ``"chromadb_init"``).
Automatically namespaced to avoid collisions.
name: A human-readable lock name (e.g. ``"chromadb_init"``). The
built-in default namespaces it to avoid collisions; a custom
backend receives it verbatim.
timeout: Maximum seconds to wait for the lock before raising.
"""
# Snapshot the global once: a concurrent set_lock_backend() must not turn
# the check-then-call into calling ``None``.
backend = _backend
if backend is not None:
with backend(name, timeout=timeout):
yield
return
channel = f"crewai:{md5(name.encode(), usedforsecurity=False).hexdigest()}"
if _redis_available():

View File

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

View File

@@ -11,7 +11,10 @@ from crewai_files.formatting.anthropic import AnthropicFormatter
from crewai_files.formatting.bedrock import BedrockFormatter
from crewai_files.formatting.gemini import GeminiFormatter
from crewai_files.formatting.openai import OpenAIFormatter, OpenAIResponsesFormatter
from crewai_files.processing.constraints import get_constraints_for_provider
from crewai_files.processing.constraints import (
get_constraints_for_provider,
uses_openai_responses_api,
)
from crewai_files.processing.processor import FileProcessor
from crewai_files.resolution.resolver import FileResolver, FileResolverConfig
from crewai_files.uploaders.factory import ProviderType
@@ -120,9 +123,11 @@ def format_multimodal_content(
if not files:
return content_blocks
constraints_key: str = provider_type
if api == "responses" and "openai" in provider_type.lower():
constraints_key = "openai_responses"
constraints_key = (
"openai_responses"
if uses_openai_responses_api(provider_type, api)
else provider_type
)
processor = FileProcessor(constraints=constraints_key)
processed_files = processor.process_files(files)
@@ -184,9 +189,11 @@ async def aformat_multimodal_content(
if not files:
return content_blocks
constraints_key: str = provider_type
if api == "responses" and "openai" in provider_type.lower():
constraints_key = "openai_responses"
constraints_key = (
"openai_responses"
if uses_openai_responses_api(provider_type, api)
else provider_type
)
processor = FileProcessor(constraints=constraints_key)
processed_files = await processor.aprocess_files(files)

View File

@@ -346,6 +346,20 @@ def get_constraints_for_provider(
return None
def uses_openai_responses_api(provider: str, api: str | None = None) -> bool:
"""Return whether provider/API should use OpenAI Responses file support."""
if api != "responses":
return False
provider_lower = provider.lower()
return (
"openai" in provider_lower
or provider_lower == "gpt"
or provider_lower.startswith("gpt-")
or "/gpt-" in provider_lower
)
def get_supported_content_types(provider: str, api: str | None = None) -> list[str]:
"""Get supported MIME type prefixes for a provider.
@@ -356,9 +370,9 @@ def get_supported_content_types(provider: str, api: str | None = None) -> list[s
Returns:
List of supported MIME type prefixes (e.g., ["image/", "application/pdf"]).
"""
lookup_key = provider
if api == "responses" and "openai" in provider.lower():
lookup_key = "openai_responses"
lookup_key = (
"openai_responses" if uses_openai_responses_api(provider, api) else provider
)
constraints = get_constraints_for_provider(lookup_key)
if not constraints:

View File

@@ -11,6 +11,7 @@ from crewai_files.processing.constraints import (
ProviderConstraints,
VideoConstraints,
get_constraints_for_provider,
get_supported_content_types,
)
import pytest
@@ -70,6 +71,13 @@ class TestPDFConstraints:
assert constraints.max_size_bytes == 1000
assert constraints.max_pages is None
@pytest.mark.parametrize("provider", ["openai", "gpt", "gpt-4o-mini"])
def test_openai_responses_supports_pdf_for_gpt_aliases(self, provider):
"""OpenAI Responses PDF support applies to concrete GPT model names."""
supported_types = get_supported_content_types(provider, api="responses")
assert "application/pdf" in supported_types
class TestAudioConstraints:
"""Tests for AudioConstraints dataclass."""

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.6",
"crewai==1.14.7a2",
"tiktoken>=0.8.0,<0.13",
"beautifulsoup4~=4.13.4",
"python-docx~=1.2.0",

View File

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

View File

@@ -8,8 +8,8 @@ authors = [
]
requires-python = ">=3.10, <3.14"
dependencies = [
"crewai-core==1.14.6",
"crewai-cli==1.14.6",
"crewai-core==1.14.7a2",
"crewai-cli==1.14.7a2",
# Core Dependencies
"pydantic>=2.11.9,<2.13",
"openai>=2.30.0,<3",
@@ -27,9 +27,9 @@ dependencies = [
"openpyxl~=3.1.5",
# Authentication and Security
"python-dotenv>=1.2.2,<2",
"pyjwt>=2.9.0,<3",
"pyjwt>=2.13.0,<3",
# Configuration and Utils
"click~=8.1.7",
"click>=8.1.7,<9",
"appdirs~=1.4.4",
"jsonref~=1.1.0",
"json-repair~=0.25.2",
@@ -54,7 +54,7 @@ Repository = "https://github.com/crewAIInc/crewAI"
[project.optional-dependencies]
tools = [
"crewai-tools==1.14.6",
"crewai-tools==1.14.7a2",
]
embeddings = [
"tiktoken>=0.8.0,<0.13"
@@ -138,6 +138,9 @@ torchvision = [
crewai-files = { workspace = true }
[project.scripts]
crewai = "crewai_cli.cli:crewai"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

View File

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

View File

@@ -1219,9 +1219,17 @@ class Agent(BaseAgent):
def _use_trained_data(self, task_prompt: str) -> str:
"""Use trained data for the agent task prompt to improve output."""
trained_file = os.getenv(
CREWAI_TRAINED_AGENTS_FILE_ENV, TRAINED_AGENTS_DATA_FILE
crew_trained_agents_file = (
getattr(self.crew, "trained_agents_file", None)
if self.crew and not isinstance(self.crew, str)
else None
)
trained_file = (
os.fspath(crew_trained_agents_file)
if crew_trained_agents_file
else os.getenv(CREWAI_TRAINED_AGENTS_FILE_ENV, TRAINED_AGENTS_DATA_FILE)
)
if data := CrewTrainingHandler(trained_file).load():
if trained_data_output := data.get(self.role):
task_prompt += (

View File

@@ -179,6 +179,7 @@ class Crew(FlowTrackable, BaseModel):
max_rpm: Maximum number of requests per minute for the crew execution to
be respected.
prompt_file: Path to the prompt json file to be used for the crew.
trained_agents_file: Path to trained agent suggestions loaded during inference.
id: A unique identifier for the crew instance.
task_callback: Callback to be executed after each task for every agents
execution.
@@ -303,6 +304,13 @@ class Crew(FlowTrackable, BaseModel):
default=None,
description="Path to the prompt json file to be used for the crew.",
)
trained_agents_file: str | Path | None = Field(
default=None,
description=(
"Path to a trained-agents pickle produced by train(). "
"When set, agents load suggestions from this file during inference."
),
)
output_log_file: bool | str | None = Field(
default=None,
description="Path to the log file to be saved",

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

@@ -306,20 +306,24 @@ class EventListener(BaseEventListener):
self._telemetry.flow_execution_span(
event.flow_name, list(source._methods.keys())
)
self.formatter.handle_flow_created(event.flow_name, str(source.flow_id))
self.formatter.handle_flow_started(event.flow_name, str(source.flow_id))
if not getattr(source, "suppress_flow_events", False):
self.formatter.handle_flow_created(event.flow_name, str(source.flow_id))
self.formatter.handle_flow_started(event.flow_name, str(source.flow_id))
@crewai_event_bus.on(FlowFinishedEvent)
def on_flow_finished(source: Any, event: FlowFinishedEvent) -> None:
self.formatter.handle_flow_status(
event.flow_name,
source.flow_id,
)
if not getattr(source, "suppress_flow_events", False):
self.formatter.handle_flow_status(
event.flow_name,
source.flow_id,
)
@crewai_event_bus.on(MethodExecutionStartedEvent)
def on_method_execution_started(
_: Any, event: MethodExecutionStartedEvent
source: Any, event: MethodExecutionStartedEvent
) -> None:
if getattr(source, "suppress_flow_events", False):
return
self.formatter.handle_method_status(
event.method_name,
"running",
@@ -327,8 +331,10 @@ class EventListener(BaseEventListener):
@crewai_event_bus.on(MethodExecutionFinishedEvent)
def on_method_execution_finished(
_: Any, event: MethodExecutionFinishedEvent
source: Any, event: MethodExecutionFinishedEvent
) -> None:
if getattr(source, "suppress_flow_events", False):
return
self.formatter.handle_method_status(
event.method_name,
"completed",

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

@@ -222,6 +222,8 @@ To enable tracing later, do any one of these:
return
self.batch_manager.batch_owner_type = None
self.batch_manager.batch_owner_id = None
self.batch_manager.defer_session_finalization = False
self.batch_manager._batch_finalized = False
self.batch_manager.current_batch = None
self.batch_manager.event_buffer.clear()
self.batch_manager.trace_batch_id = None

View File

@@ -62,6 +62,7 @@ class TraceBatchManager:
self._pending_events_lock = Lock()
self._pending_events_cv = Condition(self._pending_events_lock)
self._pending_events_count = 0
self._finalize_lock = Lock()
self.is_current_batch_ephemeral = False
self.trace_batch_id: str | None = None
@@ -70,6 +71,8 @@ class TraceBatchManager:
self.execution_start_times: dict[str, datetime] = {}
self.batch_owner_type: str | None = None
self.batch_owner_id: str | None = None
self.defer_session_finalization: bool = False
self._batch_finalized: bool = False
self.backend_initialized: bool = False
self.ephemeral_trace_url: str | None = None
try:
@@ -101,6 +104,7 @@ class TraceBatchManager:
user_context=user_context, execution_metadata=execution_metadata
)
self.is_current_batch_ephemeral = use_ephemeral
self._batch_finalized = False
self.record_start_time("execution")
@@ -312,6 +316,9 @@ class TraceBatchManager:
def finalize_batch(self) -> TraceBatch | None:
"""Finalize batch and return it for sending"""
if self._batch_finalized:
return None
if not self.current_batch or not is_tracing_enabled_in_context():
return None
@@ -340,16 +347,15 @@ class TraceBatchManager:
self.current_batch.events = sorted_events
events_sent_count = len(sorted_events)
if sorted_events:
original_buffer = self.event_buffer
self.event_buffer = sorted_events
events_sent_to_backend_status = self._send_events_to_backend()
self.event_buffer = original_buffer
if events_sent_to_backend_status == 500 and self.trace_batch_id:
self._mark_batch_as_failed(
self.trace_batch_id, "Error sending events to backend"
)
return None
self._finalize_backend_batch(events_sent_count)
if not self._finalize_backend_batch(events_sent_count):
return None
finalized_batch = self.current_batch
@@ -360,80 +366,87 @@ class TraceBatchManager:
self.event_buffer.clear()
self.trace_batch_id = None
self.is_current_batch_ephemeral = False
self._batch_finalized = True
self._cleanup_batch_data()
return finalized_batch
def _finalize_backend_batch(self, events_count: int = 0) -> None:
def _finalize_backend_batch(self, events_count: int = 0) -> bool:
"""Send batch finalization to backend
Args:
events_count: Number of events that were successfully sent
"""
if not self.plus_api or not self.trace_batch_id:
return
with self._finalize_lock:
batch_id = self.trace_batch_id
is_ephemeral = self.is_current_batch_ephemeral
if self._batch_finalized or not self.plus_api or not batch_id:
return True
try:
payload: TraceFinalizePayload = {
"status": "completed",
"duration_ms": self.calculate_duration("execution"),
"final_event_count": events_count,
}
try:
payload: TraceFinalizePayload = {
"status": "completed",
"duration_ms": self.calculate_duration("execution"),
"final_event_count": events_count,
}
response = (
self.plus_api.finalize_ephemeral_trace_batch(
self.trace_batch_id, payload
)
if self.is_current_batch_ephemeral
else self.plus_api.finalize_trace_batch(self.trace_batch_id, payload)
)
if response.status_code == 200:
access_code = response.json().get("access_code", None)
console = Console()
settings = Settings()
base_url = settings.enterprise_base_url or DEFAULT_CREWAI_ENTERPRISE_URL
return_link = (
f"{base_url}/crewai_plus/trace_batches/{self.trace_batch_id}"
if not self.is_current_batch_ephemeral and access_code is None
else f"{base_url}/crewai_plus/ephemeral_trace_batches/{self.trace_batch_id}?access_code={access_code}"
response = (
self.plus_api.finalize_ephemeral_trace_batch(batch_id, payload)
if is_ephemeral
else self.plus_api.finalize_trace_batch(batch_id, payload)
)
if self.is_current_batch_ephemeral:
self.ephemeral_trace_url = return_link
if response.status_code == 200:
self._batch_finalized = True
access_code = response.json().get("access_code", None)
console = Console()
settings = Settings()
base_url = (
settings.enterprise_base_url or DEFAULT_CREWAI_ENTERPRISE_URL
)
return_link = (
f"{base_url}/crewai_plus/trace_batches/{batch_id}"
if not is_ephemeral and access_code is None
else f"{base_url}/crewai_plus/ephemeral_trace_batches/{batch_id}?access_code={access_code}"
)
message_parts = [
f"✅ Trace batch finalized with session ID: {self.trace_batch_id}",
"",
f"🔗 View here: {return_link}",
]
if is_ephemeral:
self.ephemeral_trace_url = return_link
if access_code:
message_parts.append(f"🔑 Access Code: {access_code}")
message_parts = [
f"✅ Trace batch finalized with session ID: {batch_id}",
"",
f"🔗 View here: {return_link}",
]
panel = Panel(
"\n".join(message_parts),
title="Trace Batch Finalization",
border_style="green",
)
if not should_auto_collect_first_time_traces():
console.print(panel)
if access_code:
message_parts.append(f"🔑 Access Code: {access_code}")
panel = Panel(
"\n".join(message_parts),
title="Trace Batch Finalization",
border_style="green",
)
if not should_auto_collect_first_time_traces():
console.print(panel)
return True
else:
logger.error(
f"❌ Failed to finalize trace batch: {response.status_code} - {response.text}"
)
self._mark_batch_as_failed(self.trace_batch_id, response.text)
self._mark_batch_as_failed(batch_id, response.text)
return False
except Exception as e:
logger.error(f"❌ Error finalizing trace batch: {e}")
try:
self._mark_batch_as_failed(self.trace_batch_id, str(e))
except Exception:
logger.debug(
"Could not mark trace batch as failed (network unavailable)"
)
except Exception as e:
logger.error(f"❌ Error finalizing trace batch: {e}")
try:
self._mark_batch_as_failed(batch_id, str(e))
except Exception:
logger.debug(
"Could not mark trace batch as failed (network unavailable)"
)
return False
def _cleanup_batch_data(self) -> None:
"""Clean up batch data after successful finalization to free memory"""

View File

@@ -1,5 +1,6 @@
"""Trace collection listener for orchestrating trace collection."""
from datetime import datetime, timezone
import os
from typing import Any, ClassVar
import uuid
@@ -61,6 +62,8 @@ from crewai.events.types.crew_events import (
CrewKickoffStartedEvent,
)
from crewai.events.types.flow_events import (
ConversationMessageAddedEvent,
ConversationRouteSelectedEvent,
FlowCreatedEvent,
FlowFinishedEvent,
FlowPlotEvent,
@@ -230,11 +233,14 @@ class TraceCollectionListener(BaseEventListener):
@event_bus.on(FlowStartedEvent)
def on_flow_started(source: Any, event: FlowStartedEvent) -> None:
# Always call _initialize_flow_batch to claim ownership.
# If batch was already initialized by a concurrent action event
# (race condition), initialize_batch() returns early but
# batch_owner_type is still correctly set to "flow".
self._initialize_flow_batch(source, event)
# Only the first execution to open the session batch owns it. A flow
# that starts while a batch already exists is nested -- inside a crew
# (e.g. an agent's Flow-based executor), a conversational Flow, or a
# parent flow -- and must NOT re-claim ownership. Re-claiming would
# mark batch_owner_type="flow" and cause the nested flow to finalize
# the parent's batch prematurely when it completes.
if not self.batch_manager.is_batch_initialized():
self._initialize_flow_batch(source, event)
self._handle_trace_event("flow_started", source, event)
@event_bus.on(MethodExecutionStartedEvent)
@@ -251,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)
@@ -264,18 +282,20 @@ class TraceCollectionListener(BaseEventListener):
@event_bus.on(CrewKickoffStartedEvent)
def on_crew_started(source: Any, event: CrewKickoffStartedEvent) -> None:
if self.batch_manager.batch_owner_type != "flow":
# Always call _initialize_crew_batch to claim ownership.
# If batch was already initialized by a concurrent action event
# (e.g. LLM/tool before crew_kickoff_started), initialize_batch()
# returns early but batch_owner_type is still correctly set to "crew".
# Skip only when a parent flow already owns the batch.
# Nested crew inside Flow.kickoff: never claim an existing flow session batch.
if not self._nested_in_flow_execution() and (
not self.batch_manager.is_batch_initialized()
):
self._initialize_crew_batch(source, event)
self._handle_trace_event("crew_kickoff_started", source, event)
@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:
return
if self._nested_in_flow_execution():
return
if self.batch_manager.batch_owner_type == "crew":
if self.first_time_handler.is_first_time:
self.first_time_handler.mark_events_collected()
@@ -286,10 +306,14 @@ 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:
return
if self._nested_in_flow_execution():
return
if self.first_time_handler.is_first_time:
self.first_time_handler.mark_events_collected()
self.first_time_handler.handle_execution_completion()
else:
elif self.batch_manager.batch_owner_type == "crew":
self.batch_manager.finalize_batch()
@event_bus.on(TaskStartedEvent)
@@ -707,8 +731,32 @@ class TraceCollectionListener(BaseEventListener):
@on_signal
def handle_signal(source: Any, event: SignalEvent) -> None:
"""Flush trace batch on system signals to prevent data loss."""
if self.batch_manager.is_batch_initialized():
self.batch_manager.finalize_batch()
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:
return
self.batch_manager.finalize_batch()
@staticmethod
def _is_inside_active_flow_context() -> bool:
"""True when ``kickoff_async`` has set ``current_flow_id`` (nested crew)."""
from crewai.flow.flow_context import current_flow_id
return current_flow_id.get() is not None
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":
return True
batch = self.batch_manager.current_batch
if batch is not None:
return batch.execution_metadata.get("execution_type") == "flow"
return False
def _nested_in_flow_execution(self) -> bool:
"""True when a crew runs inside a flow session (context or batch ownership)."""
return self._is_inside_active_flow_context() or self._flow_owns_trace_batch()
def _initialize_crew_batch(self, source: Any, event: BaseEvent) -> None:
"""Initialize trace batch.
@@ -729,6 +777,33 @@ class TraceCollectionListener(BaseEventListener):
self._initialize_batch(user_context, execution_metadata)
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.
"""
from crewai.flow.flow_context import current_flow_id, current_flow_name
flow_id = current_flow_id.get()
if flow_id is None:
return False
started_at = getattr(event, "timestamp", None) or datetime.now(timezone.utc)
user_context = self._get_user_context()
execution_metadata = {
"flow_name": current_flow_name.get() or "Unknown Flow",
"execution_start": started_at,
"crewai_version": get_crewai_version(),
"execution_type": "flow",
}
self.batch_manager.batch_owner_type = "flow"
self.batch_manager.batch_owner_id = flow_id
self._initialize_batch(user_context, execution_metadata)
return True
def _initialize_flow_batch(self, source: Any, event: BaseEvent) -> None:
"""Initialize trace batch for Flow execution.
@@ -793,12 +868,19 @@ class TraceCollectionListener(BaseEventListener):
event: Event object.
"""
if not self.batch_manager.is_batch_initialized():
user_context = self._get_user_context()
execution_metadata = {
"crew_name": getattr(source, "name", "Unknown Crew"),
"crewai_version": get_crewai_version(),
}
self._initialize_batch(user_context, execution_metadata)
if self._try_initialize_flow_batch_from_context(event):
pass
elif not self._nested_in_flow_execution():
user_context = self._get_user_context()
execution_metadata = {
"crew_name": getattr(source, "name", "Unknown Crew"),
"crewai_version": get_crewai_version(),
}
self.batch_manager.batch_owner_type = "crew"
self.batch_manager.batch_owner_id = getattr(
source, "id", str(uuid.uuid4())
)
self._initialize_batch(user_context, execution_metadata)
self.batch_manager.begin_event_processing()
try:

View File

@@ -166,6 +166,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

@@ -1,31 +1,32 @@
from crewai.experimental.agent_executor import AgentExecutor, CrewAgentExecutorFlow
from crewai.experimental.evaluation import (
AgentEvaluationResult,
AgentEvaluator,
BaseEvaluator,
EvaluationScore,
EvaluationTraceCallback,
ExperimentResult,
ExperimentResults,
ExperimentRunner,
GoalAlignmentEvaluator,
MetricCategory,
ParameterExtractionEvaluator,
ReasoningEfficiencyEvaluator,
SemanticQualityEvaluator,
ToolInvocationEvaluator,
ToolSelectionEvaluator,
create_default_evaluator,
create_evaluation_callbacks,
"""Experimental CrewAI surface — APIs here may change without major-version bumps."""
from __future__ import annotations
from typing import Any
# ``crewai.experimental.conversational`` is pure data shapes — no Flow or Task
# imports — so it's safe to eager-import. Everything else is resolved lazily
# below; otherwise the chain
# crewai → Flow → experimental.conversational → experimental.__init__
# → experimental.agent_executor / experimental.evaluation
# → Flow / Task (mid-load)
# would deadlock with "partially initialized module" ImportErrors.
from crewai.experimental.conversational import (
AgentMessage,
ConversationConfig,
ConversationEvent,
ConversationMessage,
ConversationState,
RouterConfig,
)
__all__ = [
_LAZY_FROM_AGENT_EXECUTOR = {"AgentExecutor", "CrewAgentExecutorFlow"}
_LAZY_FROM_EVALUATION = {
"AgentEvaluationResult",
"AgentEvaluator",
"AgentExecutor",
"BaseEvaluator",
"CrewAgentExecutorFlow", # Deprecated alias for AgentExecutor
"EvaluationScore",
"EvaluationTraceCallback",
"ExperimentResult",
@@ -40,4 +41,62 @@ __all__ = [
"ToolSelectionEvaluator",
"create_default_evaluator",
"create_evaluation_callbacks",
}
def __getattr__(name: str) -> Any:
"""Lazily resolve symbols whose modules import ``Flow`` or ``Task``.
Eager re-exports would deadlock when ``Flow`` itself is the consumer that
triggered ``crewai.experimental.__init__`` (``Flow`` imports types from
:mod:`crewai.experimental.conversational`). Callers like
``from crewai.experimental import AgentExecutor`` still work — the
real import just runs lazily, after the original loader finishes.
"""
if name in _LAZY_FROM_AGENT_EXECUTOR:
from crewai.experimental.agent_executor import (
AgentExecutor,
CrewAgentExecutorFlow,
)
globals()["AgentExecutor"] = AgentExecutor
globals()["CrewAgentExecutorFlow"] = CrewAgentExecutorFlow
return globals()[name]
if name in _LAZY_FROM_EVALUATION:
from crewai.experimental import evaluation as _evaluation_mod
for attr in _LAZY_FROM_EVALUATION:
globals()[attr] = getattr(_evaluation_mod, attr)
return globals()[name]
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
__all__ = [
"AgentEvaluationResult",
"AgentEvaluator",
"AgentExecutor",
"AgentMessage",
"BaseEvaluator",
"ConversationConfig",
"ConversationEvent",
"ConversationMessage",
"ConversationState",
"CrewAgentExecutorFlow", # Deprecated alias for AgentExecutor
"EvaluationScore",
"EvaluationTraceCallback",
"ExperimentResult",
"ExperimentResults",
"ExperimentRunner",
"GoalAlignmentEvaluator",
"MetricCategory",
"ParameterExtractionEvaluator",
"ReasoningEfficiencyEvaluator",
"RouterConfig",
"SemanticQualityEvaluator",
"ToolInvocationEvaluator",
"ToolSelectionEvaluator",
"create_default_evaluator",
"create_evaluation_callbacks",
]

View File

@@ -93,6 +93,7 @@ from crewai.utilities.agent_utils import (
track_delegation_if_needed,
)
from crewai.utilities.constants import TRAINING_DATA_FILE
from crewai.utilities.file_store import aget_all_files, get_all_files
from crewai.utilities.i18n import I18N_DEFAULT
from crewai.utilities.planning_types import (
PlanStep,
@@ -2771,7 +2772,7 @@ class AgentExecutor(Flow[AgentExecutorState], BaseAgentExecutor):
mark_cache_breakpoint(format_message_for_llm(user_prompt))
)
self._inject_files_from_inputs(inputs)
await self._ainject_files_from_inputs(inputs)
self.state.ask_for_human_input = bool(
inputs.get("ask_for_human_input", False)
@@ -2982,12 +2983,42 @@ class AgentExecutor(Flow[AgentExecutorState], BaseAgentExecutor):
training_handler.save(training_data)
def _inject_files_from_inputs(self, inputs: dict[str, Any]) -> None:
"""Inject files from inputs into the last user message.
"""Inject files into the last user message.
Args:
inputs: Input dictionary that may contain a 'files' key.
"""
files = inputs.get("files")
files: dict[str, Any] = {}
if self.crew and self.task:
stored_files = get_all_files(self.crew.id, self.task.id)
if stored_files:
files.update(stored_files)
if inputs.get("files"):
files.update(inputs["files"])
if not files:
return
for i in range(len(self.state.messages) - 1, -1, -1):
msg = self.state.messages[i]
if msg.get("role") == "user":
msg["files"] = files
break
async def _ainject_files_from_inputs(self, inputs: dict[str, Any]) -> None:
"""Async inject files into the last user message."""
files: dict[str, Any] = {}
if self.crew and self.task:
stored_files = await aget_all_files(self.crew.id, self.task.id)
if stored_files:
files.update(stored_files)
if inputs.get("files"):
files.update(inputs["files"])
if not files:
return

View File

@@ -0,0 +1,184 @@
"""Conversational types and helpers shared by ``Flow`` (experimental).
The conversational chat surface (``Flow`` with ``conversational = True``) is
EXPERIMENTAL. APIs in this module and the conversational methods on ``Flow``
may change without a major-version bump until the feature graduates.
This module hosts the **data shapes** — ``ConversationConfig``,
``RouterConfig``, ``ConversationState`` and its message types — plus the
``_conversational_only`` decorator used to gate built-in conversational
methods on the base ``Flow`` class. The methods themselves live on ``Flow``
directly.
"""
from __future__ import annotations
from collections.abc import Callable, Sequence
from dataclasses import dataclass
from typing import Any, Literal, TypeVar, cast
from uuid import uuid4
from pydantic import BaseModel, ConfigDict, Field
from crewai.utilities.types import LLMMessage
ConversationMessageRole = Literal["user", "assistant", "system", "tool"]
ConversationEventVisibility = Literal["private", "public"]
F = TypeVar("F", bound=Callable[..., Any])
def _conversational_only(func: F) -> F:
"""Mark a method as part of the conversational built-in graph.
Methods carrying this marker only register on a ``Flow`` subclass when
``conversational = True``. Subclasses that don't opt in see them as
inert attributes — they don't fire and don't pollute the listener graph.
"""
func.__conversational_only__ = True # type: ignore[attr-defined]
return func
@dataclass
class RouterConfig:
"""LLM router configuration for the experimental conversational ``Flow``.
.. warning::
**EXPERIMENTAL.** Part of the conversational ``Flow`` surface. Fields
and defaults may change before the feature graduates from
``crewai.experimental``. Pin your CrewAI version if you depend on
a specific shape.
``route_descriptions`` overrides the per-route descriptions used to build
the router LLM's "available routes" catalog. Routes without an entry fall
back to the handler's docstring first line (or, for built-in routes, the
framework's canned description). ``prompt`` is reserved for domain
policy/voice, not the route catalog — that's auto-built.
"""
prompt: str | None = None
response_format: type[BaseModel] | None = None
llm: Any | None = None
routes: Sequence[str] | None = None
route_descriptions: dict[str, str] | None = None
default_intent: str | None = "converse"
fallback_intent: str | None = "converse"
intent_field: str = "intent"
@dataclass
class ConversationConfig:
"""Class-level configuration for the experimental conversational ``Flow``.
.. warning::
**EXPERIMENTAL.** Part of the conversational ``Flow`` surface. Fields
and defaults may change before the feature graduates from
``crewai.experimental``. Pin your CrewAI version if you depend on
a specific shape.
``system_prompt`` defaults to the ``slices.conversational_system_prompt``
translation when left as ``None``. Pass an empty string to opt out of any
system prompt for ``converse_turn``. ``answer_from_history_prompt`` falls
back to ``slices.conversational_answer_from_history_prompt`` when ``None``.
"""
system_prompt: str | None = None
llm: Any | None = None
router: RouterConfig | None = None
answer_from_history_prompt: str | None = None
default_intents: Sequence[str] | None = None
intent_llm: Any | None = None
answer_from_history_llm: Any | None = None
visible_agent_outputs: Sequence[str] | Literal["all"] | None = None
defer_trace_finalization: bool = True
def __call__(self, flow_cls: type[Any]) -> type[Any]:
"""Use this config as a class decorator."""
flow_cls.conversational_config = self
return flow_cls
class ConversationMessage(BaseModel):
"""Canonical user-facing message shared across conversational turns."""
model_config = ConfigDict(arbitrary_types_allowed=True)
role: ConversationMessageRole
content: str | list[dict[str, Any]] | None
name: str | None = None
tool_call_id: str | None = None
tool_calls: list[dict[str, Any]] | None = None
files: dict[str, Any] | None = None
metadata: dict[str, Any] = Field(default_factory=dict)
class AgentMessage(BaseModel):
"""Private per-agent message or scratch result."""
role: ConversationMessageRole | str = "assistant"
content: Any
metadata: dict[str, Any] = Field(default_factory=dict)
class ConversationEvent(BaseModel):
"""Structured trace/event that is separate from user-visible messages."""
type: str
payload: dict[str, Any] = Field(default_factory=dict)
agent_name: str | None = None
visibility: ConversationEventVisibility = "private"
class ConversationState(BaseModel):
"""Structured state for the experimental conversational ``Flow``.
.. warning::
**EXPERIMENTAL.** Field shape and defaults may change before the
conversational ``Flow`` graduates from ``crewai.experimental``.
``messages`` is the canonical user-facing history. Agent/tool scratch work
belongs in ``events`` or ``agent_threads`` unless explicitly made public.
"""
id: str = Field(default_factory=lambda: str(uuid4()))
messages: list[ConversationMessage] = Field(default_factory=list)
current_user_message: str | None = None
last_user_message: str | None = None
last_intent: str | None = None
ended: bool = False
events: list[ConversationEvent] = Field(default_factory=list)
agent_threads: dict[str, list[AgentMessage]] = Field(default_factory=dict)
session_ready: bool = False
def message_to_llm_dict(message: Any) -> LLMMessage:
"""Coerce a stored ``ConversationMessage`` (or dict) into an ``LLMMessage``."""
if isinstance(message, BaseModel):
data = message.model_dump(exclude_none=True)
elif isinstance(message, dict):
data = dict(message)
else:
data = {"role": "user", "content": str(message)}
return cast(
LLMMessage,
{key: value for key, value in data.items() if key != "metadata"},
)
__all__ = [
"AgentMessage",
"ConversationConfig",
"ConversationEvent",
"ConversationEventVisibility",
"ConversationMessage",
"ConversationMessageRole",
"ConversationState",
"RouterConfig",
"_conversational_only",
"message_to_llm_dict",
]

View File

@@ -0,0 +1,942 @@
"""Conversational graph + helpers as a mixin for ``Flow`` (experimental).
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``).
Import surface:
- :class:`_ConversationalMixin` — internal; ``Flow`` mixes it in. Users
don't import it directly.
- The data types this mixin uses live in
:mod:`crewai.experimental.conversational`.
"""
from __future__ import annotations
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 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,
ConversationEvent,
ConversationMessage,
ConversationState,
RouterConfig,
_conversational_only,
message_to_llm_dict,
)
from crewai.flow.conversation import (
append_message as _append_conversation_message,
get_conversation_messages,
receive_user_message as _receive_user_message,
)
from crewai.flow.dsl import listen, router, start
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``).
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.
"""
# 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.
# Class-level slots use ``ClassVar`` to match Flow's actual declarations
# (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]
_method_call_counts: dict[Any, int]
_is_execution_resuming: bool
_pending_user_message: str | dict[str, Any] | None
_pending_intents: Sequence[str] | None
_pending_intent_llm: str | BaseLLM | None
def _clear_or_listeners(self) -> None:
pass
def _collapse_to_outcome(
self,
feedback: str,
outcomes: tuple[str, ...],
llm: str | BaseLLM | Any,
) -> str:
pass
def _copy_and_serialize_state(self) -> dict[str, Any]:
pass
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.
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``.
"""
state = cast(ConversationState, self.state)
return state.current_user_message
@router(conversation_start)
@_conversational_only
def route_conversation(self) -> str:
"""Route the current turn to a listener label."""
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")
@_conversational_only
def converse_turn(self) -> str:
"""Built-in chat handler over canonical conversation history."""
llm = self._default_conversation_llm()
if llm is None:
content = "I can continue the conversation once an LLM is configured."
self.append_assistant_message(content)
return content
messages: list[LLMMessage] = []
system_prompt = self._resolve_system_prompt()
if system_prompt:
messages.append({"role": "system", "content": system_prompt})
messages.extend(self.conversation_messages)
response = self._coerce_llm(llm).call(messages=messages)
content = self._stringify_result(response)
self.append_assistant_message(content)
return content
@listen("end")
@_conversational_only
def end_conversation(self) -> str:
"""Built-in conversation terminator."""
cast(ConversationState, self.state).ended = True
content = "Conversation ended."
self.append_assistant_message(content)
return content
@listen("answer_from_history")
@_conversational_only
def answer_from_history_turn(self) -> str | None:
"""Answer directly from canonical conversation history when configured."""
config = self._conversation_config
if config is None:
return None
llm = config.answer_from_history_llm
if llm is None:
return None
llm_instance = self._coerce_llm(llm)
messages: list[LLMMessage] = [
{
"role": "system",
"content": self._resolve_answer_from_history_prompt(),
},
*self.build_agent_context("answer_from_history"),
]
response = llm_instance.call(messages=messages)
content = self._stringify_result(response)
self.append_assistant_message(content)
return content
def handle_turn(
self,
message: str,
*,
session_id: str | None = None,
intents: Sequence[str] | None = None,
intent_llm: str | BaseLLM | None = None,
**kickoff_kwargs: Any,
) -> Any:
"""Append a user message, run one conversational turn, and return output.
.. warning::
**EXPERIMENTAL.** This is the public entry point for the
conversational ``Flow``. Signature and semantics may change before
the feature graduates from ``crewai.experimental``.
Available only when ``conversational = True`` is set on the subclass.
Stashes the message + session_id as pending turn state, runs kickoff
(which restores from persist and then applies the pending turn), and
promotes the result to an assistant message when the handler didn't.
"""
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.
self._pending_user_message = message
self._pending_intents = list(intents) if intents else None
self._pending_intent_llm = intent_llm
# Each turn is a fresh execution; clear graph tracking so the second
# turn re-runs instead of being treated as a checkpoint restore.
if "from_checkpoint" not in kickoff_kwargs:
self._reset_turn_execution_state()
assistant_count = self._assistant_message_count()
try:
result = self.kickoff(inputs={"id": sid}, **kickoff_kwargs)
finally:
self._pending_user_message = None
self._pending_intents = None
self._pending_intent_llm = None
if (
result is not None
and self._assistant_message_count() == assistant_count
and self._is_public_turn_result(result)
):
self.append_assistant_message(self._stringify_result(result))
return result
def chat(
self,
*,
session_id: str | None = None,
prompt: str = "\nYou: ",
assistant_prefix: str = "\nAssistant: ",
exit_commands: Sequence[str] = ("exit", "quit"),
input_fn: Callable[[str], str] = input,
output_fn: Callable[[str], None] = print,
skip_empty: bool = True,
defer_trace_finalization: bool = True,
**handle_turn_kwargs: Any,
) -> None:
"""Run an interactive terminal chat loop for a conversational Flow.
``chat()`` is a convenience wrapper around ``handle_turn()`` for local
REPLs. For web apps, tests, and custom transports, call
``handle_turn()`` directly. The input/output callables are injectable so
callers can customize prompts or exercise the loop without patching
builtins.
"""
if not getattr(type(self), "conversational", False):
raise ValueError("Flow.chat() is only available on conversational flows")
exit_set = {command.lower() for command in exit_commands}
previous_defer = getattr(self, "defer_trace_finalization", False)
if defer_trace_finalization:
self.defer_trace_finalization = True
try:
while True:
try:
message = input_fn(prompt).strip()
except (EOFError, KeyboardInterrupt):
output_fn("")
break
if message.lower() in exit_set:
break
if skip_empty and not message:
continue
result = self.handle_turn(
message,
session_id=session_id,
**handle_turn_kwargs,
)
output_fn(f"{assistant_prefix}{self._stringify_result(result)}")
finally:
self.finalize_session_traces()
if defer_trace_finalization:
self.defer_trace_finalization = previous_defer
def build_router_context(self) -> dict[str, Any]:
"""Build context used by the routing policy for the current turn."""
state = cast(ConversationState, self.state)
return {
"system_prompt": self._resolve_system_prompt(),
"current_user_message": state.current_user_message,
"message_history": self.conversation_messages,
"events": [event.model_dump() for event in state.events],
"last_intent": state.last_intent,
}
def build_agent_context(self, agent_name: str) -> list[LLMMessage]:
"""Build canonical message context for an agent or direct LLM call."""
state = cast(ConversationState, self.state)
messages = list(self.conversation_messages)
thread = state.agent_threads.get(agent_name, [])
messages.extend(
cast(
LLMMessage,
{
"role": msg.role,
"content": self._stringify_result(msg.content),
},
)
for msg in thread
)
return messages
def route_turn(self, context: dict[str, Any]) -> str | None:
"""Route the current turn via the LLM router.
When ``ConversationConfig.router`` is omitted, the router is
auto-enabled with default settings as long as the flow declares
custom ``@listen`` handlers (anything beyond the built-in
``converse`` / ``end`` routes). ``@ConversationConfig(llm=ROUTER_LLM)``
is enough to dispatch to your custom handlers — no explicit
``RouterConfig()`` needed.
Pass an explicit ``RouterConfig`` only to override the routing prompt,
supply per-route descriptions, or change the default/fallback intent.
Override this method to bypass the LLM router entirely (e.g.,
permission gates before the LLM decision).
"""
config = self._conversation_config
if config is None:
return None
router_config = config.router
if router_config is None:
if config.default_intents:
return None
custom_routes = self._effective_routes(None) - set(self.builtin_routes)
if not custom_routes:
return None
router_config = RouterConfig()
return self._route_with_config(router_config, context)
def can_answer_from_history(self, context: dict[str, Any]) -> bool:
"""Return whether this turn can be answered from message history."""
config = self._conversation_config
if config is None or config.answer_from_history_llm is None:
return False
if len(self.conversation_messages) < 2:
return False
feedback = (
f"{self._resolve_answer_from_history_prompt()}\n\n"
f"Current user message: {context.get('current_user_message')}\n\n"
f"Message history:\n{self._format_messages(self.conversation_messages)}"
)
outcome = self._collapse_to_outcome(
feedback,
("answer_from_history", "route_to_flow"),
config.answer_from_history_llm,
)
return outcome == "answer_from_history"
def append_agent_result(
self,
agent_name: str,
result: Any,
*,
visibility: Literal["private", "public"] = "private",
metadata: dict[str, Any] | None = None,
) -> None:
"""Record an agent result, optionally making it visible to the user."""
content = self._stringify_result(result)
event_visibility = self._resolve_visibility(agent_name, visibility)
event = ConversationEvent(
type="agent_result",
agent_name=agent_name,
visibility=event_visibility,
payload={"content": content, **(metadata or {})},
)
state = cast(ConversationState, self.state)
state.events.append(event)
state.agent_threads.setdefault(agent_name, []).append(
AgentMessage(content=content, metadata=metadata or {})
)
if event_visibility == "public":
self.append_assistant_message(content)
def append_assistant_message(
self,
content: str,
*,
metadata: dict[str, Any] | None = None,
) -> None:
"""Append a final user-visible assistant message."""
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,
role: Literal["user", "assistant", "system", "tool"],
content: str,
**extra: Any,
) -> None:
"""Append a message to conversation history (legacy ChatState path)."""
_append_conversation_message(cast("Flow[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))
]
def receive_user_message(
self,
text: str,
*,
outcomes: Sequence[str] | None = None,
llm: str | BaseLLM | None = None,
) -> str:
"""Append a user message and optionally classify intent.
Conversational flows push a ``ConversationMessage`` onto
``state.messages`` and preserve ``last_intent`` across turns.
Non-conversational flows fall through to the legacy helper.
"""
if self.conversational:
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:
intent = self.classify_intent(
text,
outcomes,
llm=llm,
context=self.conversation_messages,
)
state.last_intent = intent
return intent
return text
return _receive_user_message(
cast("Flow[Any]", self), text, outcomes=outcomes, llm=llm
)
def classify_intent(
self,
text: str,
outcomes: Sequence[str],
*,
llm: str | BaseLLM,
context: Sequence[Mapping[str, Any]] | None = None,
) -> str:
"""Map user text to one of the given outcomes using an LLM."""
if context:
context_blob = "\n".join(
f"{m.get('role', 'user')}: {m.get('content', '')}" for m in context
)
feedback = f"{context_blob}\n\nLatest user message: {text}"
else:
feedback = text
return self._collapse_to_outcome(feedback, tuple(outcomes), llm)
@property
def _conversation_config(self) -> ConversationConfig | None:
return getattr(type(self), "conversational_config", None)
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.
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)
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._method_call_counts.clear()
self._clear_or_listeners()
self._is_execution_resuming = False
def _apply_pending_conversational_turn(self) -> None:
"""Drain the stashed user message + classify if intents configured.
Called from ``Flow.kickoff_async`` AFTER persist state restore so
the appended message survives ``self.persistence.load_state(...)``.
"""
if self._pending_user_message is None:
return
text = self._coerce_user_message_text(self._pending_user_message)
if not text.strip():
return
cfg = self._conversation_config
outcomes = self._pending_intents
if outcomes is None and cfg is not None:
outcomes = cfg.default_intents
llm = self._pending_intent_llm
if llm is None and cfg is not None:
llm = cfg.intent_llm
if outcomes:
if llm is None:
raise ValueError("intent_llm is required when intents are provided")
self.receive_user_message(text, outcomes=outcomes, llm=llm)
else:
self.receive_user_message(text)
def _resolve_system_prompt(self) -> str | None:
"""Return the effective conversational system prompt."""
from crewai.utilities.i18n import I18N_DEFAULT
config = self._conversation_config
if config is None or config.system_prompt is None:
return I18N_DEFAULT.slice("conversational_system_prompt")
return config.system_prompt or None
def _resolve_answer_from_history_prompt(self) -> str:
"""Return the effective ``answer_from_history`` prompt."""
from crewai.utilities.i18n import I18N_DEFAULT
config = self._conversation_config
if config is None or not config.answer_from_history_prompt:
return I18N_DEFAULT.slice("conversational_answer_from_history_prompt")
return config.answer_from_history_prompt
def _route_with_config(
self,
router_config: RouterConfig,
context: dict[str, Any],
) -> str | None:
router_llm = self._default_router_llm(router_config)
if router_llm is None:
return router_config.default_intent
try:
llm = self._coerce_llm(router_llm)
response = self._call_router_llm(
llm,
messages=self._build_router_messages(router_config, context),
response_format=self._router_response_format(router_config),
)
intent = self._extract_router_intent(response, router_config.intent_field)
except Exception:
return router_config.fallback_intent or router_config.default_intent
if intent is None:
return router_config.fallback_intent or router_config.default_intent
valid_labels = self._effective_routes(router_config)
if valid_labels and intent not in valid_labels:
return router_config.fallback_intent or router_config.default_intent
return intent
def _default_router_llm(self, router_config: RouterConfig) -> Any | None:
config = self._conversation_config
return (
router_config.llm
or (config.intent_llm if config else None)
or (config.llm if config else None)
)
def _router_response_format(
self,
router_config: RouterConfig,
) -> type[BaseModel]:
if router_config.response_format is not None:
return router_config.response_format
routes = sorted(self._effective_routes(router_config))
field_definitions: dict[str, Any] = {
router_config.intent_field: (
str,
Field(description=f"One of: {', '.join(routes)}"),
)
}
return cast(
type[BaseModel],
create_model("ConversationRoute", **field_definitions),
)
def _call_router_llm(
self,
llm: Any,
*,
messages: list[LLMMessage],
response_format: type[BaseModel],
) -> Any:
try:
return llm.call(messages=messages, response_format=response_format)
except TypeError as exc:
if "response_format" not in str(exc):
raise
return llm.call(messages=messages, response_model=response_format)
def _build_router_messages(
self,
router_config: RouterConfig,
context: dict[str, Any],
) -> list[LLMMessage]:
catalog = self._build_route_catalog(router_config)
context = {**context, "available_routes": sorted(catalog.keys())}
domain_prompt = f"{router_config.prompt}\n\n" if router_config.prompt else ""
routes_section = "Routes:\n" + "\n".join(
f"- {label}: {description}" if description else f"- {label}"
for label, description in sorted(catalog.items())
)
routing_prompt = (
domain_prompt
+ routes_section
+ "\n\nChoose exactly one route from the list above. Prefer "
"'converse' for follow-ups, summaries, and clarifications about "
"prior turns — even if they touch on a topic the user previously "
"invoked a custom route for. Use a custom route only when the user "
"is making a fresh request for that tool or workflow."
)
return [
{"role": "system", "content": routing_prompt},
{"role": "user", "content": json.dumps(context, default=str)},
]
def _build_route_catalog(
self,
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))
routes = self._effective_routes(router_config)
overrides = (
router_config.route_descriptions
if router_config and router_config.route_descriptions
else {}
)
catalog: dict[str, str] = {}
for route_label in routes:
if route_label in overrides:
catalog[route_label] = overrides[route_label]
continue
if route_label in self.builtin_route_descriptions:
catalog[route_label] = self.builtin_route_descriptions[route_label]
continue
handler_name = label_to_method.get(route_label)
description = ""
if handler_name:
method = getattr(type(self), handler_name, None)
doc = getattr(method, "__doc__", None)
if doc:
description = doc.strip().split("\n", 1)[0].strip()
catalog[route_label] = description
return catalog
def _extract_router_intent(self, response: Any, intent_field: str) -> str | None:
if isinstance(response, BaseModel):
value = getattr(response, intent_field, None)
elif isinstance(response, dict):
value = response.get(intent_field)
elif isinstance(response, str):
try:
parsed = json.loads(response)
except json.JSONDecodeError:
value = response.strip()
else:
value = parsed.get(intent_field)
else:
value = getattr(response, intent_field, None)
if value is None:
return None
if isinstance(value, Enum):
return str(value.value)
return str(value)
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)
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()
if not custom_routes:
custom_routes = (
self._valid_route_labels()
- set(self.builtin_routes)
- set(self.internal_routes)
)
return custom_routes | set(self.builtin_routes)
def _default_conversation_llm(self) -> Any | None:
config = self._conversation_config
if config is None:
return None
if config.llm is not None:
return config.llm
if config.answer_from_history_llm is not None:
return config.answer_from_history_llm
if config.router is not None:
return config.router.llm
return config.intent_llm
def _resolve_visibility(
self,
agent_name: str,
visibility: Literal["private", "public"],
) -> Literal["private", "public"]:
if visibility == "public":
return "public"
config = self._conversation_config
visible = config.visible_agent_outputs if config else None
if visible == "all" or (visible is not None and agent_name in visible):
return "public"
return "private"
def _assistant_message_count(self) -> int:
state = cast(ConversationState, self.state)
return sum(1 for message in state.messages if message.role == "assistant")
def _is_public_turn_result(self, result: Any) -> bool:
if not isinstance(result, str):
return False
if result in {
"conversation",
"converse",
"end",
"answer_from_history",
"route_to_flow",
}:
return False
return result != cast(ConversationState, self.state).last_intent
@staticmethod
def _coerce_user_message_text(user_message: str | dict[str, Any] | Any) -> str:
if isinstance(user_message, str):
return user_message
if isinstance(user_message, dict) and user_message.get("content") is not None:
return str(user_message["content"])
return str(user_message)
@staticmethod
def _stringify_result(result: Any) -> str:
if hasattr(result, "raw"):
return str(result.raw)
if isinstance(result, BaseModel):
return result.model_dump_json()
return str(result)
@staticmethod
def _format_messages(messages: Sequence[Mapping[str, Any]]) -> str:
return "\n".join(
f"{message.get('role', 'user')}: {message.get('content', '')}"
for message in messages
)
@staticmethod
def _coerce_llm(llm: str | BaseLLM | Any) -> Any:
from crewai.llm import LLM
from crewai.llms.base_llm import BaseLLM as BaseLLMClass
if isinstance(llm, str):
return LLM(model=llm)
if isinstance(llm, BaseLLMClass) or callable(getattr(llm, "call", None)):
return llm
raise ValueError(f"Invalid llm type: {type(llm)}. Expected str or BaseLLM.")
def finalize_session_traces(self) -> None:
"""Emit a final ``FlowFinishedEvent`` and finalize the trace batch.
Pairs with ``flow.defer_trace_finalization = True`` (or
``ConversationConfig(defer_trace_finalization=True)``): per-turn
``handle_turn()`` skips the close, then a single call here at
session end emits one ``FlowFinishedEvent`` + ``finalize_batch()``
so the whole conversation lands as one trace.
Safe to call when not deferring — it's a no-op if the trace batch
was already finalized per-turn or never started.
"""
from crewai.events.event_bus import crewai_event_bus
from crewai.events.event_context import restore_event_scope
from crewai.events.listeners.tracing.trace_listener import (
TraceCollectionListener,
)
from crewai.events.types.flow_events import FlowFinishedEvent
# Only emit the session-end event when a deferred flow_started is
# actually pending. ``_deferred_flow_started_event_id`` is set only by
# deferred kickoffs; when finalization was not deferred, each per-turn
# kickoff already emitted its own flow_finished, so emitting here would
# duplicate the session-end event and confuse tracing. Restoring the
# stashed scope also pairs this flow_finished with its opener instead
# 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
restore_event_scope(((started_id, "flow_started"),))
try:
crewai_event_bus.emit(
self,
FlowFinishedEvent(
type="flow_finished",
flow_name=self.name or self.__class__.__name__,
result=last_output,
state=self._copy_and_serialize_state(),
),
)
except Exception:
logger.warning(
"FlowFinishedEvent emission failed during finalize_session_traces",
exc_info=True,
)
finally:
restore_event_scope(())
object.__setattr__(self, "_deferred_flow_started_event_id", None)
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()
__all__ = ["_ConversationalMixin"]

View File

@@ -4,10 +4,14 @@ from crewai.flow.async_feedback import (
HumanFeedbackProvider,
PendingFeedbackContext,
)
from crewai.flow.conversation import (
ChatState,
ConversationalConfig,
ConversationalInputs,
)
from crewai.flow.dsl import HumanFeedbackResult, human_feedback
from crewai.flow.flow import Flow, and_, listen, or_, router, start
from crewai.flow.flow_config import flow_config
from crewai.flow.flow_serializer import flow_structure
from crewai.flow.human_feedback import HumanFeedbackResult, human_feedback
from crewai.flow.input_provider import InputProvider, InputResponse
from crewai.flow.persistence import persist
from crewai.flow.visualization import (
@@ -18,7 +22,10 @@ from crewai.flow.visualization import (
__all__ = [
"ChatState",
"ConsoleProvider",
"ConversationalConfig",
"ConversationalInputs",
"Flow",
"FlowStructure",
"HumanFeedbackPending",
@@ -30,7 +37,6 @@ __all__ = [
"and_",
"build_flow_structure",
"flow_config",
"flow_structure",
"human_feedback",
"listen",
"or_",

View File

@@ -0,0 +1,246 @@
"""Conversational turn helpers for CrewAI Flows.
Provides message history utilities, kickoff input normalization, and optional
class-level defaults via ``ConversationalConfig``. Session identity is ``state.id``
(``inputs["id"]`` / ``kickoff(session_id=...)``), not a separate Flow field.
"""
from __future__ import annotations
from collections.abc import Sequence
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any, Literal, TypedDict, cast
from uuid import uuid4
from pydantic import BaseModel, Field
from crewai.utilities.types import LLMMessage
if TYPE_CHECKING:
from crewai.flow.flow import Flow
from crewai.llms.base_llm import BaseLLM
_EXIT_COMMANDS_DEFAULT: tuple[str, ...] = ("exit", "quit")
class ConversationalInputs(TypedDict, total=False):
"""Conventional ``kickoff(inputs=...)`` keys for chat turns."""
id: str
user_message: str | dict[str, Any]
last_intent: str
@dataclass
class ConversationalConfig:
"""Optional class-level defaults for conversational flows.
Override per kickoff via ``user_message``, ``session_id``, ``intents``, etc.
"""
default_intents: Sequence[str] | None = None
intent_llm: str | None = None
interactive_prompt: str = "You: "
interactive_timeout: float | None = None
exit_commands: Sequence[str] = field(default_factory=lambda: _EXIT_COMMANDS_DEFAULT)
defer_trace_finalization: bool = True
class ChatState(BaseModel):
"""Recommended persisted state shape for multi-turn flows."""
id: str = Field(default_factory=lambda: str(uuid4()))
messages: list[LLMMessage] = Field(default_factory=list)
last_user_message: str | None = None
last_intent: str | None = None
session_ready: bool = False
def _coerce_user_message_text(user_message: str | dict[str, Any] | Any) -> str:
if isinstance(user_message, str):
return user_message
if isinstance(user_message, dict):
content = user_message.get("content")
if content is not None:
return str(content)
return str(user_message)
def normalize_kickoff_inputs(
inputs: dict[str, Any] | None,
*,
user_message: str | dict[str, Any] | None = None,
session_id: str | None = None,
) -> dict[str, Any] | None:
"""Merge conversational kickoff kwargs into the inputs dict.
Returns ``None`` when the caller passed no inputs and no conversational
kwargs — so ``FlowStartedEvent.inputs`` stays ``None`` for stateless flows
instead of being materialized as an empty dict.
"""
if inputs is None and user_message is None and session_id is None:
return None
merged: dict[str, Any] = dict(inputs or {})
if session_id is not None:
merged["id"] = session_id
if user_message is not None:
merged["user_message"] = user_message
return merged
def get_conversation_messages(flow: Flow[Any]) -> list[LLMMessage]:
"""Read message history from flow state or the internal fallback buffer."""
buffer: list[LLMMessage] = getattr(flow, "_conversation_messages", [])
state = getattr(flow, "_state", None)
if state is None:
return list(buffer)
if isinstance(state, dict):
messages = state.get("messages")
if isinstance(messages, list):
return cast(list[LLMMessage], messages)
elif isinstance(state, BaseModel) and hasattr(state, "messages"):
messages = getattr(state, "messages", None)
if isinstance(messages, list):
return cast(list[LLMMessage], messages)
return list(buffer)
def append_message(
flow: Flow[Any],
role: Literal["user", "assistant", "system", "tool"],
content: str,
**extra: Any,
) -> None:
"""Append a message to ``state.messages`` or the flow fallback buffer."""
message: LLMMessage = {"role": role, "content": content}
for key, value in extra.items():
if key in ("tool_call_id", "name", "tool_calls", "files"):
message[key] = value # type: ignore[literal-required]
state = getattr(flow, "_state", None)
if state is not None:
if isinstance(state, dict):
messages = state.get("messages")
if isinstance(messages, list):
messages.append(message)
return
elif isinstance(state, BaseModel) and hasattr(state, "messages"):
messages = getattr(state, "messages", None)
if messages is None:
object.__setattr__(state, "messages", [])
messages = state.messages
if isinstance(messages, list):
messages.append(message)
return
if not hasattr(flow, "_conversation_messages"):
object.__setattr__(flow, "_conversation_messages", [])
flow._conversation_messages.append(message)
def set_state_field(flow: Flow[Any], name: str, value: Any) -> None:
"""Set a field on structured or dict flow state when present."""
state = getattr(flow, "_state", None)
if state is None:
return
if isinstance(state, dict):
state[name] = value
elif isinstance(state, BaseModel) and hasattr(state, name):
object.__setattr__(state, name, value)
def receive_user_message(
flow: Flow[Any],
text: str,
*,
outcomes: Sequence[str] | None = None,
llm: str | BaseLLM | None = None,
) -> str:
"""Record a user turn: append message and optionally classify intent."""
append_message(flow, "user", text)
set_state_field(flow, "last_user_message", text)
if outcomes and llm is not None:
intent = flow.classify_intent(
text,
outcomes,
llm=llm,
context=get_conversation_messages(flow),
)
set_state_field(flow, "last_intent", intent)
return intent
return text
def prepare_conversational_turn(
flow: Flow[Any],
*,
user_message: str | dict[str, Any] | None = None,
intents: Sequence[str] | None = None,
intent_llm: str | BaseLLM | None = None,
config: ConversationalConfig | None = None,
) -> None:
"""Hydrate conversation state after inputs are merged into flow state."""
if user_message is None:
state = getattr(flow, "_state", None)
if isinstance(state, dict) and "user_message" in state:
user_message = state["user_message"]
elif isinstance(state, BaseModel) and hasattr(state, "user_message"):
user_message = getattr(state, "user_message", None)
if user_message is None:
return
text = _coerce_user_message_text(user_message)
if not text.strip():
return
# Fresh classification each turn (do not reuse prior turn's route label).
set_state_field(flow, "last_intent", None)
resolved_intents = intents
if resolved_intents is None and config is not None:
resolved_intents = config.default_intents
resolved_llm = intent_llm
if resolved_llm is None and config is not None:
resolved_llm = config.intent_llm
if resolved_intents:
if resolved_llm is None:
raise ValueError("intent_llm is required when intents are provided")
receive_user_message(
flow,
text,
outcomes=resolved_intents,
llm=resolved_llm,
)
else:
receive_user_message(flow, text)
def input_history_to_messages(entries: Sequence[Any]) -> list[LLMMessage]:
"""Convert ``Flow.input_history`` entries to LLM message format."""
messages: list[LLMMessage] = []
for entry in entries:
prompt = entry.get("message") if isinstance(entry, dict) else None
response = entry.get("response") if isinstance(entry, dict) else None
if prompt:
messages.append({"role": "assistant", "content": str(prompt)})
if response:
messages.append({"role": "user", "content": str(response)})
return messages
def get_conversational_config(flow: Flow[Any]) -> ConversationalConfig | None:
"""Return class-level ``conversational_config`` if defined."""
return getattr(type(flow), "conversational_config", None)

View File

@@ -0,0 +1,32 @@
"""Flow DSL: the Python authoring layer for Flows.
Provides the ``@start`` / ``@listen`` / ``@router`` decorators and the
``or_`` / ``and_`` condition combinators used to write Flow classes in
Python. The DSL is one way to produce a Flow Structure: this package
extracts a :class:`~crewai.flow.flow_definition.FlowDefinition` from a
Python Flow class. Execution is handled by ``runtime``.
"""
from crewai.flow.dsl._conditions import and_, or_
from crewai.flow.dsl._human_feedback import (
HumanFeedbackResult,
human_feedback,
)
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,
)
__all__ = [
"HumanFeedbackResult",
"and_",
"human_feedback",
"listen",
"or_",
"router",
"start",
]

View File

@@ -0,0 +1,287 @@
"""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.
"""
from __future__ import annotations
from collections.abc import Sequence
from typing import Any
from typing_extensions import TypeIs
from crewai.flow.constants import AND_CONDITION, OR_CONDITION
from crewai.flow.dsl._types import FlowTrigger
from crewai.flow.flow_definition import FlowDefinitionCondition
from crewai.flow.flow_wrappers import (
FlowCondition,
FlowConditions,
SimpleFlowCondition,
)
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)
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}
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}
def _runtime_condition_from_definition(
condition: FlowDefinitionCondition,
) -> FlowMethodName | FlowCondition:
if isinstance(condition, str):
return FlowMethodName(condition)
if is_flow_condition_dict(condition):
return condition
if "and" in condition:
return {
"type": AND_CONDITION,
"conditions": [
_runtime_condition_from_definition(item)
for item in condition.get("and", [])
],
}
return {
"type": OR_CONDITION,
"conditions": [
_runtime_condition_from_definition(item) for item in condition.get("or", [])
],
}
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

View File

@@ -0,0 +1,98 @@
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,
)
if TYPE_CHECKING:
from crewai.flow.async_feedback.types import HumanFeedbackProvider
from crewai.llms.base_llm import BaseLLM
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,
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]:
"""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,
)
config = HumanFeedbackConfig(
message=message,
emit=emit,
llm=llm,
default_outcome=default_outcome,
metadata=metadata,
provider=provider,
learn=learn,
learn_source=learn_source,
learn_strict=learn_strict,
)
def decorator(func: F) -> F:
wrapper = runtime_decorator(func)
_stamp_human_feedback_metadata(wrapper, func, config)
return wrapper
return decorator

View File

@@ -0,0 +1,55 @@
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._types import FlowMethodDecorator, FlowTrigger
from crewai.flow.dsl._utils import (
P,
R,
_set_flow_method_definition,
_set_trigger_metadata,
)
from crewai.flow.flow_definition import FlowMethodDefinition
from crewai.flow.flow_wrappers import ListenMethod
def listen(condition: FlowTrigger) -> FlowMethodDecorator:
"""Creates a listener that executes when specified conditions are met.
This decorator sets up a method to execute in response to other method
executions in the flow. It supports both simple and complex triggering
conditions.
Args:
condition: Route label, method reference, or condition returned by
or_() / and_() that triggers the listener.
Returns:
A flow method decorator that preserves the decorated method's static signature.
Raises:
ValueError: If the condition format is invalid.
Examples:
>>> @listen("process_data")
>>> def handle_processed_data(self):
... pass
>>> @listen("method_name")
>>> def handle_completion(self):
... pass
"""
def decorator(func: Callable[P, R]) -> ListenMethod[P, R]:
wrapper = ListenMethod(func)
_set_flow_method_definition(
wrapper,
FlowMethodDefinition(listen=_definition_condition_from_runtime(condition)),
)
_set_trigger_metadata(wrapper, condition)
return wrapper
return cast(FlowMethodDecorator, decorator)

View File

@@ -0,0 +1,166 @@
from __future__ import annotations
from collections.abc import Callable, Sequence
from enum import Enum
import inspect
from types import UnionType
from typing import (
Any,
Literal,
Union,
cast,
get_args,
get_origin,
get_type_hints,
)
from crewai.flow.dsl._conditions import _definition_condition_from_runtime
from crewai.flow.dsl._types import FlowMethodDecorator, FlowTrigger
from crewai.flow.dsl._utils import (
P,
R,
_set_flow_method_definition,
_set_trigger_metadata,
)
from crewai.flow.flow_definition import FlowMethodDefinition
from crewai.flow.flow_wrappers import RouterMethod
def _unwrap_function(function: Any) -> Any:
if hasattr(function, "__func__"):
function = function.__func__
if hasattr(function, "__wrapped__"):
wrapped = function.__wrapped__
if hasattr(wrapped, "unwrap"):
return wrapped.unwrap()
return wrapped
if hasattr(function, "unwrap"):
return function.unwrap()
return function
def _string_values_from_annotation(annotation: Any) -> list[str]:
if annotation is inspect.Signature.empty or isinstance(annotation, str):
return []
if isinstance(annotation, type) and issubclass(annotation, Enum):
return [member.value for member in annotation if isinstance(member.value, str)]
origin = get_origin(annotation)
if origin is None:
return []
args = get_args(annotation)
if origin is Literal or getattr(origin, "__name__", "") == "Literal":
return [arg for arg in args if isinstance(arg, str)]
if not (
origin is Union
or origin is UnionType
or getattr(origin, "__name__", "") == "Annotated"
):
return []
values: list[str] = []
for arg in args:
values.extend(_string_values_from_annotation(arg))
return values
def _return_annotation(function: Any) -> Any:
unwrapped = _unwrap_function(function)
try:
return get_type_hints(unwrapped, include_extras=True).get(
"return", inspect.Signature.empty
)
except (NameError, TypeError, ValueError):
try:
return inspect.signature(unwrapped).return_annotation
except (TypeError, ValueError):
return inspect.Signature.empty
def _get_router_return_events(function: Any) -> list[str] | None:
values = _string_values_from_annotation(_return_annotation(function))
return list(dict.fromkeys(values)) if values else None
def _normalize_router_emit(value: Sequence[Any] | str) -> list[str]:
if isinstance(value, str):
return [str(value)]
return list(dict.fromkeys(str(item) for item in value))
def router(
condition: FlowTrigger,
*,
emit: Sequence[str] | str | None = None,
) -> FlowMethodDecorator:
"""Creates a routing method that directs flow execution based on conditions.
This decorator marks a method as a router, which can dynamically determine
the next steps in the flow based on its return value. Routers are triggered
by specified conditions and can return constants that emit downstream events.
Args:
condition: Specifies when the router should execute. Can be:
- str: Route label or method name that triggers this router
- FlowCondition: Result from or_() or and_(), including nested conditions
- Flow method reference: A method whose completion triggers this router
emit: Optional explicit router output events for static FlowDefinition
and visualization. If omitted, Literal/Enum return annotations are
used when available.
Returns:
A flow method decorator that preserves the decorated method's static signature.
Raises:
ValueError: If the condition format is invalid.
Examples:
>>> @router("check_status")
>>> def route_based_on_status(self):
... if self.state.status == "success":
... return "SUCCESS"
... return "FAILURE"
>>> @router(and_("validate", "process"))
>>> def complex_routing(self):
... if all([self.state.valid, self.state.processed]):
... return "CONTINUE"
... return "STOP"
>>> @router("check_status", emit=["SUCCESS", "FAILURE"])
>>> def explicit_routing(self):
... return "SUCCESS"
"""
def decorator(func: Callable[P, R]) -> RouterMethod[P, R]:
wrapper = RouterMethod(func)
if emit is not None:
router_events = _normalize_router_emit(emit)
else:
router_events = _get_router_return_events(func) or []
_set_flow_method_definition(
wrapper,
FlowMethodDefinition(
listen=_definition_condition_from_runtime(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

@@ -0,0 +1,69 @@
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._types import FlowMethodDecorator, FlowTrigger
from crewai.flow.dsl._utils import (
P,
R,
_set_flow_method_definition,
_set_trigger_metadata,
)
from crewai.flow.flow_definition import FlowMethodDefinition
from crewai.flow.flow_wrappers import StartMethod
def start(
condition: FlowTrigger | None = None,
) -> FlowMethodDecorator:
"""Marks a method as a flow's starting point.
This decorator designates a method as an entry point for the flow execution.
It can optionally specify conditions that trigger the start based on other
method executions.
Args:
condition: Defines when the start method should execute. Can be:
- str: Route label or method name that triggers this start
- FlowCondition: Result from or_() or and_(), including nested conditions
- Flow method reference: A method whose completion triggers this start
Default is None, meaning unconditional start.
Returns:
A flow method decorator that preserves the decorated method's static signature.
Raises:
ValueError: If the condition format is invalid.
Examples:
>>> @start() # Unconditional start
>>> def begin_flow(self):
... pass
>>> @start("method_name") # Start after specific method
>>> def conditional_start(self):
... pass
>>> @start(and_("method1", "method2")) # Start after multiple methods
>>> def complex_start(self):
... pass
"""
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_trigger_metadata(wrapper, condition)
else:
_set_flow_method_definition(wrapper, FlowMethodDefinition(start=True))
return wrapper
return cast(FlowMethodDecorator, decorator)

View File

@@ -0,0 +1,27 @@
"""Private typing helpers for the Python Flow DSL."""
from __future__ import annotations
from collections.abc import Callable
from typing import Any, Protocol, TypeAlias, TypeVar
from crewai.flow.flow_wrappers import FlowCondition
from crewai.flow.types import FlowMethodCallable
__all__ = ["FlowMethodDecorator", "FlowTrigger"]
F = TypeVar("F", bound=Callable[..., Any])
FlowTrigger: TypeAlias = str | FlowMethodCallable[..., Any] | FlowCondition
class FlowMethodDecorator(Protocol):
"""Decorator returned by Flow DSL authoring helpers.
The runtime wraps methods in FlowMethod subclasses, but the authoring
contract preserves the decorated method's static callable type.
"""
def __call__(self, func: F) -> F:
raise NotImplementedError

View File

@@ -0,0 +1,529 @@
from __future__ import annotations
from collections.abc import Sequence
import json
import logging
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 (
FlowConfigDefinition,
FlowDefinition,
FlowDefinitionCondition,
FlowDefinitionDiagnostic,
FlowHumanFeedbackDefinition,
FlowMethodDefinition,
FlowPersistenceDefinition,
FlowStateDefinition,
)
from crewai.flow.flow_wrappers import (
FlowMethod,
ListenMethod,
RouterMethod,
StartMethod,
)
from crewai.flow.types import FlowMethodName
P = ParamSpec("P")
R = TypeVar("R")
logger = logging.getLogger(__name__)
_FLOW_METHOD_DEFINITION_ATTR = "__flow_method_definition__"
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)
)
def _should_include_flow_method(flow_class: type, method: Any) -> bool:
if getattr(method, "__conversational_only__", False):
return bool(getattr(flow_class, "conversational", False))
return True
def _flow_method_names(values: Sequence[Any]) -> list[FlowMethodName]:
return [FlowMethodName(str(value)) for value in values]
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
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'")
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 _set_flow_method_definition(
wrapper: StartMethod[P, R] | ListenMethod[P, R] | RouterMethod[P, R],
definition: FlowMethodDefinition,
) -> None:
setattr(wrapper, _FLOW_METHOD_DEFINITION_ATTR, definition)
def _get_flow_method_definition(method: Any) -> FlowMethodDefinition | None:
definition = getattr(method, _FLOW_METHOD_DEFINITION_ATTR, None)
if isinstance(definition, FlowMethodDefinition):
return definition
if definition is not None:
return FlowMethodDefinition.model_validate(definition)
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)
except (TypeError, ValueError):
return False
return True
def _serialize_static_value(
value: Any,
diagnostics: list[FlowDefinitionDiagnostic],
path: str,
) -> Any:
if value is None or _is_json_serializable(value):
return value
to_config = getattr(value, "to_config_dict", None)
if callable(to_config):
try:
config = to_config()
if _is_json_serializable(config):
return config
except Exception:
logger.debug(
"Failed to serialize %s via to_config_dict().",
path,
exc_info=True,
)
if isinstance(value, BaseModel):
try:
data = value.model_dump(mode="json")
if _is_json_serializable(data):
return data
except Exception:
logger.debug(
"Failed to serialize %s via Pydantic model_dump().",
path,
exc_info=True,
)
ref = _object_ref(value)
diagnostics.append(
FlowDefinitionDiagnostic(
code="non_serializable_value",
path=path,
message=f"value is not fully serializable; preserved import reference {ref}",
)
)
return {"ref": ref}
def _state_ref(value: Any) -> str | None:
if value is None:
return None
target = value if isinstance(value, type) else type(value)
module = getattr(target, "__module__", None)
qualname = getattr(target, "__qualname__", None)
if module and qualname:
return f"{module}:{qualname}"
return None
def _build_state_definition(
flow_class: type,
diagnostics: list[FlowDefinitionDiagnostic],
) -> FlowStateDefinition | None:
from pydantic import BaseModel as PydanticBaseModel
state_value = getattr(flow_class, "_initial_state_t", None)
initial_state = getattr(flow_class, "initial_state", None)
if initial_state is not None:
state_value = initial_state
if state_value is None:
return None
if state_value is dict or isinstance(state_value, dict):
default = None
if isinstance(state_value, dict):
default = _serialize_static_value(state_value, diagnostics, "state.default")
return FlowStateDefinition(type="dict", default=default)
if isinstance(state_value, type) and issubclass(state_value, PydanticBaseModel):
return FlowStateDefinition(type="pydantic", ref=_state_ref(state_value))
if isinstance(state_value, PydanticBaseModel):
return FlowStateDefinition(
type="pydantic",
ref=_state_ref(state_value),
default=_serialize_static_value(state_value, diagnostics, "state.default"),
)
diagnostics.append(
FlowDefinitionDiagnostic(
code="unknown_state_type",
path="state",
message=f"could not serialize state type {_object_ref(state_value)}",
)
)
return FlowStateDefinition(type="unknown", ref=_state_ref(state_value))
def _build_config_definition(
flow_class: type,
diagnostics: list[FlowDefinitionDiagnostic],
) -> FlowConfigDefinition:
config_field_names = set(FlowConfigDefinition.model_fields)
field_defaults = {
name: field.default
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}"
)
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],
path: str,
) -> FlowHumanFeedbackDefinition | None:
config = getattr(method, "__human_feedback_config__", None)
if config is None:
return None
emit = getattr(config, "emit", None)
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"
),
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"
),
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:
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"
),
)
def _build_method_definition(
method: Any,
diagnostics: list[FlowDefinitionDiagnostic],
path: str,
) -> FlowMethodDefinition:
fragment = _get_flow_method_definition(method)
if fragment is None:
method_definition = _flow_method_definition_from_legacy_metadata(method)
else:
method_definition = fragment.model_copy(deep=True)
if bool(getattr(method, "__is_router__", False)):
method_definition.router = True
human_feedback = _build_human_feedback_definition(
method, diagnostics, f"{path}.human_feedback"
)
if human_feedback is not None:
method_definition.human_feedback = human_feedback
if human_feedback.emit:
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]
return method_definition
def _iter_flow_methods(flow_class: type) -> dict[str, Any]:
methods: dict[str, Any] = {}
for attr_name in dir(flow_class):
if attr_name.startswith("_"):
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
# function is preserved as the field default. Recover those so the
# definition still reflects every method once the class is built.
for field_name, field in getattr(flow_class, "model_fields", {}).items():
if field_name in methods or field_name.startswith("_"):
continue
default = getattr(field, "default", None)
if is_flow_method(default) and _should_include_flow_method(flow_class, default):
methods[field_name] = default
return methods
def _build_flow_definition_from_class(
flow_class: type,
namespace: dict[str, Any] | None = None,
) -> FlowDefinition:
diagnostics: list[FlowDefinitionDiagnostic] = []
methods: dict[str, FlowMethodDefinition] = {}
flow_methods = _iter_flow_methods(flow_class)
if namespace is not None:
for attr_name, attr_value in namespace.items():
if is_flow_method(attr_value) and _should_include_flow_method(
flow_class, attr_value
):
flow_methods[attr_name] = attr_value
for method_name, method in flow_methods.items():
methods[method_name] = _build_method_definition(
method, diagnostics, f"methods.{method_name}"
)
description = None
docstring = flow_class.__doc__
if docstring:
description = docstring.strip()
definition = FlowDefinition(
name=getattr(flow_class, "__name__", "Flow"),
description=description,
state=_build_state_definition(flow_class, diagnostics),
config=_build_config_definition(flow_class, diagnostics),
persist=_build_persistence_definition(flow_class, diagnostics, "persist"),
methods=methods,
diagnostics=diagnostics,
)
definition.diagnostics.extend(definition.validate_contract())
definition.log_diagnostics()
return definition
def build_flow_definition(
flow_class: type,
namespace: dict[str, Any] | None = None,
) -> 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

File diff suppressed because it is too large Load Diff

View File

@@ -18,3 +18,7 @@ current_flow_id: contextvars.ContextVar[str | None] = contextvars.ContextVar(
current_flow_method_name: contextvars.ContextVar[str] = contextvars.ContextVar(
"flow_method_name", default="unknown"
)
current_flow_name: contextvars.ContextVar[str | None] = contextvars.ContextVar(
"flow_name", default=None
)

View File

@@ -0,0 +1,280 @@
"""Flow Structure: the serializable, language-agnostic Flow contract.
Defines :class:`FlowDefinition` and its sub-models — a static, textual
(JSON/YAML) representation of a Flow: its methods, trigger conditions,
state, and configuration. It is independent of the Python authoring
layer that may have produced it and of the engine that runs it (see
``runtime``).
"""
from __future__ import annotations
import json
import logging
from typing import Any, Literal as TypingLiteral
from pydantic import BaseModel, ConfigDict, Field
import yaml
logger = logging.getLogger(__name__)
FlowDefinitionCondition = str | dict[str, Any]
__all__ = [
"FlowConfigDefinition",
"FlowDefinition",
"FlowDefinitionCondition",
"FlowDefinitionDiagnostic",
"FlowHumanFeedbackDefinition",
"FlowMethodDefinition",
"FlowPersistenceDefinition",
"FlowStateDefinition",
]
class FlowDefinitionDiagnostic(BaseModel):
"""A non-fatal Flow Definition build or validation diagnostic."""
code: str
message: str
severity: TypingLiteral["warning", "error"] = "warning"
path: str | None = None
class FlowStateDefinition(BaseModel):
"""Static description of a Flow state contract."""
type: TypingLiteral["dict", "pydantic", "unknown"] = "dict"
ref: str | None = None
default: Any = None
class FlowConfigDefinition(BaseModel):
"""Serializable Flow-level configuration."""
tracing: bool | None = None
stream: bool = False
memory: Any = None
input_provider: Any = None
suppress_flow_events: bool = False
max_method_calls: int = 100
class FlowPersistenceDefinition(BaseModel):
"""Static persistence configuration."""
enabled: bool = False
verbose: bool = False
persistence: Any = None
class FlowHumanFeedbackDefinition(BaseModel):
"""Static human feedback configuration."""
message: str
emit: list[str] | None = None
llm: Any = "gpt-4o-mini"
default_outcome: str | None = None
metadata: dict[str, Any] | None = None
provider: Any = None
learn: bool = False
learn_source: str = "hitl"
learn_strict: bool = False
class FlowMethodDefinition(BaseModel):
"""Static definition of one Flow method and its execution roles."""
start: bool | FlowDefinitionCondition | None = None
listen: FlowDefinitionCondition | None = None
router: bool = False
emit: list[str] | None = None
human_feedback: FlowHumanFeedbackDefinition | None = None
persist: FlowPersistenceDefinition | None = None
@property
def is_start(self) -> bool:
"""Whether this method is a start method.
A loaded contract may carry ``start: false`` to mark a non-start
method explicitly, so falsy values (``False``/``None``/empty string)
are treated as "not a start method".
"""
return bool(self.start)
class FlowDefinition(BaseModel):
"""Static, serializable definition of a Flow."""
model_config = ConfigDict(populate_by_name=True, arbitrary_types_allowed=True)
schema_: str = 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
methods: dict[str, FlowMethodDefinition] = Field(default_factory=dict)
diagnostics: list[FlowDefinitionDiagnostic] = Field(default_factory=list)
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")
def to_json(self, *, indent: int | None = 2, exclude_none: bool = True) -> str:
"""Serialize the definition to JSON."""
data = self.to_dict(exclude_none=exclude_none)
return json.dumps(data, indent=indent)
def to_yaml(self, *, exclude_none: bool = True) -> str:
"""Serialize the definition to YAML."""
return yaml.safe_dump(
self.to_dict(exclude_none=exclude_none),
sort_keys=False,
allow_unicode=True,
)
@classmethod
def from_dict(cls, data: dict[str, Any]) -> FlowDefinition:
"""Load a definition from a dictionary and attach diagnostics."""
serialized_diagnostics = _deserialize_diagnostics(data.get("diagnostics", []))
definition = cls.model_validate(data)
definition.diagnostics = _merge_diagnostics(
serialized_diagnostics, definition.validate_contract()
)
definition.log_diagnostics()
return definition
@classmethod
def from_json(cls, data: str) -> FlowDefinition:
"""Load a definition from JSON."""
return cls.from_dict(json.loads(data))
@classmethod
def from_yaml(cls, data: str) -> FlowDefinition:
"""Load a definition from YAML."""
loaded = yaml.safe_load(data) or {}
if not isinstance(loaded, dict):
raise ValueError("Flow definition YAML must contain a mapping")
return cls.from_dict(loaded)
@classmethod
def json_schema(cls) -> dict[str, Any]:
"""Return the JSON Schema for the Flow Definition contract."""
return cls.model_json_schema(by_alias=True)
def validate_contract(self) -> list[FlowDefinitionDiagnostic]:
"""Validate the static contract without rejecting dynamic routing."""
diagnostics: list[FlowDefinitionDiagnostic] = []
for method_name, method in self.methods.items():
path = f"methods.{method_name}"
if method.router and not method.is_start and method.listen is None:
diagnostics.append(
FlowDefinitionDiagnostic(
code="router_without_trigger",
severity="error",
path=path,
message="router: true requires either start or listen",
)
)
if method.emit and not method.router:
diagnostics.append(
FlowDefinitionDiagnostic(
code="emit_without_router",
path=f"{path}.emit",
message="emit is only used by routers to declare downstream events",
)
)
if method.human_feedback:
human_feedback_config = method.human_feedback
if human_feedback_config.emit and not human_feedback_config.llm:
diagnostics.append(
FlowDefinitionDiagnostic(
code="human_feedback_llm_required",
severity="error",
path=f"{path}.human_feedback.llm",
message="llm is required when human_feedback.emit is set",
)
)
if (
human_feedback_config.default_outcome is not None
and not human_feedback_config.emit
):
diagnostics.append(
FlowDefinitionDiagnostic(
code="human_feedback_default_requires_emit",
severity="error",
path=f"{path}.human_feedback.default_outcome",
message="default_outcome requires human_feedback.emit",
)
)
elif (
human_feedback_config.default_outcome is not None
and human_feedback_config.emit
):
if (
human_feedback_config.default_outcome
not in human_feedback_config.emit
):
diagnostics.append(
FlowDefinitionDiagnostic(
code="human_feedback_default_not_in_emit",
severity="error",
path=f"{path}.human_feedback.default_outcome",
message="default_outcome must be one of human_feedback.emit",
)
)
return diagnostics
def with_diagnostics(self) -> FlowDefinition:
"""Attach fresh diagnostics and return this definition."""
self.diagnostics = self.validate_contract()
self.log_diagnostics()
return self
def log_diagnostics(self) -> None:
"""Emit all attached diagnostics through the flow definition logger."""
_log_flow_definition_diagnostics(self.name, self.diagnostics)
def _log_flow_definition_diagnostics(
definition_name: str,
diagnostics: list[FlowDefinitionDiagnostic],
) -> None:
for diagnostic in diagnostics:
level = logging.ERROR if diagnostic.severity == "error" else logging.WARNING
path = f" at {diagnostic.path}" if diagnostic.path else ""
logger.log(
level,
"Flow definition diagnostic for %s%s [%s]: %s",
definition_name,
path,
diagnostic.code,
diagnostic.message,
)
def _deserialize_diagnostics(value: Any) -> list[FlowDefinitionDiagnostic]:
return [FlowDefinitionDiagnostic.model_validate(item) for item in value or []]
def _merge_diagnostics(
*diagnostic_groups: list[FlowDefinitionDiagnostic],
) -> list[FlowDefinitionDiagnostic]:
diagnostics: list[FlowDefinitionDiagnostic] = []
seen: set[tuple[str, str, str | None, str]] = set()
for group in diagnostic_groups:
for diagnostic in group:
key = (
diagnostic.code,
diagnostic.severity,
diagnostic.path,
diagnostic.message,
)
if key in seen:
continue
seen.add(key)
diagnostics.append(diagnostic)
return diagnostics

View File

@@ -1,592 +0,0 @@
"""Flow structure serializer for introspecting Flow classes.
This module provides the flow_structure() function that analyzes a Flow class
and returns a JSON-serializable dictionary describing its graph structure.
This is used by Studio UI to render a visual flow graph.
Example:
>>> from crewai.flow import Flow, start, listen
>>> from crewai.flow.flow_serializer import flow_structure
>>>
>>> class MyFlow(Flow):
... @start()
... def begin(self):
... return "started"
...
... @listen(begin)
... def process(self):
... return "done"
>>>
>>> structure = flow_structure(MyFlow)
>>> print(structure["name"])
'MyFlow'
"""
from __future__ import annotations
import inspect
import logging
import re
import textwrap
from typing import Any, TypedDict, get_args, get_origin
from pydantic import BaseModel
from pydantic_core import PydanticUndefined
from crewai.flow.flow_wrappers import (
FlowCondition,
FlowMethod,
ListenMethod,
RouterMethod,
StartMethod,
)
logger = logging.getLogger(__name__)
class MethodInfo(TypedDict, total=False):
"""Information about a single flow method.
Attributes:
name: The method name.
type: Method type - start, listen, router, or start_router.
trigger_methods: List of method names that trigger this method.
condition_type: 'AND' or 'OR' for composite conditions, null otherwise.
router_paths: For routers, the possible route names returned.
has_human_feedback: Whether the method has @human_feedback decorator.
has_crew: Whether the method body references a Crew.
"""
name: str
type: str
trigger_methods: list[str]
condition_type: str | None
router_paths: list[str]
has_human_feedback: bool
has_crew: bool
class EdgeInfo(TypedDict, total=False):
"""Information about an edge between flow methods.
Attributes:
from_method: Source method name.
to_method: Target method name.
edge_type: Type of edge - 'listen' or 'route'.
condition: Route name for router edges, null for listen edges.
"""
from_method: str
to_method: str
edge_type: str
condition: str | None
class StateFieldInfo(TypedDict, total=False):
"""Information about a state field.
Attributes:
name: Field name.
type: Field type as string.
default: Default value if any.
"""
name: str
type: str
default: Any
class StateSchemaInfo(TypedDict, total=False):
"""Information about the flow's state schema.
Attributes:
fields: List of field information.
"""
fields: list[StateFieldInfo]
class FlowStructureInfo(TypedDict, total=False):
"""Complete flow structure information.
Attributes:
name: Flow class name.
description: Flow docstring if available.
methods: List of method information.
edges: List of edge information.
state_schema: State schema if typed, null otherwise.
inputs: Detected flow inputs if available.
"""
name: str
description: str | None
methods: list[MethodInfo]
edges: list[EdgeInfo]
state_schema: StateSchemaInfo | None
inputs: list[str]
def _get_method_type(
method_name: str,
method: Any,
start_methods: list[str],
routers: set[str],
) -> str:
"""Determine the type of a flow method.
Args:
method_name: Name of the method.
method: The method object.
start_methods: List of start method names.
routers: Set of router method names.
Returns:
One of: 'start', 'listen', 'router', or 'start_router'.
"""
is_start = method_name in start_methods or getattr(
method, "__is_start_method__", False
)
is_router = method_name in routers or getattr(method, "__is_router__", False)
if is_start and is_router:
return "start_router"
if is_start:
return "start"
if is_router:
return "router"
return "listen"
def _has_human_feedback(method: Any) -> bool:
"""Check if a method has the @human_feedback decorator.
Args:
method: The method object to check.
Returns:
True if the method has __human_feedback_config__ attribute.
"""
return hasattr(method, "__human_feedback_config__")
def _detect_crew_reference(method: Any) -> bool:
"""Detect if a method body references a Crew.
Checks for patterns like:
- .crew() method calls
- Crew( instantiation
- References to Crew class in type hints
Note:
This is a **best-effort heuristic for UI hints**, not a guarantee.
Uses inspect.getsource + regex which can false-positive on comments
or string literals, and may fail on dynamically generated methods
or lambdas. Do not rely on this for correctness-critical logic.
Args:
method: The method object to inspect.
Returns:
True if crew reference detected, False otherwise.
"""
try:
func = method
if hasattr(method, "_meth"):
func = method._meth
elif hasattr(method, "__wrapped__"):
func = method.__wrapped__
source = inspect.getsource(func)
source = textwrap.dedent(source)
crew_patterns = [
r"\.crew\(\)", # .crew() method call
r"Crew\s*\(", # Crew( instantiation
r":\s*Crew\b", # Type hint with Crew
r"->.*Crew", # Return type hint with Crew
]
for pattern in crew_patterns:
if re.search(pattern, source):
return True
return False
except (OSError, TypeError):
return False
def _extract_trigger_methods(method: Any) -> tuple[list[str], str | None]:
"""Extract trigger methods and condition type from a method.
Args:
method: The method object to inspect.
Returns:
Tuple of (trigger_methods list, condition_type or None).
"""
trigger_methods: list[str] = []
condition_type: str | None = None
if hasattr(method, "__trigger_methods__") and method.__trigger_methods__:
trigger_methods = [str(m) for m in method.__trigger_methods__]
# For complex conditions (or_/and_ combinators), extract from __trigger_condition__
if (
not trigger_methods
and hasattr(method, "__trigger_condition__")
and method.__trigger_condition__
):
trigger_condition = method.__trigger_condition__
trigger_methods = _extract_all_methods_from_condition(trigger_condition)
if hasattr(method, "__condition_type__") and method.__condition_type__:
condition_type = str(method.__condition_type__)
return trigger_methods, condition_type
def _extract_router_paths(
method: Any, router_paths_registry: dict[str, list[str]]
) -> list[str]:
"""Extract router paths for a router method.
Args:
method: The method object.
router_paths_registry: The class-level _router_paths dict.
Returns:
List of possible route names.
"""
method_name = getattr(method, "__name__", "")
if hasattr(method, "__router_paths__") and method.__router_paths__:
return [str(p) for p in method.__router_paths__]
if method_name in router_paths_registry:
return [str(p) for p in router_paths_registry[method_name]]
return []
def _extract_all_methods_from_condition(
condition: str | FlowCondition | dict[str, Any] | list[Any],
) -> list[str]:
"""Extract all method names from a condition tree recursively.
Args:
condition: Can be a string, FlowCondition tuple, dict, or list.
Returns:
List of all method names found in the condition.
"""
if isinstance(condition, str):
return [condition]
if isinstance(condition, tuple) and len(condition) == 2:
# FlowCondition: (condition_type, methods_list)
_, methods = condition
if isinstance(methods, list):
result: list[str] = []
for m in methods:
result.extend(_extract_all_methods_from_condition(m))
return result
return []
if isinstance(condition, dict):
conditions_list = condition.get("conditions", [])
dict_methods: list[str] = []
for sub_cond in conditions_list:
dict_methods.extend(_extract_all_methods_from_condition(sub_cond))
return dict_methods
if isinstance(condition, list):
list_methods: list[str] = []
for item in condition:
list_methods.extend(_extract_all_methods_from_condition(item))
return list_methods
return []
def _generate_edges(
listeners: dict[str, tuple[str, list[str]] | FlowCondition],
routers: set[str],
router_paths: dict[str, list[str]],
all_methods: set[str],
) -> list[EdgeInfo]:
"""Generate edges from listeners and routers.
Args:
listeners: Map of listener_name -> (condition_type, trigger_methods) or FlowCondition.
routers: Set of router method names.
router_paths: Map of router_name -> possible return values.
all_methods: Set of all method names in the flow.
Returns:
List of EdgeInfo dictionaries.
"""
edges: list[EdgeInfo] = []
for listener_name, condition_data in listeners.items():
trigger_methods: list[str] = []
if isinstance(condition_data, tuple) and len(condition_data) == 2:
_condition_type, methods = condition_data
trigger_methods = [str(m) for m in methods]
elif isinstance(condition_data, dict):
trigger_methods = _extract_all_methods_from_condition(condition_data)
edges.extend(
EdgeInfo(
from_method=trigger,
to_method=listener_name,
edge_type="listen",
condition=None,
)
for trigger in trigger_methods
if trigger in all_methods
)
for router_name, paths in router_paths.items():
for path in paths:
for listener_name, condition_data in listeners.items():
path_triggers: list[str] = []
if isinstance(condition_data, tuple) and len(condition_data) == 2:
_, methods = condition_data
path_triggers = [str(m) for m in methods]
elif isinstance(condition_data, dict):
path_triggers = _extract_all_methods_from_condition(condition_data)
if str(path) in path_triggers:
edges.append(
EdgeInfo(
from_method=router_name,
to_method=listener_name,
edge_type="route",
condition=str(path),
)
)
return edges
def _extract_state_schema(flow_class: type) -> StateSchemaInfo | None:
"""Extract state schema from a Flow class.
Checks for:
- Generic type parameter (Flow[MyState])
- initial_state class attribute
Args:
flow_class: The Flow class to inspect.
Returns:
StateSchemaInfo if a Pydantic model state is detected, None otherwise.
"""
state_type: type | None = None
# _initial_state_t is set by Flow.__class_getitem__
if hasattr(flow_class, "_initial_state_t"):
state_type = flow_class._initial_state_t
if state_type is None and hasattr(flow_class, "initial_state"):
initial_state = flow_class.initial_state
if isinstance(initial_state, type) and issubclass(initial_state, BaseModel):
state_type = initial_state
elif isinstance(initial_state, BaseModel):
state_type = type(initial_state)
if state_type is None and hasattr(flow_class, "__orig_bases__"):
for base in flow_class.__orig_bases__:
origin = get_origin(base)
if origin is not None:
args = get_args(base)
if args:
candidate = args[0]
if isinstance(candidate, type) and issubclass(candidate, BaseModel):
state_type = candidate
break
if state_type is None or not issubclass(state_type, BaseModel):
return None
fields: list[StateFieldInfo] = []
try:
model_fields = state_type.model_fields
for field_name, field_info in model_fields.items():
field_type_str = "Any"
if field_info.annotation is not None:
field_type_str = str(field_info.annotation)
field_type_str = field_type_str.replace("typing.", "")
field_type_str = field_type_str.replace("<class '", "").replace(
"'>", ""
)
default_value = None
if (
field_info.default is not PydanticUndefined
and field_info.default is not None
and not callable(field_info.default)
):
try:
default_value = field_info.default
except Exception:
default_value = str(field_info.default)
fields.append(
StateFieldInfo(
name=field_name,
type=field_type_str,
default=default_value,
)
)
except Exception:
logger.debug(
"Failed to extract state schema fields for %s", flow_class.__name__
)
return StateSchemaInfo(fields=fields) if fields else None
def _detect_flow_inputs(flow_class: type) -> list[str]:
"""Detect flow input parameters.
Inspects the __init__ signature for custom parameters beyond standard Flow params.
Args:
flow_class: The Flow class to inspect.
Returns:
List of detected input names.
"""
inputs: list[str] = []
try:
init_method = flow_class.__init__ # type: ignore[misc]
init_sig = inspect.signature(init_method)
standard_params = {
"self",
"persistence",
"tracing",
"suppress_flow_events",
"max_method_calls",
"kwargs",
}
inputs.extend(
param_name
for param_name in init_sig.parameters
if param_name not in standard_params and not param_name.startswith("_")
)
except Exception:
logger.debug(
"Failed to detect inputs from __init__ for %s", flow_class.__name__
)
return inputs
def flow_structure(flow_class: type) -> FlowStructureInfo:
"""Introspect a Flow class and return its structure as a JSON-serializable dict.
This function analyzes a Flow CLASS (not instance) and returns complete
information about its graph structure including methods, edges, and state.
Args:
flow_class: A Flow class (not an instance) to introspect.
Returns:
FlowStructureInfo dictionary containing:
- name: Flow class name
- description: Docstring if available
- methods: List of method info dicts
- edges: List of edge info dicts
- state_schema: State schema if typed, None otherwise
- inputs: Detected input names
Raises:
TypeError: If flow_class is not a class.
Example:
>>> structure = flow_structure(MyFlow)
>>> print(structure["name"])
'MyFlow'
>>> for method in structure["methods"]:
... print(method["name"], method["type"])
"""
if not isinstance(flow_class, type):
raise TypeError(
f"flow_structure requires a Flow class, not an instance. "
f"Got {type(flow_class).__name__}"
)
start_methods: list[str] = getattr(flow_class, "_start_methods", [])
listeners: dict[str, Any] = getattr(flow_class, "_listeners", {})
routers: set[str] = getattr(flow_class, "_routers", set())
router_paths_registry: dict[str, list[str]] = getattr(
flow_class, "_router_paths", {}
)
methods: list[MethodInfo] = []
all_method_names: set[str] = set()
for attr_name in dir(flow_class):
if attr_name.startswith("_"):
continue
try:
attr = getattr(flow_class, attr_name)
except AttributeError:
continue
is_flow_method = (
isinstance(attr, (FlowMethod, StartMethod, ListenMethod, RouterMethod))
or hasattr(attr, "__is_flow_method__")
or hasattr(attr, "__is_start_method__")
or hasattr(attr, "__trigger_methods__")
or hasattr(attr, "__is_router__")
)
if not is_flow_method:
continue
all_method_names.add(attr_name)
method_type = _get_method_type(attr_name, attr, start_methods, routers)
trigger_methods, condition_type = _extract_trigger_methods(attr)
router_paths_list: list[str] = []
if method_type in ("router", "start_router"):
router_paths_list = _extract_router_paths(attr, router_paths_registry)
has_hf = _has_human_feedback(attr)
has_crew = _detect_crew_reference(attr)
method_info = MethodInfo(
name=attr_name,
type=method_type,
trigger_methods=trigger_methods,
condition_type=condition_type,
router_paths=router_paths_list,
has_human_feedback=has_hf,
has_crew=has_crew,
)
methods.append(method_info)
edges = _generate_edges(listeners, routers, router_paths_registry, all_method_names)
state_schema = _extract_state_schema(flow_class)
inputs = _detect_flow_inputs(flow_class)
description: str | None = None
if flow_class.__doc__:
description = flow_class.__doc__.strip()
return FlowStructureInfo(
name=flow_class.__name__,
description=description,
methods=methods,
edges=edges,
state_schema=state_schema,
inputs=inputs,
)

View File

@@ -18,6 +18,17 @@ R = TypeVar("R")
FlowConditionType: TypeAlias = Literal["OR", "AND"]
SimpleFlowCondition: TypeAlias = tuple[FlowConditionType, list[FlowMethodName]]
__all__ = [
"FlowCondition",
"FlowConditionType",
"FlowConditions",
"FlowMethod",
"ListenMethod",
"RouterMethod",
"SimpleFlowCondition",
"StartMethod",
]
class FlowCondition(TypedDict, total=False):
"""Type definition for flow trigger conditions.
@@ -26,16 +37,16 @@ class FlowCondition(TypedDict, total=False):
Attributes:
type: The type of the condition.
conditions: A list of conditions types.
methods: A list of methods.
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[FlowMethodName | FlowCondition]
methods: list[FlowMethodName]
conditions: Sequence[str | FlowMethodName | FlowCondition]
methods: Sequence[str | FlowMethodName]
FlowConditions: TypeAlias = list[FlowMethodName | FlowCondition]
FlowConditions: TypeAlias = Sequence[str | FlowMethodName | FlowCondition]
class FlowMethod(Generic[P, R]):
@@ -73,9 +84,12 @@ class FlowMethod(Generic[P, R]):
# Preserve flow-related attributes from wrapped method (e.g., from @human_feedback)
for attr in [
"__is_router__",
"__router_paths__",
"__router_emit__",
"__human_feedback_config__",
"_hf_llm", # Live LLM object for HITL resume
"__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))
@@ -165,3 +179,4 @@ class RouterMethod(FlowMethod[P, R]):
__trigger_methods__: list[FlowMethodName] | None = None
__condition_type__: FlowConditionType | None = None
__trigger_condition__: FlowCondition | None = None
__router_emit__: list[str] | None = None

View File

@@ -78,14 +78,10 @@ logger = logging.getLogger(__name__)
F = TypeVar("F", bound=Callable[..., Any])
__all__ = ["HumanFeedbackResult", "human_feedback"]
def _serialize_llm_for_context(llm: Any) -> dict[str, Any] | str | None:
"""Serialize a BaseLLM object to a dict preserving full config.
Delegates to ``llm.to_config_dict()`` when available (BaseLLM and
subclasses). Falls back to extracting the model string with provider
prefix for unknown LLM types.
"""
to_config: Callable[[], dict[str, Any]] | None = getattr(
llm, "to_config_dict", None
)
@@ -103,13 +99,6 @@ def _serialize_llm_for_context(llm: Any) -> dict[str, Any] | str | None:
def _deserialize_llm_from_context(
llm_data: dict[str, Any] | str | None,
) -> BaseLLM | None:
"""Reconstruct an LLM instance from serialized context data.
Handles both the new dict format (with full config) and the legacy
string format (model name only) for backward compatibility.
Returns a BaseLLM instance, or None if llm_data is None.
"""
if llm_data is None:
return None
@@ -202,12 +191,12 @@ class HumanFeedbackMethod(FlowMethod[Any, Any]):
Attributes:
__is_router__: True when emit is specified, enabling router behavior.
__router_paths__: List of possible outcomes when acting as a router.
__router_emit__: List of possible outcomes when acting as a router.
__human_feedback_config__: The HumanFeedbackConfig for this method.
"""
__is_router__: bool = False
__router_paths__: list[str] | None = None
__router_emit__: list[str] | None = None
__human_feedback_config__: HumanFeedbackConfig | None = None
@@ -232,7 +221,7 @@ class DistilledLessons(BaseModel):
)
def human_feedback(
def _build_human_feedback_runtime_decorator(
message: str,
emit: Sequence[str] | None = None,
llm: str | BaseLLM | None = "gpt-4o-mini",
@@ -243,102 +232,6 @@ def human_feedback(
learn_source: str = "hitl",
learn_strict: bool = False,
) -> Callable[[F], F]:
"""Decorator for Flow methods that require human feedback.
This decorator wraps a Flow method to:
1. Execute the method and capture its output
2. Display the output to the human with a feedback request
3. Collect the human's free-form feedback
4. Optionally collapse the feedback to a predefined outcome using an LLM
5. Store the result for access by downstream methods
When `emit` is specified, the decorator acts as a router, and the
collapsed outcome triggers the appropriate @listen decorated method.
Supports both synchronous (blocking) and asynchronous (non-blocking)
feedback collection through the `provider` parameter. If no provider
is specified, defaults to synchronous console input.
Args:
message: The message shown to the human when requesting feedback.
This should clearly explain what kind of feedback is expected.
emit: Optional sequence of outcome strings. When provided, the
human's feedback will be collapsed to one of these outcomes
using the specified LLM. The outcome then triggers @listen
methods that match.
llm: The LLM model to use for collapsing feedback to outcomes.
Required when emit is specified. Can be a model string
like "gpt-4o-mini" or a BaseLLM instance.
default_outcome: The outcome to use when the human provides no
feedback (empty input). Must be one of the emit values
if emit is specified.
metadata: Optional metadata for enterprise integrations. This is
passed through to the HumanFeedbackResult and can be used
by enterprise forks for features like Slack/Teams integration.
provider: Optional HumanFeedbackProvider for custom feedback
collection. Use this for async workflows that integrate with
external systems like Slack, Teams, or webhooks. When the
provider raises HumanFeedbackPending, the flow pauses and
can be resumed later with Flow.resume().
learn: Enable HITL learning. Recall past lessons to pre-review
output before the human sees it, and distill new lessons
from feedback after.
learn_source: Memory source tag for stored/recalled lessons.
learn_strict: When True, re-raise exceptions from the pre-review
and distillation steps instead of falling back to raw output.
Default False preserves graceful degradation; failures are
always logged via ``logger.warning`` regardless of this flag.
Returns:
A decorator function that wraps the method with human feedback
collection logic.
Raises:
ValueError: If emit is specified but llm is not provided.
ValueError: If default_outcome is specified but emit is not.
ValueError: If default_outcome is not in the emit list.
HumanFeedbackPending: When an async provider pauses execution.
Example:
Basic feedback without routing:
```python
@start()
@human_feedback(message="Please review this output:")
def generate_content(self):
return "Generated content..."
```
With routing based on feedback:
```python
@start()
@human_feedback(
message="Review and approve or reject:",
emit=["approved", "rejected", "needs_revision"],
llm="gpt-4o-mini",
default_outcome="needs_revision",
)
def review_document(self):
return document_content
@listen("approved")
def publish(self):
print(f"Publishing: {self.last_human_feedback.output}")
```
Async feedback with custom provider:
```python
@start()
@human_feedback(
message="Review this content:",
emit=["approved", "rejected"],
llm="gpt-4o-mini",
provider=SlackProvider(channel="#reviews"),
)
def generate_content(self):
return "Content to review..."
```
"""
if emit is not None:
if not llm:
raise ValueError(
@@ -356,20 +249,12 @@ def human_feedback(
raise ValueError("default_outcome requires emit to be specified.")
def decorator(func: F) -> F:
"""Inner decorator that wraps the function."""
def _get_hitl_prompt(key: str) -> str:
"""Read a HITL prompt from the i18n translations."""
from crewai.utilities.i18n import I18N_DEFAULT
return I18N_DEFAULT.slice(key)
def _resolve_llm_instance() -> Any:
"""Resolve the ``llm`` parameter to a BaseLLM instance.
Uses the SAME model specified in the decorator so pre-review,
distillation, and outcome collapsing all share one model.
"""
if llm is None:
from crewai.llm import LLM
@@ -383,7 +268,6 @@ def human_feedback(
def _pre_review_with_lessons(
flow_instance: Flow[Any], method_output: Any
) -> Any:
"""Recall past HITL lessons and use LLM to pre-review the output."""
try:
mem = flow_instance.memory
if mem is None:
@@ -431,7 +315,6 @@ def human_feedback(
def _distill_and_store_lessons(
flow_instance: Flow[Any], method_output: Any, raw_feedback: str
) -> None:
"""Extract generalizable lessons from output + feedback, store in memory."""
try:
mem = flow_instance.memory
if mem is None:
@@ -485,7 +368,6 @@ def human_feedback(
def _build_feedback_context(
flow_instance: Flow[Any], method_output: Any
) -> tuple[Any, Any]:
"""Build the PendingFeedbackContext and resolve the effective provider."""
from crewai.flow.async_feedback.types import PendingFeedbackContext
context = PendingFeedbackContext(
@@ -509,7 +391,6 @@ def human_feedback(
return context, effective_provider
def _request_feedback(flow_instance: Flow[Any], method_output: Any) -> str:
"""Request feedback using provider or default console (sync)."""
context, effective_provider = _build_feedback_context(
flow_instance, method_output
)
@@ -535,7 +416,6 @@ def human_feedback(
async def _request_feedback_async(
flow_instance: Flow[Any], method_output: Any
) -> str:
"""Request feedback, awaiting the provider if it returns a coroutine."""
context, effective_provider = _build_feedback_context(
flow_instance, method_output
)
@@ -559,7 +439,6 @@ def human_feedback(
method_output: Any,
raw_feedback: str,
) -> HumanFeedbackResult | str:
"""Process feedback and return result or outcome."""
collapsed_outcome: str | None = None
if not raw_feedback.strip():
@@ -655,42 +534,33 @@ def human_feedback(
wrapper = sync_wrapper
for attr in [
"__is_start_method__",
"__trigger_methods__",
"__condition_type__",
"__trigger_condition__",
"__is_flow_method__",
]:
if hasattr(func, attr):
setattr(wrapper, attr, getattr(func, attr))
# Create config inline to avoid race conditions
wrapper.__human_feedback_config__ = HumanFeedbackConfig(
message=message,
emit=emit,
llm=llm,
default_outcome=default_outcome,
metadata=metadata,
provider=provider,
learn=learn,
learn_source=learn_source,
learn_strict=learn_strict,
)
wrapper.__is_flow_method__ = True
if emit:
wrapper.__is_router__ = True
wrapper.__router_paths__ = list(emit)
# Stash the live LLM object for HITL resume to retrieve.
# When a flow pauses for human feedback and later resumes (possibly in a
# different process), the serialized context only contains a model string.
# By storing the original LLM on the wrapper, resume_async can retrieve
# the fully-configured LLM (with credentials, project, safety_settings, etc.)
# instead of creating a bare LLM from just the model string.
wrapper._hf_llm = llm
return wrapper # type: ignore[no-any-return]
return decorator
def human_feedback(
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]:
"""Compatibility import path for the Flow human-feedback DSL decorator."""
from crewai.flow.dsl._human_feedback import human_feedback as dsl_human_feedback
return dsl_human_feedback(
message=message,
emit=emit,
llm=llm,
default_outcome=default_outcome,
metadata=metadata,
provider=provider,
learn=learn,
learn_source=learn_source,
learn_strict=learn_strict,
)

View File

@@ -4,16 +4,9 @@ CrewAI Flow Persistence.
This module provides interfaces and implementations for persisting flow states.
"""
from typing import Any, TypeVar
from pydantic import BaseModel
from crewai.flow.persistence.base import FlowPersistence
from crewai.flow.persistence.decorators import persist
from crewai.flow.persistence.sqlite import SQLiteFlowPersistence
__all__ = ["FlowPersistence", "SQLiteFlowPersistence", "persist"]
StateType = TypeVar("StateType", bound=dict[str, Any] | BaseModel)
DictStateType = dict[str, Any]

View File

@@ -28,6 +28,7 @@ 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 crewai_core.printer import PRINTER
@@ -44,6 +45,8 @@ if TYPE_CHECKING:
logger = logging.getLogger(__name__)
T = TypeVar("T")
__all__ = ["PersistenceDecorator", "persist"]
LOG_MESSAGES: Final[dict[str, str]] = {
"save_state": "Saving flow state to memory for ID: {}",
"save_error": "Failed to persist state for method {}: {}",
@@ -52,6 +55,31 @@ LOG_MESSAGES: Final[dict[str, str]] = {
}
def _stamp_persistence_metadata(
target: Any,
persistence: FlowPersistence,
verbose: bool,
) -> None:
target.__flow_persistence_config__ = SimpleNamespace(
persistence=persistence,
verbose=verbose,
)
_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."""
@@ -163,10 +191,10 @@ def persist(
"""
def decorator(target: type | Callable[..., T]) -> type | Callable[..., T]:
"""Decorator that handles both class and method decoration."""
actual_persistence = persistence or SQLiteFlowPersistence()
if isinstance(target, type):
_stamp_persistence_metadata(target, actual_persistence, verbose)
original_init = target.__init__ # type: ignore[misc]
@functools.wraps(original_init)
@@ -211,12 +239,7 @@ def persist(
wrapped = create_async_wrapper(name, method)
for attr in [
"__is_start_method__",
"__trigger_methods__",
"__condition_type__",
"__is_router__",
]:
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]
@@ -239,12 +262,7 @@ def persist(
wrapped = create_sync_wrapper(name, method)
for attr in [
"__is_start_method__",
"__trigger_methods__",
"__condition_type__",
"__is_router__",
]:
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]
@@ -254,6 +272,7 @@ def persist(
return target
method = target
method.__is_flow_method__ = True # type: ignore[attr-defined]
_stamp_persistence_metadata(method, actual_persistence, verbose)
if asyncio.iscoroutinefunction(method):
@@ -271,15 +290,13 @@ def persist(
)
return cast(T, result)
for attr in [
"__is_start_method__",
"__trigger_methods__",
"__condition_type__",
"__is_router__",
]:
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)
@@ -290,15 +307,11 @@ def persist(
)
return result
for attr in [
"__is_start_method__",
"__trigger_methods__",
"__condition_type__",
"__is_router__",
]:
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)
return decorator

File diff suppressed because it is too large Load Diff

View File

@@ -22,7 +22,6 @@ P = ParamSpec("P")
R = TypeVar("R", covariant=True)
FlowMethodName = NewType("FlowMethodName", str)
FlowRouteName = NewType("FlowRouteName", str)
PendingListenerKey = NewType(
"PendingListenerKey",
Annotated[str, "nested flow conditions use 'listener_name:object_id'"],
@@ -32,7 +31,7 @@ PendingListenerKey = NewType(
class FlowMethodCallable(Protocol[P, R]):
"""A callable that can be used as a flow method reference."""
__name__: FlowMethodName
__name__: str
def __call__(self, *args: P.args, **kwargs: P.kwargs) -> R: ...

View File

@@ -1,954 +0,0 @@
"""
Utility functions for flow visualization and dependency analysis.
This module provides core functionality for analyzing and manipulating flow structures,
including node level calculation, ancestor tracking, and return value analysis.
Functions in this module are primarily used by the visualization system to create
accurate and informative flow diagrams.
Example
-------
>>> flow = Flow()
>>> node_levels = calculate_node_levels(flow)
>>> ancestors = build_ancestor_dict(flow)
"""
from __future__ import annotations
import ast
from collections import defaultdict, deque
from enum import Enum
import inspect
import textwrap
from typing import TYPE_CHECKING, Any
from crewai_core.printer import PRINTER
from typing_extensions import TypeIs
from crewai.flow.constants import AND_CONDITION, OR_CONDITION
from crewai.flow.flow_wrappers import (
FlowCondition,
FlowConditions,
FlowMethod,
SimpleFlowCondition,
)
from crewai.flow.types import FlowMethodCallable, FlowMethodName
if TYPE_CHECKING:
from crewai.flow.flow import Flow
def _extract_string_literals_from_type_annotation(
node: ast.expr,
function_globals: dict[str, Any] | None = None,
) -> list[str]:
"""Extract string literals from a type annotation AST node.
Handles:
- Literal["a", "b", "c"]
- "a" | "b" | "c" (union of string literals)
- Just "a" (single string constant annotation)
- Enum types with string values (e.g., class MyEnum(str, Enum))
Args:
node: The AST node representing a type annotation.
function_globals: The globals dict from the function, used to resolve Enum types.
Returns:
List of string literals found in the annotation.
"""
strings: list[str] = []
if isinstance(node, ast.Constant) and isinstance(node.value, str):
strings.append(node.value)
elif isinstance(node, ast.Name) and function_globals:
enum_class = function_globals.get(node.id)
if (
enum_class is not None
and isinstance(enum_class, type)
and issubclass(enum_class, Enum)
):
strings.extend(
member.value for member in enum_class if isinstance(member.value, str)
)
elif isinstance(node, ast.Attribute) and function_globals:
try:
if isinstance(node.value, ast.Name):
module = function_globals.get(node.value.id)
if module is not None:
enum_class = getattr(module, node.attr, None)
if (
enum_class is not None
and isinstance(enum_class, type)
and issubclass(enum_class, Enum)
):
strings.extend(
member.value
for member in enum_class
if isinstance(member.value, str)
)
except (AttributeError, TypeError):
pass
elif isinstance(node, ast.Subscript):
is_literal = False
if isinstance(node.value, ast.Name) and node.value.id == "Literal":
is_literal = True
elif isinstance(node.value, ast.Attribute) and node.value.attr == "Literal":
is_literal = True
if is_literal:
if isinstance(node.slice, ast.Tuple):
strings.extend(
elt.value
for elt in node.slice.elts
if isinstance(elt, ast.Constant) and isinstance(elt.value, str)
)
elif isinstance(node.slice, ast.Constant) and isinstance(
node.slice.value, str
):
strings.append(node.slice.value)
elif isinstance(node, ast.BinOp) and isinstance(node.op, ast.BitOr):
strings.extend(
_extract_string_literals_from_type_annotation(node.left, function_globals)
)
strings.extend(
_extract_string_literals_from_type_annotation(node.right, function_globals)
)
return strings
def _unwrap_function(function: Any) -> Any:
"""Unwrap a function to get the original function with correct globals.
Flow methods are wrapped by decorators like @router, @listen, etc.
This function unwraps them to get the original function which has
the correct __globals__ for resolving type annotations like Enums.
Args:
function: The potentially wrapped function.
Returns:
The unwrapped original function.
"""
if hasattr(function, "__func__"):
function = function.__func__
if hasattr(function, "__wrapped__"):
wrapped = function.__wrapped__
if hasattr(wrapped, "unwrap"):
return wrapped.unwrap()
return wrapped
return function
def get_possible_return_constants(
function: Any, verbose: bool = True
) -> list[str] | None:
"""Extract possible string return values from a function using AST parsing.
This function analyzes the source code of a router method to identify
all possible string values it might return. It handles:
- Return type annotations: -> Literal["a", "b"] or -> "a" | "b" | "c"
- Enum type annotations: -> MyEnum (extracts string values from members)
- Direct string literals: return "value"
- Variable assignments: x = "value"; return x
- Dictionary lookups: d = {"k": "v"}; return d[key]
- Conditional returns: return "a" if cond else "b"
- State attributes: return self.state.attr (infers from class context)
Args:
function: The function to analyze.
Returns:
List of possible string return values, or None if analysis fails.
"""
unwrapped = _unwrap_function(function)
try:
source = inspect.getsource(function)
except OSError:
return None
except Exception as e:
if verbose:
PRINTER.print(
f"Error retrieving source code for function {function.__name__}: {e}",
color="red",
)
return None
try:
source = textwrap.dedent(source)
code_ast = ast.parse(source)
except IndentationError as e:
if verbose:
PRINTER.print(
f"IndentationError while parsing source code of {function.__name__}: {e}",
color="red",
)
PRINTER.print(f"Source code:\n{source}", color="yellow")
return None
except SyntaxError as e:
if verbose:
PRINTER.print(
f"SyntaxError while parsing source code of {function.__name__}: {e}",
color="red",
)
PRINTER.print(f"Source code:\n{source}", color="yellow")
return None
except Exception as e:
if verbose:
PRINTER.print(
f"Unexpected error while parsing source code of {function.__name__}: {e}",
color="red",
)
PRINTER.print(f"Source code:\n{source}", color="yellow")
return None
return_values: set[str] = set()
function_globals = getattr(unwrapped, "__globals__", None)
for node in ast.walk(code_ast):
if isinstance(node, ast.FunctionDef):
if node.returns:
annotation_values = _extract_string_literals_from_type_annotation(
node.returns, function_globals
)
return_values.update(annotation_values)
break # Only process the first function definition
dict_definitions: dict[str, list[str]] = {}
variable_values: dict[str, list[str]] = {}
state_attribute_values: dict[str, list[str]] = {}
def extract_string_constants(node: ast.expr) -> list[str]:
"""Recursively extract all string constants from an AST node."""
strings: list[str] = []
if isinstance(node, ast.Constant) and isinstance(node.value, str):
strings.append(node.value)
elif isinstance(node, ast.IfExp):
strings.extend(extract_string_constants(node.body))
strings.extend(extract_string_constants(node.orelse))
elif isinstance(node, ast.Call):
if (
isinstance(node.func, ast.Attribute)
and node.func.attr == "get"
and len(node.args) >= 2
):
default_arg = node.args[1]
if isinstance(default_arg, ast.Constant) and isinstance(
default_arg.value, str
):
strings.append(default_arg.value)
return strings
class VariableAssignmentVisitor(ast.NodeVisitor):
def visit_Assign(self, node: ast.Assign) -> None:
if isinstance(node.value, ast.Dict) and len(node.targets) == 1:
target = node.targets[0]
if isinstance(target, ast.Name):
var_name = target.id
dict_values = [
val.value
for val in node.value.values
if isinstance(val, ast.Constant) and isinstance(val.value, str)
]
if dict_values:
dict_definitions[var_name] = dict_values
if len(node.targets) == 1:
target = node.targets[0]
var_name_alt: str | None = None
if isinstance(target, ast.Name):
var_name_alt = target.id
elif isinstance(target, ast.Attribute):
var_name_alt = f"{target.value.id if isinstance(target.value, ast.Name) else '_'}.{target.attr}"
if var_name_alt:
strings = extract_string_constants(node.value)
if strings:
variable_values[var_name_alt] = strings
self.generic_visit(node)
def get_attribute_chain(node: ast.expr) -> str | None:
"""Extract the full attribute chain from an AST node.
Examples:
self.state.run_type -> "self.state.run_type"
x.y.z -> "x.y.z"
simple_var -> "simple_var"
"""
if isinstance(node, ast.Name):
return node.id
if isinstance(node, ast.Attribute):
base = get_attribute_chain(node.value)
if base:
return f"{base}.{node.attr}"
return None
class ReturnVisitor(ast.NodeVisitor):
def visit_Return(self, node: ast.Return) -> None:
if (
node.value
and isinstance(node.value, ast.Constant)
and isinstance(node.value.value, str)
):
return_values.add(node.value.value)
elif node.value and isinstance(node.value, ast.Subscript):
if isinstance(node.value.value, ast.Name):
var_name_dict = node.value.value.id
if var_name_dict in dict_definitions:
for v in dict_definitions[var_name_dict]:
return_values.add(v)
elif node.value:
var_name_ret = get_attribute_chain(node.value)
if var_name_ret and var_name_ret in variable_values:
for v in variable_values[var_name_ret]:
return_values.add(v)
elif var_name_ret and var_name_ret in state_attribute_values:
for v in state_attribute_values[var_name_ret]:
return_values.add(v)
self.generic_visit(node)
def visit_If(self, node: ast.If) -> None:
self.generic_visit(node)
try:
if hasattr(function, "__self__"):
class_obj = function.__self__.__class__
elif hasattr(function, "__qualname__") and "." in function.__qualname__:
class_name = function.__qualname__.rsplit(".", 1)[0]
if hasattr(function, "__globals__"):
class_obj = function.__globals__.get(class_name)
else:
class_obj = None
else:
class_obj = None
if class_obj is not None:
try:
class_source = inspect.getsource(class_obj)
class_source = textwrap.dedent(class_source)
class_ast = ast.parse(class_source)
class StateAttributeVisitor(ast.NodeVisitor):
def visit_Compare(self, node: ast.Compare) -> None:
"""Find comparisons like: self.state.attr == "value" """
left_attr = get_attribute_chain(node.left)
if left_attr:
for comparator in node.comparators:
if isinstance(comparator, ast.Constant) and isinstance(
comparator.value, str
):
if left_attr not in state_attribute_values:
state_attribute_values[left_attr] = []
if (
comparator.value
not in state_attribute_values[left_attr]
):
state_attribute_values[left_attr].append(
comparator.value
)
for comparator in node.comparators:
right_attr = get_attribute_chain(comparator)
if (
right_attr
and isinstance(node.left, ast.Constant)
and isinstance(node.left.value, str)
):
if right_attr not in state_attribute_values:
state_attribute_values[right_attr] = []
if (
node.left.value
not in state_attribute_values[right_attr]
):
state_attribute_values[right_attr].append(
node.left.value
)
self.generic_visit(node)
StateAttributeVisitor().visit(class_ast)
except Exception as e:
if verbose:
PRINTER.print(
f"Could not analyze class context for {function.__name__}: {e}",
color="yellow",
)
except Exception as e:
if verbose:
PRINTER.print(
f"Could not introspect class for {function.__name__}: {e}",
color="yellow",
)
VariableAssignmentVisitor().visit(code_ast)
ReturnVisitor().visit(code_ast)
return list(return_values) if return_values else None
def calculate_node_levels(flow: Any) -> dict[str, int]:
"""
Calculate the hierarchical level of each node in the flow.
Performs a breadth-first traversal of the flow graph to assign levels
to nodes, starting with start methods at level 0.
Parameters
----------
flow : Any
The flow instance containing methods, listeners, and router configurations.
Returns
-------
Dict[str, int]
Dictionary mapping method names to their hierarchical levels.
Notes
-----
- Start methods are assigned level 0
- Each subsequent connected node is assigned level = parent_level + 1
- Handles both OR and AND conditions for listeners
- Processes router paths separately
"""
levels: dict[str, int] = {}
queue: deque[str] = deque()
visited: set[str] = set()
pending_and_listeners: dict[str, set[str]] = {}
for method_name, method in flow._methods.items():
if hasattr(method, "__is_start_method__"):
levels[method_name] = 0
queue.append(method_name)
or_listeners = defaultdict(list)
and_listeners = defaultdict(set)
for listener_name, condition_data in flow._listeners.items():
if isinstance(condition_data, tuple):
condition_type, trigger_methods = condition_data
elif isinstance(condition_data, dict):
trigger_methods = _extract_all_methods_recursive(condition_data, flow)
condition_type = condition_data.get("type", "OR")
else:
continue
if condition_type == "OR":
for method in trigger_methods:
or_listeners[method].append(listener_name)
elif condition_type == "AND":
and_listeners[listener_name] = set(trigger_methods)
while queue:
current = queue.popleft()
current_level = levels[current]
visited.add(current)
for listener_name in or_listeners[current]:
if listener_name not in levels or levels[listener_name] > current_level + 1:
levels[listener_name] = current_level + 1
if listener_name not in visited:
queue.append(listener_name)
for listener_name, required_methods in and_listeners.items():
if current in required_methods:
if listener_name not in pending_and_listeners:
pending_and_listeners[listener_name] = set()
pending_and_listeners[listener_name].add(current)
if required_methods == pending_and_listeners[listener_name]:
if (
listener_name not in levels
or levels[listener_name] > current_level + 1
):
levels[listener_name] = current_level + 1
if listener_name not in visited:
queue.append(listener_name)
process_router_paths(flow, current, current_level, levels, queue)
max_level = max(levels.values()) if levels else 0
for method_name in flow._methods:
if method_name not in levels:
levels[method_name] = max_level + 1
return levels
def count_outgoing_edges(flow: Any) -> dict[str, int]:
"""
Count the number of outgoing edges for each method in the flow.
Parameters
----------
flow : Any
The flow instance to analyze.
Returns
-------
Dict[str, int]
Dictionary mapping method names to their outgoing edge count.
"""
counts = {}
for method_name in flow._methods:
counts[method_name] = 0
for condition_data in flow._listeners.values():
if isinstance(condition_data, tuple):
_, trigger_methods = condition_data
elif isinstance(condition_data, dict):
trigger_methods = _extract_all_methods_recursive(condition_data, flow)
else:
continue
for trigger in trigger_methods:
if trigger in flow._methods:
counts[trigger] += 1
return counts
def build_ancestor_dict(flow: Any) -> dict[str, set[str]]:
"""
Build a dictionary mapping each node to its ancestor nodes.
Parameters
----------
flow : Any
The flow instance to analyze.
Returns
-------
Dict[str, Set[str]]
Dictionary mapping each node to a set of its ancestor nodes.
"""
ancestors: dict[str, set[str]] = {node: set() for node in flow._methods}
visited: set[str] = set()
for node in flow._methods:
if node not in visited:
dfs_ancestors(node, ancestors, visited, flow)
return ancestors
def dfs_ancestors(
node: str, ancestors: dict[str, set[str]], visited: set[str], flow: Any
) -> None:
"""
Perform depth-first search to build ancestor relationships.
Parameters
----------
node : str
Current node being processed.
ancestors : Dict[str, Set[str]]
Dictionary tracking ancestor relationships.
visited : Set[str]
Set of already visited nodes.
flow : Any
The flow instance being analyzed.
Notes
-----
This function modifies the ancestors dictionary in-place to build
the complete ancestor graph.
"""
if node in visited:
return
visited.add(node)
for listener_name, condition_data in flow._listeners.items():
if isinstance(condition_data, tuple):
_, trigger_methods = condition_data
elif isinstance(condition_data, dict):
trigger_methods = _extract_all_methods_recursive(condition_data, flow)
else:
continue
if node in trigger_methods:
ancestors[listener_name].add(node)
ancestors[listener_name].update(ancestors[node])
dfs_ancestors(listener_name, ancestors, visited, flow)
if node in flow._routers:
router_method_name = node
paths = flow._router_paths.get(router_method_name, [])
for path in paths:
for listener_name, condition_data in flow._listeners.items():
if isinstance(condition_data, tuple):
_, trigger_methods = condition_data
elif isinstance(condition_data, dict):
trigger_methods = _extract_all_methods_recursive(
condition_data, flow
)
else:
continue
if path in trigger_methods:
ancestors[listener_name].update(ancestors[node])
dfs_ancestors(listener_name, ancestors, visited, flow)
def is_ancestor(
node: str, ancestor_candidate: str, ancestors: dict[str, set[str]]
) -> bool:
"""
Check if one node is an ancestor of another.
Parameters
----------
node : str
The node to check ancestors for.
ancestor_candidate : str
The potential ancestor node.
ancestors : Dict[str, Set[str]]
Dictionary containing ancestor relationships.
Returns
-------
bool
True if ancestor_candidate is an ancestor of node, False otherwise.
"""
return ancestor_candidate in ancestors.get(node, set())
def build_parent_children_dict(flow: Any) -> dict[str, list[str]]:
"""
Build a dictionary mapping parent nodes to their children.
Parameters
----------
flow : Any
The flow instance to analyze.
Returns
-------
Dict[str, List[str]]
Dictionary mapping parent method names to lists of their child method names.
Notes
-----
- Maps listeners to their trigger methods
- Maps router methods to their paths and listeners
- Children lists are sorted for consistent ordering
"""
parent_children: dict[str, list[str]] = {}
for listener_name, condition_data in flow._listeners.items():
if isinstance(condition_data, tuple):
_, trigger_methods = condition_data
elif isinstance(condition_data, dict):
trigger_methods = _extract_all_methods_recursive(condition_data, flow)
else:
continue
for trigger in trigger_methods:
if trigger not in parent_children:
parent_children[trigger] = []
if listener_name not in parent_children[trigger]:
parent_children[trigger].append(listener_name)
for router_method_name, paths in flow._router_paths.items():
for path in paths:
for listener_name, condition_data in flow._listeners.items():
if isinstance(condition_data, tuple):
_, trigger_methods = condition_data
elif isinstance(condition_data, dict):
trigger_methods = _extract_all_methods_recursive(
condition_data, flow
)
else:
continue
if path in trigger_methods:
if router_method_name not in parent_children:
parent_children[router_method_name] = []
if listener_name not in parent_children[router_method_name]:
parent_children[router_method_name].append(listener_name)
return parent_children
def get_child_index(
parent: str, child: str, parent_children: dict[str, list[str]]
) -> int:
"""
Get the index of a child node in its parent's sorted children list.
Parameters
----------
parent : str
The parent node name.
child : str
The child node name to find the index for.
parent_children : Dict[str, List[str]]
Dictionary mapping parents to their children lists.
Returns
-------
int
Zero-based index of the child in its parent's sorted children list.
"""
children = parent_children.get(parent, [])
children.sort()
return children.index(child)
def process_router_paths(
flow: Any,
current: str,
current_level: int,
levels: dict[str, int],
queue: deque[str],
) -> None:
"""Handle the router connections for the current node."""
if current in flow._routers:
paths = flow._router_paths.get(current, [])
for path in paths:
for listener_name, condition_data in flow._listeners.items():
if isinstance(condition_data, tuple):
_condition_type, trigger_methods = condition_data
elif isinstance(condition_data, dict):
trigger_methods = _extract_all_methods_recursive(
condition_data, flow
)
else:
continue
if path in trigger_methods:
if (
listener_name not in levels
or levels[listener_name] > current_level + 1
):
levels[listener_name] = current_level + 1
queue.append(listener_name)
def is_flow_method_name(obj: Any) -> TypeIs[FlowMethodName]:
"""Check if the object is a valid flow method name.
Args:
obj: The object to check.
Returns:
True if the object is a valid flow method name, False otherwise.
"""
return isinstance(obj, str)
def is_flow_method_callable(obj: Any) -> TypeIs[FlowMethodCallable[..., Any]]:
"""Check if the object is a callable flow method.
Args:
obj: The object to check.
Returns:
True if the object is a callable, False otherwise.
"""
return callable(obj) and hasattr(obj, "__name__")
def is_flow_condition_list(obj: Any) -> TypeIs[FlowConditions]:
"""Check if the object is a list of FlowCondition dictionaries.
Args:
obj: The object to check.
Returns:
True if the object is a list of FlowCondition dictionaries, False otherwise.
"""
if not isinstance(obj, list):
return False
for item in obj:
if not (is_flow_method_name(item) or is_flow_condition_dict(item)):
return False
return True
def is_simple_flow_condition(obj: Any) -> TypeIs[SimpleFlowCondition]:
"""Check if the object is a simple flow condition tuple.
Args:
obj: The object to check.
Returns:
True if the object is a (condition_type, methods) tuple, False otherwise.
"""
return (
isinstance(obj, tuple)
and len(obj) == 2
and isinstance(obj[0], str)
and isinstance(obj[1], list)
)
def is_flow_method(obj: Any) -> TypeIs[FlowMethod[Any, Any]]:
"""Check if the object is a flow method wrapper.
Checks for attributes added by @start, @listen, or @router decorators.
Args:
obj: The object to check.
Returns:
True if the object is a FlowMethod subclass (StartMethod, ListenMethod, or RouterMethod).
"""
return (
hasattr(obj, "__is_flow_method__")
or hasattr(obj, "__is_start_method__")
or hasattr(obj, "__trigger_methods__")
or hasattr(obj, "__is_router__")
)
def is_flow_condition_dict(obj: Any) -> TypeIs[FlowCondition]:
"""Check if the object matches the FlowCondition structure.
Args:
obj: The object to check.
Returns:
True if the object is a valid FlowCondition dictionary, False otherwise.
"""
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 isinstance(conditions, list):
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 (isinstance(methods, list) 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 _extract_all_methods_recursive(
condition: str | FlowCondition | dict[str, Any] | list[Any],
flow: Flow[Any] | None = None,
) -> list[FlowMethodName]:
"""Extract ALL method names from a condition tree recursively.
This function recursively extracts every method name from the entire
condition tree, regardless of nesting. Used for visualization and debugging.
Note: Only extracts actual method names, not router output strings.
If flow is provided, it will filter out strings that are not in flow._methods.
Args:
condition: Can be a string, dict, or list
flow: Optional flow instance to filter out non-method strings
Returns:
List of all method names found in the condition tree
"""
if is_flow_method_name(condition):
if flow is not None:
if condition in flow._methods:
return [condition]
return []
return [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 _normalize_condition(
condition: FlowConditions | FlowCondition | FlowMethodName,
) -> FlowCondition:
"""Normalize a condition to standard format with 'conditions' key.
Args:
condition: Can be a string (method name), dict (condition), or list
Returns:
Normalized dict with 'type' and 'conditions' keys
"""
if is_flow_method_name(condition):
return {"type": OR_CONDITION, "conditions": [condition]}
if is_flow_condition_dict(condition):
if "conditions" in condition:
return condition
if "methods" in condition:
return {"type": condition["type"], "conditions": condition["methods"]}
return condition
if is_flow_condition_list(condition):
return {"type": OR_CONDITION, "conditions": condition}
raise ValueError(f"Cannot normalize condition: {condition}")
def _extract_all_methods(
condition: str | FlowCondition | dict[str, Any] | list[Any],
) -> list[FlowMethodName]:
"""Extract all method names from a condition (including nested).
For AND conditions, this extracts methods that must ALL complete.
For OR conditions nested inside AND, we don't extract their methods
since only one branch of the OR needs to trigger, not all methods.
This function is used for runtime execution logic, where we need to know
which methods must complete for AND conditions. For visualization purposes,
use _extract_all_methods_recursive() instead.
Args:
condition: Can be a string, dict, or list
Returns:
List of all method names in the condition tree that must complete
"""
if is_flow_method_name(condition):
return [condition]
if is_flow_condition_dict(condition):
normalized = _normalize_condition(condition)
cond_type = normalized.get("type", OR_CONDITION)
if cond_type == AND_CONDITION:
return [
sub_cond
for sub_cond in normalized.get("conditions", [])
if is_flow_method_name(sub_cond)
]
return []
if isinstance(condition, list):
methods = []
for item in condition:
methods.extend(_extract_all_methods(item))
return methods
return []

View File

@@ -684,7 +684,7 @@ class TriggeredByHighlighter {
});
} else {
for (const [nodeName, nodeInfo] of Object.entries(nodeData)) {
if (nodeInfo.router_paths && nodeInfo.router_paths.includes(triggerNodeId)) {
if (nodeInfo.router_events && nodeInfo.router_events.includes(triggerNodeId)) {
const routerNode = nodeName;
const routerEdges = allEdges.filter(
@@ -768,7 +768,7 @@ class TriggeredByHighlighter {
this.animateEdgeStyles();
}
highlightAllRouterPaths() {
highlightAllRouterEvents() {
this.clear();
if (!this.activeDrawerNodeId) {
@@ -792,10 +792,10 @@ class TriggeredByHighlighter {
routerEdges.forEach(edge => {
pathNodes.add(edge.to);
});
} else if (activeMetadata && activeMetadata.router_paths && activeMetadata.router_paths.length > 0) {
activeMetadata.router_paths.forEach(pathName => {
} else if (activeMetadata && activeMetadata.router_events && activeMetadata.router_events.length > 0) {
activeMetadata.router_events.forEach(eventName => {
for (const [nodeName, nodeInfo] of Object.entries(nodeData)) {
if (nodeInfo.router_paths && nodeInfo.router_paths.includes(pathName)) {
if (nodeInfo.router_events && nodeInfo.router_events.includes(eventName)) {
const edgeFromRouter = allEdges.filter(
(edge) => edge.from === nodeName && edge.to === this.activeDrawerNodeId && edge.dashes
);
@@ -821,6 +821,42 @@ class TriggeredByHighlighter {
this.animateEdgeStyles();
}
highlightRouterEvent(eventName) {
this.clear();
if (this.activeDrawerEdges && this.activeDrawerEdges.length > 0) {
this.resetEdgesToDefault(this.activeDrawerEdges);
this.activeDrawerEdges = [];
}
if (!this.activeDrawerNodeId || !eventName) {
return;
}
const routerEdges = this.edges.get().filter(
(edge) =>
edge.from === this.activeDrawerNodeId &&
edge.dashes &&
edge.label === eventName,
);
if (routerEdges.length === 0) {
return;
}
const pathNodes = new Set([this.activeDrawerNodeId]);
routerEdges.forEach((edge) => {
pathNodes.add(edge.from);
pathNodes.add(edge.to);
});
this.highlightedNodes = Array.from(pathNodes);
this.highlightedEdges = routerEdges.map((e) => e.id);
this.animateNodeOpacity();
this.animateEdgeStyles();
}
highlightTriggeredBy(triggerNodeId) {
this.clear();
@@ -892,8 +928,8 @@ class TriggeredByHighlighter {
) {
for (const [nodeName, nodeInfo] of Object.entries(nodeData)) {
if (
nodeInfo.router_paths &&
nodeInfo.router_paths.includes(triggerNodeId)
nodeInfo.router_events &&
nodeInfo.router_events.includes(triggerNodeId)
) {
const routerNode = nodeName;
@@ -1501,7 +1537,7 @@ class DrawerManager {
const activeMetadata = nodeData[activeNodeId];
if (activeMetadata && activeMetadata.trigger_methods && activeMetadata.trigger_methods.includes(triggerNodeId)) {
for (const [nodeName, nodeInfo] of Object.entries(nodeData)) {
if (nodeInfo.router_paths && nodeInfo.router_paths.includes(triggerNodeId)) {
if (nodeInfo.router_events && nodeInfo.router_events.includes(triggerNodeId)) {
const routerEdges = allEdges.filter(
(edge) => edge.from === nodeName && edge.dashes
);
@@ -1660,16 +1696,16 @@ class DrawerManager {
`;
}
if (metadata.router_paths && metadata.router_paths.length > 0) {
const uniqueRouterPaths = [...new Set(metadata.router_paths)];
const routerPathsJson = JSON.stringify(uniqueRouterPaths).replace(/"/g, '&quot;');
if (metadata.router_events && metadata.router_events.length > 0) {
const uniqueRouterEvents = [...new Set(metadata.router_events)];
const routerEventsJson = JSON.stringify(uniqueRouterEvents).replace(/"/g, '&quot;');
metadataContent += `
<div class="drawer-section">
<div class="drawer-section-title router-paths-title" data-router-paths="${routerPathsJson}" style="cursor: pointer; display: inline-flex; align-items: center; gap: 4px;">
Router Paths <i data-lucide="chevron-down" style="width: 14px; height: 14px; color: var(--text-primary);"></i>
<div class="drawer-section-title router-events-title" data-router-events="${routerEventsJson}" style="cursor: pointer; display: inline-flex; align-items: center; gap: 4px;">
Router Events <i data-lucide="chevron-down" style="width: 14px; height: 14px; color: var(--text-primary);"></i>
</div>
<ul class="drawer-list">
${uniqueRouterPaths.map((p) => `<li><span class="drawer-code-link" data-node-id="${p}" style="color: {{ CREWAI_ORANGE }}; border-color: rgba(255,90,80,0.3);">${p}</span></li>`).join("")}
${uniqueRouterEvents.map((eventName) => `<li><span class="drawer-code-link" data-router-event="${eventName}" style="color: {{ CREWAI_ORANGE }}; border-color: rgba(255,90,80,0.3);">${eventName}</span></li>`).join("")}
</ul>
</div>
`;
@@ -1823,14 +1859,26 @@ class DrawerManager {
});
});
const routerPathsTitle = this.elements.content.querySelector(
".router-paths-title[data-router-paths]",
const routerEventLinks = this.elements.content.querySelectorAll(
".drawer-code-link[data-router-event]",
);
if (routerPathsTitle) {
routerPathsTitle.addEventListener("click", (e) => {
routerEventLinks.forEach((link) => {
link.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
this.triggeredByHighlighter.highlightAllRouterPaths();
const routerEvent = link.getAttribute("data-router-event");
this.triggeredByHighlighter.highlightRouterEvent(routerEvent);
});
});
const routerEventsTitle = this.elements.content.querySelector(
".router-events-title[data-router-events]",
);
if (routerEventsTitle) {
routerEventsTitle.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
this.triggeredByHighlighter.highlightAllRouterEvents();
});
}
}

View File

@@ -1,131 +1,118 @@
"""Flow structure builder for analyzing Flow execution."""
"""Flow structure builder for definition-only Flow visualization."""
from __future__ import annotations
from collections import defaultdict
import inspect
import logging
from typing import TYPE_CHECKING, Any
from typing import TYPE_CHECKING, Any, cast
from crewai.flow.constants import AND_CONDITION, OR_CONDITION
from crewai.flow.flow_wrappers import FlowCondition
from crewai.flow.types import FlowMethodName
from crewai.flow.utils import (
is_flow_condition_dict,
is_simple_flow_condition,
from crewai.flow.flow_definition import (
FlowDefinition,
FlowDefinitionCondition,
FlowMethodDefinition,
)
from crewai.flow.visualization.schema import extract_method_signature
from crewai.flow.visualization.types import FlowStructure, NodeMetadata, StructureEdge
logger = logging.getLogger(__name__)
__all__ = ["build_flow_structure", "calculate_execution_paths"]
if TYPE_CHECKING:
from crewai.flow.flow import Flow
def _definition_condition_items(
condition: dict[str, Any],
key: str,
) -> list[FlowDefinitionCondition]:
return cast(list[FlowDefinitionCondition], condition.get(key, []))
def _definition_condition_parts(
condition: dict[str, Any],
) -> tuple[str, list[FlowDefinitionCondition]]:
if "and" in condition:
return AND_CONDITION, _definition_condition_items(condition, "and")
return OR_CONDITION, _definition_condition_items(condition, "or")
def _condition_type_from_definition(
condition: FlowDefinitionCondition | None,
) -> str | None:
if isinstance(condition, dict):
if "and" in condition:
return AND_CONDITION
if "or" in condition:
return OR_CONDITION
if isinstance(condition, str):
return OR_CONDITION
return None
def _runtime_condition_from_definition(
condition: FlowDefinitionCondition,
) -> str | dict[str, Any]:
if isinstance(condition, str):
return condition
condition_type, conditions = _definition_condition_parts(condition)
return {
"type": condition_type,
"conditions": [_runtime_condition_from_definition(item) for item in conditions],
}
def _method_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 _method_router_events(method_definition: FlowMethodDefinition) -> list[str]:
if method_definition.human_feedback and method_definition.human_feedback.emit:
return [str(event) for event in method_definition.human_feedback.emit]
if method_definition.emit:
return [str(event) for event in method_definition.emit]
return []
def _extract_direct_or_triggers(
condition: str | dict[str, Any] | list[Any] | FlowCondition,
condition: FlowDefinitionCondition,
) -> list[str]:
"""Extract direct OR-level trigger strings from a condition.
This function extracts strings that would directly trigger a listener,
meaning they appear at the top level of an OR condition. Strings nested
inside AND conditions are NOT considered direct triggers for router paths.
For example:
- or_("a", "b") -> ["a", "b"] (both are direct triggers)
- and_("a", "b") -> [] (neither are direct triggers, both required)
- or_(and_("a", "b"), "c") -> ["c"] (only "c" is a direct trigger)
Args:
condition: Can be a string, dict, or list.
Returns:
List of direct OR-level trigger strings.
"""
if isinstance(condition, str):
return [condition]
if isinstance(condition, dict):
cond_type = condition.get("type", OR_CONDITION)
conditions_list = condition.get("conditions", [])
if cond_type == OR_CONDITION:
strings = []
for sub_cond in conditions_list:
strings.extend(_extract_direct_or_triggers(sub_cond))
return strings
condition_type, conditions = _definition_condition_parts(condition)
if condition_type == AND_CONDITION:
return []
if isinstance(condition, list):
strings = []
for item in condition:
strings.extend(_extract_direct_or_triggers(item))
return strings
if callable(condition) and hasattr(condition, "__name__"):
return [condition.__name__]
return []
strings: list[str] = []
for sub_condition in conditions:
strings.extend(_extract_direct_or_triggers(sub_condition))
return strings
def _extract_all_trigger_names(
condition: str | dict[str, Any] | list[Any] | FlowCondition,
condition: FlowDefinitionCondition,
) -> list[str]:
"""Extract ALL trigger names from a condition for display purposes.
Unlike _extract_direct_or_triggers, this extracts ALL strings and method
names from the entire condition tree, including those nested in AND conditions.
This is used for displaying trigger information in the UI.
For example:
- or_("a", "b") -> ["a", "b"]
- and_("a", "b") -> ["a", "b"]
- or_(and_("a", method_6), method_4) -> ["a", "method_6", "method_4"]
Args:
condition: Can be a string, dict, or list.
Returns:
List of all trigger names found in the condition.
"""
if isinstance(condition, str):
return [condition]
if isinstance(condition, dict):
conditions_list = condition.get("conditions", [])
strings = []
for sub_cond in conditions_list:
strings.extend(_extract_all_trigger_names(sub_cond))
return strings
if isinstance(condition, list):
strings = []
for item in condition:
strings.extend(_extract_all_trigger_names(item))
return strings
if callable(condition) and hasattr(condition, "__name__"):
return [condition.__name__]
return []
_, conditions = _definition_condition_parts(condition)
strings: list[str] = []
for sub_condition in conditions:
strings.extend(_extract_all_trigger_names(sub_condition))
return strings
def _create_edges_from_condition(
condition: str | dict[str, Any] | list[Any] | FlowCondition,
condition: FlowDefinitionCondition,
target: str,
nodes: dict[str, NodeMetadata],
) -> list[StructureEdge]:
"""Create edges from a condition tree, preserving AND/OR semantics.
This function recursively processes the condition tree and creates edges
with the appropriate condition_type for each trigger.
For AND conditions, all triggers get edges with condition_type="AND".
For OR conditions, triggers get edges with condition_type="OR".
Args:
condition: The condition tree (string, dict, or list).
target: The target node name.
nodes: Dictionary of all nodes for validation.
Returns:
List of StructureEdge objects representing the condition.
"""
edges: list[StructureEdge] = []
if isinstance(condition, str):
@@ -135,24 +122,11 @@ def _create_edges_from_condition(
source=condition,
target=target,
condition_type=OR_CONDITION,
is_router_path=False,
)
)
elif callable(condition) and hasattr(condition, "__name__"):
method_name = condition.__name__
if method_name in nodes:
edges.append(
StructureEdge(
source=method_name,
target=target,
condition_type=OR_CONDITION,
is_router_path=False,
is_router_event=False,
)
)
elif isinstance(condition, dict):
cond_type = condition.get("type", OR_CONDITION)
conditions_list = condition.get("conditions", [])
cond_type, conditions = _definition_condition_parts(condition)
if cond_type == AND_CONDITION:
triggers = _extract_all_trigger_names(condition)
edges.extend(
@@ -160,277 +134,144 @@ def _create_edges_from_condition(
source=trigger,
target=target,
condition_type=AND_CONDITION,
is_router_path=False,
is_router_event=False,
)
for trigger in triggers
if trigger in nodes
)
else:
for sub_cond in conditions_list:
edges.extend(_create_edges_from_condition(sub_cond, target, nodes))
elif isinstance(condition, list):
for item in condition:
edges.extend(_create_edges_from_condition(item, target, nodes))
for sub_condition in conditions:
edges.extend(_create_edges_from_condition(sub_condition, target, nodes))
return edges
def build_flow_structure(flow: Flow[Any]) -> FlowStructure:
"""Build a structure representation of a Flow's execution.
def _flow_definition_from(
flow_or_definition: Flow[Any] | type[Flow[Any]] | FlowDefinition,
) -> FlowDefinition:
if isinstance(flow_or_definition, FlowDefinition):
return flow_or_definition
Args:
flow: Flow instance to analyze.
flow_class = (
flow_or_definition
if isinstance(flow_or_definition, type)
else type(flow_or_definition)
)
flow_definition = getattr(flow_class, "flow_definition", None)
if callable(flow_definition):
return cast(FlowDefinition, flow_definition())
raise TypeError(
"build_flow_structure requires a FlowDefinition or a Flow class/instance "
"with flow_definition()."
)
Returns:
Dictionary with nodes, edges, start_methods, and router_methods.
"""
def build_flow_structure(
flow_or_definition: Flow[Any] | type[Flow[Any]] | FlowDefinition,
) -> FlowStructure:
"""Build a visualization structure projection from a FlowDefinition."""
definition = _flow_definition_from(flow_or_definition)
nodes: dict[str, NodeMetadata] = {}
edges: list[StructureEdge] = []
start_methods: list[str] = []
router_methods: list[str] = []
for method_name, method in flow._methods.items():
node_metadata: NodeMetadata = {"type": "listen"}
for method_name, method_definition in definition.methods.items():
node_metadata: NodeMetadata = {"type": "listen", "class_name": definition.name}
if hasattr(method, "__is_start_method__") and method.__is_start_method__:
if method_definition.is_start:
node_metadata["type"] = "start"
start_methods.append(method_name)
if hasattr(method, "__is_router__") and method.__is_router__:
if method_definition.router:
node_metadata["is_router"] = True
node_metadata["type"] = "router"
router_methods.append(method_name)
router_events = _method_router_events(method_definition)
if router_events:
node_metadata["router_events"] = router_events
if method_name in flow._router_paths:
node_metadata["router_paths"] = [
str(p) for p in flow._router_paths[method_name]
]
if hasattr(method, "__trigger_methods__") and method.__trigger_methods__:
node_metadata["trigger_methods"] = [
str(m) for m in method.__trigger_methods__
]
if hasattr(method, "__condition_type__") and method.__condition_type__:
node_metadata["trigger_condition_type"] = method.__condition_type__
if "condition_type" not in node_metadata:
node_metadata["condition_type"] = method.__condition_type__
trigger_condition = _method_trigger_condition(method_definition)
condition_type = _condition_type_from_definition(trigger_condition)
if condition_type is not None and trigger_condition is not None:
node_metadata["trigger_condition_type"] = condition_type
node_metadata["condition_type"] = condition_type
extracted = _extract_all_trigger_names(trigger_condition)
if extracted:
node_metadata["trigger_methods"] = extracted
runtime_condition = _runtime_condition_from_definition(trigger_condition)
if isinstance(runtime_condition, dict):
node_metadata["trigger_condition"] = runtime_condition
if node_metadata.get("is_router") and "condition_type" not in node_metadata:
node_metadata["condition_type"] = "IF"
if (
hasattr(method, "__trigger_condition__")
and method.__trigger_condition__ is not None
):
node_metadata["trigger_condition"] = method.__trigger_condition__
if "trigger_methods" not in node_metadata:
extracted = _extract_all_trigger_names(method.__trigger_condition__)
if extracted:
node_metadata["trigger_methods"] = extracted
node_metadata["method_signature"] = extract_method_signature(
method, method_name
)
try:
source_code = inspect.getsource(method)
node_metadata["source_code"] = source_code
try:
source_lines, start_line = inspect.getsourcelines(method)
node_metadata["source_lines"] = source_lines
node_metadata["source_start_line"] = start_line
except (OSError, TypeError):
pass
try:
source_file = inspect.getsourcefile(method)
if source_file:
node_metadata["source_file"] = source_file
except (OSError, TypeError):
try:
class_file = inspect.getsourcefile(flow.__class__)
if class_file:
node_metadata["source_file"] = class_file
except (OSError, TypeError):
pass
except (OSError, TypeError):
pass
try:
class_obj = flow.__class__
if class_obj:
class_name = class_obj.__name__
bases = class_obj.__bases__
if bases:
base_strs = []
for base in bases:
if hasattr(base, "__name__"):
if hasattr(base, "__origin__"):
base_strs.append(str(base))
else:
base_strs.append(base.__name__)
else:
base_strs.append(str(base))
try:
source_lines = inspect.getsource(class_obj).split("\n")
_, class_start_line = inspect.getsourcelines(class_obj)
for idx, line in enumerate(source_lines):
stripped = line.strip()
if stripped.startswith("class ") and class_name in stripped:
class_signature = stripped.rstrip(":")
node_metadata["class_signature"] = class_signature
node_metadata["class_line_number"] = (
class_start_line + idx
)
break
except (OSError, TypeError):
class_signature = f"class {class_name}({', '.join(base_strs)})"
node_metadata["class_signature"] = class_signature
else:
class_signature = f"class {class_name}"
node_metadata["class_signature"] = class_signature
node_metadata["class_name"] = class_name
except (OSError, TypeError, AttributeError):
pass
nodes[method_name] = node_metadata
for listener_name, condition_data in flow._listeners.items():
if listener_name in router_methods:
for method_name, method_definition in definition.methods.items():
trigger_condition = _method_trigger_condition(method_definition)
if trigger_condition is None:
continue
if is_simple_flow_condition(condition_data):
cond_type, methods = condition_data
edges.extend(
StructureEdge(
source=str(trigger_method),
target=str(listener_name),
condition_type=cond_type,
is_router_path=False,
)
for trigger_method in methods
if str(trigger_method) in nodes
)
elif is_flow_condition_dict(condition_data):
edges.extend(
_create_edges_from_condition(condition_data, str(listener_name), nodes)
)
for method_name, node_metadata in nodes.items(): # type: ignore[assignment]
if node_metadata.get("is_router") and "trigger_methods" in node_metadata:
trigger_methods = node_metadata["trigger_methods"]
condition_type = node_metadata.get("trigger_condition_type", OR_CONDITION)
if "trigger_condition" in node_metadata:
edges.extend(
_create_edges_from_condition(
node_metadata["trigger_condition"], # type: ignore[arg-type]
method_name,
nodes,
)
)
else:
edges.extend(
StructureEdge(
source=trigger_method,
target=method_name,
condition_type=condition_type,
is_router_path=False,
)
for trigger_method in trigger_methods
if trigger_method in nodes
)
edges.extend(
_create_edges_from_condition(trigger_condition, method_name, nodes)
)
all_string_triggers: set[str] = set()
for condition_data in flow._listeners.values():
if is_simple_flow_condition(condition_data):
_, methods = condition_data
for m in methods:
if str(m) not in nodes: # It's a string trigger, not a method name
all_string_triggers.add(str(m))
elif is_flow_condition_dict(condition_data):
for trigger in _extract_direct_or_triggers(condition_data):
if trigger not in nodes:
all_string_triggers.add(trigger)
for method_definition in definition.methods.values():
trigger_condition = _method_trigger_condition(method_definition)
if trigger_condition is None:
continue
for trigger in _extract_direct_or_triggers(trigger_condition):
if trigger not in nodes:
all_string_triggers.add(trigger)
all_router_outputs: set[str] = set()
all_router_events: set[str] = set()
for router_method_name in router_methods:
if router_method_name not in flow._router_paths:
flow._router_paths[FlowMethodName(router_method_name)] = []
router_events = _method_router_events(definition.methods[router_method_name])
if router_events and router_method_name in nodes:
nodes[router_method_name]["router_events"] = router_events
all_router_events.update(router_events)
current_paths = flow._router_paths.get(FlowMethodName(router_method_name), [])
if current_paths and router_method_name in nodes:
nodes[router_method_name]["router_paths"] = [str(p) for p in current_paths]
all_router_outputs.update(str(p) for p in current_paths)
if not current_paths:
if not router_events:
logger.warning(
f"Could not determine return paths for router '{router_method_name}'. "
f"Add a return type annotation like "
f"'-> Literal[\"path1\", \"path2\"]' or '-> YourEnum' "
f"to enable proper flow visualization."
f"Router events for '{router_method_name}' are dynamic or not "
f"statically inferable; static visualization may omit event edges."
)
orphaned_triggers = all_string_triggers - all_router_outputs
orphaned_triggers = all_string_triggers - all_router_events
if orphaned_triggers:
logger.error(
f"Found listeners waiting for triggers {orphaned_triggers} "
f"but no router outputs these values explicitly. "
f"If your router returns a non-static value, check that your router has proper return type annotations."
logger.warning(
f"Static visualization could not match listener triggers "
f"{orphaned_triggers} to explicit router events. "
f"Dynamic router values may still trigger these listeners at runtime."
)
for router_method_name in router_methods:
if router_method_name not in flow._router_paths:
continue
router_events = _method_router_events(definition.methods[router_method_name])
router_paths = flow._router_paths[FlowMethodName(router_method_name)]
for path in router_paths:
for listener_name, condition_data in flow._listeners.items():
for event in router_events:
for listener_name, method_definition in definition.methods.items():
if listener_name == router_method_name:
continue
trigger_strings_from_cond: list[str] = []
trigger_condition = _method_trigger_condition(method_definition)
if trigger_condition is None:
continue
trigger_strings_from_cond = _extract_direct_or_triggers(
trigger_condition
)
if is_simple_flow_condition(condition_data):
_, methods = condition_data
trigger_strings_from_cond = [str(m) for m in methods]
elif is_flow_condition_dict(condition_data):
trigger_strings_from_cond = _extract_direct_or_triggers(
condition_data
)
if str(path) in trigger_strings_from_cond:
if str(event) in trigger_strings_from_cond:
edges.append(
StructureEdge(
source=router_method_name,
target=str(listener_name),
target=listener_name,
condition_type=None,
is_router_path=True,
router_path_label=str(path),
is_router_event=True,
router_event=str(event),
)
)
for start_method in flow._start_methods:
if start_method not in nodes and start_method in flow._methods:
method = flow._methods[start_method]
nodes[str(start_method)] = NodeMetadata(type="start")
if hasattr(method, "__trigger_methods__") and method.__trigger_methods__:
nodes[str(start_method)]["trigger_methods"] = [
str(m) for m in method.__trigger_methods__
]
if hasattr(method, "__condition_type__") and method.__condition_type__:
nodes[str(start_method)]["condition_type"] = method.__condition_type__
return FlowStructure(
nodes=nodes,
edges=edges,
@@ -453,7 +294,7 @@ def calculate_execution_paths(structure: FlowStructure) -> int:
graph[edge["source"]].append(
{
"target": edge["target"],
"is_router": edge["is_router_path"],
"is_router": edge["is_router_event"],
"condition": edge["condition_type"],
}
)
@@ -466,15 +307,6 @@ def calculate_execution_paths(structure: FlowStructure) -> int:
return 0
def count_paths_from(node: str, visited: set[str]) -> int:
"""Recursively count execution paths from a given node.
Args:
node: Node name to start counting from.
visited: Set of already visited nodes to prevent cycles.
Returns:
Number of execution paths from this node to terminal nodes.
"""
if node in terminal_nodes:
return 1

View File

@@ -309,18 +309,18 @@ def render_interactive(
</div>
""")
if metadata.get("router_paths"):
paths = metadata["router_paths"]
paths_items = "".join(
if metadata.get("router_events"):
router_events = metadata["router_events"]
event_items = "".join(
[
f'<li style="margin: 3px 0;"><code style="background: rgba(255,90,80,0.08); padding: 2px 6px; border-radius: 3px; font-size: 10px; color: {CREWAI_ORANGE}; border: 1px solid rgba(255,90,80,0.2); font-weight: 600;">{p}</code></li>'
for p in paths
for p in router_events
]
)
title_parts.append(f"""
<div>
<div style="font-size: 10px; text-transform: uppercase; color: {GRAY}; letter-spacing: 0.5px; margin-bottom: 4px; font-weight: 600;">Router Paths</div>
<ul style="list-style: none; padding: 0; margin: 0;">{paths_items}</ul>
<div style="font-size: 10px; text-transform: uppercase; color: {GRAY}; letter-spacing: 0.5px; margin-bottom: 4px; font-weight: 600;">Router Events</div>
<ul style="list-style: none; padding: 0; margin: 0;">{event_items}</ul>
</div>
""")
@@ -364,11 +364,11 @@ def render_interactive(
edge_color: str = GRAY
edge_dashes: bool | list[int] = False
if edge["is_router_path"]:
if edge["is_router_event"]:
edge_color = CREWAI_ORANGE
edge_dashes = [15, 10]
if "router_path_label" in edge:
edge_label = edge["router_path_label"]
if "router_event" in edge:
edge_label = edge["router_event"] or ""
elif edge["condition_type"] == "AND":
edge_label = "AND"
edge_color = CREWAI_ORANGE

View File

@@ -1,104 +0,0 @@
"""OpenAPI schema conversion utilities for Flow methods."""
import inspect
from typing import Any, get_args, get_origin
def type_to_openapi_schema(type_hint: Any) -> dict[str, Any]:
"""Convert Python type hint to OpenAPI schema.
Args:
type_hint: Python type hint to convert.
Returns:
OpenAPI schema dictionary.
"""
if type_hint is inspect.Parameter.empty:
return {}
if type_hint is None or type_hint is type(None):
return {"type": "null"}
if hasattr(type_hint, "__module__") and hasattr(type_hint, "__name__"):
if type_hint.__module__ == "typing" and type_hint.__name__ == "Any":
return {}
type_str = str(type_hint)
if type_str == "typing.Any" or type_str == "<class 'typing.Any'>":
return {}
if isinstance(type_hint, str):
return {"type": type_hint}
origin = get_origin(type_hint)
args = get_args(type_hint)
if type_hint is str:
return {"type": "string"}
if type_hint is int:
return {"type": "integer"}
if type_hint is float:
return {"type": "number"}
if type_hint is bool:
return {"type": "boolean"}
if type_hint is dict or origin is dict:
if args and len(args) > 1:
return {
"type": "object",
"additionalProperties": type_to_openapi_schema(args[1]),
}
return {"type": "object"}
if type_hint is list or origin is list:
if args:
return {"type": "array", "items": type_to_openapi_schema(args[0])}
return {"type": "array"}
if hasattr(type_hint, "__name__"):
return {"type": "object", "className": type_hint.__name__}
return {}
def extract_method_signature(method: Any, method_name: str) -> dict[str, Any]:
"""Extract method signature as OpenAPI schema with documentation.
Args:
method: Method to analyze.
method_name: Method name.
Returns:
Dictionary with operationId, parameters, returns, summary, and description.
"""
try:
sig = inspect.signature(method)
parameters = {}
for param_name, param in sig.parameters.items():
if param_name == "self":
continue
parameters[param_name] = type_to_openapi_schema(param.annotation)
return_type = type_to_openapi_schema(sig.return_annotation)
docstring = inspect.getdoc(method)
result: dict[str, Any] = {
"operationId": method_name,
"parameters": parameters,
"returns": return_type,
}
if docstring:
lines = docstring.strip().split("\n")
summary = lines[0].strip()
if summary:
result["summary"] = summary
if len(lines) > 1:
description = "\n".join(line.strip() for line in lines[1:]).strip()
if description:
result["description"] = description
return result
except Exception:
return {"operationId": method_name, "parameters": {}, "returns": {}}

View File

@@ -1,6 +1,11 @@
"""Type definitions for Flow structure visualization."""
from typing import Any, TypedDict
from typing import Any
from typing_extensions import Required, TypedDict
__all__ = ["FlowStructure", "NodeMetadata", "StructureEdge"]
class NodeMetadata(TypedDict, total=False):
@@ -8,19 +13,12 @@ class NodeMetadata(TypedDict, total=False):
type: str
is_router: bool
router_paths: list[str]
router_events: list[str]
condition_type: str | None
trigger_condition_type: str | None
trigger_methods: list[str]
trigger_condition: dict[str, Any] | None
method_signature: dict[str, Any]
source_code: str
source_lines: list[str]
source_start_line: int
source_file: str
class_signature: str
class_name: str
class_line_number: int
class StructureEdge(TypedDict, total=False):
@@ -29,8 +27,8 @@ class StructureEdge(TypedDict, total=False):
source: str
target: str
condition_type: str | None
is_router_path: bool
router_path_label: str
is_router_event: Required[bool]
router_event: str | None
class FlowStructure(TypedDict):

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