mirror of
https://github.com/crewAIInc/crewAI.git
synced 2026-06-18 22:58:12 +00:00
Compare commits
6 Commits
main
...
lucas/con-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
483deddfc4 | ||
|
|
0be94a43f6 | ||
|
|
58e0f69e86 | ||
|
|
d9083d8424 | ||
|
|
2b4ae346da | ||
|
|
eb18db13b3 |
@@ -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
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "1.14.8a1"
|
||||
__version__ = "1.14.7"
|
||||
|
||||
@@ -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"
|
||||
"""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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`)
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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."'
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "1.14.8a1"
|
||||
__version__ = "1.14.7"
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -152,4 +152,4 @@ __all__ = [
|
||||
"wrap_file_source",
|
||||
]
|
||||
|
||||
__version__ = "1.14.8a1"
|
||||
__version__ = "1.14.7"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -330,4 +330,4 @@ __all__ = [
|
||||
"ZapierActionTools",
|
||||
]
|
||||
|
||||
__version__ = "1.14.8a1"
|
||||
__version__ = "1.14.7"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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])
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
157
lib/crewai/src/crewai/flow/runtime/_expressions.py
Normal file
157
lib/crewai/src/crewai/flow/runtime/_expressions.py
Normal 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
|
||||
@@ -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)
|
||||
@@ -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))
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""CrewAI development tools."""
|
||||
|
||||
__version__ = "1.14.8a1"
|
||||
__version__ = "1.14.7"
|
||||
|
||||
@@ -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
13
uv.lock
generated
@@ -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]]
|
||||
|
||||
Reference in New Issue
Block a user