Compare commits

..

6 Commits

Author SHA1 Message Date
Lucas Gomide
483deddfc4 docs: polish Datadog/OTel guides — symmetric paths, auto-provisioned 2026-06-18 14:22:25 -03:00
Lucas Gomide
0be94a43f6 docs(enterprise): re-stub Datadog guide in pt-BR/ko/ar
Adds stub MDX pages for the new Datadog Integration guide in each locale,
with translated frontmatter and a "translation in progress" note. Body
content is English while waiting on full translations, matching the
discoverability of every other Enterprise guide.

Also reframes capture_telemetry_logs.mdx in each locale to match the en
restructure: a translated lead Tip recommending OpenTelemetry as the
vendor-neutral default, and a slimmed-down Datadog tab that points at
the dedicated Datadog Integration guide instead of duplicating its
collector configuration steps.

Registers the new datadog page in the pt-BR/ko/ar edge sidebars where
the old datadog_dashboard entry would have lived. structured_logs is
intentionally not re-stubbed — the schema lives inside the Datadog page
now.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-17 19:11:07 -03:00
Lucas Gomide
58e0f69e86 docs(enterprise): consolidate Datadog and structured logs into single guide
Merges the standalone structured_logs guide into a dedicated Datadog
Integration page. The stdout JSON schema is Datadog-Agent-path-specific
in practice (OTLP path uses OpenTelemetry attribute names), so a
vendor-neutral structured-logs page was misleading. Now Datadog customers
have one canonical page covering both ingestion paths plus the dashboard
import, and non-Datadog customers land on the OpenTelemetry Export page
without being buried in Datadog content.

- Delete docs/edge/en/enterprise/guides/structured_logs.mdx; the schema
  reference moves verbatim into the new datadog.mdx as an anchor-linkable
  section.

- Rename datadog_dashboard.mdx to datadog.mdx (preserved via git mv).
  New structure: choose-a-path tabs (Datadog Agent recommended /
  Datadog OTLP intake) → log schema reference (with explicit Info
  callout that it's the Agent-path schema, not OTLP) → dashboard
  import → verify ingestion → customize → troubleshooting.

- Move the Datadog OTLP UI walkthrough (site domain, API key,
  /v1/traces vs /v1/logs paths) onto the Datadog page so it lives in
  exactly one place. Datadog dashboard JSON artifact path stays at
  datadog_dashboard.json — the file name is artifact-specific.

- Reframe capture_telemetry_logs.mdx: add a lead Tip recommending OTel
  as the vendor-neutral first option, and shrink the Datadog tab to a
  pointer to the new Datadog Integration guide.

- Update docs/docs.json en edge sidebar: drop structured_logs, replace
  datadog_dashboard with datadog.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-17 19:02:11 -03:00
Lucas Gomide
d9083d8424 Revert "docs(enterprise): register structured logs + Datadog dashboard in pt-BR/ko/ar"
This reverts commit 2b4ae346da.
2026-06-17 18:52:43 -03:00
Lucas Gomide
2b4ae346da docs(enterprise): register structured logs + Datadog dashboard in pt-BR/ko/ar
Adds stub MDX pages with translated frontmatter and a "translation in
progress" note in each locale. Body content is English while waiting on
full translations, matching the discoverability of every other Enterprise
guide (registered across all four edge locales).

Also translates the Datadog OTLP /v1/logs touch-up and the new cross-links
in pt-BR/ko/ar versions of capture_telemetry_logs.mdx.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-17 17:43:39 -03:00
Lucas Gomide
eb18db13b3 docs(enterprise): add structured JSON logs guide + Datadog dashboard
Documents the structured-logs work shipped in crewAI-enterprise
PR #1195 and ships the customer-facing Datadog dashboard the CON-249
self-hosted observability ask called out for.

- docs/edge/en/enterprise/guides/structured_logs.mdx: schema v1
  reference, opt-in env var (CREWAI_LOG_FORMAT=json), before/after
  JSON example, compatibility contract. Backend-agnostic — usable
  for Splunk, Loki, ELK, CloudWatch as well.

- docs/edge/en/enterprise/guides/datadog_dashboard.mdx: two ingestion
  paths (Datadog Agent stdout vs Datadog OTLP intake) for self-hosted
  customers to pick from, facet-promotion prerequisites, 3-step
  dashboard import, dashboard tour, customization tips, troubleshooting.

- docs/edge/en/enterprise/guides/datadog_dashboard.json: the importable
  dashboard artifact itself — 4 sections (Header / Throughput / Errors /
  Cost) with template variables wired to @automation_name,
  @crewai_version, and service.

- docs/edge/en/enterprise/guides/capture_telemetry_logs.mdx: clarify
  that the default Datadog OTel template ships traces only and link to
  the new log-export options (Structured Logs + Datadog Dashboard).

- docs/docs.json: register both new pages in the edge/en sidebar
  alongside capture_telemetry_logs. Version snapshots (v1.x.x) and
  non-English locales deliberately untouched — new content lives only
  on the edge channel; translation stubs land in a follow-up PR.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-17 17:24:42 -03:00
54 changed files with 823 additions and 3354 deletions

View File

@@ -4,65 +4,6 @@ description: "تحديثات المنتج والتحسينات وإصلاحات
icon: "clock"
mode: "wide"
---
<Update label="18 يونيو 2026">
## v1.14.8a1
[عرض الإصدار على GitHub](https://github.com/crewAIInc/crewAI/releases/tag/1.14.8a1)
## ما الذي تغير
### الميزات
- إضافة تعبير if اختياري إلى خطوات each.do
### إصلاحات الأخطاء
- إصلاح مشكلات JSON crew
### الوثائق
- تحديث snapshot و changelog للإصدار v1.14.8a
## المساهمون
@joaomdmoura, @vinibrsl
</Update>
<Update label="17 يونيو 2026">
## v1.14.8a
[عرض الإصدار على GitHub](https://github.com/crewAIInc/crewAI/releases/tag/1.14.8a)
## ما الذي تغير
### الميزات
- إضافة إجراء كتلة نصية/كود إلى FlowDefinition
- إضافة إجراءات الطاقم إلى FlowDefinition
- إضافة إجراء مركب `each` إلى FlowDefinition
- تنفيذ دعم وضع DMN في إنشاء الطاقم وتنفيذه
- تحسين وظيفة إعادة تعيين الذاكرة ومعالجة الطاقم بتنسيق JSON
- إضافة تعبيرات إلى إجراءات FlowDefinition
- تنفيذ أدوات تشغيل تعريف التدفق بدون كود Python
- دفع التغذية الراجعة البشرية من تعريف التدفق
- توصيل التكوين والاستمرارية من FlowDefinition إلى وقت التشغيل
- إضافة `crewai run --definition` التجريبية للتدفقات
- دعم تراجع نشر ZIP وتشغيل مشاريع الطاقم بتنسيق JSON
- تقديم الطواقم بتنسيق JSON أولاً
### إصلاحات الأخطاء
- إصلاح أداة Exa المكررة
- إصلاح استخدام الرموز المجمعة عبر جميع استدعاءات LLM
- حل المشكلات المتعلقة بتحميل الطاقم ومنطق التحقق
### الوثائق
- توثيق حقول FlowDefinition في مخطط JSON
- تحديث وثائق التثبيت والبدء السريع لمشاريع الطاقم بتنسيق JSON أولاً
- تحديث سجل التغييرات والإصدار لـ v1.14.7
## المساهمون
@gabemilani, @greysonlalonde, @iris-clawd, @joaomdmoura, @lorenzejay, @lucasgomide, @theCyberTech, @vinibrsl
</Update>
<Update label="11 يونيو 2026">
## v1.14.7

View File

@@ -25,7 +25,7 @@ CrewAI supports two log-ingestion paths to Datadog — both are first-class and
**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.
2. Set `CREWAI_LOG_FORMAT=json` on every CrewAI container (API + workers) so each log event is a single line instead of a multi-line traceback. See the [log schema reference](#log-schema-reference) below for the full field contract.
3. 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.
@@ -81,7 +81,7 @@ When `CREWAI_LOG_FORMAT=json` is set, every log event is emitted as a **single J
### 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.
Set the `CREWAI_LOG_FORMAT` environment variable to `json` on every container that runs your deployment (API + workers).
```shell
CREWAI_LOG_FORMAT=json
@@ -237,7 +237,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 `CREWAI_LOG_FORMAT=json` is set on the running container, the deployment was restarted after the change, and the Datadog Agent is tailing container stdout.
</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).

View File

@@ -4,65 +4,6 @@ description: "Product updates, improvements, and bug fixes for CrewAI"
icon: "clock"
mode: "wide"
---
<Update label="Jun 18, 2026">
## v1.14.8a1
[View release on GitHub](https://github.com/crewAIInc/crewAI/releases/tag/1.14.8a1)
## What's Changed
### Features
- Add optional if expression to each.do steps
### Bug Fixes
- Fix JSON crew issues
### Documentation
- Update snapshot and changelog for v1.14.8a
## Contributors
@joaomdmoura, @vinibrsl
</Update>
<Update label="Jun 17, 2026">
## v1.14.8a
[View release on GitHub](https://github.com/crewAIInc/crewAI/releases/tag/1.14.8a)
## What's Changed
### Features
- Add script/code block action to FlowDefinition
- Add crew actions to FlowDefinition
- Add `each` composite action to FlowDefinition
- Implement DMN mode support in crew creation and execution
- Enhance memory reset functionality and JSON crew handling
- Add expressions to FlowDefinition actions
- Implement Flow definition run tools without Python code
- Drive human feedback from the flow definition
- Wire config and persistence from FlowDefinition into the runtime
- Add experimental `crewai run --definition` for flows
- Support ZIP deployment fallback and JSON crew project env runs
- Introduce JSON first crews
### Bug Fixes
- Fix duplicated Exa tool
- Fix aggregate token usage across all LLM calls
- Resolve issues with crew loading and validation logic
### Documentation
- Document FlowDefinition fields in the JSON schema
- Update installation and quickstart documentation for JSON-first crew projects
- Update changelog and version for v1.14.7
## Contributors
@gabemilani, @greysonlalonde, @iris-clawd, @joaomdmoura, @lorenzejay, @lucasgomide, @theCyberTech, @vinibrsl
</Update>
<Update label="Jun 11, 2026">
## v1.14.7

View File

@@ -21,7 +21,7 @@ CrewAI supports two log-ingestion paths to Datadog — both are first-class and
**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.
2. Set `CREWAI_LOG_FORMAT=json` on every CrewAI container (API + workers) so each log event is a single line instead of a multi-line traceback. See the [log schema reference](#log-schema-reference) below for the full field contract.
3. 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.
@@ -77,7 +77,7 @@ When `CREWAI_LOG_FORMAT=json` is set, every log event is emitted as a **single J
### 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.
Set the `CREWAI_LOG_FORMAT` environment variable to `json` on every container that runs your deployment (API + workers).
```shell
CREWAI_LOG_FORMAT=json
@@ -233,7 +233,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 `CREWAI_LOG_FORMAT=json` is set on the running container, the deployment was restarted after the change, and the Datadog Agent is tailing container stdout.
</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).

View File

@@ -28,60 +28,6 @@ mode: "wide"
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 12, justifyContent: 'center' }}>
<a className="button button-primary" href="/en/quickstart">Get started</a>
<button
type="button"
className="button"
onClick={async (event) => {
const prompt = `Set up this environment so I can build with CrewAI.
First install the official CrewAI coding-agent skills if this environment supports npx:
npx skills add crewaiinc/skills
If npx is missing or the current agent cannot load skills, do not fail the whole setup. Report the exact issue and continue using the CrewAI docs directly.
Use these CrewAI docs as source of truth before making assumptions:
- https://skills.crewai.com
- https://docs.crewai.com/llms.txt
- https://docs.crewai.com/en/installation
- https://docs.crewai.com/en/guides/coding-tools/build-with-ai
Setup steps:
1. Check python3 --version. CrewAI requires Python >=3.10 and <3.14.
2. Install uv if missing:
curl -LsSf https://astral.sh/uv/install.sh | sh
3. Source the uv environment if needed:
source "$HOME/.local/bin/env"
4. Install the CrewAI CLI:
uv tool install crewai
5. Verify the CLI:
crewai version
crewai create --help
6. Create a project:
CREWAI_DMN=true crewai create
7. After project creation, inspect the generated files before editing.
8. Run:
crewai install
crewai run
Do not hardcode API keys. Use .env.
Do not invent CLI flags. Validate with crewai --help or crewai create --help.
If a command fails, show the exact command and error, explain the likely cause, fix what you can safely fix, and retry once.`;
const button = event.currentTarget;
try {
await navigator.clipboard.writeText(prompt);
button.textContent = "Copied";
} catch {
button.textContent = "Copy failed";
} finally {
window.setTimeout(() => {
button.textContent = "Copy instructions for coding agents";
}, 1600);
}
}}
>
Copy instructions for coding agents
</button>
<a className="button" href="/en/changelog">View changelog</a>
<a className="button" href="/en/api-reference/introduction">API Reference</a>
</div>

View File

@@ -9,60 +9,7 @@ mode: "wide"
Install our coding agent skills (Claude Code, Codex, ...) to quickly get your coding agents up and running with CrewAI.
<button
type="button"
className="button button-primary"
onClick={async (event) => {
const prompt = `Set up this environment so I can build with CrewAI.
First install the official CrewAI coding-agent skills if this environment supports npx:
npx skills add crewaiinc/skills
If npx is missing or the current agent cannot load skills, do not fail the whole setup. Report the exact issue and continue using the CrewAI docs directly.
Use these CrewAI docs as source of truth before making assumptions:
- https://skills.crewai.com
- https://docs.crewai.com/llms.txt
- https://docs.crewai.com/en/installation
- https://docs.crewai.com/en/guides/coding-tools/build-with-ai
Setup steps:
1. Check python3 --version. CrewAI requires Python >=3.10 and <3.14.
2. Install uv if missing:
curl -LsSf https://astral.sh/uv/install.sh | sh
3. Source the uv environment if needed:
source "$HOME/.local/bin/env"
4. Install the CrewAI CLI:
uv tool install crewai
5. Verify the CLI:
crewai version
crewai create --help
6. Create a project:
CREWAI_DMN=true crewai create
7. After project creation, inspect the generated files before editing.
8. Run:
crewai install
crewai run
Do not hardcode API keys. Use .env.
Do not invent CLI flags. Validate with crewai --help or crewai create --help.
If a command fails, show the exact command and error, explain the likely cause, fix what you can safely fix, and retry once.`;
const button = event.currentTarget;
try {
await navigator.clipboard.writeText(prompt);
button.textContent = "Copied";
} catch {
button.textContent = "Copy failed";
} finally {
window.setTimeout(() => {
button.textContent = "Copy instructions for coding agents";
}, 1600);
}
}}
>
Copy instructions for coding agents
</button>
You can install it with `npx skills add crewaiinc/skills`
<iframe src="https://www.loom.com/embed/befb9f68b81f42ad8112bfdd95a780af" frameborder="0" webkitallowfullscreen mozallowfullscreen allowfullscreen style={{width: "100%", height: "400px"}}></iframe>

View File

@@ -4,65 +4,6 @@ description: "CrewAI의 제품 업데이트, 개선 사항 및 버그 수정"
icon: "clock"
mode: "wide"
---
<Update label="2026년 6월 18일">
## v1.14.8a1
[GitHub 릴리스 보기](https://github.com/crewAIInc/crewAI/releases/tag/1.14.8a1)
## 변경 사항
### 기능
- 각 do 단계에 선택적 if 표현식을 추가
### 버그 수정
- JSON 크루 문제 수정
### 문서
- v1.14.8a의 스냅샷 및 변경 로그 업데이트
## 기여자
@joaomdmoura, @vinibrsl
</Update>
<Update label="2026년 6월 17일">
## v1.14.8a
[GitHub 릴리스 보기](https://github.com/crewAIInc/crewAI/releases/tag/1.14.8a)
## 변경 사항
### 기능
- FlowDefinition에 스크립트/코드 블록 액션 추가
- FlowDefinition에 크루 액션 추가
- FlowDefinition에 `each` 복합 액션 추가
- 크루 생성 및 실행에서 DMN 모드 지원 구현
- 메모리 재설정 기능 및 JSON 크루 처리 기능 향상
- FlowDefinition 액션에 표현식 추가
- Python 코드 없이 Flow 정의 실행 도구 구현
- Flow 정의에서 인간 피드백 유도
- FlowDefinition의 구성 및 지속성을 런타임에 연결
- 흐름을 위한 실험적 `crewai run --definition` 추가
- ZIP 배포 대체 및 JSON 크루 프로젝트 환경 실행 지원
- JSON 우선 크루 도입
### 버그 수정
- 중복된 Exa 도구 수정
- 모든 LLM 호출에서 집계 토큰 사용 수정
- 크루 로딩 및 검증 로직 관련 문제 해결
### 문서
- JSON 스키마에서 FlowDefinition 필드 문서화
- JSON 우선 크루 프로젝트에 대한 설치 및 빠른 시작 문서 업데이트
- v1.14.7에 대한 변경 로그 및 버전 업데이트
## 기여자
@gabemilani, @greysonlalonde, @iris-clawd, @joaomdmoura, @lorenzejay, @lucasgomide, @theCyberTech, @vinibrsl
</Update>
<Update label="2026년 6월 11일">
## v1.14.7

View File

@@ -25,7 +25,7 @@ CrewAI supports two log-ingestion paths to Datadog — both are first-class and
**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.
2. Set `CREWAI_LOG_FORMAT=json` on every CrewAI container (API + workers) so each log event is a single line instead of a multi-line traceback. See the [log schema reference](#log-schema-reference) below for the full field contract.
3. 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.
@@ -81,7 +81,7 @@ When `CREWAI_LOG_FORMAT=json` is set, every log event is emitted as a **single J
### 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.
Set the `CREWAI_LOG_FORMAT` environment variable to `json` on every container that runs your deployment (API + workers).
```shell
CREWAI_LOG_FORMAT=json
@@ -237,7 +237,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 `CREWAI_LOG_FORMAT=json` is set on the running container, the deployment was restarted after the change, and the Datadog Agent is tailing container stdout.
</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).

View File

@@ -4,65 +4,6 @@ description: "Atualizações de produto, melhorias e correções do CrewAI"
icon: "clock"
mode: "wide"
---
<Update label="18 jun 2026">
## v1.14.8a1
[Ver release no GitHub](https://github.com/crewAIInc/crewAI/releases/tag/1.14.8a1)
## O que Mudou
### Recursos
- Adicionar expressão if opcional aos passos each.do
### Correções de Bugs
- Corrigir problemas de JSON da equipe
### Documentação
- Atualizar snapshot e changelog para v1.14.8a
## Contribuidores
@joaomdmoura, @vinibrsl
</Update>
<Update label="17 jun 2026">
## v1.14.8a
[Ver release no GitHub](https://github.com/crewAIInc/crewAI/releases/tag/1.14.8a)
## O que Mudou
### Recursos
- Adicionar ação de bloco de script/código ao FlowDefinition
- Adicionar ações de equipe ao FlowDefinition
- Adicionar ação composta `each` ao FlowDefinition
- Implementar suporte ao modo DMN na criação e execução de equipes
- Melhorar a funcionalidade de redefinição de memória e o manuseio de equipes em JSON
- Adicionar expressões às ações do FlowDefinition
- Implementar ferramentas de execução de definição de fluxo sem código Python
- Conduzir feedback humano a partir da definição de fluxo
- Conectar configuração e persistência do FlowDefinition ao tempo de execução
- Adicionar `crewai run --definition` experimental para fluxos
- Suportar fallback de implantação ZIP e execuções de projeto de equipe em JSON
- Introduzir equipes em JSON primeiro
### Correções de Bugs
- Corrigir ferramenta Exa duplicada
- Corrigir uso de token agregado em todas as chamadas LLM
- Resolver problemas com o carregamento de equipes e lógica de validação
### Documentação
- Documentar campos do FlowDefinition no esquema JSON
- Atualizar documentação de instalação e início rápido para projetos de equipe em JSON-primeiro
- Atualizar changelog e versão para v1.14.7
## Contribuidores
@gabemilani, @greysonlalonde, @iris-clawd, @joaomdmoura, @lorenzejay, @lucasgomide, @theCyberTech, @vinibrsl
</Update>
<Update label="11 jun 2026">
## v1.14.7

View File

@@ -25,7 +25,7 @@ CrewAI supports two log-ingestion paths to Datadog — both are first-class and
**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.
2. Set `CREWAI_LOG_FORMAT=json` on every CrewAI container (API + workers) so each log event is a single line instead of a multi-line traceback. See the [log schema reference](#log-schema-reference) below for the full field contract.
3. 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.
@@ -81,7 +81,7 @@ When `CREWAI_LOG_FORMAT=json` is set, every log event is emitted as a **single J
### 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.
Set the `CREWAI_LOG_FORMAT` environment variable to `json` on every container that runs your deployment (API + workers).
```shell
CREWAI_LOG_FORMAT=json
@@ -237,7 +237,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 `CREWAI_LOG_FORMAT=json` is set on the running container, the deployment was restarted after the change, and the Datadog Agent is tailing container stdout.
</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).

View File

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

View File

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

View File

@@ -89,16 +89,13 @@ description = "{name} using crewAI"
authors = [{{ name = "Your Name", email = "you@example.com" }}]
requires-python = ">=3.10,<3.14"
dependencies = [
"crewai[tools]==1.14.8a1"
"crewai[tools]>=1.14.7"
]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
only-include = ["agents", "crew.jsonc", "tools", "knowledge", "skills"]
[tool.crewai]
type = "crew"
"""

View File

@@ -34,25 +34,6 @@ _C_MUTED = "#666666" # dimmer than _C_DIM for past timeline
_STEP_NUMBER_RE = re.compile(r"\bstep\s+(\d+)\b", re.IGNORECASE)
_REFINEMENT_RE = re.compile(r"^\s*step\s+(\d+)\s*:\s*(.+)\s*$", re.IGNORECASE)
_INTERNAL_TOOL_NAMES = {"create_reasoning_plan"}
_LOG_ARGS_TEXT_LIMIT = 3_000
_LOG_RESULT_TEXT_LIMIT = 5_000
_LOG_TRUNCATION_SUFFIX = "... [truncated]"
# Background memory saves can emit their start event just after kickoff returns.
_MEMORY_SAVE_DRAIN_GRACE_SECONDS = 2.0
def _is_save_to_memory_tool(tool_name: str | None) -> bool:
return (tool_name or "").replace(" ", "_").lower() == "save_to_memory"
def _truncate_log_text(value: Any, limit: int) -> str | None:
if value is None:
return None
text = str(value)
if len(text) <= limit:
return text
suffix = _LOG_TRUNCATION_SUFFIX
return f"{text[: max(0, limit - len(suffix))]}{suffix}"
def _enable_tracing_in_dotenv() -> None:
@@ -538,8 +519,6 @@ FooterKey .footer-key--key {
self._log_expanded: set[int] = set()
self._log_scroll_needed: bool = False
self._log_line_map: list[tuple[int, int, int]] = []
self._suppressed_memory_save_event_ids: set[str] = set()
self._memory_save_drain_timer: Any = None
self._event_handlers: list[tuple[type, Any]] = []
@@ -654,6 +633,7 @@ FooterKey .footer-key--key {
self.call_from_thread(self._on_crew_failed, str(e))
def _on_crew_done(self, output: str | None) -> None:
self._unsubscribe()
with self._lock:
self._status = "completed"
self._final_output = output
@@ -669,8 +649,6 @@ FooterKey .footer-key--key {
now = time.time()
for entry in self._log_entries:
if entry["status"] == "running":
if entry["tool_name"] == "memory_save":
continue
entry["status"] = "timeout"
entry["error"] = "No result received before crew completed"
entry["duration"] = now - entry["start_time"]
@@ -702,9 +680,9 @@ FooterKey .footer-key--key {
self.call_later(self._focus_activity_log)
self._tick_timer.stop()
self._tick_timer = self.set_interval(1 / 2, self._tick)
self._unsubscribe_if_no_running_memory_save(wait_for_queued=True)
def _on_crew_failed(self, error: str) -> None:
self._unsubscribe()
with self._lock:
self._status = "failed"
self._error = error
@@ -714,16 +692,12 @@ FooterKey .footer-key--key {
now = time.time()
for entry in self._log_entries:
if entry["status"] == "running":
if entry["tool_name"] == "memory_save":
continue
entry["status"] = "error"
entry["error"] = "No result received before crew failed"
entry["duration"] = now - entry["start_time"]
self._tick()
self.call_later(self._focus_activity_log)
self._tick_timer.stop()
self._tick_timer = self.set_interval(1 / 2, self._tick)
self._unsubscribe_if_no_running_memory_save(wait_for_queued=True)
# ── Actions ─────────────────────────────────────────────
@@ -1540,53 +1514,6 @@ FooterKey .footer-key--key {
pass
self._event_handlers.clear()
def _has_running_memory_save_locked(self) -> bool:
return any(
entry["tool_name"] == "memory_save" and entry["status"] == "running"
for entry in self._log_entries
)
def _on_memory_save_drain_elapsed(self) -> None:
self._memory_save_drain_timer = None
self._unsubscribe_if_no_running_memory_save()
def _schedule_memory_save_drain_unsubscribe(self) -> bool:
loop = getattr(self, "_loop", None)
if loop is None:
return False
if getattr(self, "_thread_id", None) != threading.get_ident():
try:
loop.call_soon_threadsafe(self._schedule_memory_save_drain_unsubscribe)
except RuntimeError:
return False
return True
if self._memory_save_drain_timer is not None:
self._memory_save_drain_timer.stop()
self._memory_save_drain_timer = self.set_timer(
_MEMORY_SAVE_DRAIN_GRACE_SECONDS,
self._on_memory_save_drain_elapsed,
name="memory-save-drain",
)
return True
def _unsubscribe_if_no_running_memory_save(
self, *, wait_for_queued: bool = False
) -> None:
with self._lock:
should_unsubscribe = (
self._status
in {
"completed",
"failed",
}
and not self._has_running_memory_save_locked()
)
if should_unsubscribe:
if wait_for_queued and self._schedule_memory_save_drain_unsubscribe():
return
self._unsubscribe()
def _subscribe(self) -> None:
from crewai.events.event_bus import crewai_event_bus
from crewai.events.types.crew_events import CrewKickoffStartedEvent
@@ -1875,8 +1802,6 @@ FooterKey .footer-key--key {
entry["status"] == "running"
and entry["tool_name"] != event.tool_name
):
if entry["tool_name"] == "memory_save":
continue
entry["status"] = "timeout"
entry["error"] = (
"No result received before the next tool started"
@@ -1905,7 +1830,6 @@ FooterKey .footer-key--key {
"duration": None,
"task_idx": self._current_task_idx,
"plan_step_number": plan_step_number,
"event_id": event.event_id,
}
)
self._complete_step("teal", f"{event.tool_name}")
@@ -1999,178 +1923,8 @@ FooterKey .footer-key--key {
MemoryRetrievalCompletedEvent,
MemoryRetrievalFailedEvent,
MemoryRetrievalStartedEvent,
MemorySaveCompletedEvent,
MemorySaveFailedEvent,
MemorySaveStartedEvent,
)
def is_nested_save_to_memory_event(event: Any) -> bool:
if event.parent_event_id is None:
return False
state = crewai_event_bus.runtime_state
if state is None:
return False
parent_node = state.event_record.nodes.get(event.parent_event_id)
parent_event = getattr(parent_node, "event", None)
return getattr(
parent_event, "type", None
) == "tool_usage_started" and _is_save_to_memory_tool(
getattr(parent_event, "tool_name", None)
)
@crewai_event_bus.on(MemorySaveStartedEvent)
def on_memory_save_started(source: Any, event: MemorySaveStartedEvent) -> None:
with self._lock:
if is_nested_save_to_memory_event(event):
self._suppressed_memory_save_event_ids.add(event.event_id)
return
for entry in reversed(self._log_entries):
if (
_is_save_to_memory_tool(entry["tool_name"])
and entry.get("event_id") == event.parent_event_id
):
self._suppressed_memory_save_event_ids.add(event.event_id)
return
for entry in reversed(self._log_entries):
if (
entry["tool_name"] == "memory_save"
and entry.get("started_event_id") == event.event_id
):
entry["args"] = _truncate_log_text(
event.value, _LOG_ARGS_TEXT_LIMIT
)
return
self._log_entries.append(
{
"tool_name": "memory_save",
"status": "running",
"args": _truncate_log_text(event.value, _LOG_ARGS_TEXT_LIMIT),
"result": None,
"error": None,
"start_time": time.time(),
"duration": None,
"task_idx": self._current_task_idx,
"event_id": event.event_id,
}
)
self._register_handler(MemorySaveStartedEvent, on_memory_save_started)
@crewai_event_bus.on(MemorySaveCompletedEvent)
def on_memory_save_completed(
source: Any, event: MemorySaveCompletedEvent
) -> None:
with self._lock:
if (
event.started_event_id in self._suppressed_memory_save_event_ids
or is_nested_save_to_memory_event(event)
):
if event.started_event_id is not None:
self._suppressed_memory_save_event_ids.discard(
event.started_event_id
)
else:
for entry in reversed(self._log_entries):
has_started_event_match = (
event.started_event_id is not None
and (
entry.get("event_id") == event.started_event_id
or entry.get("started_event_id")
== event.started_event_id
)
)
has_running_event_without_id = (
event.started_event_id is None
and entry["status"] == "running"
)
if entry["tool_name"] == "memory_save" and (
has_running_event_without_id or has_started_event_match
):
entry["status"] = "success"
entry["duration"] = event.save_time_ms / 1000
entry["result"] = _truncate_log_text(
event.value, _LOG_RESULT_TEXT_LIMIT
)
entry["error"] = None
entry["started_event_id"] = event.started_event_id
break
else:
self._log_entries.append(
{
"tool_name": "memory_save",
"status": "success",
"args": None,
"result": _truncate_log_text(
event.value, _LOG_RESULT_TEXT_LIMIT
),
"error": None,
"start_time": time.time(),
"duration": event.save_time_ms / 1000,
"task_idx": self._current_task_idx,
"started_event_id": event.started_event_id,
}
)
self._unsubscribe_if_no_running_memory_save(wait_for_queued=True)
self._register_handler(MemorySaveCompletedEvent, on_memory_save_completed)
@crewai_event_bus.on(MemorySaveFailedEvent)
def on_memory_save_failed(source: Any, event: MemorySaveFailedEvent) -> None:
with self._lock:
if (
event.started_event_id in self._suppressed_memory_save_event_ids
or is_nested_save_to_memory_event(event)
):
if event.started_event_id is not None:
self._suppressed_memory_save_event_ids.discard(
event.started_event_id
)
else:
for idx, entry in reversed(list(enumerate(self._log_entries))):
has_started_event_match = (
event.started_event_id is not None
and (
entry.get("event_id") == event.started_event_id
or entry.get("started_event_id")
== event.started_event_id
)
)
has_running_event_without_id = (
event.started_event_id is None
and entry["status"] == "running"
)
if entry["tool_name"] == "memory_save" and (
has_running_event_without_id or has_started_event_match
):
entry["status"] = "error"
entry["error"] = event.error
entry["duration"] = time.time() - entry["start_time"]
entry["started_event_id"] = event.started_event_id
self._log_expanded.add(idx)
break
else:
self._log_entries.append(
{
"tool_name": "memory_save",
"status": "error",
"args": _truncate_log_text(
event.value, _LOG_ARGS_TEXT_LIMIT
),
"result": None,
"error": event.error,
"start_time": time.time(),
"duration": 0,
"task_idx": self._current_task_idx,
"started_event_id": event.started_event_id,
}
)
self._log_expanded.add(len(self._log_entries) - 1)
self._unsubscribe_if_no_running_memory_save(wait_for_queued=True)
self._register_handler(MemorySaveFailedEvent, on_memory_save_failed)
@crewai_event_bus.on(MemoryRetrievalStartedEvent)
def on_memory_retrieval_started(
source: Any, event: MemoryRetrievalStartedEvent

View File

@@ -1,6 +1,5 @@
from __future__ import annotations
from collections.abc import Callable
from contextlib import AbstractContextManager, nullcontext
from enum import Enum
import os
@@ -8,9 +7,10 @@ from pathlib import Path
import re
import subprocess
import sys
from typing import TYPE_CHECKING, Any, cast
from typing import TYPE_CHECKING, Any
import click
from crewai.project.json_loader import find_crew_json_file
from crewai_core.constants import CREWAI_TRAINED_AGENTS_FILE_ENV
from packaging import version
@@ -38,15 +38,6 @@ class CrewType(Enum):
_INPUT_PLACEHOLDER_RE = re.compile(r"(?<!{){([A-Za-z_][A-Za-z0-9_\-]*)}(?!})")
_CREWAI_CLI_RUNNER_PACKAGE_DIR_ENV = "CREWAI_CLI_RUNNER_PACKAGE_DIR"
_CREWAI_RUNNER_SOURCE_DIR_ENV = "CREWAI_RUNNER_SOURCE_DIR"
_FULL_CREWAI_INSTALL_MESSAGE = """\
CrewAI CLI is installed without the `crewai` package required to run crews.
Install the full CrewAI prerelease package:
uv tool install --force --prerelease=allow 'crewai[tools]==1.14.8a1'
The quotes are required in zsh so `crewai[tools]` is not treated as a glob.
"""
_JSON_CREW_RUNNER_CODE = """
import importlib.util
import os
@@ -81,39 +72,12 @@ module_spec.loader.exec_module(module)
from crewai_core.constants import CREWAI_TRAINED_AGENTS_FILE_ENV
try:
module._run_json_crew(
trained_agents_file=os.getenv(CREWAI_TRAINED_AGENTS_FILE_ENV)
)
except module.click.ClickException as exc:
exc.show()
raise SystemExit(exc.exit_code)
module._run_json_crew(
trained_agents_file=os.getenv(CREWAI_TRAINED_AGENTS_FILE_ENV)
)
""".strip()
def _import_find_crew_json_file() -> Callable[[], Path | None]:
from crewai.project.json_loader import find_crew_json_file as _find_crew_json_file
return cast("Callable[[], Path | None]", _find_crew_json_file)
def _is_missing_crewai_package(exc: ModuleNotFoundError) -> bool:
return bool(exc.name and exc.name.startswith("crewai"))
def _full_crewai_install_error() -> click.ClickException:
return click.ClickException(_FULL_CREWAI_INSTALL_MESSAGE)
def find_crew_json_file() -> Path | None:
try:
return _import_find_crew_json_file()()
except ModuleNotFoundError as exc:
if _is_missing_crewai_package(exc):
raise _full_crewai_install_error() from exc
raise
def _has_json_crew() -> bool:
"""Check if this is a JSON-defined crew project.

View File

@@ -767,11 +767,10 @@ class CustomSearchTool(BaseTool):
```python
from crewai.tools import tool
@tool("WordCount")
def word_count(text: str) -> str:
"""Counts the number of words in the given text."""
count = len(text.split())
return f"Word count: {count}"
@tool("Calculator")
def calculator(expression: str) -> str:
"""Evaluates a mathematical expression and returns the result."""
return str(eval(expression))
```
### Built-in Tools (install with `uv add crewai-tools`)

View File

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

View File

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

View File

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

View File

@@ -5,10 +5,7 @@ from pathlib import Path
from unittest import mock
import pytest
import tomli
from click.testing import CliRunner
from packaging.requirements import Requirement
from packaging.version import Version
import crewai_cli.create_json_crew as json_crew
import crewai_cli.tui_picker as tui_picker
from crewai_cli.create_crew import create_crew, create_folder_structure
@@ -715,14 +712,6 @@ def test_json_create_provider_preselects_default_model(tmp_path, monkeypatch):
assert not (tmp_path / "json_crew" / "tests").exists()
assert not (tmp_path / "json_crew" / "config.jsonc").exists()
pyproject = tomli.loads((tmp_path / "json_crew" / "pyproject.toml").read_text())
dependency = pyproject["project"]["dependencies"][0]
assert dependency == "crewai[tools]==1.14.8a1"
assert Version("1.14.8a1") in Requirement(dependency).specifier
assert pyproject["tool"]["hatch"]["build"]["targets"]["wheel"][
"only-include"
] == ["agents", "crew.jsonc", "tools", "knowledge", "skills"]
crew_template = (tmp_path / "json_crew" / "crew.jsonc").read_text()
assert (
'"guardrail": "Every factual claim needs context support."'

View File

@@ -4,11 +4,6 @@ import time
import pytest
from crewai.events.event_bus import crewai_event_bus
from crewai.events.types.memory_events import (
MemorySaveCompletedEvent,
MemorySaveFailedEvent,
MemorySaveStartedEvent,
)
from crewai.events.types.observation_events import (
GoalAchievedEarlyEvent,
PlanRefinementEvent,
@@ -26,12 +21,7 @@ from crewai.events.types.tool_usage_events import (
)
from crewai_cli.command import AuthenticationRequiredError
from crewai_cli import run_crew
from crewai_cli.crew_run_tui import (
CrewRunApp,
_LOG_ARGS_TEXT_LIMIT,
_LOG_RESULT_TEXT_LIMIT,
_LOG_TRUNCATION_SUFFIX,
)
from crewai_cli.crew_run_tui import CrewRunApp
def _app_with_plan() -> CrewRunApp:
@@ -345,396 +335,6 @@ def test_internal_reasoning_function_call_is_hidden_from_activity_log() -> None:
assert app._current_task_steps == []
def test_memory_save_events_are_shown_in_activity_log() -> None:
app = _app_with_plan()
app._current_task_idx = 1
app._subscribe()
try:
_emit_event(
MemorySaveStartedEvent(
value="2 memories (background)",
metadata={},
source_type="unified_memory",
)
)
_emit_event(
MemorySaveCompletedEvent(
value="2 memories saved",
metadata={},
save_time_ms=123,
source_type="unified_memory",
)
)
finally:
app._unsubscribe()
assert len(app._log_entries) == 1
assert app._log_entries[0]["tool_name"] == "memory_save"
assert app._log_entries[0]["status"] == "success"
assert app._log_entries[0]["args"] == "2 memories (background)"
assert app._log_entries[0]["result"] == "2 memories saved"
assert app._log_entries[0]["error"] is None
assert app._log_entries[0]["duration"] == 0.123
assert app._log_entries[0]["task_idx"] == 1
def test_nested_memory_save_event_is_hidden_for_save_to_memory_tool() -> None:
app = _app_with_plan()
app._subscribe()
try:
tool_args = {"contents": ["Fact to remember."]}
_emit_event(
ToolUsageStartedEvent(
tool_name="save_to_memory",
tool_args=tool_args,
)
)
_emit_event(
MemorySaveStartedEvent(
value="Fact to remember.",
metadata={},
source_type="unified_memory",
)
)
_emit_event(
MemorySaveCompletedEvent(
value="Fact to remember.",
metadata={},
save_time_ms=123,
source_type="unified_memory",
)
)
now = datetime.now()
_emit_event(
ToolUsageFinishedEvent(
tool_name="save_to_memory",
tool_args=tool_args,
started_at=now,
finished_at=now,
output="Saved to memory.",
)
)
finally:
app._unsubscribe()
assert len(app._log_entries) == 1
assert app._log_entries[0]["tool_name"] == "save_to_memory"
assert app._log_entries[0]["status"] == "success"
assert app._log_entries[0]["result"] == "Saved to memory."
def test_memory_save_failure_is_shown_in_activity_log() -> None:
app = _app_with_plan()
app._subscribe()
try:
_emit_event(
MemorySaveStartedEvent(
value="background save",
metadata={},
source_type="unified_memory",
)
)
_emit_event(
MemorySaveFailedEvent(
value="background save",
metadata={},
error="embedding connection failed",
source_type="unified_memory",
)
)
finally:
app._unsubscribe()
assert app._log_entries[0]["tool_name"] == "memory_save"
assert app._log_entries[0]["status"] == "error"
assert app._log_entries[0]["error"] == "embedding connection failed"
assert app._log_expanded == {0}
def test_memory_save_completion_updates_timed_out_row() -> None:
app = _app_with_plan()
app._subscribe()
try:
_emit_event(
MemorySaveStartedEvent(
value="9 memories (background)",
metadata={},
source_type="unified_memory",
)
)
app._log_entries[0]["status"] = "timeout"
app._log_entries[0]["error"] = "No result received before crew completed"
app._log_entries[0]["duration"] = 8.3
_emit_event(
MemorySaveCompletedEvent(
value="9 memories saved",
metadata={},
save_time_ms=8300,
source_type="unified_memory",
)
)
finally:
app._unsubscribe()
assert len(app._log_entries) == 1
assert app._log_entries[0]["tool_name"] == "memory_save"
assert app._log_entries[0]["status"] == "success"
assert app._log_entries[0]["result"] == "9 memories saved"
assert app._log_entries[0]["error"] is None
assert app._log_entries[0]["duration"] == 8.3
def test_memory_save_completion_with_unmatched_id_does_not_update_running_row() -> None:
app = _app_with_plan()
app._subscribe()
try:
_emit_event(
MemorySaveStartedEvent(
value="first background save",
metadata={},
source_type="unified_memory",
parent_event_id="manual-parent",
)
)
_emit_event(
MemorySaveStartedEvent(
value="second background save",
metadata={},
source_type="unified_memory",
parent_event_id="manual-parent",
)
)
_emit_event(
MemorySaveCompletedEvent(
value="orphan save completed",
metadata={},
save_time_ms=2800,
source_type="unified_memory",
parent_event_id="manual-parent",
started_event_id="missing-memory-save-start",
)
)
finally:
app._unsubscribe()
assert [entry["status"] for entry in app._log_entries] == [
"running",
"running",
"success",
]
assert app._log_entries[0]["args"] == "first background save"
assert app._log_entries[1]["args"] == "second background save"
assert app._log_entries[2]["result"] == "orphan save completed"
assert app._log_entries[2]["started_event_id"] == "missing-memory-save-start"
def test_memory_save_failure_with_unmatched_id_does_not_update_running_row() -> None:
app = _app_with_plan()
app._subscribe()
try:
_emit_event(
MemorySaveStartedEvent(
value="first background save",
metadata={},
source_type="unified_memory",
parent_event_id="manual-parent",
)
)
_emit_event(
MemorySaveStartedEvent(
value="second background save",
metadata={},
source_type="unified_memory",
parent_event_id="manual-parent",
)
)
_emit_event(
MemorySaveFailedEvent(
value="orphan save failed",
metadata={},
error="embedding connection failed",
source_type="unified_memory",
parent_event_id="manual-parent",
started_event_id="missing-memory-save-start",
)
)
finally:
app._unsubscribe()
assert [entry["status"] for entry in app._log_entries] == [
"running",
"running",
"error",
]
assert app._log_entries[0]["args"] == "first background save"
assert app._log_entries[1]["args"] == "second background save"
assert app._log_entries[2]["args"] == "orphan save failed"
assert app._log_entries[2]["error"] == "embedding connection failed"
assert app._log_entries[2]["started_event_id"] == "missing-memory-save-start"
assert app._log_expanded == {2}
def test_memory_save_completion_without_id_does_not_update_stale_row() -> None:
app = _app_with_plan()
now = time.time()
app._log_entries = [
{
"tool_name": "memory_save",
"status": "running",
"args": "current background save",
"result": None,
"error": None,
"start_time": now,
"duration": None,
"task_idx": 1,
},
{
"tool_name": "memory_save",
"status": "success",
"args": "stale background save",
"result": "stale save completed",
"error": None,
"start_time": now - 10,
"duration": 1.0,
"task_idx": 1,
},
]
app._subscribe()
try:
_emit_event(
MemorySaveCompletedEvent(
value="current save completed",
metadata={},
save_time_ms=2800,
source_type="unified_memory",
parent_event_id="manual-parent",
)
)
finally:
app._unsubscribe()
assert [entry["status"] for entry in app._log_entries] == [
"success",
"success",
]
assert app._log_entries[0]["args"] == "current background save"
assert app._log_entries[0]["result"] == "current save completed"
assert app._log_entries[1]["args"] == "stale background save"
assert app._log_entries[1]["result"] == "stale save completed"
def test_memory_save_failure_without_id_does_not_update_stale_row() -> None:
app = _app_with_plan()
now = time.time()
app._log_entries = [
{
"tool_name": "memory_save",
"status": "running",
"args": "current background save",
"result": None,
"error": None,
"start_time": now,
"duration": None,
"task_idx": 1,
},
{
"tool_name": "memory_save",
"status": "success",
"args": "stale background save",
"result": "stale save completed",
"error": None,
"start_time": now - 10,
"duration": 1.0,
"task_idx": 1,
},
]
app._subscribe()
try:
_emit_event(
MemorySaveFailedEvent(
value="current save failed",
metadata={},
error="embedding connection failed",
source_type="unified_memory",
parent_event_id="manual-parent",
)
)
finally:
app._unsubscribe()
assert [entry["status"] for entry in app._log_entries] == ["error", "success"]
assert app._log_entries[0]["args"] == "current background save"
assert app._log_entries[0]["error"] == "embedding connection failed"
assert app._log_entries[1]["args"] == "stale background save"
assert app._log_entries[1]["result"] == "stale save completed"
assert app._log_entries[1]["error"] is None
assert app._log_expanded == {0}
def test_memory_save_payloads_are_truncated_in_activity_log() -> None:
app = _app_with_plan()
long_args = "a" * (_LOG_ARGS_TEXT_LIMIT + 10)
long_result = "r" * (_LOG_RESULT_TEXT_LIMIT + 10)
app._subscribe()
try:
_emit_event(
MemorySaveStartedEvent(
value=long_args,
metadata={},
source_type="unified_memory",
)
)
_emit_event(
MemorySaveCompletedEvent(
value=long_result,
metadata={},
save_time_ms=8300,
source_type="unified_memory",
)
)
finally:
app._unsubscribe()
assert len(app._log_entries[0]["args"]) == _LOG_ARGS_TEXT_LIMIT
assert app._log_entries[0]["args"].endswith(_LOG_TRUNCATION_SUFFIX)
assert len(app._log_entries[0]["result"]) == _LOG_RESULT_TEXT_LIMIT
assert app._log_entries[0]["result"].endswith(_LOG_TRUNCATION_SUFFIX)
def test_starting_next_tool_does_not_timeout_memory_save() -> None:
app = _app_with_plan()
app._subscribe()
try:
_emit_event(
MemorySaveStartedEvent(
value="9 memories (background)",
metadata={},
source_type="unified_memory",
)
)
_emit_event(
ToolUsageStartedEvent(
tool_name="read_website_content",
tool_args={"url": "https://example.com"},
)
)
finally:
app._unsubscribe()
assert app._log_entries[0]["tool_name"] == "memory_save"
assert app._log_entries[0]["status"] == "running"
assert app._log_entries[0]["error"] is None
assert app._log_entries[1]["tool_name"] == "read_website_content"
assert app._log_entries[1]["status"] == "running"
def test_tool_failure_does_not_override_successful_plan_step_completion() -> None:
app = _app_with_plan()
app._subscribe()
@@ -880,187 +480,6 @@ async def test_crew_done_does_not_mark_unfinished_tool_successful() -> None:
assert app._plan_step_status == {1: "failed", 2: "done", 3: "done"}
@pytest.mark.asyncio
async def test_crew_done_does_not_timeout_memory_save() -> None:
app = _app_with_plan()
async with app.run_test(size=(100, 40)) as pilot:
app._log_entries = [
{
"tool_name": "memory_save",
"status": "running",
"args": "9 memories (background)",
"result": None,
"error": None,
"start_time": time.time() - 8,
"duration": None,
"task_idx": 1,
},
{
"tool_name": "search",
"status": "running",
"args": '{"query": "CrewAI"}',
"result": None,
"error": None,
"start_time": time.time() - 2,
"duration": None,
"task_idx": 1,
},
]
app._on_crew_done("final output")
await pilot.pause()
assert app._log_entries[0]["status"] == "running"
assert app._log_entries[0]["error"] is None
assert app._log_entries[1]["status"] == "timeout"
assert app._log_entries[1]["error"] == "No result received before crew completed"
@pytest.mark.asyncio
async def test_crew_done_keeps_memory_save_subscription_until_completion(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(
"crewai_cli.crew_run_tui._MEMORY_SAVE_DRAIN_GRACE_SECONDS", 0.05
)
app = _app_with_plan()
auto_unsubscribed = False
async with app.run_test(size=(100, 40)) as pilot:
try:
assert app._event_handlers
started_event = MemorySaveStartedEvent(
value="9 memories (background)",
metadata={},
source_type="unified_memory",
)
_emit_event(started_event)
app._on_crew_done("final output")
await pilot.pause()
assert app._log_entries[0]["status"] == "running"
assert app._event_handlers
_emit_event(
MemorySaveCompletedEvent(
value="9 memories saved",
metadata={},
save_time_ms=8300,
source_type="unified_memory",
started_event_id=started_event.event_id,
)
)
await pilot.pause()
assert app._event_handlers
await pilot.pause(0.08)
auto_unsubscribed = not app._event_handlers
finally:
app._unsubscribe()
assert app._log_entries[0]["tool_name"] == "memory_save"
assert app._log_entries[0]["status"] == "success"
assert app._log_entries[0]["result"] == "9 memories saved"
assert app._log_entries[0]["error"] is None
assert app._log_entries[0]["duration"] == 8.3
assert auto_unsubscribed is True
@pytest.mark.asyncio
async def test_crew_done_waits_for_queued_memory_save_events(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(
"crewai_cli.crew_run_tui._MEMORY_SAVE_DRAIN_GRACE_SECONDS", 0.05
)
app = _app_with_plan()
auto_unsubscribed = False
async with app.run_test(size=(100, 40)) as pilot:
try:
assert app._event_handlers
app._on_crew_done("final output")
assert app._event_handlers
started_event = MemorySaveStartedEvent(
value="9 memories (background)",
metadata={},
source_type="unified_memory",
parent_event_id="manual-parent",
)
_emit_event(started_event)
await pilot.pause()
assert app._log_entries[0]["tool_name"] == "memory_save"
assert app._log_entries[0]["status"] == "running"
_emit_event(
MemorySaveCompletedEvent(
value="9 memories saved",
metadata={},
save_time_ms=8300,
source_type="unified_memory",
parent_event_id="manual-parent",
started_event_id=started_event.event_id,
)
)
await pilot.pause()
assert app._event_handlers
await pilot.pause(0.08)
auto_unsubscribed = not app._event_handlers
finally:
app._unsubscribe()
assert app._log_entries[0]["tool_name"] == "memory_save"
assert app._log_entries[0]["status"] == "success"
assert app._log_entries[0]["args"] == "9 memories (background)"
assert app._log_entries[0]["result"] == "9 memories saved"
assert app._log_entries[0]["error"] is None
assert app._log_entries[0]["duration"] == 8.3
assert auto_unsubscribed is True
@pytest.mark.asyncio
async def test_crew_failed_does_not_timeout_memory_save() -> None:
app = _app_with_plan()
async with app.run_test(size=(100, 40)) as pilot:
app._log_entries = [
{
"tool_name": "memory_save",
"status": "running",
"args": "9 memories (background)",
"result": None,
"error": None,
"start_time": time.time() - 8,
"duration": None,
"task_idx": 1,
},
{
"tool_name": "search",
"status": "running",
"args": '{"query": "CrewAI"}',
"result": None,
"error": None,
"start_time": time.time() - 2,
"duration": None,
"task_idx": 1,
},
]
app._on_crew_failed("boom")
await pilot.pause()
assert app._log_entries[0]["status"] == "running"
assert app._log_entries[0]["error"] is None
assert app._log_entries[1]["status"] == "error"
assert app._log_entries[1]["error"] == "No result received before crew failed"
def test_streamed_step_observation_updates_named_step_only() -> None:
app = _app_with_plan()

View File

@@ -5,33 +5,12 @@ from pathlib import Path
import subprocess
import sys
import click
import pytest
from crewai_core.constants import CREWAI_TRAINED_AGENTS_FILE_ENV
import crewai_cli.run_crew as run_crew_module
def test_missing_crewai_package_shows_full_install_hint(monkeypatch):
def missing_crewai_package():
raise ModuleNotFoundError("No module named 'crewai'", name="crewai")
monkeypatch.setattr(
run_crew_module, "_import_find_crew_json_file", missing_crewai_package
)
with pytest.raises(click.ClickException) as exc_info:
run_crew_module.find_crew_json_file()
message = exc_info.value.message
assert "CrewAI CLI is installed without the `crewai` package" in message
assert (
"uv tool install --force --prerelease=allow 'crewai[tools]==1.14.8a1'"
in message
)
assert "quotes are required in zsh" in message
def test_run_crew_forwards_trained_agents_file_to_json_crews(monkeypatch):
"""crewai run -f must reach JSON crews, not only classic subprocess crews."""
monkeypatch.setattr(run_crew_module, "_has_json_crew", lambda: True)

View File

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

View File

@@ -9,7 +9,7 @@ authors = [
requires-python = ">=3.10, <3.14"
dependencies = [
"Pillow~=12.1.1",
"pypdf~=6.13.3",
"pypdf~=6.10.0",
"python-magic>=0.4.27",
"aiocache~=0.12.3",
"aiofiles~=24.1.0",
@@ -19,8 +19,6 @@ dependencies = [
[tool.uv]
exclude-newer = "3 days"
# pypdf 6.13.3 is a security fix newer than the global supply-chain cutoff.
exclude-newer-package = { pypdf = "2026-06-18T00:00:00Z" }
[build-system]
requires = ["hatchling"]

View File

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

View File

@@ -10,7 +10,7 @@ requires-python = ">=3.10, <3.14"
dependencies = [
"pytube~=15.0.0",
"requests>=2.33.0,<3",
"crewai==1.14.8a1",
"crewai==1.14.7",
"tiktoken>=0.8.0,<0.13",
"beautifulsoup4~=4.13.4",
"python-docx~=1.2.0",

View File

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

View File

@@ -32,8 +32,6 @@ class ToolSpecExtractor:
if name.endswith("Tool") and name not in self.processed_tools:
obj = getattr(tools, name, None)
if inspect.isclass(obj) and issubclass(obj, BaseTool):
if getattr(obj, "is_deprecated_alias", False):
continue
self.extract_tool_info(obj)
self.processed_tools.add(name)
return self.tools_spec

View File

@@ -2,7 +2,7 @@ from __future__ import annotations
from builtins import type as type_
import os
from typing import Any, ClassVar, TypedDict
from typing import Any, TypedDict
import warnings
from crewai.tools import BaseTool, EnvVar
@@ -160,8 +160,6 @@ class ExaSearchTool(BaseTool):
class EXASearchTool(ExaSearchTool):
"""Deprecated alias for :class:`ExaSearchTool`. Kept for backwards compatibility."""
is_deprecated_alias: ClassVar[bool] = True
name: str = "ExaSearchTool"
def __init__(self, *args: Any, **kwargs: Any) -> None:

View File

@@ -1,4 +1,3 @@
import builtins
import json
from unittest import mock
@@ -8,19 +7,6 @@ from pydantic import BaseModel, Field
import pytest
def _getattr_for(tool_name, tool_cls):
"""Build a getattr side_effect that resolves the patched tool name to
``tool_cls`` while delegating every other lookup (e.g. the
``is_deprecated_alias`` check) to the real builtin."""
def _getattr(obj, name, *default):
if name == tool_name:
return tool_cls
return builtins.getattr(obj, name, *default)
return _getattr
class MockToolSchema(BaseModel):
query: str = Field(..., description="The query parameter")
count: int = Field(5, description="Number of results to return")
@@ -98,10 +84,7 @@ def test_unwrap_schema(extractor):
def mock_tool_extractor(extractor):
with (
mock.patch("crewai_tools.generate_tool_specs.dir", return_value=["MockTool"]),
mock.patch(
"crewai_tools.generate_tool_specs.getattr",
side_effect=_getattr_for("MockTool", MockTool),
),
mock.patch("crewai_tools.generate_tool_specs.getattr", return_value=MockTool),
):
extractor.extract_all_tools()
assert len(extractor.tools_spec) == 1
@@ -240,7 +223,7 @@ def test_intermediate_base_fields_preserved_for_derived_tool(extractor):
),
mock.patch(
"crewai_tools.generate_tool_specs.getattr",
side_effect=_getattr_for("MockDerivedTool", MockDerivedTool),
return_value=MockDerivedTool,
),
):
extractor.extract_all_tools()
@@ -270,10 +253,7 @@ def test_future_base_tool_field_auto_excluded(extractor):
by checking that ONLY non-BaseTool fields appear."""
with (
mock.patch("crewai_tools.generate_tool_specs.dir", return_value=["MockTool"]),
mock.patch(
"crewai_tools.generate_tool_specs.getattr",
side_effect=_getattr_for("MockTool", MockTool),
),
mock.patch("crewai_tools.generate_tool_specs.getattr", return_value=MockTool),
):
extractor.extract_all_tools()
tool_info = extractor.tools_spec[0]

View File

@@ -111,11 +111,3 @@ def test_exasearchtool_alias_is_deprecated():
with pytest.warns(DeprecationWarning, match="ExaSearchTool"):
tool = EXASearchTool(api_key="test_api_key")
assert isinstance(tool, ExaSearchTool)
def test_deprecated_alias_excluded_from_tool_specs():
from crewai_tools.generate_tool_specs import ToolSpecExtractor
names = {tool["name"] for tool in ToolSpecExtractor().extract_all_tools()}
assert "ExaSearchTool" in names
assert "EXASearchTool" not in names

View File

@@ -9622,6 +9622,225 @@
"type": "object"
}
},
{
"description": "Search the web with Exa, the fastest and most accurate web search API.",
"env_vars": [
{
"default": null,
"description": "API key for Exa services",
"name": "EXA_API_KEY",
"required": false
},
{
"default": null,
"description": "API url for the Exa services",
"name": "EXA_BASE_URL",
"required": false
}
],
"humanized_name": "ExaSearchTool",
"init_params_schema": {
"$defs": {
"EnvVar": {
"properties": {
"default": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"title": "Default"
},
"description": {
"title": "Description",
"type": "string"
},
"name": {
"title": "Name",
"type": "string"
},
"required": {
"default": true,
"title": "Required",
"type": "boolean"
}
},
"required": [
"name",
"description"
],
"title": "EnvVar",
"type": "object"
}
},
"description": "Deprecated alias for :class:`ExaSearchTool`. Kept for backwards compatibility.",
"properties": {
"api_key": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"description": "API key for Exa services",
"required": false,
"title": "Api Key"
},
"base_url": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"description": "API server url",
"required": false,
"title": "Base Url"
},
"client": {
"anyOf": [
{},
{
"type": "null"
}
],
"default": null,
"title": "Client"
},
"content": {
"anyOf": [
{
"type": "boolean"
},
{
"additionalProperties": true,
"type": "object"
},
{
"type": "null"
}
],
"default": false,
"title": "Content"
},
"highlights": {
"anyOf": [
{
"type": "boolean"
},
{
"additionalProperties": true,
"type": "object"
},
{
"type": "null"
}
],
"default": true,
"title": "Highlights"
},
"summary": {
"anyOf": [
{
"type": "boolean"
},
{
"additionalProperties": true,
"type": "object"
},
{
"type": "null"
}
],
"default": false,
"title": "Summary"
},
"type": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": "auto",
"title": "Type"
}
},
"required": [],
"title": "EXASearchTool",
"type": "object"
},
"name": "EXASearchTool",
"package_dependencies": [
"exa_py"
],
"run_params_schema": {
"properties": {
"end_published_date": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"description": "End date for the search",
"title": "End Published Date"
},
"include_domains": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": "null"
}
],
"default": null,
"description": "List of domains to include in the search",
"title": "Include Domains"
},
"search_query": {
"description": "Mandatory search query you want to use to search the internet",
"title": "Search Query",
"type": "string"
},
"start_published_date": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"description": "Start date for the search",
"title": "Start Published Date"
}
},
"required": [
"search_query"
],
"title": "ExaBaseToolSchema",
"type": "object"
}
},
{
"description": "Search the web with Exa, the fastest and most accurate web search API.",
"env_vars": [

View File

@@ -8,8 +8,8 @@ authors = [
]
requires-python = ">=3.10, <3.14"
dependencies = [
"crewai-core==1.14.8a1",
"crewai-cli==1.14.8a1",
"crewai-core==1.14.7",
"crewai-cli==1.14.7",
# 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.14.8a1",
"crewai-tools==1.14.7",
]
embeddings = [
"tiktoken>=0.8.0,<0.13"

View File

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

View File

@@ -10,7 +10,6 @@ from crewai.flow.conversation import (
ConversationalInputs,
)
from crewai.flow.dsl import HumanFeedbackResult, human_feedback
from crewai.flow.expressions import Expression
from crewai.flow.flow import Flow, and_, listen, or_, router, start
from crewai.flow.flow_config import flow_config
from crewai.flow.input_provider import InputProvider, InputResponse
@@ -27,7 +26,6 @@ __all__ = [
"ConsoleProvider",
"ConversationalConfig",
"ConversationalInputs",
"Expression",
"Flow",
"FlowStructure",
"HumanFeedbackPending",

View File

@@ -14,6 +14,7 @@ from crewai.flow.flow_definition import (
FlowConversationalDefinition,
FlowConversationalRouterDefinition,
FlowDefinition,
FlowDefinitionDiagnostic,
FlowDictStateDefinition,
FlowHumanFeedbackDefinition,
FlowMethodDefinition,
@@ -22,7 +23,6 @@ from crewai.flow.flow_definition import (
FlowStateDefinition,
FlowUnknownStateDefinition,
_object_ref,
log_flow_definition_issues,
)
from crewai.flow.flow_wrappers import (
FlowMethod,
@@ -116,6 +116,7 @@ def _is_json_serializable(value: Any) -> bool:
def _serialize_static_value(
value: Any,
diagnostics: list[FlowDefinitionDiagnostic],
path: str,
) -> Any:
if value is None or _is_json_serializable(value):
@@ -147,11 +148,12 @@ def _serialize_static_value(
)
ref = _object_ref(value)
logger.warning(
"Flow definition value at %s is not fully serializable; "
"preserved import reference %s.",
path,
ref,
diagnostics.append(
FlowDefinitionDiagnostic(
code="non_serializable_value",
path=path,
message=f"value is not fully serializable; preserved import reference {ref}",
)
)
return {"ref": ref}
@@ -167,7 +169,10 @@ def _state_ref(value: Any) -> str | None:
return None
def _build_state_definition(flow_class: type) -> FlowStateDefinition | None:
def _build_state_definition(
flow_class: type,
diagnostics: list[FlowDefinitionDiagnostic],
) -> FlowStateDefinition | None:
from pydantic import BaseModel as PydanticBaseModel
state_value = getattr(flow_class, "_initial_state_t", None)
@@ -182,23 +187,29 @@ def _build_state_definition(flow_class: type) -> FlowStateDefinition | None:
if state_value is dict or isinstance(state_value, dict):
default = None
if isinstance(state_value, dict):
default = _serialize_static_value(state_value, "state.default")
default = _serialize_static_value(state_value, diagnostics, "state.default")
return FlowDictStateDefinition(default=default)
if isinstance(state_value, type) and issubclass(state_value, PydanticBaseModel):
return FlowPydanticStateDefinition(ref=_state_ref(state_value))
if isinstance(state_value, PydanticBaseModel):
return FlowPydanticStateDefinition(
ref=_state_ref(state_value),
default=_serialize_static_value(state_value, "state.default"),
default=_serialize_static_value(state_value, diagnostics, "state.default"),
)
diagnostics.append(
FlowDefinitionDiagnostic(
code="unknown_state_type",
path="state",
message=f"could not serialize state type {_object_ref(state_value)}",
)
logger.warning(
"Flow definition state could not serialize state type %s.",
_object_ref(state_value),
)
return FlowUnknownStateDefinition(ref=_state_ref(state_value))
def _build_config_definition(flow_class: type) -> FlowConfigDefinition:
def _build_config_definition(
flow_class: type,
diagnostics: list[FlowDefinitionDiagnostic],
) -> FlowConfigDefinition:
config_field_names = set(FlowConfigDefinition.model_fields)
field_defaults = {
name: field.get_default(call_default_factory=True)
@@ -214,12 +225,15 @@ def _build_config_definition(flow_class: type) -> FlowConfigDefinition:
value if value is None or isinstance(value, str) else _object_ref(value)
)
else:
values[field_name] = _serialize_static_value(value, f"config.{field_name}")
values[field_name] = _serialize_static_value(
value, diagnostics, f"config.{field_name}"
)
return FlowConfigDefinition(**values)
def _build_human_feedback_definition(
method: Any,
diagnostics: list[FlowDefinitionDiagnostic],
path: str,
) -> FlowHumanFeedbackDefinition | None:
config = getattr(method, "__human_feedback_config__", None)
@@ -234,7 +248,7 @@ def _build_human_feedback_definition(
llm=getattr(config, "llm", None),
default_outcome=getattr(config, "default_outcome", None),
metadata=_serialize_static_value(
getattr(config, "metadata", None), f"{path}.metadata"
getattr(config, "metadata", None), diagnostics, f"{path}.metadata"
),
provider=getattr(config, "provider", None),
learn=bool(getattr(config, "learn", False)),
@@ -259,6 +273,7 @@ def _build_persistence_definition(value: Any) -> FlowPersistenceDefinition | Non
def _build_conversational_router_definition(
router_config: Any,
diagnostics: list[FlowDefinitionDiagnostic],
path: str,
) -> FlowConversationalRouterDefinition | None:
if router_config is None:
@@ -269,9 +284,12 @@ def _build_conversational_router_definition(
prompt=getattr(router_config, "prompt", None),
response_format=_serialize_static_value(
getattr(router_config, "response_format", None),
diagnostics,
f"{path}.response_format",
),
llm=_serialize_static_value(getattr(router_config, "llm", None), f"{path}.llm"),
llm=_serialize_static_value(
getattr(router_config, "llm", None), diagnostics, f"{path}.llm"
),
routes=[str(route) for route in routes] if routes is not None else None,
route_descriptions=getattr(router_config, "route_descriptions", None),
default_intent=getattr(router_config, "default_intent", "converse"),
@@ -282,6 +300,7 @@ def _build_conversational_router_definition(
def _build_conversational_definition(
flow_class: type,
diagnostics: list[FlowDefinitionDiagnostic],
) -> FlowConversationalDefinition | None:
if not _is_conversational_flow(flow_class):
return None
@@ -305,9 +324,12 @@ def _build_conversational_definition(
return FlowConversationalDefinition(
enabled=True,
system_prompt=getattr(config, "system_prompt", None),
llm=_serialize_static_value(getattr(config, "llm", None), "conversational.llm"),
llm=_serialize_static_value(
getattr(config, "llm", None), diagnostics, "conversational.llm"
),
router=_build_conversational_router_definition(
getattr(config, "router", None),
diagnostics,
"conversational.router",
),
answer_from_history_prompt=getattr(config, "answer_from_history_prompt", None),
@@ -318,10 +340,12 @@ def _build_conversational_definition(
),
intent_llm=_serialize_static_value(
getattr(config, "intent_llm", None),
diagnostics,
"conversational.intent_llm",
),
answer_from_history_llm=_serialize_static_value(
getattr(config, "answer_from_history_llm", None),
diagnostics,
"conversational.answer_from_history_llm",
),
visible_agent_outputs=(
@@ -341,6 +365,7 @@ def _build_conversational_definition(
def _build_method_definition(
method: Any,
diagnostics: list[FlowDefinitionDiagnostic],
path: str,
) -> FlowMethodDefinition:
fragment = _get_flow_method_definition(method)
@@ -351,7 +376,9 @@ def _build_method_definition(
deep=True, update={"do": _method_action(method)}
)
human_feedback = _build_human_feedback_definition(method, f"{path}.human_feedback")
human_feedback = _build_human_feedback_definition(
method, diagnostics, f"{path}.human_feedback"
)
if human_feedback is not None:
method_definition.human_feedback = human_feedback
if human_feedback.emit:
@@ -417,6 +444,7 @@ def _build_flow_definition_from_class(
flow_class: type,
namespace: dict[str, Any] | None = None,
) -> FlowDefinition:
diagnostics: list[FlowDefinitionDiagnostic] = []
methods: dict[str, FlowMethodDefinition] = {}
flow_methods = _iter_flow_methods(flow_class)
if namespace is not None:
@@ -428,7 +456,7 @@ def _build_flow_definition_from_class(
for method_name, method in flow_methods.items():
methods[method_name] = _build_method_definition(
method, f"methods.{method_name}"
method, diagnostics, f"methods.{method_name}"
)
description = None
@@ -439,13 +467,15 @@ def _build_flow_definition_from_class(
definition = FlowDefinition(
name=getattr(flow_class, "__name__", "Flow"),
description=description,
state=_build_state_definition(flow_class),
config=_build_config_definition(flow_class),
state=_build_state_definition(flow_class, diagnostics),
config=_build_config_definition(flow_class, diagnostics),
persist=_build_persistence_definition(flow_class),
conversational=_build_conversational_definition(flow_class),
conversational=_build_conversational_definition(flow_class, diagnostics),
methods=methods,
diagnostics=diagnostics,
)
log_flow_definition_issues(definition)
definition.diagnostics.extend(definition.validate_contract())
definition.log_diagnostics()
return definition

View File

@@ -1,329 +0,0 @@
"""Runtime expression support for FlowDefinition CEL expressions."""
from __future__ import annotations
from collections.abc import Iterable
import json
from typing import TYPE_CHECKING, Any, TypeAlias, cast
from crewai.utilities.serialization import to_serializable
if TYPE_CHECKING:
from crewai.flow.runtime import Flow
else:
from typing_extensions import TypeAliasType
_CEL_MACROS_WITH_LOCAL_BINDINGS = frozenset(
{"all", "exists", "exists_one", "filter", "map"}
)
if TYPE_CHECKING:
ExpressionData: TypeAlias = (
str
| int
| float
| bool
| None
| list["ExpressionData"]
| dict[str, "ExpressionData"]
)
else:
ExpressionData = TypeAliasType(
"ExpressionData",
str
| int
| float
| bool
| None
| list["ExpressionData"]
| dict[str, "ExpressionData"],
)
__all__ = [
"Expression",
"ExpressionData",
"ExpressionError",
]
class ExpressionError(ValueError):
"""An expression failed to parse, validate, render, or evaluate."""
class Expression:
"""CEL expression helper used for definition-time checks and runtime rendering."""
def __init__(
self, value: ExpressionData, *, context: dict[str, Any] | None = None
) -> None:
self.value = value
self.context = context
@classmethod
def from_flow(
cls,
value: ExpressionData,
flow: Flow[Any],
*,
local_context: dict[str, Any] | None = None,
) -> Expression:
"""Build an expression with the standard Flow runtime context."""
return cls(value, context=cls._flow_context(flow, local_context=local_context))
def validate_expression(
self,
*,
allowed_roots: Iterable[str],
source: str = "CEL expression",
) -> None:
"""Validate a full CEL expression without evaluating it."""
allowed = frozenset(allowed_roots)
expression = self._require_cel_source(cast(str, self.value), source=source)
roots = self._collect_root_identifiers(
self._compile_cel(expression, source=source)
)
unknown = sorted(root for root in roots if root not in allowed)
if unknown:
allowed_list = ", ".join(sorted(allowed))
unknown_list = ", ".join(repr(root) for root in unknown)
raise ExpressionError(
f"unknown CEL root at {source}: {unknown_list}; "
f"allowed roots: {allowed_list}. Reference flow data through one "
"of those roots, for example state.field or outputs.step_name."
)
def validate_template(
self,
*,
allowed_roots: Iterable[str],
source: str = "with block",
) -> None:
"""Validate nested strings fully wrapped in ``${...}`` as CEL."""
self._validate_template_value(
self.value, allowed_roots=allowed_roots, source=source
)
def evaluate(self, context: dict[str, Any] | None = None) -> Any:
"""Evaluate this value as a full CEL expression."""
resolved_context = self.context if context is None else context
return self._evaluate_cel(
self._require_cel_source(cast(str, self.value)),
resolved_context or {},
)
def render_template(self, context: dict[str, Any] | None = None) -> Any:
"""Evaluate nested strings fully wrapped in ``${...}`` as CEL."""
resolved_context = self.context if context is None else context
return self._render_template_value(self.value, resolved_context or {})
@staticmethod
def _validate_template_value(
value: ExpressionData,
*,
allowed_roots: Iterable[str],
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
)
return
if isinstance(value, dict):
for key, item in value.items():
item_source = f"{source}.{key}" if isinstance(key, str) else source
Expression._validate_template_value(
item, allowed_roots=allowed_roots, source=item_source
)
return
if isinstance(value, list):
for index, item in enumerate(value):
Expression._validate_template_value(
item,
allowed_roots=allowed_roots,
source=f"{source}[{index}]",
)
@staticmethod
def _flow_context(
flow: Flow[Any], local_context: dict[str, Any] | None = None
) -> dict[str, Any]:
from crewai.flow.runtime._outputs import outputs_by_name
local_outputs = local_context.get("outputs") if local_context else None
outputs = outputs_by_name(
flow._method_outputs,
local_outputs=local_outputs,
serialize=True,
)
context: dict[str, Any] = {
"state": flow._copy_and_serialize_state(),
"outputs": outputs,
}
if local_context:
context.update(
{
key: to_serializable(value, max_depth=0)
for key, value in local_context.items()
if key not in {"outputs", "state"}
}
)
return context
@staticmethod
def _render_template_value(value: ExpressionData, context: dict[str, Any]) -> Any:
if isinstance(value, str):
return Expression._render_template_string(value, context)
if isinstance(value, dict):
return {
key: Expression._render_template_value(item, context)
for key, item in value.items()
}
if isinstance(value, list):
return [Expression._render_template_value(item, context) for item in value]
return value
@staticmethod
def _render_template_string(value: str, context: dict[str, Any]) -> Any:
expression = Expression._expression_marker_source(value)
if expression is None:
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
@staticmethod
def _evaluate_cel(expression: str, context: dict[str, Any]) -> Any:
try:
from celpy import Environment
from celpy.adapter import CELJSONEncoder, json_to_cel
from celpy.evaluation import Context
environment = Environment()
program = environment.program(
Expression._compile_cel(expression, environment=environment)
)
result = program.evaluate(cast(Context, json_to_cel(context)))
return json.loads(json.dumps(result, cls=CELJSONEncoder))
except Exception as e:
raise ExpressionError(
f"failed to evaluate CEL expression {expression!r}: {e}"
) from e
@staticmethod
def _compile_cel(
expression: str,
*,
source: str | None = None,
environment: Any | None = None,
) -> Any:
if environment is None:
from celpy import Environment
environment = Environment()
try:
return environment.compile(expression)
except Exception as e:
if source is None:
raise
raise ExpressionError(
f"invalid CEL expression at {source}: {expression!r}. "
f"Check the CEL syntax. Parser details: {e}"
) from e
@staticmethod
def _require_cel_source(value: str, *, source: str | None = None) -> str:
expression = value.strip()
if expression.startswith("${") and expression.endswith("}"):
expression = expression[2:-1].strip()
if expression:
return expression
if source is None:
raise ExpressionError("empty CEL expression")
raise ExpressionError(
f"empty CEL expression at {source}. Provide a CEL expression such as "
"state.topic or outputs.step_name."
)
@staticmethod
def _collect_root_identifiers(
tree: Any, local_roots: frozenset[str] = frozenset()
) -> set[str]:
"""Collect CEL root identifiers, excluding receiver macro local variables."""
data = getattr(tree, "data", None)
children = list(getattr(tree, "children", []) or [])
if data == "ident" and children:
name = str(children[0])
return set() if name in local_roots else {name}
if data == "ident_arg":
return Expression._collect_root_identifiers_from(
children[1:], local_roots=local_roots
)
if data == "member_dot_arg":
roots = (
Expression._collect_root_identifiers(children[0], local_roots)
if children
else set()
)
nested_locals = frozenset(
{*local_roots, *Expression._receiver_macro_local_roots(children)}
)
roots.update(
Expression._collect_root_identifiers_from(
children[2:], local_roots=nested_locals
)
)
return roots
return Expression._collect_root_identifiers_from(
children, local_roots=local_roots
)
@staticmethod
def _collect_root_identifiers_from(
trees: Iterable[Any], *, local_roots: frozenset[str]
) -> set[str]:
return set().union(
*(Expression._collect_root_identifiers(tree, local_roots) for tree in trees)
)
@staticmethod
def _receiver_macro_local_roots(children: list[Any]) -> set[str]:
if len(children) < 3 or str(children[1]) not in _CEL_MACROS_WITH_LOCAL_BINDINGS:
return set()
exprlist = children[2]
exprs = list(getattr(exprlist, "children", []) or [])
if exprs and (name := Expression._single_identifier_name(exprs[0])):
return {name}
return set()
@staticmethod
def _single_identifier_name(tree: Any) -> str | None:
data = getattr(tree, "data", None)
children = list(getattr(tree, "children", []) or [])
if data == "ident" and children:
return str(children[0])
if len(children) != 1:
return None
return Expression._single_identifier_name(children[0])

View File

@@ -12,12 +12,13 @@ from __future__ import annotations
import json
import logging
import re
from typing import Annotated, Any, Literal, TypeAlias, cast
from typing import Annotated, Any, Literal as TypingLiteral, TypeAlias
from pydantic import (
BaseModel,
ConfigDict,
Field,
RootModel,
field_serializer,
model_validator,
)
@@ -27,21 +28,16 @@ from crewai.flow.conversational_definition import (
FlowConversationalDefinition,
FlowConversationalRouterDefinition,
)
from crewai.flow.expressions import ExpressionData
from crewai.project.crew_definition import AgentDefinition, CrewDefinition
from crewai.project.crew_definition import CrewDefinition
logger = logging.getLogger(__name__)
FlowDefinitionCondition = str | dict[str, Any]
_STEP_NAME_PATTERN = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
_BASE_CEL_ROOTS = frozenset({"outputs", "state"})
_EACH_STEP_CEL_ROOTS = frozenset({"item", "outputs", "state"})
__all__ = [
"FlowActionDefinition",
"FlowAgentActionDefinition",
"FlowAtomicActionDefinition",
"FlowCodeActionDefinition",
"FlowConfigDefinition",
"FlowConversationalDefinition",
@@ -49,16 +45,16 @@ __all__ = [
"FlowCrewActionDefinition",
"FlowDefinition",
"FlowDefinitionCondition",
"FlowDefinitionDiagnostic",
"FlowDictStateDefinition",
"FlowEachActionDefinition",
"FlowEachStepDefinition",
"FlowEachInnerActionDefinition",
"FlowExpressionActionDefinition",
"FlowHumanFeedbackDefinition",
"FlowJsonSchemaStateDefinition",
"FlowMethodDefinition",
"FlowPersistenceDefinition",
"FlowPydanticStateDefinition",
"FlowScriptActionDefinition",
"FlowStateDefinition",
"FlowToolActionDefinition",
"FlowUnknownStateDefinition",
@@ -73,12 +69,21 @@ def _object_ref(value: Any) -> str:
return f"{module}:{qualname}" if module and qualname else repr(value)
class FlowDefinitionDiagnostic(BaseModel):
"""A non-fatal Flow Definition build or validation diagnostic."""
code: str
message: str
severity: TypingLiteral["warning", "error"] = "warning"
path: str | None = None
class FlowDictStateDefinition(BaseModel):
"""Static description of a plain dictionary Flow state contract."""
model_config = ConfigDict(extra="forbid")
type: Literal["dict"] = Field(
type: TypingLiteral["dict"] = Field(
default="dict",
description="Plain dictionary state with optional default values.",
examples=["dict"],
@@ -95,7 +100,7 @@ class FlowPydanticStateDefinition(BaseModel):
model_config = ConfigDict(extra="forbid")
type: Literal["pydantic"] = Field(
type: TypingLiteral["pydantic"] = Field(
default="pydantic",
description="Importable Pydantic model used as the Flow state type.",
examples=["pydantic"],
@@ -130,7 +135,7 @@ class FlowJsonSchemaStateDefinition(BaseModel):
model_config = ConfigDict(extra="forbid")
type: Literal["json_schema"] = Field(
type: TypingLiteral["json_schema"] = Field(
default="json_schema",
description="Inline JSON Schema used as the Flow state contract.",
examples=["json_schema"],
@@ -157,7 +162,7 @@ class FlowUnknownStateDefinition(BaseModel):
model_config = ConfigDict(extra="forbid")
type: Literal["unknown"] = Field(
type: TypingLiteral["unknown"] = Field(
default="unknown",
description="Unknown state representation; runtime falls back to dictionary state.",
examples=["unknown"],
@@ -186,46 +191,14 @@ FlowStateDefinition: TypeAlias = Annotated[
class FlowConfigDefinition(BaseModel):
"""Serializable Flow-level configuration."""
tracing: bool | None = Field(
default=None,
description="Override for flow tracing; when omitted, runtime defaults apply.",
examples=[True],
)
stream: bool = Field(
default=False,
description="Whether the flow should emit streaming events when supported.",
examples=[True],
)
memory: dict[str, Any] | None = Field(
default=None,
description="Serializable memory configuration passed to flow execution.",
examples=[{"enabled": True}],
)
input_provider: str | None = Field(
default=None,
description="Import reference or provider key used to supply flow inputs.",
examples=["my_project.inputs:load_inputs"],
)
suppress_flow_events: bool = Field(
default=False,
description="Disable flow event emission for this definition.",
examples=[False],
)
max_method_calls: int = Field(
default=100,
description="Maximum number of method executions allowed during one kickoff.",
examples=[20],
)
defer_trace_finalization: bool = Field(
default=False,
description="Defer trace finalization so callers can complete tracing later.",
examples=[False],
)
checkpoint: bool | dict[str, Any] | None = Field(
default=None,
description="Checkpointing configuration, or true to use default checkpointing.",
examples=[True, {"enabled": True}],
)
tracing: bool | None = None
stream: bool = False
memory: dict[str, Any] | None = None
input_provider: str | None = None
suppress_flow_events: bool = False
max_method_calls: int = 100
defer_trace_finalization: bool = False
checkpoint: bool | dict[str, Any] | None = None
class FlowPersistenceDefinition(BaseModel):
@@ -237,21 +210,9 @@ class FlowPersistenceDefinition(BaseModel):
serialized config.
"""
enabled: bool = Field(
default=False,
description="Whether persistence is enabled for this flow or method.",
examples=[True],
)
verbose: bool = Field(
default=False,
description="Whether persistence should emit verbose diagnostic output.",
examples=[False],
)
persistence: Any = Field(
default=None,
description="Persistence backend configuration or import reference.",
examples=[{"ref": "my_project.persistence:FlowStore"}],
)
enabled: bool = False
verbose: bool = False
persistence: Any = None
@field_serializer("persistence", when_used="json")
def _serialize_persistence(self, value: Any) -> Any:
@@ -277,53 +238,15 @@ class FlowHumanFeedbackDefinition(BaseModel):
a serialized config (``llm``) or a ``module:qualname`` ref (``provider``).
"""
message: str = Field(
description="Prompt shown to the human reviewer when feedback is requested.",
examples=["Review the research summary before publishing."],
)
emit: list[str] | None = Field(
default=None,
description=(
"Allowed feedback outcomes. When set, the method routes like a router "
"using the selected outcome."
),
examples=[["approved", "revise"]],
)
llm: Any = Field(
default="gpt-4o-mini",
description="LLM configuration used to assist or process human feedback.",
examples=["gpt-4o-mini"],
)
default_outcome: str | None = Field(
default=None,
description="Outcome to use when feedback cannot be collected.",
examples=["revise"],
)
metadata: dict[str, Any] | None = Field(
default=None,
description="Serializable metadata attached to the feedback request.",
examples=[{"team": "research"}],
)
provider: Any = Field(
default=None,
description="Feedback provider configuration or import reference.",
examples=["my_project.feedback:provider"],
)
learn: bool = Field(
default=False,
description="Whether feedback should be recorded for later learning workflows.",
examples=[True],
)
learn_source: str = Field(
default="hitl",
description="Source label attached to learned feedback records.",
examples=["hitl"],
)
learn_strict: bool = Field(
default=False,
description="Whether learning should enforce strict validation of feedback data.",
examples=[False],
)
message: str
emit: list[str] | None = None
llm: Any = "gpt-4o-mini"
default_outcome: str | None = None
metadata: dict[str, Any] | None = None
provider: Any = None
learn: bool = False
learn_source: str = "hitl"
learn_strict: bool = False
@field_serializer("llm", when_used="json")
def _serialize_llm(self, value: Any) -> dict[str, Any] | str | None:
@@ -343,124 +266,30 @@ class FlowHumanFeedbackDefinition(BaseModel):
class FlowCodeActionDefinition(BaseModel):
"""A Flow method action that executes importable Python code."""
model_config = ConfigDict(
populate_by_name=True,
extra="forbid",
)
model_config = ConfigDict(populate_by_name=True, extra="forbid")
call: Literal["code"] = Field(
default="code",
description="Action discriminator. Use code to call importable Python.",
examples=["code"],
)
ref: str = Field(
description="Import reference for the callable, formatted as module:qualname.",
examples=["my_project.flows:normalize_topic"],
)
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."
),
examples=[{"topic": "${state.topic}"}],
)
call: TypingLiteral["code"] = "code"
ref: str
with_: dict[str, Any] | None = Field(default=None, alias="with")
class FlowToolActionDefinition(BaseModel):
"""A Flow method action that invokes a CrewAI tool."""
model_config = ConfigDict(
populate_by_name=True,
extra="forbid",
)
model_config = ConfigDict(populate_by_name=True, extra="forbid")
call: Literal["tool"] = Field(
description="Action discriminator. Use tool to instantiate and run a CrewAI tool.",
examples=["tool"],
)
ref: str = Field(
description="Import reference for a BaseTool class, formatted as module:qualname.",
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."
),
examples=[{"query": "${outputs.normalize_topic}", "limit": 5}],
)
call: TypingLiteral["tool"]
ref: str
with_: dict[str, Any] | None = Field(default=None, alias="with")
class FlowCrewActionDefinition(BaseModel):
"""A Flow method action that builds and kicks off a CrewAI crew."""
model_config = ConfigDict(
populate_by_name=True,
extra="forbid",
)
model_config = ConfigDict(populate_by_name=True, extra="forbid")
call: Literal["crew"] = Field(
description="Action discriminator. Use crew to run an inline Crew definition.",
examples=["crew"],
)
with_: CrewDefinition = Field(
alias="with",
description="Inline Crew definition to load and execute for this action.",
examples=[
{
"name": "inline_research",
"agents": {
"researcher": {
"role": "Researcher",
"goal": "Research {topic}",
"backstory": "Knows the domain.",
}
},
"tasks": [
{
"name": "research_task",
"description": "Research {topic}",
"expected_output": "Findings about {topic}",
"agent": "researcher",
}
],
"inputs": {"topic": "${state.topic}"},
}
],
)
class FlowAgentActionDefinition(BaseModel):
"""A Flow method action that builds and kicks off a CrewAI agent."""
model_config = ConfigDict(
populate_by_name=True,
extra="forbid",
)
call: Literal["agent"] = Field(
description="Action discriminator. Use agent to run an inline Agent definition.",
examples=["agent"],
)
with_: AgentDefinition = Field(
alias="with",
description="Inline Agent definition to load and execute for this action.",
examples=[
{
"role": "Analyst",
"goal": "Answer user questions",
"backstory": "Precise and concise.",
"settings": {"llm": "openai/gpt-4o-mini"},
"input": "${state.question}",
}
],
)
call: TypingLiteral["crew"]
with_: CrewDefinition = Field(alias="with")
class FlowExpressionActionDefinition(BaseModel):
@@ -468,143 +297,66 @@ class FlowExpressionActionDefinition(BaseModel):
model_config = ConfigDict(extra="forbid")
call: Literal["expression"] = Field(
description="Action discriminator. Use expression to evaluate a CEL expression.",
examples=["expression"],
)
expr: str = Field(
description="CEL expression evaluated against state, outputs, and local context.",
examples=["state.topic", "outputs.normalize_topic"],
)
call: TypingLiteral["expression"]
expr: str
class FlowScriptActionDefinition(BaseModel):
"""A Flow method action that executes trusted inline Python."""
model_config = ConfigDict(extra="forbid")
call: Literal["script"] = Field(
description="Action discriminator. Use script to execute trusted inline Python.",
examples=["script"],
)
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."
),
examples=[
"state['normalized_topic'] = input.strip()\n"
"return state['normalized_topic']"
],
)
language: Literal["python"] = Field(
default="python",
description="Script language. Only python is currently supported.",
examples=["python"],
)
FlowAtomicActionDefinition: TypeAlias = Annotated[
FlowInnerActionDefinition = (
FlowCodeActionDefinition
| FlowToolActionDefinition
| FlowCrewActionDefinition
| FlowAgentActionDefinition
| FlowExpressionActionDefinition
| FlowScriptActionDefinition,
Field(discriminator="call"),
]
)
class FlowEachStepDefinition(BaseModel):
"""One named step inside an ``each`` composite action."""
model_config = ConfigDict(
populate_by_name=True,
extra="forbid",
)
name: str = Field(
description="Step name used to reference this step's output.",
examples=["clean"],
)
if_: str | None = Field(
default=None,
alias="if",
description=(
"Optional CEL expression evaluated against state, outputs, and local "
"context. When present, the step runs only if the expression evaluates "
"to true."
),
examples=["item.kind == 'invoice'"],
)
action: FlowAtomicActionDefinition = Field(
description="Atomic action to run for this step.",
examples=[{"call": "script", "code": "return item.strip()"}],
)
class FlowEachInnerActionDefinition(RootModel[dict[str, FlowInnerActionDefinition]]):
"""One named action inside an ``each`` composite action."""
@model_validator(mode="after")
def _validate_step_name(self) -> FlowEachStepDefinition:
_validate_step_name(self.name, field="each.do step names")
def _validate_action_mapping(self) -> FlowEachInnerActionDefinition:
if len(self.root) != 1:
raise ValueError("each.do entries must be one-key mappings")
_validate_step_name(self.name, field="each.do action names")
return self
@property
def name(self) -> str:
return next(iter(self.root))
@property
def action(self) -> FlowInnerActionDefinition:
return next(iter(self.root.values()))
class FlowEachActionDefinition(BaseModel):
"""A composite action that runs a sequential mini-pipeline for each item."""
model_config = ConfigDict(
populate_by_name=True,
extra="forbid",
)
model_config = ConfigDict(populate_by_name=True, extra="forbid")
call: Literal["each"] = Field(
description=(
"Action discriminator. Use each to run a sequence of actions for every "
"item in an input list."
),
examples=["each"],
)
in_: str = Field(
alias="in",
description="CEL expression that must evaluate to the list to iterate.",
examples=["state.rows"],
)
do: list[FlowEachStepDefinition] = Field(
description=(
"Ordered steps to run for each item. Each step has a name, optional "
"if expression, and atomic action."
),
examples=[
[
{
"name": "clean",
"action": {"call": "script", "code": "return item.strip()"},
},
{
"name": "tag",
"if": "outputs.clean != ''",
"action": {"call": "expression", "expr": "outputs.clean"},
},
]
],
)
call: TypingLiteral["each"]
in_: str = Field(alias="in")
do: list[FlowEachInnerActionDefinition]
@model_validator(mode="after")
def _validate_step_list(self) -> FlowEachActionDefinition:
def _validate_inner_action_list(self) -> FlowEachActionDefinition:
if not self.do:
raise ValueError("each.do must contain at least one step")
raise ValueError("each.do must contain at least one action")
seen: set[str] = set()
for inner_action in self.do:
name = inner_action.name
if name in seen:
raise ValueError(f"each.do action names must be unique: {name!r}")
seen.add(name)
_validate_step_list(self.do, field="each.do")
return self
FlowActionDefinition: TypeAlias = (
FlowActionDefinition = (
FlowCodeActionDefinition
| FlowToolActionDefinition
| FlowCrewActionDefinition
| FlowAgentActionDefinition
| FlowExpressionActionDefinition
| FlowScriptActionDefinition
| FlowEachActionDefinition
)
@@ -612,48 +364,14 @@ FlowActionDefinition: TypeAlias = (
class FlowMethodDefinition(BaseModel):
"""Static definition of one Flow method and its execution roles."""
description: str | None = Field(
default=None,
description="Human-readable summary of what this method does.",
examples=["Normalize the incoming topic."],
)
do: FlowActionDefinition = Field(
description="Action executed when this method runs.",
examples=[{"call": "script", "code": "return input.strip()"}],
)
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."
),
examples=[True],
)
listen: FlowDefinitionCondition | None = Field(
default=None,
description="Trigger condition that runs this method after upstream events.",
examples=["seed", {"or": ["approved", "revise"]}],
)
router: bool = Field(
default=False,
description="Whether the method output should be treated as the next event name.",
examples=[True],
)
emit: list[str] | None = Field(
default=None,
description="Declared router events this method may emit.",
examples=[["approved", "revise"]],
)
human_feedback: FlowHumanFeedbackDefinition | None = Field(
default=None,
description="Optional human feedback step applied after the method action.",
examples=[{"message": "Review the research summary before publishing."}],
)
persist: FlowPersistenceDefinition | None = Field(
default=None,
description="Method-level persistence override.",
examples=[{"enabled": True}],
)
description: str | None = None
do: FlowActionDefinition
start: bool | FlowDefinitionCondition | None = None
listen: FlowDefinitionCondition | None = None
router: bool = False
emit: list[str] | None = None
human_feedback: FlowHumanFeedbackDefinition | None = None
persist: FlowPersistenceDefinition | None = None
@model_validator(mode="after")
def _canonicalize_human_feedback_routing(self) -> FlowMethodDefinition:
@@ -679,57 +397,19 @@ class FlowMethodDefinition(BaseModel):
class FlowDefinition(BaseModel):
"""Static, serializable definition of a Flow."""
model_config = ConfigDict(
populate_by_name=True,
arbitrary_types_allowed=True,
)
model_config = ConfigDict(populate_by_name=True, arbitrary_types_allowed=True)
schema_: Literal["crewai.flow/v1"] = Field(
default="crewai.flow/v1",
alias="schema",
description="Flow Definition schema identifier and version.",
examples=["crewai.flow/v1"],
)
name: str = Field(
description="Unique flow name used in logs, events, and traces.",
examples=["ResearchFlow"],
)
description: str | None = Field(
default=None,
description="Human-readable summary of the flow.",
examples=["Normalize a topic and prepare it for research."],
)
state: FlowStateDefinition | None = Field(
default=None,
description="State contract for kickoff inputs and runtime state.",
examples=[{"type": "dict", "default": {"topic": "AI agents"}}],
)
config: FlowConfigDefinition = Field(
default_factory=FlowConfigDefinition,
description="Serializable flow-level runtime configuration.",
examples=[{"stream": True, "max_method_calls": 20}],
)
persist: FlowPersistenceDefinition | None = Field(
default=None,
description="Flow-level persistence configuration.",
examples=[{"enabled": True}],
)
conversational: FlowConversationalDefinition | None = Field(
default=None,
description="Conversational flow configuration, when the flow supports chat.",
)
methods: dict[str, FlowMethodDefinition] = Field(
default_factory=dict,
description="Mapping of method names to method definitions.",
examples=[
{
"seed": {
"start": True,
"do": {"call": "expression", "expr": "state.topic"},
}
}
],
schema_: TypingLiteral["crewai.flow/v1"] = Field(
default="crewai.flow/v1", alias="schema"
)
name: str
description: str | None = None
state: FlowStateDefinition | None = None
config: FlowConfigDefinition = Field(default_factory=FlowConfigDefinition)
persist: FlowPersistenceDefinition | None = None
conversational: FlowConversationalDefinition | None = None
methods: dict[str, FlowMethodDefinition] = Field(default_factory=dict)
diagnostics: list[FlowDefinitionDiagnostic] = Field(default_factory=list)
@model_validator(mode="after")
def _validate_method_names(self) -> FlowDefinition:
@@ -737,16 +417,6 @@ class FlowDefinition(BaseModel):
_validate_step_name(method_name, field="Flow method names")
return self
@model_validator(mode="after")
def _validate_cel_expressions(self) -> FlowDefinition:
for method_name, method in self.methods.items():
_validate_action_cel(
method.do,
path=f"methods.{method_name}.do",
allowed_roots=_BASE_CEL_ROOTS,
)
return self
def to_dict(self, *, exclude_none: bool = True) -> dict[str, Any]:
"""Serialize the definition to a JSON/YAML-ready dictionary."""
return self.model_dump(by_alias=True, exclude_none=exclude_none, mode="json")
@@ -766,9 +436,13 @@ class FlowDefinition(BaseModel):
@classmethod
def from_dict(cls, data: dict[str, Any]) -> FlowDefinition:
"""Load a definition from a dictionary."""
"""Load a definition from a dictionary and attach diagnostics."""
serialized_diagnostics = _deserialize_diagnostics(data.get("diagnostics", []))
definition = cls.model_validate(data)
log_flow_definition_issues(definition)
definition.diagnostics = _merge_diagnostics(
serialized_diagnostics, definition.validate_contract()
)
definition.log_diagnostics()
return definition
@classmethod
@@ -789,153 +463,122 @@ class FlowDefinition(BaseModel):
"""Return the JSON Schema for the Flow Definition contract."""
return cls.model_json_schema(by_alias=True)
def validate_contract(self) -> list[FlowDefinitionDiagnostic]:
"""Validate the static contract without rejecting dynamic routing."""
diagnostics: list[FlowDefinitionDiagnostic] = []
for method_name, method in self.methods.items():
path = f"methods.{method_name}"
if method.router and not method.is_start and method.listen is None:
diagnostics.append(
FlowDefinitionDiagnostic(
code="router_without_trigger",
severity="error",
path=path,
message="router: true requires either start or listen",
)
)
if method.emit and not method.router:
diagnostics.append(
FlowDefinitionDiagnostic(
code="emit_without_router",
path=f"{path}.emit",
message="emit is only used by routers to declare downstream events",
)
)
if method.human_feedback:
human_feedback_config = method.human_feedback
if human_feedback_config.emit and not human_feedback_config.llm:
diagnostics.append(
FlowDefinitionDiagnostic(
code="human_feedback_llm_required",
severity="error",
path=f"{path}.human_feedback.llm",
message="llm is required when human_feedback.emit is set",
)
)
if (
human_feedback_config.default_outcome is not None
and not human_feedback_config.emit
):
diagnostics.append(
FlowDefinitionDiagnostic(
code="human_feedback_default_requires_emit",
severity="error",
path=f"{path}.human_feedback.default_outcome",
message="default_outcome requires human_feedback.emit",
)
)
elif (
human_feedback_config.default_outcome is not None
and human_feedback_config.emit
):
if (
human_feedback_config.default_outcome
not in human_feedback_config.emit
):
diagnostics.append(
FlowDefinitionDiagnostic(
code="human_feedback_default_not_in_emit",
severity="error",
path=f"{path}.human_feedback.default_outcome",
message="default_outcome must be one of human_feedback.emit",
)
)
return diagnostics
def with_diagnostics(self) -> FlowDefinition:
"""Attach fresh diagnostics and return this definition."""
self.diagnostics = self.validate_contract()
self.log_diagnostics()
return self
def log_diagnostics(self) -> None:
"""Emit all attached diagnostics through the flow definition logger."""
_log_flow_definition_diagnostics(self.name, self.diagnostics)
def _log_flow_definition_diagnostics(
definition_name: str,
diagnostics: list[FlowDefinitionDiagnostic],
) -> None:
for diagnostic in diagnostics:
level = logging.ERROR if diagnostic.severity == "error" else logging.WARNING
path = f" at {diagnostic.path}" if diagnostic.path else ""
logger.log(
level,
"Flow definition diagnostic for %s%s [%s]: %s",
definition_name,
path,
diagnostic.code,
diagnostic.message,
)
def _deserialize_diagnostics(value: Any) -> list[FlowDefinitionDiagnostic]:
return [FlowDefinitionDiagnostic.model_validate(item) for item in value or []]
def _validate_step_name(name: str, *, field: str) -> None:
if not isinstance(name, str) or not _STEP_NAME_PATTERN.fullmatch(name):
raise ValueError(f"{field} must match {_STEP_NAME_PATTERN.pattern}")
def _validate_step_list(steps: list[FlowEachStepDefinition], *, field: str) -> None:
seen: set[str] = set()
for step in steps:
name = step.name
if name in seen:
raise ValueError(f"{field} step names must be unique: {name!r}")
seen.add(name)
def _validate_action_cel(
action: FlowActionDefinition,
*,
path: str,
allowed_roots: frozenset[str],
) -> None:
from crewai.flow.expressions import Expression
if isinstance(action, FlowExpressionActionDefinition):
Expression(action.expr).validate_expression(
allowed_roots=allowed_roots, source=f"{path}.expr"
)
return
if isinstance(action, (FlowCodeActionDefinition, FlowToolActionDefinition)):
if action.with_ is not None:
Expression(action.with_).validate_template(
allowed_roots=allowed_roots, source=f"{path}.with"
def _merge_diagnostics(
*diagnostic_groups: list[FlowDefinitionDiagnostic],
) -> list[FlowDefinitionDiagnostic]:
diagnostics: list[FlowDefinitionDiagnostic] = []
seen: set[tuple[str, str, str | None, str]] = set()
for group in diagnostic_groups:
for diagnostic in group:
key = (
diagnostic.code,
diagnostic.severity,
diagnostic.path,
diagnostic.message,
)
return
if isinstance(action, FlowCrewActionDefinition):
Expression(cast(ExpressionData, action.with_.inputs)).validate_template(
allowed_roots=allowed_roots,
source=f"{path}.with.inputs",
)
return
if isinstance(action, FlowAgentActionDefinition):
Expression(cast(ExpressionData, action.with_.input)).validate_template(
allowed_roots=allowed_roots,
source=f"{path}.with.input",
)
return
if isinstance(action, FlowEachActionDefinition):
Expression(action.in_).validate_expression(
allowed_roots=_BASE_CEL_ROOTS,
source=f"{path}.in",
)
for index, step in enumerate(action.do):
step_path = f"{path}.do[{index}]"
if step.if_ is not None:
Expression(step.if_).validate_expression(
allowed_roots=_EACH_STEP_CEL_ROOTS,
source=f"{step_path}.if",
)
_validate_action_cel(
step.action,
path=f"{step_path}.action",
allowed_roots=_EACH_STEP_CEL_ROOTS,
)
return
if isinstance(action, FlowScriptActionDefinition):
return
raise TypeError(
f"no CEL validation defined for action type {type(action).__name__} at "
f"{path}; add a branch to _validate_action_cel for it."
)
def log_flow_definition_issues(definition: FlowDefinition) -> None:
for method_name, method in definition.methods.items():
path = f"methods.{method_name}"
if method.router and not method.is_start and method.listen is None:
_log_flow_definition_issue(
definition.name,
code="router_without_trigger",
severity="error",
path=path,
message="router: true requires either start or listen",
)
if method.emit and not method.router:
_log_flow_definition_issue(
definition.name,
code="emit_without_router",
path=f"{path}.emit",
message="emit is only used by routers to declare downstream events",
)
if method.human_feedback:
human_feedback_config = method.human_feedback
if human_feedback_config.emit and not human_feedback_config.llm:
_log_flow_definition_issue(
definition.name,
code="human_feedback_llm_required",
severity="error",
path=f"{path}.human_feedback.llm",
message="llm is required when human_feedback.emit is set",
)
if (
human_feedback_config.default_outcome is not None
and not human_feedback_config.emit
):
_log_flow_definition_issue(
definition.name,
code="human_feedback_default_requires_emit",
severity="error",
path=f"{path}.human_feedback.default_outcome",
message="default_outcome requires human_feedback.emit",
)
elif (
human_feedback_config.default_outcome is not None
and human_feedback_config.emit
and human_feedback_config.default_outcome
not in human_feedback_config.emit
):
_log_flow_definition_issue(
definition.name,
code="human_feedback_default_not_in_emit",
severity="error",
path=f"{path}.human_feedback.default_outcome",
message="default_outcome must be one of human_feedback.emit",
)
def _log_flow_definition_issue(
definition_name: str,
*,
code: str,
message: str,
severity: Literal["warning", "error"] = "warning",
path: str | None = None,
) -> None:
level = logging.ERROR if severity == "error" else logging.WARNING
location = f" at {path}" if path else ""
logger.log(
level,
"Flow definition issue for %s%s [%s]: %s",
definition_name,
location,
code,
message,
)
if key in seen:
continue
seen.add(key)
diagnostics.append(diagnostic)
return diagnostics

View File

@@ -121,7 +121,7 @@ from crewai.flow.human_feedback import (
)
from crewai.flow.input_provider import InputProvider
from crewai.flow.persistence.base import FlowPersistence
from crewai.flow.runtime._actions import FlowScriptExecutionDisabledError, build_action
from crewai.flow.runtime._actions import build_action
from crewai.flow.runtime._refs import resolve_instance_ref, resolve_ref
from crewai.flow.types import (
FlowExecutionData,
@@ -1090,8 +1090,6 @@ class Flow(BaseModel, Generic[T], metaclass=FlowMeta):
def build(name: str, definition: FlowMethodDefinition) -> Callable[..., Any]:
try:
return build_action(self, definition.do)
except FlowScriptExecutionDisabledError:
raise
except Exception as e:
unresolved.append(f"{name}: {e}")
return lambda *args, **kwargs: None

View File

@@ -2,27 +2,22 @@
from __future__ import annotations
import ast
import asyncio
from collections.abc import Awaitable, Callable
from collections.abc import Callable
import contextvars
import inspect
import os
from typing import TYPE_CHECKING, Any, Protocol, cast
from crewai.flow.expressions import Expression, ExpressionData
from crewai.flow.flow_definition import (
FlowActionDefinition,
FlowAgentActionDefinition,
FlowCodeActionDefinition,
FlowCrewActionDefinition,
FlowEachActionDefinition,
FlowEachStepDefinition,
FlowEachInnerActionDefinition,
FlowExpressionActionDefinition,
FlowScriptActionDefinition,
FlowToolActionDefinition,
)
from crewai.flow.runtime._outputs import outputs_by_name
from crewai.flow.runtime._expressions import evaluate_expression, render_with_block
from crewai.flow.runtime._refs import InvalidRefError, resolve_ref
@@ -30,18 +25,10 @@ if TYPE_CHECKING:
from crewai.flow.runtime import Flow
__all__ = ["FlowScriptExecutionDisabledError", "build_action"]
__all__ = ["build_action"]
LocalContext = dict[str, Any]
NestedStepRunner = Callable[[LocalContext], Awaitable[Any]]
NestedStep = tuple[str, str | None, NestedStepRunner]
_LOCAL_CONTEXT_KWARG = "__flow_definition_local_context"
_ALLOW_SCRIPT_EXECUTION_ENV_VAR = "CREWAI_ALLOW_FLOW_SCRIPT_EXECUTION"
_TRUSTED_SCRIPT_EXECUTION_VALUES = frozenset({"1", "true", "yes"})
class FlowScriptExecutionDisabledError(RuntimeError):
"""Raised when a flow definition tries to execute inline script code."""
class _BuiltAction(Protocol):
@@ -68,9 +55,9 @@ class CodeAction:
if self.definition.with_ is None:
return self.handler(*args, **kwargs)
return self.handler(
**Expression.from_flow(
self.definition.with_, self.flow, local_context=local_context
).render_template()
**render_with_block(
self.flow, self.definition.with_, local_context=local_context
)
)
def _resolve_handler(self) -> Callable[..., Any]:
@@ -96,9 +83,7 @@ class ToolAction:
def run(self, *_args: Any, **kwargs: Any) -> Any:
local_context = _pop_local_context(kwargs)
return self.tool.run(
**Expression.from_flow(
self.kwargs, self.flow, local_context=local_context
).render_template()
**render_with_block(self.flow, self.kwargs, local_context=local_context)
)
def _build_tool(self) -> Any:
@@ -132,44 +117,13 @@ class CrewAction:
local_context = _pop_local_context(kwargs)
crew_definition = self.definition.with_
inputs = Expression.from_flow(
cast(ExpressionData, crew_definition.inputs),
self.flow,
local_context=local_context,
).render_template()
inputs = render_with_block(
self.flow, crew_definition.inputs, local_context=local_context
)
crew, _ = load_crew_from_definition(crew_definition, source="crew action")
return await crew.kickoff_async(inputs=inputs)
class AgentAction:
definition_type = FlowAgentActionDefinition
def __init__(self, flow: Flow[Any], definition: FlowAgentActionDefinition) -> None:
self.flow = flow
self.definition = definition
async def run(self, *_args: Any, **kwargs: Any) -> Any:
from crewai.project.json_loader import load_agent_from_definition
local_context = _pop_local_context(kwargs)
rendered_input = Expression.from_flow(
cast(ExpressionData, self.definition.with_.input),
self.flow,
local_context=local_context,
).render_template()
if not isinstance(rendered_input, str):
raise ValueError("agent input must render to a string")
agent, response_format = load_agent_from_definition(
self.definition.with_,
source="agent action",
)
return await agent.kickoff_async(
rendered_input,
response_format=response_format,
)
class ExpressionAction:
definition_type = FlowExpressionActionDefinition
@@ -181,71 +135,10 @@ class ExpressionAction:
def run(self, *_args: Any, **kwargs: Any) -> Any:
local_context = _pop_local_context(kwargs)
return Expression.from_flow(
self.definition.expr, self.flow, local_context=local_context
).evaluate()
class ScriptAction:
definition_type = FlowScriptActionDefinition
def __init__(self, flow: Flow[Any], definition: FlowScriptActionDefinition) -> None:
self.flow = flow
self.definition = definition
self.handler = self._compile_handler()
def run(self, *args: Any, **kwargs: Any) -> Any:
local_context = _pop_local_context(kwargs)
return self.handler(
state=self.flow.state,
outputs=outputs_by_name(
self.flow._method_outputs,
local_outputs=local_context.get("outputs") if local_context else None,
),
input=args[0] if args else None,
item=local_context.get("item") if local_context else None,
return evaluate_expression(
self.flow, self.definition.expr, local_context=local_context
)
def _compile_handler(self) -> Callable[..., Any]:
raw = os.environ.get(_ALLOW_SCRIPT_EXECUTION_ENV_VAR, "")
if raw.strip().lower() not in _TRUSTED_SCRIPT_EXECUTION_VALUES:
raise FlowScriptExecutionDisabledError(
"Flow script execution is disabled by default. "
f"Set {_ALLOW_SCRIPT_EXECUTION_ENV_VAR}=1 to enable it only for "
"trusted flow definitions."
)
filename = f"crewai.flow.script.{self.flow._definition.name}"
module = ast.parse(self.definition.code, filename=filename)
function = ast.FunctionDef(
name="_flow_script",
args=ast.arguments(
posonlyargs=[],
args=[ast.arg(arg) for arg in ("state", "outputs", "input", "item")],
vararg=None,
kwonlyargs=[],
kw_defaults=[],
kwarg=None,
defaults=[],
),
body=module.body or [ast.Pass()],
decorator_list=[],
returns=None,
type_comment=None,
type_params=[],
)
module.body = [function]
ast.fix_missing_locations(module)
# The YAML here is trusted project source authored by the code owner,
# so this has the same trust boundary as using custom tools. We
# intentionally do not interpolate user input and runtime values are passed
# as function arguments. This is still arbitrary trusted Python execution,
# so it remains disabled by default behind `CREWAI_ALLOW_FLOW_SCRIPT_EXECUTION`
namespace: dict[str, Any] = {"__name__": filename}
exec(compile(module, filename, "exec"), namespace) # nosec B102 # noqa: S102
return cast(Callable[..., Any], namespace["_flow_script"])
class EachAction:
definition_type = FlowEachActionDefinition
@@ -253,13 +146,13 @@ class EachAction:
def __init__(self, flow: Flow[Any], definition: FlowEachActionDefinition) -> None:
self.flow = flow
self.definition = definition
self.steps: list[NestedStep] = [
(step.name, step.if_, self._build_step_action(step))
for step in definition.do
self.inner_actions = [
(inner_action.name, self._build_inner_action(inner_action))
for inner_action in definition.do
]
async def run(self, *_args: Any, **_kwargs: Any) -> list[Any]:
items = Expression.from_flow(self.definition.in_, self.flow).evaluate()
items = evaluate_expression(self.flow, self.definition.in_)
if not isinstance(items, list):
raise ValueError("each.in must evaluate to an array")
@@ -267,32 +160,22 @@ class EachAction:
for item in items:
local_outputs: dict[str, Any] = {}
local_context = {"item": item, "outputs": local_outputs}
last_output: Any = None
for name, condition, run_step_action in self.steps:
if condition is not None and not self._condition_matches(
condition, local_context
):
continue
last_output = await run_step_action(local_context)
for name, run_inner_action in self.inner_actions:
last_output = await run_inner_action(
{"item": item, "outputs": local_outputs}
)
local_outputs[name] = last_output
results.append(last_output)
return results
def _condition_matches(self, condition: str, local_context: LocalContext) -> bool:
result = Expression.from_flow(
condition, self.flow, local_context=local_context
).evaluate()
if not isinstance(result, bool):
raise ValueError("if expression must evaluate to a boolean")
return result
def _build_inner_action(
self, inner_action: FlowEachInnerActionDefinition
) -> Callable[[LocalContext], Any]:
run_action = build_action(self.flow, inner_action.action)
def _build_step_action(self, step: FlowEachStepDefinition) -> NestedStepRunner:
run_action = build_action(self.flow, step.action)
async def run_step_action(local_context: LocalContext) -> Any:
async def run_inner_action(local_context: LocalContext) -> Any:
kwargs = {_LOCAL_CONTEXT_KWARG: local_context}
if inspect.iscoroutinefunction(run_action):
result = run_action(**kwargs)
@@ -307,17 +190,15 @@ class EachAction:
result = await result
return result
return run_step_action
return run_inner_action
_ACTION_TYPES: tuple[_ActionType, ...] = (
EachAction,
CodeAction,
ToolAction,
AgentAction,
CrewAction,
ExpressionAction,
ScriptAction,
)

View File

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

View File

@@ -1,40 +0,0 @@
"""Shared FlowDefinition runtime output helpers."""
from __future__ import annotations
from collections.abc import Mapping
from typing import Any, TypedDict
from crewai.utilities.serialization import to_serializable
class _MethodOutput(TypedDict):
method: str
output: Any
def outputs_by_name(
method_outputs: list[_MethodOutput],
*,
local_outputs: Mapping[str, Any] | None = None,
serialize: bool = False,
) -> dict[str, Any]:
outputs: dict[str, Any] = {}
for entry in method_outputs:
outputs[entry["method"]] = _output_value(entry["output"], serialize=serialize)
if local_outputs is not None:
outputs.update(
{
key: _output_value(output, serialize=serialize)
for key, output in local_outputs.items()
}
)
return outputs
def _output_value(value: Any, *, serialize: bool) -> Any:
if not serialize:
return value
return to_serializable(value, max_depth=0)

View File

@@ -3,9 +3,7 @@
from __future__ import annotations
from concurrent.futures import Future, ThreadPoolExecutor
from contextlib import suppress
import contextvars
import copy
from datetime import datetime
import threading
import time
@@ -55,24 +53,6 @@ def _default_embedder() -> OpenAIEmbeddingFunction:
return build_embedder(spec)
def _non_streaming_analysis_llm(llm: Any) -> Any:
"""Return an isolated non-streaming LLM for internal memory analysis."""
if not isinstance(llm, BaseLLM):
return llm
try:
analysis_llm = copy.copy(llm)
except Exception:
try:
analysis_llm = llm.model_copy(deep=False)
except Exception:
return llm
with suppress(Exception):
analysis_llm.stream = False
return analysis_llm
class Memory(BaseModel):
"""Unified memory: standalone, LLM-analyzed, with intelligent recall flow.
@@ -220,9 +200,7 @@ class Memory(BaseModel):
query_analysis_threshold=self.query_analysis_threshold,
)
self._llm_instance = (
None if isinstance(self.llm, str) else _non_streaming_analysis_llm(self.llm)
)
self._llm_instance = None if isinstance(self.llm, str) else self.llm
self._embedder_instance = (
self.embedder
if (self.embedder is not None and not isinstance(self.embedder, dict))

View File

@@ -15,22 +15,16 @@ from crewai.project.annotations import (
)
from crewai.project.crew_base import CrewBase
from crewai.project.crew_definition import (
AgentDefinition,
CrewAgentDefinition,
CrewDefinition,
CrewTaskDefinition,
PythonReferenceDefinition,
)
from crewai.project.crew_loader import load_crew, load_crew_and_kickoff
from crewai.project.json_loader import (
load_agent,
load_agent_from_definition,
strip_jsonc_comments,
)
from crewai.project.json_loader import load_agent, strip_jsonc_comments
__all__ = [
"AgentDefinition",
"CrewAgentDefinition",
"CrewBase",
"CrewDefinition",
@@ -44,7 +38,6 @@ __all__ = [
"crew",
"llm",
"load_agent",
"load_agent_from_definition",
"load_crew",
"load_crew_and_kickoff",
"output_json",

View File

@@ -8,7 +8,6 @@ from pydantic import BaseModel, ConfigDict, Field, field_validator, model_valida
__all__ = [
"AgentDefinition",
"CrewAgentDefinition",
"CrewDefinition",
"CrewTaskDefinition",
@@ -54,20 +53,6 @@ class CrewAgentDefinition(BaseModel):
return value or {}
class AgentDefinition(CrewAgentDefinition):
"""Inline agent definition used by a Flow agent action."""
input: str
response_format: PythonReferenceDefinition | None = None
@field_validator("input", mode="before")
@classmethod
def _validate_input(cls, value: Any) -> Any:
if not isinstance(value, str):
raise ValueError("agent.input must be a string")
return value
class CrewTaskDefinition(BaseModel):
"""Task definition used by a crew definition."""

View File

@@ -207,18 +207,19 @@ def load_jsonc_file(source: str | Path) -> Any:
return parse_jsonc(path.read_text(encoding="utf-8"), source=path)
def _instantiate_agent_from_data(
defn: dict[str, Any], source_label: str, root: Path
) -> Any:
"""Resolve the agent class and kwargs from definition data and instantiate it."""
def load_agent(source: str | Path) -> Any:
"""Load an existing ``Agent`` from a ``.json`` / ``.jsonc`` definition file."""
path = Path(source)
defn = _expect_object(load_jsonc_file(path), path)
root = path.parent.parent if path.parent.name == "agents" else path.parent
agent_class = _agent_class_from_definition(
defn,
f"{source_label}: type",
f"{path}: type",
project_root=root,
)
agent_kwargs = _agent_kwargs_from_definition(
defn,
source_label,
path,
agent_class=agent_class,
project_root=root,
)
@@ -226,50 +227,9 @@ def _instantiate_agent_from_data(
try:
return agent_class(**agent_kwargs)
except ValidationError as exc:
raise JSONProjectError(_format_validation_error(source_label, exc)) from exc
raise JSONProjectError(_format_validation_error(path, exc)) from exc
except Exception as exc:
raise JSONProjectError(f"{source_label}: failed to load agent: {exc}") from exc
def load_agent(source: str | Path) -> Any:
"""Load an existing ``Agent`` from a ``.json`` / ``.jsonc`` definition file."""
path = Path(source)
defn = _expect_object(load_jsonc_file(path), path)
root = path.parent.parent if path.parent.name == "agents" else path.parent
return _instantiate_agent_from_data(defn, str(path), root)
def load_agent_from_definition(
definition: dict[str, Any] | Any,
*,
source: str | Path = "<inline agent>",
project_root: str | Path | None = None,
) -> tuple[Any, type[BaseModel] | None]:
"""Load an ``Agent`` and optional kickoff response model from an inline definition."""
from crewai.project.crew_definition import AgentDefinition
root = Path(project_root) if project_root is not None else Path.cwd()
source_label = str(source)
agent_definition = (
definition
if isinstance(definition, AgentDefinition)
else AgentDefinition.model_validate(definition)
)
definition_data = agent_definition.model_dump(mode="python", exclude_none=True)
response_format_ref = definition_data.pop("response_format", None)
definition_data.pop("input", None)
agent = _instantiate_agent_from_data(definition_data, source_label, root)
response_format = None
if response_format_ref is not None:
response_format = _resolve_model_class(
response_format_ref,
f"{source_label}: response_format",
root,
)
return agent, response_format
raise JSONProjectError(f"{path}: failed to load agent: {exc}") from exc
def validate_crew_project(

View File

@@ -19,39 +19,6 @@ from crewai.memory.types import (
)
def test_memory_analysis_llm_is_isolated_from_streaming_agent_llm(
tmp_path: Path,
) -> None:
"""Memory analysis should not share a mutable streaming LLM with the agent UI."""
from crewai.llms.base_llm import BaseLLM
from crewai.memory.unified_memory import Memory
from crewai.utilities.types import LLMMessage
class FakeStreamingLLM(BaseLLM):
def call(
self,
messages: str | list[LLMMessage],
tools: list[dict] | None = None,
callbacks: list | None = None,
available_functions: dict | None = None,
from_task: object | None = None,
from_agent: object | None = None,
response_model: type | None = None,
) -> str:
return ""
agent_llm = FakeStreamingLLM(model="fake-model", stream=True)
mem = Memory(
storage=str(tmp_path / "db"),
llm=agent_llm,
embedder=lambda texts: [[0.1] for _ in texts],
)
assert mem._llm is not agent_llm
assert mem._llm.stream is False
agent_llm.stream = True
assert mem._llm.stream is False
def test_memory_record_defaults() -> None:

View File

@@ -7,7 +7,6 @@ from pathlib import Path
import sys
import pytest
from pydantic import BaseModel
from crewai.llms.base_llm import BaseLLM
from crewai.project.json_loader import (
@@ -15,7 +14,6 @@ from crewai.project.json_loader import (
_looks_like_windows_absolute_path,
find_json_project_file,
load_agent,
load_agent_from_definition,
strip_jsonc_comments,
)
@@ -360,30 +358,6 @@ class TestLoadAgent:
load_agent(Path("/nonexistent/agent.json"))
class TestLoadAgentFromDefinition:
def test_resolves_response_format_from_project_module(self, tmp_path: Path):
(tmp_path / "models.py").write_text(
"from pydantic import BaseModel\n"
"class AnswerModel(BaseModel):\n"
" answer: str\n"
)
_, response_format = load_agent_from_definition(
{
"role": "Analyst",
"goal": "Analyze data",
"backstory": "Data expert.",
"input": "Summarize this",
"response_format": {"python": "models.AnswerModel"},
},
source="agent action",
project_root=tmp_path,
)
assert issubclass(response_format, BaseModel)
assert response_format.__name__ == "AnswerModel"
class TestResolveTools:
def test_unknown_tool_raises_with_guidance(self):
from crewai.project.json_loader import JSONProjectError, _resolve_tools

View File

@@ -631,7 +631,7 @@ class TestLegacyMethodOutputsRestore:
assert restored.method_outputs == ["first", "second"]
def test_restore_legacy_outputs_evaluates_expressions(self) -> None:
from crewai.flow.expressions import Expression
from crewai.flow.runtime._expressions import _expression_context
flow = Flow()
flow._method_outputs = ["legacy"]
@@ -642,14 +642,17 @@ class TestLegacyMethodOutputsRestore:
cfg = CheckpointConfig(restore_from=loc)
restored = Flow.from_checkpoint(cfg)
context = Expression._flow_context(restored)
context = _expression_context(restored)
assert context["outputs"] == {"": "legacy"}
def test_raw_legacy_outputs_property_remains_readable(self) -> None:
def test_raw_legacy_outputs_remain_readable(self) -> None:
from crewai.flow.runtime._expressions import _expression_context
flow = Flow()
flow._method_outputs = ["legacy"]
assert flow.method_outputs == ["legacy"]
assert _expression_context(flow)["outputs"] == {"": "legacy"}
class TestAgentCheckpoint:

View File

@@ -37,8 +37,6 @@ def test_flow_public_exports_are_explicit():
}
assert set(flow_definition.__all__) == {
"FlowActionDefinition",
"FlowAgentActionDefinition",
"FlowAtomicActionDefinition",
"FlowCodeActionDefinition",
"FlowConfigDefinition",
"FlowConversationalDefinition",
@@ -46,16 +44,16 @@ def test_flow_public_exports_are_explicit():
"FlowCrewActionDefinition",
"FlowDefinition",
"FlowDefinitionCondition",
"FlowDefinitionDiagnostic",
"FlowDictStateDefinition",
"FlowEachActionDefinition",
"FlowEachStepDefinition",
"FlowEachInnerActionDefinition",
"FlowExpressionActionDefinition",
"FlowHumanFeedbackDefinition",
"FlowJsonSchemaStateDefinition",
"FlowMethodDefinition",
"FlowPersistenceDefinition",
"FlowPydanticStateDefinition",
"FlowScriptActionDefinition",
"FlowStateDefinition",
"FlowToolActionDefinition",
"FlowUnknownStateDefinition",
@@ -64,126 +62,6 @@ def test_flow_public_exports_are_explicit():
assert "calculate_node_levels" not in flow_visualization.__all__
def test_flow_definition_json_schema_carries_reference_descriptions():
schema = flow_definition.FlowDefinition.json_schema()
defs = schema["$defs"]
assert schema["properties"]["schema"]["description"]
assert schema["properties"]["methods"]["description"]
assert "diagnostics" not in schema["properties"]
method_properties = defs["FlowMethodDefinition"]["properties"]
assert method_properties["do"]["description"] == "Action executed when this method runs."
assert "Trigger condition" in method_properties["listen"]["description"]
script_properties = defs["FlowScriptActionDefinition"]["properties"]
assert "trusted inline Python" in script_properties["call"]["description"]
assert "not interpolated" in script_properties["code"]["description"]
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"]
state_schema = next(
branch
for branch in schema["properties"]["state"]["anyOf"]
if "discriminator" in branch
)
assert state_schema["discriminator"]["propertyName"] == "type"
assert state_schema["discriminator"]["mapping"] == {
"dict": "#/$defs/FlowDictStateDefinition",
"json_schema": "#/$defs/FlowJsonSchemaStateDefinition",
"pydantic": "#/$defs/FlowPydanticStateDefinition",
"unknown": "#/$defs/FlowUnknownStateDefinition",
}
dict_state_properties = defs["FlowDictStateDefinition"]["properties"]
assert dict_state_properties["type"]["description"]
assert "ref" not in dict_state_properties
json_schema_state_properties = defs["FlowJsonSchemaStateDefinition"]["properties"]
assert json_schema_state_properties["json_schema"]["description"]
assert "json_schema" in defs["FlowJsonSchemaStateDefinition"]["required"]
pydantic_state_properties = defs["FlowPydanticStateDefinition"]["properties"]
assert "Fallback JSON Schema" in pydantic_state_properties["json_schema"][
"description"
]
each_properties = defs["FlowEachActionDefinition"]["properties"]
assert "list to iterate" in each_properties["in"]["description"]
assert "Ordered steps" in each_properties["do"]["description"]
step_properties = defs["FlowEachStepDefinition"]["properties"]
assert "runs only if" in step_properties["if"]["description"]
def test_flow_definition_json_schema_carries_field_examples_only():
schema = flow_definition.FlowDefinition.json_schema()
defs = schema["$defs"]
for model_name in [
"FlowDefinition",
"FlowCodeActionDefinition",
"FlowToolActionDefinition",
"FlowAgentActionDefinition",
"FlowCrewActionDefinition",
"FlowExpressionActionDefinition",
"FlowScriptActionDefinition",
"FlowEachActionDefinition",
"FlowEachStepDefinition",
"FlowMethodDefinition",
"FlowDictStateDefinition",
"FlowJsonSchemaStateDefinition",
"FlowPydanticStateDefinition",
"FlowUnknownStateDefinition",
"FlowConfigDefinition",
"FlowPersistenceDefinition",
"FlowHumanFeedbackDefinition",
]:
model_schema = schema if model_name == "FlowDefinition" else defs[model_name]
assert "examples" not in model_schema
assert schema["properties"]["name"]["examples"] == ["ResearchFlow"]
assert schema["properties"]["schema"]["examples"] == ["crewai.flow/v1"]
assert schema["properties"]["methods"]["examples"][0]["seed"]["do"] == {
"call": "expression",
"expr": "state.topic",
}
script_properties = defs["FlowScriptActionDefinition"]["properties"]
assert script_properties["call"]["examples"] == ["script"]
assert "input.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}"}]
agent_properties = defs["FlowAgentActionDefinition"]["properties"]
assert agent_properties["call"]["examples"] == ["agent"]
assert agent_properties["with"]["examples"][0]["input"] == "${state.question}"
each_properties = defs["FlowEachActionDefinition"]["properties"]
assert each_properties["in"]["examples"] == ["state.rows"]
assert each_properties["do"]["examples"][0][0]["name"] == "clean"
assert each_properties["do"]["examples"][0][0]["action"]["call"] == "script"
assert each_properties["do"]["examples"][0][1]["if"] == "outputs.clean != ''"
step_properties = defs["FlowEachStepDefinition"]["properties"]
assert step_properties["if"]["examples"] == ["item.kind == 'invoice'"]
method_properties = defs["FlowMethodDefinition"]["properties"]
assert method_properties["listen"]["examples"] == [
"seed",
{"or": ["approved", "revise"]},
]
assert method_properties["emit"]["examples"] == [["approved", "revise"]]
def test_flow_state_definition_uses_discriminated_branches():
definition = flow_definition.FlowDefinition.model_validate(
{
@@ -355,7 +233,7 @@ def test_flow_definition_maps_dsl_to_static_contract():
assert review.human_feedback.learn_strict is True
assert definition.methods["audit"].listen == {"and": ["begin", "process"]}
assert "diagnostics" not in definition.to_dict()
assert definition.diagnostics == []
def test_flow_definition_excludes_conversational_builtins_for_regular_flows():
@@ -437,8 +315,7 @@ def test_flow_definition_uses_collapsed_conversational_router_start():
assert methods["route_conversation"].router is True
def test_flow_definition_serializes_human_feedback_metadata(caplog):
caplog.set_level(logging.WARNING, logger="crewai.flow.dsl._utils")
def test_flow_definition_serializes_human_feedback_metadata():
marker = object()
class MetadataFlow(Flow):
@@ -457,9 +334,9 @@ def test_flow_definition_serializes_human_feedback_metadata(caplog):
assert review.human_feedback is not None
assert review.human_feedback.metadata == {"ref": "builtins:dict"}
assert any(
"methods.review.human_feedback.metadata" in record.message
and "not fully serializable" in record.message
for record in caplog.records
diagnostic.code == "non_serializable_value"
and diagnostic.path == "methods.review.human_feedback.metadata"
for diagnostic in definition.diagnostics
)
definition.to_json()
@@ -604,16 +481,14 @@ def test_each_action_round_trips_json_and_yaml():
"in": "state.rows",
"do": [
{
"name": "normalize",
"action": {
"normalize": {
"call": "tool",
"ref": "my_tools:NormalizeRowTool",
"with": {"row": "${ item }"},
}
},
{
"name": "save",
"action": {
"save": {
"call": "code",
"ref": "my_flow:save_row",
"with": {
@@ -700,6 +575,7 @@ def test_flow_definition_allows_dynamic_router_emit():
definition = DynamicRouterFlow.flow_definition()
assert definition.methods["decide"].emit is None
assert definition.diagnostics == []
def test_flow_definition_infers_literal_router_emit():
@@ -852,15 +728,16 @@ def test_flow_definition_accepts_explicit_router_events():
assert definition.methods["decide"].emit == ["left", "right"]
def test_flow_definition_ignores_legacy_diagnostics_loaded_from_contract():
def test_flow_definition_preserves_diagnostics_loaded_from_contract():
definition = flow_definition.FlowDefinition.from_dict(
{
"schema": "crewai.flow/v1",
"name": "LoadedDiagnosticsFlow",
"methods": {
"begin": {
"do": {"ref": "loaded_flows:LoadedDiagnosticsFlow.begin"},
"start": True,
"decision": {
"do": {"ref": "loaded_flows:LoadedDiagnosticsFlow.decision"},
"router": True,
"emit": ["continue"],
}
},
"diagnostics": [
@@ -880,13 +757,13 @@ def test_flow_definition_ignores_legacy_diagnostics_loaded_from_contract():
}
)
assert "diagnostics" not in definition.to_dict()
codes = [diagnostic.code for diagnostic in definition.diagnostics]
assert "serialized_warning" in codes
assert codes.count("router_without_trigger") == 1
def test_router_start_false_without_listen_logs_missing_trigger(caplog):
caplog.set_level(logging.ERROR, logger="crewai.flow.flow_definition")
flow_definition.FlowDefinition.from_dict(
def test_router_start_false_without_listen_reports_missing_trigger():
definition = flow_definition.FlowDefinition.from_dict(
{
"schema": "crewai.flow/v1",
"name": "LoadedFlow",
@@ -902,10 +779,9 @@ def test_router_start_false_without_listen_logs_missing_trigger(caplog):
)
assert any(
record.levelno == logging.ERROR
and "router_without_trigger" in record.message
and "methods.decision" in record.message
for record in caplog.records
diagnostic.code == "router_without_trigger"
and diagnostic.path == "methods.decision"
for diagnostic in definition.diagnostics
)
@@ -933,7 +809,7 @@ def test_router_human_feedback_preserves_existing_router_metadata():
assert method.human_feedback is not None
def test_dynamic_router_flow_definition_allows_dynamic_emit():
def test_dynamic_router_flow_definition_has_no_diagnostics():
class LazyDynamicRouterFlow(Flow):
@start()
def begin(self):
@@ -944,7 +820,7 @@ def test_dynamic_router_flow_definition_allows_dynamic_emit():
return self.state["dynamic_event"]
definition = LazyDynamicRouterFlow.flow_definition()
assert definition.methods["decide"].emit is None
assert definition.diagnostics == []
def test_dynamic_router_string_listener_is_valid_contract():
@@ -963,7 +839,7 @@ def test_dynamic_router_string_listener_is_valid_contract():
definition = DynamicRouterListenerFlow.flow_definition()
assert definition.methods["handle"].listen == "dynamic_event"
assert definition.diagnostics == []
def test_static_string_listener_is_allowed_by_contract():
@@ -983,7 +859,7 @@ def test_static_string_listener_is_allowed_by_contract():
},
}
)
assert definition.methods["handle"].listen == "begni"
assert definition.diagnostics == []
def test_start_false_not_classified_as_start_method():
@@ -1048,10 +924,10 @@ def test_flow_definition_cache_is_not_reused_by_subclasses():
assert set(child_definition.methods) == {"child_step"}
def test_flow_definition_logs_validation_issues_when_loaded_from_contract(caplog):
def test_flow_definition_logs_diagnostics_when_loaded_from_contract(caplog):
caplog.set_level(logging.WARNING, logger="crewai.flow.flow_definition")
flow_definition.FlowDefinition.from_dict(
definition = flow_definition.FlowDefinition.from_dict(
{
"schema": "crewai.flow/v1",
"name": "LoadedFlow",
@@ -1065,6 +941,10 @@ def test_flow_definition_logs_validation_issues_when_loaded_from_contract(caplog
}
)
assert any(
diagnostic.code == "router_without_trigger"
for diagnostic in definition.diagnostics
)
assert any(
record.levelno == logging.ERROR
and "LoadedFlow" in record.message

View File

@@ -26,7 +26,6 @@ from crewai.flow.flow_config import flow_config
from crewai.flow.flow_definition import FlowConfigDefinition, FlowDefinition
from crewai.flow.persistence import persist
from crewai.flow.persistence.base import FlowPersistence
from crewai.flow.runtime._actions import FlowScriptExecutionDisabledError
from crewai.state.checkpoint_config import CheckpointConfig
from crewai.tools import BaseTool
from crewai.types.streaming import FlowStreamingOutput
@@ -114,7 +113,7 @@ class EachActionFlow(Flow):
except RuntimeError:
pass
else:
raise RuntimeError("each step ran on the event loop")
raise RuntimeError("inner action ran on the event loop")
from crewai.flow.flow_context import current_flow_method_name
@@ -644,7 +643,7 @@ methods:
assert flow.kickoff(inputs={"topic": "ai"}) == "found:ai agents"
def test_tool_action_treats_embedded_cel_marker_as_literal():
def test_tool_action_rejects_braces_in_embedded_cel_input():
definition = FlowDefinition.from_dict(
{
"schema": "crewai.flow/v1",
@@ -660,62 +659,16 @@ def test_tool_action_treats_embedded_cel_marker_as_literal():
"prefix": "${'p}x'}",
},
},
},
}
},
}
)
assert Flow.from_definition(definition).kickoff() == "p}x:wrapped ${'a}b'} value"
with pytest.raises(ValueError, match="cannot contain braces"):
Flow.from_definition(definition).kickoff()
def test_tool_action_treats_marker_with_trailing_text_as_literal():
definition = FlowDefinition.from_dict(
{
"schema": "crewai.flow/v1",
"name": "ToolFlow",
"methods": {
"search": {
"start": True,
"do": {
"call": "tool",
"ref": f"{__name__}:StaticSearchTool",
"with": {
"search_query": "${state.topic} extra",
"prefix": "p",
},
},
},
},
}
)
assert Flow.from_definition(definition).kickoff() == "p:${state.topic} extra"
def test_tool_action_rejects_adjacent_markers_as_invalid_cel():
with pytest.raises(ValidationError, match="invalid CEL expression"):
FlowDefinition.from_dict(
{
"schema": "crewai.flow/v1",
"name": "ToolFlow",
"methods": {
"search": {
"start": True,
"do": {
"call": "tool",
"ref": f"{__name__}:StaticSearchTool",
"with": {
"search_query": "${'a'}${'b'}",
"prefix": "p",
},
},
},
},
}
)
def test_tool_action_accepts_braces_in_full_cel_marker():
def test_tool_action_rejects_braces_in_full_cel_input():
definition = FlowDefinition.from_dict(
{
"schema": "crewai.flow/v1",
@@ -728,15 +681,16 @@ def test_tool_action_accepts_braces_in_full_cel_marker():
"ref": f"{__name__}:StaticSearchTool",
"with": {
"search_query": "${{'query': 'ai agents'}.query}",
"prefix": "${'p}x'}",
"prefix": "found",
},
},
},
}
},
}
)
assert Flow.from_definition(definition).kickoff() == "p}x:ai agents"
with pytest.raises(ValueError, match="cannot contain braces"):
Flow.from_definition(definition).kickoff()
def test_tool_action_renders_latest_output_by_method_name():
@@ -811,166 +765,6 @@ methods:
)
def test_agent_action_runs_inline_yaml_definition(monkeypatch: pytest.MonkeyPatch):
from crewai import Agent
async def fake_kickoff_async(
self: Agent, messages: str, **_kwargs: Any
) -> dict[str, Any]:
return {"agent": self.role, "input": messages}
monkeypatch.setattr(Agent, "kickoff_async", fake_kickoff_async)
yaml_str = """
schema: crewai.flow/v1
name: AgentFlow
methods:
answer:
do:
call: agent
with:
role: Analyst
goal: Answer questions
backstory: Knows things.
input: "${state.question}"
start: true
"""
flow = Flow.from_definition(FlowDefinition.from_yaml(yaml_str))
assert flow.kickoff(inputs={"question": "What is CrewAI?"}) == {
"agent": "Analyst",
"input": "What is CrewAI?",
}
def test_agent_action_runs_inside_each(monkeypatch: pytest.MonkeyPatch):
from crewai import Agent
async def fake_kickoff_async(
self: Agent, messages: str, **_kwargs: Any
) -> str:
return f"{self.role}:{messages}"
monkeypatch.setattr(Agent, "kickoff_async", fake_kickoff_async)
yaml_str = """
schema: crewai.flow/v1
name: AgentEachFlow
methods:
answer_each:
do:
call: each
in: state.questions
do:
- name: answer
action:
call: agent
with:
role: Analyst
goal: Answer questions
backstory: Knows things.
input: "${item}"
start: true
"""
flow = Flow.from_definition(FlowDefinition.from_yaml(yaml_str))
assert flow.kickoff(inputs={"questions": ["one", "two"]}) == [
"Analyst:one",
"Analyst:two",
]
def test_agent_action_round_trips_with_inline_definition():
definition = FlowDefinition.from_dict(
{
"schema": "crewai.flow/v1",
"name": "AgentFlow",
"methods": {
"answer": {
"start": True,
"do": {
"call": "agent",
"with": {
"role": "Analyst",
"goal": "Answer questions",
"backstory": "Knows things.",
"settings": {"verbose": True},
"input": "${state.question}",
},
},
}
},
}
)
round_trip = FlowDefinition.from_yaml(definition.to_yaml())
action = round_trip.to_dict()["methods"]["answer"]["do"]
assert action["call"] == "agent"
assert action["with"]["role"] == "Analyst"
assert action["with"]["input"] == "${state.question}"
assert action["with"]["settings"] == {"verbose": True}
def test_agent_action_json_schema_describes_inline_agent_definitions():
schema_defs = FlowDefinition.json_schema()["$defs"]
assert set(schema_defs["AgentDefinition"]["properties"]) >= {
"role",
"goal",
"backstory",
"settings",
"input",
"response_format",
}
def test_agent_action_rejects_non_string_input_in_definition():
with pytest.raises(ValidationError, match="agent.input must be a string"):
FlowDefinition.from_dict(
{
"schema": "crewai.flow/v1",
"name": "AgentFlow",
"methods": {
"answer": {
"start": True,
"do": {
"call": "agent",
"with": {
"role": "Analyst",
"goal": "Answer questions",
"backstory": "Knows things.",
"input": 123,
},
},
}
},
}
)
def test_agent_action_reports_invalid_cel_expression():
yaml_str = """
schema: crewai.flow/v1
name: AgentFlow
methods:
answer:
do:
call: agent
with:
role: Analyst
goal: Answer questions
backstory: Knows things.
input: "${state.}"
start: true
"""
with pytest.raises(ValidationError, match="invalid CEL expression"):
FlowDefinition.from_yaml(yaml_str)
def test_crew_action_runs_inline_yaml_definition(monkeypatch: pytest.MonkeyPatch):
from crewai import Crew
@@ -1231,8 +1025,10 @@ methods:
start: true
"""
with pytest.raises(ValidationError, match="invalid CEL expression"):
FlowDefinition.from_yaml(yaml_str)
flow = Flow.from_definition(FlowDefinition.from_yaml(yaml_str))
with pytest.raises(ValueError, match="failed to evaluate CEL expression"):
flow.kickoff()
def test_code_action_renders_keyword_inputs():
@@ -1284,8 +1080,7 @@ methods:
call: each
in: state.rows
do:
- name: normalize
action:
- normalize:
call: code
ref: {__name__}:EachActionFlow.normalize_row
with:
@@ -1301,7 +1096,7 @@ methods:
]
def test_each_action_runs_sync_steps_off_event_loop_with_context():
def test_each_action_runs_sync_inner_actions_off_event_loop_with_context():
yaml_str = f"""
schema: crewai.flow/v1
name: EachFlow
@@ -1311,8 +1106,7 @@ methods:
call: each
in: state.rows
do:
- name: threaded
action:
- threaded:
call: code
ref: {__name__}:EachActionFlow.require_threaded_context
with:
@@ -1328,7 +1122,7 @@ methods:
assert flow.inner_thread_id != caller_thread_id
def test_each_action_runs_async_tool_results_from_sync_steps():
def test_each_action_runs_async_tool_results_from_sync_inner_actions():
yaml_str = f"""
schema: crewai.flow/v1
name: EachFlow
@@ -1338,8 +1132,7 @@ methods:
call: each
in: state.rows
do:
- name: async_tool
action:
- async_tool:
call: tool
ref: {__name__}:AsyncResultTool
with:
@@ -1352,120 +1145,7 @@ methods:
assert flow.kickoff(inputs={"rows": ["a", "b"]}) == ["async:a", "async:b"]
def test_script_action_requires_explicit_opt_in():
yaml_str = """
schema: crewai.flow/v1
name: ScriptFlow
methods:
normalize:
do:
call: script
code: |
return "blocked"
start: true
"""
with pytest.raises(
FlowScriptExecutionDisabledError,
match="CREWAI_ALLOW_FLOW_SCRIPT_EXECUTION=1",
) as exc_info:
Flow.from_definition(FlowDefinition.from_yaml(yaml_str))
assert "methods with unresolvable actions" not in str(exc_info.value)
def test_script_action_runs_python_imports_mutates_state_and_returns_value(
monkeypatch: pytest.MonkeyPatch,
):
monkeypatch.setenv("CREWAI_ALLOW_FLOW_SCRIPT_EXECUTION", "1")
yaml_str = """
schema: crewai.flow/v1
name: ScriptFlow
methods:
normalize:
do:
call: script
code: |
import math
state["rounded"] = math.ceil(state["raw_score"])
return f"rounded:{state['rounded']}"
start: true
"""
flow = Flow.from_definition(FlowDefinition.from_yaml(yaml_str))
assert flow.kickoff(inputs={"raw_score": 3.2}) == "rounded:4"
assert flow.state["rounded"] == 4
def test_script_listener_reads_trigger_input_and_outputs(
monkeypatch: pytest.MonkeyPatch,
):
monkeypatch.setenv("CREWAI_ALLOW_FLOW_SCRIPT_EXECUTION", "1")
yaml_str = """
schema: crewai.flow/v1
name: ScriptFlow
methods:
seed:
do:
call: expression
expr: "'alpha'"
start: true
combine:
do:
call: script
code: |
state["input_matches_output"] = input == outputs["seed"]
return f"{outputs['seed']}:{input}"
listen: seed
"""
flow = Flow.from_definition(FlowDefinition.from_yaml(yaml_str))
assert flow.kickoff() == "alpha:alpha"
assert flow.state["input_matches_output"] is True
def test_script_each_action_reads_item_and_step_outputs(
monkeypatch: pytest.MonkeyPatch,
):
monkeypatch.setenv("CREWAI_ALLOW_FLOW_SCRIPT_EXECUTION", "1")
yaml_str = """
schema: crewai.flow/v1
name: ScriptEachFlow
methods:
seed:
do:
call: expression
expr: "'global'"
start: true
process_rows:
do:
call: each
in: state.rows
do:
- name: clean
action:
call: script
code: |
return item.strip()
- name: tag
action:
call: script
code: |
return f"{outputs['seed']}:{outputs['clean']}"
listen: seed
"""
flow = Flow.from_definition(FlowDefinition.from_yaml(yaml_str))
assert flow.kickoff(inputs={"rows": [" a ", " b "]}) == ["global:a", "global:b"]
def test_each_action_uses_iteration_outputs_between_steps():
def test_each_action_uses_iteration_outputs_between_nested_actions():
yaml_str = f"""
schema: crewai.flow/v1
name: EachFlow
@@ -1475,15 +1155,13 @@ methods:
call: each
in: state.rows
do:
- name: normalize
action:
- normalize:
call: code
ref: {__name__}:EachActionFlow.normalize_row
with:
row: "${{item}}"
prefix: saved
- name: save
action:
- save:
call: code
ref: {__name__}:EachActionFlow.save_row
with:
@@ -1500,7 +1178,7 @@ methods:
]
def test_each_action_resets_step_outputs_between_iterations():
def test_each_action_resets_inner_outputs_between_iterations():
yaml_str = """
schema: crewai.flow/v1
name: EachFlow
@@ -1510,12 +1188,10 @@ methods:
call: each
in: state.rows
do:
- name: leak_check
action:
- leak_check:
call: expression
expr: "has(outputs.previous) ? outputs.previous : 'empty'"
- name: previous
action:
- previous:
call: expression
expr: item
start: true
@@ -1529,7 +1205,7 @@ methods:
]
def test_each_action_preserves_flow_outputs_and_prefers_step_outputs():
def test_each_action_preserves_flow_outputs_and_prefers_inner_outputs():
yaml_str = """
schema: crewai.flow/v1
name: EachFlow
@@ -1544,16 +1220,13 @@ methods:
call: each
in: state.rows
do:
- name: before_shadow
action:
- before_shadow:
call: expression
expr: "outputs.seed + ':' + item"
- name: seed
action:
- seed:
call: expression
expr: "'local:' + item"
- name: after_shadow
action:
- after_shadow:
call: expression
expr: "outputs.seed"
listen: seed
@@ -1571,130 +1244,6 @@ methods:
]
def test_each_action_runs_simple_if_clauses():
yaml_str = """
schema: crewai.flow/v1
name: EachIfFlow
methods:
process_rows:
do:
call: each
in: state.rows
do:
- name: kind
action:
call: expression
expr: item.kind
- name: kept
if: "outputs.kind == 'keep'"
action:
call: expression
expr: "'kept:' + item.value"
- name: skipped
if: "outputs.kind != 'keep'"
action:
call: expression
expr: "'skipped:' + item.value"
start: true
"""
flow = Flow.from_definition(FlowDefinition.from_yaml(yaml_str))
assert flow.kickoff(
inputs={
"rows": [
{"kind": "keep", "value": "a"},
{"kind": "drop", "value": "b"},
]
}
) == ["kept:a", "skipped:b"]
def test_each_action_accepts_expression_markers_in_explicit_cel_fields():
yaml_str = """
schema: crewai.flow/v1
name: EachIfFlow
methods:
process_rows:
do:
call: each
in: "${state.rows}"
do:
- name: kind
action:
call: expression
expr: "${item.kind}"
- name: kept
if: "${outputs.kind == 'keep'}"
action:
call: expression
expr: "${item.value}"
start: true
"""
flow = Flow.from_definition(FlowDefinition.from_yaml(yaml_str))
assert flow.kickoff(inputs={"rows": [{"kind": "keep", "value": "a"}]}) == ["a"]
def test_each_action_skipped_if_keeps_previous_output():
yaml_str = """
schema: crewai.flow/v1
name: EachIfFlow
methods:
process_rows:
do:
call: each
in: state.rows
do:
- name: original
action:
call: expression
expr: item.value
- name: maybe_included
if: item.include
action:
call: expression
expr: "'included:' + item.value"
start: true
"""
flow = Flow.from_definition(FlowDefinition.from_yaml(yaml_str))
assert flow.kickoff(
inputs={
"rows": [
{"include": True, "value": "a"},
{"include": False, "value": "b"},
]
}
) == ["included:a", "b"]
def test_each_action_if_condition_must_be_boolean():
yaml_str = """
schema: crewai.flow/v1
name: EachIfFlow
methods:
process_rows:
do:
call: each
in: state.rows
do:
- name: value
if: item.value
action:
call: expression
expr: item.value
start: true
"""
flow = Flow.from_definition(FlowDefinition.from_yaml(yaml_str))
with pytest.raises(ValueError, match="if expression must evaluate to a boolean"):
flow.kickoff(inputs={"rows": [{"value": "truthy"}]})
def test_each_action_empty_list_returns_empty_and_listener_runs_once():
yaml_str = f"""
schema: crewai.flow/v1
@@ -1705,8 +1254,7 @@ methods:
call: each
in: state.rows
do:
- name: normalize
action:
- normalize:
call: code
ref: {__name__}:EachActionFlow.normalize_row
with:
@@ -1755,12 +1303,7 @@ def test_each_action_rejects_non_list_inputs(expr, inputs):
"do": {
"call": "each",
"in": expr,
"do": [
{
"name": "value",
"action": {"call": "expression", "expr": "item"},
}
],
"do": [{"value": {"call": "expression", "expr": "item"}}],
},
}
},
@@ -1776,25 +1319,15 @@ def test_each_action_rejects_non_list_inputs(expr, inputs):
"action_do",
[
[],
[{"value": {"call": "expression", "expr": "item"}}],
[{"name": "1bad", "action": {"call": "expression", "expr": "item"}}],
[{"name": "missing_action"}],
[{"action": {"call": "expression", "expr": "item"}}],
[{"first": {"call": "expression", "expr": "item"}, "second": {"call": "expression", "expr": "item"}}],
[{"1bad": {"call": "expression", "expr": "item"}}],
[
{
"name": "value",
"if": "true",
"then": [],
"action": {"call": "expression", "expr": "item"},
}
],
[
{"name": "same", "action": {"call": "expression", "expr": "item"}},
{"name": "same", "action": {"call": "expression", "expr": "item"}},
{"same": {"call": "expression", "expr": "item"}},
{"same": {"call": "expression", "expr": "item"}},
],
],
)
def test_each_action_validates_step_shape(action_do):
def test_each_action_validates_inner_action_shape(action_do):
with pytest.raises(ValidationError):
FlowDefinition.from_dict(
{
@@ -1814,26 +1347,6 @@ def test_each_action_validates_step_shape(action_do):
)
def test_if_clauses_are_rejected_at_method_level():
with pytest.raises(ValidationError):
FlowDefinition.from_dict(
{
"schema": "crewai.flow/v1",
"name": "TopLevelIfFlow",
"methods": {
"process": {
"start": True,
"do": {
"call": "expression",
"if": "true",
"expr": "'ok'",
},
}
},
}
)
def test_each_action_rejects_nested_each_actions():
with pytest.raises(ValidationError):
FlowDefinition.from_dict(
@@ -1848,14 +1361,12 @@ def test_each_action_rejects_nested_each_actions():
"in": "state.rows",
"do": [
{
"name": "nested",
"action": {
"nested": {
"call": "each",
"in": "state.children",
"do": [
{
"name": "child",
"action": {
"child": {
"call": "expression",
"expr": "item",
}
@@ -1881,8 +1392,7 @@ methods:
call: each
in: state.rows
do:
- name: validate
action:
- validate:
call: code
ref: {__name__}:EachActionFlow.fail_on_bad_row
with:
@@ -1920,28 +1430,8 @@ def test_expression_action_round_trips():
assert Flow.from_definition(definition).kickoff(inputs={"score": 90}) == "qualified"
def test_explicit_cel_fields_accept_expression_markers():
definition = FlowDefinition.from_dict(
{
"schema": "crewai.flow/v1",
"name": "ExpressionFlow",
"methods": {
"classify": {
"start": True,
"do": {
"call": "expression",
"expr": "${state.score >= 80 ? 'qualified' : 'nurture'}",
},
}
},
}
)
assert Flow.from_definition(definition).kickoff(inputs={"score": 90}) == "qualified"
def test_expression_local_context_recurses_into_dataclass_values():
from crewai.flow.expressions import Expression
from crewai.flow.runtime._expressions import evaluate_expression
class Payload(BaseModel):
name: str
@@ -1951,37 +1441,15 @@ def test_expression_local_context_recurses_into_dataclass_values():
payload: Payload
assert (
Expression.from_flow(
"item.payload.name",
evaluate_expression(
Flow(),
"item.payload.name",
local_context={"item": Row(payload=Payload(name="qualified"))},
).evaluate()
)
== "qualified"
)
def test_expression_empty_context_overrides_stored_context():
from crewai.flow.expressions import Expression, ExpressionError
expression = Expression("state.score", context={"state": {"score": 90}})
assert expression.evaluate() == 90
with pytest.raises(ExpressionError):
expression.evaluate({})
def test_expression_template_empty_context_overrides_stored_context():
from crewai.flow.expressions import Expression, ExpressionError
expression = Expression(
{"score": "${state.score}"}, context={"state": {"score": 90}}
)
assert expression.render_template() == {"score": 90}
with pytest.raises(ExpressionError):
expression.render_template({})
def test_expression_action_can_route_like_if_else():
yaml_str = f"""
schema: crewai.flow/v1
@@ -2033,24 +1501,10 @@ methods:
start: true
"""
with pytest.raises(ValidationError, match="invalid CEL expression"):
FlowDefinition.from_yaml(yaml_str)
flow = Flow.from_definition(FlowDefinition.from_yaml(yaml_str))
def test_expression_action_rejects_unknown_cel_root():
yaml_str = """
schema: crewai.flow/v1
name: ExpressionFlow
methods:
classify:
do:
call: expression
expr: "score >= 80"
start: true
"""
with pytest.raises(ValidationError, match="unknown CEL root"):
FlowDefinition.from_yaml(yaml_str)
with pytest.raises(ValueError, match="failed to evaluate CEL expression"):
flow.kickoff()
def test_tool_action_requires_module_qualname_ref():

View File

@@ -1,3 +1,3 @@
"""CrewAI development tools."""
__version__ = "1.14.8a1"
__version__ = "1.14.7"

View File

@@ -171,8 +171,6 @@ info = "Commits must follow Conventional Commits 1.0.0."
[tool.uv]
exclude-newer = "3 days"
# pypdf 6.13.3 is a security fix newer than the global supply-chain cutoff.
exclude-newer-package = { pypdf = "2026-06-18T00:00:00Z" }
# composio-core pins rich<14 but textual requires rich>=14.
# onnxruntime 1.24+ dropped Python 3.10 wheels; cap it so qdrant[fastembed] resolves on 3.10.
@@ -182,8 +180,7 @@ exclude-newer-package = { pypdf = "2026-06-18T00:00:00Z" }
# langchain-text-splitters <1.1.2 has GHSA-fv5p-p927-qmxr (SSRF bypass in split_text_from_url).
# transformers 4.57.6 has CVE-2026-1839; force 5.4+ (docling 2.84 allows huggingface-hub>=1).
# cryptography 46.0.6 has CVE-2026-39892; force 46.0.7+.
# pypdf <6.10.2 has GHSA-4pxv-j86v-mhcw, GHSA-7gw9-cf7v-778f, GHSA-x284-j5p8-9c5p.
# pypdf <6.13.3 has GHSA-jm82-fx9c-mx94; force 6.13.3+.
# pypdf <6.10.2 has GHSA-4pxv-j86v-mhcw, GHSA-7gw9-cf7v-778f, GHSA-x284-j5p8-9c5p; force 6.10.2+.
# uv <0.11.15 has GHSA-4gg8-gxpx-9rph (and earlier GHSA-pjjw-68hj-v9mw); force 0.11.15+.
# python-multipart <0.0.27 has GHSA-pp6c-gr5w-3c5g (DoS via unbounded multipart headers).
# gitpython <3.1.50 has GHSA-mv93-w799-cj2w (config_writer newline injection bypassing the 3.1.49 patch -> RCE via core.hooksPath).
@@ -208,7 +205,7 @@ override-dependencies = [
"urllib3>=2.7.0",
"transformers>=5.4.0; python_version >= '3.10'",
"cryptography>=46.0.7",
"pypdf>=6.13.3,<7",
"pypdf>=6.10.2,<7",
"uv>=0.11.15,<1",
"python-multipart>=0.0.27,<1",
"gitpython>=3.1.50,<4",

13
uv.lock generated
View File

@@ -16,9 +16,6 @@ resolution-markers = [
exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values.
exclude-newer-span = "P3D"
[options.exclude-newer-package]
pypdf = "2026-06-18T00:00:00Z"
[manifest]
members = [
"crewai",
@@ -43,7 +40,7 @@ overrides = [
{ name = "pillow", specifier = ">=12.1.1" },
{ name = "pip", specifier = ">=26.1.2" },
{ name = "pydantic-settings", specifier = ">=2.14.0" },
{ name = "pypdf", specifier = ">=6.13.3,<7" },
{ name = "pypdf", specifier = ">=6.10.2,<7" },
{ name = "python-multipart", specifier = ">=0.0.27,<1" },
{ name = "rich", specifier = ">=13.7.1" },
{ name = "starlette", specifier = ">=1.3.1" },
@@ -1587,7 +1584,7 @@ requires-dist = [
{ name = "aiofiles", specifier = "~=24.1.0" },
{ name = "av", specifier = "~=13.0.0" },
{ name = "pillow", specifier = "~=12.1.1" },
{ name = "pypdf", specifier = "~=6.13.3" },
{ name = "pypdf", specifier = "~=6.10.0" },
{ name = "python-magic", specifier = ">=0.4.27" },
{ name = "tinytag", specifier = "~=2.2.1" },
]
@@ -7191,14 +7188,14 @@ wheels = [
[[package]]
name = "pypdf"
version = "6.13.3"
version = "6.13.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions", marker = "python_full_version < '3.11'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/17/18/9947cc201af9ccf76720fd3347bf4f70eb882ce3fcf4cb05f7443e4cf871/pypdf-6.13.3.tar.gz", hash = "sha256:f3cb822769725f1bac658c406cfc9460399043f3750c2d3e4650e0a85eacabd7", size = 6484063, upload-time = "2026-06-17T15:22:00.898Z" }
sdist = { url = "https://files.pythonhosted.org/packages/15/d9/9d12fa0d9660d03320725ff686c961b645a4218940a82296e1272d9e1ff0/pypdf-6.13.1.tar.gz", hash = "sha256:4841d8a4c1589e5833915dc0c7ddfacff80a2e0bcbeb5d1e681fecaa1674b03a", size = 6477811, upload-time = "2026-06-08T11:01:49.344Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/94/56/2967e621598987905fb8cdfadd8f8de6b5c68c9351f0523c4df8409f28f1/pypdf-6.13.3-py3-none-any.whl", hash = "sha256:c6e3f86afb625791510b02ad5480e94b63970bb957df75d44657c282ecc52224", size = 347288, upload-time = "2026-06-17T15:21:59.512Z" },
{ url = "https://files.pythonhosted.org/packages/fe/dd/8f03e0a5788a5d1feb4550617c3e6db5e9099eaee248a3e482ddaeacbbb0/pypdf-6.13.1-py3-none-any.whl", hash = "sha256:e555e4ce3f561ef069307622f1374136ba964ca6ca24f24158701decaf83ed9b", size = 346259, upload-time = "2026-06-08T11:01:47.741Z" },
]
[[package]]