Compare commits

...

26 Commits

Author SHA1 Message Date
Joao Moura
0039376e7b fix(agent): warn when non-empty apps resolve to zero platform tools
When an agent declares `apps` (enterprise integrations) but they resolve to no
tools — unknown/misspelled app, app not connected, no actions, or a missing
CREWAI_PLATFORM_INTEGRATION_TOKEN — the agent silently runs without them and
can't do what those apps were for. CrewaiPlatformTools returns an empty list in
all those cases, and both resolution sites dropped it silently (agent kickoff
`if platform_tools:` and crew `_inject_platform_tools` merge), while the only
existing signal is a verbose-gated Logger.log.

Emit a loud UserWarning from Agent.get_platform_tools() — the shared choke point
for the agent-kickoff path (core.py:_prepare_kickoff), the crew task path
(crew.py:_inject_platform_tools), and declarative flows (agent actions route
through the same kickoff). The warning names the unresolved apps and the likely
causes.

Tests: warns on zero-tool resolution (direct + crew-prepare paths), stays quiet
when tools resolve.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RBYGqJHC2TMC6fonFziuuh
2026-07-07 00:15:40 -07:00
Lorenze Jay
799ab0f548 ensure we are writing version for flows (#6467)
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-07-06 18:47:27 -07:00
João Moura
2b56dab813 feat(cli): pull latest LLM models dynamically in the crew wizard (#6462)
* feat(cli): pull latest LLM models dynamically in the crew wizard

The JSON-crew creation wizard hardcoded a short model list per provider,
which goes stale as vendors ship new models every few weeks. Add a
three-tier resolver that prefers live data and falls back to a curated list.

- New `model_catalog.get_provider_models(provider, fallback)`:
  1. Vendor API (openai/anthropic/gemini/groq/cerebras/ollama) when the
     provider key is already in the environment — the only reliably-fresh
     source (real release dates / display names).
  2. Curated hardcoded fallback — hand-verified, used when no key is set.
  3. LiteLLM feed — only for providers with no curated list; it lags real
     releases, so it must never preempt the curated fallback.
- Rank by date/version parsed from model ids, humanize labels, 6h cache,
  short timeouts, silent fallback on any error.
- Wire it into `create_json_crew._select_model()` (picker only).
- Refresh the curated fallback against each vendor's official model docs
  (Anthropic Fable 5 / Opus 4.8 / Sonnet 5; OpenAI GPT-5.5(+pro); Gemini
  3.5 Flash / 3.1 Pro preview / 3 Flash preview; Groq Llama 4 / GPT-OSS).
- Tests for ranking, chat filtering, caching, and the tier order (17 tests).

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

* fix(cli): address model_catalog review findings

- Key the Ollama catalog cache by its base URL so a changed OLLAMA_API_BASE /
  API_BASE no longer serves the previous host's models for up to the TTL.
- Negatively cache the curated fallback after a failed/empty fetch (short
  _NEGATIVE_TTL) so the picker doesn't repeat a timeout-prone vendor/LiteLLM
  request on every call — most impactful for a down local Ollama server.
- Guard _read_catalog_cache / _write_catalog_cache against a non-dict cache
  root (corrupt JSON array no longer raises AttributeError).
- Replace the two empty `except OSError: pass` blocks with
  contextlib.suppress(OSError) plus an explanatory comment (CodeQL empty-except).
- Tests: negative cache, base-keyed Ollama cache, corrupt-cache no-crash (20 total).

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

* fix(cli): guard null litellm_provider and paginate Gemini models

- _from_litellm: coerce a present-but-null `litellm_provider` before string
  ops so it's skipped instead of raising AttributeError (keeps the documented
  "never raises" contract).
- _fetch_gemini: walk models.list pages via nextPageToken (bounded to 10) —
  the API is paginated and not guaranteed newest-first, so a single page could
  drop models the ranking should consider.
- Tests for both (22 total).

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

* ci: ignore nltk PYSEC-2026-597 in pip-audit (no fix, not reachable)

pip-audit newly flags nltk 3.9.4 for PYSEC-2026-597 (CVE-2026-12243), a path
traversal via percent-encoded `..%2f` in nltk.data.load()/find(). It affects
all nltk versions <=3.9.4 with no patched release, so it can't be resolved by a
version bump — same situation as the already-ignored PYSEC-2026-97.

nltk is a transitive dependency (unstructured[local-inference, all-docs] in
crewai-tools) used for text tokenization; we never pass untrusted resource
URLs/paths to nltk.data, so the traversal is not reachable. Add it to the
curated --ignore-vuln list with a justification, matching the existing pattern.

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

* fix(cli): address second Cursor review round on model_catalog

- Cache ignores new API keys: include API-key presence in the cache key
  (`<provider>#key|#nokey`), so a key added after a no-key/negative-cached
  lookup triggers a fresh live fetch instead of serving the stale fallback.
- Bad LiteLLM cache crashes picker: `_from_litellm` now requires a dict from
  `_load_litellm_data` (a non-mapping JSON root is skipped, not `.items()`'d).
- Stale LiteLLM refetch loop: memoize the feed load once per process
  (`_litellm_memo` + `_reset_litellm_memo` test hook) so repeated uncurated-
  provider lookups don't each re-attempt a timed download when offline.
- Tests: new-key bypass, corrupt-litellm-cache no-crash, one-fetch-per-process
  (25 total).

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

* fix(cli): keep partial Gemini results on later-page fetch error

_fetch_gemini paginates; a network/HTTP error on page 2+ previously raised out
through _from_vendor, discarding models already parsed from earlier pages and
forcing the curated fallback. Catch per-page fetch errors and return the
partial set instead (a first-page failure still yields an empty list -> fallback).
Test: test_vendor_gemini_keeps_partial_on_later_page_error (26 total).

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

* fix(cli): don't let an invalid fresh LiteLLM cache block download

_fetch_litellm_data treated any truthy JSON root in a fresh provider_cache.json
as the feed and returned it, so a non-mapping root (e.g. a JSON array) was
memoized and the tier never re-downloaded until the file aged out — leaving
uncurated providers with an empty picker despite a recoverable cache. Only
short-circuit on a usable dict; otherwise fall through to the download.
Test renamed to test_invalid_litellm_cache_falls_through_to_download (asserts
recovery via refetch).

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

* fix(cli): honor OLLAMA_HOST and treat empty vendor list as authoritative

- Ollama host mismatch: _ollama_base now also reads OLLAMA_HOST (the Ollama
  runtime convention) after OLLAMA_API_BASE/API_BASE, normalizing a scheme-less
  value (e.g. "127.0.0.1:11434" -> "http://127.0.0.1:11434"), so users who set
  only OLLAMA_HOST see models from the server the crew will actually use.
- Empty vendor list: a successful vendor fetch returning no models is now
  authoritative instead of collapsing to the curated fallback. A reachable
  Ollama with nothing installed yields an empty list (the picker prompts for
  manual entry) rather than offering hardcoded models that aren't installed; a
  failed fetch still falls back. _from_vendor now returns [] on success-empty
  and None only when the tier is unavailable.
- Tests: ollama empty->manual, ollama down->fallback, OLLAMA_HOST resolution
  (29 total).

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

* fix(cli): Gemini first-page failure falls back instead of showing empty

Interaction from the prior two fixes: _fetch_gemini swallowed a first-page
error and returned [], which _from_vendor reported as a successful-empty result
and get_provider_models treated as authoritative — skipping the curated Gemini
fallback and jumping to manual entry. Now a first-page failure (nothing gathered
yet) re-raises so _from_vendor returns None and the curated list is used; a
later-page failure still keeps the partial results.
Test: test_vendor_gemini_first_page_error_uses_fallback (30 total).

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

* fix(cli): Gemini GOOGLE_API_KEY + Ollama recovery not blocked by cache

- Gemini ignores GOOGLE_API_KEY: _PROVIDER_KEY_ENV now maps each provider to a
  tuple of accepted env vars; Gemini accepts GEMINI_API_KEY or GOOGLE_API_KEY
  (matching crewai's own Gemini provider). A new _provider_api_key() resolver
  is used by both _from_vendor and the cache key, so a GOOGLE_API_KEY user gets
  the live models API instead of the stale curated fallback.
- Ollama recovery blocked by cache: skip the negative (fallback) cache for
  Ollama. It's a local, fast-failing server, so re-probing each call is cheap
  and lets the picker pick up real installed models as soon as the server comes
  up, instead of serving suggestions for the negative-cache TTL.
- Tests: GOOGLE_API_KEY live fetch, Ollama down->recover (32 total).

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

* style(cli): ruff format model_catalog.py

Add the blank line ruff format expects after _provider_api_key; no behavior
change. Fixes the lint-run `ruff format --check lib/` step.

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

* fix(cli): don't treat 'search' substring as a non-chat model marker

The 'search' entry in _NON_CHAT_MARKERS matched anywhere in a model id, dropping
legitimate completion models like gpt-4o-search-preview and anything containing
'research' (e.g. o3-deep-research, since 'search' is a substring). Remove it;
the remaining markers (embedding/audio/image/moderation/etc.) still filter
genuine non-chat models. Test: test_search_substring_not_treated_as_non_chat (33).

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

* Revert "ci: ignore nltk PYSEC-2026-597 in pip-audit"

Do not suppress an unpatched security advisory to make CI green. Remove
PYSEC-2026-597 from the pip-audit ignore list; leave the scan failing so it
keeps surfacing the nltk path traversal (CVE-2026-12243). This PR should not be
merged until nltk ships a fix (or the vulnerable transitive dep is otherwise
resolved).

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

* fix(cli): exclude fine-tuned models and checkpoints from the picker

With a live OPENAI_API_KEY, /v1/models returns the user's fine-tunes and
training checkpoints (ft:..., ...:ckpt-step-N). Their recent `created`
timestamps ranked them above the base models and filled every slot, so the
picker showed a wall of `ft:gpt-4o-mini-...:crewai::...` with mangled labels and
no foundation models at all. Skip fine-tunes/checkpoints in the OpenAI-shaped
fetcher so clean base models surface; a user who wants a fine-tune can still
enter it via the picker's "Other" option. Test:
test_openai_excludes_fine_tunes_and_checkpoints (34 total).

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

* fix(cli): cleaner model labels + filter Ollama non-chat models

Anthropic/Gemini already use vendor display names; OpenAI/Groq/Cerebras/Ollama
fall to _humanize for anything outside the curated map, which produced mediocre
labels ("GPT Oss 120b", "qwen3 32b", "Deepseek r1", "llama3.3:70b").

Improve _humanize:
- split on ':' too (Ollama tags: llama3.3:70b -> "Llama3.3 70B")
- uppercase size suffixes (70b -> 70B), acronyms OSS/IT, brand casing
  (DeepSeek, ChatGPT, QwQ)
- capitalize the leading letter of fused family+version tokens (qwen3 -> Qwen3)
  while preserving OpenAI o-series lowercase (o3, o1-mini)

Also fix _fetch_ollama: /api/tags lists everything installed, so filter
non-chat (embedding) and fine-tune entries the same way the other tiers do.

Tests: expanded test_humanize + test_ollama_excludes_embedding_models (35 total).

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Lorenze Jay <63378463+lorenzejay@users.noreply.github.com>
2026-07-06 20:49:52 -03:00
Lorenze Jay
e55e710df0 Implement message setup and feedback handling in AgentExecutor (#6465)
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
* Implement message setup and feedback handling in AgentExecutor

- Added  method to streamline message preparation for agent execution, allowing for integration with human input providers.
- Introduced  and  methods to manage the state during feedback processing.
- Enhanced  and  methods to re-run the executor flow using existing feedback messages.
- Updated tests to verify the new message setup and feedback handling functionality, ensuring compatibility with human input providers.

* dont commit runner

* Remove xfail marker from test_crew_train_success as training feedback migration to AgentExecutor is complete.

* fix runtype errors

* fix test

* revert

* mypy fix

* handled reset iterations
2026-07-06 15:28:37 -07:00
Mani
56edf1f95f Added client name header (#6413)
- added client_name header to the 4 tavily tools to classify incoming requests as 'crewai' requests.

- This is for internal analysis

Co-authored-by: Lorenze Jay <63378463+lorenzejay@users.noreply.github.com>
2026-07-06 14:20:59 -07:00
Vinicius Brasil
2b90117e88 Add repository agents to flow definitions (#6437)
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
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
Inline agent and crew actions can now use repository-backed agents
without duplicating role, goal, and backstory in each definition.

Examples:
* `agent.with.from_repository: support_specialist`
* `crew.with.agents.researcher.from_repository: researcher`

`PlusAPI.get_agent` now uses the shared synchronous request path so
project loaders can fetch repository agents without nested event loops.
2026-07-02 14:28:01 -07:00
Vinicius Brasil
24901cd4f6 Support templated Flow action inputs (#6426)
Flow action inputs now support `${...}` inside strings, not only
strings that are fully wrapped in one expression. This lets authored
flows use simple prompt-like values such as:

* `query: "News about ${state.topic}"`
* `input: "Ticket ${text(state, "ticket.id", "unknown")}"`
* `sources: ["${state.primary_source}", "archive-${state.topic}"]`

Whole-expression values still preserve their runtime type, so
`${state.limit}` remains a number and `${state.domains}` remains a list.
Mixed literal and expression strings render as text.

This removes the need to build labeled strings with CEL concatenation,
which was hard to read, easy to quote incorrectly in YAML, and a poor
fit for the Flow authoring skill examples.
2026-07-02 08:57:50 -07:00
Lorenze Jay
559a9c65c4 docs: snapshot and changelog for v1.15.2a2 (#6422)
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
Check Documentation Broken Links / Check broken links (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
Mark stale issues and pull requests / stale (push) Has been cancelled
2026-07-01 15:14:56 -07:00
Lorenze Jay
6244738d2a feat: bump versions to 1.15.2a2 (#6421) 2026-07-01 14:55:27 -07:00
Tiago Freire
8b197a7ca8 fix: include aiobotocore in the bedrock extra (#6419)
### Overview

`BedrockCompletion.acall()` (the async completion path used when a crew is kicked off asynchronously) requires `aiobotocore` to build its async client. The `bedrock` extra, however, only declared `boto3`. Crews configured with an AWS Bedrock model work fine under a synchronous `kickoff()`, since that path only needs `boto3`, but raise `NotImplementedError: Async support for AWS Bedrock requires aiobotocore` as soon as they're kicked off asynchronously, since `aiobotocore` was never installed.

The fix adds `aiobotocore` to the `bedrock` extra, so `crewai[bedrock]` installs both the sync (`boto3`) and async (`aiobotocore`) dependencies the native Bedrock provider needs. The lockfile is regenerated to match. The exception message is also corrected — it previously pointed to a `bedrock-async` extra that never existed in `pyproject.toml`.

### Changes

- `lib/crewai/pyproject.toml`: add `aiobotocore~=3.5.0` to the `bedrock` extra
- `uv.lock`: regenerated to reflect the updated `bedrock` extra
- `lib/crewai/src/crewai/llms/providers/bedrock/completion.py`: fix the install hint in the `NotImplementedError` message to reference the real `bedrock` extra instead of the nonexistent `bedrock-async`
2026-07-01 17:30:59 -04:00
Vinicius Brasil
f630c471cf Document flow agent options (#6420)
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
* Document flow agent options

Document and type inline Flow agent options so authored flows can set:
* `llm.model`, `llm.max_tokens`, and `llm.max_completion_tokens`
* `planning_config.max_attempts`
* `allow_delegation`
* `max_iter`
* `max_rpm`
* `max_execution_time` in seconds

Also tell the flow skill to omit optional fields unless needed.

* Potential fix for pull request finding 'Unused import'

Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>

---------

Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>
2026-07-01 14:08:44 -07:00
Vinicius Brasil
31a4a4e162 Squeeze AGENTS.md file (#6416) 2026-07-01 11:14:36 -07:00
Vinicius Brasil
1452ee2021 Add text helper to flow skill example (#6406)
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
2026-06-30 21:18:58 -07:00
Vinicius Brasil
629f5d537b Reject self-listening flow methods (#6405)
A method that listens to its own name can re-trigger itself or collide
with router events. Rejecting that definition keeps declarative and
Python-authored flows aligned before kickoff.
2026-06-30 19:42:01 -07:00
Vinicius Brasil
ba2dafdeda Add text helper for flow CEL prompts (#6404)
CEL string concatenation currently fails when prompt builders read
missing or null fields. This commit adds `text(root, "path", "default")`
custom CEL helper so prompt text can safely read nested state/output
values.
2026-06-30 16:45:39 -07:00
Lorenze Jay
b37505bcf9 Add streaming docs to the navigation (#6403) 2026-06-30 16:00:38 -07:00
Lorenze Jay
694881c7bf docs: snapshot and changelog for v1.15.2a1 (#6398)
Some checks failed
CodeQL Advanced / Analyze (actions) (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Check Documentation Broken Links / Check broken links (push) Has been cancelled
Vulnerability Scan / pip-audit (push) Has been cancelled
Build uv cache / build-cache (3.10) (push) Has been cancelled
Build uv cache / build-cache (3.11) (push) Has been cancelled
Build uv cache / build-cache (3.12) (push) Has been cancelled
Build uv cache / build-cache (3.13) (push) Has been cancelled
2026-06-30 13:17:53 -07:00
Lorenze Jay
958d8270db feat: bump versions to 1.15.2a1 (#6397) 2026-06-30 11:43:14 -07:00
Daniel Barreto
ba855bae2b feat: repoint template commands to crewAIInc-fde org (#6394)
Point `crewai template list`/`template add` at the crewAIInc-fde GitHub
org so the FDE template_* repos are listed and installed instead of the
crewAIInc ones.

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Lorenze Jay <63378463+lorenzejay@users.noreply.github.com>
2026-06-30 11:32:54 -07:00
Vinicius Brasil
c157199065 Support inline skill definitions (#6396)
* Support inline skill definitions

This commit adds inline skill loading without a need for a file. It also
DRYs the skill loading feature.

* Address code review suggestions
2026-06-30 11:22:05 -07:00
Lorenze Jay
8eed457e70 Define stream frame protocol for flows (#6391)
* Define stream frame protocol for flows

* Add direct LLM streaming helpers

* Unify flow streaming frame items

* Update flow streaming integration properties

* Drop stream frame debug runner example

* Address streaming contract review feedback

* Replay cached stream frame projections

* Remove stream frame version field

* Fix streaming contract docs link

* Preserve LLM instance state for stream events

* Address streaming review cleanup
2026-06-30 10:53:48 -07:00
Vinicius Brasil
04fec31f1e Type tool and app in CrewDefinition (#6395)
* Type tool and app in CrewDefinition

This commit fixes a bug in the CrewDefinition class where the tool and
app were not being added.

* Type mcps= parameter
2026-06-30 10:24:21 -07:00
Vinicius Brasil
1556dbea3e Add generated Flow Definition authoring skill (#6393)
* Add generated Flow Definition authoring skill

Generate a portable skill from the Flow Definition schema so agents can
author valid declarative flows with the same reference CrewAI uses to
validate them. New declarative flow projects now write this skill.

```python
from crewai.flow.flow_definition import FlowDefinition
skill = FlowDefinition.skill(skips=(), examples_format="yaml")
```

* `examples_format` accepts `"yaml"` or `"json"`.
* Supported skips: `conversational`, `non_linear_flows`, `each`, `hitl`, `persistence`, `config`, `expression_action`, `script_action`, `tool_action`

The generated skill includes authoring rules, a routed crew example, and
an API reference extracted from the Flow, action, state, agent, crew,
and task Pydantic schemas.

* Fix declarative flow scaffold without framework import

* Fix skipped expression action guidance

* Fix markdown links in skill
2026-06-30 09:13:33 -07:00
Lucas Gomide
e8dced8a2d docs: document Cost Limit rule type in Agent Control Plane (#6387)
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
2026-06-29 14:56:13 -03:00
Lucas Gomide
2b87098279 fix: cut docs version nav from Edge so new pages aren't dropped (#6349)
* fix: freeze docs version nav from Edge instead of previous release

The docs cut copied every Edge file into the new `docs/v<X.Y.Z>/`
snapshot but built that version's `docs.json` navigation by cloning the
previous frozen release and only rewriting path prefixes. Pages added to
Edge since the last release were therefore copied to disk yet never
linked in the version selector, which is why the v1.15.0 cut shipped
without the Datadog guide. `_build_new_entry` now clones the Edge nav
entry and rewrites `edge/<locale>/` to `v<new>/<locale>/`, so promoting
Edge to Latest carries every current page and nav restructuring.

* docs: link the v1.15.0 Datadog guide dropped during the cut

The v1.15.0 freeze copied `enterprise/guides/datadog` into the snapshot
for every locale but never linked it in `docs.json`, because the cut
cloned the v1.14.7 nav instead of Edge. This backfills the missing nav
reference in the `en`, `pt-BR`, `ko`, and `ar` v1.15.0 blocks so the
already-shipped page is reachable from the version selector. Pairs with
the `_build_new_entry` fix that prevents future cuts from dropping pages.

* docs: link the v1.15.1 Datadog guide dropped during the cut

The v1.15.1 cut ran before the freeze-from-Edge fix landed, so it
inherited the same bug as v1.15.0: `enterprise/guides/datadog` was
copied into the snapshot for every locale but never linked in
`docs.json`. This backfills the missing nav reference in the `en`,
`pt-BR`, `ko`, and `ar` v1.15.1 blocks so the page is reachable from the
version selector.
2026-06-29 10:03:26 -04:00
Lucas Gomide
4379c45804 docs: drop CREWAI_LOG_FORMAT references from Datadog guide (#6307)
JSON-formatted stdout is now the only supported log shape in CrewAI
Enterprise — the `CREWAI_LOG_FORMAT=json` opt-in env var is gone and
no longer needs to be configured in AMP. Removes the "Enabling JSON
output" section, the env-var setup step, the troubleshooting check,
and the `legacy text mode` comparison across the four locale copies
(`en`, `ko`, `pt-BR`, `ar`) of `docs/edge/<lang>/enterprise/guides/datadog.mdx`.
2026-06-29 09:04:49 -04:00
97 changed files with 7910 additions and 999 deletions

158
AGENTS.md
View File

@@ -1,142 +1,26 @@
# Docs contributor guide
# Agent Instructions for CrewAI OSS
The `docs/` directory is published at [docs.crewai.com](https://docs.crewai.com)
by [Mintlify](https://www.mintlify.com/). Mintlify watches `docs/docs.json`
and the MDX files referenced from it.
CrewAI is a Python based framework for building AI agents and agentic systems.
Follow these guidelines when contributing:
## TL;DR for editing docs
## Key Guidelines
- Edit MDX under `docs/edge/<lang>/...` (e.g. `docs/edge/en/concepts/agents.mdx`).
- Your change ships under the **Edge** version selector the moment it merges
to `main`. Edge follows `main` and is the channel for unreleased work.
- On release cut, the current Edge state is frozen into `docs/v<X.Y.Z>/` and
that snapshot becomes the new default version in the selector (tag:
`Latest`). Canonical URLs (`/<lang>/...`) auto-redirect to the new default.
- Never modify files under `docs/v*/`. Those are frozen release snapshots
and the `docs-snapshots` CI guard rejects writes. The only exception is a
release-cut PR (auto-generated by `devtools release` or the manual
`scripts/docs/freeze_current_edge.py` wrapper), which uses a
`[docs-freeze]` title prefix to opt out.
- Never delete or rename files under `docs/images/`. Images are append-only.
See [Images](#images) below.
1. Follow Python best practices and idiomatic patterns.
2. Maintain existing code structure and organization.
3. Write unit tests for new functionality focusing on behaivor and not
implementation.
4. Document public APIs and complex logic.
5. Suggest changes to the `docs/` folder when appropriate
6. Follow software principles such as DRY and YAGNI.
7. Keep diffs as minimal as possible.
## The version model
## Changing Docs
The site has one rolling channel (Edge) plus one frozen snapshot per
release.
```
docs/
edge/ <-- Edge sources (you edit here)
en/...
pt-BR/ ko/ ar/
enterprise-api.*.yaml
v1.14.7/ <-- frozen snapshot of v1.14.7
en/...
pt-BR/ ko/ ar/
enterprise-api.*.yaml
v1.14.6/...
...
images/ <-- shared, append-only
docs.json <-- Mintlify config: navigation + redirects
```
`docs/docs.json` lists one navigation block per version per language. Edge
points at `docs/edge/<lang>/...`; every other version points at its own
`docs/v<X.Y.Z>/<lang>/...` subtree. Mintlify scopes both the sidebar and the
in-site search to whichever version the reader selects, so picking
`v1.10.0` genuinely shows the v1.10.0 docs (and only those).
### URLs and canonical redirects
Each Mintlify version corresponds to its own URL prefix:
- Edge: `/edge/<lang>/<page>` (e.g. `/edge/en/concepts/agents`)
- Frozen: `/v<X.Y.Z>/<lang>/<page>` (e.g. `/v1.14.7/en/concepts/agents`)
External links to the old, unversioned `/<lang>/<page>` URLs would 404 under
this layout. To keep them working, `docs.json` ships wildcard redirects:
```jsonc
{ "source": "/en/:slug*", "destination": "/v1.14.7/en/:slug*", "permanent": false }
```
The release-cut step rewrites the destination on every release so canonical
`/<lang>/...` URLs always resolve to the latest stable docs.
## Lifecycle
1. **During development.** You add or edit pages under
`docs/edge/<lang>/...` in normal PRs. They land in Edge as soon as the PR
merges. Both `/edge/<lang>/<page>` and the version selector's `Edge` entry
reflect the change immediately.
2. **Release cut.** The release engineer runs `devtools release X.Y.Z`. As
part of that flow the CLI opens a `[docs-freeze]` PR that copies Edge into
`docs/v<X.Y.Z>/`, rewrites internal OpenAPI references, updates
`docs/docs.json` to make `v<X.Y.Z>` the new default + `Latest`, and rewires
the canonical-URL redirects to the new default. The PR must merge before
the tag and PyPI publish run.
3. **After release.** Edge keeps rolling. Patch fixes to the just-released
docs go into Edge and ship with the next release. We do not back-edit
frozen snapshots.
See [`RELEASING.md`](RELEASING.md) for the full release runbook.
## Images
Snapshots share a single `docs/images/` directory. If an image is deleted
or renamed, every frozen snapshot that referenced it breaks. So the rule
is:
- Adding new images is always fine.
- Deleting or renaming an existing image fails CI unless the PR is a
`[docs-freeze]` release-cut PR.
- If an asset is wrong, add a new file with a new name and reference the
new name in the Edge MDX (`docs/edge/<lang>/...`). Leave the old file
alone.
## Local preview
Install the Mintlify CLI and run from `docs/`:
```bash
npm i -g mintlify
mintlify dev
```
Use the version selector at the top of the rendered page to switch between
Edge and frozen versions.
To check links across every version:
```bash
mintlify broken-links
```
CI runs the broken-links check on every PR that touches `docs/**` via
[`.github/workflows/docs-broken-links.yml`](.github/workflows/docs-broken-links.yml).
## Scripts
- `scripts/docs/freeze_historical_versions.py` — one-time migration that
reconstructed `docs/v1.10.0/` through `docs/v1.14.7/` from git tags. You
should not need to run this again.
- `scripts/docs/prefix_version_paths.py` — one-time migration that switched
`docs/docs.json` to directory-based versioning, inserted Edge, and added
the canonical-URL redirects. You should not need to run this again.
- `scripts/docs/freeze_current_edge.py` — thin CLI wrapper around
`crewai_devtools.docs_versioning.freeze`. `devtools release` calls the
same module during its docs PR step; this script is the manual escape
hatch (e.g. retroactively freezing a forgotten release).
## CI guards
- [`.github/workflows/docs-snapshots.yml`](.github/workflows/docs-snapshots.yml)
enforces the two rules above (frozen snapshots immutable, images
append-only). Both checks accept the `[docs-freeze]` PR-title escape
hatch.
- [`.github/workflows/docs-broken-links.yml`](.github/workflows/docs-broken-links.yml)
runs `mintlify broken-links` against the whole site, so adding a new
page or moving a snapshot file that breaks a link will fail CI.
1. Edit MDX under `docs/edge/en/*` and reference it from `docs/docs.json` if
needed.
2. Do not modify files under `docs/v*/`. Those are frozen release snapshots
managed by devtools.
3. Do not delete or rename files under `docs/images/` as frozen snapshots
may reference them.
4. If you want to preview your changes locally, use `cd docs && mintlify dev`.
To check for broken links, run `cd docs && mintlify broken-links`.

View File

@@ -159,6 +159,7 @@
"edge/en/concepts/tasks",
"edge/en/concepts/crews",
"edge/en/concepts/flows",
"edge/en/concepts/streaming",
"edge/en/concepts/production-architecture",
"edge/en/concepts/knowledge",
"edge/en/concepts/skills",
@@ -364,6 +365,8 @@
"edge/en/learn/human-feedback-in-flows",
"edge/en/learn/kickoff-async",
"edge/en/learn/kickoff-for-each",
"edge/en/learn/streaming-runtime-contract",
"edge/en/learn/consuming-streams",
"edge/en/learn/llm-connections",
"edge/en/learn/litellm-removal-guide",
"edge/en/learn/multimodal-agents",
@@ -1053,6 +1056,7 @@
"v1.15.1/en/enterprise/guides/update-crew",
"v1.15.1/en/enterprise/guides/enable-crew-studio",
"v1.15.1/en/enterprise/guides/capture_telemetry_logs",
"v1.15.1/en/enterprise/guides/datadog",
"v1.15.1/en/enterprise/guides/azure-openai-setup",
"v1.15.1/en/enterprise/guides/vertex-ai-workload-identity-setup",
"v1.15.1/en/enterprise/guides/tool-repository",
@@ -1582,6 +1586,7 @@
"v1.15.0/en/enterprise/guides/update-crew",
"v1.15.0/en/enterprise/guides/enable-crew-studio",
"v1.15.0/en/enterprise/guides/capture_telemetry_logs",
"v1.15.0/en/enterprise/guides/datadog",
"v1.15.0/en/enterprise/guides/azure-openai-setup",
"v1.15.0/en/enterprise/guides/vertex-ai-workload-identity-setup",
"v1.15.0/en/enterprise/guides/tool-repository",
@@ -9585,6 +9590,7 @@
"edge/pt-BR/learn/human-feedback-in-flows",
"edge/pt-BR/learn/kickoff-async",
"edge/pt-BR/learn/kickoff-for-each",
"edge/pt-BR/learn/streaming-runtime-contract",
"edge/pt-BR/learn/llm-connections",
"edge/pt-BR/learn/multimodal-agents",
"edge/pt-BR/learn/replay-tasks-from-latest-crew-kickoff",
@@ -10233,6 +10239,7 @@
"v1.15.1/pt-BR/enterprise/guides/update-crew",
"v1.15.1/pt-BR/enterprise/guides/enable-crew-studio",
"v1.15.1/pt-BR/enterprise/guides/capture_telemetry_logs",
"v1.15.1/pt-BR/enterprise/guides/datadog",
"v1.15.1/pt-BR/enterprise/guides/azure-openai-setup",
"v1.15.1/pt-BR/enterprise/guides/tool-repository",
"v1.15.1/pt-BR/enterprise/guides/custom-mcp-server",
@@ -10739,6 +10746,7 @@
"v1.15.0/pt-BR/enterprise/guides/update-crew",
"v1.15.0/pt-BR/enterprise/guides/enable-crew-studio",
"v1.15.0/pt-BR/enterprise/guides/capture_telemetry_logs",
"v1.15.0/pt-BR/enterprise/guides/datadog",
"v1.15.0/pt-BR/enterprise/guides/azure-openai-setup",
"v1.15.0/pt-BR/enterprise/guides/tool-repository",
"v1.15.0/pt-BR/enterprise/guides/custom-mcp-server",
@@ -18473,6 +18481,7 @@
"edge/ko/learn/human-feedback-in-flows",
"edge/ko/learn/kickoff-async",
"edge/ko/learn/kickoff-for-each",
"edge/ko/learn/streaming-runtime-contract",
"edge/ko/learn/llm-connections",
"edge/ko/learn/multimodal-agents",
"edge/ko/learn/replay-tasks-from-latest-crew-kickoff",
@@ -19133,6 +19142,7 @@
"v1.15.1/ko/enterprise/guides/update-crew",
"v1.15.1/ko/enterprise/guides/enable-crew-studio",
"v1.15.1/ko/enterprise/guides/capture_telemetry_logs",
"v1.15.1/ko/enterprise/guides/datadog",
"v1.15.1/ko/enterprise/guides/azure-openai-setup",
"v1.15.1/ko/enterprise/guides/tool-repository",
"v1.15.1/ko/enterprise/guides/custom-mcp-server",
@@ -19651,6 +19661,7 @@
"v1.15.0/ko/enterprise/guides/update-crew",
"v1.15.0/ko/enterprise/guides/enable-crew-studio",
"v1.15.0/ko/enterprise/guides/capture_telemetry_logs",
"v1.15.0/ko/enterprise/guides/datadog",
"v1.15.0/ko/enterprise/guides/azure-openai-setup",
"v1.15.0/ko/enterprise/guides/tool-repository",
"v1.15.0/ko/enterprise/guides/custom-mcp-server",
@@ -27577,6 +27588,7 @@
"edge/ar/learn/human-feedback-in-flows",
"edge/ar/learn/kickoff-async",
"edge/ar/learn/kickoff-for-each",
"edge/ar/learn/streaming-runtime-contract",
"edge/ar/learn/llm-connections",
"edge/ar/learn/multimodal-agents",
"edge/ar/learn/replay-tasks-from-latest-crew-kickoff",
@@ -28237,6 +28249,7 @@
"v1.15.1/ar/enterprise/guides/update-crew",
"v1.15.1/ar/enterprise/guides/enable-crew-studio",
"v1.15.1/ar/enterprise/guides/capture_telemetry_logs",
"v1.15.1/ar/enterprise/guides/datadog",
"v1.15.1/ar/enterprise/guides/azure-openai-setup",
"v1.15.1/ar/enterprise/guides/tool-repository",
"v1.15.1/ar/enterprise/guides/custom-mcp-server",
@@ -28755,6 +28768,7 @@
"v1.15.0/ar/enterprise/guides/update-crew",
"v1.15.0/ar/enterprise/guides/enable-crew-studio",
"v1.15.0/ar/enterprise/guides/capture_telemetry_logs",
"v1.15.0/ar/enterprise/guides/datadog",
"v1.15.0/ar/enterprise/guides/azure-openai-setup",
"v1.15.0/ar/enterprise/guides/tool-repository",
"v1.15.0/ar/enterprise/guides/custom-mcp-server",

View File

@@ -4,6 +4,60 @@ description: "تحديثات المنتج والتحسينات وإصلاحات
icon: "clock"
mode: "wide"
---
<Update label="1 يوليو 2026">
## v1.15.2a2
[عرض الإصدار على GitHub](https://github.com/crewAIInc/crewAI/releases/tag/1.15.2a2)
## ما الذي تغير
### الميزات
- إضافة aiobotocore إلى الإضافات الأساسية
- توثيق خيارات وكيل التدفق
- إضافة مساعد نصي إلى مثال مهارة التدفق
- إضافة مساعد نصي لمطالب CEL الخاصة بالتدفق
- إضافة وثائق البث إلى التنقل
### إصلاحات الأخطاء
- رفض طرق التدفق ذات الاستماع الذاتي
### الوثائق
- تحديث اللقطة وسجل التغييرات للإصدار v1.15.2a1
- ضغط ملف AGENTS.md
## المساهمون
@akaKuruma, @github-code-quality[bot], @lorenzejay, @vinibrsl
</Update>
<Update label="30 يونيو 2026">
## v1.15.2a1
[عرض الإصدار على GitHub](https://github.com/crewAIInc/crewAI/releases/tag/1.15.2a1)
## ما الذي تغير
### الميزات
- إعادة توجيه أوامر القالب إلى منظمة crewAIInc-fde
- دعم تعريفات المهارات المضمنة
- تعريف بروتوكول إطار التدفق للتدفقات
- إضافة أداة النوع والتطبيق في CrewDefinition
- إضافة مهارة تأليف تعريف التدفق المُنشأ
### إصلاحات الأخطاء
- قطع تنقل إصدار الوثائق من Edge لمنع فقدان الصفحات الجديدة
### الوثائق
- توثيق نوع قاعدة حد التكلفة في وحدة التحكم الخاصة بالوكيل
- إزالة مراجع CREWAI_LOG_FORMAT من دليل Datadog
## المساهمون
@danielfsbarreto, @joaomdmoura, @lorenzejay, @lucasgomide, @vinibrsl
</Update>
<Update label="26 يونيو 2026">
## v1.15.1

View File

@@ -21,12 +21,11 @@ CrewAI supports two log-ingestion paths to Datadog — both are first-class and
<Tabs>
<Tab title="Datadog Agent">
The Datadog Agent runs alongside your CrewAI containers (typically as a DaemonSet on Kubernetes) and tails their stdout. With `CREWAI_LOG_FORMAT=json` set, each log event ships as a single billable line with structured attributes.
The Datadog Agent runs alongside your CrewAI containers (typically as a DaemonSet on Kubernetes) and tails their stdout. Each log event ships as a single billable line with structured attributes — see the [log schema reference](#log-schema-reference) for the full field contract.
**Setup:**
1. Run the Datadog Agent next to your CrewAI containers — see [Datadog's deployment docs](https://docs.datadoghq.com/agent/) for Kubernetes, ECS, or VM setup. Enable log collection (`logs_enabled: true`) and container log collection (`logs_config.container_collect_all: true`).
2. Set `CREWAI_LOG_FORMAT=json` as an **automation environment variable** in CrewAI AMP (open your automation → **Settings → Environment Variables**) so each log event is a single line instead of a multi-line traceback. AMP propagates the value to every container in the deployment (API + workers) — don't set it on the container or host directly. See [Enabling JSON output](#enabling-json-output) below for the AMP UI walkthrough and the [log schema reference](#log-schema-reference) for the full field contract.
3. Confirm logs arrive in Datadog Logs with the JSON fields parsed — see [Verify ingestion](#verify-ingestion).
2. Confirm logs arrive in Datadog Logs with the JSON fields parsed — see [Verify ingestion](#verify-ingestion).
**Pick this path if** you already operate Datadog Agents (e.g. for infrastructure metrics), or your log volume makes per-event ingestion cost a real concern — collapsing tracebacks into single events keeps Agent ingestion cheap at scale.
</Tab>
@@ -57,10 +56,10 @@ Either path lands the same structured facets in Datadog (`@automation_id`, `@kic
## Log schema reference
<Info>
This schema applies to the **Datadog Agent path** — stdout JSON logs produced when `CREWAI_LOG_FORMAT=json` is set. Logs delivered via the **Datadog OTLP intake** use OpenTelemetry attribute names and may differ; see [OpenTelemetry Export](./capture_telemetry_logs).
This schema applies to the **Datadog Agent path** — structured stdout JSON logs emitted by every CrewAI worker container. Logs delivered via the **Datadog OTLP intake** use OpenTelemetry attribute names and may differ; see [OpenTelemetry Export](./capture_telemetry_logs).
</Info>
When `CREWAI_LOG_FORMAT=json` is set, every log event is emitted as a **single JSON object per line** to stdout, with internal newlines escaped. The format is plain JSON — Datadog parses it natively, and the same payload is also consumable by Splunk, Loki, Elasticsearch, and CloudWatch without custom log pipelines.
Every log event is emitted as a **single JSON object per line** to stdout, with internal newlines escaped. The format is plain JSON — Datadog parses it natively, and the same payload is also consumable by Splunk, Loki, Elasticsearch, and CloudWatch without custom log pipelines.
### Why JSON output
@@ -79,20 +78,6 @@ When `CREWAI_LOG_FORMAT=json` is set, every log event is emitted as a **single J
</Card>
</CardGroup>
### Enabling JSON output
`CREWAI_LOG_FORMAT=json` must be set as an **automation environment variable** in CrewAI AMP — it is **not** a container, host, or Docker setting. Open your automation in AMP, click the **Settings** icon, and add the variable under the **Environment Variables** section. AMP applies the value to every container in the deployment (API + workers) on the next restart. See [Update Your Crew](./update-crew) for the full UI walkthrough with screenshots.
```shell
CREWAI_LOG_FORMAT=json
```
Restart the deployment to pick up the change. Every log line on stdout from that point on is a single JSON object.
<Note>
The default value is `text`, which preserves the legacy human-readable line format byte-for-byte. Setting any value other than `json` falls back to text mode. There is no migration step — the variable is read at process start and the format switches immediately.
</Note>
### Example events
A single info-level log inside an active automation kickoff:
@@ -135,7 +120,7 @@ An error with a Python exception is collapsed into a single event with the trace
}
```
The same error in legacy text mode would have produced ~25 separate log events (one per traceback line) — all of which the backend would bill and index individually.
Without JSON output, that same error would produce ~25 separate log events (one per traceback line) — all of which the backend would bill and index individually.
### Schema v1 fields
@@ -237,7 +222,7 @@ Open [Logs Explorer](https://app.datadoghq.com/logs) and run a query that matche
<Tab title="Datadog Agent">
Search `service:crewai* @schema:v1`. You should see structured logs with the JSON fields parsed into Datadog facets. Pick a recent event and verify it has `@automation_id`, `@kickoff_id`, `@execution_id`, `@crewai_version`, and (when running inside a span) `@trace_id` / `@span_id` populated.
If nothing appears, confirm `CREWAI_LOG_FORMAT=json` is set under your automation's **Environment Variables** in AMP, the deployment was restarted after the change, and the Datadog Agent is tailing container stdout.
If nothing appears, confirm the Datadog Agent is tailing container stdout and that the deployment is running a recent enough CrewAI Enterprise build.
</Tab>
<Tab title="Datadog OTLP intake">
Search `source:otlp service:crewai*`. OTLP attributes land with their OpenTelemetry names (`automation_id`, `crewai.kickoff.id`, etc.) rather than the stdout JSON keys, but they map to the same dashboard facets after [facet promotion](#prerequisite-promote-facets).
@@ -280,7 +265,7 @@ The `$service` template variable defaults to `*` and will catch every CrewAI dep
| All widgets show "No data" | Facets aren't promoted | Re-do the [Promote facets](#prerequisite-promote-facets) step. Datadog won't query against an un-promoted field. |
| Error Rate widget shows `NaN` | No executions in the time window | Either no traffic, or `@execution_id` isn't faceted. Expand the time range and re-check facets. |
| Throughput chart is flat at the same value | Logs aren't reaching Datadog | Search `service:crewai*` in Logs Explorer. If nothing shows, verify the Datadog Agent is running (Agent path) or the OTel collector endpoint is correct (OTLP path). |
| `crewai_version` shows fewer values than expected | Some containers predate the structured-logs work | The `crewai_version` field was added alongside JSON output. Older deployments running text mode (or older AMP builds) won't emit it. Upgrade those deployments to pick up the field. See the [log schema reference](#log-schema-reference) for the full field contract. |
| `crewai_version` shows fewer values than expected | Some containers predate the structured-logs work | The `crewai_version` field was added alongside JSON output. Older deployments (pre-structured-logs AMP builds) won't emit it. Upgrade those deployments to pick up the field. See the [log schema reference](#log-schema-reference) for the full field contract. |
| Template variables don't filter widgets | The widget's filter line doesn't reference the template variable | Edit the widget and confirm the search includes `$automation $version $service`. |
## Next steps

View File

@@ -11,7 +11,7 @@ mode: "wide"
## كيف يعمل بث التدفق
عند تفعيل البث في تدفق، يلتقط CrewAI ويبث المخرجات من أي أطقم أو استدعاءات LLM داخل التدفق. يقدم البث أجزاء منظمة تحتوي على المحتوى وسياق المهمة ومعلومات الوكيل مع تقدم التنفيذ.
عند تفعيل البث في تدفق، يلتقط CrewAI ويبث المخرجات من أي أطقم أو استدعاءات LLM أو أدوات أو أحداث دورة حياة داخل التدفق. يقدم البث عناصر `StreamFrame` مرتبة تحتوي على محتوى قابل للطباعة وبيانات حدث مهيكلة مع تقدم التنفيذ.
## تفعيل البث
@@ -52,7 +52,7 @@ class ResearchFlow(Flow):
## البث المتزامن
عند استدعاء `kickoff()` على تدفق مع تفعيل البث، يُرجع كائن `FlowStreamingOutput` يمكنك التكرار عليه:
عند استدعاء `kickoff()` على تدفق مع تفعيل البث، يُرجع جلسة stream تنتج عناصر `StreamFrame` مرتبة:
```python Code
flow = ResearchFlow()
@@ -60,44 +60,43 @@ flow = ResearchFlow()
# Start streaming execution
streaming = flow.kickoff()
# Iterate over chunks as they arrive
for chunk in streaming:
print(chunk.content, end="", flush=True)
# Iterate over stream items as they arrive
for item in streaming:
print(item.content, end="", flush=True)
# Access the final result after streaming completes
result = streaming.result
print(f"\n\nFinal output: {result}")
```
### معلومات جزء البث
### معلومات عنصر البث
يوفر كل جزء سياقاً حول مصدره في التدفق:
يوفر كل عنصر محتوى قابلاً للطباعة وبيانات حدث مهيكلة:
```python Code
streaming = flow.kickoff()
for chunk in streaming:
print(f"Agent: {chunk.agent_role}")
print(f"Task: {chunk.task_name}")
print(f"Content: {chunk.content}")
print(f"Type: {chunk.chunk_type}") # TEXT or TOOL_CALL
for item in streaming:
print(f"Channel: {item.channel}")
print(f"Type: {item.type}")
print(f"Content: {item.content}")
print(f"Event payload: {item.event}")
```
### الوصول إلى خصائص البث
يوفر كائن `FlowStreamingOutput` خصائص وطرق مفيدة:
توفر جلسة stream خصائص وطرق مفيدة:
```python Code
streaming = flow.kickoff()
# Iterate and collect chunks
for chunk in streaming:
print(chunk.content, end="", flush=True)
# Iterate and collect items
for item in streaming:
print(item.content, end="", flush=True)
# After iteration completes
print(f"\nCompleted: {streaming.is_completed}")
print(f"Full text: {streaming.get_full_text()}")
print(f"Total chunks: {len(streaming.chunks)}")
print(f"Total frames: {len(streaming.frames)}")
print(f"Final result: {streaming.result}")
```
@@ -114,9 +113,9 @@ async def stream_flow():
# Start async streaming
streaming = await flow.kickoff_async()
# Async iteration over chunks
async for chunk in streaming:
print(chunk.content, end="", flush=True)
# Async iteration over stream items
async for item in streaming:
print(item.content, end="", flush=True)
# Access final result
result = streaming.result
@@ -181,13 +180,14 @@ flow = MultiStepFlow()
streaming = flow.kickoff()
current_step = ""
for chunk in streaming:
for item in streaming:
# Track which flow step is executing
if chunk.task_name != current_step:
current_step = chunk.task_name
print(f"\n\n=== {chunk.task_name} ===\n")
step_name = item.event.get("method_name") or item.event.get("task_name")
if step_name and step_name != current_step:
current_step = step_name
print(f"\n\n=== {step_name} ===\n")
print(chunk.content, end="", flush=True)
print(item.content, end="", flush=True)
result = streaming.result
print(f"\n\nFinal analysis: {result}")
@@ -201,7 +201,6 @@ print(f"\n\nFinal analysis: {result}")
import asyncio
from crewai.flow.flow import Flow, listen, start
from crewai import Agent, Crew, Task
from crewai.types.streaming import StreamChunkType
class ResearchPipeline(Flow):
stream = True
@@ -254,33 +253,35 @@ async def run_with_dashboard():
current_agent = ""
current_task = ""
chunk_count = 0
frame_count = 0
async for chunk in streaming:
chunk_count += 1
async for item in streaming:
frame_count += 1
# Display phase transitions
if chunk.task_name != current_task:
current_task = chunk.task_name
current_agent = chunk.agent_role
task_name = item.event.get("task_name", "")
agent_role = item.event.get("agent_role", "")
if task_name and task_name != current_task:
current_task = task_name
current_agent = agent_role
print(f"\n\n📋 Phase: {current_task}")
print(f"👤 Agent: {current_agent}")
print("-" * 60)
# Display text output
if chunk.chunk_type == StreamChunkType.TEXT:
print(chunk.content, end="", flush=True)
if item.content:
print(item.content, end="", flush=True)
# Display tool usage
elif chunk.chunk_type == StreamChunkType.TOOL_CALL and chunk.tool_call:
print(f"\n🔧 Tool: {chunk.tool_call.tool_name}")
elif item.channel == "tools":
print(f"\n🔧 Tool event: {item.type}")
# Show completion summary
result = streaming.result
print(f"\n\n{'='*60}")
print("PIPELINE COMPLETE")
print(f"{'='*60}")
print(f"Total chunks: {chunk_count}")
print(f"Total frames: {frame_count}")
print(f"Final output length: {len(str(result))} characters")
asyncio.run(run_with_dashboard())
@@ -353,8 +354,8 @@ class StatefulStreamingFlow(Flow[AnalysisState]):
flow = StatefulStreamingFlow()
streaming = flow.kickoff(inputs={"topic": "quantum computing"})
for chunk in streaming:
print(chunk.content, end="", flush=True)
for item in streaming:
print(item.content, end="", flush=True)
result = streaming.result
print(f"\n\nFinal state:")
@@ -374,29 +375,29 @@ print(f"Insights length: {len(flow.state.insights)}")
- **تتبع التقدم**: إظهار المرحلة الحالية من سير العمل للمستخدمين
- **لوحات المعلومات الحية**: إنشاء واجهات مراقبة لتدفقات الإنتاج
## أنواع أجزاء البث
## قنوات إطارات البث
مثل بث الطاقم، يمكن أن تكون أجزاء التدفق من أنواع مختلفة:
ينتج بث التدفق عناصر `StreamFrame` عبر عدة قنوات:
### أجزاء TEXT
### إطارات LLM
محتوى نصي قياسي من استجابات LLM:
```python Code
for chunk in streaming:
if chunk.chunk_type == StreamChunkType.TEXT:
print(chunk.content, end="", flush=True)
for item in streaming:
if item.channel == "llm" and item.content:
print(item.content, end="", flush=True)
```
### أجزاء TOOL_CALL
### إطارات الأدوات
معلومات حول استدعاءات الأدوات داخل التدفق:
```python Code
for chunk in streaming:
if chunk.chunk_type == StreamChunkType.TOOL_CALL and chunk.tool_call:
print(f"\nTool: {chunk.tool_call.tool_name}")
print(f"Args: {chunk.tool_call.arguments}")
for item in streaming:
if item.channel == "tools":
print(f"\nTool event: {item.type}")
print(f"Payload: {item.event}")
```
## معالجة الأخطاء
@@ -408,8 +409,8 @@ flow = ResearchFlow()
streaming = flow.kickoff()
try:
for chunk in streaming:
print(chunk.content, end="", flush=True)
for item in streaming:
print(item.content, end="", flush=True)
result = streaming.result
print(f"\nSuccess! Result: {result}")
@@ -422,7 +423,7 @@ except Exception as e:
## الإلغاء وتنظيف الموارد
يدعم `FlowStreamingOutput` الإلغاء السلس بحيث يتوقف العمل الجاري فوراً عند انقطاع اتصال المستهلك.
تدعم جلسة stream الإلغاء السلس بحيث يتوقف العمل الجاري فوراً عند انقطاع اتصال المستهلك.
### مدير السياق غير المتزامن
@@ -430,8 +431,8 @@ except Exception as e:
streaming = await flow.kickoff_async()
async with streaming:
async for chunk in streaming:
print(chunk.content, end="", flush=True)
async for item in streaming:
print(item.content, end="", flush=True)
```
### الإلغاء الصريح
@@ -439,8 +440,8 @@ async with streaming:
```python Code
streaming = await flow.kickoff_async()
try:
async for chunk in streaming:
print(chunk.content, end="", flush=True)
async for item in streaming:
print(item.content, end="", flush=True)
finally:
await streaming.aclose() # غير متزامن
# streaming.close() # المكافئ المتزامن
@@ -451,10 +452,10 @@ finally:
## ملاحظات مهمة
- يفعّل البث تلقائياً بث LLM لأي أطقم مستخدمة داخل التدفق
- يجب التكرار عبر جميع الأجزاء قبل الوصول إلى خاصية `.result`
- يجب التكرار عبر جميع عناصر stream قبل الوصول إلى خاصية `.result`
- يعمل البث مع كل من حالة التدفق المنظمة وغير المنظمة
- يلتقط بث التدفق المخرجات من جميع الأطقم واستدعاءات LLM في التدفق
- يتضمن كل جزء سياقاً حول الوكيل والمهمة التي ولدته
- يتضمن كل إطار سياق حدث مهيكلاً مثل القناة والنوع والنطاق والحمولة
- يضيف البث حملاً ضئيلاً لتنفيذ التدفق
## الدمج مع تصور التدفق
@@ -468,8 +469,8 @@ flow.plot("research_flow") # Creates HTML visualization
# Run with streaming
streaming = flow.kickoff()
for chunk in streaming:
print(chunk.content, end="", flush=True)
for item in streaming:
print(item.content, end="", flush=True)
result = streaming.result
print(f"\nFlow complete! View structure at: research_flow.html")

View File

@@ -0,0 +1,194 @@
---
title: عقد بث وقت التشغيل
description: بث إطارات وقت تشغيل مرتبة من التدفقات واستدعاءات LLM المباشرة ودورات المحادثة.
icon: tower-broadcast
mode: "wide"
---
## نظرة عامة
يوفر CrewAI عقد بث قائمًا على الإطارات للأنظمة التي تحتاج إلى أكثر من أجزاء نصية بسيطة. يصدر العقد كائنات `StreamFrame` مرتبة لأحداث دورة حياة Flow، وتوكنات LLM المباشرة، ونشاط الأدوات، ورسائل المحادثة، والأحداث المخصصة.
استخدم هذه الواجهة عندما تبني واجهة مستخدم، أو جسر خدمة، أو تطبيق طرفية، أو وقت تشغيل نشر يحتاج إلى تدفق ثابت من الأحداث المهيكلة أثناء تشغيل Flow أو دورة محادثة أو استدعاء LLM مباشر.
## StreamFrame
لكل إطار نفس الغلاف:
```python
from crewai.types.streaming import StreamFrame
frame.id # معرف إطار فريد
frame.seq # ترتيب محلي للتنفيذ، عند توفره
frame.type # نوع الحدث المصدر، مثل "flow_started"
frame.channel # "llm", "flow", "tools", "messages", "lifecycle", or "custom"
frame.namespace # نطاق المصدر/وقت التشغيل
frame.timestamp # طابع وقت الحدث
frame.parent_id # معرف الحدث الأب، عند توفره
frame.previous_id # معرف الحدث السابق، عند توفره
frame.data # حمولة الحدث
frame.event # اسم بديل لـ frame.data
frame.content # نص قابل للطباعة لإطارات التوكن، وإلا ""
```
حقل `channel` هو أسرع طريقة لتوجيه الإطارات في المستهلكين:
| القناة | تحتوي على |
|--------|-----------|
| `llm` | توكنات وأجزاء التفكير من أحداث بث LLM |
| `flow` | دورة حياة Flow، وتنفيذ الدوال، والتوجيه، وأحداث الإيقاف/الاستئناف |
| `tools` | أحداث استخدام الأدوات |
| `messages` | أحداث سجل المحادثة |
| `lifecycle` | أحداث دورة حياة وقت التشغيل التي لا تخص قناة أخرى |
| `custom` | أحداث لا تُطابق قناة مدمجة |
يحافظ `frame.type` على نوع الحدث المصدر، حتى يتمكن المستهلكون من التعامل مع أحداث محددة داخل القناة.
## بث Flow
عيّن `stream=True` على Flow لجعل `kickoff()` يعيد جلسة stream:
```python
from crewai.flow import Flow, start
class ReportFlow(Flow):
@start()
def generate(self):
return "done"
flow = ReportFlow(stream=True)
stream = flow.kickoff()
with stream:
for chunk in stream:
print(chunk.content, end="", flush=True)
if chunk.type == "tool_usage_started":
print(chunk.event["tool_name"])
result = stream.result
```
يجب استهلاك stream قبل قراءة `stream.result`. يؤدي الوصول إلى النتيجة مبكرًا إلى رفع `RuntimeError` حتى لا يتعامل المستهلكون بالخطأ مع تشغيل جزئي على أنه مكتمل.
يمكنك أيضًا استدعاء `flow.stream_events(...)` مباشرة عندما تريد البث لاستدعاء واحد بدون تعيين `stream=True` على مثيل Flow.
## التصفية حسب القناة
يوفر `StreamSession` إسقاطات حسب القناة تحافظ على ترتيب الإطارات العالمي داخل القناة المحددة:
```python
stream = flow.stream_events()
with stream:
for frame in stream.llm:
print(frame.content, end="", flush=True)
result = stream.result
```
الإسقاطات المتاحة هي:
| الإسقاط | الإطارات |
|---------|----------|
| `stream.events` | كل الإطارات |
| `stream.llm` | إطارات LLM |
| `stream.messages` | إطارات رسائل المحادثة |
| `stream.flow` | إطارات Flow |
| `stream.tools` | إطارات الأدوات |
| `stream.interleave([...])` | مجموعة مختارة من القنوات |
استخدم `stream.interleave(["flow", "llm", "messages"])` عندما يريد المستهلك بعض القنوات فقط لكنه ما زال يحتاج إلى ترتيبها النسبي.
## البث غير المتزامن
استخدم `astream()` للمستهلكين غير المتزامنين:
```python
flow = ReportFlow()
stream = flow.astream()
async with stream:
async for chunk in stream.events:
print(chunk.channel, chunk.type, chunk.content)
result = stream.result
```
تملك الجلسة غير المتزامنة نفس إسقاطات الجلسة المتزامنة.
## بث استدعاء LLM مباشر
ما زال `llm.call(...)` يعيد النتيجة النهائية المجمعة. استخدم `llm.stream_events(...)` عندما تريد التكرار على الأجزاء فور وصولها مع الحفاظ على حمولة الحدث المهيكلة:
```python
from crewai import LLM
llm = LLM(model="gpt-4o-mini")
stream = llm.stream_events(
messages=[
{
"role": "user",
"content": "Explain CrewAI streaming in two short sentences.",
}
]
)
with stream:
for chunk in stream:
print(chunk.content, end="", flush=True)
result = stream.result
```
يفعل `llm.stream_events(...)` البث مؤقتًا للاستدعاء المغلف ثم يستعيد إعداد `stream` السابق في LLM بعد ذلك. تستمر تكاملات المزودين في إصدار أحداث بث LLM الأساسية؛ يوفر هذا المساعد واجهة مكرر مشتركة فوق تلك الأحداث لكل مزودي LLM.
## دورات المحادثة
يمكن للتدفقات المحادثية بث دورة مستخدم واحدة باستخدام `stream_turn()`:
```python
from crewai import Flow
from crewai.experimental.conversational import ConversationConfig, ConversationState
@ConversationConfig(llm="gpt-4o-mini", defer_trace_finalization=True)
class ChatFlow(Flow[ConversationState]):
conversational = True
flow = ChatFlow()
stream = flow.stream_turn("What can you help me with?", session_id="session-1")
with stream:
for frame in stream.events:
if frame.channel == "llm" and frame.type == "llm_stream_chunk":
print(frame.content, end="", flush=True)
reply = stream.result
```
أثناء `stream_turn()`، يفعّل مسار الإجابة المحادثية المدمج بث توكنات LLM لذلك الدور ثم يستعيد إعداد `stream` السابق في LLM بعد ذلك. يجب على معالجات المسارات المخصصة التي تنشئ Agents أو مثيلات LLM خاصة بها تهيئة تلك النماذج للبث إذا احتاجت إلى إخراج على مستوى التوكن.
## التنظيف
استخدم الجلسة كمدير سياق عندما يكون ذلك ممكنًا. إذا انقطع اتصال العميل قبل استهلاك stream بالكامل، فأغلق الجلسة صراحة:
```python
stream = flow.stream_events()
try:
for frame in stream.events:
print(frame.type)
finally:
if not stream.is_exhausted:
stream.close()
```
للتدفقات غير المتزامنة، استخدم `await stream.aclose()`.
## بث الأجزاء القديم
ما زال بث Crew مع `stream=True` يعيد واجهة `CrewStreamingOutput` المعتمدة على الأجزاء والموضحة في [بث تنفيذ Crew](/ar/learn/streaming-crew-execution). وما زالت استدعاءات `llm.call(...)` المباشرة تعيد نتيجة LLM النهائية. عقد الإطارات مخصص لأوقات التشغيل التي تحتاج إلى غلاف حدث ثابت عبر Flows، واستدعاءات LLM المباشرة، ودورات المحادثة، والأدوات، والرسائل.

View File

@@ -4,6 +4,60 @@ description: "Product updates, improvements, and bug fixes for CrewAI"
icon: "clock"
mode: "wide"
---
<Update label="Jul 01, 2026">
## v1.15.2a2
[View release on GitHub](https://github.com/crewAIInc/crewAI/releases/tag/1.15.2a2)
## What's Changed
### Features
- Add aiobotocore to the bedrock extra
- Document flow agent options
- Add text helper to flow skill example
- Add text helper for flow CEL prompts
- Add streaming docs to the navigation
### Bug Fixes
- Reject self-listening flow methods
### Documentation
- Update snapshot and changelog for v1.15.2a1
- Squeeze AGENTS.md file
## Contributors
@akaKuruma, @github-code-quality[bot], @lorenzejay, @vinibrsl
</Update>
<Update label="Jun 30, 2026">
## v1.15.2a1
[View release on GitHub](https://github.com/crewAIInc/crewAI/releases/tag/1.15.2a1)
## What's Changed
### Features
- Repoint template commands to crewAIInc-fde org
- Support inline skill definitions
- Define stream frame protocol for flows
- Add type tool and app in CrewDefinition
- Add generated Flow Definition authoring skill
### Bug Fixes
- Cut docs version navigation from Edge to prevent new pages from being dropped
### Documentation
- Document Cost Limit rule type in Agent Control Plane
- Drop CREWAI_LOG_FORMAT references from Datadog guide
## Contributors
@danielfsbarreto, @joaomdmoura, @lorenzejay, @lucasgomide, @vinibrsl
</Update>
<Update label="Jun 26, 2026">
## v1.15.1

View File

@@ -0,0 +1,137 @@
---
title: Streaming
description: Understand CrewAI's streaming model for Flows, direct LLM calls, tools, and conversational turns.
icon: radio
mode: "wide"
---
## Overview
Streaming lets your application receive execution updates while work is still running. Instead of waiting for the final result, you can render LLM tokens, tool activity, Flow lifecycle events, and conversation messages as they happen.
CrewAI has two streaming surfaces:
| Surface | Used by | Output |
|---------|---------|--------|
| Frame streaming | Flows, direct LLM calls, conversational turns | Ordered `StreamFrame` objects |
| Crew chunk streaming | Crews with `stream=True` | `CrewStreamingOutput` chunks |
For new runtime integrations, UIs, terminal apps, service bridges, and conversational surfaces, use frame streaming. It provides one stable event envelope across the runtime.
## StreamFrame
A `StreamFrame` is the common object emitted by streamable runtimes:
```python
frame.id # unique frame id
frame.seq # execution-local order, when available
frame.type # source event type, such as "llm_stream_chunk"
frame.channel # "llm", "flow", "tools", "messages", "lifecycle", or "custom"
frame.namespace # source/runtime namespace
frame.timestamp # event timestamp
frame.parent_id # parent event id, when available
frame.previous_id # previous event id, when available
frame.data # structured event payload
frame.event # alias for frame.data
frame.content # printable text for token-like frames, otherwise ""
```
The important fields for most consumers are:
| Field | Use it for |
|-------|------------|
| `channel` | Routing frames to the right UI region |
| `type` | Handling a specific event inside a channel |
| `content` | Printing token-like text |
| `event` | Reading structured metadata, such as tool names or message roles |
| `seq` | Preserving execution order |
## Channels
Frames are grouped into high-level channels:
| Channel | Contains |
|---------|----------|
| `llm` | LLM call lifecycle, text chunks, and thinking chunks |
| `flow` | Flow lifecycle, method execution, routing, pause, and resume events |
| `tools` | Tool usage start, finish, and error events |
| `messages` | Conversation transcript events |
| `lifecycle` | Runtime lifecycle events that do not belong to another channel |
| `custom` | Events that do not map to a built-in channel |
The stream itself remains one ordered timeline. Channel projections let consumers focus on only part of that timeline.
```mermaid
flowchart LR
A["flow<br/>flow_started"] --> B["llm<br/>llm_call_started"]
B --> C["llm<br/>llm_stream_chunk"]
C --> D["tools<br/>tool_usage_started"]
D --> E["tools<br/>tool_usage_finished"]
E --> F["llm<br/>llm_stream_chunk"]
F --> G["flow<br/>flow_finished"]
```
## Stream Sessions
Frame streaming returns a stream session:
```python
stream = flow.stream_events(inputs={"topic": "AI agents"})
```
The session is both an iterator and the holder for the final result:
```python
with stream:
for frame in stream:
print(frame.content, end="", flush=True)
result = stream.result
```
Consume the stream before reading `stream.result`. Reading the result too early raises an error because the runtime may still be producing frames.
## Channel Projections
Use channel projections when you only need one kind of frame:
```python
with flow.stream_events(inputs={"topic": "AI agents"}) as stream:
for frame in stream.llm:
print(frame.content, end="", flush=True)
result = stream.result
```
Available projections:
| Projection | Frames |
|------------|--------|
| `stream.events` | All frames |
| `stream.llm` | LLM frames |
| `stream.flow` | Flow frames |
| `stream.tools` | Tool frames |
| `stream.messages` | Conversation message frames |
| `stream.interleave([...])` | Selected channels in relative order |
## Entrypoints
Use the entrypoint that matches the runtime you are streaming:
| Runtime | Streaming entrypoint |
|---------|----------------------|
| Flow | `flow.stream_events(...)` |
| Flow with `stream=True` | `flow.kickoff(...)` returns a stream session |
| Async Flow | `flow.astream(...)` or `await flow.kickoff_async(...)` when `stream=True` |
| Direct LLM call | `llm.stream_events(...)` |
| Conversational Flow turn | `flow.stream_turn(...)` |
| Crew | `Crew(..., stream=True).kickoff(...)` returns `CrewStreamingOutput` |
Direct `llm.call(...)` still returns the final assembled LLM result. Use `llm.stream_events(...)` when you want to iterate over LLM chunks as they arrive.
## Related Guides
- [Consuming Streams](/edge/en/learn/consuming-streams)
- [Streaming Runtime Contract](/edge/en/learn/streaming-runtime-contract)
- [Streaming Flow Execution](/edge/en/learn/streaming-flow-execution)
- [Streaming Crew Execution](/edge/en/learn/streaming-crew-execution)

View File

@@ -20,7 +20,7 @@ The **Agent Control Plane** (ACP) is the operations hub for everything you have
- Monitor the **health** of every live automation (crew or flow), with `Critical` / `Warning` / `Healthy` breakdowns and execution counts.
- Track **LLM consumption** — tokens and cost — per automation, per provider, and per model, with a delta vs the previous period.
- Drill into any single automation or model provider for time-series charts and per-provider breakdowns.
- Apply organization-wide **Rules** (today: PII Redaction) across many automations at once instead of editing each deployment individually.
- Apply organization-wide **Rules** (today: PII Redaction and Cost Limit) across many automations at once instead of editing each deployment individually.
<Frame>
![Agent Control Plane overview](/images/enterprise/acp-overview-automations-sankey.png)
@@ -33,7 +33,7 @@ The **Agent Control Plane** (ACP) is the operations hub for everything you have
The two tabs answer two different questions:
- **Automations** — *"How is my fleet behaving right now, and what is it costing me?"* See [Monitoring](/en/enterprise/features/agent-control-plane/monitoring).
- **Rules** — *"How do I enforce a policy (e.g. PII redaction) across many deployments without re-deploying each one?"* See [Rules](/en/enterprise/features/agent-control-plane/rules).
- **Rules** — *"How do I enforce a policy (e.g. PII redaction or a spend budget) across many deployments without re-deploying each one?"* See [Rules](/en/enterprise/features/agent-control-plane/rules).
## Requirements
@@ -42,7 +42,7 @@ The two tabs answer two different questions:
</Warning>
<Warning>
**Enterprise Plan or Ultra Plan** is required to create or edit [Rules](/en/enterprise/features/agent-control-plane/rules). Lower-tier organizations can open the Rules tab and view existing rules, but the editor renders read-only with an "Enterprise" lock pill and the alert *"PII Redaction rules require an Enterprise plan."* Monitoring (the Automations tab) is available on all plans where the feature is enabled.
**Enterprise Plan or Ultra Plan** is required to create or edit **PII Redaction** [Rules](/en/enterprise/features/agent-control-plane/rules). Lower-tier organizations can open the Rules tab and view existing rules, but the PII editor renders read-only with an "Enterprise" lock pill and the alert *"PII Redaction rules require an Enterprise plan."* **Cost Limit** rules and Monitoring (the Automations tab) are available on all plans where the feature is enabled.
</Warning>
- The **Agent Control Plane** feature must be enabled for your organization. If you don't see it in the sidebar, ask your account owner to request enablement.
@@ -56,7 +56,7 @@ The two tabs answer two different questions:
Watch fleet health and LLM spend with metric cards, an interactive sankey, per-automation tables, and drill-down side panels for any automation or provider.
</Card>
<Card title="Rules" icon="shield-check" href="/en/enterprise/features/agent-control-plane/rules">
Apply organization-wide PII Redaction policies scoped by tools and tags. Changes take effect on the next execution — no re-deploy required.
Apply organization-wide PII Redaction and Cost Limit policies scoped by tools and tags. Changes take effect on the next execution — no re-deploy required.
</Card>
</CardGroup>

View File

@@ -16,7 +16,7 @@ mode: "wide"
## Overview
Rules let you apply policies — today: **PII Redaction** — across many automations at once, instead of configuring each deployment individually. Open the **Rules** tab in the [Agent Control Plane](/en/enterprise/features/agent-control-plane/overview) to manage them.
Rules let you apply policies — today **PII Redaction** and **Cost Limit** — across many automations at once, instead of configuring each deployment individually. Open the **Rules** tab in the [Agent Control Plane](/en/enterprise/features/agent-control-plane/overview) to manage them.
<Frame>
![Rules list](/images/enterprise/acp-rules-list.png)
@@ -27,26 +27,78 @@ Each rule card shows the name, description, the **scope** the rule applies to (s
## Requirements
<Warning>
**Enterprise Plan or Ultra Plan** is required to create or edit PII Redaction rules. Lower-tier organizations can still open the Rules tab and view existing rules, but the editor renders read-only with an "Enterprise" lock pill and the alert *"PII Redaction rules require an Enterprise plan."* — contact your account owner or sales to upgrade.
**Enterprise Plan or Ultra Plan** is required to create or edit **PII Redaction** rules. Lower-tier organizations can still open the Rules tab and view existing rules, but the PII editor renders read-only with an "Enterprise" lock pill and the alert *"PII Redaction rules require an Enterprise plan."* — contact your account owner or sales to upgrade. **Cost Limit** rules are **not** plan-gated and can be created on any plan where the Agent Control Plane is enabled.
</Warning>
- The **Agent Control Plane** feature must be enabled for your organization. See [Overview — Requirements](/en/enterprise/features/agent-control-plane/overview#requirements).
- The `manage` [RBAC permission](/en/enterprise/features/rbac) on Agent Control Plane is required to create, edit, toggle, or delete rules. The `read` permission is enough to view them.
- All rule changes are versioned for auditing.
## Available rule types
## Rule types
| Type | What it does |
|------|---------------|
| **PII Redaction** | Applies PII redaction to executions of every matching automation, using the same entity catalog and custom recognizers documented in [PII Redaction for Traces](/en/enterprise/features/pii-trace-redactions). |
Every rule is one of the types below. Open the tab for the policy you want to enforce.
<Tabs>
<Tab title="PII Redaction">
Applies PII redaction to executions of every matching automation, using the same entity catalog and custom recognizers documented in [PII Redaction for Traces](/en/enterprise/features/pii-trace-redactions).
<Warning>
Creating or editing PII Redaction rules requires an **Enterprise** or **Ultra** plan. On lower tiers the PII editor renders read-only with an "Enterprise" lock pill.
</Warning>
**Configuration** — in the **PII Mask Type** table, check each entity type you want covered and choose how to handle it:
- **Mask** — replaces the match with the entity label (e.g. `<CREDIT_CARD>`).
- **Redact** — removes the matched text entirely.
See [PII Redaction for Traces](/en/enterprise/features/pii-trace-redactions) for the full entity catalog and how to add organization-level custom recognizers.
</Tab>
<Tab title="Cost Limit">
Emails the recipients you choose when a matching automation's LLM spend exceeds a budget threshold in the selected period. Available on **all plans** where the Agent Control Plane is enabled — it is not Enterprise-gated.
<Warning>
Cost Limit rules are **notify-only**. They never pause, throttle, or stop a run — they only send an email so a human can decide what to do. Adjust the budget or remove the rule if you no longer want the alert.
</Warning>
**Configuration**
| Field | Description |
|-------|-------------|
| **Budget period** | The window spend is measured over: **Daily**, **Weekly**, or **Monthly** (default *Monthly*). Spend resets at the start of each calendar period. |
| **Threshold (USD)** | The dollar amount that triggers an alert. Must be greater than `0`. The alert fires once the automation's spend for the current period exceeds this value. |
| **Recipient emails** | Up to 50 email addresses. Type an address and press **Enter** or comma to add it as a chip; **Backspace** removes the last chip. These do not need to be CrewAI users. |
| **Notify roles** | Optionally select organization [roles](/en/enterprise/features/rbac); the alert is sent to every member of the chosen roles. Roles with no members can't be selected. You must provide at least one recipient — an email or a role. |
| **Re-alert frequency** | How often the alert can re-fire while an automation stays over budget: **Once per period**, **Every hour while over**, **Every 4h while over**, or **Daily while over**. Re-alerts are capped at 24 per period. |
**How spend is measured and matched**
- The threshold is evaluated **per automation**, not summed across the whole scope. Each engaged automation has its own running total for the period.
- A rule can match many automations via its conditions (tools/tags), and a single automation can be covered by **multiple** Cost Limit rules at once. Each rule tracks its own budget and alert state independently — they don't merge.
- A background check compares each engaged automation's period-to-date spend against the threshold and sends the email when it's exceeded. Because the check runs periodically, expect a short delay between crossing the threshold and the email arriving.
**The alert email**
When an automation goes over budget, recipients get an email summarizing the overage — the automation name, the **current spend**, the **budget threshold**, and how far over it is in both dollars and percent (e.g. `$0.38` current vs a `$0.10` budget = `+277%`). The email reiterates that the run was **not** paused.
</Tab>
</Tabs>
More rule types will be added over time.
## Creating a rule
<Tabs>
<Tab title="PII Redaction">
<Frame>
<img src="/images/enterprise/acp-rules-edit-side-panel.png" alt="Rule edit side panel with conditions and PII mask type" width="450" />
<img src="/images/enterprise/acp-rules-edit-side-panel.png" alt="New Rule side panel configured for PII Redaction with the PII mask type table" width="450" />
</Frame>
</Tab>
<Tab title="Cost Limit">
<Frame>
<img src="/images/enterprise/acp-rules-edit-cost-limit.png" alt="New Rule side panel configured for Cost Limit with budget period, threshold, and recipient emails" width="450" />
</Frame>
</Tab>
</Tabs>
<Steps>
<Step title="Open the editor">
@@ -54,11 +106,11 @@ More rule types will be added over time.
</Step>
<Step title="Name and describe the rule">
Give the rule a clear name (e.g. *Mask PII (CC)*) and a description explaining when it applies. Both show up on the rule card and in the Engaged Automations modal.
Give the rule a clear name (e.g. *Mask PII (CC)* or *Monthly $100 budget*) and a description explaining when it applies. Both show up on the rule card and in the Engaged Automations modal.
</Step>
<Step title="Pick the type">
Today only **PII Redaction** is available.
Choose **PII Redaction** or **Cost Limit**. The type determines which configuration section appears below the conditions. The type is fixed once the rule is created — to switch, create a new rule.
</Step>
<Step title="Set the conditions">
@@ -70,8 +122,8 @@ More rule types will be added over time.
Leaving a picker empty means "no filter on this dimension". Leaving both empty means the rule applies to **every** automation in the organization.
</Step>
<Step title="Configure the PII Mask Type table">
Check each entity type you want covered and choose **Mask** (replaces with the entity label, e.g. `<CREDIT_CARD>`) or **Redact** (removes the matched text entirely). See [PII Redaction for Traces](/en/enterprise/features/pii-trace-redactions) for the full entity catalog and how to add organization-level custom recognizers.
<Step title="Configure the type-specific section">
The editor shows the configuration for the type you picked — the **PII Mask Type** table for PII Redaction, or the budget fields for Cost Limit. See [Rule types](#rule-types) for what each field does.
</Step>
<Step title="Save">
@@ -91,12 +143,14 @@ This is the fastest way to sanity-check a rule's scope before enabling it — fo
## Org-wide rules vs per-deployment settings
PII Redaction can be configured in two places:
Both PII Redaction and Cost Limit can be configured in two places: org-wide as a Rule on this page, or per-deployment under that deployment's **Settings**. When an enabled org-wide rule's scope matches a deployment, the rule takes precedence over the deployment-owned setting while it's attached.
- **Per-deployment** — under **Settings → PII Protection** on each individual deployment ([guide](/en/enterprise/features/pii-trace-redactions))
- **Org-wide** — as a Rule on this page
| Policy | Per-deployment setting | What an attached org-wide rule does |
|--------|------------------------|-------------------------------------|
| **PII Redaction** | **Settings → PII Protection** ([guide](/en/enterprise/features/pii-trace-redactions)) | The rule's entity configuration **overrides** the deployment's PII settings for that deployment's executions. |
| **Cost Limit** | **Settings → Cost Alerts** | The deployment's manual cost alert is **paused** and the attached cost rule(s) fire instead. The per-deployment form stays editable as a fallback. |
When an enabled org-wide rule's scope matches a deployment, the rule's entity configuration **overrides** the deployment-owned PII settings for that deployment's executions — the rule becomes the single source of truth while it's attached. Disable or detach the rule (or change its scope so it no longer matches) and the deployment falls back to its own PII Protection settings.
Disable or detach the rule (or change its scope so it no longer matches) and the deployment falls back to its own per-deployment settings.
Prefer org-wide rules when you want to enforce a consistent policy across many deployments; reserve per-deployment configuration for one-off exceptions.

View File

@@ -17,12 +17,11 @@ CrewAI supports two log-ingestion paths to Datadog — both are first-class and
<Tabs>
<Tab title="Datadog Agent">
The Datadog Agent runs alongside your CrewAI containers (typically as a DaemonSet on Kubernetes) and tails their stdout. With `CREWAI_LOG_FORMAT=json` set, each log event ships as a single billable line with structured attributes.
The Datadog Agent runs alongside your CrewAI containers (typically as a DaemonSet on Kubernetes) and tails their stdout. Each log event ships as a single billable line with structured attributes — see the [log schema reference](#log-schema-reference) for the full field contract.
**Setup:**
1. Run the Datadog Agent next to your CrewAI containers — see [Datadog's deployment docs](https://docs.datadoghq.com/agent/) for Kubernetes, ECS, or VM setup. Enable log collection (`logs_enabled: true`) and container log collection (`logs_config.container_collect_all: true`).
2. Set `CREWAI_LOG_FORMAT=json` as an **automation environment variable** in CrewAI AMP (open your automation → **Settings → Environment Variables**) so each log event is a single line instead of a multi-line traceback. AMP propagates the value to every container in the deployment (API + workers) — don't set it on the container or host directly. See [Enabling JSON output](#enabling-json-output) below for the AMP UI walkthrough and the [log schema reference](#log-schema-reference) for the full field contract.
3. Confirm logs arrive in Datadog Logs with the JSON fields parsed — see [Verify ingestion](#verify-ingestion).
2. Confirm logs arrive in Datadog Logs with the JSON fields parsed — see [Verify ingestion](#verify-ingestion).
**Pick this path if** you already operate Datadog Agents (e.g. for infrastructure metrics), or your log volume makes per-event ingestion cost a real concern — collapsing tracebacks into single events keeps Agent ingestion cheap at scale.
</Tab>
@@ -53,10 +52,10 @@ Either path lands the same structured facets in Datadog (`@automation_id`, `@kic
## Log schema reference
<Info>
This schema applies to the **Datadog Agent path** — stdout JSON logs produced when `CREWAI_LOG_FORMAT=json` is set. Logs delivered via the **Datadog OTLP intake** use OpenTelemetry attribute names and may differ; see [OpenTelemetry Export](./capture_telemetry_logs).
This schema applies to the **Datadog Agent path** — structured stdout JSON logs emitted by every CrewAI worker container. Logs delivered via the **Datadog OTLP intake** use OpenTelemetry attribute names and may differ; see [OpenTelemetry Export](./capture_telemetry_logs).
</Info>
When `CREWAI_LOG_FORMAT=json` is set, every log event is emitted as a **single JSON object per line** to stdout, with internal newlines escaped. The format is plain JSON — Datadog parses it natively, and the same payload is also consumable by Splunk, Loki, Elasticsearch, and CloudWatch without custom log pipelines.
Every log event is emitted as a **single JSON object per line** to stdout, with internal newlines escaped. The format is plain JSON — Datadog parses it natively, and the same payload is also consumable by Splunk, Loki, Elasticsearch, and CloudWatch without custom log pipelines.
### Why JSON output
@@ -75,20 +74,6 @@ When `CREWAI_LOG_FORMAT=json` is set, every log event is emitted as a **single J
</Card>
</CardGroup>
### Enabling JSON output
`CREWAI_LOG_FORMAT=json` must be set as an **automation environment variable** in CrewAI AMP — it is **not** a container, host, or Docker setting. Open your automation in AMP, click the **Settings** icon, and add the variable under the **Environment Variables** section. AMP applies the value to every container in the deployment (API + workers) on the next restart. See [Update Your Crew](./update-crew) for the full UI walkthrough with screenshots.
```shell
CREWAI_LOG_FORMAT=json
```
Restart the deployment to pick up the change. Every log line on stdout from that point on is a single JSON object.
<Note>
The default value is `text`, which preserves the legacy human-readable line format byte-for-byte. Setting any value other than `json` falls back to text mode. There is no migration step — the variable is read at process start and the format switches immediately.
</Note>
### Example events
A single info-level log inside an active automation kickoff:
@@ -131,7 +116,7 @@ An error with a Python exception is collapsed into a single event with the trace
}
```
The same error in legacy text mode would have produced ~25 separate log events (one per traceback line) — all of which the backend would bill and index individually.
Without JSON output, that same error would produce ~25 separate log events (one per traceback line) — all of which the backend would bill and index individually.
### Schema v1 fields
@@ -233,7 +218,7 @@ Open [Logs Explorer](https://app.datadoghq.com/logs) and run a query that matche
<Tab title="Datadog Agent">
Search `service:crewai* @schema:v1`. You should see structured logs with the JSON fields parsed into Datadog facets. Pick a recent event and verify it has `@automation_id`, `@kickoff_id`, `@execution_id`, `@crewai_version`, and (when running inside a span) `@trace_id` / `@span_id` populated.
If nothing appears, confirm `CREWAI_LOG_FORMAT=json` is set under your automation's **Environment Variables** in AMP, the deployment was restarted after the change, and the Datadog Agent is tailing container stdout.
If nothing appears, confirm the Datadog Agent is tailing container stdout and that the deployment is running a recent enough CrewAI Enterprise build.
</Tab>
<Tab title="Datadog OTLP intake">
Search `source:otlp service:crewai*`. OTLP attributes land with their OpenTelemetry names (`automation_id`, `crewai.kickoff.id`, etc.) rather than the stdout JSON keys, but they map to the same dashboard facets after [facet promotion](#prerequisite-promote-facets).
@@ -276,7 +261,7 @@ The `$service` template variable defaults to `*` and will catch every CrewAI dep
| All widgets show "No data" | Facets aren't promoted | Re-do the [Promote facets](#prerequisite-promote-facets) step. Datadog won't query against an un-promoted field. |
| Error Rate widget shows `NaN` | No executions in the time window | Either no traffic, or `@execution_id` isn't faceted. Expand the time range and re-check facets. |
| Throughput chart is flat at the same value | Logs aren't reaching Datadog | Search `service:crewai*` in Logs Explorer. If nothing shows, verify the Datadog Agent is running (Agent path) or the OTel collector endpoint is correct (OTLP path). |
| `crewai_version` shows fewer values than expected | Some containers predate the structured-logs work | The `crewai_version` field was added alongside JSON output. Older deployments running text mode (or older AMP builds) won't emit it. Upgrade those deployments to pick up the field. See the [log schema reference](#log-schema-reference) for the full field contract. |
| `crewai_version` shows fewer values than expected | Some containers predate the structured-logs work | The `crewai_version` field was added alongside JSON output. Older deployments (pre-structured-logs AMP builds) won't emit it. Upgrade those deployments to pick up the field. See the [log schema reference](#log-schema-reference) for the full field contract. |
| Template variables don't filter widgets | The widget's filter line doesn't reference the template variable | Edit the widget and confirm the search includes `$automation $version $service`. |
## Next steps

View File

@@ -25,6 +25,7 @@ Use **`flow.handle_turn(message, session_id=...)`** for every user message from
| API | Use for |
|-----|---------|
| `handle_turn(message, session_id=...)` | Ergonomic one-turn wrapper for conversational `Flow` |
| `stream_turn(message, session_id=...)` | Stream one conversational turn as ordered runtime frames |
| `chat()` | Local terminal REPL for conversational `Flow` |
| `kickoff(inputs={...})` | Advanced flow execution without conversational turn handling |
| `ask()` | Blocking prompt **inside** one step (wizard, clarification) |
@@ -85,6 +86,23 @@ finally:
flow.finalize_session_traces() # one trace link for the whole chat
```
## Streaming a turn
Use `stream_turn()` when a UI or runtime needs structured events for one chat turn. It returns a stream session with ordered frames for Flow routing, LLM chunks, tool activity, and conversation messages.
```python
stream = flow.stream_turn("Where is my order?", session_id=session_id)
with stream:
for frame in stream.events:
if frame.channel == "llm" and frame.type == "llm_stream_chunk":
print(frame.data.get("chunk", ""), end="", flush=True)
result = stream.result
```
For the full frame contract, channel list, and async API, see [Streaming Runtime Contract](/edge/en/learn/streaming-runtime-contract).
## Turn lifecycle
Each `handle_turn` runs this pipeline:

View File

@@ -0,0 +1,177 @@
---
title: Consuming Streams
description: Print LLM chunks, observe tool events, and read final results from CrewAI streams.
icon: square-terminal
mode: "wide"
---
## Overview
Use this guide when you want to subscribe to a CrewAI stream and print or route frames as they arrive.
The basic pattern is:
```python
stream = flow.stream_events(inputs={"topic": "AI agents"})
with stream:
for frame in stream:
...
result = stream.result
```
Always consume the stream before reading `stream.result`.
## Print LLM Output
If you only care about text generated by LLM calls, subscribe to the `llm` projection and print `frame.content`:
```python
stream = flow.stream_events(inputs={"topic": "AI agents"})
with stream:
for frame in stream.llm:
print(frame.content, end="", flush=True)
print()
result = stream.result
```
`frame.content` is an empty string for frames that do not carry printable text, so this is also safe:
```python
with flow.stream_events(inputs={"topic": "AI agents"}) as stream:
for frame in stream.events:
if frame.channel == "llm" and frame.content:
print(frame.content, end="", flush=True)
result = stream.result
```
## Print Tool Activity
Tool events arrive on the `tools` channel. Use `frame.type` to distinguish starts, finishes, and errors.
```python
with flow.stream_events(inputs={"topic": "AI agents"}) as stream:
for frame in stream.events:
if frame.channel == "llm" and frame.content:
print(frame.content, end="", flush=True)
if frame.channel == "tools" and frame.type == "tool_usage_started":
print(f"\nTool started: {frame.event.get('tool_name')}")
if frame.channel == "tools" and frame.type == "tool_usage_finished":
print(f"\nTool finished: {frame.event.get('tool_name')}")
result = stream.result
```
`frame.event` is the structured payload for the source event. Use it for metadata such as tool names, arguments, message roles, and runtime identifiers.
## Watch Flow Progress
Flow lifecycle and method execution frames arrive on the `flow` channel:
```python
with flow.stream_events(inputs={"topic": "AI agents"}) as stream:
for frame in stream.flow:
print(frame.type, frame.namespace)
result = stream.result
```
Use this when you want a progress log instead of token-level output.
## Interleave Selected Channels
Use `interleave()` when you want a subset of channels while preserving their relative order:
```python
with flow.stream_events(inputs={"topic": "AI agents"}) as stream:
for frame in stream.interleave(["llm", "tools"]):
if frame.channel == "llm":
print(frame.content, end="", flush=True)
elif frame.type == "tool_usage_started":
print(f"\nTool: {frame.event.get('tool_name')}")
result = stream.result
```
## Stream a Direct LLM Call
Direct `llm.call(...)` returns the final assembled result. To stream a direct LLM call, use `llm.stream_events(...)`:
```python
from crewai import LLM
llm = LLM(model="gpt-4o-mini")
stream = llm.stream_events("Explain streaming in one sentence.")
with stream:
for frame in stream.llm:
print(frame.content, end="", flush=True)
print()
result = stream.result
```
## Stream a Conversational Turn
Conversational Flows expose `stream_turn()` for one user message:
```python
stream = flow.stream_turn(
"What can you help me with?",
session_id="session-1",
)
with stream:
for frame in stream.interleave(["llm", "messages"]):
if frame.channel == "llm":
print(frame.content, end="", flush=True)
elif frame.channel == "messages":
print(f"\n{frame.event.get('role')}: {frame.event.get('content')}")
reply = stream.result
```
## Async Consumers
Async streams use the same channel projections:
```python
stream = flow.astream(inputs={"topic": "AI agents"})
async with stream:
async for frame in stream.llm:
print(frame.content, end="", flush=True)
result = stream.result
```
## Cleanup
Use the stream as a context manager when possible. If a client disconnects or you stop consuming early, close the stream:
```python
stream = flow.stream_events(inputs={"topic": "AI agents"})
try:
for frame in stream.events:
print(frame.content, end="", flush=True)
finally:
if not stream.is_exhausted:
stream.close()
```
For async streams, call `await stream.aclose()`.
## See Also
- [Streaming](/edge/en/concepts/streaming)
- [Streaming Runtime Contract](/edge/en/learn/streaming-runtime-contract)
- [Streaming Flow Execution](/edge/en/learn/streaming-flow-execution)
- [Streaming Crew Execution](/edge/en/learn/streaming-crew-execution)

View File

@@ -11,7 +11,7 @@ CrewAI Flows support streaming output, allowing you to receive real-time updates
## How Flow Streaming Works
When streaming is enabled on a Flow, CrewAI captures and streams output from any crews or LLM calls within the flow. The stream delivers structured chunks containing the content, task context, and agent information as execution progresses.
When streaming is enabled on a Flow, CrewAI captures and streams output from any crews, LLM calls, tools, and lifecycle events within the flow. The stream delivers ordered `StreamFrame` items with printable content plus structured event data as execution progresses.
## Enabling Streaming
@@ -52,7 +52,7 @@ class ResearchFlow(Flow):
## Synchronous Streaming
When you call `kickoff()` on a flow with streaming enabled, it returns a `FlowStreamingOutput` object that you can iterate over:
When you call `kickoff()` on a flow with streaming enabled, it returns a stream session that yields ordered `StreamFrame` items:
```python Code
flow = ResearchFlow()
@@ -60,44 +60,43 @@ flow = ResearchFlow()
# Start streaming execution
streaming = flow.kickoff()
# Iterate over chunks as they arrive
for chunk in streaming:
print(chunk.content, end="", flush=True)
# Iterate over stream items as they arrive
for item in streaming:
print(item.content, end="", flush=True)
# Access the final result after streaming completes
result = streaming.result
print(f"\n\nFinal output: {result}")
```
### Stream Chunk Information
### Stream Item Information
Each chunk provides context about where it originated in the flow:
Each item provides both printable content and structured event data:
```python Code
streaming = flow.kickoff()
for chunk in streaming:
print(f"Agent: {chunk.agent_role}")
print(f"Task: {chunk.task_name}")
print(f"Content: {chunk.content}")
print(f"Type: {chunk.chunk_type}") # TEXT or TOOL_CALL
for item in streaming:
print(f"Channel: {item.channel}")
print(f"Type: {item.type}")
print(f"Content: {item.content}")
print(f"Event payload: {item.event}")
```
### Accessing Streaming Properties
The `FlowStreamingOutput` object provides useful properties and methods:
The stream session provides useful properties and methods:
```python Code
streaming = flow.kickoff()
# Iterate and collect chunks
for chunk in streaming:
print(chunk.content, end="", flush=True)
# Iterate and collect items
for item in streaming:
print(item.content, end="", flush=True)
# After iteration completes
print(f"\nCompleted: {streaming.is_completed}")
print(f"Full text: {streaming.get_full_text()}")
print(f"Total chunks: {len(streaming.chunks)}")
print(f"Total frames: {len(streaming.frames)}")
print(f"Final result: {streaming.result}")
```
@@ -114,9 +113,9 @@ async def stream_flow():
# Start async streaming
streaming = await flow.kickoff_async()
# Async iteration over chunks
async for chunk in streaming:
print(chunk.content, end="", flush=True)
# Async iteration over stream items
async for item in streaming:
print(item.content, end="", flush=True)
# Access final result
result = streaming.result
@@ -181,13 +180,14 @@ flow = MultiStepFlow()
streaming = flow.kickoff()
current_step = ""
for chunk in streaming:
for item in streaming:
# Track which flow step is executing
if chunk.task_name != current_step:
current_step = chunk.task_name
print(f"\n\n=== {chunk.task_name} ===\n")
step_name = item.event.get("method_name") or item.event.get("task_name")
if step_name and step_name != current_step:
current_step = step_name
print(f"\n\n=== {step_name} ===\n")
print(chunk.content, end="", flush=True)
print(item.content, end="", flush=True)
result = streaming.result
print(f"\n\nFinal analysis: {result}")
@@ -201,7 +201,6 @@ Here's a complete example showing how to build a progress dashboard with streami
import asyncio
from crewai.flow.flow import Flow, listen, start
from crewai import Agent, Crew, Task
from crewai.types.streaming import StreamChunkType
class ResearchPipeline(Flow):
stream = True
@@ -254,33 +253,35 @@ async def run_with_dashboard():
current_agent = ""
current_task = ""
chunk_count = 0
frame_count = 0
async for chunk in streaming:
chunk_count += 1
async for item in streaming:
frame_count += 1
# Display phase transitions
if chunk.task_name != current_task:
current_task = chunk.task_name
current_agent = chunk.agent_role
task_name = item.event.get("task_name", "")
agent_role = item.event.get("agent_role", "")
if task_name and task_name != current_task:
current_task = task_name
current_agent = agent_role
print(f"\n\n📋 Phase: {current_task}")
print(f"👤 Agent: {current_agent}")
print("-" * 60)
# Display text output
if chunk.chunk_type == StreamChunkType.TEXT:
print(chunk.content, end="", flush=True)
if item.content:
print(item.content, end="", flush=True)
# Display tool usage
elif chunk.chunk_type == StreamChunkType.TOOL_CALL and chunk.tool_call:
print(f"\n🔧 Tool: {chunk.tool_call.tool_name}")
elif item.channel == "tools":
print(f"\n🔧 Tool event: {item.type}")
# Show completion summary
result = streaming.result
print(f"\n\n{'='*60}")
print("PIPELINE COMPLETE")
print(f"{'='*60}")
print(f"Total chunks: {chunk_count}")
print(f"Total frames: {frame_count}")
print(f"Final output length: {len(str(result))} characters")
asyncio.run(run_with_dashboard())
@@ -353,8 +354,8 @@ class StatefulStreamingFlow(Flow[AnalysisState]):
flow = StatefulStreamingFlow()
streaming = flow.kickoff(inputs={"topic": "quantum computing"})
for chunk in streaming:
print(chunk.content, end="", flush=True)
for item in streaming:
print(item.content, end="", flush=True)
result = streaming.result
print(f"\n\nFinal state:")
@@ -374,29 +375,29 @@ Flow streaming is particularly valuable for:
- **Progress Tracking**: Show users which stage of the workflow is currently executing
- **Live Dashboards**: Create monitoring interfaces for production flows
## Stream Chunk Types
## Stream Frame Channels
Like crew streaming, flow chunks can be of different types:
Flow streaming yields `StreamFrame` items across several channels:
### TEXT Chunks
### LLM Frames
Standard text content from LLM responses:
```python Code
for chunk in streaming:
if chunk.chunk_type == StreamChunkType.TEXT:
print(chunk.content, end="", flush=True)
for item in streaming:
if item.channel == "llm" and item.content:
print(item.content, end="", flush=True)
```
### TOOL_CALL Chunks
### Tool Frames
Information about tool calls within the flow:
```python Code
for chunk in streaming:
if chunk.chunk_type == StreamChunkType.TOOL_CALL and chunk.tool_call:
print(f"\nTool: {chunk.tool_call.tool_name}")
print(f"Args: {chunk.tool_call.arguments}")
for item in streaming:
if item.channel == "tools":
print(f"\nTool event: {item.type}")
print(f"Payload: {item.event}")
```
## Error Handling
@@ -408,8 +409,8 @@ flow = ResearchFlow()
streaming = flow.kickoff()
try:
for chunk in streaming:
print(chunk.content, end="", flush=True)
for item in streaming:
print(item.content, end="", flush=True)
result = streaming.result
print(f"\nSuccess! Result: {result}")
@@ -422,7 +423,7 @@ except Exception as e:
## Cancellation and Resource Cleanup
`FlowStreamingOutput` supports graceful cancellation so that in-flight work stops promptly when the consumer disconnects.
The stream session supports graceful cancellation so that in-flight work stops promptly when the consumer disconnects.
### Async Context Manager
@@ -430,8 +431,8 @@ except Exception as e:
streaming = await flow.kickoff_async()
async with streaming:
async for chunk in streaming:
print(chunk.content, end="", flush=True)
async for item in streaming:
print(item.content, end="", flush=True)
```
### Explicit Cancellation
@@ -439,8 +440,8 @@ async with streaming:
```python Code
streaming = await flow.kickoff_async()
try:
async for chunk in streaming:
print(chunk.content, end="", flush=True)
async for item in streaming:
print(item.content, end="", flush=True)
finally:
await streaming.aclose() # async
# streaming.close() # sync equivalent
@@ -451,10 +452,10 @@ After cancellation, `streaming.is_cancelled` and `streaming.is_completed` are bo
## Important Notes
- Streaming automatically enables LLM streaming for any crews used within the flow
- You must iterate through all chunks before accessing the `.result` property
- You must iterate through all stream items before accessing the `.result` property
- Streaming works with both structured and unstructured flow state
- Flow streaming captures output from all crews and LLM calls in the flow
- Each chunk includes context about which agent and task generated it
- Each frame includes structured event context such as channel, type, namespace, and payload
- Streaming adds minimal overhead to flow execution
## Combining with Flow Visualization
@@ -468,11 +469,11 @@ flow.plot("research_flow") # Creates HTML visualization
# Run with streaming
streaming = flow.kickoff()
for chunk in streaming:
print(chunk.content, end="", flush=True)
for item in streaming:
print(item.content, end="", flush=True)
result = streaming.result
print(f"\nFlow complete! View structure at: research_flow.html")
```
By leveraging flow streaming, you can build sophisticated, responsive applications that provide users with real-time visibility into complex multi-stage workflows, making your AI automations more transparent and engaging.
By leveraging flow streaming, you can build sophisticated, responsive applications that provide users with real-time visibility into complex multi-stage workflows, making your AI automations more transparent and engaging.

View File

@@ -0,0 +1,194 @@
---
title: Streaming Runtime Contract
description: Stream ordered runtime frames from Flows, direct LLM calls, and conversational turns.
icon: tower-broadcast
mode: "wide"
---
## Overview
CrewAI exposes a frame-based streaming contract for runtimes that need more than plain text chunks. The contract emits ordered `StreamFrame` objects for Flow lifecycle events, direct LLM tokens, tool activity, conversation messages, and custom events.
Use this API when you are building a UI, service bridge, terminal app, or deployment runtime that needs a stable stream of structured events while a Flow, chat turn, or direct LLM call is running.
## StreamFrame
Every frame has the same envelope:
```python
from crewai.types.streaming import StreamFrame
frame.id # unique frame id
frame.seq # execution-local order, when available
frame.type # source event type, such as "flow_started"
frame.channel # "llm", "flow", "tools", "messages", "lifecycle", or "custom"
frame.namespace # source/runtime namespace
frame.timestamp # event timestamp
frame.parent_id # parent event id, when available
frame.previous_id # previous event id, when available
frame.data # event payload
frame.event # alias for frame.data
frame.content # printable text for token-like frames, otherwise ""
```
The `channel` field is the fastest way to route frames in consumers:
| Channel | Contains |
|---------|----------|
| `llm` | Token and thinking chunks from LLM streaming events |
| `flow` | Flow lifecycle, method execution, routing, and pause/resume events |
| `tools` | Tool usage events |
| `messages` | Conversation transcript events |
| `lifecycle` | Runtime lifecycle events that are not specific to another channel |
| `custom` | Events that do not map to a built-in channel |
`frame.type` preserves the source event type, so consumers can handle specific events inside a channel.
## Stream a Flow
Set `stream=True` on a Flow to make `kickoff()` return a stream session:
```python
from crewai.flow import Flow, start
class ReportFlow(Flow):
@start()
def generate(self):
return "done"
flow = ReportFlow(stream=True)
stream = flow.kickoff()
with stream:
for chunk in stream:
print(chunk.content, end="", flush=True)
if chunk.type == "tool_usage_started":
print(chunk.event["tool_name"])
result = stream.result
```
You must consume the stream before reading `stream.result`. Accessing the result early raises a `RuntimeError` so consumers do not accidentally treat a partial run as complete.
You can also call `flow.stream_events(...)` directly when you want streaming for a single invocation without setting `stream=True` on the Flow instance.
## Filter by Channel
`StreamSession` exposes channel projections that preserve global frame order within the selected channel:
```python
stream = flow.stream_events()
with stream:
for frame in stream.llm:
print(frame.content, end="", flush=True)
result = stream.result
```
Available projections are:
| Projection | Frames |
|------------|--------|
| `stream.events` | All frames |
| `stream.llm` | LLM frames |
| `stream.messages` | Conversation message frames |
| `stream.flow` | Flow frames |
| `stream.tools` | Tool frames |
| `stream.interleave([...])` | A selected set of channels |
Use `stream.interleave(["flow", "llm", "messages"])` when a consumer wants only some channels but still needs their relative order.
## Async Streaming
Use `astream()` for async consumers:
```python
flow = ReportFlow()
stream = flow.astream()
async with stream:
async for chunk in stream.events:
print(chunk.channel, chunk.type, chunk.content)
result = stream.result
```
The async session has the same projections as the sync session.
## Stream a Direct LLM Call
`llm.call(...)` still returns the final assembled result. Use `llm.stream_events(...)` when you want to iterate over chunks as they arrive while keeping the structured event payload:
```python
from crewai import LLM
llm = LLM(model="gpt-4o-mini")
stream = llm.stream_events(
messages=[
{
"role": "user",
"content": "Explain CrewAI streaming in two short sentences.",
}
]
)
with stream:
for chunk in stream:
print(chunk.content, end="", flush=True)
result = stream.result
```
`llm.stream_events(...)` temporarily enables streaming for the wrapped call and restores the LLM's previous `stream` setting afterward. Provider integrations continue to emit the underlying LLM stream events; this helper provides a common iterator API over those events for every LLM provider.
## Conversational Turns
Conversational Flows can stream one user turn with `stream_turn()`:
```python
from crewai import Flow
from crewai.experimental.conversational import ConversationConfig, ConversationState
@ConversationConfig(llm="gpt-4o-mini", defer_trace_finalization=True)
class ChatFlow(Flow[ConversationState]):
conversational = True
flow = ChatFlow()
stream = flow.stream_turn("What can you help me with?", session_id="session-1")
with stream:
for frame in stream.events:
if frame.channel == "llm" and frame.type == "llm_stream_chunk":
print(frame.content, end="", flush=True)
reply = stream.result
```
During `stream_turn()`, the built-in conversational answer path enables LLM token streaming for that turn and restores the LLM's previous `stream` setting afterward. Custom route handlers that create their own agents or LLM instances should configure those LLMs for streaming if they need token-level output.
## Cleanup
Use the session as a context manager when possible. If a client disconnects before the stream is exhausted, close the session explicitly:
```python
stream = flow.stream_events()
try:
for frame in stream.events:
print(frame.type)
finally:
if not stream.is_exhausted:
stream.close()
```
For async streams, use `await stream.aclose()`.
## Legacy Chunk Streaming
Crew streaming with `stream=True` still returns the chunk-oriented `CrewStreamingOutput` API described in [Streaming Crew Execution](/en/learn/streaming-crew-execution). Direct `llm.call(...)` still returns the final LLM result. The frame contract is intended for runtimes that need a stable event envelope across Flows, direct LLM calls, conversational turns, tools, and messages.

View File

@@ -4,6 +4,60 @@ description: "CrewAI의 제품 업데이트, 개선 사항 및 버그 수정"
icon: "clock"
mode: "wide"
---
<Update label="2026년 7월 1일">
## v1.15.2a2
[GitHub 릴리스 보기](https://github.com/crewAIInc/crewAI/releases/tag/1.15.2a2)
## 변경 사항
### 기능
- bedrock 추가에 aiobotocore 추가
- 흐름 에이전트 옵션 문서화
- 흐름 기술 예제에 텍스트 도우미 추가
- 흐름 CEL 프롬프트를 위한 텍스트 도우미 추가
- 탐색에 스트리밍 문서 추가
### 버그 수정
- 자기 청취 흐름 메서드 거부
### 문서
- v1.15.2a1에 대한 스냅샷 및 변경 로그 업데이트
- AGENTS.md 파일 압축
## 기여자
@akaKuruma, @github-code-quality[bot], @lorenzejay, @vinibrsl
</Update>
<Update label="2026년 6월 30일">
## v1.15.2a1
[GitHub 릴리스 보기](https://github.com/crewAIInc/crewAI/releases/tag/1.15.2a1)
## 변경 사항
### 기능
- 템플릿 명령을 crewAIInc-fde 조직으로 재지정
- 인라인 기술 정의 지원
- 흐름을 위한 스트림 프레임 프로토콜 정의
- CrewDefinition에 타입 도구 및 앱 추가
- 생성된 흐름 정의 저작 기술 추가
### 버그 수정
- 새로운 페이지가 삭제되는 것을 방지하기 위해 Edge에서 문서 버전 탐색 제거
### 문서
- 에이전트 제어 평면에서 비용 한도 규칙 유형 문서화
- Datadog 가이드에서 CREWAI_LOG_FORMAT 참조 제거
## 기여자
@danielfsbarreto, @joaomdmoura, @lorenzejay, @lucasgomide, @vinibrsl
</Update>
<Update label="2026년 6월 26일">
## v1.15.1

View File

@@ -21,12 +21,11 @@ CrewAI supports two log-ingestion paths to Datadog — both are first-class and
<Tabs>
<Tab title="Datadog Agent">
The Datadog Agent runs alongside your CrewAI containers (typically as a DaemonSet on Kubernetes) and tails their stdout. With `CREWAI_LOG_FORMAT=json` set, each log event ships as a single billable line with structured attributes.
The Datadog Agent runs alongside your CrewAI containers (typically as a DaemonSet on Kubernetes) and tails their stdout. Each log event ships as a single billable line with structured attributes — see the [log schema reference](#log-schema-reference) for the full field contract.
**Setup:**
1. Run the Datadog Agent next to your CrewAI containers — see [Datadog's deployment docs](https://docs.datadoghq.com/agent/) for Kubernetes, ECS, or VM setup. Enable log collection (`logs_enabled: true`) and container log collection (`logs_config.container_collect_all: true`).
2. Set `CREWAI_LOG_FORMAT=json` as an **automation environment variable** in CrewAI AMP (open your automation → **Settings → Environment Variables**) so each log event is a single line instead of a multi-line traceback. AMP propagates the value to every container in the deployment (API + workers) — don't set it on the container or host directly. See [Enabling JSON output](#enabling-json-output) below for the AMP UI walkthrough and the [log schema reference](#log-schema-reference) for the full field contract.
3. Confirm logs arrive in Datadog Logs with the JSON fields parsed — see [Verify ingestion](#verify-ingestion).
2. Confirm logs arrive in Datadog Logs with the JSON fields parsed — see [Verify ingestion](#verify-ingestion).
**Pick this path if** you already operate Datadog Agents (e.g. for infrastructure metrics), or your log volume makes per-event ingestion cost a real concern — collapsing tracebacks into single events keeps Agent ingestion cheap at scale.
</Tab>
@@ -57,10 +56,10 @@ Either path lands the same structured facets in Datadog (`@automation_id`, `@kic
## Log schema reference
<Info>
This schema applies to the **Datadog Agent path** — stdout JSON logs produced when `CREWAI_LOG_FORMAT=json` is set. Logs delivered via the **Datadog OTLP intake** use OpenTelemetry attribute names and may differ; see [OpenTelemetry Export](./capture_telemetry_logs).
This schema applies to the **Datadog Agent path** — structured stdout JSON logs emitted by every CrewAI worker container. Logs delivered via the **Datadog OTLP intake** use OpenTelemetry attribute names and may differ; see [OpenTelemetry Export](./capture_telemetry_logs).
</Info>
When `CREWAI_LOG_FORMAT=json` is set, every log event is emitted as a **single JSON object per line** to stdout, with internal newlines escaped. The format is plain JSON — Datadog parses it natively, and the same payload is also consumable by Splunk, Loki, Elasticsearch, and CloudWatch without custom log pipelines.
Every log event is emitted as a **single JSON object per line** to stdout, with internal newlines escaped. The format is plain JSON — Datadog parses it natively, and the same payload is also consumable by Splunk, Loki, Elasticsearch, and CloudWatch without custom log pipelines.
### Why JSON output
@@ -79,20 +78,6 @@ When `CREWAI_LOG_FORMAT=json` is set, every log event is emitted as a **single J
</Card>
</CardGroup>
### Enabling JSON output
`CREWAI_LOG_FORMAT=json` must be set as an **automation environment variable** in CrewAI AMP — it is **not** a container, host, or Docker setting. Open your automation in AMP, click the **Settings** icon, and add the variable under the **Environment Variables** section. AMP applies the value to every container in the deployment (API + workers) on the next restart. See [Update Your Crew](./update-crew) for the full UI walkthrough with screenshots.
```shell
CREWAI_LOG_FORMAT=json
```
Restart the deployment to pick up the change. Every log line on stdout from that point on is a single JSON object.
<Note>
The default value is `text`, which preserves the legacy human-readable line format byte-for-byte. Setting any value other than `json` falls back to text mode. There is no migration step — the variable is read at process start and the format switches immediately.
</Note>
### Example events
A single info-level log inside an active automation kickoff:
@@ -135,7 +120,7 @@ An error with a Python exception is collapsed into a single event with the trace
}
```
The same error in legacy text mode would have produced ~25 separate log events (one per traceback line) — all of which the backend would bill and index individually.
Without JSON output, that same error would produce ~25 separate log events (one per traceback line) — all of which the backend would bill and index individually.
### Schema v1 fields
@@ -237,7 +222,7 @@ Open [Logs Explorer](https://app.datadoghq.com/logs) and run a query that matche
<Tab title="Datadog Agent">
Search `service:crewai* @schema:v1`. You should see structured logs with the JSON fields parsed into Datadog facets. Pick a recent event and verify it has `@automation_id`, `@kickoff_id`, `@execution_id`, `@crewai_version`, and (when running inside a span) `@trace_id` / `@span_id` populated.
If nothing appears, confirm `CREWAI_LOG_FORMAT=json` is set under your automation's **Environment Variables** in AMP, the deployment was restarted after the change, and the Datadog Agent is tailing container stdout.
If nothing appears, confirm the Datadog Agent is tailing container stdout and that the deployment is running a recent enough CrewAI Enterprise build.
</Tab>
<Tab title="Datadog OTLP intake">
Search `source:otlp service:crewai*`. OTLP attributes land with their OpenTelemetry names (`automation_id`, `crewai.kickoff.id`, etc.) rather than the stdout JSON keys, but they map to the same dashboard facets after [facet promotion](#prerequisite-promote-facets).
@@ -280,7 +265,7 @@ The `$service` template variable defaults to `*` and will catch every CrewAI dep
| All widgets show "No data" | Facets aren't promoted | Re-do the [Promote facets](#prerequisite-promote-facets) step. Datadog won't query against an un-promoted field. |
| Error Rate widget shows `NaN` | No executions in the time window | Either no traffic, or `@execution_id` isn't faceted. Expand the time range and re-check facets. |
| Throughput chart is flat at the same value | Logs aren't reaching Datadog | Search `service:crewai*` in Logs Explorer. If nothing shows, verify the Datadog Agent is running (Agent path) or the OTel collector endpoint is correct (OTLP path). |
| `crewai_version` shows fewer values than expected | Some containers predate the structured-logs work | The `crewai_version` field was added alongside JSON output. Older deployments running text mode (or older AMP builds) won't emit it. Upgrade those deployments to pick up the field. See the [log schema reference](#log-schema-reference) for the full field contract. |
| `crewai_version` shows fewer values than expected | Some containers predate the structured-logs work | The `crewai_version` field was added alongside JSON output. Older deployments (pre-structured-logs AMP builds) won't emit it. Upgrade those deployments to pick up the field. See the [log schema reference](#log-schema-reference) for the full field contract. |
| Template variables don't filter widgets | The widget's filter line doesn't reference the template variable | Edit the widget and confirm the search includes `$automation $version $service`. |
## Next steps

View File

@@ -0,0 +1,194 @@
---
title: 스트리밍 런타임 계약
description: Flow, 직접 LLM 호출, 대화 턴에서 정렬된 런타임 프레임을 스트리밍합니다.
icon: tower-broadcast
mode: "wide"
---
## 개요
CrewAI는 단순한 텍스트 청크보다 더 많은 정보가 필요한 런타임을 위해 프레임 기반 스트리밍 계약을 제공합니다. 이 계약은 Flow 생명주기 이벤트, 직접 LLM 토큰, 도구 활동, 대화 메시지, 사용자 지정 이벤트에 대해 정렬된 `StreamFrame` 객체를 방출합니다.
UI, 서비스 브리지, 터미널 앱, 배포 런타임을 만들 때 Flow, 채팅 턴, 직접 LLM 호출이 실행되는 동안 안정적인 구조화 이벤트 스트림이 필요하다면 이 API를 사용하세요.
## StreamFrame
모든 프레임은 같은 envelope를 가집니다:
```python
from crewai.types.streaming import StreamFrame
frame.id # 고유 프레임 id
frame.seq # 사용 가능한 경우 실행 로컬 순서
frame.type # "flow_started" 같은 원본 이벤트 타입
frame.channel # "llm", "flow", "tools", "messages", "lifecycle", "custom"
frame.namespace # 소스/런타임 namespace
frame.timestamp # 이벤트 timestamp
frame.parent_id # 사용 가능한 경우 부모 이벤트 id
frame.previous_id # 사용 가능한 경우 이전 이벤트 id
frame.data # 이벤트 payload
frame.event # frame.data의 alias
frame.content # 토큰류 프레임의 출력 가능한 텍스트, 그 외에는 ""
```
`channel` 필드는 소비자에서 프레임을 라우팅하는 가장 빠른 방법입니다:
| 채널 | 포함 내용 |
|------|-----------|
| `llm` | LLM 스트리밍 이벤트의 토큰 및 thinking 청크 |
| `flow` | Flow 생명주기, 메서드 실행, 라우팅, pause/resume 이벤트 |
| `tools` | 도구 사용 이벤트 |
| `messages` | 대화 transcript 이벤트 |
| `lifecycle` | 다른 채널에 속하지 않는 런타임 생명주기 이벤트 |
| `custom` | 내장 채널에 매핑되지 않는 이벤트 |
`frame.type`은 원본 이벤트 타입을 보존하므로, 소비자는 채널 안에서 특정 이벤트를 처리할 수 있습니다.
## Flow 스트리밍
Flow에 `stream=True`를 설정하면 `kickoff()`가 stream session을 반환합니다:
```python
from crewai.flow import Flow, start
class ReportFlow(Flow):
@start()
def generate(self):
return "done"
flow = ReportFlow(stream=True)
stream = flow.kickoff()
with stream:
for chunk in stream:
print(chunk.content, end="", flush=True)
if chunk.type == "tool_usage_started":
print(chunk.event["tool_name"])
result = stream.result
```
`stream.result`를 읽기 전에 stream을 소비해야 합니다. 결과를 너무 일찍 접근하면 `RuntimeError`가 발생하여, 소비자가 부분 실행을 완료된 실행으로 잘못 처리하지 않도록 합니다.
Flow 인스턴스에 `stream=True`를 설정하지 않고 단일 호출만 스트리밍하려면 `flow.stream_events(...)`를 직접 호출할 수도 있습니다.
## 채널별 필터링
`StreamSession`은 선택한 채널 안에서 전역 프레임 순서를 보존하는 채널 projection을 제공합니다:
```python
stream = flow.stream_events()
with stream:
for frame in stream.llm:
print(frame.content, end="", flush=True)
result = stream.result
```
사용 가능한 projection은 다음과 같습니다:
| Projection | 프레임 |
|------------|--------|
| `stream.events` | 모든 프레임 |
| `stream.llm` | LLM 프레임 |
| `stream.messages` | 대화 메시지 프레임 |
| `stream.flow` | Flow 프레임 |
| `stream.tools` | 도구 프레임 |
| `stream.interleave([...])` | 선택한 채널 집합 |
소비자가 일부 채널만 원하지만 상대 순서도 필요하다면 `stream.interleave(["flow", "llm", "messages"])`를 사용하세요.
## 비동기 스트리밍
비동기 소비자는 `astream()`을 사용하세요:
```python
flow = ReportFlow()
stream = flow.astream()
async with stream:
async for chunk in stream.events:
print(chunk.channel, chunk.type, chunk.content)
result = stream.result
```
비동기 세션은 동기 세션과 같은 projection을 제공합니다.
## 직접 LLM 호출 스트리밍
`llm.call(...)`은 계속 최종 조립 결과를 반환합니다. 구조화된 이벤트 payload를 유지하면서 청크가 도착하는 대로 반복 처리하려면 `llm.stream_events(...)`를 사용하세요:
```python
from crewai import LLM
llm = LLM(model="gpt-4o-mini")
stream = llm.stream_events(
messages=[
{
"role": "user",
"content": "Explain CrewAI streaming in two short sentences.",
}
]
)
with stream:
for chunk in stream:
print(chunk.content, end="", flush=True)
result = stream.result
```
`llm.stream_events(...)`는 감싼 호출 동안 일시적으로 streaming을 활성화하고, 이후 LLM의 이전 `stream` 설정을 복원합니다. provider 통합은 계속 기본 LLM stream 이벤트를 방출하며, 이 helper는 모든 LLM provider에서 그 이벤트 위에 공통 iterator API를 제공합니다.
## 대화 턴
대화형 Flow는 `stream_turn()`으로 사용자 턴 하나를 스트리밍할 수 있습니다:
```python
from crewai import Flow
from crewai.experimental.conversational import ConversationConfig, ConversationState
@ConversationConfig(llm="gpt-4o-mini", defer_trace_finalization=True)
class ChatFlow(Flow[ConversationState]):
conversational = True
flow = ChatFlow()
stream = flow.stream_turn("What can you help me with?", session_id="session-1")
with stream:
for frame in stream.events:
if frame.channel == "llm" and frame.type == "llm_stream_chunk":
print(frame.content, end="", flush=True)
reply = stream.result
```
`stream_turn()` 중에는 내장 대화 응답 경로가 해당 턴에 대해 LLM 토큰 스트리밍을 활성화하고 이후 LLM의 이전 `stream` 설정을 복원합니다. 자체 agent 또는 LLM 인스턴스를 만드는 사용자 지정 route handler는 토큰 단위 출력이 필요하다면 해당 LLM을 streaming으로 구성해야 합니다.
## 정리
가능하면 세션을 context manager로 사용하세요. stream이 끝나기 전에 클라이언트 연결이 끊기면 세션을 명시적으로 닫으세요:
```python
stream = flow.stream_events()
try:
for frame in stream.events:
print(frame.type)
finally:
if not stream.is_exhausted:
stream.close()
```
비동기 stream에서는 `await stream.aclose()`를 사용하세요.
## 레거시 청크 스트리밍
`stream=True`를 사용하는 Crew 스트리밍은 계속 [스트리밍 Crew 실행](/ko/learn/streaming-crew-execution)에 설명된 청크 중심 `CrewStreamingOutput` API를 반환합니다. 직접 `llm.call(...)` 호출도 계속 최종 LLM 결과를 반환합니다. 프레임 계약은 Flow, 직접 LLM 호출, 대화 턴, 도구, 메시지 전반에서 안정적인 이벤트 envelope가 필요한 런타임을 위한 것입니다.

View File

@@ -4,6 +4,60 @@ description: "Atualizações de produto, melhorias e correções do CrewAI"
icon: "clock"
mode: "wide"
---
<Update label="01 jul 2026">
## v1.15.2a2
[Ver release no GitHub](https://github.com/crewAIInc/crewAI/releases/tag/1.15.2a2)
## O que Mudou
### Recursos
- Adicionar aiobotocore ao extra bedrock
- Documentar opções do agente de fluxo
- Adicionar helper de texto ao exemplo de habilidade de fluxo
- Adicionar helper de texto para prompts CEL de fluxo
- Adicionar documentação de streaming à navegação
### Correções de Bugs
- Rejeitar métodos de fluxo de autoescuta
### Documentação
- Atualizar snapshot e changelog para v1.15.2a1
- Compactar arquivo AGENTS.md
## Contribuidores
@akaKuruma, @github-code-quality[bot], @lorenzejay, @vinibrsl
</Update>
<Update label="30 jun 2026">
## v1.15.2a1
[Ver release no GitHub](https://github.com/crewAIInc/crewAI/releases/tag/1.15.2a1)
## O que Mudou
### Recursos
- Reapontar comandos de template para a organização crewAIInc-fde
- Suporte a definições de habilidades inline
- Definir protocolo de quadro de fluxo para fluxos
- Adicionar ferramenta de tipo e aplicativo em CrewDefinition
- Adicionar habilidade de autoria de Definição de Fluxo gerada
### Correções de Bugs
- Remover a navegação de versão de docs do Edge para evitar que novas páginas sejam descartadas
### Documentação
- Documentar o tipo de regra Limite de Custo no Painel de Controle do Agente
- Remover referências a CREWAI_LOG_FORMAT do guia Datadog
## Contribuidores
@danielfsbarreto, @joaomdmoura, @lorenzejay, @lucasgomide, @vinibrsl
</Update>
<Update label="26 jun 2026">
## v1.15.1

View File

@@ -21,12 +21,11 @@ CrewAI supports two log-ingestion paths to Datadog — both are first-class and
<Tabs>
<Tab title="Datadog Agent">
The Datadog Agent runs alongside your CrewAI containers (typically as a DaemonSet on Kubernetes) and tails their stdout. With `CREWAI_LOG_FORMAT=json` set, each log event ships as a single billable line with structured attributes.
The Datadog Agent runs alongside your CrewAI containers (typically as a DaemonSet on Kubernetes) and tails their stdout. Each log event ships as a single billable line with structured attributes — see the [log schema reference](#log-schema-reference) for the full field contract.
**Setup:**
1. Run the Datadog Agent next to your CrewAI containers — see [Datadog's deployment docs](https://docs.datadoghq.com/agent/) for Kubernetes, ECS, or VM setup. Enable log collection (`logs_enabled: true`) and container log collection (`logs_config.container_collect_all: true`).
2. Set `CREWAI_LOG_FORMAT=json` as an **automation environment variable** in CrewAI AMP (open your automation → **Settings → Environment Variables**) so each log event is a single line instead of a multi-line traceback. AMP propagates the value to every container in the deployment (API + workers) — don't set it on the container or host directly. See [Enabling JSON output](#enabling-json-output) below for the AMP UI walkthrough and the [log schema reference](#log-schema-reference) for the full field contract.
3. Confirm logs arrive in Datadog Logs with the JSON fields parsed — see [Verify ingestion](#verify-ingestion).
2. Confirm logs arrive in Datadog Logs with the JSON fields parsed — see [Verify ingestion](#verify-ingestion).
**Pick this path if** you already operate Datadog Agents (e.g. for infrastructure metrics), or your log volume makes per-event ingestion cost a real concern — collapsing tracebacks into single events keeps Agent ingestion cheap at scale.
</Tab>
@@ -57,10 +56,10 @@ Either path lands the same structured facets in Datadog (`@automation_id`, `@kic
## Log schema reference
<Info>
This schema applies to the **Datadog Agent path** — stdout JSON logs produced when `CREWAI_LOG_FORMAT=json` is set. Logs delivered via the **Datadog OTLP intake** use OpenTelemetry attribute names and may differ; see [OpenTelemetry Export](./capture_telemetry_logs).
This schema applies to the **Datadog Agent path** — structured stdout JSON logs emitted by every CrewAI worker container. Logs delivered via the **Datadog OTLP intake** use OpenTelemetry attribute names and may differ; see [OpenTelemetry Export](./capture_telemetry_logs).
</Info>
When `CREWAI_LOG_FORMAT=json` is set, every log event is emitted as a **single JSON object per line** to stdout, with internal newlines escaped. The format is plain JSON — Datadog parses it natively, and the same payload is also consumable by Splunk, Loki, Elasticsearch, and CloudWatch without custom log pipelines.
Every log event is emitted as a **single JSON object per line** to stdout, with internal newlines escaped. The format is plain JSON — Datadog parses it natively, and the same payload is also consumable by Splunk, Loki, Elasticsearch, and CloudWatch without custom log pipelines.
### Why JSON output
@@ -79,20 +78,6 @@ When `CREWAI_LOG_FORMAT=json` is set, every log event is emitted as a **single J
</Card>
</CardGroup>
### Enabling JSON output
`CREWAI_LOG_FORMAT=json` must be set as an **automation environment variable** in CrewAI AMP — it is **not** a container, host, or Docker setting. Open your automation in AMP, click the **Settings** icon, and add the variable under the **Environment Variables** section. AMP applies the value to every container in the deployment (API + workers) on the next restart. See [Update Your Crew](./update-crew) for the full UI walkthrough with screenshots.
```shell
CREWAI_LOG_FORMAT=json
```
Restart the deployment to pick up the change. Every log line on stdout from that point on is a single JSON object.
<Note>
The default value is `text`, which preserves the legacy human-readable line format byte-for-byte. Setting any value other than `json` falls back to text mode. There is no migration step — the variable is read at process start and the format switches immediately.
</Note>
### Example events
A single info-level log inside an active automation kickoff:
@@ -135,7 +120,7 @@ An error with a Python exception is collapsed into a single event with the trace
}
```
The same error in legacy text mode would have produced ~25 separate log events (one per traceback line) — all of which the backend would bill and index individually.
Without JSON output, that same error would produce ~25 separate log events (one per traceback line) — all of which the backend would bill and index individually.
### Schema v1 fields
@@ -237,7 +222,7 @@ Open [Logs Explorer](https://app.datadoghq.com/logs) and run a query that matche
<Tab title="Datadog Agent">
Search `service:crewai* @schema:v1`. You should see structured logs with the JSON fields parsed into Datadog facets. Pick a recent event and verify it has `@automation_id`, `@kickoff_id`, `@execution_id`, `@crewai_version`, and (when running inside a span) `@trace_id` / `@span_id` populated.
If nothing appears, confirm `CREWAI_LOG_FORMAT=json` is set under your automation's **Environment Variables** in AMP, the deployment was restarted after the change, and the Datadog Agent is tailing container stdout.
If nothing appears, confirm the Datadog Agent is tailing container stdout and that the deployment is running a recent enough CrewAI Enterprise build.
</Tab>
<Tab title="Datadog OTLP intake">
Search `source:otlp service:crewai*`. OTLP attributes land with their OpenTelemetry names (`automation_id`, `crewai.kickoff.id`, etc.) rather than the stdout JSON keys, but they map to the same dashboard facets after [facet promotion](#prerequisite-promote-facets).
@@ -280,7 +265,7 @@ The `$service` template variable defaults to `*` and will catch every CrewAI dep
| All widgets show "No data" | Facets aren't promoted | Re-do the [Promote facets](#prerequisite-promote-facets) step. Datadog won't query against an un-promoted field. |
| Error Rate widget shows `NaN` | No executions in the time window | Either no traffic, or `@execution_id` isn't faceted. Expand the time range and re-check facets. |
| Throughput chart is flat at the same value | Logs aren't reaching Datadog | Search `service:crewai*` in Logs Explorer. If nothing shows, verify the Datadog Agent is running (Agent path) or the OTel collector endpoint is correct (OTLP path). |
| `crewai_version` shows fewer values than expected | Some containers predate the structured-logs work | The `crewai_version` field was added alongside JSON output. Older deployments running text mode (or older AMP builds) won't emit it. Upgrade those deployments to pick up the field. See the [log schema reference](#log-schema-reference) for the full field contract. |
| `crewai_version` shows fewer values than expected | Some containers predate the structured-logs work | The `crewai_version` field was added alongside JSON output. Older deployments (pre-structured-logs AMP builds) won't emit it. Upgrade those deployments to pick up the field. See the [log schema reference](#log-schema-reference) for the full field contract. |
| Template variables don't filter widgets | The widget's filter line doesn't reference the template variable | Edit the widget and confirm the search includes `$automation $version $service`. |
## Next steps

View File

@@ -0,0 +1,194 @@
---
title: Contrato de Streaming do Runtime
description: Transmita frames ordenados do runtime a partir de Flows, chamadas diretas de LLM e turnos conversacionais.
icon: tower-broadcast
mode: "wide"
---
## Visão geral
O CrewAI expõe um contrato de streaming baseado em frames para runtimes que precisam de mais do que chunks de texto simples. O contrato emite objetos `StreamFrame` ordenados para eventos de ciclo de vida de Flow, tokens de LLM diretos, atividade de ferramentas, mensagens de conversa e eventos personalizados.
Use esta API ao criar uma UI, ponte de serviço, aplicativo de terminal ou runtime de implantação que precise de um fluxo estável de eventos estruturados enquanto um Flow, turno de chat ou chamada direta de LLM está em execução.
## StreamFrame
Todo frame tem o mesmo envelope:
```python
from crewai.types.streaming import StreamFrame
frame.id # id único do frame
frame.seq # ordem local da execução, quando disponível
frame.type # tipo do evento de origem, como "flow_started"
frame.channel # "llm", "flow", "tools", "messages", "lifecycle" ou "custom"
frame.namespace # namespace de origem/runtime
frame.timestamp # timestamp do evento
frame.parent_id # id do evento pai, quando disponível
frame.previous_id # id do evento anterior, quando disponível
frame.data # payload do evento
frame.event # alias para frame.data
frame.content # texto imprimível para frames de token, caso contrário ""
```
O campo `channel` é a forma mais rápida de rotear frames em consumidores:
| Canal | Contém |
|-------|--------|
| `llm` | Tokens e chunks de raciocínio de eventos de streaming de LLM |
| `flow` | Ciclo de vida do Flow, execução de métodos, roteamento e eventos de pausa/retomada |
| `tools` | Eventos de uso de ferramentas |
| `messages` | Eventos do transcript da conversa |
| `lifecycle` | Eventos de ciclo de vida do runtime que não pertencem a outro canal |
| `custom` | Eventos que não mapeiam para um canal integrado |
`frame.type` preserva o tipo do evento de origem, para que consumidores possam tratar eventos específicos dentro de um canal.
## Transmitir um Flow
Defina `stream=True` em um Flow para fazer `kickoff()` retornar uma sessão de stream:
```python
from crewai.flow import Flow, start
class ReportFlow(Flow):
@start()
def generate(self):
return "done"
flow = ReportFlow(stream=True)
stream = flow.kickoff()
with stream:
for chunk in stream:
print(chunk.content, end="", flush=True)
if chunk.type == "tool_usage_started":
print(chunk.event["tool_name"])
result = stream.result
```
Você deve consumir o stream antes de ler `stream.result`. Acessar o resultado cedo demais gera um `RuntimeError`, para que consumidores não tratem uma execução parcial como concluída.
Você também pode chamar `flow.stream_events(...)` diretamente quando quiser streaming para uma única invocação sem definir `stream=True` na instância do Flow.
## Filtrar por canal
`StreamSession` expõe projeções por canal que preservam a ordem global dos frames dentro do canal selecionado:
```python
stream = flow.stream_events()
with stream:
for frame in stream.llm:
print(frame.content, end="", flush=True)
result = stream.result
```
As projeções disponíveis são:
| Projeção | Frames |
|----------|--------|
| `stream.events` | Todos os frames |
| `stream.llm` | Frames de LLM |
| `stream.messages` | Frames de mensagens de conversa |
| `stream.flow` | Frames de Flow |
| `stream.tools` | Frames de ferramentas |
| `stream.interleave([...])` | Um conjunto selecionado de canais |
Use `stream.interleave(["flow", "llm", "messages"])` quando um consumidor quiser apenas alguns canais, mas ainda precisar da ordem relativa entre eles.
## Streaming assíncrono
Use `astream()` para consumidores assíncronos:
```python
flow = ReportFlow()
stream = flow.astream()
async with stream:
async for chunk in stream.events:
print(chunk.channel, chunk.type, chunk.content)
result = stream.result
```
A sessão assíncrona tem as mesmas projeções da sessão síncrona.
## Transmitir uma chamada direta de LLM
`llm.call(...)` ainda retorna o resultado final montado. Use `llm.stream_events(...)` quando quiser iterar pelos chunks conforme eles chegam, mantendo o payload estruturado do evento:
```python
from crewai import LLM
llm = LLM(model="gpt-4o-mini")
stream = llm.stream_events(
messages=[
{
"role": "user",
"content": "Explain CrewAI streaming in two short sentences.",
}
]
)
with stream:
for chunk in stream:
print(chunk.content, end="", flush=True)
result = stream.result
```
`llm.stream_events(...)` ativa temporariamente o streaming para a chamada encapsulada e restaura a configuração anterior de `stream` do LLM depois. As integrações de provedores continuam emitindo os eventos de stream de LLM subjacentes; esse helper fornece uma API de iterador comum sobre esses eventos para todos os provedores de LLM.
## Turnos conversacionais
Flows conversacionais podem transmitir um turno de usuário com `stream_turn()`:
```python
from crewai import Flow
from crewai.experimental.conversational import ConversationConfig, ConversationState
@ConversationConfig(llm="gpt-4o-mini", defer_trace_finalization=True)
class ChatFlow(Flow[ConversationState]):
conversational = True
flow = ChatFlow()
stream = flow.stream_turn("What can you help me with?", session_id="session-1")
with stream:
for frame in stream.events:
if frame.channel == "llm" and frame.type == "llm_stream_chunk":
print(frame.content, end="", flush=True)
reply = stream.result
```
Durante `stream_turn()`, o caminho de resposta conversacional integrado ativa o streaming de tokens de LLM para esse turno e restaura a configuração anterior de `stream` do LLM depois. Handlers de rota personalizados que criam seus próprios agentes ou instâncias de LLM devem configurar esses LLMs para streaming se precisarem de saída em nível de token.
## Limpeza
Use a sessão como gerenciador de contexto quando possível. Se um cliente se desconectar antes de o stream ser esgotado, feche a sessão explicitamente:
```python
stream = flow.stream_events()
try:
for frame in stream.events:
print(frame.type)
finally:
if not stream.is_exhausted:
stream.close()
```
Para streams assíncronos, use `await stream.aclose()`.
## Streaming de chunks legado
O streaming de Crew com `stream=True` ainda retorna a API orientada a chunks `CrewStreamingOutput` descrita em [Streaming da Execução de Crew](/pt-BR/learn/streaming-crew-execution). Chamadas diretas `llm.call(...)` ainda retornam o resultado final do LLM. O contrato de frames é destinado a runtimes que precisam de um envelope de evento estável em Flows, chamadas diretas de LLM, turnos conversacionais, ferramentas e mensagens.

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

View File

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

View File

@@ -1 +1 @@
__version__ = "1.15.1"
__version__ = "1.15.2a2"

View File

@@ -126,10 +126,7 @@ def _create_declarative_flow(
package_dir = Path(__file__).parent
templates_dir = package_dir / "templates" / "declarative_flow"
agents_md_src = package_dir / "templates" / "AGENTS.md"
if agents_md_src.exists():
shutil.copy2(agents_md_src, project_root / "AGENTS.md")
root_template_files = {".gitignore", "AGENTS.md", "README.md", "pyproject.toml"}
for src_file in templates_dir.rglob("*"):
if not src_file.is_file():
@@ -138,7 +135,7 @@ def _create_declarative_flow(
relative_path = src_file.relative_to(templates_dir)
dst_file = (
project_root / relative_path
if relative_path.name in {".gitignore", "README.md", "pyproject.toml"}
if relative_path.name in root_template_files
else package_root / relative_path
)
dst_file.parent.mkdir(parents=True, exist_ok=True)

View File

@@ -14,6 +14,7 @@ from rich.text import Text
from crewai_cli.constants import ENV_VARS
from crewai_cli.git import initialize_if_git_available
from crewai_cli.model_catalog import get_provider_models
from crewai_cli.tui_picker import pick_many, pick_one
from crewai_cli.utils import (
enable_prompt_line_editing,
@@ -42,41 +43,50 @@ _PROVIDERS: list[tuple[str, str]] = [
("watson", "IBM watsonx"),
]
# Curated offline fallback / label source. The picker prefers models pulled
# live from the vendor's own API via ``model_catalog.get_provider_models``;
# this list is the hand-verified backstop used when no API key is available.
# Keep entries to real, current model ids — last verified against each vendor's
# official model docs on 2026-07-05.
_PROVIDER_MODELS: dict[str, list[tuple[str, str]]] = {
"openai": [
("gpt-5.5", "GPT-5.5"),
("gpt-5.5-pro", "GPT-5.5 Pro"),
("gpt-5.4", "GPT-5.4"),
("o4-mini", "o4-mini"),
("gpt-5.4-mini", "GPT-5.4 Mini"),
("gpt-5.2", "GPT-5.2"),
("gpt-4.1", "GPT-4.1"),
("gpt-4.1-mini", "GPT-4.1 Mini"),
],
"anthropic": [
("claude-opus-4-6", "Claude Opus 4.6"),
("claude-fable-5", "Claude Fable 5"),
("claude-opus-4-8", "Claude Opus 4.8"),
("claude-sonnet-5", "Claude Sonnet 5"),
("claude-opus-4-7", "Claude Opus 4.7"),
("claude-haiku-4-5", "Claude Haiku 4.5"),
("claude-sonnet-4-6", "Claude Sonnet 4.6"),
("claude-haiku-4-5-20251001", "Claude Haiku 4.5"),
("claude-3-7-sonnet-20250219", "Claude 3.7 Sonnet"),
("claude-3-5-sonnet-20241022", "Claude 3.5 Sonnet"),
],
"gemini": [
("gemini-3-pro-preview", "Gemini 3 Pro (preview)"),
("gemini-2.5-pro-exp-03-25", "Gemini 2.5 Pro"),
("gemini-2.5-flash-preview-04-17", "Gemini 2.5 Flash"),
("gemini-2.0-flash-001", "Gemini 2.0 Flash"),
("gemini-1.5-pro", "Gemini 1.5 Pro"),
("gemini-3.5-flash", "Gemini 3.5 Flash"),
("gemini-3.1-pro-preview", "Gemini 3.1 Pro (preview)"),
("gemini-3-flash-preview", "Gemini 3 Flash (preview)"),
("gemini-2.5-pro", "Gemini 2.5 Pro"),
("gemini-2.5-flash", "Gemini 2.5 Flash"),
("gemini-2.5-flash-lite", "Gemini 2.5 Flash Lite"),
],
"groq": [
("meta-llama/llama-4-maverick-17b-128e-instruct", "Llama 4 Maverick"),
("meta-llama/llama-4-scout-17b-16e-instruct", "Llama 4 Scout"),
("openai/gpt-oss-120b", "GPT-OSS 120B"),
("qwen/qwen3-32b", "Qwen3 32B"),
("moonshotai/kimi-k2-instruct-0905", "Kimi K2"),
("llama-3.3-70b-versatile", "Llama 3.3 70B"),
("llama-3.1-70b-versatile", "Llama 3.1 70B"),
("llama-3.1-8b-instant", "Llama 3.1 8B"),
("deepseek-r1-distill-llama-70b", "DeepSeek R1 70B"),
("mixtral-8x7b-32768", "Mixtral 8x7B"),
],
"ollama": [
("llama3.3", "Llama 3.3"),
("llama3.1", "Llama 3.1"),
("qwen3", "Qwen 3"),
("deepseek-r1", "DeepSeek R1"),
("qwen2.5", "Qwen 2.5"),
("gpt-oss", "GPT-OSS"),
("gemma3", "Gemma 3"),
("mistral", "Mistral"),
],
}
@@ -758,7 +768,9 @@ def _select_model() -> str:
provider_key, provider_name = _PROVIDERS[p_idx]
click.secho(f"{provider_name}", fg="green")
models = _PROVIDER_MODELS.get(provider_key, [])
# Prefer the latest models pulled live from the vendor / LiteLLM; the
# curated ``_PROVIDER_MODELS`` entry is the offline fallback and label source.
models = get_provider_models(provider_key, _PROVIDER_MODELS.get(provider_key, []))
if not models:
custom = click.prompt(
click.style(f" Enter model name for {provider_key}/", fg="cyan"),

View File

@@ -0,0 +1,657 @@
"""Dynamic model catalog for the crew-creation wizard.
Resolves the models to offer for a given provider using a three-tier strategy:
1. **Vendor API** - when the provider's API key is already present in the
environment, query the vendor's own model-listing endpoint. This is the only
source that reliably reflects the *latest* models (real release dates /
display names, straight from the vendor).
2. **Curated hardcoded fallback** - the hand-verified list baked into the
wizard, used when no API key is available. Authoritative but frozen, so it is
refreshed periodically.
3. **LiteLLM feed** - the community ``model_prices_and_context_window.json`` the
CLI already caches. Only used for providers with *no* curated list: the feed
lags real releases badly (it can miss a vendor's newest models entirely), so
it must never preempt the curated fallback.
Every tier is best-effort: any network error, timeout, missing key, or empty
result quietly falls through to the next tier, and the caller's hardcoded list
is always the final backstop. The picker never blocks for long — network calls
use a short timeout and successful results are cached.
"""
from __future__ import annotations
from collections.abc import Callable
import contextlib
import json
import os
from pathlib import Path
import re
import time
from typing import Any
import certifi
import httpx
from crewai_cli.constants import JSON_URL
# ── Tunables ─────────────────────────────────────────────────────
#: How many models to surface per provider.
MAX_MODELS = 8
#: Timeout (seconds) for any network call made while resolving models.
_TIMEOUT = 6.0
#: How long a resolved (dynamic) catalog stays fresh before we refetch.
_CATALOG_TTL = 6 * 3600
#: How long a fallback result is cached after a failed/empty fetch. Short, so a
#: newly-added API key takes effect soon, but long enough to spare the picker a
#: repeated timeout-prone network attempt on every call within one session.
_NEGATIVE_TTL = 300
#: How long the shared LiteLLM feed cache stays fresh.
_LITELLM_TTL = 24 * 3600
#: Env vars that may hold each provider's API key, in priority order. A
#: provider with an empty tuple (e.g. local Ollama) needs no key. Gemini accepts
#: either name, matching crewai's own Gemini provider.
_PROVIDER_KEY_ENV: dict[str, tuple[str, ...]] = {
"openai": ("OPENAI_API_KEY",),
"anthropic": ("ANTHROPIC_API_KEY",),
"gemini": ("GEMINI_API_KEY", "GOOGLE_API_KEY"),
"groq": ("GROQ_API_KEY",),
"cerebras": ("CEREBRAS_API_KEY",),
"ollama": (),
}
def _provider_api_key(provider_key: str) -> str | None:
"""First non-empty API key found among the provider's env vars."""
for env in _PROVIDER_KEY_ENV.get(provider_key, ()):
value = os.environ.get(env)
if value:
return value
return None
# Substrings that mark a model id as *not* a chat/completion model. Used to
# filter noisy OpenAI-compatible ``/models`` listings.
_NON_CHAT_MARKERS = (
"embedding",
"embed",
"whisper",
"tts",
"audio",
"transcribe",
"realtime",
"dall-e",
"dalle",
"image",
"moderation",
"similarity",
"-edit",
"davinci-002",
"babbage-002",
"computer-use",
"guard",
)
_ACRONYMS = {
"gpt": "GPT",
"ai": "AI",
"nim": "NIM",
"llm": "LLM",
"hd": "HD",
"us": "US",
"eu": "EU",
"oss": "OSS",
"it": "IT",
}
# Tokens with non-title-case brand capitalization.
_BRAND_TOKENS = {
"deepseek": "DeepSeek",
"chatgpt": "ChatGPT",
"qwq": "QwQ",
}
# ── Public API ───────────────────────────────────────────────────
def get_provider_models(
provider_key: str, fallback: list[tuple[str, str]]
) -> list[tuple[str, str]]:
"""Return ``(model_id, label)`` pairs for ``provider_key``, newest first.
Tries the vendor API (if a key is in the environment) first, since it is the
only reliably-fresh source. When no key is available it returns the curated
``fallback`` verbatim — the LiteLLM feed is consulted **only** for providers
with no curated list, because the feed lags real releases and would
otherwise surface a staler list than the hand-verified fallback. Never
raises: any failure degrades to the next tier.
Args:
provider_key: Short provider identifier, e.g. ``"anthropic"``.
fallback: Curated ``(model_id, label)`` pairs to use as the backstop and
to source friendly labels for known models.
Returns:
Up to :data:`MAX_MODELS` ``(model_id, label)`` pairs. Falls back to
``fallback`` verbatim when no fresher list can be resolved.
"""
cached = _read_catalog_cache(provider_key)
if cached is not None:
return cached
label_map = {model_id: label for model_id, label in fallback}
# A non-None vendor result is authoritative — even when empty (e.g. a
# reachable Ollama with no models installed): show that rather than
# hardcoded suggestions the crew can't actually run. The picker handles an
# empty list by prompting for manual entry.
vendor = _from_vendor(provider_key)
if vendor is not None:
result = _finalize(vendor, label_map)
if result:
_write_catalog_cache(provider_key, result, source="dynamic")
return result
# Vendor tier unavailable. The LiteLLM feed lags real releases, so only
# reach for it when we have no curated fallback — never override the fallback.
entries = _from_litellm(provider_key) if not fallback else None
result = _finalize(entries, label_map) if entries else []
if result:
_write_catalog_cache(provider_key, result, source="dynamic")
return result
# Nothing fresher than the curated list. Cache it briefly (negative cache)
# so a failed vendor/LiteLLM fetch isn't retried on every subsequent call.
# Skip Ollama: it's a local, fast-failing server, so re-probing is cheap and
# avoids serving suggestions after the server comes up within the TTL.
if fallback and provider_key != "ollama":
_write_catalog_cache(provider_key, fallback, source="fallback")
return fallback
# ── Tier 1: vendor APIs ──────────────────────────────────────────
def _from_vendor(provider_key: str) -> list[dict[str, Any]] | None:
"""Fetch models from the vendor.
Returns the model list on a successful fetch — **including an empty list**,
which is meaningful (e.g. a reachable Ollama server with nothing installed).
Returns ``None`` only when the vendor tier is unavailable: no fetcher, no
API key, or the request failed.
"""
fetcher = _VENDOR_FETCHERS.get(provider_key)
if fetcher is None:
return None
api_key = _provider_api_key(provider_key)
if _PROVIDER_KEY_ENV.get(provider_key) and not api_key:
# Provider needs a key and none is set — skip to the next tier.
return None
try:
return fetcher(api_key)
except Exception:
# Network error, auth failure, unexpected payload — degrade quietly.
return None
def _fetch_openai(api_key: str | None) -> list[dict[str, Any]]:
return _fetch_openai_compatible("https://api.openai.com/v1", api_key)
def _fetch_groq(api_key: str | None) -> list[dict[str, Any]]:
return _fetch_openai_compatible("https://api.groq.com/openai/v1", api_key)
def _fetch_cerebras(api_key: str | None) -> list[dict[str, Any]]:
return _fetch_openai_compatible("https://api.cerebras.ai/v1", api_key)
def _fetch_openai_compatible(
base_url: str, api_key: str | None
) -> list[dict[str, Any]]:
"""Parse an OpenAI-shaped ``GET /models`` response."""
data = _http_get_json(
f"{base_url}/models",
headers={"Authorization": f"Bearer {api_key}"},
)
entries: list[dict[str, Any]] = []
for item in data.get("data", []):
model_id = item.get("id")
if not model_id or not _is_chat_model(model_id) or _is_fine_tune(model_id):
continue
created = _as_float(item.get("created"))
entries.append(_entry(model_id, _humanize(model_id), created=created))
return entries
def _fetch_anthropic(api_key: str | None) -> list[dict[str, Any]]:
data = _http_get_json(
"https://api.anthropic.com/v1/models",
headers={"x-api-key": api_key or "", "anthropic-version": "2023-06-01"},
)
entries: list[dict[str, Any]] = []
for item in data.get("data", []):
model_id = item.get("id")
if not model_id:
continue
label = item.get("display_name") or _humanize(model_id)
created = _parse_iso(item.get("created_at"))
entries.append(_entry(model_id, label, created=created))
return entries
def _fetch_gemini(api_key: str | None) -> list[dict[str, Any]]:
entries: list[dict[str, Any]] = []
params: dict[str, Any] = {"key": api_key or "", "pageSize": 200}
# models.list is paginated and not guaranteed newest-first, so walk pages
# (bounded) to see the full set — _finalize does the sort + truncation.
for _ in range(10):
try:
data = _http_get_json(
"https://generativelanguage.googleapis.com/v1beta/models",
params=params,
)
except Exception:
# Later-page failure: keep the models already gathered. First-page
# failure (nothing gathered yet) is a real outage — re-raise so the
# caller falls back to the curated list rather than mistaking it for
# a successful empty result.
if entries:
break
raise
for item in data.get("models", []):
methods = item.get("supportedGenerationMethods") or []
if "generateContent" not in methods:
continue
name = (item.get("name") or "").removeprefix("models/")
if not name or not _is_chat_model(name) or "aqa" in name:
continue
label = item.get("displayName") or _humanize(name)
# Gemini has no timestamp; rank by the version in name/version.
version_hint = f"{name} {item.get('version') or ''}"
entries.append(_entry(name, label, version_hint=version_hint))
token = data.get("nextPageToken")
if not token:
break
params = {"key": api_key or "", "pageSize": 200, "pageToken": token}
return entries
def _ollama_base() -> str:
"""Resolve the Ollama server base URL from the environment.
Checks ``OLLAMA_API_BASE`` / ``API_BASE`` (what LiteLLM and the generated
crew use) first, then ``OLLAMA_HOST`` (the Ollama runtime convention), so a
user who only set ``OLLAMA_HOST`` sees models from the right server.
"""
base = (
os.environ.get("OLLAMA_API_BASE")
or os.environ.get("API_BASE")
or os.environ.get("OLLAMA_HOST")
or "http://localhost:11434"
).strip()
# OLLAMA_HOST is often scheme-less (e.g. "127.0.0.1:11434").
if "://" not in base:
base = f"http://{base}"
return base.rstrip("/")
def _fetch_ollama(_api_key: str | None) -> list[dict[str, Any]]:
"""List models installed on the local Ollama server (no API key)."""
data = _http_get_json(f"{_ollama_base()}/api/tags")
entries: list[dict[str, Any]] = []
for item in data.get("models", []):
model_id = item.get("model") or item.get("name")
if not model_id or not _is_chat_model(model_id) or _is_fine_tune(model_id):
# /api/tags lists everything installed, including embedding models.
continue
# Ollama returns an ISO 8601 modified_at we can rank by.
created = _parse_iso(item.get("modified_at"))
entries.append(_entry(model_id, _humanize(model_id), created=created))
return entries
_VENDOR_FETCHERS: dict[str, Callable[[str | None], list[dict[str, Any]]]] = {
"openai": _fetch_openai,
"anthropic": _fetch_anthropic,
"gemini": _fetch_gemini,
"groq": _fetch_groq,
"cerebras": _fetch_cerebras,
"ollama": _fetch_ollama,
}
# ── Tier 2: LiteLLM feed ─────────────────────────────────────────
# Process-level memo so a single CLI run attempts the LiteLLM download at most
# once — repeated picker calls otherwise each incur a multi-second timeout when
# the feed is stale/unreachable. Reset via _reset_litellm_memo() in tests.
_UNSET: Any = object()
_litellm_memo: Any = _UNSET
def _reset_litellm_memo() -> None:
"""Clear the process-level LiteLLM memo (test hook)."""
global _litellm_memo
_litellm_memo = _UNSET
def _from_litellm(provider_key: str) -> list[dict[str, Any]] | None:
"""Build chat-model entries for ``provider_key`` from the LiteLLM feed."""
data = _load_litellm_data()
# A corrupt feed (non-mapping JSON root) must not crash the picker.
if not isinstance(data, dict):
return None
entries: list[dict[str, Any]] = []
for model_name, props in data.items():
if not isinstance(props, dict):
continue
# `litellm_provider` can be present-but-null in the feed; coerce before
# string ops so a null value is skipped rather than raising.
if (props.get("litellm_provider") or "").strip().lower() != provider_key:
continue
if props.get("mode") != "chat":
continue
# LiteLLM keys are sometimes prefixed with the provider; the picker
# re-adds ``provider/`` itself, so strip a leading one to avoid dupes.
model_id = model_name
if model_id.startswith(f"{provider_key}/"):
model_id = model_id[len(provider_key) + 1 :]
if not model_id:
continue
entries.append(_entry(model_id, _humanize(model_id), version_hint=model_id))
return entries or None
def _load_litellm_data() -> dict[str, Any] | None:
"""Return the LiteLLM feed, memoized once per process (see _litellm_memo)."""
global _litellm_memo
if _litellm_memo is _UNSET:
_litellm_memo = _fetch_litellm_data()
memoized: dict[str, Any] | None = _litellm_memo
return memoized
def _fetch_litellm_data() -> dict[str, Any] | None:
"""Read the cached LiteLLM feed, fetching it once if the cache is cold."""
cache_file = _litellm_cache_file()
fresh = (
cache_file.exists()
and (time.time() - cache_file.stat().st_mtime) < _LITELLM_TTL
)
if fresh:
data = _read_json(cache_file)
# A corrupt/non-mapping fresh cache must not block a recoverable
# download — only short-circuit on a usable mapping.
if isinstance(data, dict) and data:
return data
try:
data = _http_get_json(JSON_URL)
except Exception:
# Fall back to a stale cache if we have one, else give up on this tier.
return _read_json(cache_file)
# Best-effort cache write; a failure (e.g. read-only home) is non-fatal
# since we already hold the freshly-fetched data.
with contextlib.suppress(OSError):
cache_file.parent.mkdir(parents=True, exist_ok=True)
cache_file.write_text(json.dumps(data), encoding="utf-8")
return data
# ── Ranking + labelling ──────────────────────────────────────────
def _finalize(
entries: list[dict[str, Any]], label_map: dict[str, str]
) -> list[tuple[str, str]]:
"""Sort newest-first, dedupe, relabel with curated names, and truncate."""
entries.sort(key=lambda e: e["sort"], reverse=True)
seen: set[str] = set()
out: list[tuple[str, str]] = []
for entry in entries:
model_id = entry["id"]
if model_id in seen:
continue
seen.add(model_id)
label = label_map.get(model_id) or entry["label"]
out.append((model_id, label))
if len(out) >= MAX_MODELS:
break
return out
def _entry(
model_id: str,
label: str,
*,
created: float = 0.0,
version_hint: str | None = None,
) -> dict[str, Any]:
"""Build a rankable catalog entry.
``sort`` is a comparable tuple ``(created, date_int, version_tuple)`` so a
real vendor timestamp wins, then a date embedded in the id, then the numeric
version. Types line up positionally, so entries compare cleanly.
"""
date_int, version = _version_key(version_hint or model_id)
return {
"id": model_id,
"label": label,
"sort": (created, date_int, version),
}
_DATE_RE = re.compile(r"(20\d{2})[-_]?(0[1-9]|1[0-2])[-_]?(0[1-9]|[12]\d|3[01])")
_NUM_RE = re.compile(r"\d+")
def _version_key(text: str) -> tuple[int, tuple[int, ...]]:
"""Extract a ``(date_int, version_tuple)`` sort key from a model id.
A trailing/embedded ``YYYYMMDD`` (or ``YYYY-MM-DD``) becomes ``date_int``;
remaining numbers become the version tuple. ``claude-opus-4-6`` → version
``(4, 6)``; ``claude-3-5-sonnet-20241022`` → date ``20241022`` version
``(3, 5)``.
"""
text = text or ""
date_int = 0
match = _DATE_RE.search(text)
if match:
date_int = int(match.group(1) + match.group(2) + match.group(3))
text = _DATE_RE.sub(" ", text)
version = tuple(int(n) for n in _NUM_RE.findall(text)[:4])
return date_int, version
def _is_chat_model(model_id: str) -> bool:
"""Heuristically reject embedding/audio/image/etc. models by their id."""
lowered = model_id.lower()
return not any(marker in lowered for marker in _NON_CHAT_MARKERS)
def _is_fine_tune(model_id: str) -> bool:
"""A user fine-tune or training checkpoint (``ft:...`` / ``...:ckpt-step-N``).
These are account-specific artifacts: they clutter the picker, crowd out the
foundation models (their recent ``created`` timestamps rank them first), and
humanize into unreadable labels. Excluded from the auto-list; a user who
wants one can still enter it via the picker's "Other" option.
"""
lowered = model_id.lower()
return lowered.startswith("ft:") or ":ckpt" in lowered
_SIZE_RE = re.compile(r"^\d+(?:\.\d+)?[bmk]$") # 8b, 70b, 1.5b, 120m, 32k
_OSERIES_RE = re.compile(r"^o\d+$") # o1, o3, o4 — kept lowercase (OpenAI brand)
def _humanize(model_id: str) -> str:
"""Derive a readable label from a raw model id.
Best-effort only — vendor display names and the curated label map take
precedence. Drops embedded dates and applies light casing so raw ids read
cleanly: ``gpt-oss-120b`` → ``GPT OSS 120B``, ``qwen3-32b`` → ``Qwen3 32B``,
``deepseek-r1:671b`` → ``DeepSeek R1 671B``, ``o3-mini`` → ``o3 Mini``.
"""
base = model_id.split("/")[-1]
# Drop embedded release dates — they're noise in a label, and the picker
# already shows the full model id alongside it.
base = _DATE_RE.sub(" ", base)
words: list[str] = []
# Split on separators including ``:`` so Ollama tags (llama3.3:70b) read well.
for part in re.split(r"[-_\s:]+", base):
if not part:
continue
low = part.lower()
if low in _ACRONYMS:
words.append(_ACRONYMS[low])
elif low in _BRAND_TOKENS:
words.append(_BRAND_TOKENS[low])
elif _SIZE_RE.match(low):
words.append(low[:-1] + low[-1].upper()) # 70b -> 70B
elif _OSERIES_RE.match(low):
words.append(low) # o3 stays lowercase
elif part[0].isalpha():
# Capitalize the leading letter, preserve the rest (so a fused
# family+version keeps its digits): qwen3 -> Qwen3, mini -> Mini.
words.append(part[0].upper() + part[1:])
else:
words.append(part) # starts with a digit (4o, 4.1, 0905) — leave as-is
return " ".join(words) or base
# ── HTTP + parsing helpers ───────────────────────────────────────
def _http_get_json(
url: str,
*,
headers: dict[str, str] | None = None,
params: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""GET ``url`` and return parsed JSON, with a short timeout and TLS verify."""
ssl_config = os.environ.get("SSL_CERT_FILE") or certifi.where()
response = httpx.get(
url,
headers=headers,
params=params,
timeout=_TIMEOUT,
verify=ssl_config,
follow_redirects=True,
)
response.raise_for_status()
result: dict[str, Any] = response.json()
return result
def _parse_iso(value: Any) -> float:
"""Parse an ISO 8601 timestamp to an epoch float; ``0.0`` on failure."""
if not value or not isinstance(value, str):
return 0.0
from datetime import datetime
try:
return datetime.fromisoformat(value.replace("Z", "+00:00")).timestamp()
except ValueError:
return 0.0
def _as_float(value: Any) -> float:
try:
return float(value)
except (TypeError, ValueError):
return 0.0
def _read_json(path: Path) -> dict[str, Any] | None:
try:
data: dict[str, Any] = json.loads(path.read_text(encoding="utf-8"))
return data
except (OSError, json.JSONDecodeError):
return None
# ── Caching ──────────────────────────────────────────────────────
def _cache_dir() -> Path:
return Path.home() / ".crewai"
def _catalog_cache_file() -> Path:
return _cache_dir() / "model_catalog_cache.json"
def _litellm_cache_file() -> Path:
# Shared with crewai_cli.provider so both flows warm the same cache.
return _cache_dir() / "provider_cache.json"
def _cache_key(provider_key: str) -> str:
"""Cache key for a provider's resolved model list.
Includes the inputs that change what a fetch would return, so a cached
entry is only reused when those inputs still match:
- Ollama lists models from a base URL that can change between runs.
- Whether the vendor's API key is present flips between a live fetch and
the negatively-cached fallback — so a key added after a no-key call is
not shadowed by the cached fallback.
"""
if provider_key == "ollama":
return f"ollama@{_ollama_base()}"
suffix = "key" if _provider_api_key(provider_key) else "nokey"
return f"{provider_key}#{suffix}"
def _read_catalog_cache(provider_key: str) -> list[tuple[str, str]] | None:
"""Return a fresh cached catalog for ``provider_key``, or ``None``."""
payload = _read_json(_catalog_cache_file())
if not isinstance(payload, dict):
return None
entry = payload.get(_cache_key(provider_key))
if not isinstance(entry, dict):
return None
# Fallback (negative) entries expire fast; dynamic ones live the full TTL.
ttl = _NEGATIVE_TTL if entry.get("source") == "fallback" else _CATALOG_TTL
if (time.time() - _as_float(entry.get("ts"))) >= ttl:
return None
models = entry.get("models")
if not isinstance(models, list) or not models:
return None
try:
return [(str(m[0]), str(m[1])) for m in models]
except (IndexError, TypeError):
return None
def _write_catalog_cache(
provider_key: str, models: list[tuple[str, str]], *, source: str
) -> None:
cache_file = _catalog_cache_file()
payload = _read_json(cache_file)
if not isinstance(payload, dict):
payload = {}
payload[_cache_key(provider_key)] = {
"ts": time.time(),
"source": source,
"models": [[model_id, label] for model_id, label in models],
}
# Best-effort cache write; a failure (e.g. read-only home) is non-fatal.
with contextlib.suppress(OSError):
cache_file.parent.mkdir(parents=True, exist_ok=True)
cache_file.write_text(json.dumps(payload), encoding="utf-8")

View File

@@ -17,7 +17,7 @@ from crewai_cli.command import BaseCommand
logger = logging.getLogger(__name__)
console = Console()
GITHUB_ORG = "crewAIInc"
GITHUB_ORG = "crewAIInc-fde"
TEMPLATE_PREFIX = "template_"
GITHUB_API_BASE = "https://api.github.com"
@@ -218,7 +218,7 @@ class TemplateCommand(BaseCommand):
def _extract_zip(self, zip_bytes: bytes, dest: str) -> None:
"""Extract a GitHub zipball into dest, stripping the top-level directory."""
with zipfile.ZipFile(io.BytesIO(zip_bytes)) as zf:
# GitHub zipballs have a single top-level dir like 'crewAIInc-template_xxx-<sha>/'
# GitHub zipballs have a single top-level dir like 'crewAIInc-fde-template_xxx-<sha>/'
members = zf.namelist()
if not members:
click.secho("Downloaded archive is empty.", fg="red")

View File

@@ -0,0 +1,352 @@
# Flow Definition
You are writing a CrewAI Flow declaration for the user.
Use these instructions when the user asks you to create or edit a Flow.
Return one valid `crewai.flow/v1` YAML or JSON document.
Treat this document as instructions for you, not as text to show the user.
Follow the examples for shape and formatting, then use the API reference to check exact fields.
## Output Format
Return one valid `crewai.flow/v1` Flow declaration.
Do not include explanatory prose unless the user asks for it.
## Build It In This Order
1. Define `state` first. Use `type: json_schema` and put the JSON Schema inline.
2. Put required input fields in `state.json_schema.required`. Do not rely on `state.default` to make fields required.
3. Add exactly one method with `start: true`.
4. Add later methods with `listen`.
5. Give each method exactly one `do` action object. Never make `do` a list.
6. Pass data with `${...}` mappings from `state` and completed `outputs`.
7. Before final output, check every `listen`, `emit`, and `outputs.some_method` reference.
Set optional fields only when you are confident they are needed. Otherwise, trust CrewAI defaults and omit them.
Method names must match `^[A-Za-z_][A-Za-z0-9_]*$`.
## Choose One Action Per Method
Pick the simplest action that does the job.
- Use `call: expression` for simple reads, filters, computed values, and deterministic routing.
- Use `call: agent` for one AI worker that classifies, decides, summarizes, writes, or drafts. Put `role`, `goal`, `backstory`, and `input` under `with`. Do not add an action-level `inputs` map to an agent.
- Use `call: crew` for coordinated AI work with multiple agents or tasks. Define the crew under `with`. Pass runtime values with the action-level `inputs` map.
## Wire Methods Explicitly
- `state` is the initial shared data shape. Action results do not automatically merge into `state`.
- Read method results with `outputs.method_name` after that method can run.
- `listen` targets a method name or a router-emitted event name.
- Methods must not listen to their own method name.
- Method names and emitted event names share one namespace. Avoid reusing the same string for both unless the user explicitly wants that.
- Use `router: true` plus `emit` when one method chooses between named branches.
- A router action must return exactly one emitted event string. It must not return JSON, a list, or an explanation.
- Use `start: true` for the single entrypoint.
If an agent is a router, make its goal say exactly what to return, for example:
`Return exactly one bare value: approved, rejected, or needs_review. Do not include explanation.`
Prefer `call: expression` when routing can be computed without an agent.
## CEL And Dynamic Values
CEL is the expression language for reading Flow data and making small decisions.
Use agents and crews for larger work or side effects.
Use these expression forms correctly:
- Raw CEL: use in `expr`. Do not wrap raw CEL in `${...}`.
- Use `${...}` inside action mapping strings to read Flow data with CEL. Example value: `Ticket: ${state.ticket_id}`.
- Use `state` for input data. Use `outputs.step_name` for a completed method result.
- If a value is only one `${...}` expression, the result keeps its type. Use this for numbers, booleans, objects, and lists.
- If the string has other text, the final value is text. Non-text values become JSON. `null` becomes empty text.
- Use `text(root, "path", "default")` for values that may be missing or null. The default is optional and is `""`.
Expression examples:
Mix text and Flow data:
```yaml
query: "News about ${state.topic}"
```
Keep a list or number type:
```yaml
domains: "${state.domains}"
limit: "${state.limit}"
```
Use a default for missing text:
```yaml
input: "Ticket ${text(state, \"ticket.id\", \"unknown\")}"
```
- Crew text: use `{name}` placeholders from crew inputs. Example: `Research {topic}`.
- Crew inputs become prompt text only when agent or task text references matching `{name}` placeholders.
- Passing an input that is not referenced by any `{name}` placeholder does not ground the crew. If the crew needs a field, put that placeholder in an agent `goal`, task `description`, or task `expected_output`.
Available CEL variables:
- `state`: initial input data, for example `state.ticket.subject`.
- `outputs`: completed method outputs, for example `outputs.classify_ticket`.
Dynamic value rules:
- When an agent needs multiple fields, write one text value with labels and separators. Example value: `Ticket ID: ${state.ticket_id}; Message: ${state.message}`.
- Crew action-level `inputs` are the actual Crew kickoff inputs. Use `${...}` values there for runtime data from `state` or `outputs`.
- Crew action-level `inputs` alone are not grounding. Include placeholders for the facts the model must use.
- Crew outputs are objects. Use `${outputs.research_brief.raw}` for text.
- For structured crew output, use fields like `${outputs.research_brief.json_dict.field}` or `${outputs.research_brief.pydantic.field}`.
- Do not pass a whole crew output to an agent input, like `${outputs.research_brief}`.
- Agent outputs may also be objects. Use fields like `${outputs.classify_ticket.raw}` or `${outputs.classify_ticket.pydantic.category}`.
- Use `with.inputs` only for static Crew input defaults.
- Agent action `with.input` is the agent's single input value.
## Do Not
- Do not invent top-level keys outside the Flow declaration shape.
- Do not use fields outside the declaration schema.
- Do not put more than one action under a method's `do`.
- Do not make `do` a list.
- Do not reference `outputs.some_method` before `some_method` can run.
- Do not set a method's `listen` to its own method name.
- Do not use the same string for an emitted event and a method name unless the user asks for it.
- Do not use `emit` without `router: true`.
- Do not rely on crew action-level `inputs` alone to ground agent behavior. Inputs that do not match placeholders are effectively unused by the prompt.
- Do not ask agents to infer missing facts when accuracy matters. Tell them to mark missing dates, amounts, offers, logs, or constraints as unknown.
- Do not set `config.stream: true` unless the caller is expected to consume a streaming result. For normal generated flows and CLI smoke tests, omit it.
## Examples
### Crew review with routed follow-up
```yaml
schema: crewai.flow/v1
name: ResearchReviewFlow
state:
type: json_schema
json_schema:
type: object
properties:
topic:
type: string
audience:
type: string
required:
- topic
- audience
default:
topic: AI agent orchestration
audience: platform engineering leaders
methods:
research_brief:
start: true
do:
call: crew
with:
agents:
researcher:
role: Research analyst
goal: Research {topic} for {audience}
backstory: Expert at concise technical research.
reviewer:
role: Strategy reviewer
goal: Decide whether the research needs an executive follow-up
backstory: Experienced at reviewing technical briefs for leaders.
tasks:
- name: research_task
description: Research {topic} for {audience}.
expected_output: Key findings and tradeoffs.
agent: researcher
- name: review_task
description: Review the research and decide if an executive follow-up is needed.
expected_output: 'A brief review ending with `needs_followup: true` or `needs_followup: false`.'
agent: reviewer
inputs:
topic: Default topic
audience: Default audience
inputs:
topic: "${state.topic}"
audience: "${state.audience}"
route_followup:
listen: research_brief
router: true
emit:
- followup
- done
do:
call: agent
with:
role: Follow-up router
goal: 'Return exactly one bare value: followup or done. Do not include explanation.'
backstory: Skilled at routing reviewed research briefs.
input: "Reviewed research: ${outputs.research_brief.raw}"
write_followup:
listen: followup
do:
call: agent
with:
role: Executive communications specialist
goal: Draft a concise executive follow-up from the reviewed research
backstory: Writes crisp follow-ups for technical leaders.
input: "${outputs.research_brief.raw}"
```
## API Reference
Use this appendix to check exact field names, required fields, linked object types, and allowed action/state shapes. Linked type names point to another section in this reference.
### Flow Definition
Fields:
- `schema` (optional): must be `crewai.flow/v1`; default `crewai.flow/v1`. Declarative Flow schema identifier and version. Include it explicitly in authored declarations.
- `name` (required): string. Unique flow name used in logs, events, and traces.
- `description` (optional): string | null; default `null`. Human-readable summary of the flow.
- `state` (required): [State](#json-schema-state-statetypejson_schema). State contract for the initial state and updates during execution.
- `config` (optional): [Config (`config`)](#config-config); default generated default. Serializable flow-level execution configuration.
- `methods` (required): map of string to [Method](#method-methods). Mapping of method names to method definitions.
### JSON Schema State (`state[type=json_schema]`)
Shape:
- `type: json_schema`
Fields:
- `type` (optional): must be `json_schema`; default `json_schema`. Inline JSON Schema used as the Flow state contract.
- `json_schema` (required): map of string to any. JSON Schema used to validate and document flow state. Declare required fields with JSON Schema's `required` array.
- `default` (optional): map of string to any | null; default `null`. Default values used to initialize Flow state. Defaults are not the same as schema-required fields.
### Method (`methods.<name>`)
Fields:
- `description` (optional): string | null; default `null`. Human-readable summary of what this method does.
- `do` (required): [Action](#action). Single action object executed when this method runs.
- `start` (optional): boolean | string | map of string to any | null; default `null`. Marks the single normal entrypoint. Use `true`.
- `listen` (optional): string | map of string to any | null; default `null`. Runs this method after one upstream method or router-emitted event.
- `router` (optional): boolean; default `false`. Whether the method output should be treated as the next event name. Router actions must return one event name string, with no surrounding explanation.
- `emit` (optional): list[string] | null; default `null`. Declared router events this method may emit. Each emitted event name should be unique and should not collide with method names.
### Action
Discriminated union by `call`.
Allowed shapes:
- [`call: crew`](#crew-action-methodsdocallcrew)
- [`call: agent`](#agent-action-methodsdocallagent)
- [`call: expression`](#expression-action-methodsdocallexpression)
### Crew Action (`methods.<name>.do[call=crew]`)
Shape:
- `call: crew`
Fields:
- `call` (required): must be `crew`. Action discriminator. Use crew to run an inline Crew definition. Example: `crew`
- `with` (required): inline crew definition. Inline Crew definition to load and execute for this action. Example: `{"agents": {"researcher": {"backstory": "Knows the domain.", "goal": "Research {topic}", "role": "Researcher"}}, "name": "inline_research", "tasks": [{"agent": "researcher", "description": "Research {topic}", "expected_output": "Findings about {topic}", "name": "research_task"}]}`
- `inputs` (optional): map of string to expression data | null; default `null`. Actual kickoff inputs passed to the Crew. Use `${...}` inside action mapping strings to read Flow data with CEL. Example value: `Ticket: ${state.ticket_id}`. Use `state` for input data. Use `outputs.step_name` for a completed method result. If a value is only one `${...}` expression, the result keeps its type. Use this for numbers, booleans, objects, and lists. If the string has other text, the final value is text. Non-text values become JSON. `null` becomes empty text. Use `text(root, "path", "default")` for values that may be missing or null. The default is optional and is `""`. The evaluated values are available to crew agent and task interpolation as `{name}` placeholders; reference each input the crew needs in agent or task text. Example: `{"topic": "${state.topic}"}`
#### Crew Definition (`methods.<name>.do[call=crew].with`)
Fields:
- `agents` (required): map of string to any | list[map of string to any]. Inline crew agents keyed by agent name. Example: `{"researcher": {"backstory": "Expert at concise technical research.", "goal": "Research {topic}", "role": "Research analyst"}}`
- `tasks` (required): list[any]. Ordered crew tasks. Example: `[{"agent": "researcher", "description": "Research {topic}.", "expected_output": "Key findings about {topic}.", "name": "research_task"}]`
- `inputs` (optional): map of string to any. Static default crew inputs. Values are available to crew agent and task interpolation as `{name}` placeholders, for example `{topic}`. Prefer action-level crew `inputs` for runtime values from `state` or `outputs`, and include placeholders for any inputs the crew must reason over. Example: `{"topic": "AI agents"}`
#### Crew Agent Definition (`methods.<name>.do[call=crew].with.agents.<name>`)
Fields:
- `role` (required): string. Crew agent role. Crew inputs are interpolated with `{name}` placeholders such as `{topic}`; this is not CEL. Example: `Research analyst`
- `goal` (required): string. Crew agent goal. Crew inputs are interpolated with `{name}` placeholders such as `{topic}`; this is not CEL. Example: `Research {topic}`
- `backstory` (required): string. Crew agent backstory. Crew inputs are interpolated with `{name}` placeholders such as `{topic}`; this is not CEL. Example: `Expert at concise technical research.`
- `settings` (optional): map of string to any. Additional agent settings passed to the loader. Example: `{"llm": "openai/gpt-4o-mini"}`
- `llm` (optional): string or inline LLM config; default `null`. Language model that runs this crew agent. Use an object when setting LLM options such as `max_tokens`. Example: `{"max_tokens": 4096, "model": "openai/gpt-4o-mini"}`
- `planning_config` (optional): object | null; default `null`. Agent planning configuration. Set `max_attempts` to limit planning refinement attempts before task execution. Example: `{"max_attempts": 3}`
- `allow_delegation` (optional): boolean | null; default `null`. Enable agent to delegate and ask questions among each other. Example: `false`
- `max_iter` (optional): integer | null; default `null`. Maximum iterations for an agent to execute a task Example: `25`
- `max_rpm` (optional): integer | null; default `null`. Maximum number of requests per minute for the agent execution to be respected. Example: `10`
- `max_execution_time` (optional): integer | null; default `null`. Maximum execution time in seconds for an agent to execute a task Example: `300`
- `tools` (optional): list[string | map of string to any] | null; default `null`. Tool refs or serialized tool definitions available to this agent. String refs can use CrewAI tool names, `custom:<name>`, or fully qualified `module:Class` references. Example: `["crewai_tools:SerperDevTool", "custom:file_read"]`
- `apps` (optional): list[string] | null; default `null`. Platform apps available to this agent. Can contain app names such as `gmail` or app/action refs such as `gmail/send_email`. Example: `["gmail", "slack/send_message"]`
- `mcps` (optional): list[string | map of string to any] | null; default `null`. MCP server refs or serialized MCP server configs available to this agent. String refs can use HTTPS URLs, connected MCP integration slugs, or refs with a `#tool_name` suffix for specific tools. Example: `["https://api.weather.com/mcp#get_current_weather", "snowflake", "stripe#list_invoices", {"cache_tools_list": true, "headers": {"Authorization": "Bearer your_token"}, "streamable": true, "url": "https://api.example.com/mcp"}]`
#### LLM Definition
Fields:
- `model` (required): string. Model identifier used to instantiate the LLM. Example: `openai/gpt-4o-mini`
- `max_tokens` (optional): integer | null; default `null`. Maximum number of tokens the LLM can generate. If null, CrewAI does not set an explicit output token cap and the provider's default applies. Example: `4096`
#### Crew Task Definition (`methods.<name>.do[call=crew].with.tasks[]`)
Fields:
- `description` (required): string. Task instructions. Crew inputs are interpolated with `{name}` placeholders such as `{topic}`; this is not CEL. Example: `Research {topic}.`
- `expected_output` (required): string. Expected task output. Crew inputs are interpolated with `{name}` placeholders such as `{topic}`; this is not CEL. Example: `Key findings about {topic}.`
- `name` (optional): string | null; default `null`. Optional task name. Example: `research_task`
- `agent` (optional): string | null; default `null`. Name of the crew agent assigned to this task. Example: `researcher`
### Agent Action (`methods.<name>.do[call=agent]`)
Shape:
- `call: agent`
Fields:
- `call` (required): must be `agent`. Action discriminator. Use agent to run an individual inline Agent definition outside of a crew. Example: `agent`
- `with` (required): any. Individual Agent definition to load and execute outside of a crew for this action. Put the agent input in `with.input`; agent actions do not support action-level `inputs`. Example: `{"backstory": "Precise and concise.", "goal": "Answer user questions", "input": "${state.question}", "role": "Analyst", "settings": {"llm": "openai/gpt-4o-mini"}}`
#### Agent Definition (`methods.<name>.do[call=agent].with`)
Fields:
- `role` (required): string. Individual agent role used by a Flow agent action outside of a crew. Example: `Support specialist`
- `goal` (required): string. Individual agent goal for the Flow agent action outside of a crew. Example: `Draft a concise customer reply`
- `backstory` (required): string. Individual agent backstory used to shape behavior outside of a crew. Example: `Expert at resolving SaaS support questions.`
- `settings` (optional): map of string to any. Additional agent settings passed to the loader. Example: `{"llm": "openai/gpt-4o-mini"}`
- `llm` (optional): string or inline LLM config; default `null`. Language model that runs this agent. Use an object when setting LLM options such as `max_tokens`. Example: `{"max_tokens": 4096, "model": "openai/gpt-4o-mini"}`
- `planning_config` (optional): object | null; default `null`. Agent planning configuration. Set `max_attempts` to limit planning refinement attempts before task execution. Example: `{"max_attempts": 3}`
- `allow_delegation` (optional): boolean | null; default `null`. Enable agent to delegate and ask questions among each other. Example: `false`
- `max_iter` (optional): integer | null; default `null`. Maximum iterations for an agent to execute a task Example: `25`
- `max_rpm` (optional): integer | null; default `null`. Maximum number of requests per minute for the agent execution to be respected. Example: `10`
- `max_execution_time` (optional): integer | null; default `null`. Maximum execution time in seconds for an agent to execute a task Example: `300`
- `tools` (optional): list[string | map of string to any] | null; default `null`. Tool refs or serialized tool definitions available to this agent. String refs can use CrewAI tool names, `custom:<name>`, or fully qualified `module:Class` references. Example: `["crewai_tools:SerperDevTool", "custom:file_read"]`
- `apps` (optional): list[string] | null; default `null`. Platform apps available to this agent. Can contain app names such as `gmail` or app/action refs such as `gmail/send_email`. Example: `["gmail", "slack/send_message"]`
- `mcps` (optional): list[string | map of string to any] | null; default `null`. MCP server refs or serialized MCP server configs available to this agent. String refs can use HTTPS URLs, connected MCP integration slugs, or refs with a `#tool_name` suffix for specific tools. Example: `["https://api.weather.com/mcp#get_current_weather", "snowflake", "stripe#list_invoices", {"cache_tools_list": true, "headers": {"Authorization": "Bearer your_token"}, "streamable": true, "url": "https://api.example.com/mcp"}]`
- `input` (required): string. Input passed to the individual agent kickoff outside of a crew. Use one string. Use `${...}` inside action mapping strings to read Flow data with CEL. Example value: `Ticket: ${state.ticket_id}`. Use `state` for input data. Use `outputs.step_name` for a completed method result. If a value is only one `${...}` expression, the result keeps its type. Use this for numbers, booleans, objects, and lists. If the string has other text, the final value is text. Non-text values become JSON. `null` becomes empty text. Use `text(root, "path", "default")` for values that may be missing or null. The default is optional and is `""`. When an agent needs multiple fields, write one string with labels and separators, for example `Ticket ID: ${state.ticket_id}; Message: ${state.message}`. Example: `${state.ticket.body}`
#### LLM Definition
Fields:
- `model` (required): string. Model identifier used to instantiate the LLM. Example: `openai/gpt-4o-mini`
- `max_tokens` (optional): integer | null; default `null`. Maximum number of tokens the LLM can generate. If null, CrewAI does not set an explicit output token cap and the provider's default applies. Example: `4096`
### Expression Action (`methods.<name>.do[call=expression]`)
Shape:
- `call: expression`
Fields:
- `call` (required): must be `expression`. Action discriminator. Use expression to evaluate a CEL expression.
- `expr` (required): string. CEL expression evaluated against state, outputs, and local context.
### Config (`config`)
Fields:
- `tracing` (optional): boolean | null; default `null`. Override for flow tracing; when omitted, execution defaults apply.
- `stream` (optional): boolean; default `false`. Whether the flow should emit streaming events when supported.
- `memory` (optional): map of string to any | null; default `null`. Serializable memory configuration passed to flow execution.
- `input_provider` (optional): string | null; default `null`. Provider key used to supply initial state.
- `suppress_flow_events` (optional): boolean; default `false`. Disable flow event emission for this definition.
- `max_method_calls` (optional): integer; default `100`. Maximum number of method executions allowed during one kickoff.
- `defer_trace_finalization` (optional): boolean; default `false`. Defer trace finalization so callers can complete tracing later.
- `checkpoint` (optional): boolean | map of string to any | null; default `null`. Checkpointing configuration, or true to use default checkpointing.
### Cross-Field Rules
- A method has exactly one `do` action object with one `call` discriminator.
- `listen` targets method names and router-emitted event names in one shared namespace.
- Methods cannot listen to their own method name.
- A router method result must match one declared `emit` value.
- Crew action-level `inputs` are the Crew kickoff inputs; use CEL-wrapped strings there for runtime values.
- Crew agent/task interpolation uses `{name}` placeholders from evaluated crew inputs.
- Agent `with.input` must be text. Use `${outputs.method_name.raw}` or a text field like `${outputs.method_name.json_dict.summary}`.

View File

@@ -26,6 +26,15 @@ def test_create_flow_declarative_project_can_run(
assert pyproject["project"]["requires-python"]
assert pyproject["project"]["dependencies"]
assert (project_root / pyproject["tool"]["crewai"]["definition"]).is_file()
agents_md = (project_root / "AGENTS.md").read_text(encoding="utf-8")
assert "CrewAI Flow declaration" in agents_md
assert "schema: crewai.flow/v1" in agents_md
assert 'text(root, "path", "default")' in agents_md
assert "call: expression" in agents_md
assert "call: tool" not in agents_md
assert "call: script" not in agents_md
assert "call: each" not in agents_md
assert "human_feedback" not in agents_md
monkeypatch.chdir(project_root)
result = CliRunner().invoke(crewai, ["run"], env={"UV_RUN_RECURSION_DEPTH": "1"})

View File

@@ -0,0 +1,551 @@
"""Tests for the dynamic model catalog used by the crew-creation wizard."""
from __future__ import annotations
import json
import pytest
import crewai_cli.model_catalog as mc
_ALL_KEY_ENVS = [
"OPENAI_API_KEY",
"ANTHROPIC_API_KEY",
"GEMINI_API_KEY",
"GOOGLE_API_KEY",
"GROQ_API_KEY",
"CEREBRAS_API_KEY",
"OLLAMA_API_BASE",
"API_BASE",
"OLLAMA_HOST",
]
FALLBACK_ANTHROPIC = [
("claude-opus-4-6", "Claude Opus 4.6"),
("claude-sonnet-4-6", "Claude Sonnet 4.6"),
]
@pytest.fixture(autouse=True)
def isolated_env(monkeypatch, tmp_path):
"""Point the cache at a temp dir and clear provider keys for every test."""
monkeypatch.setattr(mc, "_cache_dir", lambda: tmp_path)
mc._reset_litellm_memo() # clear the process-level LiteLLM memo per test
for key in _ALL_KEY_ENVS:
monkeypatch.delenv(key, raising=False)
# ── version / label helpers ──────────────────────────────────────
def test_version_key_parses_embedded_date():
date_int, version = mc._version_key("claude-3-5-sonnet-20241022")
assert date_int == 20241022
assert version == (3, 5)
def test_version_key_parses_dashed_date():
date_int, _ = mc._version_key("gpt-4o-2024-08-06")
assert date_int == 20240806
def test_version_key_version_only():
date_int, version = mc._version_key("claude-opus-4-6")
assert date_int == 0
assert version == (4, 6)
def test_version_key_ranks_newer_higher():
older = mc._version_key("claude-sonnet-4-5")
newer = mc._version_key("claude-sonnet-4-6")
assert newer > older
def test_is_chat_model_rejects_non_chat():
assert mc._is_chat_model("gpt-4.1-mini")
assert not mc._is_chat_model("text-embedding-3-large")
assert not mc._is_chat_model("whisper-1")
assert not mc._is_chat_model("dall-e-3")
def test_search_substring_not_treated_as_non_chat():
# 'search' must not drop legitimate completion models: a token like
# *-search-preview, or 'research' (which contains 'search' as a substring).
assert mc._is_chat_model("gpt-4o-search-preview")
assert mc._is_chat_model("o3-deep-research")
# genuine non-chat markers still filter
assert not mc._is_chat_model("text-embedding-3-large")
def test_humanize():
assert mc._humanize("gpt-4.1-mini") == "GPT 4.1 Mini"
assert mc._humanize("anthropic/claude-opus-4-6") == "Claude Opus 4 6"
# size suffixes uppercased, acronyms/brands cased, o-series preserved, ':' split
assert mc._humanize("openai/gpt-oss-120b") == "GPT OSS 120B"
assert mc._humanize("qwen/qwen3-32b") == "Qwen3 32B"
assert mc._humanize("deepseek-r1-distill-llama-70b") == "DeepSeek R1 Distill Llama 70B"
assert mc._humanize("o3-mini") == "o3 Mini"
assert mc._humanize("chatgpt-4o-latest") == "ChatGPT 4o Latest"
assert mc._humanize("llama3.3:70b") == "Llama3.3 70B"
assert mc._humanize("gemma2-9b-it") == "Gemma2 9B IT"
# ── vendor tier ──────────────────────────────────────────────────
def test_vendor_anthropic_ranks_by_date_and_uses_display_name(monkeypatch):
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-test")
payload = {
"data": [
{
"id": "claude-3-5-sonnet-20240620",
"display_name": "Claude 3.5 Sonnet (old)",
"created_at": "2024-06-20T00:00:00Z",
},
{
"id": "claude-opus-4-6",
"display_name": "Claude Opus 4.6",
"created_at": "2026-02-01T00:00:00Z",
},
{
"id": "claude-haiku-4-5-20251001",
"display_name": "Claude Haiku 4.5",
"created_at": "2025-10-01T00:00:00Z",
},
]
}
monkeypatch.setattr(mc, "_http_get_json", lambda *a, **k: payload)
models = mc.get_provider_models("anthropic", FALLBACK_ANTHROPIC)
# Newest first by created_at, display names preserved.
assert models[0] == ("claude-opus-4-6", "Claude Opus 4.6")
assert models[1] == ("claude-haiku-4-5-20251001", "Claude Haiku 4.5")
assert models[2] == ("claude-3-5-sonnet-20240620", "Claude 3.5 Sonnet (old)")
def test_vendor_openai_filters_non_chat_models(monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "sk-test")
payload = {
"data": [
{"id": "gpt-4.1", "created": 1_700_000_000},
{"id": "text-embedding-3-large", "created": 1_800_000_000},
{"id": "whisper-1", "created": 1_800_000_000},
{"id": "gpt-5.5", "created": 1_750_000_000},
]
}
monkeypatch.setattr(mc, "_http_get_json", lambda *a, **k: payload)
models = mc.get_provider_models("openai", [])
ids = [m for m, _ in models]
assert ids == ["gpt-5.5", "gpt-4.1"] # embeddings/whisper dropped, newest first
def test_vendor_gemini_requires_generate_content(monkeypatch):
monkeypatch.setenv("GEMINI_API_KEY", "key")
payload = {
"models": [
{
"name": "models/gemini-2.5-pro",
"displayName": "Gemini 2.5 Pro",
"supportedGenerationMethods": ["generateContent"],
},
{
"name": "models/text-embedding-004",
"displayName": "Embedding",
"supportedGenerationMethods": ["embedContent"],
},
{
"name": "models/gemini-1.5-pro",
"displayName": "Gemini 1.5 Pro",
"supportedGenerationMethods": ["generateContent"],
},
]
}
monkeypatch.setattr(mc, "_http_get_json", lambda *a, **k: payload)
models = mc.get_provider_models("gemini", [])
ids = [m for m, _ in models]
# "models/" prefix stripped, embedding excluded, newer version first.
assert ids == ["gemini-2.5-pro", "gemini-1.5-pro"]
def test_openai_excludes_fine_tunes_and_checkpoints(monkeypatch):
# Fine-tunes/checkpoints have recent `created` timestamps and would otherwise
# crowd out (and rank above) the base models — they must be excluded so the
# picker shows clean foundation models.
monkeypatch.setenv("OPENAI_API_KEY", "sk-test")
payload = {
"data": [
{"id": "ft:gpt-4o-mini-2024-07-18:crewai::DyJG86uF", "created": 1_900_000_000},
{
"id": "ft:gpt-4o-mini-2024-07-18:crewai::DyJG7Q9N:ckpt-step-84",
"created": 1_900_000_001,
},
{"id": "gpt-5.5", "created": 1_800_000_000},
{"id": "gpt-4.1", "created": 1_700_000_000},
]
}
monkeypatch.setattr(mc, "_http_get_json", lambda *a, **k: payload)
ids = [m for m, _ in mc.get_provider_models("openai", [])]
assert ids == ["gpt-5.5", "gpt-4.1"] # fine-tunes + checkpoints dropped
def test_vendor_gemini_paginates(monkeypatch):
monkeypatch.setenv("GEMINI_API_KEY", "key")
pages = {
None: {
"models": [
{
"name": "models/gemini-3.5-flash",
"displayName": "Gemini 3.5 Flash",
"supportedGenerationMethods": ["generateContent"],
}
],
"nextPageToken": "p2",
},
"p2": {
"models": [
{
"name": "models/gemini-2.5-pro",
"displayName": "Gemini 2.5 Pro",
"supportedGenerationMethods": ["generateContent"],
}
]
},
}
def fetch(url, headers=None, params=None):
return pages[(params or {}).get("pageToken")]
monkeypatch.setattr(mc, "_http_get_json", fetch)
ids = sorted(m for m, _ in mc.get_provider_models("gemini", []))
# Both pages contributed (newest-first ranking is _finalize's job).
assert ids == ["gemini-2.5-pro", "gemini-3.5-flash"]
def test_vendor_gemini_first_page_error_uses_fallback(monkeypatch):
# A total (first-page) Gemini failure with a key set must fall back to the
# curated list, not be mistaken for a successful empty result.
monkeypatch.setenv("GEMINI_API_KEY", "key")
def boom(*a, **k):
raise RuntimeError("gemini down")
monkeypatch.setattr(mc, "_http_get_json", boom)
models = mc.get_provider_models("gemini", [("gemini-x", "Gemini X")])
assert models == [("gemini-x", "Gemini X")]
def test_vendor_gemini_keeps_partial_on_later_page_error(monkeypatch):
monkeypatch.setenv("GEMINI_API_KEY", "key")
def fetch(url, headers=None, params=None):
if (params or {}).get("pageToken"):
raise RuntimeError("page 2 down")
return {
"models": [
{
"name": "models/gemini-3.5-flash",
"displayName": "Gemini 3.5 Flash",
"supportedGenerationMethods": ["generateContent"],
}
],
"nextPageToken": "p2",
}
monkeypatch.setattr(mc, "_http_get_json", fetch)
# Page-1 models are kept; the later-page error doesn't force the fallback.
models = mc.get_provider_models("gemini", [("fallback-x", "Fallback X")])
assert [m for m, _ in models] == ["gemini-3.5-flash"]
def test_ollama_empty_response_not_filled_with_fallback(monkeypatch):
# A reachable Ollama with nothing installed -> empty (manual entry), not the
# curated suggestions the crew can't actually run.
monkeypatch.setattr(mc, "_http_get_json", lambda *a, **k: {"models": []})
assert mc.get_provider_models("ollama", [("llama3.3", "Llama 3.3")]) == []
def test_ollama_unreachable_uses_fallback(monkeypatch):
# Server down (fetch raises) is different from empty -> fall back to suggestions.
def boom(*a, **k):
raise RuntimeError("connection refused")
monkeypatch.setattr(mc, "_http_get_json", boom)
models = mc.get_provider_models("ollama", [("llama3.3", "Llama 3.3")])
assert models == [("llama3.3", "Llama 3.3")]
def test_ollama_excludes_embedding_models(monkeypatch):
# /api/tags lists everything installed, including embeddings — filter them.
monkeypatch.setattr(
mc,
"_http_get_json",
lambda *a, **k: {
"models": [
{"model": "llama3.3:70b"},
{"model": "nomic-embed-text"},
{"model": "mxbai-embed-large"},
]
},
)
ids = [m for m, _ in mc.get_provider_models("ollama", [])]
assert ids == ["llama3.3:70b"]
def test_ollama_base_honors_ollama_host(monkeypatch):
# OLLAMA_HOST (scheme-less runtime convention) is resolved with a scheme.
monkeypatch.setenv("OLLAMA_HOST", "10.0.0.5:11434")
assert mc._ollama_base() == "http://10.0.0.5:11434"
def test_ollama_recovery_not_blocked_by_negative_cache(monkeypatch):
# Ollama down -> fallback, but not negatively cached; once the server is up
# the next call fetches live models rather than serving suggestions.
calls = {"n": 0}
def flaky(*a, **k):
calls["n"] += 1
if calls["n"] == 1:
raise RuntimeError("connection refused")
return {"models": [{"model": "llama-installed"}]}
monkeypatch.setattr(mc, "_http_get_json", flaky)
first = mc.get_provider_models("ollama", [("llama3.3", "Llama 3.3")])
assert first == [("llama3.3", "Llama 3.3")] # down -> fallback (not cached)
second = mc.get_provider_models("ollama", [("llama3.3", "Llama 3.3")])
assert [m for m, _ in second] == ["llama-installed"] # recovered live
def test_gemini_honors_google_api_key(monkeypatch):
# GOOGLE_API_KEY (equivalent to GEMINI_API_KEY in crewai) enables the live tier.
monkeypatch.setenv("GOOGLE_API_KEY", "key")
monkeypatch.setattr(
mc,
"_http_get_json",
lambda *a, **k: {
"models": [
{
"name": "models/gemini-3.5-flash",
"displayName": "Gemini 3.5 Flash",
"supportedGenerationMethods": ["generateContent"],
}
]
},
)
models = mc.get_provider_models("gemini", [("gemini-x", "Gemini X")])
assert [m for m, _ in models] == ["gemini-3.5-flash"] # live, not fallback
def test_curated_label_overrides_raw_vendor_label(monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "sk-test")
payload = {"data": [{"id": "gpt-5.5", "created": 1}]}
monkeypatch.setattr(mc, "_http_get_json", lambda *a, **k: payload)
models = mc.get_provider_models("openai", [("gpt-5.5", "GPT-5.5 (curated)")])
assert models == [("gpt-5.5", "GPT-5.5 (curated)")]
def test_truncates_to_max_models(monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "sk-test")
payload = {
"data": [{"id": f"gpt-test-{i}", "created": i} for i in range(20)]
}
monkeypatch.setattr(mc, "_http_get_json", lambda *a, **k: payload)
models = mc.get_provider_models("openai", [])
assert len(models) == mc.MAX_MODELS
# ── litellm tier ─────────────────────────────────────────────────
def test_litellm_tier_for_uncurated_provider(monkeypatch):
# A provider with no curated fallback ([]) -> the LiteLLM feed is consulted.
litellm_data = {
"claude-opus-4-6": {"litellm_provider": "anthropic", "mode": "chat"},
"claude-sonnet-4-5": {"litellm_provider": "anthropic", "mode": "chat"},
"voyage-embed": {"litellm_provider": "anthropic", "mode": "embedding"},
"gpt-4.1": {"litellm_provider": "openai", "mode": "chat"},
}
mc._litellm_cache_file().write_text(json.dumps(litellm_data), encoding="utf-8")
models = mc.get_provider_models("anthropic", []) # empty == uncurated
ids = [m for m, _ in models]
# Only anthropic chat models, embedding + other providers excluded.
assert ids == ["claude-opus-4-6", "claude-sonnet-4-5"]
def test_null_litellm_provider_does_not_crash(monkeypatch):
# A present-but-null litellm_provider must be skipped, not raise.
litellm_data = {
"weird-model": {"litellm_provider": None, "mode": "chat"},
"anthropic.claude-v2": {"litellm_provider": "bedrock", "mode": "chat"},
}
mc._litellm_cache_file().write_text(json.dumps(litellm_data), encoding="utf-8")
models = mc.get_provider_models("bedrock", [])
assert [m for m, _ in models] == ["anthropic.claude-v2"]
def test_litellm_strips_provider_prefix(monkeypatch):
litellm_data = {
"gemini/gemini-1.5-pro": {"litellm_provider": "gemini", "mode": "chat"},
}
mc._litellm_cache_file().write_text(json.dumps(litellm_data), encoding="utf-8")
models = mc.get_provider_models("gemini", [])
assert models == [("gemini-1.5-pro", "Gemini 1.5 Pro")]
# ── fallback + caching ───────────────────────────────────────────
def test_falls_back_when_everything_fails(monkeypatch):
# No key, no litellm cache, network raises -> curated fallback verbatim.
def boom(*a, **k):
raise RuntimeError("network down")
monkeypatch.setattr(mc, "_http_get_json", boom)
models = mc.get_provider_models("anthropic", FALLBACK_ANTHROPIC)
assert models == FALLBACK_ANTHROPIC
def test_result_is_cached(monkeypatch):
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-test")
calls = {"n": 0}
def fetch(*a, **k):
calls["n"] += 1
return {"data": [{"id": "claude-opus-4-6", "created_at": "2026-01-01T00:00:00Z"}]}
monkeypatch.setattr(mc, "_http_get_json", fetch)
first = mc.get_provider_models("anthropic", FALLBACK_ANTHROPIC)
# Second call must hit the cache and not touch the network again.
monkeypatch.setattr(mc, "_http_get_json", lambda *a, **k: pytest.fail("refetched"))
second = mc.get_provider_models("anthropic", FALLBACK_ANTHROPIC)
assert first == second
assert calls["n"] == 1
def test_curated_fallback_preferred_over_litellm(monkeypatch):
# The feed lags real releases, so a non-empty curated fallback must win even
# when a fresh LiteLLM cache is present (regression: Anthropic's feed lacked
# Fable 5 / Opus 4.8 / Sonnet 5).
monkeypatch.setattr(mc, "_http_get_json", lambda *a, **k: pytest.fail("no net"))
litellm_data = {
"claude-opus-4-6": {"litellm_provider": "anthropic", "mode": "chat"},
}
mc._litellm_cache_file().write_text(json.dumps(litellm_data), encoding="utf-8")
models = mc.get_provider_models("anthropic", FALLBACK_ANTHROPIC)
assert models == FALLBACK_ANTHROPIC
def test_added_key_bypasses_negative_cache(monkeypatch):
# A no-key call negatively-caches the fallback; adding a key afterwards must
# fetch live models rather than serve the cached fallback (distinct cache key).
first = mc.get_provider_models("openai", [("gpt-x", "GPT X")])
assert first == [("gpt-x", "GPT X")] # no key -> fallback
monkeypatch.setenv("OPENAI_API_KEY", "sk-test")
monkeypatch.setattr(
mc, "_http_get_json", lambda *a, **k: {"data": [{"id": "gpt-5.5", "created": 1}]}
)
second = mc.get_provider_models("openai", [("gpt-x", "GPT X")])
assert [m for m, _ in second] == ["gpt-5.5"] # live fetch, not cached fallback
def test_invalid_litellm_cache_falls_through_to_download(monkeypatch):
# A corrupt-but-fresh cache must neither crash the picker nor block a
# recoverable download — it falls through and refetches.
mc._litellm_cache_file().write_text("[1, 2, 3]", encoding="utf-8")
monkeypatch.setattr(
mc,
"_http_get_json",
lambda *a, **k: {
"anthropic.claude-v2": {"litellm_provider": "bedrock", "mode": "chat"}
},
)
models = mc.get_provider_models("bedrock", [])
assert [m for m, _ in models] == ["anthropic.claude-v2"] # recovered via download
def test_litellm_fetch_attempted_once_per_process(monkeypatch):
# With no cache and a failing download, the feed is fetched at most once per
# process — repeated lookups (across providers) must not re-hit the network.
calls = {"n": 0}
def boom(*a, **k):
calls["n"] += 1
raise RuntimeError("offline")
monkeypatch.setattr(mc, "_http_get_json", boom)
mc.get_provider_models("bedrock", [])
mc.get_provider_models("azure", [])
assert calls["n"] == 1 # memoized after the first failed attempt
def test_litellm_fills_uncurated_bedrock(monkeypatch):
# No vendor fetcher and no curated fallback -> LiteLLM feed fills the gap.
monkeypatch.setattr(mc, "_http_get_json", lambda *a, **k: pytest.fail("no net"))
litellm_data = {
"anthropic.claude-v2": {"litellm_provider": "bedrock", "mode": "chat"},
}
mc._litellm_cache_file().write_text(json.dumps(litellm_data), encoding="utf-8")
models = mc.get_provider_models("bedrock", [])
assert models == [("anthropic.claude-v2", "Anthropic.claude V2")]
def test_failed_fetch_is_negatively_cached(monkeypatch):
# A failed vendor fetch must not be retried on every call — the fallback is
# cached briefly so the picker doesn't re-hit the timeout-prone endpoint.
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-test")
calls = {"n": 0}
def boom(*a, **k):
calls["n"] += 1
raise RuntimeError("down")
monkeypatch.setattr(mc, "_http_get_json", boom)
first = mc.get_provider_models("anthropic", FALLBACK_ANTHROPIC)
second = mc.get_provider_models("anthropic", FALLBACK_ANTHROPIC)
assert first == second == FALLBACK_ANTHROPIC
assert calls["n"] == 1 # second call served from the negative cache
def test_bad_cache_json_does_not_crash(monkeypatch):
# A corrupt cache whose root is not a mapping must not raise (get_provider_models
# is documented to never raise).
mc._catalog_cache_file().write_text("[1, 2, 3]", encoding="utf-8")
models = mc.get_provider_models("anthropic", FALLBACK_ANTHROPIC)
assert models == FALLBACK_ANTHROPIC
def test_ollama_cache_keyed_by_base(monkeypatch):
# Changing OLLAMA_API_BASE must not serve the previous host's cached models.
monkeypatch.setenv("OLLAMA_API_BASE", "http://host-a:11434")
monkeypatch.setattr(
mc, "_http_get_json", lambda *a, **k: {"models": [{"model": "llama-a"}]}
)
first = mc.get_provider_models("ollama", [])
assert [m for m, _ in first] == ["llama-a"]
monkeypatch.setenv("OLLAMA_API_BASE", "http://host-b:11434")
monkeypatch.setattr(
mc, "_http_get_json", lambda *a, **k: {"models": [{"model": "llama-b"}]}
)
second = mc.get_provider_models("ollama", [])
assert [m for m, _ in second] == ["llama-b"] # not the host-a cache

View File

@@ -1,8 +1,6 @@
import os
import unittest
from unittest.mock import ANY, AsyncMock, MagicMock, patch
import pytest
from unittest.mock import ANY, MagicMock, patch
from crewai_cli.plus_api import PlusAPI
@@ -343,28 +341,23 @@ class TestPlusAPI(unittest.TestCase):
)
@pytest.mark.asyncio
@patch("httpx.AsyncClient")
async def test_get_agent(mock_async_client_class):
@patch("crewai_core.plus_api.PlusAPI._make_request")
def test_get_agent(mock_make_request):
api = PlusAPI("test_api_key")
mock_response = MagicMock()
mock_client_instance = AsyncMock()
mock_client_instance.get.return_value = mock_response
mock_async_client_class.return_value.__aenter__.return_value = mock_client_instance
mock_make_request.return_value = mock_response
response = await api.get_agent("test_agent_handle")
response = api.get_agent("test_agent_handle")
mock_client_instance.get.assert_called_once_with(
f"{api.base_url}/crewai_plus/api/v1/agents/test_agent_handle",
headers=api.headers,
mock_make_request.assert_called_once_with(
"GET", "/crewai_plus/api/v1/agents/test_agent_handle"
)
assert response == mock_response
@pytest.mark.asyncio
@patch("httpx.AsyncClient")
@patch("crewai_core.plus_api.PlusAPI._make_request")
@patch("crewai_core.plus_api.Settings")
async def test_get_agent_with_org_uuid(mock_settings_class, mock_async_client_class):
def test_get_agent_with_org_uuid(mock_settings_class, mock_make_request):
org_uuid = "test-org-uuid"
mock_settings = MagicMock()
mock_settings.org_uuid = org_uuid
@@ -374,15 +367,12 @@ async def test_get_agent_with_org_uuid(mock_settings_class, mock_async_client_cl
api = PlusAPI("test_api_key")
mock_response = MagicMock()
mock_client_instance = AsyncMock()
mock_client_instance.get.return_value = mock_response
mock_async_client_class.return_value.__aenter__.return_value = mock_client_instance
mock_make_request.return_value = mock_response
response = await api.get_agent("test_agent_handle")
response = api.get_agent("test_agent_handle")
mock_client_instance.get.assert_called_once_with(
f"{api.base_url}/crewai_plus/api/v1/agents/test_agent_handle",
headers=api.headers,
mock_make_request.assert_called_once_with(
"GET", "/crewai_plus/api/v1/agents/test_agent_handle"
)
assert "X-Crewai-Organization-Id" in api.headers
assert api.headers["X-Crewai-Organization-Id"] == org_uuid

View File

@@ -1 +1 @@
__version__ = "1.15.1"
__version__ = "1.15.2a2"

View File

@@ -232,10 +232,8 @@ class PlusAPI:
def get_tool(self, handle: str) -> httpx.Response:
return self._make_request("GET", f"{self.TOOLS_RESOURCE}/{handle}")
async def get_agent(self, handle: str) -> httpx.Response:
url = urljoin(self.base_url, f"{self.AGENTS_RESOURCE}/{handle}")
async with httpx.AsyncClient() as client:
return await client.get(url, headers=cast(dict[str, str], self.headers))
def get_agent(self, handle: str) -> httpx.Response:
return self._make_request("GET", f"{self.AGENTS_RESOURCE}/{handle}")
def publish_tool(
self,

View File

@@ -264,10 +264,12 @@ class Telemetry:
def flow_creation_span(self, flow_name: str) -> None:
"""Records the creation of a new flow."""
from crewai_core.version import get_crewai_version
def _operation() -> None:
tracer = trace.get_tracer("crewai.telemetry")
span = tracer.start_span("Flow Creation")
self._add_attribute(span, "crewai_version", get_crewai_version())
self._add_attribute(span, "flow_name", flow_name)
close_span(span)

View File

@@ -233,3 +233,31 @@ def test_core_telemetry_records_feature_usage(
tracer.start_span.assert_called_once_with("Feature Usage")
span.set_attribute.assert_any_call("feature", "cli_usage:view_traces")
span.end.assert_called_once()
def test_core_telemetry_records_flow_creation_version(
monkeypatch: pytest.MonkeyPatch,
) -> None:
from crewai_core.telemetry import Telemetry
Telemetry._instance = None
monkeypatch.delenv("OTEL_SDK_DISABLED", raising=False)
monkeypatch.delenv("CREWAI_DISABLE_TELEMETRY", raising=False)
monkeypatch.delenv("CREWAI_DISABLE_TRACKING", raising=False)
monkeypatch.setattr("crewai_core.version.get_crewai_version", lambda: "1.0.0")
tracer = Mock()
span = Mock()
tracer.start_span.return_value = span
monkeypatch.setattr(
"crewai_core.telemetry.trace.get_tracer",
lambda _name: tracer,
)
telemetry = Telemetry()
telemetry.flow_creation_span("ResearchFlow")
tracer.start_span.assert_called_once_with("Flow Creation")
span.set_attribute.assert_any_call("crewai_version", "1.0.0")
span.set_attribute.assert_any_call("flow_name", "ResearchFlow")
span.end.assert_called_once()

View File

@@ -152,4 +152,4 @@ __all__ = [
"wrap_file_source",
]
__version__ = "1.15.1"
__version__ = "1.15.2a2"

View File

@@ -10,7 +10,7 @@ requires-python = ">=3.10, <3.14"
dependencies = [
"pytube~=15.0.0",
"requests>=2.33.0,<3",
"crewai==1.15.1",
"crewai==1.15.2a2",
"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.15.1"
__version__ = "1.15.2a2"

View File

@@ -87,9 +87,11 @@ class TavilyExtractorTool(BaseTool):
"""
super().__init__(**kwargs)
if TAVILY_AVAILABLE:
self.client = TavilyClient(api_key=self.api_key, proxies=self.proxies)
self.client = TavilyClient(
api_key=self.api_key, proxies=self.proxies, client_name="crewai"
)
self.async_client = AsyncTavilyClient(
api_key=self.api_key, proxies=self.proxies
api_key=self.api_key, proxies=self.proxies, client_name="crewai"
)
else:
try:

View File

@@ -54,8 +54,10 @@ class TavilyGetResearchTool(BaseTool):
super().__init__(**kwargs)
if TAVILY_AVAILABLE:
api_key = os.getenv("TAVILY_API_KEY")
self._client = TavilyClient(api_key=api_key)
self._async_client = AsyncTavilyClient(api_key=api_key)
self._client = TavilyClient(api_key=api_key, client_name="crewai")
self._async_client = AsyncTavilyClient(
api_key=api_key, client_name="crewai"
)
else:
try:
import subprocess

View File

@@ -90,8 +90,10 @@ class TavilyResearchTool(BaseTool):
super().__init__(**kwargs)
if TAVILY_AVAILABLE:
api_key = os.getenv("TAVILY_API_KEY")
self._client = TavilyClient(api_key=api_key)
self._async_client = AsyncTavilyClient(api_key=api_key)
self._client = TavilyClient(api_key=api_key, client_name="crewai")
self._async_client = AsyncTavilyClient(
api_key=api_key, client_name="crewai"
)
else:
try:
import subprocess

View File

@@ -115,9 +115,11 @@ class TavilySearchTool(BaseTool):
def __init__(self, **kwargs: Any):
super().__init__(**kwargs)
if TAVILY_AVAILABLE:
self.client = TavilyClient(api_key=self.api_key, proxies=self.proxies)
self.client = TavilyClient(
api_key=self.api_key, proxies=self.proxies, client_name="crewai"
)
self.async_client = AsyncTavilyClient(
api_key=self.api_key, proxies=self.proxies
api_key=self.api_key, proxies=self.proxies, client_name="crewai"
)
else:
try:

View File

@@ -8,8 +8,8 @@ authors = [
]
requires-python = ">=3.10, <3.14"
dependencies = [
"crewai-core==1.15.1",
"crewai-cli==1.15.1",
"crewai-core==1.15.2a2",
"crewai-cli==1.15.2a2",
# Core Dependencies
"pydantic>=2.11.9,<2.13",
"openai>=2.30.0,<3",
@@ -55,7 +55,7 @@ Repository = "https://github.com/crewAIInc/crewAI"
[project.optional-dependencies]
tools = [
"crewai-tools==1.15.1",
"crewai-tools==1.15.2a2",
]
embeddings = [
"tiktoken>=0.8.0,<0.13"
@@ -92,6 +92,7 @@ litellm = [
]
bedrock = [
"boto3~=1.42.90",
"aiobotocore~=3.5.0",
]
google-genai = [
"google-genai~=1.65.0",

View File

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

View File

@@ -73,7 +73,6 @@ from crewai.events.types.memory_events import (
MemoryRetrievalFailedEvent,
MemoryRetrievalStartedEvent,
)
from crewai.events.types.skill_events import SkillActivatedEvent
from crewai.experimental.agent_executor import AgentExecutor
from crewai.knowledge.knowledge import Knowledge
from crewai.knowledge.source.base_knowledge_source import BaseKnowledgeSource
@@ -82,8 +81,8 @@ from crewai.llms.base_llm import BaseLLM
from crewai.mcp.config import MCPServerConfig
from crewai.rag.embeddings.types import EmbedderConfig
from crewai.security.fingerprint import Fingerprint
from crewai.skills.loader import activate_skill, discover_skills
from crewai.skills.models import INSTRUCTIONS, Skill as SkillModel
from crewai.skills.loader import load_skills
from crewai.skills.models import Skill as SkillModel
from crewai.state.checkpoint_config import CheckpointConfig, apply_checkpoint
from crewai.tools.agent_tools.agent_tools import AgentTools
from crewai.types.callback import SerializableCallable
@@ -202,7 +201,7 @@ class Agent(BaseAgent):
_last_messages: list[LLMMessage] = PrivateAttr(default_factory=list)
max_execution_time: int | None = Field(
default=None,
description="Maximum execution time for an agent to execute a task",
description="Maximum execution time in seconds for an agent to execute a task",
)
step_callback: SerializableCallable | None = Field(
default=None,
@@ -429,13 +428,12 @@ class Agent(BaseAgent):
self,
resolved_crew_skills: list[SkillModel] | None = None,
) -> None:
"""Resolve skill paths while preserving explicit disclosure levels.
"""Load configured skills while preserving explicit disclosure levels.
Path entries trigger discovery and activation because directory-based
skills opt into eager loading. Pre-loaded Skill objects keep their
current disclosure level so callers can attach METADATA-only skills and
progressively activate them later. Crew-level skills are merged in with
event emission so observability is consistent regardless of origin.
Path strings, Path objects, inline SKILL.md strings, and registry refs
are loaded through the shared skill loader. Pre-loaded Skill objects
keep their current disclosure level so callers can attach METADATA-only
skills and progressively activate them later.
Args:
resolved_crew_skills: Pre-resolved crew skills. When provided,
@@ -444,7 +442,7 @@ class Agent(BaseAgent):
from crewai.crew import Crew
if resolved_crew_skills is None:
crew_skills: list[Path | SkillModel | str] | None = (
crew_skills = (
self.crew.skills
if isinstance(self.crew, Crew) and isinstance(self.crew.skills, list)
else None
@@ -455,58 +453,14 @@ class Agent(BaseAgent):
if not self.skills and not crew_skills:
return
needs_work = self.skills and any(
isinstance(s, (Path, str))
or (isinstance(s, SkillModel) and s.disclosure_level < INSTRUCTIONS)
for s in self.skills
)
if not needs_work and not crew_skills:
return
seen: set[str] = set()
resolved: list[Path | SkillModel | str] = []
items: list[Path | SkillModel | str] = list(self.skills) if self.skills else []
items = list(self.skills) if self.skills else []
if crew_skills:
items.extend(crew_skills)
for item in items:
if isinstance(item, str):
from crewai.experimental.skills.registry import (
is_registry_ref,
parse_registry_ref,
resolve_registry_ref,
)
if is_registry_ref(item):
skill = resolve_registry_ref(item, source=self)
org, _ = parse_registry_ref(item)
dedup_key = f"{org}/{skill.name}"
if dedup_key not in seen:
seen.add(dedup_key)
resolved.append(skill)
elif isinstance(item, Path):
discovered = discover_skills(item, source=self)
for skill in discovered:
if skill.name not in seen:
seen.add(skill.name)
resolved.append(activate_skill(skill, source=self))
elif isinstance(item, SkillModel):
if item.name not in seen:
seen.add(item.name)
if item.disclosure_level >= INSTRUCTIONS:
crewai_event_bus.emit(
self,
event=SkillActivatedEvent(
from_agent=self,
skill_name=item.name,
skill_path=item.path,
disclosure_level=item.disclosure_level,
),
)
resolved.append(item)
self.skills = resolved if resolved else None
self.skills = cast(
list[Path | SkillModel | str] | None,
load_skills(items, source=self) or None,
)
def _is_any_available_memory(self) -> bool:
"""Check if unified memory is available (agent or crew)."""
@@ -1175,15 +1129,32 @@ class Agent(BaseAgent):
return agent_tools.tools()
def get_platform_tools(self, apps: list[PlatformAppOrAction]) -> list[BaseTool]:
platform_tools: list[BaseTool] = []
try:
from crewai_tools import (
CrewaiPlatformTools,
)
return CrewaiPlatformTools(apps=apps)
platform_tools = CrewaiPlatformTools(apps=apps)
except Exception as e:
self._logger.log("error", f"Error getting platform tools: {e!s}")
return []
if apps and not platform_tools:
# A non-empty `apps` that resolves to zero tools leaves the agent
# unable to do what those apps were for — surface it loudly (the
# underlying resolver only logs, and Logger.log is verbose-gated) so
# it isn't discovered as a mysterious runtime failure.
app_names = ", ".join(str(app) for app in apps)
warnings.warn(
f"Agent '{self.role}' declares apps [{app_names}] but none "
"resolved to any tools; the agent will run without them and may "
"be unable to complete its goal. Check that these apps exist and "
"are connected, and that CREWAI_PLATFORM_INTEGRATION_TOKEN is set.",
UserWarning,
stacklevel=2,
)
return platform_tools
def get_mcp_tools(self, mcps: list[str | MCPServerConfig]) -> list[BaseTool]:
"""Convert MCP server references/configs to CrewAI tools.

View File

@@ -388,7 +388,7 @@ class BaseAgent(BaseModel, ABC, metaclass=AgentMeta):
)
skills: list[Path | Skill | str] | None = Field(
default=None,
description="Agent Skills. Accepts paths for discovery, pre-loaded Skill objects, or '@org/name' registry refs.",
description="Agent Skills. Accepts paths for discovery, inline SKILL.md strings, pre-loaded Skill objects, or '@org/name' registry refs.",
min_length=1,
)
execution_context: ExecutionContext | None = Field(default=None)
@@ -494,20 +494,6 @@ class BaseAgent(BaseModel, ABC, metaclass=AgentMeta):
def process_model_config(cls, values: Any) -> dict[str, Any]:
return process_config(values, cls)
@field_validator("skills", mode="before")
@classmethod
def coerce_skill_strings(cls, skills: Any) -> Any:
"""Coerce plain path strings to Path objects; keep @-prefixed refs as str."""
if not isinstance(skills, list):
return skills
result = []
for item in skills:
if isinstance(item, str) and not item.startswith("@"):
result.append(Path(item))
else:
result.append(item)
return result
@field_validator("tools")
@classmethod
def validate_tools(cls, tools: list[Any]) -> list[BaseTool]:

View File

@@ -361,7 +361,7 @@ class Crew(FlowTrackable, BaseModel):
)
skills: list[Path | Skill | str] | None = Field(
default=None,
description="Skill search paths, pre-loaded Skill objects, or '@org/name' registry refs applied to all agents in the crew.",
description="Skill search paths, inline SKILL.md strings, pre-loaded Skill objects, or '@org/name' registry refs applied to all agents in the crew.",
)
security_config: SecurityConfig = Field(
@@ -574,20 +574,6 @@ class Crew(FlowTrackable, BaseModel):
if max_seq > 0:
set_emission_counter(max_seq)
@field_validator("skills", mode="before")
@classmethod
def coerce_skill_strings(cls, skills: Any) -> Any:
"""Coerce plain path strings to Path objects; keep @-prefixed refs as str."""
if not isinstance(skills, list):
return skills
result = []
for item in skills:
if isinstance(item, str) and not item.startswith("@"):
result.append(Path(item))
else:
result.append(item)
return result
@field_validator("id", mode="before")
@classmethod
def _deny_user_set_id(cls, v: UUID4 | None, info: Any) -> UUID4 | None:

View File

@@ -4,7 +4,6 @@ from __future__ import annotations
import asyncio
from collections.abc import Callable, Coroutine, Iterable, Mapping
from pathlib import Path
from typing import TYPE_CHECKING, Any
from opentelemetry import baggage
@@ -13,7 +12,7 @@ from crewai.agents.agent_builder.base_agent import BaseAgent
from crewai.crews.crew_output import CrewOutput
from crewai.llms.base_llm import BaseLLM
from crewai.rag.embeddings.types import EmbedderConfig
from crewai.skills.loader import activate_skill, discover_skills
from crewai.skills.loader import activate_skill, load_skills
from crewai.skills.models import INSTRUCTIONS, Skill as SkillModel
from crewai.types.streaming import CrewStreamingOutput, FlowStreamingOutput
from crewai.utilities.file_store import store_files
@@ -60,23 +59,13 @@ def _resolve_crew_skills(crew: Crew) -> list[SkillModel] | None:
if not isinstance(crew.skills, list) or not crew.skills:
return None
resolved: list[SkillModel] = []
seen: set[str] = set()
for item in crew.skills:
if isinstance(item, Path):
for skill in discover_skills(item):
if skill.name not in seen:
seen.add(skill.name)
resolved.append(activate_skill(skill))
elif isinstance(item, SkillModel):
if item.name not in seen:
seen.add(item.name)
resolved.append(
activate_skill(item)
if item.disclosure_level < INSTRUCTIONS
else item
)
return resolved
resolved = load_skills(crew.skills)
if not resolved:
return None
return [
activate_skill(skill) if skill.disclosure_level < INSTRUCTIONS else skill
for skill in resolved
]
def setup_agents(

View File

@@ -40,6 +40,7 @@ from crewai.events.event_context import (
set_last_event_id,
)
from crewai.events.handler_graph import build_execution_plan
from crewai.events.stream_context import publish_stream_event
from crewai.events.types.event_bus_types import (
AsyncHandler,
AsyncHandlerSet,
@@ -565,6 +566,7 @@ class CrewAIEventsBus:
set_last_event_id(event.event_id)
publish_stream_event(source, event)
self._record_event(event)
def emit(self, source: Any, event: BaseEvent) -> Future[None] | None:
@@ -775,9 +777,7 @@ class CrewAIEventsBus:
source: The object emitting the event
event: The event instance to emit
"""
self._register_source(source)
event.emission_sequence = get_next_emission_sequence()
self._record_event(event)
self._prepare_event(source, event)
event_type = type(event)

View File

@@ -0,0 +1,30 @@
"""Scoped stream sinks for converting emitted events into public frames."""
from __future__ import annotations
from collections.abc import Callable
import contextvars
from typing import Any
StreamSink = Callable[[Any, Any], None]
_stream_sinks: contextvars.ContextVar[tuple[StreamSink, ...]] = contextvars.ContextVar(
"crewai_stream_sinks", default=()
)
def add_stream_sink(sink: StreamSink) -> contextvars.Token[tuple[StreamSink, ...]]:
"""Register a sink in the current context."""
return _stream_sinks.set((*_stream_sinks.get(), sink))
def reset_stream_sinks(token: contextvars.Token[tuple[StreamSink, ...]]) -> None:
"""Restore the stream sink context."""
_stream_sinks.reset(token)
def publish_stream_event(source: Any, event: Any) -> None:
"""Publish a prepared event to sinks scoped to the current execution."""
for sink in _stream_sinks.get():
sink(source, event)

View File

@@ -106,6 +106,7 @@ from crewai.utilities.planning_types import (
TodoItem,
TodoList,
)
from crewai.utilities.prompts import StandardPromptResult, SystemPromptResult
from crewai.utilities.step_execution_context import StepExecutionContext, StepResult
from crewai.utilities.string_utils import sanitize_tool_name
from crewai.utilities.tool_utils import execute_tool_and_check_finality
@@ -118,7 +119,6 @@ if TYPE_CHECKING:
from crewai.agents.tools_handler import ToolsHandler
from crewai.llms.base_llm import BaseLLM
from crewai.tools.tool_types import ToolResult
from crewai.utilities.prompts import StandardPromptResult, SystemPromptResult
_RouteT = TypeVar("_RouteT", bound=str)
@@ -218,6 +218,7 @@ class AgentExecutor(Flow[AgentExecutorState], BaseAgentExecutor):
_instance_id: str = PrivateAttr(default_factory=lambda: str(uuid4())[:8])
_step_executor: Any = PrivateAttr(default=None)
_planner_observer: PlannerObserver | None = PrivateAttr(default=None)
_is_feedback_iteration: bool = PrivateAttr(default=False)
@model_validator(mode="after")
def _setup_executor(self) -> Self:
@@ -296,6 +297,33 @@ class AgentExecutor(Flow[AgentExecutorState], BaseAgentExecutor):
"""Set state messages."""
self._state.messages = value
def _setup_messages(self, inputs: dict[str, Any]) -> None:
"""Set up messages for the agent execution."""
provider = get_provider()
if provider.setup_messages(cast("ExecutorContext", self)):
return
from crewai.llms.cache import mark_cache_breakpoint
if isinstance(self.prompt, SystemPromptResult):
system_prompt = self._format_prompt(self.prompt["system"], inputs)
user_prompt = self._format_prompt(self.prompt["user"], inputs)
self.state.messages.append(
mark_cache_breakpoint(
format_message_for_llm(system_prompt, role="system")
)
)
self.state.messages.append(
mark_cache_breakpoint(format_message_for_llm(user_prompt))
)
elif isinstance(self.prompt, StandardPromptResult):
user_prompt = self._format_prompt(self.prompt["prompt"], inputs)
self.state.messages.append(
mark_cache_breakpoint(format_message_for_llm(user_prompt))
)
provider.post_setup_messages(cast("ExecutorContext", self))
@property
def ask_for_human_input(self) -> bool:
"""Compatibility property - returns state ask_for_human_input."""
@@ -314,6 +342,8 @@ class AgentExecutor(Flow[AgentExecutorState], BaseAgentExecutor):
enabled on the agent, it generates a plan before execution begins.
The plan is stored in state and todos are created from the steps.
"""
if self._is_feedback_iteration:
return
if not getattr(self.agent, "planning_enabled", False):
return
@@ -2761,27 +2791,7 @@ class AgentExecutor(Flow[AgentExecutorState], BaseAgentExecutor):
"AgentExecutor.llm or .prompt is unset; the executor was "
"not fully restored or initialized before execution."
)
if "system" in self.prompt:
from crewai.llms.cache import mark_cache_breakpoint
prompt = cast("SystemPromptResult", self.prompt)
system_prompt = self._format_prompt(prompt["system"], inputs)
user_prompt = self._format_prompt(prompt["user"], inputs)
self.state.messages.append(
mark_cache_breakpoint(
format_message_for_llm(system_prompt, role="system")
)
)
self.state.messages.append(
mark_cache_breakpoint(format_message_for_llm(user_prompt))
)
else:
from crewai.llms.cache import mark_cache_breakpoint
user_prompt = self._format_prompt(self.prompt["prompt"], inputs)
self.state.messages.append(
mark_cache_breakpoint(format_message_for_llm(user_prompt))
)
self._setup_messages(inputs)
self._inject_files_from_inputs(inputs)
@@ -2867,27 +2877,7 @@ class AgentExecutor(Flow[AgentExecutorState], BaseAgentExecutor):
"AgentExecutor.llm or .prompt is unset; the executor was "
"not fully restored or initialized before execution."
)
if "system" in self.prompt:
from crewai.llms.cache import mark_cache_breakpoint
prompt = cast("SystemPromptResult", self.prompt)
system_prompt = self._format_prompt(prompt["system"], inputs)
user_prompt = self._format_prompt(prompt["user"], inputs)
self.state.messages.append(
mark_cache_breakpoint(
format_message_for_llm(system_prompt, role="system")
)
)
self.state.messages.append(
mark_cache_breakpoint(format_message_for_llm(user_prompt))
)
else:
from crewai.llms.cache import mark_cache_breakpoint
user_prompt = self._format_prompt(self.prompt["prompt"], inputs)
self.state.messages.append(
mark_cache_breakpoint(format_message_for_llm(user_prompt))
)
self._setup_messages(inputs)
await self._ainject_files_from_inputs(inputs)
@@ -3169,8 +3159,13 @@ class AgentExecutor(Flow[AgentExecutorState], BaseAgentExecutor):
Returns:
Final answer after feedback.
"""
self.messages = self.state.messages
provider = get_provider()
return provider.handle_feedback(formatted_answer, cast("ExecutorContext", self))
final_answer = provider.handle_feedback(
formatted_answer, cast("ExecutorContext", self)
)
self._complete_feedback(final_answer)
return final_answer
async def _ahandle_human_feedback(
self, formatted_answer: AgentFinish
@@ -3183,10 +3178,63 @@ class AgentExecutor(Flow[AgentExecutorState], BaseAgentExecutor):
Returns:
Final answer after feedback.
"""
self.messages = self.state.messages
provider = get_provider()
return await provider.handle_feedback_async(
final_answer = await provider.handle_feedback_async(
formatted_answer, cast("AsyncExecutorContext", self)
)
self._complete_feedback(final_answer)
return final_answer
def _complete_feedback(self, final_answer: AgentFinish) -> None:
"""Mark the final reviewed answer as the completed executor state."""
self.state.current_answer = final_answer
self.state.is_finished = True
self.state.ask_for_human_input = False
self._finalize_called = True
def _prepare_feedback_iteration(self) -> None:
"""Reset flow completion state before rerunning with feedback."""
self._finalize_called = False
self._is_feedback_iteration = True
self.state.current_answer = None
self.state.is_finished = False
self.state.iterations = 0
self.state.use_native_tools = False
self.state.pending_tool_calls = []
self.state.plan = None
self.state.plan_ready = False
self.state.todos = TodoList()
self.state.replan_count = 0
self.state.last_replan_reason = None
self.state.observations = {}
self.state.execution_log = []
def _invoke_loop(self) -> AgentFinish:
"""Re-run the executor flow using the existing feedback messages."""
self._prepare_feedback_iteration()
try:
self.kickoff()
finally:
self._is_feedback_iteration = False
if not isinstance(self.state.current_answer, AgentFinish):
raise RuntimeError("Agent execution ended without reaching a final answer.")
return self.state.current_answer
async def _ainvoke_loop(self) -> AgentFinish:
"""Re-run the executor flow asynchronously using feedback messages."""
self._prepare_feedback_iteration()
try:
await self.kickoff_async()
finally:
self._is_feedback_iteration = False
if not isinstance(self.state.current_answer, AgentFinish):
raise RuntimeError("Agent execution ended without reaching a final answer.")
return self.state.current_answer
def _is_training_mode(self) -> bool:
"""Check if training mode is active.
@@ -3196,6 +3244,12 @@ class AgentExecutor(Flow[AgentExecutorState], BaseAgentExecutor):
"""
return bool(self.crew and self.crew._train)
def _format_feedback_message(self, feedback: str) -> LLMMessage:
"""Format human feedback as an LLM message."""
return format_message_for_llm(
I18N_DEFAULT.slice("feedback_instructions").format(feedback=feedback)
)
# Backward compatibility alias (deprecated)
CrewAgentExecutorFlow = AgentExecutor

View File

@@ -18,7 +18,8 @@ Import surface:
from __future__ import annotations
from collections.abc import Callable, Mapping, Sequence
from collections.abc import Callable, Iterator, Mapping, Sequence
from contextlib import contextmanager
from enum import Enum
import json
import logging
@@ -44,6 +45,7 @@ from crewai.experimental.conversational import (
_conversational_only,
message_to_llm_dict,
)
from crewai.flow.async_feedback import HumanFeedbackPending
from crewai.flow.conversation import (
append_message as _append_conversation_message,
get_conversation_messages,
@@ -221,7 +223,9 @@ class _ConversationalMixin:
messages.append({"role": "system", "content": system_prompt})
messages.extend(self.conversation_messages)
response = self._coerce_llm(llm).call(messages=messages)
llm_instance = self._coerce_llm(llm)
with self._conversation_streaming_enabled(llm_instance):
response = llm_instance.call(messages=messages)
content = self._stringify_result(response)
self.append_assistant_message(content)
return content
@@ -254,7 +258,8 @@ class _ConversationalMixin:
},
*self.build_agent_context("answer_from_history"),
]
response = llm_instance.call(messages=messages)
with self._conversation_streaming_enabled(llm_instance):
response = llm_instance.call(messages=messages)
content = self._stringify_result(response)
self.append_assistant_message(content)
return content
@@ -337,6 +342,102 @@ class _ConversationalMixin:
)
return result
def stream_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 and stream one conversational turn as frames."""
if not self._is_conversational_enabled():
raise ValueError(
"Flow.stream_turn() is only available on conversational flows"
)
from crewai.types.streaming import StreamSession
from crewai.utilities.streaming import (
create_frame_generator,
create_frame_streaming_state,
)
state = cast(ConversationState, self.state)
sid = session_id or state.id
result_holder: list[Any] = []
frame_state = create_frame_streaming_state(result_holder, use_async=False)
output_holder: list[StreamSession[Any]] = []
def run_turn() -> Any:
crewai_event_bus.emit(
self,
ConversationTurnStartedEvent(
type="conversation_turn_started",
flow_name=self.name or self.__class__.__name__,
session_id=sid,
),
)
self._pending_user_message = message
self._pending_intents = list(intents) if intents else None
self._pending_intent_llm = intent_llm
try:
if "from_checkpoint" not in kickoff_kwargs:
self._reset_turn_execution_state()
assistant_count = self._assistant_message_count()
original_stream = bool(getattr(self, "stream", False))
original_streaming_turn = getattr(
self, "_streaming_conversation_turn", False
)
try:
object.__setattr__(self, "stream", False)
object.__setattr__(self, "_streaming_conversation_turn", True)
result = self.kickoff(inputs={"id": sid}, **kickoff_kwargs)
finally:
object.__setattr__(self, "stream", original_stream)
object.__setattr__(
self, "_streaming_conversation_turn", original_streaming_turn
)
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))
except HumanFeedbackPending as exc:
return exc
except Exception as exc:
failed_event = ConversationTurnFailedEvent(
type="conversation_turn_failed",
flow_name=self.name or self.__class__.__name__,
session_id=sid,
error=exc,
)
self._emit_terminal_conversation_turn_event(failed_event)
raise
finally:
self._pending_user_message = None
self._pending_intents = None
self._pending_intent_llm = None
self._emit_terminal_conversation_turn_event(
ConversationTurnCompletedEvent(
type="conversation_turn_completed",
flow_name=self.name or self.__class__.__name__,
session_id=sid,
),
)
return result
stream_session: StreamSession[Any] = StreamSession(
sync_iterator=create_frame_generator(frame_state, run_turn, output_holder)
)
output_holder.append(stream_session)
return stream_session
def _emit_terminal_conversation_turn_event(
self,
event: ConversationTurnCompletedEvent | ConversationTurnFailedEvent,
@@ -685,6 +786,8 @@ class _ConversationalMixin:
object.__setattr__(self, "_pending_intents", None)
if not hasattr(self, "_pending_intent_llm"):
object.__setattr__(self, "_pending_intent_llm", None)
if not hasattr(self, "_streaming_conversation_turn"):
object.__setattr__(self, "_streaming_conversation_turn", False)
def _create_default_extension_state(self) -> ConversationState | None:
initial_state_t = getattr(self, "_initial_state_t", None)
@@ -1055,6 +1158,19 @@ class _ConversationalMixin:
return llm
raise ValueError(f"Invalid llm type: {type(llm)}. Expected str or BaseLLM.")
@contextmanager
def _conversation_streaming_enabled(self, llm: Any) -> Iterator[None]:
if not getattr(self, "_streaming_conversation_turn", False) or not hasattr(
llm, "stream"
):
yield
return
from crewai.llms.base_llm import call_stream_override
with call_stream_override(llm, True):
yield
def finalize_session_traces(self) -> None:
"""Emit a final ``FlowFinishedEvent`` and finalize the trace batch.

View File

@@ -3,13 +3,17 @@
from __future__ import annotations
from collections.abc import Iterable
from functools import lru_cache
import json
from typing import TYPE_CHECKING, Any, TypeAlias, cast
from typing import TYPE_CHECKING, Any, NamedTuple, TypeAlias, cast
from crewai.utilities.serialization import to_serializable
if TYPE_CHECKING:
from celpy.celtypes import StringType
from celpy.evaluation import CELFunction
from crewai.flow.runtime import Flow
else:
from typing_extensions import TypeAliasType
@@ -18,6 +22,141 @@ else:
_CEL_MACROS_WITH_LOCAL_BINDINGS = frozenset(
{"all", "exists", "exists_one", "filter", "map"}
)
def _handle_text_custom_expression(
root: Any, path: Any, default: Any = ""
) -> StringType:
from celpy.celtypes import StringType
fallback = StringType("" if default is None else str(default))
current = root
for part in str(path).split("."):
if current is None:
return fallback
try:
if isinstance(current, list):
current = current[int(part)]
else:
current = current[StringType(part)]
except (KeyError, IndexError, TypeError, ValueError):
return fallback
if current is None:
return fallback
return StringType(_stringify_cel_value(current))
def _stringify_cel_value(value: Any) -> str:
from celpy.adapter import CELJSONEncoder
if isinstance(value, str):
return value
return json.dumps(value, cls=CELJSONEncoder, ensure_ascii=False)
class _ExpressionSegment(NamedTuple):
source: str
def _marker_end(value: str, start: int) -> int:
from celpy.celparser import CELParser
CELParser()
parser: Any = CELParser.CEL_PARSER
depth = 1
try:
for token in parser.lex(value[start:]):
if token.type == "LBRACE":
depth += 1
elif token.type == "RBRACE":
depth -= 1
if depth == 0:
return start + int(token.start_pos)
except Exception as e:
raise ExpressionError(
f"unterminated or invalid ${{...}} expression in {value!r}: {e}"
) from e
raise ExpressionError(f"unterminated ${{...}} expression in {value!r}")
@lru_cache(maxsize=256)
def _parse_template_segments(value: str) -> tuple[str | _ExpressionSegment, ...]:
segments: list[str | _ExpressionSegment] = []
index = 0
while (start := value.find("${", index)) != -1:
if start > index:
segments.append(value[index:start])
end = _marker_end(value, start + 2)
source = value[start + 2 : end].strip()
if not source:
raise ExpressionError(f"empty CEL expression in {value!r}")
segments.append(_ExpressionSegment(source))
index = end + 1
if index < len(value) or not segments:
segments.append(value[index:])
return tuple(segments)
_EXPRESSION_FUNCTIONS: dict[str, CELFunction] = {
"text": _handle_text_custom_expression,
}
FLOW_TEMPLATE_EXPRESSION_RULES: tuple[str, ...] = (
"Use `${...}` inside action mapping strings to read Flow data with CEL. "
"Example value: `Ticket: ${state.ticket_id}`.",
"Use `state` for input data. Use `outputs.step_name` for a completed "
"method result.",
"If a value is only one `${...}` expression, the result keeps its type. "
"Use this for numbers, booleans, objects, and lists.",
"If the string has other text, the final value is text. Non-text values "
"become JSON. `null` becomes empty text.",
'Use `text(root, "path", "default")` for values that may be missing '
'or null. The default is optional and is `""`.',
)
FLOW_TEMPLATE_EXPRESSION_CONTRACT = " ".join(FLOW_TEMPLATE_EXPRESSION_RULES)
FLOW_TEMPLATE_EXPRESSION_EXAMPLES: dict[str, tuple[dict[str, str], ...]] = {
"yaml": (
{
"title": "Mix text and Flow data",
"code": 'query: "News about ${state.topic}"',
},
{
"title": "Keep a list or number type",
"code": 'domains: "${state.domains}"\nlimit: "${state.limit}"',
},
{
"title": "Use a default for missing text",
"code": 'input: "Ticket ${text(state, \\"ticket.id\\", \\"unknown\\")}"',
},
),
"json": (
{
"title": "Mix text and Flow data",
"code": '{\n "query": "News about ${state.topic}"\n}',
},
{
"title": "Keep a list or number type",
"code": (
'{\n "domains": "${state.domains}",\n "limit": "${state.limit}"\n}'
),
},
{
"title": "Use a default for missing text",
"code": (
"{\n"
' "input": "Ticket ${text(state, \\"ticket.id\\", \\"unknown\\")}"\n'
"}"
),
},
),
}
def flow_template_expression_description(prefix: str) -> str:
return f"{prefix} {FLOW_TEMPLATE_EXPRESSION_CONTRACT}"
if TYPE_CHECKING:
ExpressionData: TypeAlias = (
str
@@ -41,9 +180,13 @@ else:
)
__all__ = [
"FLOW_TEMPLATE_EXPRESSION_CONTRACT",
"FLOW_TEMPLATE_EXPRESSION_EXAMPLES",
"FLOW_TEMPLATE_EXPRESSION_RULES",
"Expression",
"ExpressionData",
"ExpressionError",
"flow_template_expression_description",
]
@@ -99,7 +242,7 @@ class Expression:
allowed_roots: Iterable[str],
source: str = "with block",
) -> None:
"""Validate nested strings fully wrapped in ``${...}`` as CEL."""
"""Validate ``${...}`` expressions inside nested strings as CEL."""
self._validate_template_value(
self.value, allowed_roots=allowed_roots, source=source
)
@@ -113,7 +256,11 @@ class Expression:
)
def render_template(self, context: dict[str, Any] | None = None) -> Any:
"""Evaluate nested strings fully wrapped in ``${...}`` as CEL."""
"""Interpolate ``${...}`` expressions inside nested strings as CEL.
A string that is exactly one ``${...}`` keeps the evaluated value's
type; strings mixing literals and expressions render as text.
"""
resolved_context = self.context if context is None else context
return self._render_template_value(self.value, resolved_context or {})
@@ -125,10 +272,23 @@ class Expression:
source: str,
) -> None:
if isinstance(value, str):
expression = Expression._expression_marker_source(value, source=source)
if expression is not None:
Expression(expression).validate_expression(
allowed_roots=allowed_roots, source=source
try:
segments = _parse_template_segments(value)
except ExpressionError as e:
raise ExpressionError(f"{e} at {source}") from None
expressions = [
segment
for segment in segments
if isinstance(segment, _ExpressionSegment)
]
for index, segment in enumerate(expressions):
segment_source = (
source
if len(expressions) == 1
else f"{source} (expression {index + 1})"
)
Expression(segment.source).validate_expression(
allowed_roots=allowed_roots, source=segment_source
)
return
if isinstance(value, dict):
@@ -187,28 +347,23 @@ class Expression:
@staticmethod
def _render_template_string(value: str, context: dict[str, Any]) -> Any:
expression = Expression._expression_marker_source(value)
if expression is None:
segments = _parse_template_segments(value)
expressions = [
segment for segment in segments if isinstance(segment, _ExpressionSegment)
]
if not expressions:
return value
return Expression._evaluate_cel(expression, context)
@staticmethod
def _expression_marker_source(
value: str, *, source: str | None = None
) -> str | None:
"""Return CEL source when the trimmed string starts with ``${`` and ends with ``}``."""
stripped = value.strip()
if not stripped.startswith("${"):
return None
if not stripped.endswith("}"):
return None
expression = stripped[2:-1].strip()
if not expression:
if source is None:
raise ExpressionError("empty CEL expression in with block")
raise ExpressionError(f"empty CEL expression at {source}")
return expression
literals = [segment for segment in segments if isinstance(segment, str)]
if len(expressions) == 1 and all(not literal.strip() for literal in literals):
return Expression._evaluate_cel(expressions[0].source, context)
rendered: list[str] = []
for segment in segments:
if isinstance(segment, str):
rendered.append(segment)
continue
result = Expression._evaluate_cel(segment.source, context)
rendered.append("" if result is None else _stringify_cel_value(result))
return "".join(rendered)
@staticmethod
def _evaluate_cel(expression: str, context: dict[str, Any]) -> Any:
@@ -219,7 +374,8 @@ class Expression:
environment = Environment()
program = environment.program(
Expression._compile_cel(expression, environment=environment)
Expression._compile_cel(expression, environment=environment),
functions=_EXPRESSION_FUNCTIONS,
)
result = program.evaluate(cast(Context, json_to_cel(context)))
return json.loads(json.dumps(result, cls=CELJSONEncoder))

View File

@@ -9,6 +9,7 @@ layer that may have produced it and of the engine that runs it (see
from __future__ import annotations
from collections.abc import Sequence
import logging
from pathlib import Path
import re
@@ -28,7 +29,10 @@ from crewai.flow.conversational_definition import (
FlowConversationalDefinition,
FlowConversationalRouterDefinition,
)
from crewai.flow.expressions import ExpressionData
from crewai.flow.expressions import (
ExpressionData,
flow_template_expression_description,
)
from crewai.project.crew_definition import AgentDefinition, CrewDefinition
@@ -86,7 +90,7 @@ class FlowDictStateDefinition(BaseModel):
)
default: dict[str, Any] | None = Field(
default=None,
description="Default state values applied before kickoff inputs.",
description="Default values used to initialize Flow state.",
examples=[{"topic": "AI agents", "limit": 3}],
)
@@ -121,7 +125,7 @@ class FlowPydanticStateDefinition(BaseModel):
)
default: dict[str, Any] | None = Field(
default=None,
description="Default state values applied before kickoff inputs.",
description="Default values used to initialize Flow state.",
examples=[{"topic": "AI agents", "limit": 3}],
)
@@ -148,7 +152,7 @@ class FlowJsonSchemaStateDefinition(BaseModel):
)
default: dict[str, Any] | None = Field(
default=None,
description="Default state values applied before kickoff inputs.",
description="Default values used to initialize Flow state.",
examples=[{"topic": "AI agents", "limit": 3}],
)
@@ -160,7 +164,7 @@ class FlowUnknownStateDefinition(BaseModel):
type: Literal["unknown"] = Field(
default="unknown",
description="Unknown state representation; runtime falls back to dictionary state.",
description="Unknown state representation; execution uses dictionary state.",
examples=["unknown"],
)
ref: str | None = Field(
@@ -170,7 +174,7 @@ class FlowUnknownStateDefinition(BaseModel):
)
default: dict[str, Any] | None = Field(
default=None,
description="Default state values applied before kickoff inputs.",
description="Default values used to initialize Flow state.",
examples=[{"topic": "AI agents", "limit": 3}],
)
@@ -189,7 +193,7 @@ class FlowConfigDefinition(BaseModel):
tracing: bool | None = Field(
default=None,
description="Override for flow tracing; when omitted, runtime defaults apply.",
description="Override for flow tracing; when omitted, execution defaults apply.",
examples=[True],
)
stream: bool = Field(
@@ -204,7 +208,7 @@ class FlowConfigDefinition(BaseModel):
)
input_provider: str | None = Field(
default=None,
description="Import reference or provider key used to supply flow inputs.",
description="Provider key used to supply initial state.",
examples=["my_project.inputs:load_inputs"],
)
suppress_flow_events: bool = Field(
@@ -361,12 +365,10 @@ class FlowCodeActionDefinition(BaseModel):
with_: dict[str, ExpressionData] | None = Field(
default=None,
alias="with",
description=(
"Keyword arguments passed to the callable. String values are evaluated "
"as CEL only when the trimmed value starts with ${ and ends with }; "
"all other values are literal."
description=flow_template_expression_description(
"Keyword arguments passed to the callable."
),
examples=[{"topic": "${state.topic}"}],
examples=[{"topic": "${state.topic}", "query": "News about ${state.topic}"}],
)
@@ -383,17 +385,13 @@ class FlowToolActionDefinition(BaseModel):
examples=["tool"],
)
ref: str = Field(
description="Import reference for a BaseTool class, formatted as module:qualname.",
description="Reference to the CrewAI tool to run.",
examples=["my_project.tools:SearchTool"],
)
with_: dict[str, ExpressionData] | None = Field(
default=None,
alias="with",
description=(
"Tool input arguments. String values are evaluated as CEL only when "
"the trimmed value starts with ${ and ends with }; all other values "
"are literal."
),
description=flow_template_expression_description("Tool input arguments."),
examples=[{"query": "${outputs.normalize_topic}", "limit": 5}],
)
@@ -445,10 +443,12 @@ class FlowCrewActionDefinition(BaseModel):
)
inputs: dict[str, ExpressionData] | None = Field(
default=None,
description=(
"Input overrides passed to the Crew. String values are evaluated as CEL "
"only when the trimmed value starts with ${ and ends with }; all other "
"values are literal."
description=flow_template_expression_description(
"Input overrides passed to the Crew."
)
+ (
" The resulting values are available to crew agent and task "
"interpolation as `{name}` placeholders."
),
examples=[{"topic": "${state.topic}"}],
)
@@ -463,7 +463,7 @@ class FlowCrewActionDefinition(BaseModel):
class FlowAgentActionDefinition(BaseModel):
"""A Flow method action that builds and kicks off a CrewAI agent."""
"""A Flow method action that builds and kicks off one agent outside a crew."""
model_config = ConfigDict(
populate_by_name=True,
@@ -471,12 +471,18 @@ class FlowAgentActionDefinition(BaseModel):
)
call: Literal["agent"] = Field(
description="Action discriminator. Use agent to run an inline Agent definition.",
description=(
"Action discriminator. Use agent to run an individual inline Agent "
"definition outside of a crew."
),
examples=["agent"],
)
with_: AgentDefinition = Field(
alias="with",
description="Inline Agent definition to load and execute for this action.",
description=(
"Individual Agent definition to load and execute outside of a crew "
"for this action."
),
examples=[
{
"role": "Analyst",
@@ -515,12 +521,11 @@ class FlowScriptActionDefinition(BaseModel):
)
code: str = Field(
description=(
"Trusted Python source executed as a generated function. Runtime values are "
"passed as state, outputs, input, and item; they are not interpolated into "
"the source. This is not sandboxed."
"Trusted inline Python source. Values are available as state and outputs; "
"they are not interpolated into the source. This is not sandboxed."
),
examples=[
"state['normalized_topic'] = input.strip()\n"
"state['normalized_topic'] = state['topic'].strip()\n"
"return state['normalized_topic']"
],
)
@@ -645,13 +650,13 @@ class FlowMethodDefinition(BaseModel):
)
do: FlowActionDefinition = Field(
description="Action executed when this method runs.",
examples=[{"call": "script", "code": "return input.strip()"}],
examples=[{"call": "expression", "expr": "state.topic"}],
)
start: bool | FlowDefinitionCondition | None = Field(
default=None,
description=(
"Marks a start method. True starts unconditionally; a condition starts "
"when the kickoff inputs or events satisfy it."
"when the initial state or events satisfy it."
),
examples=[True],
)
@@ -729,12 +734,12 @@ class FlowDefinition(BaseModel):
)
state: FlowStateDefinition | None = Field(
default=None,
description="State contract for kickoff inputs and runtime state.",
description="State contract for the initial state and updates during execution.",
examples=[{"type": "dict", "default": {"topic": "AI agents"}}],
)
config: FlowConfigDefinition = Field(
default_factory=FlowConfigDefinition,
description="Serializable flow-level runtime configuration.",
description="Serializable flow-level execution configuration.",
examples=[{"stream": True, "max_method_calls": 20}],
)
persist: FlowPersistenceDefinition | None = Field(
@@ -765,6 +770,15 @@ class FlowDefinition(BaseModel):
_validate_step_name(method_name, field="Flow method names")
return self
@model_validator(mode="after")
def _validate_trigger_namespace(self) -> FlowDefinition:
for method_name, method in self.methods.items():
if _condition_references(method.listen, method_name):
raise ValueError(
f"methods.{method_name}.listen must not reference itself"
)
return self
@model_validator(mode="after")
def _validate_cel_expressions(self) -> FlowDefinition:
for method_name, method in self.methods.items():
@@ -835,6 +849,18 @@ class FlowDefinition(BaseModel):
log_flow_definition_issues(definition)
return definition
@classmethod
def skill(
cls,
*,
skips: Sequence[str] = (),
examples_format: Literal["yaml", "json"] = "yaml",
) -> str:
"""Return a portable Markdown skill for authoring Flow declarations."""
from crewai.flow.skill import render_skill_markdown
return render_skill_markdown(skips=skips, examples_format=examples_format)
def _validate_step_name(name: str, *, field: str) -> None:
if not isinstance(name, str) or not _STEP_NAME_PATTERN.fullmatch(name):
@@ -850,6 +876,18 @@ def _validate_step_list(steps: list[FlowEachStepDefinition], *, field: str) -> N
seen.add(name)
def _condition_references(condition: FlowDefinitionCondition | None, name: str) -> bool:
if condition is None:
return False
if isinstance(condition, str):
return condition == name
return any(
_condition_references(child, name)
for key in ("and", "or")
for child in condition.get(key, [])
)
def _validate_action_cel(
action: FlowActionDefinition,
*,

View File

@@ -9,11 +9,7 @@ Structure (see ``flow_definition``) and executed here.
from __future__ import annotations
import asyncio
from collections.abc import (
Callable,
Iterator,
Sequence,
)
from collections.abc import Callable, Iterator, Sequence
from concurrent.futures import Future, ThreadPoolExecutor
import contextvars
import copy
@@ -140,17 +136,16 @@ if TYPE_CHECKING:
from crewai.llms.base_llm import BaseLLM
from crewai.flow.visualization import build_flow_structure, render_interactive
from crewai.types.streaming import CrewStreamingOutput, FlowStreamingOutput
from crewai.types.streaming import (
AsyncStreamSession,
StreamSession,
)
from crewai.types.usage_metrics import UsageMetrics
from crewai.utilities.env import get_env_context
from crewai.utilities.streaming import (
TaskInfo,
create_async_chunk_generator,
create_chunk_generator,
create_streaming_state,
register_cleanup,
signal_end,
signal_error,
create_async_frame_generator,
create_frame_generator,
create_frame_streaming_state,
)
@@ -1832,13 +1827,79 @@ class Flow(BaseModel, Generic[T], metaclass=FlowMeta):
if hasattr(self._state, key):
object.__setattr__(self._state, key, value)
def stream_events(
self,
inputs: dict[str, Any] | None = None,
input_files: dict[str, FileInput] | None = None,
from_checkpoint: CheckpointConfig | None = None,
restore_from_state_id: str | None = None,
) -> StreamSession[Any]:
"""Run the flow and stream all scoped public ``StreamFrame`` events."""
result_holder: list[Any] = []
state = create_frame_streaming_state(result_holder, use_async=False)
output_holder: list[StreamSession[Any]] = []
def run_flow() -> Any:
original_stream = self.stream
try:
self.stream = False
return self.kickoff(
inputs=inputs,
input_files=input_files,
from_checkpoint=from_checkpoint,
restore_from_state_id=restore_from_state_id,
)
except HumanFeedbackPending as e:
return e
finally:
self.stream = original_stream
stream_session: StreamSession[Any] = StreamSession(
sync_iterator=create_frame_generator(state, run_flow, output_holder)
)
output_holder.append(stream_session)
return stream_session
def astream(
self,
inputs: dict[str, Any] | None = None,
input_files: dict[str, FileInput] | None = None,
from_checkpoint: CheckpointConfig | None = None,
restore_from_state_id: str | None = None,
) -> AsyncStreamSession[Any]:
"""Run the flow asynchronously and stream scoped public frames."""
result_holder: list[Any] = []
state = create_frame_streaming_state(result_holder, use_async=True)
output_holder: list[AsyncStreamSession[Any]] = []
async def run_flow() -> Any:
original_stream = self.stream
try:
self.stream = False
return await self.kickoff_async(
inputs=inputs,
input_files=input_files,
from_checkpoint=from_checkpoint,
restore_from_state_id=restore_from_state_id,
)
except HumanFeedbackPending as e:
return e
finally:
self.stream = original_stream
stream_session: AsyncStreamSession[Any] = AsyncStreamSession(
async_iterator=create_async_frame_generator(state, run_flow, output_holder)
)
output_holder.append(stream_session)
return stream_session
def kickoff(
self,
inputs: dict[str, Any] | None = None,
input_files: dict[str, FileInput] | None = None,
from_checkpoint: CheckpointConfig | None = None,
restore_from_state_id: str | None = None,
) -> Any | FlowStreamingOutput:
) -> Any | StreamSession[Any]:
"""Start the flow execution in a synchronous context.
This method wraps kickoff_async so that all state initialization and event
@@ -1859,7 +1920,7 @@ class Flow(BaseModel, Generic[T], metaclass=FlowMeta):
``from_checkpoint``; passing both raises ``ValueError``.
Returns:
The final output from the flow or FlowStreamingOutput if streaming.
The final output from the flow or StreamSession if streaming.
"""
if from_checkpoint is not None and restore_from_state_id is not None:
raise ValueError(
@@ -1871,46 +1932,12 @@ class Flow(BaseModel, Generic[T], metaclass=FlowMeta):
if restored is not None:
return restored.kickoff(inputs=inputs, input_files=input_files)
if self.stream:
result_holder: list[Any] = []
current_task_info: TaskInfo = {
"index": 0,
"name": "",
"id": "",
"agent_role": "",
"agent_id": "",
}
state = create_streaming_state(
current_task_info, result_holder, use_async=False
return self.stream_events(
inputs=inputs,
input_files=input_files,
from_checkpoint=from_checkpoint,
restore_from_state_id=restore_from_state_id,
)
output_holder: list[CrewStreamingOutput | FlowStreamingOutput] = []
def run_flow() -> None:
try:
self.stream = False
result = self.kickoff(
inputs=inputs,
input_files=input_files,
restore_from_state_id=restore_from_state_id,
)
result_holder.append(result)
except Exception as e:
# HumanFeedbackPending is expected control flow, not an error
if isinstance(e, HumanFeedbackPending):
result_holder.append(e)
else:
signal_error(state, e)
finally:
self.stream = True
signal_end(state)
streaming_output = FlowStreamingOutput(
sync_iterator=create_chunk_generator(state, run_flow, output_holder)
)
register_cleanup(streaming_output, state)
output_holder.append(streaming_output)
return streaming_output
async def _run_flow() -> Any:
return await self.kickoff_async(
@@ -1937,7 +1964,7 @@ class Flow(BaseModel, Generic[T], metaclass=FlowMeta):
input_files: dict[str, FileInput] | None = None,
from_checkpoint: CheckpointConfig | None = None,
restore_from_state_id: str | None = None,
) -> Any | FlowStreamingOutput:
) -> Any | AsyncStreamSession[Any]:
"""Start the flow execution asynchronously.
This method performs state restoration (if an 'id' is provided and persistence is available)
@@ -1971,48 +1998,12 @@ class Flow(BaseModel, Generic[T], metaclass=FlowMeta):
if restored is not None:
return await restored.kickoff_async(inputs=inputs, input_files=input_files)
if self.stream:
result_holder: list[Any] = []
current_task_info: TaskInfo = {
"index": 0,
"name": "",
"id": "",
"agent_role": "",
"agent_id": "",
}
state = create_streaming_state(
current_task_info, result_holder, use_async=True
return self.astream(
inputs=inputs,
input_files=input_files,
from_checkpoint=from_checkpoint,
restore_from_state_id=restore_from_state_id,
)
output_holder: list[CrewStreamingOutput | FlowStreamingOutput] = []
async def run_flow() -> None:
try:
self.stream = False
result = await self.kickoff_async(
inputs=inputs,
input_files=input_files,
restore_from_state_id=restore_from_state_id,
)
result_holder.append(result)
except Exception as e:
# HumanFeedbackPending is expected control flow, not an error
if isinstance(e, HumanFeedbackPending):
result_holder.append(e)
else:
signal_error(state, e, is_async=True)
finally:
self.stream = True
signal_end(state, is_async=True)
streaming_output = FlowStreamingOutput(
async_iterator=create_async_chunk_generator(
state, run_flow, output_holder
)
)
register_cleanup(streaming_output, state)
output_holder.append(streaming_output)
return streaming_output
ctx = baggage.set_baggage("flow_inputs", inputs or {})
ctx = baggage.set_baggage("flow_input_files", input_files or {}, context=ctx)
@@ -2356,7 +2347,7 @@ class Flow(BaseModel, Generic[T], metaclass=FlowMeta):
input_files: dict[str, FileInput] | None = None,
from_checkpoint: CheckpointConfig | None = None,
restore_from_state_id: str | None = None,
) -> Any | FlowStreamingOutput:
) -> Any | AsyncStreamSession[Any]:
"""Native async method to start the flow execution. Alias for kickoff_async.
Args:

View File

@@ -138,11 +138,12 @@ class CrewAction:
local_context = _pop_local_context(kwargs)
if self.definition.from_declaration is not None:
crew, default_inputs = load_crew(
crew, default_inputs = await asyncio.to_thread(
load_crew,
_resolve_crew_declaration(
self.definition.from_declaration,
base_dir=self.flow._definition.source_dir,
)
),
)
input_template = {**default_inputs, **(self.definition.inputs or {})}
else:
@@ -155,7 +156,9 @@ class CrewAction:
**crew_definition.inputs,
**(self.definition.inputs or {}),
}
crew, _ = load_crew_from_definition(crew_definition, source="crew action")
crew, _ = await asyncio.to_thread(
load_crew_from_definition, crew_definition, source="crew action"
)
inputs = Expression.from_flow(
cast(ExpressionData, input_template),
@@ -184,7 +187,8 @@ class AgentAction:
if not isinstance(rendered_input, str):
raise ValueError("agent input must render to a string")
agent, response_format = load_agent_from_definition(
agent, response_format = await asyncio.to_thread(
load_agent_from_definition,
self.definition.with_,
source="agent action",
)

View File

@@ -0,0 +1,577 @@
"""Markdown skill rendering for Flow Definition authoring."""
from collections.abc import Sequence
from dataclasses import dataclass, field
import json
from pathlib import Path
import re
from typing import Any, Literal
from jinja2 import Environment, FileSystemLoader
import yaml
from crewai.flow.expressions import (
FLOW_TEMPLATE_EXPRESSION_CONTRACT,
FLOW_TEMPLATE_EXPRESSION_EXAMPLES,
FLOW_TEMPLATE_EXPRESSION_RULES,
)
from crewai.flow.flow_definition import FlowDefinition
SKIP_BY_MODEL: dict[str, str] = {
"FlowScriptActionDefinition": "script_action",
"FlowToolActionDefinition": "tool_action",
"FlowExpressionActionDefinition": "expression_action",
"FlowEachActionDefinition": "each",
"FlowEachStepDefinition": "each",
"FlowConfigDefinition": "config",
"FlowHumanFeedbackDefinition": "hitl",
"FlowPersistenceDefinition": "persistence",
}
FIELD_TYPE_OVERRIDES: dict[tuple[str, str], str] = {
("FlowDefinition", "state"): "[State](#json-schema-state-statetypejson_schema)",
("FlowDefinition", "methods"): "map of string to [Method](#method-methods)",
("FlowMethodDefinition", "do"): "[Action](#action)",
("FlowCrewActionDefinition", "with"): "inline crew definition",
("CrewAgentDefinition", "llm"): "string or inline LLM config",
("AgentDefinition", "llm"): "string or inline LLM config",
}
_TEMPLATES_DIR = Path(__file__).parent / "templates"
_ENVIRONMENT = Environment( # noqa: S701 - renders trusted Markdown, not HTML.
loader=FileSystemLoader(_TEMPLATES_DIR),
trim_blocks=True,
lstrip_blocks=True,
keep_trailing_newline=False,
)
def render_skill_markdown(
*,
skips: Sequence[str] = (),
examples_format: Literal["yaml", "json"] = "yaml",
) -> str:
if examples_format not in ("yaml", "json"):
raise ValueError("Flow skill examples_format must be 'yaml' or 'json'")
skips_set = frozenset(skips)
rendered = _ENVIRONMENT.get_template("flow_definition_skill.md.j2").render(
template_context(skips_set, examples_format)
)
rendered = re.sub(r"\n{3,}", "\n\n", rendered)
return rendered.strip() + "\n"
def template_context(
skips: frozenset[str], examples_format: Literal["yaml", "json"] = "yaml"
) -> dict[str, Any]:
return {
"examples_format": examples_format,
"example": render_flow_example(examples_format),
"example_language": examples_format,
"include_each_action": "each" not in skips,
"include_conversational": "conversational" not in skips,
"include_hitl": "hitl" not in skips,
"include_non_linear_flows": "non_linear_flows" not in skips,
"include_persistence": "persistence" not in skips,
"include_expression_action": "expression_action" not in skips,
"include_script_action": "script_action" not in skips,
"include_tool_action": "tool_action" not in skips,
"expression_contract_examples": FLOW_TEMPLATE_EXPRESSION_EXAMPLES[
examples_format
],
"expression_contract_rules": FLOW_TEMPLATE_EXPRESSION_RULES,
"sections": FlowSkillReferenceExtractor(skips=skips).extract(),
}
def render_flow_example(examples_format: Literal["yaml", "json"]) -> str:
example_yaml = (_TEMPLATES_DIR / "flow_definition_example.yaml").read_text(
encoding="utf-8"
)
if examples_format == "json":
return json.dumps(yaml.safe_load(example_yaml), indent=2)
return example_yaml.rstrip()
@dataclass(frozen=True)
class ModelSpec:
name: str
section: str
address: str = ""
label: str = ""
hidden: bool = False
examples: bool = False
descriptions: dict[str, str] = field(default_factory=dict)
@property
def display_title(self) -> str:
return self.label or MODEL_TITLES.get(self.name, self.section)
@property
def display_label(self) -> str:
if not self.address:
return self.display_title
return f"{self.display_title} (`{self.address}`)"
MODEL_TITLES = {
"FlowDefinition": "Flow Definition",
"FlowDictStateDefinition": "Dict State",
"FlowPydanticStateDefinition": "Pydantic State",
"FlowJsonSchemaStateDefinition": "JSON Schema State",
"FlowUnknownStateDefinition": "Unknown State",
"FlowMethodDefinition": "Method",
"FlowCodeActionDefinition": "Code Action",
"FlowScriptActionDefinition": "Script Action",
"FlowToolActionDefinition": "Tool Action",
"FlowCrewActionDefinition": "Crew Action",
"FlowAgentActionDefinition": "Agent Action",
"FlowExpressionActionDefinition": "Expression Action",
"FlowEachActionDefinition": "Each Action",
"FlowEachStepDefinition": "Each Step",
"CrewDefinition": "Crew Definition",
"CrewAgentDefinition": "Crew Agent Definition",
"CrewTaskDefinition": "Crew Task Definition",
"AgentDefinition": "Agent Definition",
"LLMDefinition": "LLM Definition",
"FlowConfigDefinition": "Config",
"FlowPersistenceDefinition": "Persistence",
"FlowHumanFeedbackDefinition": "Human Feedback",
}
MODEL_SPECS: tuple[ModelSpec, ...] = (
ModelSpec(
"FlowDefinition",
"Flow Definition",
descriptions={
"schema": "Declarative Flow schema identifier and version. Include it explicitly in authored declarations.",
"conversational": "Top-level conversational flow configuration, only when the flow supports chat.",
},
),
ModelSpec("FlowDictStateDefinition", "State", "state[type=dict]", hidden=True),
ModelSpec(
"FlowPydanticStateDefinition", "State", "state[type=pydantic]", hidden=True
),
ModelSpec(
"FlowJsonSchemaStateDefinition",
"State",
"state[type=json_schema]",
descriptions={
"json_schema": "JSON Schema used to validate and document flow state. Declare required fields with JSON Schema's `required` array.",
"default": "Default values used to initialize Flow state. Defaults are not the same as schema-required fields.",
},
),
ModelSpec(
"FlowUnknownStateDefinition", "State", "state[type=unknown]", hidden=True
),
ModelSpec(
"FlowMethodDefinition",
"Method",
"methods.<name>",
descriptions={
"do": "Single action object executed when this method runs.",
"start": "Marks a start method. Use `true` for the normal entrypoint. String or map conditions are advanced trigger conditions; use them only when the user asks for event/condition-based starts.",
"listen": 'Trigger condition that runs this method after upstream events. A string target can be a method name or a router-emitted event name, and both live in the same trigger namespace. Methods must not listen to their own method name. Map conditions are for `and`/`or` trigger composition, for example `{"and": ["validated", "processed"]}`.',
"router": "Whether the method output should be treated as the next event name. Router actions must return one event name string, with no surrounding explanation.",
"emit": "Declared router events this method may emit. Each emitted event name should be unique and should not collide with method names.",
},
),
ModelSpec(
"FlowCodeActionDefinition",
"Action",
"methods.<name>.do[call=code]",
hidden=True,
),
ModelSpec("FlowScriptActionDefinition", "Action", "methods.<name>.do[call=script]"),
ModelSpec("FlowToolActionDefinition", "Action", "methods.<name>.do[call=tool]"),
ModelSpec(
"FlowCrewActionDefinition",
"Action",
"methods.<name>.do[call=crew]",
examples=True,
descriptions={
"call": "Action discriminator. Use crew to run an inline Crew definition.",
"inputs": f"Actual kickoff inputs passed to the Crew. {FLOW_TEMPLATE_EXPRESSION_CONTRACT} The evaluated values are available to crew agent and task interpolation as `{{name}}` placeholders; reference each input the crew needs in agent or task text.",
},
),
ModelSpec(
"FlowAgentActionDefinition",
"Action",
"methods.<name>.do[call=agent]",
examples=True,
descriptions={
"with": "Individual Agent definition to load and execute outside of a crew for this action. Put the agent input in `with.input`; agent actions do not support action-level `inputs`.",
},
),
ModelSpec(
"FlowExpressionActionDefinition",
"Action",
"methods.<name>.do[call=expression]",
),
ModelSpec("FlowEachActionDefinition", "Action", "methods.<name>.do[call=each]"),
ModelSpec(
"FlowEachStepDefinition",
"Each Step",
"methods.<name>.do[call=each].do[]",
),
ModelSpec(
"CrewDefinition",
"Crew Definition",
"methods.<name>.do[call=crew].with",
hidden=True,
examples=True,
descriptions={
"inputs": "Static default crew inputs. Values are available to crew agent and task interpolation as `{name}` placeholders, for example `{topic}`. Prefer action-level crew `inputs` for runtime values from `state` or `outputs`, and include placeholders for any inputs the crew must reason over.",
"manager_agent": "Optional manager agent name.",
},
),
ModelSpec(
"CrewAgentDefinition",
"Crew Agent Definition",
"methods.<name>.do[call=crew].with.agents.<name>",
hidden=True,
examples=True,
descriptions={
"llm": "Language model that runs this crew agent. Use an object when setting LLM options such as `max_tokens`.",
"planning_config": "Agent planning configuration. Set `max_attempts` to limit planning refinement attempts before task execution.",
},
),
ModelSpec(
"LLMDefinition",
"LLM Definition",
hidden=True,
examples=True,
),
ModelSpec(
"CrewTaskDefinition",
"Crew Task Definition",
"methods.<name>.do[call=crew].with.tasks[]",
hidden=True,
examples=True,
descriptions={
"name": "Optional task name.",
},
),
ModelSpec(
"AgentDefinition",
"Agent Definition",
"methods.<name>.do[call=agent].with",
hidden=True,
examples=True,
descriptions={
"input": f"Input passed to the individual agent kickoff outside of a crew. Use one string. {FLOW_TEMPLATE_EXPRESSION_CONTRACT} When an agent needs multiple fields, write one string with labels and separators, for example `Ticket ID: ${{state.ticket_id}}; Message: ${{state.message}}`.",
"llm": "Language model that runs this agent. Use an object when setting LLM options such as `max_tokens`.",
"planning_config": "Agent planning configuration. Set `max_attempts` to limit planning refinement attempts before task execution.",
},
),
ModelSpec("FlowConfigDefinition", "Config", "config"),
ModelSpec("FlowPersistenceDefinition", "Persistence", "persist"),
ModelSpec(
"FlowHumanFeedbackDefinition",
"Human Feedback",
"methods.<name>.human_feedback",
),
)
_SPECS_BY_NAME: dict[str, ModelSpec] = {spec.name: spec for spec in MODEL_SPECS}
@dataclass(frozen=True)
class FlowSkillReferenceExtractor:
skips: frozenset[str]
schema: dict[str, Any] = field(
default_factory=lambda: FlowDefinition.model_json_schema(by_alias=True)
)
def extract(self) -> list[dict[str, Any]]:
sections: list[dict[str, Any]] = []
for spec in MODEL_SPECS:
if spec.hidden or self.model_is_skipped(spec.name):
continue
if not sections or sections[-1]["label"] != spec.section:
sections.append(
{
"label": spec.section,
"models": [],
"kind": "union" if spec.section == "Action" else "object",
}
)
sections[-1]["models"].append(self.extract_model(spec))
for section in sections:
if section["kind"] != "union" and section["models"]:
section["label"] = section["models"][0]["label"]
return sections
def extract_model(self, spec: ModelSpec) -> dict[str, Any]:
model_name = spec.name
model_schema = (
self.schema
if model_name == "FlowDefinition"
else self.schema["$defs"][model_name]
)
required_from_schema = set(model_schema.get("required", ()))
fields = []
for field_name, field_schema in model_schema.get("properties", {}).items():
if self.field_is_hidden(model_name, field_name):
continue
required = (
field_name in required_from_schema
or (
model_name == "FlowDefinition"
and field_name in ("state", "methods")
)
or (model_name == "FlowCrewActionDefinition" and field_name == "with")
)
fields.append(
{
"name": field_name,
"type": self.render_field_type(
model_name, field_name, field_schema
),
"required": required,
"default": render_field_default(
model_name, field_name, field_schema, required
),
"description": self.render_field_description(
spec, model_name, field_name, field_schema
),
"examples": render_field_examples(spec, field_name, field_schema),
}
)
return {
"label": spec.display_label,
"anchor": f"#{markdown_heading_anchor(spec.display_label)}",
"link": (
f"[{spec.display_label}](#{markdown_heading_anchor(spec.display_label)})"
),
"discriminator": extract_discriminator(model_schema),
"fields": fields,
"inline_models": self.inline_models_for(model_name),
}
def inline_models_for(self, model_name: str) -> list[dict[str, Any]]:
names_by_model = {
"FlowCrewActionDefinition": (
"CrewDefinition",
"CrewAgentDefinition",
"CrewTaskDefinition",
),
"CrewAgentDefinition": ("LLMDefinition",),
"FlowAgentActionDefinition": ("AgentDefinition",),
"AgentDefinition": ("LLMDefinition",),
}
return [
self.extract_model(_SPECS_BY_NAME[name])
for name in names_by_model.get(model_name, ())
]
def model_is_skipped(self, model_name: str) -> bool:
skip = SKIP_BY_MODEL.get(model_name)
return skip in self.skips if skip is not None else False
def field_is_hidden(
self,
model_name: str,
field_name: str,
) -> bool:
return (
("hitl" in self.skips and field_name == "human_feedback")
or ("persistence" in self.skips and field_name == "persist")
or ("config" in self.skips and field_name == "config")
or ("conversational" in self.skips and field_name == "conversational")
or (model_name == "AgentDefinition" and field_name == "response_format")
or (model_name == "CrewDefinition" and field_name == "manager_agent")
or (model_name == "CrewTaskDefinition" and field_name == "context")
or (
field_name == "type"
and model_name
in {"AgentDefinition", "CrewAgentDefinition", "CrewTaskDefinition"}
)
or (field_name == "ref" and model_name != "FlowToolActionDefinition")
or (
model_name == "FlowCrewActionDefinition"
and field_name == "from_declaration"
)
)
def render_field_type(
self,
model_name: str,
field_name: str,
field_schema: dict[str, Any],
) -> str:
if override := FIELD_TYPE_OVERRIDES.get((model_name, field_name)):
return override
return self.render_schema_type(field_schema) or "any"
def render_schema_type(self, field_schema: dict[str, Any]) -> str | None:
if "$ref" in field_schema:
return self.render_schema_ref(field_schema["$ref"])
if "const" in field_schema:
return f"must be {format_inline_value(field_schema['const'])}"
if "enum" in field_schema:
values = ", ".join(
format_inline_value(value) for value in field_schema["enum"]
)
return f"one of {values}"
for union_key in ("anyOf", "oneOf", "allOf"):
if union_key in field_schema:
return join_unique(
self.render_schema_type(option)
for option in field_schema[union_key]
)
json_type = field_schema.get("type")
if isinstance(json_type, list):
return join_unique(
self.render_schema_type({"type": item}) for item in json_type
)
if json_type == "array":
item_type = self.render_schema_type(field_schema.get("items", {})) or "any"
return (
f"list of {item_type}"
if item_type.startswith("[")
else f"list[{item_type}]"
)
if json_type == "object":
additional_properties = field_schema.get("additionalProperties")
if isinstance(additional_properties, dict):
value_type = self.render_schema_type(additional_properties) or "any"
return f"map of string to {value_type}"
return "map of string to any" if additional_properties is True else "object"
if isinstance(json_type, str):
return json_type
return "object" if "properties" in field_schema else "any"
def render_schema_ref(self, ref: str) -> str | None:
schema_name = ref.rsplit("/", 1)[-1]
if schema_name == "ExpressionData":
return (
"expression data"
if "expression_action" not in self.skips
else "dynamic value"
)
if schema_name == "PythonReferenceDefinition":
return None
spec = _SPECS_BY_NAME.get(schema_name)
if (spec and spec.hidden) or self.model_is_skipped(schema_name):
return None
if spec is None:
return "object"
return f"[{spec.display_label}](#{markdown_heading_anchor(spec.display_label)})"
def render_field_description(
self,
spec: ModelSpec,
model_name: str,
field_name: str,
field_schema: dict[str, Any],
) -> str | None:
if "non_linear_flows" in self.skips and model_name == "FlowMethodDefinition":
if field_name == "start":
return "Marks the single normal entrypoint. Use `true`."
if field_name == "listen":
return "Runs this method after one upstream method or router-emitted event."
return render_field_description(spec, field_name, field_schema)
def render_field_default(
model_name: str,
field_name: str,
field_schema: dict[str, Any],
required: bool,
) -> str | None:
if required:
return None
if model_name == "FlowDefinition" and field_name == "config":
return "generated default"
if "default" in field_schema:
return format_inline_value(field_schema["default"])
return None
def extract_discriminator(model_schema: dict[str, Any]) -> dict[str, str] | None:
properties = model_schema.get("properties", {})
for field_name in ("call", "type"):
if field_name not in properties:
continue
value = properties[field_name].get(
"const", properties[field_name].get("default")
)
if value is not None:
return {"name": field_name, "value": str(value)}
return None
def join_unique(values: Any) -> str | None:
rendered_values = list(
dict.fromkeys(value for value in values if value is not None)
)
return " | ".join(rendered_values) or None
def markdown_heading_anchor(text: str) -> str:
heading = re.sub(r"<[^>]+>", "", text)
heading = re.sub(r"`([^`]*)`", r"\1", heading)
heading = heading.lower()
heading = re.sub(r"[^\w\s-]", "", heading)
return re.sub(r"\s+", "-", heading.strip())
def format_inline_value(value: Any) -> str:
if value is None:
return "`null`"
if isinstance(value, bool):
return f"`{str(value).lower()}`"
return f"`{value}`"
def render_field_description(
spec: ModelSpec, field_name: str, field_schema: dict[str, Any]
) -> str | None:
if field_name in spec.descriptions:
return spec.descriptions[field_name]
return field_schema.get("description")
def render_field_examples(
spec: ModelSpec, field_name: str, field_schema: dict[str, Any]
) -> list[str]:
if not spec.examples:
return []
examples = (
example
for example in field_schema.get("examples", ())
if not contains_python_reference(example)
)
return [format_inline_example(example) for example in examples]
def contains_python_reference(value: Any) -> bool:
if isinstance(value, dict):
return "python" in value or any(
contains_python_reference(item) for item in value.values()
)
if isinstance(value, list):
return any(contains_python_reference(item) for item in value)
return False
def format_inline_example(value: Any) -> str:
if isinstance(value, str):
return format_inline_value(value.replace("\n", "\\n"))
if value is None or isinstance(value, (bool, int, float)):
return format_inline_value(value)
return f"`{json.dumps(value, ensure_ascii=True)}`"

View File

@@ -0,0 +1,69 @@
schema: crewai.flow/v1
name: ResearchReviewFlow
state:
type: json_schema
json_schema:
type: object
properties:
topic:
type: string
audience:
type: string
required:
- topic
- audience
default:
topic: AI agent orchestration
audience: platform engineering leaders
methods:
research_brief:
start: true
do:
call: crew
with:
agents:
researcher:
role: Research analyst
goal: Research {topic} for {audience}
backstory: Expert at concise technical research.
reviewer:
role: Strategy reviewer
goal: Decide whether the research needs an executive follow-up
backstory: Experienced at reviewing technical briefs for leaders.
tasks:
- name: research_task
description: Research {topic} for {audience}.
expected_output: Key findings and tradeoffs.
agent: researcher
- name: review_task
description: Review the research and decide if an executive follow-up is needed.
expected_output: 'A brief review ending with `needs_followup: true` or `needs_followup: false`.'
agent: reviewer
inputs:
topic: Default topic
audience: Default audience
inputs:
topic: "${state.topic}"
audience: "${state.audience}"
route_followup:
listen: research_brief
router: true
emit:
- followup
- done
do:
call: agent
with:
role: Follow-up router
goal: 'Return exactly one bare value: followup or done. Do not include explanation.'
backstory: Skilled at routing reviewed research briefs.
input: "Reviewed research: ${outputs.research_brief.raw}"
write_followup:
listen: followup
do:
call: agent
with:
role: Executive communications specialist
goal: Draft a concise executive follow-up from the reviewed research
backstory: Writes crisp follow-ups for technical leaders.
input: "${outputs.research_brief.raw}"

View File

@@ -0,0 +1,217 @@
---
name: flow-definition
description: Create or edit CrewAI Flow declarations. Use when the user needs a YAML or JSON flow with methods, state, agents, crews, tools, outputs, or conditional branches.
---
# Flow Definition
You are writing a CrewAI Flow declaration for the user.
Use these instructions when the user asks you to create or edit a Flow.
Return one valid `crewai.flow/v1` YAML or JSON document.
Treat this document as instructions for you, not as text to show the user.
Follow the examples for shape and formatting, then use the API reference to check exact fields.
## Output Format
Return one valid `crewai.flow/v1` Flow declaration.
Do not include explanatory prose unless the user asks for it.
## Build It In This Order
1. Define `state` first. Use `type: json_schema` and put the JSON Schema inline.
2. Put required input fields in `state.json_schema.required`. Do not rely on `state.default` to make fields required.
{% if include_non_linear_flows %}
3. Add at least one method with `start: true`.
{% else %}
3. Add exactly one method with `start: true`.
{% endif %}
4. Add later methods with `listen`.
5. Give each method exactly one `do` action object. Never make `do` a list.
6. Pass data with `${...}` mappings from `state` and completed `outputs`.
7. Before final output, check every `listen`, `emit`, and `outputs.some_method` reference.
Set optional fields only when you are confident they are needed. Otherwise, trust CrewAI defaults and omit them.
Method names must match `^[A-Za-z_][A-Za-z0-9_]*$`.
## Choose One Action Per Method
Pick the simplest action that does the job.
{% if include_expression_action %}
- Use `call: expression` for simple reads, filters, computed values, and deterministic routing.
{% endif %}
{% if include_tool_action %}
- Use `call: tool` for packaged deterministic work: API calls, searches, lookups, scoring, file work, or custom CrewAI tools.
{% endif %}
- Use `call: agent` for one AI worker that classifies, decides, summarizes, writes, or drafts. Put `role`, `goal`, `backstory`, and `input` under `with`. Do not add an action-level `inputs` map to an agent.
- Use `call: crew` for coordinated AI work with multiple agents or tasks. Define the crew under `with`. Pass runtime values with the action-level `inputs` map.
{% if include_each_action %}
- Use `call: each` when the same ordered mini-pipeline must run once per item. Give every step a `name`.
{% endif %}
{% if include_hitl %}
- Use `human_feedback` when a method needs a human checkpoint.
{% endif %}
{% if include_script_action %}
- Use `call: script` only for trusted inline Python. Scripts are not sandboxed.
{% endif %}
## Wire Methods Explicitly
- `state` is the initial shared data shape. Action results do not automatically merge into `state`.
- Read method results with `outputs.method_name` after that method can run.
- `listen` targets a method name or a router-emitted event name.
- Methods must not listen to their own method name.
- Method names and emitted event names share one namespace. Avoid reusing the same string for both unless the user explicitly wants that.
- Use `router: true` plus `emit` when one method chooses between named branches.
- A router action must return exactly one emitted event string. It must not return JSON, a list, or an explanation.
{% if include_non_linear_flows %}
- Conditional `listen` values use `and` / `or`, for example `listen: {and: [validated, enriched]}`. They are not CEL.
- Use `start: true` for normal entrypoints. Use conditional `start` only for advanced event-driven starts.
{% else %}
- Use `start: true` for the single entrypoint.
{% endif %}
If an agent is a router, make its goal say exactly what to return, for example:
`Return exactly one bare value: approved, rejected, or needs_review. Do not include explanation.`
{% if include_expression_action %}
Prefer `call: expression` when routing can be computed without an agent.
{% endif %}
## CEL And Dynamic Values
CEL is the expression language for reading Flow data and making small decisions.
Use {% if include_tool_action %}tools, {% endif %}agents and crews{% if include_script_action %}, or trusted scripts{% endif %} for larger work or side effects.
Use these expression forms correctly:
{% if include_expression_action or include_each_action %}
- Raw CEL: use in {% if include_expression_action and include_each_action %}`expr`, `in`, and `if`{% elif include_expression_action %}`expr`{% else %}`in` and `if`{% endif %}. Do not wrap raw CEL in `${...}`.
{% endif %}
{% for rule in expression_contract_rules %}
- {{ rule }}
{% endfor %}
Expression examples:
{% for example in expression_contract_examples %}
{{ example.title }}:
```{{ example_language }}
{{ example.code }}
```
{% endfor %}
- Crew text: use `{name}` placeholders from crew inputs. Example: `Research {topic}`.
- Crew inputs become prompt text only when agent or task text references matching `{name}` placeholders.
- Passing an input that is not referenced by any `{name}` placeholder does not ground the crew. If the crew needs a field, put that placeholder in an agent `goal`, task `description`, or task `expected_output`.
Available CEL variables:
- `state`: initial input data, for example `state.ticket.subject`.
- `outputs`: completed method outputs, for example `outputs.classify_ticket`.
{% if include_each_action %}
- `item`: the current item inside `each`, for example `item.company_domain`.
- `outputs`: inside `each`, prior step outputs for the current item, for example `outputs.enrich`.
{% endif %}
Dynamic value rules:
- When an agent needs multiple fields, write one text value with labels and separators. Example value: `Ticket ID: ${state.ticket_id}; Message: ${state.message}`.
- Crew action-level `inputs` are the actual Crew kickoff inputs. Use `${...}` values there for runtime data from `state` or `outputs`.
- Crew action-level `inputs` alone are not grounding. Include placeholders for the facts the model must use.
- Crew outputs are objects. Use `${outputs.research_brief.raw}` for text.
- For structured crew output, use fields like `${outputs.research_brief.json_dict.field}` or `${outputs.research_brief.pydantic.field}`.
- Do not pass a whole crew output to an agent input, like `${outputs.research_brief}`.
- Agent outputs may also be objects. Use fields like `${outputs.classify_ticket.raw}` or `${outputs.classify_ticket.pydantic.category}`.
- Use `with.inputs` only for static Crew input defaults.
- Agent action `with.input` is the agent's single input value.
{% if include_script_action %}
- Trusted `script` actions can mutate state explicitly. Use them only when the user asks for that behavior.
{% endif %}
## Do Not
- Do not invent top-level keys outside the Flow declaration shape.
- Do not use fields outside the declaration schema{% if include_tool_action %} or tool refs shown here{% endif %}.
- Do not put more than one action under a method's `do`.
- Do not make `do` a list.
- Do not reference `outputs.some_method` before `some_method` can run.
- Do not set a method's `listen` to its own method name.
- Do not use the same string for an emitted event and a method name unless the user asks for it.
- Do not use `emit` without `router: true`{% if include_hitl %} or `human_feedback.emit`{% endif %}.
- Do not rely on crew action-level `inputs` alone to ground agent behavior. Inputs that do not match placeholders are effectively unused by the prompt.
- Do not ask agents to infer missing facts when accuracy matters. Tell them to mark missing dates, amounts, offers, logs, or constraints as unknown.
- Do not set `config.stream: true` unless the caller is expected to consume a streaming result. For normal generated flows and CLI smoke tests, omit it.
{% if include_conversational %}
- Do not put conversational settings under `state`, `config`, or a method. Use top-level `conversational` only when the user asks for a chat or conversation flow.
{% endif %}
{% if include_each_action %}
- Do not use `each` without at least one named step.
{% endif %}
{% if include_script_action %}
- Do not use `script` for untrusted input or user-authored code.
{% endif %}
## Examples
### Crew review with routed follow-up
```{{ example_language }}
{{ example }}
```
## API Reference
Use this appendix to check exact field names, required fields, linked object types, and allowed action/state shapes. Linked type names point to another section in this reference.
{% macro render_model(model) -%}
{% if model.discriminator %}
Shape:
- `{{ model.discriminator.name }}: {{ model.discriminator.value }}`
{% endif %}
Fields:
{% for field in model.fields %}
- `{{ field.name }}` ({% if field.required %}required{% else %}optional{% endif %}): {{ field.type }}{% if field.default %}; default {{ field.default }}{% endif %}{% if field.description %}. {{ field.description }}{% endif %}{% if field.examples %}{% if not field.description %}.{% endif %} Example{% if field.examples|length > 1 %}s{% endif %}: {{ field.examples | join(", ") }}{% endif +%}
{% endfor %}
{% for inline_model in model.inline_models %}
#### {{ inline_model.label }}
{{ render_model(inline_model) }}
{% endfor %}
{%- endmacro %}
{% for section in sections %}
### {{ section.label }}
{% if section.kind == "union" %}
Discriminated union by `{% if section.label == "State" %}type{% else %}call{% endif %}`.
Allowed shapes:
{% for model in section.models %}
- [`{{ model.discriminator.name }}: {{ model.discriminator.value }}`]({{ model.anchor }})
{% endfor %}
{% for model in section.models %}
### {{ model.label }}
{{ render_model(model) }}
{% endfor %}
{% else %}
{{ render_model(section.models[0]) }}
{% endif %}
{% endfor %}
### Cross-Field Rules
- A method has exactly one `do` action object with one `call` discriminator.
- `listen` targets method names and router-emitted event names in one shared namespace.
- Methods cannot listen to their own method name.
- A router method result must match one declared `emit` value.
- Crew action-level `inputs` are the Crew kickoff inputs; use CEL-wrapped strings there for runtime values.
- Crew agent/task interpolation uses `{name}` placeholders from evaluated crew inputs.
- Agent `with.input` must be text. Use `${outputs.method_name.raw}` or a text field like `${outputs.method_name.json_dict.summary}`.
{% if include_each_action %}
- `each.do` must contain at least one named step.
{% endif %}

View File

@@ -749,7 +749,7 @@ class LLM(BaseLLM):
"base_url": self.base_url,
"api_version": self.api_version,
"api_key": self.api_key,
"stream": self.stream,
"stream": self._effective_stream(),
"tools": tools,
"reasoning_effort": self.reasoning_effort,
**self.additional_params,
@@ -1841,7 +1841,7 @@ class LLM(BaseLLM):
self.set_callbacks(callbacks)
try:
params = self._prepare_completion_params(messages, tools)
if self.stream:
if self._effective_stream():
result = self._handle_streaming_response(
params=params,
callbacks=callbacks,
@@ -1983,7 +1983,7 @@ class LLM(BaseLLM):
messages, tools, skip_file_processing=True
)
if self.stream:
if self._effective_stream():
return await self._ahandle_streaming_response(
params=params,
callbacks=callbacks,

View File

@@ -42,8 +42,13 @@ from crewai.events.types.tool_usage_events import (
ToolUsageFinishedEvent,
ToolUsageStartedEvent,
)
from crewai.types.streaming import StreamSession
from crewai.types.usage_metrics import UsageMetrics
from crewai.utilities.pydantic_schema_utils import serialize_model_class
from crewai.utilities.streaming import (
create_frame_generator,
create_frame_streaming_state,
)
try:
@@ -77,6 +82,9 @@ _current_call_id: contextvars.ContextVar[str | None] = contextvars.ContextVar(
_call_stop_override_var: contextvars.ContextVar[dict[int, list[str]] | None] = (
contextvars.ContextVar("_call_stop_override_var", default=None)
)
_call_stream_override_var: contextvars.ContextVar[dict[int, bool] | None] = (
contextvars.ContextVar("_call_stream_override_var", default=None)
)
@contextmanager
@@ -115,6 +123,19 @@ def call_stop_override(
_call_stop_override_var.reset(token)
@contextmanager
def call_stream_override(llm: BaseLLM, stream: bool) -> Generator[None, None, None]:
"""Override streaming for ``llm`` within the current call scope."""
current = _call_stream_override_var.get()
new_overrides: dict[int, bool] = dict(current) if current else {}
new_overrides[id(llm)] = stream
token = _call_stream_override_var.set(new_overrides)
try:
yield
finally:
_call_stream_override_var.reset(token)
def get_current_call_id() -> str:
"""Get current call_id from context"""
call_id = _current_call_id.get()
@@ -213,6 +234,13 @@ class BaseLLM(BaseModel, ABC):
return override
return self.stop
def _effective_stream(self) -> bool | None:
"""Return the call-scoped streaming mode for this instance."""
overrides = _call_stream_override_var.get()
if overrides is not None and id(self) in overrides:
return overrides[id(self)]
return self.stream
_token_usage: dict[str, int] = PrivateAttr(
default_factory=lambda: {
"total_tokens": 0,
@@ -318,6 +346,39 @@ class BaseLLM(BaseModel, ABC):
RuntimeError: If the LLM request fails for other reasons.
"""
def stream_events(
self,
messages: str | list[LLMMessage],
tools: list[dict[str, BaseTool]] | None = None,
callbacks: list[Any] | None = None,
available_functions: dict[str, Any] | None = None,
from_task: Task | None = None,
from_agent: BaseAgent | None = None,
response_model: type[BaseModel] | None = None,
) -> StreamSession[Any]:
"""Run the LLM call and stream scoped public ``StreamFrame`` events."""
result_holder: list[Any] = []
state = create_frame_streaming_state(result_holder, use_async=False)
output_holder: list[StreamSession[Any]] = []
def run_llm_call() -> Any:
with call_stream_override(self, True):
return self.call(
messages=messages,
tools=tools,
callbacks=callbacks,
available_functions=available_functions,
from_task=from_task,
from_agent=from_agent,
response_model=response_model,
)
stream_session: StreamSession[Any] = StreamSession(
sync_iterator=create_frame_generator(state, run_llm_call, output_holder)
)
output_holder.append(stream_session)
return stream_session
async def acall(
self,
messages: str | list[LLMMessage],
@@ -509,7 +570,7 @@ class BaseLLM(BaseModel, ABC):
if max_tokens is None:
max_tokens = self._effective_max_tokens()
if stream is None:
stream = self.stream
stream = self._effective_stream()
if seed is None:
seed = self.seed
if stop_sequences is None:

View File

@@ -323,7 +323,7 @@ class AnthropicCompletion(BaseLLM):
effective_response_model = response_model or self.response_format
if self.stream:
if self._effective_stream():
return self._handle_streaming_completion(
completion_params,
available_functions,
@@ -393,7 +393,7 @@ class AnthropicCompletion(BaseLLM):
effective_response_model = response_model or self.response_format
if self.stream:
if self._effective_stream():
return await self._ahandle_streaming_completion(
completion_params,
available_functions,
@@ -441,7 +441,7 @@ class AnthropicCompletion(BaseLLM):
"model": self.model,
"messages": messages,
"max_tokens": self.max_tokens,
"stream": self.stream,
"stream": self._effective_stream(),
}
if system_message:

View File

@@ -42,7 +42,7 @@ try:
)
from crewai.events.types.llm_events import LLMCallType
from crewai.llms.base_llm import BaseLLM, llm_call_context
from crewai.llms.base_llm import BaseLLM, call_stream_override, llm_call_context
except ImportError:
raise ImportError(
@@ -493,15 +493,18 @@ class AzureCompletion(BaseLLM):
Completion response or tool call result
"""
if self.api == "responses":
return self._responses_delegate.call(
messages=messages,
tools=tools,
callbacks=callbacks,
available_functions=available_functions,
from_task=from_task,
from_agent=from_agent,
response_model=response_model,
)
with call_stream_override(
self._responses_delegate, bool(self._effective_stream())
):
return self._responses_delegate.call(
messages=messages,
tools=tools,
callbacks=callbacks,
available_functions=available_functions,
from_task=from_task,
from_agent=from_agent,
response_model=response_model,
)
with llm_call_context():
try:
@@ -527,7 +530,7 @@ class AzureCompletion(BaseLLM):
formatted_messages, tools, effective_response_model
)
if self.stream:
if self._effective_stream():
return self._handle_streaming_completion(
completion_params,
available_functions,
@@ -572,15 +575,18 @@ class AzureCompletion(BaseLLM):
Completion response or tool call result
"""
if self.api == "responses":
return await self._responses_delegate.acall(
messages=messages,
tools=tools,
callbacks=callbacks,
available_functions=available_functions,
from_task=from_task,
from_agent=from_agent,
response_model=response_model,
)
with call_stream_override(
self._responses_delegate, bool(self._effective_stream())
):
return await self._responses_delegate.acall(
messages=messages,
tools=tools,
callbacks=callbacks,
available_functions=available_functions,
from_task=from_task,
from_agent=from_agent,
response_model=response_model,
)
with llm_call_context():
try:
@@ -601,7 +607,7 @@ class AzureCompletion(BaseLLM):
formatted_messages, tools, effective_response_model
)
if self.stream:
if self._effective_stream():
return await self._ahandle_streaming_completion(
completion_params,
available_functions,
@@ -639,11 +645,11 @@ class AzureCompletion(BaseLLM):
"""
params: AzureCompletionParams = {
"messages": messages,
"stream": self.stream,
"stream": bool(self._effective_stream()),
}
model_extras: dict[str, Any] = {}
if self.stream:
if self._effective_stream():
model_extras["stream_options"] = {"include_usage": True}
if response_model and self.is_openai_model:

View File

@@ -428,7 +428,7 @@ class BedrockCompletion(BaseLLM):
self.additional_model_response_field_paths
)
if self.stream:
if self._effective_stream():
return self._handle_streaming_converse(
formatted_messages,
body,
@@ -492,7 +492,7 @@ class BedrockCompletion(BaseLLM):
if not AIOBOTOCORE_AVAILABLE:
raise NotImplementedError(
"Async support for AWS Bedrock requires aiobotocore. "
'Install with: uv add "crewai[bedrock-async]"'
'Install with: uv add "crewai[bedrock]"'
)
with llm_call_context():
@@ -556,7 +556,7 @@ class BedrockCompletion(BaseLLM):
self.additional_model_response_field_paths
)
if self.stream:
if self._effective_stream():
return await self._ahandle_streaming_converse(
formatted_messages,
body,

View File

@@ -322,7 +322,7 @@ class GeminiCompletion(BaseLLM):
system_instruction, tools, effective_response_model
)
if self.stream:
if self._effective_stream():
return self._handle_streaming_completion(
formatted_content,
config,
@@ -401,7 +401,7 @@ class GeminiCompletion(BaseLLM):
system_instruction, tools, effective_response_model
)
if self.stream:
if self._effective_stream():
return await self._ahandle_streaming_completion(
formatted_content,
config,

View File

@@ -469,7 +469,7 @@ class OpenAICompletion(BaseLLM):
messages=messages, tools=tools
)
if self.stream:
if self._effective_stream():
return self._handle_streaming_completion(
params=completion_params,
available_functions=available_functions,
@@ -564,7 +564,7 @@ class OpenAICompletion(BaseLLM):
messages=messages, tools=tools
)
if self.stream:
if self._effective_stream():
return await self._ahandle_streaming_completion(
params=completion_params,
available_functions=available_functions,
@@ -595,7 +595,7 @@ class OpenAICompletion(BaseLLM):
messages=messages, tools=tools, response_model=response_model
)
if self.stream:
if self._effective_stream():
return self._handle_streaming_responses(
params=params,
available_functions=available_functions,
@@ -626,7 +626,7 @@ class OpenAICompletion(BaseLLM):
messages=messages, tools=tools, response_model=response_model
)
if self.stream:
if self._effective_stream():
return await self._ahandle_streaming_responses(
params=params,
available_functions=available_functions,
@@ -685,7 +685,7 @@ class OpenAICompletion(BaseLLM):
if instructions:
params["instructions"] = instructions
if self.stream:
if self._effective_stream():
params["stream"] = True
if self.store is not None:
@@ -1540,8 +1540,8 @@ class OpenAICompletion(BaseLLM):
"model": self.model,
"messages": messages,
}
if self.stream:
params["stream"] = self.stream
if self._effective_stream():
params["stream"] = self._effective_stream()
params["stream_options"] = {"include_usage": True}
params.update(self.additional_params)

View File

@@ -6,12 +6,15 @@ from typing import Any, TypeAlias
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
from crewai.agent.planning_config import PlanningConfig
__all__ = [
"AgentDefinition",
"CrewAgentDefinition",
"CrewDefinition",
"CrewTaskDefinition",
"LLMDefinition",
"PythonReferenceDefinition",
]
@@ -19,7 +22,10 @@ __all__ = [
class PythonReferenceDefinition(BaseModel):
"""Dotted Python reference used by crew definitions."""
python: str
python: str = Field(
description="Dotted Python import path to load.",
examples=["my_project.schemas.SupportReply"],
)
@field_validator("python")
@classmethod
@@ -35,16 +41,148 @@ class PythonReferenceDefinition(BaseModel):
return path
class LLMDefinition(BaseModel):
"""LLM configuration used by inline agent definitions."""
model_config = ConfigDict(extra="allow")
model: str = Field(
description="Model identifier used to instantiate the LLM.",
examples=["openai/gpt-4o-mini"],
)
max_tokens: int | None = Field(
default=None,
description=(
"Maximum number of tokens the LLM can generate. If null, CrewAI "
"does not set an explicit output token cap and the provider's "
"default applies."
),
examples=[4096],
)
class CrewAgentDefinition(BaseModel):
"""Inline agent definition used by a crew definition."""
model_config = ConfigDict(extra="allow")
role: str
goal: str
backstory: str
type: str | PythonReferenceDefinition | None = None
settings: dict[str, Any] = Field(default_factory=dict)
role: str | None = Field(
default=None,
description=(
"Crew agent role. Crew inputs are interpolated with `{name}` "
"placeholders such as `{topic}`; this is not CEL."
),
examples=["Research analyst"],
)
goal: str | None = Field(
default=None,
description=(
"Crew agent goal. Crew inputs are interpolated with `{name}` "
"placeholders such as `{topic}`; this is not CEL."
),
examples=["Research {topic}"],
)
backstory: str | None = Field(
default=None,
description=(
"Crew agent backstory. Crew inputs are interpolated with `{name}` "
"placeholders such as `{topic}`; this is not CEL."
),
examples=["Expert at concise technical research."],
)
type: str | PythonReferenceDefinition | None = Field(
default=None,
description="Optional built-in type or Python reference used to load the agent.",
examples=["agent", {"python": "my_project.agents.ResearchAgent"}],
)
from_repository: str | None = Field(
default=None,
description=(
"Agent repository name to load. Repository values supply missing "
"agent configuration; explicitly provided local fields override the "
"repository values."
),
examples=["researcher"],
)
settings: dict[str, Any] = Field(
default_factory=dict,
description="Additional agent settings passed to the loader.",
examples=[{"llm": "openai/gpt-4o-mini"}],
)
llm: str | LLMDefinition | None = Field(
default=None,
description=(
"Language model that runs the agent. Use a string model name or an "
"object with model settings such as max_tokens."
),
examples=[{"model": "openai/gpt-4o-mini", "max_tokens": 4096}],
)
planning_config: PlanningConfig | None = Field(
default=None,
description="Configuration for agent planning before task execution.",
examples=[{"max_attempts": 3}],
)
allow_delegation: bool | None = Field(
default=None,
description="Enable agent to delegate and ask questions among each other.",
examples=[False],
)
max_iter: int | None = Field(
default=None,
description="Maximum iterations for an agent to execute a task",
examples=[25],
)
max_rpm: int | None = Field(
default=None,
description=(
"Maximum number of requests per minute for the agent execution to be "
"respected."
),
examples=[10],
)
max_execution_time: int | None = Field(
default=None,
description="Maximum execution time in seconds for an agent to execute a task",
examples=[300],
)
tools: list[str | dict[str, Any]] | None = Field(
default=None,
description=(
"Tool refs or serialized tool definitions available to this agent. "
"String refs can use CrewAI tool names, `custom:<name>`, or fully "
"qualified `module:Class` references."
),
examples=[["crewai_tools:SerperDevTool", "custom:file_read"]],
)
apps: list[str] | None = Field(
default=None,
description=(
"Platform apps available to this agent. Can contain app names such as "
"`gmail` or app/action refs such as `gmail/send_email`."
),
examples=[["gmail", "slack/send_message"]],
)
mcps: list[str | dict[str, Any]] | None = Field(
default=None,
description=(
"MCP server refs or serialized MCP server configs available to this "
"agent. String refs can use HTTPS URLs, connected MCP integration "
"slugs, or refs with a `#tool_name` suffix for specific tools."
),
examples=[
[
"https://api.weather.com/mcp#get_current_weather",
"snowflake",
"stripe#list_invoices",
{
"url": "https://api.example.com/mcp",
"headers": {"Authorization": "Bearer your_token"},
"streamable": True,
"cache_tools_list": True,
},
]
],
)
@field_validator("settings", mode="before")
@classmethod
@@ -55,10 +193,44 @@ class CrewAgentDefinition(BaseModel):
class AgentDefinition(CrewAgentDefinition):
"""Inline agent definition used by a Flow agent action."""
"""Inline individual agent definition used outside of a crew."""
input: str
response_format: PythonReferenceDefinition | None = None
role: str | None = Field(
default=None,
description="Individual agent role used by a Flow agent action outside of a crew.",
examples=["Support specialist"],
)
goal: str | None = Field(
default=None,
description="Individual agent goal for the Flow agent action outside of a crew.",
examples=["Draft a concise customer reply"],
)
backstory: str | None = Field(
default=None,
description=(
"Individual agent backstory used to shape behavior outside of a crew."
),
examples=["Expert at resolving SaaS support questions."],
)
type: str | PythonReferenceDefinition | None = Field(
default=None,
description="Optional built-in type or Python reference used to load the agent.",
examples=["agent", {"python": "my_project.agents.SupportAgent"}],
)
settings: dict[str, Any] = Field(
default_factory=dict,
description="Additional agent settings passed to the loader.",
examples=[{"llm": "openai/gpt-4o-mini"}],
)
input: str = Field(
description="Input passed to the individual agent kickoff outside of a crew.",
examples=["${state.ticket.body}"],
)
response_format: PythonReferenceDefinition | None = Field(
default=None,
description="Optional Python reference to a Pydantic response format.",
examples=[{"python": "my_project.schemas.SupportReply"}],
)
@field_validator("input", mode="before")
@classmethod
@@ -73,12 +245,40 @@ class CrewTaskDefinition(BaseModel):
model_config = ConfigDict(extra="allow")
description: str
expected_output: str
name: str | None = None
agent: str | None = None
context: list[str] | None = None
type: str | PythonReferenceDefinition | None = None
description: str = Field(
description=(
"Task instructions. Crew inputs are interpolated with `{name}` "
"placeholders such as `{topic}`; this is not CEL."
),
examples=["Research {topic}."],
)
expected_output: str = Field(
description=(
"Expected task output. Crew inputs are interpolated with `{name}` "
"placeholders such as `{topic}`; this is not CEL."
),
examples=["Key findings about {topic}."],
)
name: str | None = Field(
default=None,
description="Optional task name used by context references.",
examples=["research_task"],
)
agent: str | None = Field(
default=None,
description="Name of the crew agent assigned to this task.",
examples=["researcher"],
)
context: list[str] | None = Field(
default=None,
description="Names of previous tasks whose outputs should be used as context.",
examples=[["research_task"]],
)
type: str | PythonReferenceDefinition | None = Field(
default=None,
description="Optional built-in type or Python reference used to load the task.",
examples=["task", {"python": "my_project.tasks.ResearchTask"}],
)
_CrewAgentsInput: TypeAlias = dict[str, CrewAgentDefinition] | list[dict[str, Any]]
@@ -89,10 +289,44 @@ class CrewDefinition(BaseModel):
model_config = ConfigDict(extra="allow")
agents: dict[str, CrewAgentDefinition]
tasks: list[CrewTaskDefinition]
inputs: dict[str, Any] = Field(default_factory=dict)
manager_agent: str | PythonReferenceDefinition | None = None
agents: dict[str, CrewAgentDefinition] = Field(
description="Inline crew agents keyed by agent name.",
examples=[
{
"researcher": {
"role": "Research analyst",
"goal": "Research {topic}",
"backstory": "Expert at concise technical research.",
}
}
],
)
tasks: list[CrewTaskDefinition] = Field(
description="Ordered crew tasks.",
examples=[
[
{
"name": "research_task",
"description": "Research {topic}.",
"expected_output": "Key findings about {topic}.",
"agent": "researcher",
}
]
],
)
inputs: dict[str, Any] = Field(
default_factory=dict,
description=(
"Default crew inputs. Values are available to crew agent and task "
"interpolation as `{name}` placeholders, for example `{topic}`."
),
examples=[{"topic": "AI agents"}],
)
manager_agent: str | PythonReferenceDefinition | None = Field(
default=None,
description="Optional manager agent name or Python reference.",
examples=["manager", {"python": "my_project.agents.ManagerAgent"}],
)
@field_validator("inputs", mode="before")
@classmethod

View File

@@ -978,9 +978,10 @@ def _agent_kwargs_from_definition(
extra_allowed,
skip_unknown=skip_unknown,
)
for required in ("role", "goal", "backstory"):
if required not in defn:
errors.append(f"{path}: missing required field '{required}'")
if not defn.get("from_repository"):
for required in ("role", "goal", "backstory"):
if defn.get(required) is None:
errors.append(f"{path}: missing required field '{required}'")
settings = defn.get("settings", {})
if settings is None:

View File

@@ -3,7 +3,12 @@
Provides filesystem-based skill packaging with progressive disclosure.
"""
from crewai.skills.loader import activate_skill, discover_skills
from crewai.skills.loader import (
activate_skill,
discover_skills,
load_skill,
load_skills,
)
from crewai.skills.models import Skill, SkillFrontmatter
from crewai.skills.parser import SkillParseError
@@ -14,4 +19,6 @@ __all__ = [
"SkillParseError",
"activate_skill",
"discover_skills",
"load_skill",
"load_skills",
]

View File

@@ -6,6 +6,7 @@ for agent use, and format skill context for prompt injection.
from __future__ import annotations
from collections.abc import Iterable
import logging
from pathlib import Path
from typing import TYPE_CHECKING
@@ -18,12 +19,13 @@ from crewai.events.types.skill_events import (
SkillLoadFailedEvent,
SkillLoadedEvent,
)
from crewai.skills.models import INSTRUCTIONS, RESOURCES, Skill
from crewai.skills.models import INSTRUCTIONS, RESOURCES, Skill, SkillFrontmatter
from crewai.skills.parser import (
SKILL_FILENAME,
load_skill_instructions,
load_skill_metadata,
load_skill_resources,
parse_frontmatter,
)
@@ -143,6 +145,72 @@ def activate_skill(
return activated
def load_skill(
skill: Path | Skill | str,
source: BaseAgent | None = None,
) -> list[Skill]:
"""Load one skill input into Skill objects.
Accepts a pre-loaded Skill object, skill search path, inline SKILL.md
string, or '@org/name' registry reference. Path inputs can expand to many
skills. Path and inline inputs are activated immediately; pre-loaded Skill
objects keep their disclosure level.
"""
if isinstance(skill, Skill):
return [skill]
if isinstance(skill, Path):
return [
activate_skill(s, source=source)
for s in discover_skills(skill, source=source)
]
if isinstance(skill, str) and skill.startswith("@"):
from crewai.experimental.skills.registry import resolve_registry_ref
return [resolve_registry_ref(skill, source=source)]
if isinstance(skill, str) and skill.lstrip().startswith("---\n"):
frontmatter_dict, body = parse_frontmatter(skill.strip())
return [
Skill(
frontmatter=SkillFrontmatter(**frontmatter_dict),
instructions=body,
path=Path("."),
disclosure_level=INSTRUCTIONS,
)
]
if isinstance(skill, str):
return [
activate_skill(s, source=source)
for s in discover_skills(Path(skill), source=source)
]
msg = f"Unsupported skill input: {skill!r}"
raise TypeError(msg)
def load_skills(
skills: Iterable[Path | Skill | str],
source: BaseAgent | None = None,
) -> list[Skill]:
"""Load skill inputs into de-duplicated Skill objects.
Preserves first-seen order when multiple inputs resolve to the same skill
name. Registry refs are scoped by org so different orgs can publish skills
that share a frontmatter name.
"""
loaded: dict[str, Skill] = {}
for skill_input in skills:
for skill in load_skill(skill_input, source=source):
dedup_key = skill.name
if isinstance(skill_input, str) and skill_input.startswith("@"):
from crewai.experimental.skills.registry import parse_registry_ref
org, _ = parse_registry_ref(skill_input)
dedup_key = f"{org}/{skill.name}"
if dedup_key not in loaded:
loaded[dedup_key] = skill
return list(loaded.values())
def load_resources(skill: Skill) -> Skill:
"""Promote a skill to RESOURCES disclosure level.

View File

@@ -949,6 +949,7 @@ class Telemetry:
def _operation() -> None:
tracer = trace.get_tracer("crewai.telemetry")
span = tracer.start_span("Flow Creation")
self._add_attribute(span, "crewai_version", version("crewai"))
self._add_attribute(span, "flow_name", flow_name)
close_span(span)

View File

@@ -2,9 +2,10 @@
from __future__ import annotations
from collections.abc import AsyncIterator, Callable, Iterator
from collections.abc import AsyncIterator, Callable, Iterator, Sequence
from datetime import datetime
from enum import Enum
from typing import TYPE_CHECKING, Any, Generic, TypeVar
from typing import TYPE_CHECKING, Any, Generic, Literal, TypeVar
from pydantic import BaseModel, Field
from typing_extensions import Self
@@ -15,6 +16,262 @@ if TYPE_CHECKING:
T = TypeVar("T")
_MISSING = object()
StreamChannel = Literal[
"llm",
"flow",
"tools",
"messages",
"lifecycle",
"custom",
]
class StreamFrame(BaseModel):
"""Stable public stream frame emitted by streamable runtimes."""
id: str = Field(description="Unique frame/event identifier")
seq: int | None = Field(default=None, description="Execution-local order")
type: str = Field(description="Source event type")
channel: StreamChannel = Field(description="High-level stream channel")
namespace: list[str] = Field(default_factory=list)
timestamp: datetime
parent_id: str | None = None
previous_id: str | None = None
data: dict[str, Any] = Field(default_factory=dict)
@property
def content(self) -> str:
"""Printable text content for chunk-like consumers."""
chunk = self.data.get("chunk")
if isinstance(chunk, str):
return chunk
return ""
@property
def event(self) -> dict[str, Any]:
"""Structured source event payload."""
return self.data
class StreamSessionBase(Generic[T]):
"""Base stream session with ordered frame iteration and result access."""
def __init__(
self,
sync_iterator: Iterator[StreamFrame] | None = None,
async_iterator: AsyncIterator[StreamFrame] | None = None,
) -> None:
self._result: T | object = _MISSING
self._completed = False
self._frames: list[StreamFrame] = []
self._error: Exception | None = None
self._cancelled = False
self._exhausted = False
self._on_cleanup: Callable[[], None] | None = None
self._sync_iterator = sync_iterator
self._async_iterator = async_iterator
@property
def result(self) -> T:
"""Return the final result after stream exhaustion or completion."""
if not self._completed:
raise RuntimeError(
"Streaming has not completed yet. "
"Iterate over all frames before accessing result."
)
if self._error is not None:
raise self._error
if self._result is _MISSING:
raise RuntimeError("No result available")
return self._result # type: ignore[return-value]
@property
def is_completed(self) -> bool:
"""Check if the stream has completed."""
return self._completed
@property
def is_cancelled(self) -> bool:
"""Check if the stream was cancelled."""
return self._cancelled
@property
def is_exhausted(self) -> bool:
"""Check if the stream iterator was fully consumed."""
return self._exhausted
@property
def frames(self) -> list[StreamFrame]:
"""Return collected frames."""
return self._frames.copy()
def _set_result(self, result: T) -> None:
self._result = result
self._completed = True
class StreamSession(StreamSessionBase[T]):
"""Synchronous stream session for ordered public frames."""
def __enter__(self) -> Self:
return self
def __exit__(self, *exc_info: Any) -> None:
if not self._exhausted:
self.close()
@property
def events(self) -> Iterator[StreamFrame]:
"""Iterate over all ordered frames."""
return self.subscribe()
def __iter__(self) -> Iterator[StreamFrame]:
"""Iterate over all ordered frames."""
return self.events
@property
def llm(self) -> Iterator[StreamFrame]:
"""Iterate over LLM token and thinking frames."""
return self.subscribe(channels=["llm"])
@property
def messages(self) -> Iterator[StreamFrame]:
"""Iterate over conversation message frames."""
return self.subscribe(channels=["messages"])
@property
def flow(self) -> Iterator[StreamFrame]:
"""Iterate over Flow lifecycle and method frames."""
return self.subscribe(channels=["flow"])
@property
def tools(self) -> Iterator[StreamFrame]:
"""Iterate over tool execution frames."""
return self.subscribe(channels=["tools"])
def interleave(self, channels: Sequence[StreamChannel]) -> Iterator[StreamFrame]:
"""Iterate over selected channels while preserving global order."""
return self.subscribe(channels=channels)
def subscribe(
self, channels: Sequence[StreamChannel] | None = None
) -> Iterator[StreamFrame]:
"""Iterate over frames, optionally filtered by channel."""
selected = set(channels) if channels is not None else None
if self._exhausted:
for frame in self._frames:
if selected is None or frame.channel in selected:
yield frame
return
if self._sync_iterator is None:
raise RuntimeError("Sync iterator not available")
try:
for frame in self._sync_iterator:
self._frames.append(frame)
if selected is None or frame.channel in selected:
yield frame
self._exhausted = True
except Exception as e:
self._error = e
raise
finally:
self._completed = True
def close(self) -> None:
"""Cancel streaming and clean up resources."""
if self._cancelled or self._exhausted or self._error is not None:
return
self._cancelled = True
self._completed = True
if self._sync_iterator is not None and hasattr(self._sync_iterator, "close"):
self._sync_iterator.close()
if self._on_cleanup is not None:
self._on_cleanup()
self._on_cleanup = None
class AsyncStreamSession(StreamSessionBase[T]):
"""Asynchronous stream session for ordered public frames."""
async def __aenter__(self) -> Self:
return self
async def __aexit__(self, *exc_info: Any) -> None:
if not self._exhausted:
await self.aclose()
@property
def events(self) -> AsyncIterator[StreamFrame]:
"""Iterate over all ordered frames."""
return self.subscribe()
def __aiter__(self) -> AsyncIterator[StreamFrame]:
"""Iterate over all ordered frames."""
return self.events
@property
def llm(self) -> AsyncIterator[StreamFrame]:
"""Iterate over LLM token and thinking frames."""
return self.subscribe(channels=["llm"])
@property
def messages(self) -> AsyncIterator[StreamFrame]:
"""Iterate over conversation message frames."""
return self.subscribe(channels=["messages"])
@property
def flow(self) -> AsyncIterator[StreamFrame]:
"""Iterate over Flow lifecycle and method frames."""
return self.subscribe(channels=["flow"])
@property
def tools(self) -> AsyncIterator[StreamFrame]:
"""Iterate over tool execution frames."""
return self.subscribe(channels=["tools"])
def interleave(
self, channels: Sequence[StreamChannel]
) -> AsyncIterator[StreamFrame]:
"""Iterate over selected channels while preserving global order."""
return self.subscribe(channels=channels)
async def subscribe(
self, channels: Sequence[StreamChannel] | None = None
) -> AsyncIterator[StreamFrame]:
"""Iterate over frames, optionally filtered by channel."""
selected = set(channels) if channels is not None else None
if self._exhausted:
for frame in self._frames:
if selected is None or frame.channel in selected:
yield frame
return
if self._async_iterator is None:
raise RuntimeError("Async iterator not available")
try:
async for frame in self._async_iterator:
self._frames.append(frame)
if selected is None or frame.channel in selected:
yield frame
self._exhausted = True
except Exception as e:
self._error = e
raise
finally:
self._completed = True
async def aclose(self) -> None:
"""Cancel streaming and clean up resources."""
if self._cancelled or self._exhausted or self._error is not None:
return
self._cancelled = True
self._completed = True
if self._async_iterator is not None and hasattr(self._async_iterator, "aclose"):
await self._async_iterator.aclose()
if self._on_cleanup is not None:
self._on_cleanup()
self._on_cleanup = None
class StreamChunkType(Enum):

View File

@@ -1125,7 +1125,7 @@ def load_agent_from_repository(from_repository: str) -> dict[str, Any]:
client = PlusAPI(api_key=get_auth_token())
_print_current_organization()
response = asyncio.run(client.get_agent(from_repository))
response = client.get_agent(from_repository)
if response.status_code == 404:
raise AgentRepositoryError(
f"Agent {from_repository} does not exist, make sure the name is correct or the agent is available on your organization."
@@ -1158,6 +1158,8 @@ def load_agent_from_repository(from_repository: str) -> dict[str, Any]:
raise AgentRepositoryError(
f"Tool {tool['name']} could not be loaded: {e}"
) from e
elif key == "skills" and value == []:
continue
else:
attributes[key] = value
return attributes

View File

@@ -6,19 +6,32 @@ import contextvars
import logging
import queue
import threading
from typing import Any, NamedTuple
from typing import Any, NamedTuple, cast
import uuid
from typing_extensions import TypedDict
from crewai.events.base_events import BaseEvent
from crewai.events.event_bus import crewai_event_bus
from crewai.events.types.llm_events import LLMStreamChunkEvent
from crewai.events.stream_context import add_stream_sink, reset_stream_sinks
from crewai.events.types.flow_events import ConversationMessageAddedEvent, FlowEvent
from crewai.events.types.llm_events import (
LLMEventBase,
LLMStreamChunkEvent,
)
from crewai.events.types.tool_usage_events import (
ToolExecutionErrorEvent,
ToolUsageEvent,
)
from crewai.types.streaming import (
AsyncStreamSession,
CrewStreamingOutput,
FlowStreamingOutput,
StreamChannel,
StreamChunk,
StreamChunkType,
StreamFrame,
StreamSession,
ToolCallChunk,
)
from crewai.utilities.string_utils import sanitize_tool_name
@@ -53,6 +66,16 @@ class StreamingState(NamedTuple):
stream_id: str | None = None
class FrameStreamingState(NamedTuple):
"""Immutable state for public frame streaming execution."""
result_holder: list[Any]
sync_queue: queue.Queue[StreamFrame | None | Exception]
async_queue: asyncio.Queue[StreamFrame | None | Exception] | None
loop: asyncio.AbstractEventLoop | None
sink: Callable[[Any, BaseEvent], None]
def _extract_tool_call_info(
event: LLMStreamChunkEvent,
) -> tuple[StreamChunkType, ToolCallChunk | None]:
@@ -107,6 +130,207 @@ def _create_stream_chunk(
)
_FRAME_DATA_EXCLUDE = {
"timestamp",
"type",
"event_id",
"parent_event_id",
"previous_event_id",
"emission_sequence",
}
def _stream_channel(event: BaseEvent) -> StreamChannel:
if isinstance(event, LLMEventBase):
return "llm"
if isinstance(event, ConversationMessageAddedEvent):
return "messages"
if isinstance(event, FlowEvent):
return "flow"
if isinstance(event, ToolUsageEvent | ToolExecutionErrorEvent):
return "tools"
if "error" in event.type or "failed" in event.type:
return "lifecycle"
return "custom"
def _stream_namespace(event: BaseEvent, channel: StreamChannel) -> list[str]:
namespace: list[str] = [channel]
for attr in (
"flow_name",
"method_name",
"session_id",
"call_id",
"tool_name",
"agent_role",
"task_name",
):
value = getattr(event, attr, None)
if value is not None:
namespace.append(str(value))
return namespace
def stream_frame_from_event(event: BaseEvent) -> StreamFrame:
"""Convert an internal CrewAI event into the public stream frame contract."""
channel = _stream_channel(event)
data = event.to_json(exclude=_FRAME_DATA_EXCLUDE)
if not isinstance(data, dict):
data = {"value": data}
return StreamFrame(
id=event.event_id,
seq=event.emission_sequence,
type=event.type,
channel=channel,
namespace=_stream_namespace(event, channel),
timestamp=event.timestamp,
parent_id=event.parent_event_id,
previous_id=event.previous_event_id,
data=cast(dict[str, Any], data),
)
def _create_frame_sink(
sync_queue: queue.Queue[StreamFrame | None | Exception],
async_queue: asyncio.Queue[StreamFrame | None | Exception] | None = None,
loop: asyncio.AbstractEventLoop | None = None,
) -> Callable[[Any, BaseEvent], None]:
def frame_sink(_: Any, event: BaseEvent) -> None:
frame = stream_frame_from_event(event)
if async_queue is not None and loop is not None:
loop.call_soon_threadsafe(async_queue.put_nowait, frame)
else:
sync_queue.put(frame)
return frame_sink
def create_frame_streaming_state(
result_holder: list[Any],
use_async: bool = False,
) -> FrameStreamingState:
"""Create state for a scoped public frame stream."""
sync_queue: queue.Queue[StreamFrame | None | Exception] = queue.Queue()
async_queue: asyncio.Queue[StreamFrame | None | Exception] | None = None
loop: asyncio.AbstractEventLoop | None = None
if use_async:
async_queue = asyncio.Queue()
loop = asyncio.get_running_loop()
sink = _create_frame_sink(sync_queue, async_queue, loop)
return FrameStreamingState(
result_holder=result_holder,
sync_queue=sync_queue,
async_queue=async_queue,
loop=loop,
sink=sink,
)
def _signal_frame_end(state: FrameStreamingState, is_async: bool = False) -> None:
if is_async and state.async_queue is not None and state.loop is not None:
state.loop.call_soon_threadsafe(state.async_queue.put_nowait, None)
else:
state.sync_queue.put(None)
def _signal_frame_error(
state: FrameStreamingState, error: Exception, is_async: bool = False
) -> None:
if is_async and state.async_queue is not None and state.loop is not None:
state.loop.call_soon_threadsafe(state.async_queue.put_nowait, error)
else:
state.sync_queue.put(error)
def _finalize_frame_streaming(
state: FrameStreamingState,
stream_session: StreamSession[Any] | AsyncStreamSession[Any],
) -> None:
stream_session._on_cleanup = None
if state.result_holder:
stream_session._set_result(state.result_holder[0])
def create_frame_generator(
state: FrameStreamingState,
run_func: Callable[[], Any],
output_holder: list[StreamSession[Any]],
) -> Iterator[StreamFrame]:
"""Create a scoped synchronous public frame generator."""
def run_with_sink() -> None:
token = add_stream_sink(state.sink)
try:
result = run_func()
state.result_holder.append(result)
except Exception as e:
_signal_frame_error(state, e)
finally:
reset_stream_sinks(token)
_signal_frame_end(state)
ctx = contextvars.copy_context()
thread = threading.Thread(target=ctx.run, args=(run_with_sink,), daemon=True)
thread.start()
try:
while True:
item = state.sync_queue.get()
if item is None:
break
if isinstance(item, Exception):
raise item
yield item
finally:
thread.join()
if output_holder:
_finalize_frame_streaming(state, output_holder[0])
async def create_async_frame_generator(
state: FrameStreamingState,
run_coro: Callable[[], Any],
output_holder: list[AsyncStreamSession[Any]],
) -> AsyncIterator[StreamFrame]:
"""Create a scoped asynchronous public frame generator."""
if state.async_queue is None:
raise RuntimeError(
"Async queue not initialized. Use create_frame_streaming_state(use_async=True)."
)
async def run_with_sink() -> None:
token = add_stream_sink(state.sink)
try:
result = await run_coro()
state.result_holder.append(result)
except Exception as e:
_signal_frame_error(state, e, is_async=True)
finally:
reset_stream_sinks(token)
_signal_frame_end(state, is_async=True)
task = asyncio.create_task(run_with_sink())
try:
while True:
item = await state.async_queue.get()
if item is None:
break
if isinstance(item, Exception):
raise item
yield item
finally:
if not task.done():
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
except Exception:
logger.debug("Background frame streaming task failed", exc_info=True)
if output_holder:
_finalize_frame_streaming(state, output_holder[0])
def _create_stream_handler(
current_task_info: TaskInfo,
sync_queue: queue.Queue[StreamChunk | None | Exception],

View File

@@ -3,13 +3,14 @@
import os
import threading
from unittest import mock
from unittest.mock import MagicMock, patch
from unittest.mock import AsyncMock, MagicMock, patch
import warnings
from crewai.agents.crew_agent_executor import AgentFinish, CrewAgentExecutor
from crewai.constants import DEFAULT_LLM_MODEL
from crewai.events.event_bus import crewai_event_bus
from crewai.events.types.tool_usage_events import ToolUsageFinishedEvent
from crewai.experimental.agent_executor import AgentExecutor
from crewai.knowledge.knowledge import Knowledge
from crewai.knowledge.knowledge_config import KnowledgeConfig
from crewai.knowledge.source.base_knowledge_source import BaseKnowledgeSource
@@ -802,6 +803,97 @@ def test_agent_human_input():
assert output.strip().lower() == "hello"
def test_agent_default_executor_human_input():
from crewai.core.providers.human_input import SyncHumanInputProvider
agent = Agent(
role="test role",
goal="test goal",
backstory="test backstory",
)
task = Task(
agent=agent,
description="Say the word: Hi",
expected_output="The word: Hi",
human_input=True,
)
answers = iter(
[
AgentFinish(output="Hi", thought="", text="Hi"),
AgentFinish(output="Hello", thought="", text="Hello"),
]
)
feedback_responses = iter(["Don't say hi, say Hello instead!", ""])
def kickoff_side_effect(executor, *_args, **_kwargs):
executor.state.current_answer = next(answers)
executor.state.is_finished = True
with (
patch.object(
SyncHumanInputProvider,
"_prompt_input",
side_effect=lambda *_args, **_kwargs: next(feedback_responses),
) as mock_prompt_input,
patch.object(
AgentExecutor, "kickoff", autospec=True, side_effect=kickoff_side_effect
) as mock_kickoff,
):
output = agent.execute_task(task)
assert output == "Hello"
assert mock_prompt_input.call_count == 2
assert mock_kickoff.call_count == 2
@pytest.mark.asyncio
async def test_agent_default_executor_async_human_input():
from crewai.core.providers.human_input import SyncHumanInputProvider
agent = Agent(
role="test role",
goal="test goal",
backstory="test backstory",
)
task = Task(
agent=agent,
description="Say the word: Hi",
expected_output="The word: Hi",
human_input=True,
)
answers = iter(
[
AgentFinish(output="Hi", thought="", text="Hi"),
AgentFinish(output="Hello", thought="", text="Hello"),
]
)
feedback_responses = iter(["Don't say hi, say Hello instead!", ""])
async def kickoff_side_effect(executor, *_args, **_kwargs):
executor.state.current_answer = next(answers)
executor.state.is_finished = True
with (
patch.object(
SyncHumanInputProvider,
"_prompt_input_async",
new_callable=AsyncMock,
side_effect=lambda *_args, **_kwargs: next(feedback_responses),
) as mock_prompt_input,
patch.object(
AgentExecutor,
"kickoff_async",
autospec=True,
side_effect=kickoff_side_effect,
) as mock_kickoff,
):
output = await agent.aexecute_task(task)
assert output == "Hello"
assert mock_prompt_input.await_count == 2
assert mock_kickoff.await_count == 2
def test_interpolate_inputs():
agent = Agent(
role="{topic} specialist",
@@ -2243,6 +2335,27 @@ def test_agent_from_repository_override_attributes(mock_get_agent, mock_get_auth
assert isinstance(agent.tools[0], SerperDevTool)
@patch("crewai.plus_api.PlusAPI.get_agent")
def test_agent_from_repository_ignores_empty_skills(
mock_get_agent, mock_get_auth_token
):
mock_get_response = MagicMock()
mock_get_response.status_code = 200
mock_get_response.json.return_value = {
"role": "test role",
"goal": "test goal",
"backstory": "test backstory",
"tools": [],
"skills": [],
}
mock_get_agent.return_value = mock_get_response
agent = Agent(from_repository="test_agent")
assert agent.role == "test role"
assert agent.skills is None
@patch("crewai.plus_api.PlusAPI.get_agent")
def test_agent_from_repository_with_invalid_tools(mock_get_agent, mock_get_auth_token):
mock_get_response = MagicMock()
@@ -2482,6 +2595,59 @@ def test_agent_without_apps_no_platform_tools():
assert tools == []
def test_get_platform_tools_warns_when_apps_resolve_to_zero_tools(monkeypatch):
"""A non-empty `apps` that resolves to no tools must warn loudly."""
import crewai_tools
monkeypatch.setattr(crewai_tools, "CrewaiPlatformTools", lambda apps: [])
agent = Agent(
role="Empty Apps Agent",
goal="Use apps",
backstory="b",
apps=["gmail", "slack"],
)
with pytest.warns(UserWarning, match="resolved to any tools"):
result = agent.get_platform_tools(agent.apps)
assert result == []
def test_get_platform_tools_quiet_when_tools_resolve(monkeypatch):
"""No warning when the apps resolve to at least one tool."""
from crewai.tools import tool
@tool
def platform_tool() -> str:
"""A resolved platform tool."""
return "ok"
import crewai_tools
monkeypatch.setattr(crewai_tools, "CrewaiPlatformTools", lambda apps: [platform_tool])
agent = Agent(role="A", goal="g", backstory="b", apps=["gmail"])
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
result = agent.get_platform_tools(agent.apps)
assert result == [platform_tool]
assert not any("resolved to any tools" in str(w.message) for w in caught)
def test_crew_prepare_tools_warns_on_empty_apps(monkeypatch):
"""The warning also fires through the crew tool-injection path."""
import crewai_tools
monkeypatch.setattr(crewai_tools, "CrewaiPlatformTools", lambda apps: [])
agent = Agent(role="A", goal="g", backstory="b", apps=["gmail"])
task = Task(description="d", expected_output="o", agent=agent)
crew = Crew(agents=[agent], tasks=[task])
with pytest.warns(UserWarning, match="resolved to any tools"):
crew._prepare_tools(agent, task, [])
def test_agent_mcps_accepts_slug_with_specific_tool():
"""Agent(mcps=["notion#get_page"]) must pass validation (_SLUG_RE)."""
agent = Agent(

View File

@@ -18,6 +18,7 @@ import pytest
from pydantic import BaseModel
from crewai.agents.tools_handler import ToolsHandler as _ToolsHandler
from crewai.core.providers.human_input import SyncHumanInputProvider
from crewai.agents.step_executor import StepExecutor
@@ -27,6 +28,13 @@ def _build_executor(**kwargs: Any) -> AgentExecutor:
Uses model_construct to skip Pydantic validators so plain Mock()
objects are accepted for typed fields like llm, agent, crew, task.
"""
prompt = kwargs.get("prompt")
if isinstance(prompt, dict):
if "system" in prompt:
kwargs["prompt"] = SystemPromptResult(**prompt)
else:
kwargs["prompt"] = StandardPromptResult(**prompt)
executor = AgentExecutor.model_construct(**kwargs)
executor._state = AgentExecutorState()
executor._methods = {}
@@ -50,6 +58,7 @@ def _build_executor(**kwargs: Any) -> AgentExecutor:
executor._last_context_error = None
executor._step_executor = None
executor._planner_observer = None
executor._is_feedback_iteration = False
return executor
from crewai.agents.planner_observer import PlannerObserver
from crewai.experimental.agent_executor import (
@@ -68,7 +77,8 @@ from crewai.events.types.tool_usage_events import (
)
from crewai.tools.tool_types import ToolResult
from crewai.utilities.step_execution_context import StepExecutionContext
from crewai.utilities.planning_types import TodoItem
from crewai.utilities.planning_types import TodoItem, TodoList
from crewai.utilities.prompts import StandardPromptResult, SystemPromptResult
from crewai.utilities.file_store import clear_files, clear_task_files, store_files
from crewai_files import TextFile
@@ -119,6 +129,189 @@ class TestAgentExecutor:
class StructuredResult(BaseModel):
value: str
def test_setup_messages_calls_human_input_provider_hooks(self):
"""Message setup should preserve the HumanInputProvider hook contract."""
executor = _build_executor(
prompt=StandardPromptResult(prompt="Original task: {input}"),
)
provider = Mock()
provider.setup_messages.return_value = False
def post_setup(context: AgentExecutor) -> None:
context.messages.append(
{"role": "system", "content": "provider post setup"}
)
provider.post_setup_messages.side_effect = post_setup
with patch(
"crewai.experimental.agent_executor.get_provider", return_value=provider
):
executor._setup_messages(
{"input": "draft this", "tool_names": "", "tools": ""}
)
provider.setup_messages.assert_called_once_with(executor)
provider.post_setup_messages.assert_called_once_with(executor)
assert executor.state.messages[0]["role"] == "user"
assert executor.state.messages[0]["content"] == "Original task: draft this"
assert executor.state.messages[1] == {
"role": "system",
"content": "provider post setup",
}
def test_setup_messages_can_be_owned_by_human_input_provider(self):
"""Providers can skip standard prompt setup by returning True."""
executor = _build_executor(
prompt=StandardPromptResult(prompt="Original task: {input}"),
)
provider = Mock()
def setup(context: AgentExecutor) -> bool:
context.messages.append({"role": "user", "content": "provider message"})
return True
provider.setup_messages.side_effect = setup
with patch(
"crewai.experimental.agent_executor.get_provider", return_value=provider
):
executor._setup_messages(
{"input": "draft this", "tool_names": "", "tools": ""}
)
provider.setup_messages.assert_called_once_with(executor)
provider.post_setup_messages.assert_not_called()
assert executor.state.messages == [
{"role": "user", "content": "provider message"}
]
def test_human_feedback_reruns_flow_with_state_messages(self):
"""Human feedback should use AgentExecutor state messages."""
executor = _build_executor(agent=SimpleNamespace(verbose=False), crew=None)
executor.state.messages = [{"role": "user", "content": "original task"}]
executor.state.current_answer = AgentFinish(
thought="", output="draft", text="draft"
)
executor.state.is_finished = True
executor._finalize_called = True
executor.ask_for_human_input = True
executor.state.iterations = executor.max_iter
executor.state.plan = "completed plan"
executor.state.plan_ready = True
executor.state.todos = TodoList(
items=[TodoItem(step_number=1, description="Done", status="completed")]
)
improved_answer = AgentFinish(thought="", output="improved", text="improved")
feedback_responses = iter(["make it friendlier", ""])
def finish_feedback_iteration(*_args: Any, **_kwargs: Any) -> None:
assert executor._is_feedback_iteration is True
assert executor.state.iterations == 0
assert executor.state.plan is None
assert executor.state.todos.items == []
executor.state.current_answer = improved_answer
executor.state.is_finished = True
with (
patch.object(
SyncHumanInputProvider,
"_prompt_input",
side_effect=lambda *_args, **_kwargs: next(feedback_responses),
) as mock_prompt_input,
patch.object(
AgentExecutor, "kickoff", side_effect=finish_feedback_iteration
) as mock_kickoff,
):
result = executor._handle_human_feedback(
AgentFinish(thought="", output="draft", text="draft")
)
assert result is improved_answer
assert mock_prompt_input.call_count == 2
mock_kickoff.assert_called_once()
assert executor.messages is executor.state.messages
assert "make it friendlier" in executor.state.messages[-1]["content"]
assert executor.ask_for_human_input is False
assert executor.state.current_answer is improved_answer
assert executor.state.is_finished is True
assert executor._finalize_called is True
assert executor._is_feedback_iteration is False
@pytest.mark.asyncio
async def test_async_human_feedback_reruns_flow_with_state_messages(self):
"""Async human feedback should use AgentExecutor state messages."""
executor = _build_executor(agent=SimpleNamespace(verbose=False), crew=None)
executor.state.messages = [{"role": "user", "content": "original task"}]
executor.state.current_answer = AgentFinish(
thought="", output="draft", text="draft"
)
executor.state.is_finished = True
executor._finalize_called = True
executor.ask_for_human_input = True
executor.state.iterations = executor.max_iter
executor.state.plan = "completed plan"
executor.state.plan_ready = True
executor.state.todos = TodoList(
items=[TodoItem(step_number=1, description="Done", status="completed")]
)
improved_answer = AgentFinish(thought="", output="improved", text="improved")
feedback_responses = iter(["make it friendlier", ""])
async def finish_feedback_iteration(*_args: Any, **_kwargs: Any) -> None:
assert executor._is_feedback_iteration is True
assert executor.state.iterations == 0
assert executor.state.plan is None
assert executor.state.todos.items == []
executor.state.current_answer = improved_answer
executor.state.is_finished = True
with (
patch.object(
SyncHumanInputProvider,
"_prompt_input_async",
new_callable=AsyncMock,
side_effect=lambda *_args, **_kwargs: next(feedback_responses),
) as mock_prompt_input,
patch.object(
AgentExecutor,
"kickoff_async",
new_callable=AsyncMock,
side_effect=finish_feedback_iteration,
) as mock_kickoff,
):
result = await executor._ahandle_human_feedback(
AgentFinish(thought="", output="draft", text="draft")
)
assert result is improved_answer
assert mock_prompt_input.await_count == 2
mock_kickoff.assert_awaited_once()
assert executor.messages is executor.state.messages
assert "make it friendlier" in executor.state.messages[-1]["content"]
assert executor.ask_for_human_input is False
assert executor.state.current_answer is improved_answer
assert executor.state.is_finished is True
assert executor._finalize_called is True
assert executor._is_feedback_iteration is False
def test_feedback_iteration_skips_plan_generation(self):
"""Feedback reruns should reason over feedback without regenerating a plan."""
executor = _build_executor(
agent=SimpleNamespace(planning_enabled=True, verbose=False),
task=SimpleNamespace(),
)
executor._is_feedback_iteration = True
with patch("crewai.utilities.reasoning_handler.AgentReasoning") as reasoning:
executor.generate_plan()
reasoning.assert_not_called()
assert executor.state.plan is None
assert executor.state.todos.items == []
def test_inject_files_from_crew_task_store(self):
"""Crew-level input_files should attach to the LLM user message."""
crew_id = uuid4()

View File

@@ -24,7 +24,7 @@ SAMPLE_REPOS = [
]
def _make_zipball(files: dict[str, str], top_dir: str = "crewAIInc-template_test-abc123") -> bytes:
def _make_zipball(files: dict[str, str], top_dir: str = "crewAIInc-fde-template_test-abc123") -> bytes:
"""Create an in-memory zipball mimicking GitHub's format."""
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w") as zf:

View File

@@ -1,8 +1,6 @@
import os
import unittest
from unittest.mock import ANY, AsyncMock, MagicMock, patch
import pytest
from unittest.mock import ANY, MagicMock, patch
from crewai.plus_api import PlusAPI
@@ -396,28 +394,23 @@ class TestPlusAPI(unittest.TestCase):
)
@pytest.mark.asyncio
@patch("httpx.AsyncClient")
async def test_get_agent(mock_async_client_class):
@patch("crewai_core.plus_api.PlusAPI._make_request")
def test_get_agent(mock_make_request):
api = PlusAPI("test_api_key")
mock_response = MagicMock()
mock_client_instance = AsyncMock()
mock_client_instance.get.return_value = mock_response
mock_async_client_class.return_value.__aenter__.return_value = mock_client_instance
mock_make_request.return_value = mock_response
response = await api.get_agent("test_agent_handle")
response = api.get_agent("test_agent_handle")
mock_client_instance.get.assert_called_once_with(
f"{api.base_url}/crewai_plus/api/v1/agents/test_agent_handle",
headers=api.headers,
mock_make_request.assert_called_once_with(
"GET", "/crewai_plus/api/v1/agents/test_agent_handle"
)
assert response == mock_response
@pytest.mark.asyncio
@patch("httpx.AsyncClient")
@patch("crewai_core.plus_api.PlusAPI._make_request")
@patch("crewai_core.plus_api.Settings")
async def test_get_agent_with_org_uuid(mock_settings_class, mock_async_client_class):
def test_get_agent_with_org_uuid(mock_settings_class, mock_make_request):
org_uuid = "test-org-uuid"
mock_settings = MagicMock()
mock_settings.org_uuid = org_uuid
@@ -427,15 +420,12 @@ async def test_get_agent_with_org_uuid(mock_settings_class, mock_async_client_cl
api = PlusAPI("test_api_key")
mock_response = MagicMock()
mock_client_instance = AsyncMock()
mock_client_instance.get.return_value = mock_response
mock_async_client_class.return_value.__aenter__.return_value = mock_client_instance
mock_make_request.return_value = mock_response
response = await api.get_agent("test_agent_handle")
response = api.get_agent("test_agent_handle")
mock_client_instance.get.assert_called_once_with(
f"{api.base_url}/crewai_plus/api/v1/agents/test_agent_handle",
headers=api.headers,
mock_make_request.assert_called_once_with(
"GET", "/crewai_plus/api/v1/agents/test_agent_handle"
)
assert "X-Crewai-Organization-Id" in api.headers
assert api.headers["X-Crewai-Organization-Id"] == org_uuid

View File

@@ -355,6 +355,24 @@ class TestLoadAgent:
with pytest.raises(Exception):
load_agent(agent_file)
@pytest.mark.parametrize("field", ["role", "goal", "backstory"])
def test_load_agent_rejects_null_required_fields(
self, tmp_path: Path, field: str
):
agent_def = {
"role": "Researcher",
"goal": "Find information",
"backstory": "Expert researcher.",
}
agent_def[field] = None
agent_file = tmp_path / "agent.json"
agent_file.write_text(json.dumps(agent_def))
with pytest.raises(
JSONProjectValidationError, match=f"missing required field '{field}'"
):
load_agent(agent_file)
def test_load_agent_file_not_found(self):
with pytest.raises(FileNotFoundError):
load_agent(Path("/nonexistent/agent.json"))

View File

@@ -4,8 +4,13 @@ from pathlib import Path
import pytest
from crewai import Agent
from crewai.skills.loader import activate_skill, discover_skills, format_skill_context
from crewai import Agent, Crew, Task
from crewai.crews.utils import _resolve_crew_skills
from crewai.skills.loader import (
activate_skill,
discover_skills,
format_skill_context,
)
from crewai.skills.models import INSTRUCTIONS, METADATA
from crewai.utilities.prompts import Prompts
@@ -100,3 +105,104 @@ class TestSkillDiscoveryAndActivation:
assert "Skill travel" in system
# METADATA-level skills must not leak full instructions into the prompt
assert "Use this skill for travel planning." not in system
def test_agent_accepts_inline_skill_string(self) -> None:
agent = Agent(
role="Reviewer",
goal="Review changes.",
backstory="An experienced reviewer.",
skills=[
"---\n"
"name: inline-review\n"
"description: Inline review guidance\n"
"---\n"
"Focus on behavior and missing tests."
],
)
assert agent.skills is not None
assert [skill.name for skill in agent.skills] == ["inline-review"]
assert [skill.disclosure_level for skill in agent.skills] == [INSTRUCTIONS]
assert [skill.instructions for skill in agent.skills] == [
"Focus on behavior and missing tests."
]
result = Prompts(agent=agent, has_tools=False, use_system_prompt=True).task_execution()
system = getattr(result, "system", "") or result.prompt
assert '<skill name="inline-review">' in system
assert "Focus on behavior and missing tests." in system
def test_agent_treats_plain_skill_string_as_path(self, tmp_path: Path) -> None:
_create_skill_dir(tmp_path, "path-skill", body="Use the path skill.")
agent = Agent(
role="Reviewer",
goal="Review changes.",
backstory="An experienced reviewer.",
skills=[str(tmp_path)],
)
assert agent.skills is not None
assert [skill.name for skill in agent.skills] == ["path-skill"]
assert [skill.instructions for skill in agent.skills] == ["Use the path skill."]
def test_crew_resolves_inline_skill_string(self) -> None:
agent = Agent(
role="Reviewer",
goal="Review changes.",
backstory="An experienced reviewer.",
)
task = Task(
description="Review the diff.",
expected_output="Findings.",
agent=agent,
)
crew = Crew(
agents=[agent],
tasks=[task],
skills=[
"---\n"
"name: crew-inline-review\n"
"description: Crew-level inline review guidance\n"
"---\n"
"Apply this to every agent."
],
)
skills = _resolve_crew_skills(crew)
assert skills is not None
assert [skill.name for skill in skills] == ["crew-inline-review"]
assert [skill.instructions for skill in skills] == ["Apply this to every agent."]
def test_crew_activates_preloaded_metadata_skill(self, tmp_path: Path) -> None:
_create_skill_dir(
tmp_path,
"crew-preloaded",
body="Apply this crew-level guidance to every agent.",
)
metadata_skill = discover_skills(tmp_path)[0]
agent = Agent(
role="Reviewer",
goal="Review changes.",
backstory="An experienced reviewer.",
)
task = Task(
description="Review the diff.",
expected_output="Findings.",
agent=agent,
)
crew = Crew(
agents=[agent],
tasks=[task],
skills=[metadata_skill],
)
skills = _resolve_crew_skills(crew)
assert skills is not None
assert [skill.name for skill in skills] == ["crew-preloaded"]
assert [skill.disclosure_level for skill in skills] == [INSTRUCTIONS]
assert [skill.instructions for skill in skills] == [
"Apply this crew-level guidance to every agent."
]

View File

@@ -9,9 +9,11 @@ from crewai.skills.loader import (
discover_skills,
format_skill_context,
load_resources,
load_skill,
load_skills,
)
from crewai.skills.models import INSTRUCTIONS, METADATA, RESOURCES, Skill, SkillFrontmatter
from crewai.skills.parser import load_skill_metadata
from crewai.skills.parser import SkillParseError, load_skill_metadata
def _create_skill_dir(parent: Path, name: str, body: str = "Body.") -> Path:
@@ -84,6 +86,126 @@ class TestActivateSkill:
assert again is activated
class TestLoadSkill:
"""Tests for load_skill."""
@pytest.mark.parametrize("as_string", [False, True])
def test_loads_path_input(self, tmp_path: Path, as_string: bool) -> None:
_create_skill_dir(tmp_path, "first-skill", body="First.")
_create_skill_dir(tmp_path, "second-skill", body="Second.")
path = str(tmp_path) if as_string else tmp_path
skills = load_skill(path)
assert [skill.name for skill in skills] == ["first-skill", "second-skill"]
assert [skill.disclosure_level for skill in skills] == [
INSTRUCTIONS,
INSTRUCTIONS,
]
assert [skill.instructions for skill in skills] == ["First.", "Second."]
def test_loads_preloaded_skill(self, tmp_path: Path) -> None:
preloaded = Skill(
frontmatter=SkillFrontmatter(
name="preloaded-skill",
description="Preloaded skill",
),
path=tmp_path / "preloaded-skill",
)
skills = load_skill(preloaded)
assert skills == [preloaded]
def test_loads_inline_skill(self) -> None:
inline_skill = (
"---\n"
"name: inline-skill\n"
"description: Inline guidance\n"
"---\n"
"Follow these instructions."
)
skills = load_skill(inline_skill)
assert [skill.name for skill in skills] == ["inline-skill"]
assert [skill.disclosure_level for skill in skills] == [INSTRUCTIONS]
assert [skill.instructions for skill in skills] == [
"Follow these instructions."
]
def test_invalid_inline_skill_raises_parse_error(self) -> None:
with pytest.raises(SkillParseError, match="missing closing"):
load_skill("---\nname: inline-skill\n")
def test_missing_path_raises_file_not_found(self, tmp_path: Path) -> None:
with pytest.raises(FileNotFoundError):
load_skill(tmp_path / "missing")
def test_unsupported_input_raises_type_error(self) -> None:
with pytest.raises(TypeError, match="Unsupported skill input"):
load_skill(object()) # type: ignore[arg-type]
def test_load_skills_deduplicates_by_name(self, tmp_path: Path) -> None:
first = Skill(
frontmatter=SkillFrontmatter(
name="duplicate-skill",
description="First skill",
),
path=tmp_path / "first",
)
second = Skill(
frontmatter=SkillFrontmatter(
name="duplicate-skill",
description="Second skill",
),
path=tmp_path / "second",
)
skills = load_skills([first, second])
assert skills == [first]
def test_load_skills_keeps_registry_refs_from_different_orgs(
self,
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
first = Skill(
frontmatter=SkillFrontmatter(
name="shared-skill",
description="First registry skill",
),
path=tmp_path / "first",
disclosure_level=INSTRUCTIONS,
instructions="First instructions.",
)
second = Skill(
frontmatter=SkillFrontmatter(
name="shared-skill",
description="Second registry skill",
),
path=tmp_path / "second",
disclosure_level=INSTRUCTIONS,
instructions="Second instructions.",
)
def resolve_registry_ref(ref: str, source: object = None) -> Skill:
return {
"@first/shared-skill": first,
"@second/shared-skill": second,
}[ref]
monkeypatch.setattr(
"crewai.experimental.skills.registry.resolve_registry_ref",
resolve_registry_ref,
)
skills = load_skills(["@first/shared-skill", "@second/shared-skill"])
assert skills == [first, second]
class TestLoadResources:
"""Tests for load_resources."""

View File

@@ -96,6 +96,32 @@ def test_flow_execution_span_records_crewai_version():
span.set_attribute.assert_any_call("flow_name", "ResearchFlow")
def test_flow_creation_span_records_crewai_version():
tracer = Mock()
span = Mock()
tracer.start_span.return_value = span
with (
patch.dict(
os.environ,
{
"CREWAI_DISABLE_TELEMETRY": "false",
"CREWAI_DISABLE_TRACKING": "false",
"OTEL_SDK_DISABLED": "false",
},
),
patch("crewai.telemetry.telemetry.TracerProvider"),
patch("crewai.telemetry.telemetry.trace.get_tracer", return_value=tracer),
patch("crewai.telemetry.telemetry.version", return_value="9.9.9"),
):
telemetry = Telemetry()
telemetry.flow_creation_span("ResearchFlow")
tracer.start_span.assert_called_once_with("Flow Creation")
span.set_attribute.assert_any_call("crewai_version", "9.9.9")
span.set_attribute.assert_any_call("flow_name", "ResearchFlow")
@patch("crewai.telemetry.telemetry.logger.error")
@patch(
"opentelemetry.exporter.otlp.proto.http.trace_exporter.OTLPSpanExporter.export",

View File

@@ -2908,12 +2908,6 @@ def test_manager_agent_with_tools_raises_exception(researcher, writer):
crew.kickoff()
@pytest.mark.xfail(
strict=True,
reason="crew.train() relies on CrewAgentExecutor._format_feedback_message; "
"AgentExecutor (the new default) does not implement training feedback yet. "
"Remove this xfail once training is migrated to AgentExecutor.",
)
@pytest.mark.vcr()
def test_crew_train_success(researcher, writer, monkeypatch):
task = Task(

View File

@@ -2144,14 +2144,7 @@ def test_cyclic_flow_works_with_persist_and_id_input():
@pytest.mark.timeout(5)
def test_self_listening_method_does_not_loop():
"""A method whose @listen label matches its own name must not loop forever.
Without the guard, 'process' re-triggers itself on every completion,
running indefinitely (timeout → FAIL). The fix caps method calls
and raises RecursionError (PASS).
"""
def test_self_listening_method_is_rejected():
class SelfListenFlow(Flow):
@start()
def begin(self):
@@ -2165,15 +2158,11 @@ def test_self_listening_method_does_not_loop():
def process(self):
pass
flow = SelfListenFlow()
with pytest.raises(RecursionError, match="infinite loop"):
flow.kickoff()
with pytest.raises(ValueError, match="methods.process.listen"):
SelfListenFlow.flow_definition()
def test_or_condition_self_listen_fires_once():
"""or_() with a self-referencing label only fires once due to or_() guard."""
call_count = 0
def test_or_condition_self_listen_is_rejected():
class OrSelfListenFlow(Flow):
@start()
def begin(self):
@@ -2185,12 +2174,25 @@ def test_or_condition_self_listen_fires_once():
@listen(or_("other_trigger", "process"))
def process(self):
nonlocal call_count
call_count += 1
pass
with pytest.raises(ValueError, match="methods.process.listen"):
OrSelfListenFlow.flow_definition()
def test_router_self_listening_method_is_rejected():
class RouterSelfListenFlow(Flow):
@start()
def begin(self):
return "route"
@router("route")
def route(self):
return "done"
with pytest.raises(ValueError, match="methods.route.listen"):
RouterSelfListenFlow.flow_definition()
flow = OrSelfListenFlow()
flow.kickoff()
assert call_count == 1
class ListState(BaseModel):
items: list = []

View File

@@ -2,7 +2,7 @@
from __future__ import annotations
from typing import Any, Literal
from typing import Any, ClassVar, Literal
from unittest.mock import MagicMock, patch
from uuid import uuid4
@@ -21,7 +21,7 @@ from crewai.events.types.flow_events import (
MethodExecutionFinishedEvent,
MethodExecutionStartedEvent,
)
from crewai.events.types.llm_events import LLMCallStartedEvent
from crewai.events.types.llm_events import LLMCallStartedEvent, LLMStreamChunkEvent
from crewai.experimental import (
ConversationConfig,
ConversationMessage,
@@ -29,11 +29,13 @@ from crewai.experimental import (
RouterConfig,
)
from crewai.flow import Flow, ChatState, listen, start
from crewai.flow.async_feedback import HumanFeedbackPending, PendingFeedbackContext
from crewai.flow.flow_context import (
current_flow_defer_trace_finalization,
current_flow_id,
current_flow_name,
)
from crewai.llms.base_llm import BaseLLM
from crewai.flow.conversation import (
append_message,
get_conversation_messages,
@@ -137,6 +139,109 @@ class TestClassifyIntent:
class TestConversationalFlow:
def test_stream_turn_emits_ordered_conversation_frames(self) -> None:
flow = ConversationalFlow()
flow.stream = True
stream_values_seen_by_kickoff: list[bool] = []
def kickoff_side_effect(*_: Any, **__: Any) -> str:
stream_values_seen_by_kickoff.append(flow.stream)
crewai_event_bus.emit(
flow,
LLMStreamChunkEvent(
type="llm_stream_chunk",
chunk="pong",
call_id="call-1",
),
)
return "pong"
with patch.object(flow, "kickoff", side_effect=kickoff_side_effect):
stream = flow.stream_turn("ping", session_id="session-1")
with pytest.raises(RuntimeError, match="Streaming has not completed yet"):
_ = stream.result
frames = list(stream.events)
assert stream.result == "pong"
assert stream_values_seen_by_kickoff == [False]
assert flow.stream is True
assert [frame.seq for frame in frames] == sorted(frame.seq for frame in frames)
assert [frame.type for frame in frames] == [
"conversation_turn_started",
"llm_stream_chunk",
"conversation_message_added",
"conversation_turn_completed",
]
assert [frame.channel for frame in frames] == [
"flow",
"llm",
"messages",
"flow",
]
assert frames[1].data["chunk"] == "pong"
assert flow.state.messages[-1].content == "pong"
def test_stream_turn_enables_streaming_on_conversation_llm(self) -> None:
class FakeLLM(BaseLLM):
stream_values: ClassVar[list[bool | None]] = []
def call(self, messages: Any, *args: Any, **kwargs: Any) -> str:
self.stream_values.append(self._effective_stream())
for chunk in ("po", "ng"):
crewai_event_bus.emit(
flow,
LLMStreamChunkEvent(
type="llm_stream_chunk",
chunk=chunk,
call_id="call-1",
),
)
return "pong"
FakeLLM.stream_values = []
llm = FakeLLM(model="gpt-4o-mini", stream=False)
@ConversationConfig(llm=llm)
class StreamingChatFlow(ConversationalFlow):
pass
flow = StreamingChatFlow()
stream = flow.stream_turn("ping", session_id="session-1")
frames = list(stream.events)
assert stream.result == "pong"
assert llm.stream_values == [True]
assert llm.stream is False
assert [
frame.data["chunk"]
for frame in frames
if frame.type == "llm_stream_chunk"
] == ["po", "ng"]
def test_stream_turn_returns_pending_feedback_without_failure_event(self) -> None:
flow = ConversationalFlow()
pending = HumanFeedbackPending(
context=PendingFeedbackContext(
flow_id="session-1",
flow_class="tests.PendingFeedbackFlow",
method_name="review",
method_output="draft",
message="Please review",
)
)
def kickoff_side_effect(*_: Any, **__: Any) -> None:
raise pending
with patch.object(flow, "kickoff", side_effect=kickoff_side_effect):
stream = flow.stream_turn("review this", session_id="session-1")
frames = list(stream.events)
assert stream.result is pending
assert [frame.type for frame in frames] == ["conversation_turn_started"]
def test_deferred_multi_turn_emits_single_flow_finished(self) -> None:
"""A deferred multi-turn session lands as one trace: exactly one
``FlowFinishedEvent`` is emitted at ``finalize_session_traces()``, not

View File

@@ -14,6 +14,10 @@ import crewai.flow.dsl as flow_dsl
import crewai.flow.flow_definition as flow_definition
import crewai.flow.visualization.builder as visualization_builder
from crewai.experimental import ConversationConfig, RouterConfig
from crewai.flow.expressions import (
FLOW_TEMPLATE_EXPRESSION_EXAMPLES,
FLOW_TEMPLATE_EXPRESSION_RULES,
)
from crewai.flow import Flow, and_, human_feedback, listen, or_, persist, router, start
@@ -82,8 +86,17 @@ def test_flow_definition_json_schema_carries_reference_descriptions():
assert "not sandboxed" in script_properties["code"]["description"]
agent_properties = defs["FlowAgentActionDefinition"]["properties"]
assert "Inline Agent definition" in agent_properties["with"]["description"]
assert "run an inline Agent" in agent_properties["call"]["description"]
assert "Individual Agent definition" in agent_properties["with"]["description"]
assert "outside of a crew" in agent_properties["with"]["description"]
assert "individual inline Agent" in agent_properties["call"]["description"]
expression_rule = FLOW_TEMPLATE_EXPRESSION_RULES[0]
code_properties = defs["FlowCodeActionDefinition"]["properties"]
tool_properties = defs["FlowToolActionDefinition"]["properties"]
crew_properties = defs["FlowCrewActionDefinition"]["properties"]
assert expression_rule in code_properties["with"]["description"]
assert expression_rule in tool_properties["with"]["description"]
assert expression_rule in crew_properties["inputs"]["description"]
state_schema = next(
branch
@@ -154,14 +167,16 @@ def test_flow_definition_json_schema_carries_field_examples_only():
script_properties = defs["FlowScriptActionDefinition"]["properties"]
assert script_properties["call"]["examples"] == ["script"]
assert "input.strip()" in script_properties["code"]["examples"][0]
assert "state['topic'].strip()" in script_properties["code"]["examples"][0]
assert script_properties["language"]["examples"] == ["python"]
action_properties = defs["FlowCodeActionDefinition"]["properties"]
assert action_properties["ref"]["examples"] == [
"my_project.flows:normalize_topic"
]
assert action_properties["with"]["examples"] == [{"topic": "${state.topic}"}]
assert action_properties["with"]["examples"] == [
{"topic": "${state.topic}", "query": "News about ${state.topic}"}
]
agent_properties = defs["FlowAgentActionDefinition"]["properties"]
assert agent_properties["call"]["examples"] == ["agent"]
@@ -624,7 +639,7 @@ def test_flow_definition_from_declaration_accepts_json_and_yaml_strings():
return "left"
@listen("left")
def left(self):
def handle_left(self):
return "left"
expected = RoundTripFlow.flow_definition()
@@ -649,11 +664,11 @@ def test_flow_definition_from_declaration_accepts_json_and_yaml_strings():
"ref": "test_flow_definition:RoundTripFlow.decide"
}
},
"left": {
"handle_left": {
"listen": "left",
"do": {
"call": "code",
"ref": "test_flow_definition:RoundTripFlow.left"
"ref": "test_flow_definition:RoundTripFlow.handle_left"
}
}
}
@@ -674,11 +689,11 @@ def test_flow_definition_from_declaration_accepts_json_and_yaml_strings():
do:
call: code
ref: test_flow_definition:RoundTripFlow.decide
left:
handle_left:
listen: left
do:
call: code
ref: test_flow_definition:RoundTripFlow.left
ref: test_flow_definition:RoundTripFlow.handle_left
""",
]
@@ -945,11 +960,11 @@ def test_flow_definition_infers_literal_router_emit():
return "left"
@listen("left")
def left(self):
def handle_left(self):
return "left"
@listen("right")
def right(self):
def handle_right(self):
return "right"
definition = LiteralRouterFlow.flow_definition()
@@ -972,11 +987,11 @@ def test_flow_definition_infers_enum_router_emit():
return Decision.APPROVE
@listen("approve")
def approve(self):
def handle_approve(self):
return "approve"
@listen("reject")
def reject(self):
def handle_reject(self):
return "reject"
definition = EnumRouterFlow.flow_definition()
@@ -995,11 +1010,11 @@ def test_flow_definition_infers_literal_union_router_emit():
return "left"
@listen("left")
def left(self):
def handle_left(self):
return "left"
@listen("right")
def right(self):
def handle_right(self):
return "right"
definition = LiteralUnionRouterFlow.flow_definition()
@@ -1053,7 +1068,7 @@ def test_flow_definition_does_not_infer_unannotated_router_body_emit():
return "left"
@listen("left")
def left(self):
def handle_left(self):
return "left"
definition = UnannotatedRouterFlow.flow_definition()
@@ -1072,11 +1087,11 @@ def test_flow_definition_accepts_explicit_router_events():
return self.state["dynamic_event"]
@listen("left")
def left(self):
def handle_left(self):
return "left"
@listen("right")
def right(self):
def handle_right(self):
return "right"
definition = ExplicitRouterFlow.flow_definition()
@@ -1148,7 +1163,7 @@ def test_router_human_feedback_preserves_existing_router_metadata():
return "approved"
@listen("approved")
def approved(self):
def handle_approved(self):
return "approved"
definition = RouterHumanFeedbackFlow.flow_definition()
@@ -1213,6 +1228,30 @@ def test_static_string_listener_is_allowed_by_contract():
assert definition.methods["handle"].listen == "begni"
@pytest.mark.parametrize("listen", ["publish", {"or": ["publish", "revise"]}])
@pytest.mark.parametrize("router_enabled", [False, True])
def test_flow_definition_rejects_method_self_listen(listen, router_enabled):
with pytest.raises(ValueError, match="methods.publish.listen"):
flow_definition.FlowDefinition.from_declaration(contents=
{
"schema": "crewai.flow/v1",
"name": "SelfListenFlow",
"methods": {
"begin": {
"do": {"ref": "loaded_flows:SelfListenFlow.begin"},
"start": True,
},
"publish": {
"do": {"ref": "loaded_flows:SelfListenFlow.publish"},
"listen": listen,
"router": router_enabled,
"emit": ["done"] if router_enabled else None,
},
},
}
)
def test_start_false_not_classified_as_start_method():
definition = flow_definition.FlowDefinition.from_declaration(contents=
{
@@ -1300,3 +1339,68 @@ def test_flow_definition_allows_router_without_trigger(caplog):
StandaloneRouterFlow.flow_definition()
assert not caplog.records
def test_skill_documents_flow_wiring():
skill = flow_definition.FlowDefinition.skill()
assert isinstance(skill, str)
assert "```yaml" in skill
assert "[Method](#method-methods)" in skill
assert 'input: "Reviewed research: ${outputs.research_brief.raw}"' in skill
assert 'text(root, "path", "default")' in skill
assert "trust CrewAI defaults and omit them" in skill
assert "#### LLM Definition" in skill
assert "`max_tokens` (optional): integer | null; default `null`" in skill
assert "CrewAI does not set an explicit output token cap" in skill
assert "`planning_config` (optional): object | null; default `null`" in skill
assert "Set `max_attempts` to limit planning refinement attempts" in skill
assert "`allow_delegation` (optional): boolean | null; default `null`" in skill
assert "`max_iter` (optional): integer | null; default `null`" in skill
assert "`max_rpm` (optional): integer | null; default `null`" in skill
assert "`max_execution_time` (optional): integer | null; default `null`" in skill
assert "Maximum execution time in seconds for an agent" in skill
for rule in FLOW_TEMPLATE_EXPRESSION_RULES:
assert rule in skill
for example in FLOW_TEMPLATE_EXPRESSION_EXAMPLES["yaml"]:
assert example["title"] in skill
assert example["code"] in skill
def test_skill_can_render_json_examples():
skill = flow_definition.FlowDefinition.skill(examples_format="json")
assert "```json" in skill
assert '"schema": "crewai.flow/v1"' in skill
for example in FLOW_TEMPLATE_EXPRESSION_EXAMPLES["json"]:
assert example["title"] in skill
assert example["code"] in skill
assert FLOW_TEMPLATE_EXPRESSION_EXAMPLES["yaml"][0]["code"] not in skill
assert "```yaml" not in skill
def test_skill_ignores_unknown_skips():
skill = flow_definition.FlowDefinition.skill(skips=["unknown"])
assert "[Method](#method-methods)" in skill
def test_skill_with_skips_is_shorter():
full = flow_definition.FlowDefinition.skill()
trimmed = flow_definition.FlowDefinition.skill(
skips=[
"each",
"hitl",
"persistence",
"expression_action",
"script_action",
"tool_action",
]
)
assert "[Method](#method-methods)" in trimmed
assert "call: expression" not in trimmed
assert "Prefer `call: expression`" not in trimmed
assert "call: script" not in trimmed
assert "call: tool" not in trimmed
assert len(trimmed) < len(full)

File diff suppressed because it is too large Load Diff

View File

@@ -138,4 +138,27 @@ class TestFlowHumanInputIntegration:
for call in call_args
if call[0]
)
assert training_panel_found
assert training_panel_found
@patch("builtins.input", return_value="please make it warmer")
def test_non_empty_input_prints_processing_feedback(self, mock_input):
"""Non-empty input should be displayed as feedback to process."""
provider = SyncHumanInputProvider()
crew = MagicMock()
crew._train = False
formatter = event_listener.formatter
with (
patch.object(formatter, "pause_live_updates"),
patch.object(formatter, "resume_live_updates"),
patch.object(formatter.console, "print") as mock_console_print,
):
result = provider._prompt_input(crew)
assert result == "please make it warmer"
mock_input.assert_called_once()
printed_text = "\n".join(
str(call.args[0]) for call in mock_console_print.call_args_list
)
assert "Processing your feedback" in printed_text

View File

@@ -0,0 +1,300 @@
"""Tests for the public stream frame contract."""
from __future__ import annotations
import asyncio
from collections.abc import AsyncIterator
from typing import Any, ClassVar
import pytest
from crewai.events.event_bus import crewai_event_bus
from crewai.events.types.flow_events import ConversationMessageAddedEvent
from crewai.events.types.llm_events import LLMStreamChunkEvent, LLMThinkingChunkEvent
from crewai.events.types.tool_usage_events import ToolUsageStartedEvent
from crewai.flow.flow import Flow, start
from crewai.llms.base_llm import BaseLLM, call_stop_override
from crewai.types.streaming import StreamFrame
class FrameFlow(Flow):
@start()
def run(self) -> str:
crewai_event_bus.emit(
self,
LLMStreamChunkEvent(
type="llm_stream_chunk",
chunk="hello",
call_id="call-1",
),
)
crewai_event_bus.emit(
self,
LLMThinkingChunkEvent(
type="llm_thinking_chunk",
chunk="thinking",
call_id="call-1",
),
)
crewai_event_bus.emit(
self,
ConversationMessageAddedEvent(
type="conversation_message_added",
flow_name=self._definition.name,
session_id="session-1",
role="assistant",
content="hello",
message_index=0,
),
)
crewai_event_bus.emit(
self,
ToolUsageStartedEvent(
type="tool_usage_started",
tool_name="search",
tool_args={"query": "crew"},
),
)
return "done"
class DirectStreamingLLM(BaseLLM):
call_stream_values: ClassVar[list[bool | None]] = []
raw_stream_values: ClassVar[list[bool | None]] = []
call_instance_ids: ClassVar[list[int]] = []
call_stop_values: ClassVar[list[list[str]]] = []
def call(self, messages: Any, *args: Any, **kwargs: Any) -> str:
self.call_stream_values.append(self._effective_stream())
self.raw_stream_values.append(self.stream)
self.call_instance_ids.append(id(self))
self.call_stop_values.append(list(self.stop_sequences))
self._track_token_usage_internal(
{
"prompt_tokens": 1,
"completion_tokens": 2,
"total_tokens": 3,
}
)
crewai_event_bus.emit(
self,
LLMStreamChunkEvent(
type="llm_stream_chunk",
chunk="hel",
call_id="call-1",
),
)
crewai_event_bus.emit(
self,
LLMStreamChunkEvent(
type="llm_stream_chunk",
chunk="lo",
call_id="call-1",
),
)
return "hello"
def test_stream_frame_contract_and_ordering() -> None:
stream = FrameFlow().stream_events()
with pytest.raises(RuntimeError, match="Streaming has not completed yet"):
_ = stream.result
with stream:
frames = list(stream.events)
assert stream.result == "done"
assert all(isinstance(frame, StreamFrame) for frame in frames)
assert [frame.seq for frame in frames] == sorted(frame.seq for frame in frames)
by_type = {frame.type: frame for frame in frames}
assert by_type["flow_started"].channel == "flow"
assert by_type["method_execution_started"].parent_id == by_type["flow_started"].id
assert by_type["llm_stream_chunk"].channel == "llm"
assert by_type["llm_thinking_chunk"].channel == "llm"
assert by_type["conversation_message_added"].channel == "messages"
assert by_type["tool_usage_started"].channel == "tools"
assert "FrameFlow" in by_type["method_execution_started"].namespace
assert "run" in by_type["method_execution_started"].namespace
def test_stream_subscribe_filters_channels_without_losing_order() -> None:
with FrameFlow().stream_events() as stream:
frames = list(stream.interleave(["messages", "tools"]))
assert [frame.channel for frame in frames] == ["messages", "tools"]
assert [frame.seq for frame in frames] == sorted(frame.seq for frame in frames)
assert stream.result == "done"
def test_stream_projections_replay_cached_frames_after_exhaustion() -> None:
with FrameFlow().stream_events() as stream:
all_frames = list(stream.events)
assert [frame.content for frame in stream.llm if frame.content] == [
"hello",
"thinking",
]
assert [frame.type for frame in stream.tools] == ["tool_usage_started"]
assert list(stream.events) == all_frames
def test_stream_channel_projection_can_be_followed_by_cached_projection() -> None:
with FrameFlow().stream_events() as stream:
llm_frames = list(stream.llm)
assert [frame.content for frame in llm_frames if frame.content] == [
"hello",
"thinking",
]
assert [frame.type for frame in stream.flow] == [
"flow_started",
"method_execution_started",
"method_execution_finished",
"flow_finished",
]
def test_stream_errors_surface_after_failed_frame() -> None:
class ErrorFlow(Flow):
@start()
def run(self) -> str:
raise ValueError("boom")
stream = ErrorFlow().stream_events()
with pytest.raises(ValueError, match="boom"):
list(stream.events)
assert any(frame.type == "method_execution_failed" for frame in stream.frames)
with pytest.raises(ValueError, match="boom"):
_ = stream.result
def test_flow_streaming_returns_iterable_frame_session() -> None:
flow = FrameFlow()
flow.stream = True
stream = flow.kickoff()
with stream:
frames = list(stream)
assert all(isinstance(frame, StreamFrame) for frame in frames)
assert [frame.content for frame in frames if frame.content] == [
"hello",
"thinking",
]
first_content_frame = next(frame for frame in frames if frame.content)
assert first_content_frame.event["chunk"] == "hello"
assert stream.result == "done"
def test_direct_llm_stream_events_scope_and_restore_stream_flag() -> None:
DirectStreamingLLM.call_stream_values = []
DirectStreamingLLM.raw_stream_values = []
DirectStreamingLLM.call_instance_ids = []
DirectStreamingLLM.call_stop_values = []
llm = DirectStreamingLLM(model="gpt-4o-mini", stream=False)
with call_stop_override(llm, ["STOP"]):
with llm.stream_events("hello") as stream:
frames = list(stream)
assert [frame.content for frame in frames] == ["hel", "lo"]
assert frames[0].event["chunk"] == "hel"
assert stream.result == "hello"
assert llm.stream is False
assert DirectStreamingLLM.call_stream_values == [True]
assert DirectStreamingLLM.raw_stream_values == [False]
assert DirectStreamingLLM.call_instance_ids == [id(llm)]
assert DirectStreamingLLM.call_stop_values == [["STOP"]]
usage = llm.get_token_usage_summary()
assert usage.total_tokens == 3
assert usage.prompt_tokens == 1
assert usage.completion_tokens == 2
assert usage.successful_requests == 1
@pytest.mark.asyncio
async def test_astream_scopes_concurrent_executions() -> None:
class ConcurrentFlow(Flow):
@start()
async def run(self) -> str:
label = str(self.state["label"])
await asyncio.sleep(0)
crewai_event_bus.emit(
self,
LLMStreamChunkEvent(
type="llm_stream_chunk",
chunk=label,
call_id=label,
),
)
return label
async def collect(label: str) -> tuple[str, list[str]]:
async with ConcurrentFlow().astream(inputs={"label": label}) as stream:
frames = [frame async for frame in stream.llm]
return stream.result, [frame.data["chunk"] for frame in frames]
first, second = await asyncio.gather(collect("first"), collect("second"))
assert first == ("first", ["first"])
assert second == ("second", ["second"])
@pytest.mark.asyncio
async def test_async_stream_projections_replay_cached_frames_after_exhaustion() -> None:
async with FrameFlow().astream() as stream:
all_frames = [frame async for frame in stream.events]
llm_frames = [frame async for frame in stream.llm]
tool_frames = [frame async for frame in stream.tools]
replayed_frames = [frame async for frame in stream.events]
assert [frame.content for frame in llm_frames if frame.content] == [
"hello",
"thinking",
]
assert [frame.type for frame in tool_frames] == ["tool_usage_started"]
assert replayed_frames == all_frames
@pytest.mark.asyncio
async def test_async_stream_channel_projection_can_be_followed_by_cached_projection() -> None:
async with FrameFlow().astream() as stream:
llm_frames = [frame async for frame in stream.llm]
flow_frames = [frame async for frame in stream.flow]
assert [frame.content for frame in llm_frames if frame.content] == [
"hello",
"thinking",
]
assert [frame.type for frame in flow_frames] == [
"flow_started",
"method_execution_started",
"method_execution_finished",
"flow_finished",
]
@pytest.mark.asyncio
async def test_astream_cancellation_cleans_up_task() -> None:
class SlowFlow(Flow):
@start()
async def run(self) -> str:
await asyncio.sleep(10)
return "too late"
stream = SlowFlow().astream()
events: AsyncIterator[StreamFrame] = stream.events
first_frame = await anext(events)
assert first_frame.type == "flow_started"
await stream.aclose()
assert stream.is_cancelled is True
assert stream.is_completed is True

View File

@@ -11,11 +11,15 @@ from crewai import Agent, Crew, Task
from crewai.events.event_bus import crewai_event_bus
from crewai.events.types.llm_events import LLMStreamChunkEvent, ToolCall, FunctionCall
from crewai.flow.flow import Flow, start
from crewai.state.checkpoint_config import CheckpointConfig
from crewai.types.streaming import (
AsyncStreamSession,
CrewStreamingOutput,
FlowStreamingOutput,
StreamChunk,
StreamChunkType,
StreamFrame,
StreamSession,
ToolCallChunk,
)
@@ -417,8 +421,8 @@ class TestCrewKickoffStreamingAsync:
class TestFlowKickoffStreaming:
"""Tests for Flow(stream=True).kickoff() method."""
def test_kickoff_streaming_returns_streaming_output(self) -> None:
"""Test that flow kickoff with stream=True returns FlowStreamingOutput."""
def test_kickoff_streaming_returns_stream_session(self) -> None:
"""Test that flow kickoff with stream=True returns StreamSession."""
class SimpleFlow(Flow[dict[str, Any]]):
@start()
@@ -428,7 +432,7 @@ class TestFlowKickoffStreaming:
flow = SimpleFlow()
flow.stream = True
streaming = flow.kickoff()
assert isinstance(streaming, FlowStreamingOutput)
assert isinstance(streaming, StreamSession)
def test_flow_kickoff_streaming_captures_chunks(self) -> None:
"""Test that flow streaming captures LLM chunks from crew execution."""
@@ -469,7 +473,7 @@ class TestFlowKickoffStreaming:
with patch.object(Flow, "kickoff", mock_kickoff_fn):
streaming = flow.kickoff()
assert isinstance(streaming, FlowStreamingOutput)
assert isinstance(streaming, StreamSession)
chunks = list(streaming)
assert len(chunks) >= 2
@@ -500,19 +504,38 @@ class TestFlowKickoffStreaming:
with patch.object(Flow, "kickoff", mock_kickoff_fn):
streaming = flow.kickoff()
assert isinstance(streaming, FlowStreamingOutput)
assert isinstance(streaming, StreamSession)
_ = list(streaming)
result = streaming.result
assert result == "flow result"
def test_streaming_kickoff_passes_checkpoint_config_to_stream_events(self) -> None:
"""stream=True preserves checkpoint config when routing to stream_events."""
class TestFlow(Flow[dict[str, Any]]):
@start()
def generate(self) -> str:
return "flow result"
flow = TestFlow()
flow.stream = True
checkpoint = CheckpointConfig()
with patch.object(flow, "stream_events", wraps=flow.stream_events) as spy:
streaming = flow.kickoff(from_checkpoint=checkpoint)
list(streaming)
assert spy.call_args.kwargs["from_checkpoint"] is checkpoint
assert streaming.result == "flow result"
class TestFlowKickoffStreamingAsync:
"""Tests for Flow(stream=True).kickoff_async() method."""
@pytest.mark.asyncio
async def test_kickoff_streaming_async_returns_streaming_output(self) -> None:
"""Test that flow kickoff_async with stream=True returns FlowStreamingOutput."""
async def test_kickoff_streaming_async_returns_stream_session(self) -> None:
"""Test that flow kickoff_async with stream=True returns AsyncStreamSession."""
class SimpleFlow(Flow[dict[str, Any]]):
@start()
@@ -522,7 +545,7 @@ class TestFlowKickoffStreamingAsync:
flow = SimpleFlow()
flow.stream = True
streaming = await flow.kickoff_async()
assert isinstance(streaming, FlowStreamingOutput)
assert isinstance(streaming, AsyncStreamSession)
@pytest.mark.asyncio
async def test_flow_kickoff_streaming_async_captures_chunks(self) -> None:
@@ -567,8 +590,8 @@ class TestFlowKickoffStreamingAsync:
with patch.object(Flow, "kickoff_async", mock_kickoff_fn):
streaming = await flow.kickoff_async()
assert isinstance(streaming, FlowStreamingOutput)
chunks: list[StreamChunk] = []
assert isinstance(streaming, AsyncStreamSession)
chunks: list[StreamFrame] = []
async for chunk in streaming:
chunks.append(chunk)
@@ -601,13 +624,36 @@ class TestFlowKickoffStreamingAsync:
with patch.object(Flow, "kickoff_async", mock_kickoff_fn):
streaming = await flow.kickoff_async()
assert isinstance(streaming, FlowStreamingOutput)
assert isinstance(streaming, AsyncStreamSession)
async for _ in streaming:
pass
result = streaming.result
assert result == "async flow result"
@pytest.mark.asyncio
async def test_streaming_kickoff_async_passes_checkpoint_config_to_astream(
self,
) -> None:
"""stream=True preserves checkpoint config when routing to astream."""
class TestFlow(Flow[dict[str, Any]]):
@start()
async def generate(self) -> str:
return "async flow result"
flow = TestFlow()
flow.stream = True
checkpoint = CheckpointConfig()
with patch.object(flow, "astream", wraps=flow.astream) as spy:
streaming = await flow.kickoff_async(from_checkpoint=checkpoint)
async for _ in streaming:
pass
assert spy.call_args.kwargs["from_checkpoint"] is checkpoint
assert streaming.result == "async flow result"
class TestStreamingEdgeCases:
"""Tests for edge cases in streaming functionality."""

View File

@@ -3,8 +3,10 @@
import pytest
from crewai import Agent, Crew, Task
from crewai.events.event_bus import crewai_event_bus
from crewai.events.types.llm_events import LLMStreamChunkEvent
from crewai.flow.flow import Flow, start
from crewai.types.streaming import CrewStreamingOutput, FlowStreamingOutput
from crewai.types.streaming import AsyncStreamSession, CrewStreamingOutput, StreamSession
@pytest.fixture
@@ -212,7 +214,7 @@ class TestStreamingFlowIntegration:
streaming = flow.kickoff()
assert isinstance(streaming, FlowStreamingOutput)
assert isinstance(streaming, StreamSession)
chunks = []
for chunk in streaming:
@@ -232,6 +234,14 @@ class TestStreamingFlowIntegration:
@start()
def execute(self) -> str:
crewai_event_bus.emit(
self,
LLMStreamChunkEvent(
type="llm_stream_chunk",
chunk="Flow result",
call_id="call-1",
),
)
return "Flow result"
flow = SimpleFlow()
@@ -241,8 +251,11 @@ class TestStreamingFlowIntegration:
pass
assert streaming.is_completed is True
streaming.get_full_text()
assert len(streaming.chunks) >= 0
content_frames = [frame for frame in streaming.frames if frame.content]
full_text = "".join(frame.content for frame in content_frames)
assert full_text == "Flow result"
assert len(content_frames) == 1
assert len(streaming.frames) > 0
result = streaming.result
assert result is not None
@@ -281,7 +294,7 @@ class TestStreamingFlowIntegration:
streaming = await flow.kickoff_async()
assert isinstance(streaming, FlowStreamingOutput)
assert isinstance(streaming, AsyncStreamSession)
chunks = []
async for chunk in streaming:

View File

@@ -9,6 +9,7 @@ import pytest
from crewai.events.base_events import BaseEvent
from crewai.events.event_bus import crewai_event_bus
from crewai.events.stream_context import add_stream_sink, reset_stream_sinks
class AsyncTestEvent(BaseEvent):
@@ -53,6 +54,24 @@ async def test_aemit_with_async_handlers():
assert received_events[0] == event
@pytest.mark.asyncio
async def test_aemit_publishes_to_active_stream_sinks():
published_events = []
def sink(source: object, event: BaseEvent) -> None:
published_events.append((source, event))
event = AsyncTestEvent(type="async_test")
token = add_stream_sink(sink)
try:
await crewai_event_bus.aemit("test_source", event)
finally:
reset_stream_sinks(token)
assert published_events == [("test_source", event)]
assert event.emission_sequence is not None
@pytest.mark.asyncio
async def test_multiple_async_handlers():
received_events_1 = []

View File

@@ -1,3 +1,3 @@
"""CrewAI development tools."""
__version__ = "1.15.1"
__version__ = "1.15.2a2"

View File

@@ -201,49 +201,46 @@ def _walk_pages(node: Any, transform: Callable[[str], str]) -> Any:
return node
def _is_version_slug(value: str) -> bool:
return bool(VERSION_SLUG_RE.match(value))
def _previous_default(versions: list[dict[str, Any]]) -> dict[str, Any] | None:
"""Return the entry currently marked default (or the first versioned)."""
def _edge_entry(versions: list[dict[str, Any]]) -> dict[str, Any] | None:
"""Return the Edge version entry, the rolling channel matching main HEAD."""
for v in versions:
if v.get("default") and _is_version_slug(v.get("version", "")):
return v
for v in versions:
if _is_version_slug(v.get("version", "")):
if v.get("version") == EDGE_VERSION:
return v
return None
def _build_new_entry(
previous: dict[str, Any], version_slug: str, locale: str, docs_root: Path
edge: dict[str, Any], version_slug: str, docs_root: Path
) -> dict[str, Any] | None:
"""Clone the previous default's nav into a new entry for ``version_slug``.
"""Clone the Edge nav into a new entry for ``version_slug``.
Page paths are rewritten from ``v<prev>/<locale>/...`` to
Freezing a release means promoting *Edge* (which tracks main HEAD) to the
new Latest, so the new version's navigation is cloned from the Edge entry
rather than from the previous frozen version. Cloning from the previous
version would silently drop every page that landed in Edge since the last
release (the file gets copied into the snapshot by ``_copy_snapshot`` but
never appears in the version selector) and would ignore any Edge nav
restructuring.
Page paths are rewritten from ``edge/<locale>/...`` to
``v<new>/<locale>/...``. Paths that don't resolve to a file in the
snapshot are pruned and the now-empty groups/tabs cascade away. Returns
``None`` if the locale has no resolvable content under the snapshot (e.g.
a locale that wasn't present in Edge yet).
freshly-copied snapshot are pruned and the now-empty groups/tabs cascade
away. Returns ``None`` if Edge has no resolvable content under the
snapshot.
"""
new_entry = copy.deepcopy(previous)
new_entry = copy.deepcopy(edge)
new_entry["version"] = version_slug
new_entry["default"] = True
new_entry["tag"] = LATEST_TAG
old_prefix = re.compile(rf"^{re.escape(previous['version'])}/")
locale_prefix = f"{locale}/"
edge_prefix = f"{EDGE_PREFIX}/"
new_prefix = f"{version_slug}/"
def transform(page: str) -> str:
if page.startswith(new_prefix):
return page
rewritten = old_prefix.sub(new_prefix, page)
if rewritten != page:
return rewritten
if page.startswith(locale_prefix):
return f"{new_prefix}{page}"
if page.startswith(edge_prefix):
return f"{new_prefix}{page[len(edge_prefix) :]}"
return page
rewritten = _walk_pages(new_entry, transform)
@@ -389,18 +386,18 @@ def _migrate_docs_json(docs_json: Path, version_slug: str) -> tuple[int, int, in
inserted = 0
skipped = 0
for block in data["navigation"]["languages"]:
locale = block["language"]
versions: list[dict[str, Any]] = block.get("versions", [])
if any(v.get("version") == version_slug for v in versions):
skipped += 1
continue
previous = _previous_default(versions)
if previous is None:
edge = _edge_entry(versions)
if edge is None:
# No Edge channel for this locale; nothing to freeze.
skipped += 1
continue
new_entry = _build_new_entry(previous, version_slug, locale, docs_root)
new_entry = _build_new_entry(edge, version_slug, docs_root)
if new_entry is None:
# Locale has no resolvable content under the snapshot yet (e.g. a
# locale that didn't exist in Edge). Leave the block untouched.

View File

@@ -31,6 +31,10 @@ def _build_docs_root(tmp_path: Path) -> Path:
(edge_en / "api.mdx").write_text(
'---\nopenapi: "/enterprise-api.en.yaml GET /foo"\n---\n'
)
# A page added to Edge after the previous release. It exists as a file and
# is wired into the Edge nav, but is intentionally absent from the v1.14.7
# nav below — the freeze must still surface it in the new version.
(edge_en / "datadog.mdx").write_text("# Datadog (Edge)\n")
(docs / "edge" / "enterprise-api.en.yaml").write_text("openapi: 3.0.0\n")
# A pre-existing frozen snapshot to clone the nav structure from.
@@ -58,6 +62,7 @@ def _build_docs_root(tmp_path: Path) -> Path:
"edge/en/introduction",
"edge/en/changelog",
"edge/en/api",
"edge/en/datadog",
],
}
],
@@ -146,6 +151,25 @@ class TestFreeze:
assert "default" not in previous
assert previous.get("tag") != "Latest"
def test_new_version_nav_is_cloned_from_edge_not_previous(
self, tmp_path: Path
) -> None:
# Regression: the new version's nav must come from Edge so pages added
# to Edge since the last release ship in the freeze. Cloning the
# previous version's nav silently dropped them (the file was copied
# into the snapshot but never linked in the version selector).
docs = _build_docs_root(tmp_path)
freeze("1.15.0", docs)
data = json.loads((docs / "docs.json").read_text())
versions = data["navigation"]["languages"][0]["versions"]
new_entry = next(v for v in versions if v["version"] == "v1.15.0")
pages = [p for tab in new_entry["tabs"] for p in tab["pages"]]
assert "v1.15.0/en/datadog" in pages
# And the file is present in the snapshot it points at.
assert (docs / "v1.15.0" / "en" / "datadog.mdx").is_file()
def test_updates_canonical_url_redirect_to_new_default(
self, tmp_path: Path
) -> None:

4
uv.lock generated
View File

@@ -13,7 +13,7 @@ resolution-markers = [
]
[options]
exclude-newer = "2026-06-20T16:46:21.117658Z"
exclude-newer = "2026-06-28T20:06:34.114646Z"
exclude-newer-span = "P3D"
[options.exclude-newer-package]
@@ -1371,6 +1371,7 @@ azure-ai-inference = [
{ name = "azure-identity" },
]
bedrock = [
{ name = "aiobotocore" },
{ name = "boto3" },
]
docling = [
@@ -1418,6 +1419,7 @@ watson = [
requires-dist = [
{ name = "a2a-sdk", marker = "extra == 'a2a'", specifier = "~=0.3.10" },
{ name = "aiobotocore", marker = "extra == 'aws'", specifier = "~=3.5.0" },
{ name = "aiobotocore", marker = "extra == 'bedrock'", specifier = "~=3.5.0" },
{ name = "aiocache", extras = ["memcached", "redis"], marker = "extra == 'a2a'", specifier = "~=0.12.3" },
{ name = "aiofiles", specifier = "~=24.1.0" },
{ name = "aiosqlite", specifier = "~=0.21.0" },