Compare commits

...

21 Commits

Author SHA1 Message Date
Rip&Tear
035a90d937 Log secure settings fd close failures 2026-06-25 09:49:07 +08:00
Rip&Tear
1659ed0011 Close temp fd on secure settings write failure 2026-06-25 09:23:39 +08:00
Rip&Tear
0fea171746 Merge branch 'main' into fix/credential-file-permissions 2026-06-25 09:11:36 +08:00
Vinicius Brasil
178c2d212c docs: snapshot and changelog for v1.14.8a5 (#6329)
Some checks are pending
CodeQL Advanced / Analyze (actions) (push) Waiting to run
CodeQL Advanced / Analyze (python) (push) Waiting to run
Check Documentation Broken Links / Check broken links (push) Waiting to run
Vulnerability Scan / pip-audit (push) Waiting to run
2026-06-24 17:31:32 -07:00
Vinicius Brasil
563b55f7ca feat: bump versions to 1.14.8a5 (#6328) 2026-06-24 17:25:08 -07:00
Vinicius Brasil
340d23ae5d Remove StateProxy from flow state access (#6327)
`StateProxy` looked like a thread-safety boundary, but it only protected
a small slice of state operations. Some examples of operations that were
not covered:

- `self.state.counter += 1`, `self.state["counter"] += 1` (increments)
- `self.state.user.profile.score += 1` (nested object mutations)
- `self.state.config["limits"]["max"] = 10` (mutation through model fields)
- `self.state.items[0].status = "done"` (list/container mutations)

This commit decided to remove it completely for simplicity and
performance:

- Simpler runtime code
- attr read: 24x faster, attr write: 27x faster, list append: 19x faster (local benchmark)
- Clearer concurrency contract (lifecycle locks remain, but arbitrary
  shared state mutation is not presented as thread-safe)
2026-06-24 16:37:51 -07:00
Vinicius Brasil
7738a1d30c Make declarative refs work across flows and crews (#6326)
Declarative flows already used `module:qualname` refs for runtime
objects, but crew JSON tools still had their own lookup path. That meant
examples like `project_tools:LookupTool` were treated as named
`crewai_tools` lookups and failed with guidance that only mentioned
`SerperDevTool` or `custom:<name>`. Invalid refs such as
`not_tools:NotATool` also missed the same BaseTool validation used by
flow tool actions.

Move ref resolution into a shared declarative helper, use it from flow
tool actions and crew JSON loading, and require tool refs to resolve to
`BaseTool` classes before instantiation. Validation still checks tool
refs structurally, so validating a crew does not import or execute
project code.
2026-06-24 15:11:59 -07:00
Vinicius Brasil
156b3500b4 Fix JSON schema flow state kickoff inputs (#6325)
Allow required JSON schema state fields to be supplied by kickoff inputs
instead of requiring every field to exist in state.default before
runtime.

Example: a flow with required lead_name and no state.default can now run
with kickoff inputs={"lead_name": "Ada Lovelace"}.
2026-06-24 13:55:38 -07:00
Jesse Miller
5827abbc17 docs: nest One Card per Step under Crew Studio and drop rollout banner (AGE-107) (#6317)
Some checks failed
CodeQL Advanced / Analyze (actions) (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Check Documentation Broken Links / Check broken links (push) Has been cancelled
Vulnerability Scan / pip-audit (push) Has been cancelled
The page itself already landed on main via #6247. This rebases onto main
and applies the two remaining changes:

- Nest crew-studio + merged-step-card into a collapsible "Crew Studio"
  nav group (pencil icon), across edge and v1.14.7 in en, pt-BR, ko, ar.
- Remove the temporary "Rolling out" Note banner (feature ships today).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 13:36:49 -04:00
Vinicius Brasil
9d0e3a841b docs: snapshot and changelog for v1.14.8a4 (#6319) 2026-06-24 09:19:33 -07:00
Vinicius Brasil
12a5e91efb feat: bump versions to 1.14.8a4 (#6318) 2026-06-24 09:14:14 -07:00
Rip&Tear
fac3e3579b Fix symlink path traversal in skill archive extraction (#6235)
* Fix symlink path traversal in skill archive extraction

`_safe_extractall` (the Python < 3.12 fallback used by `crewai skills`
archive unpacking) validated each member's *name* against the destination
but never validated symlink/hardlink *targets*. A malicious skill tarball
could plant a symlink escaping the destination (e.g. `link -> /home/user/.ssh`)
followed by a regular member written through it (`link/authorized_keys`),
escaping `dest` even though every member name resolves inside it — the
classic symlink-extraction traversal.

The 3.12+ path (`extractall(..., filter="data")`) already blocks this; the
fallback now mirrors it by rejecting absolute link targets and any link
target that resolves outside the destination directory.

Adds regression tests covering absolute and relative escaping symlinks plus
benign in-tree symlinks and ordinary archives.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Harden skill cache archive extraction

* Reject special skill archive members

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 08:50:41 -07:00
Vinicius Brasil
a046e6a50b Validate declarative flow definition paths (#6311)
Some checks failed
CodeQL Advanced / Analyze (actions) (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Vulnerability Scan / pip-audit (push) Has been cancelled
2026-06-23 19:28:35 -07:00
Lorenze Jay
1862ff8f6c Support conversational flows in the CLI TUI (#6293)
Some checks failed
CodeQL Advanced / Analyze (actions) (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Vulnerability Scan / pip-audit (push) Has been cancelled
Check Documentation Broken Links / Check broken links (push) Has been cancelled
Build uv cache / build-cache (3.10) (push) Has been cancelled
Build uv cache / build-cache (3.11) (push) Has been cancelled
Build uv cache / build-cache (3.12) (push) Has been cancelled
Build uv cache / build-cache (3.13) (push) Has been cancelled
* Add conversational flow TUI support

* properly support tui
2026-06-23 18:04:09 -07:00
Vinicius Brasil
f2a074e35b docs: snapshot and changelog for v1.14.8a3 (#6310) 2026-06-23 14:11:31 -07:00
Vinicius Brasil
658b8ee8b9 feat: bump versions to 1.14.8a3 (#6309) 2026-06-23 14:05:23 -07:00
Vinicius Brasil
3452e5c187 Add unified declarative flow loading (#6308)
Add a single declaration loader shared by API and CLI callers.

- Add FlowDefinition.from_declaration for FlowDefinition instances, dictionaries, YAML/JSON strings, and file paths
- Add Flow.from_declaration to build runnable flows directly from the same inputs
- Route declarative flow CLI loading through Flow.from_declaration so path handling and validation stay centralized

```
# Load just the serializable definition when you do not need to run it yet.
definition = FlowDefinition.from_declaration(path="flows/research.crewai")
definition = FlowDefinition.from_declaration(contents=flow_yaml)
definition = FlowDefinition.from_declaration(contents=flow_dict)

# Build a runnable flow directly from the same declaration inputs.
flow = Flow.from_declaration(path="flows/research.crewai")
flow = Flow.from_declaration(contents=flow_yaml)
flow = Flow.from_declaration(contents=flow_dict)
flow = Flow.from_declaration(contents=definition)

# Run it like any other flow.
result = flow.kickoff(inputs={"topic": "AI agents"})

# The CLI now goes through the same path-based loader.
# crewai run --definition flows/research.crewai
```
2026-06-23 12:02:18 -07:00
Lucas Gomide
793539173d fix: pin opentelemetry to ~=1.42.0 (#6292)
The previous `~=1.34.0` pin kept us on the unmaintained 1.34 line —
last patched as `1.34.1` in June 2025, eight minor releases behind
upstream — and caused `_create_exp_backoff_generator` `ImportError`
crashes in factory deployments where the OpenTelemetry Operator's
injected init container shadows
`opentelemetry.exporter.otlp.proto.common._internal` with >=1.35 while
our `opentelemetry-exporter-otlp-proto-grpc==1.34.1` still imports the
removed private symbol. Pinning to `~=1.42.0` tracks the current
upstream stable line; the resolver now lands on 1.42.1 and our public
OTel trace API usage is unaffected.
2026-06-23 14:51:22 -04:00
Rip&Tear
df4495dc06 Potential fix for pull request finding 'Empty except'
Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>
2026-06-19 14:46:26 +08:00
Rip&Tear
714cd11d04 Potential fix for pull request finding 'Empty except'
Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>
2026-06-19 14:42:16 +08:00
Rip&Tear
750684ca36 fix: enforce owner-only permissions on credential files
Credentials stored at rest were left world-readable on multi-user hosts:

- TokenManager._get_secure_storage_path() documented its credential dir as
  mode 0o700 but created it via mkdir() with default perms (0o755), leaving
  the Fernet secret.key and encrypted tokens.enc in a traversable dir.
- Settings.dump() persisted tool_repository_password (plaintext) to
  settings.json via open("w"), producing a 0o644 file, and created the
  config dir at 0o755 — despite the sibling token_manager already writing
  secrets atomically at 0o600.

Fixes:
- TokenManager: chmod the credential dir to 0o700 after mkdir (robust against
  umask and pre-existing dirs).
- Settings: write settings.json atomically at 0o600 (mkstemp + chmod +
  os.replace) and chmod the dedicated config dir to 0o700. The /tmp and cwd
  fallback parents are deliberately not chmod'd; the 0o600 file mode protects
  the credential there.

Adds regression tests asserting 0o600 files and 0o700 dirs, and that shared
fallback dirs are not globally tightened.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 14:23:30 +08:00
60 changed files with 2184 additions and 698 deletions

View File

@@ -397,8 +397,14 @@
"group": "Build",
"pages": [
"edge/en/enterprise/features/automations",
"edge/en/enterprise/features/crew-studio",
"edge/en/enterprise/features/merged-step-card",
{
"group": "Crew Studio",
"icon": "pencil",
"pages": [
"edge/en/enterprise/features/crew-studio",
"edge/en/enterprise/features/merged-step-card"
]
},
"edge/en/enterprise/features/marketplace",
"edge/en/enterprise/features/agent-repositories",
"edge/en/enterprise/features/tools-and-integrations",
@@ -922,8 +928,14 @@
"group": "Build",
"pages": [
"v1.14.7/en/enterprise/features/automations",
"v1.14.7/en/enterprise/features/crew-studio",
"v1.14.7/en/enterprise/features/merged-step-card",
{
"group": "Crew Studio",
"icon": "pencil",
"pages": [
"v1.14.7/en/enterprise/features/crew-studio",
"v1.14.7/en/enterprise/features/merged-step-card"
]
},
"v1.14.7/en/enterprise/features/marketplace",
"v1.14.7/en/enterprise/features/agent-repositories",
"v1.14.7/en/enterprise/features/tools-and-integrations",
@@ -8549,8 +8561,14 @@
"group": "Construir",
"pages": [
"edge/pt-BR/enterprise/features/automations",
"edge/pt-BR/enterprise/features/crew-studio",
"edge/pt-BR/enterprise/features/merged-step-card",
{
"group": "Crew Studio",
"icon": "pencil",
"pages": [
"edge/pt-BR/enterprise/features/crew-studio",
"edge/pt-BR/enterprise/features/merged-step-card"
]
},
"edge/pt-BR/enterprise/features/marketplace",
"edge/pt-BR/enterprise/features/agent-repositories",
"edge/pt-BR/enterprise/features/tools-and-integrations",
@@ -9051,8 +9069,14 @@
"group": "Construir",
"pages": [
"v1.14.7/pt-BR/enterprise/features/automations",
"v1.14.7/pt-BR/enterprise/features/crew-studio",
"v1.14.7/pt-BR/enterprise/features/merged-step-card",
{
"group": "Crew Studio",
"icon": "pencil",
"pages": [
"v1.14.7/pt-BR/enterprise/features/crew-studio",
"v1.14.7/pt-BR/enterprise/features/merged-step-card"
]
},
"v1.14.7/pt-BR/enterprise/features/marketplace",
"v1.14.7/pt-BR/enterprise/features/agent-repositories",
"v1.14.7/pt-BR/enterprise/features/tools-and-integrations",
@@ -16415,8 +16439,14 @@
"group": "빌드",
"pages": [
"edge/ko/enterprise/features/automations",
"edge/ko/enterprise/features/crew-studio",
"edge/ko/enterprise/features/merged-step-card",
{
"group": "Crew Studio",
"icon": "pencil",
"pages": [
"edge/ko/enterprise/features/crew-studio",
"edge/ko/enterprise/features/merged-step-card"
]
},
"edge/ko/enterprise/features/marketplace",
"edge/ko/enterprise/features/agent-repositories",
"edge/ko/enterprise/features/tools-and-integrations",
@@ -16929,8 +16959,14 @@
"group": "빌드",
"pages": [
"v1.14.7/ko/enterprise/features/automations",
"v1.14.7/ko/enterprise/features/crew-studio",
"v1.14.7/ko/enterprise/features/merged-step-card",
{
"group": "Crew Studio",
"icon": "pencil",
"pages": [
"v1.14.7/ko/enterprise/features/crew-studio",
"v1.14.7/ko/enterprise/features/merged-step-card"
]
},
"v1.14.7/ko/enterprise/features/marketplace",
"v1.14.7/ko/enterprise/features/agent-repositories",
"v1.14.7/ko/enterprise/features/tools-and-integrations",
@@ -24473,8 +24509,14 @@
"group": "البناء",
"pages": [
"edge/ar/enterprise/features/automations",
"edge/ar/enterprise/features/crew-studio",
"edge/ar/enterprise/features/merged-step-card",
{
"group": "استوديو الطاقم",
"icon": "pencil",
"pages": [
"edge/ar/enterprise/features/crew-studio",
"edge/ar/enterprise/features/merged-step-card"
]
},
"edge/ar/enterprise/features/marketplace",
"edge/ar/enterprise/features/agent-repositories",
"edge/ar/enterprise/features/tools-and-integrations",
@@ -24987,8 +25029,14 @@
"group": "البناء",
"pages": [
"v1.14.7/ar/enterprise/features/automations",
"v1.14.7/ar/enterprise/features/crew-studio",
"v1.14.7/ar/enterprise/features/merged-step-card",
{
"group": "استوديو الطاقم",
"icon": "pencil",
"pages": [
"v1.14.7/ar/enterprise/features/crew-studio",
"v1.14.7/ar/enterprise/features/merged-step-card"
]
},
"v1.14.7/ar/enterprise/features/marketplace",
"v1.14.7/ar/enterprise/features/agent-repositories",
"v1.14.7/ar/enterprise/features/tools-and-integrations",

View File

@@ -4,6 +4,83 @@ description: "تحديثات المنتج والتحسينات وإصلاحات
icon: "clock"
mode: "wide"
---
<Update label="24 يونيو 2026">
## v1.14.8a5
[عرض الإصدار على GitHub](https://github.com/crewAIInc/crewAI/releases/tag/1.14.8a5)
## ما الذي تغير
### الميزات
- جعل المراجع التصريحية تعمل عبر التدفقات والفرق (#6326)
### إصلاحات الأخطاء
- إصلاح مدخلات بدء حالة تدفق مخطط JSON (#6325)
### الوثائق
- وضع بطاقة واحدة لكل خطوة تحت استوديو الفريق وإزالة لافتة التوزيع (AGE-107) (#6317)
- تحديث اللقطة وسجل التغييرات للإصدار v1.14.8a4 (#6319)
### إعادة الهيكلة
- إزالة `StateProxy` من الوصول إلى حالة التدفق (#6327)
## المساهمون
@jessemiller, @vinibrsl
</Update>
<Update label="24 يونيو 2026">
## v1.14.8a4
[عرض الإصدار على GitHub](https://github.com/crewAIInc/crewAI/releases/tag/1.14.8a4)
## ما الذي تغير
### الميزات
- دعم تدفقات المحادثة في واجهة سطر الأوامر TUI.
### إصلاحات الأخطاء
- إصلاح مسار التوجيه الرمزي في استخراج أرشيف المهارات.
- التحقق من صحة مسارات تعريف التدفق الإعلاني.
### الوثائق
- تحديث اللقطة وسجل التغييرات للإصدار v1.14.8a3.
## المساهمون
@lorenzejay, @theCyberTech, @vinibrsl
</Update>
<Update label="23 يونيو 2026">
## v1.14.8a3
[عرض الإصدار على GitHub](https://github.com/crewAIInc/crewAI/releases/tag/1.14.8a3)
## ما الذي تغير
### الميزات
- إضافة تحميل تدفق موحد إعلاني
- تحسين تجربة بدء تشغيل crewai run
- دمج `crewai run` و `crewai flow kickoff`
- الحفاظ على تقدم طريقة التدفق مرئيًا للفرق المتداخلة
- إضافة دعم واجهة سطر الأوامر الإعلانية للتدفق
- السماح باستخدام `@router()` كطريقة بدء لتدفق
- إضافة مخططات مخرجات مكتوبة لأدوات CrewAI
### إصلاحات الأخطاء
- تثبيت opentelemetry على ~=1.42.0
### الوثائق
- إضافة صفحة استوديو "بطاقة واحدة لكل خطوة"
## المساهمون
@jessemiller, @joaomdmoura, @lucasgomide, @vinibrsl
</Update>
<Update label="18 يونيو 2026">
## v1.14.8a2

View File

@@ -5,11 +5,6 @@ icon: "layer-group"
mode: "wide"
---
{/* CLEANUP: This <Note> banner is the only time-bound content on the page. After the feature ships (Wednesday, June 24th 2026), delete the banner below — the rest of the page is evergreen present-tense docs and needs no other edits. */}
<Note>
**الإطلاق يوم الأربعاء 24 يونيو.** تنتقل لوحة Studio إلى بطاقة واحدة لكل خطوة بدلاً من عُقد منفصلة للمهمة والوكيل، وذلك لتبسيط اللوحة مع إضافتنا لوظائف جديدة قريبًا. تستمر أتمتتك الحالية في العمل دون أي تغييرات مطلوبة — تبقى جميع إعدادات المهمة والوكيل متاحة، ولكن منظّمة في بطاقة واحدة.
</Note>
## نظرة عامة
على لوحة Studio، تُمثَّل كل خطوة عمل بـ **بطاقة واحدة**. تجمع البطاقة بين عنصرين كانا في السابق في عُقد منفصلة:

View File

@@ -4,6 +4,83 @@ description: "Product updates, improvements, and bug fixes for CrewAI"
icon: "clock"
mode: "wide"
---
<Update label="Jun 24, 2026">
## v1.14.8a5
[View release on GitHub](https://github.com/crewAIInc/crewAI/releases/tag/1.14.8a5)
## What's Changed
### Features
- Make declarative refs work across flows and crews (#6326)
### Bug Fixes
- Fix JSON schema flow state kickoff inputs (#6325)
### Documentation
- Nest One Card per Step under Crew Studio and drop rollout banner (AGE-107) (#6317)
- Update snapshot and changelog for v1.14.8a4 (#6319)
### Refactoring
- Remove `StateProxy` from flow state access (#6327)
## Contributors
@jessemiller, @vinibrsl
</Update>
<Update label="Jun 24, 2026">
## v1.14.8a4
[View release on GitHub](https://github.com/crewAIInc/crewAI/releases/tag/1.14.8a4)
## What's Changed
### Features
- Support conversational flows in the CLI TUI.
### Bug Fixes
- Fix symlink path traversal in skill archive extraction.
- Validate declarative flow definition paths.
### Documentation
- Update snapshot and changelog for v1.14.8a3.
## Contributors
@lorenzejay, @theCyberTech, @vinibrsl
</Update>
<Update label="Jun 23, 2026">
## v1.14.8a3
[View release on GitHub](https://github.com/crewAIInc/crewAI/releases/tag/1.14.8a3)
## What's Changed
### Features
- Add unified declarative flow loading
- Improve crewai run startup UX
- Consolidate `crewai run` and `crewai flow kickoff`
- Keep flow method progress visible for nested crews
- Add declarative Flow CLI support
- Allow `@router()` as start method of a flow
- Add typed output schemas for CrewAI tools
### Bug Fixes
- Pin opentelemetry to ~=1.42.0
### Documentation
- Add "One Card per Step" Studio page
## Contributors
@jessemiller, @joaomdmoura, @lucasgomide, @vinibrsl
</Update>
<Update label="Jun 18, 2026">
## v1.14.8a2

View File

@@ -5,11 +5,6 @@ icon: "layer-group"
mode: "wide"
---
{/* CLEANUP: This <Note> banner is the only time-bound content on the page. After the feature ships (Wednesday, June 24th 2026), delete the banner below — the rest of the page is evergreen present-tense docs and needs no other edits. */}
<Note>
**Rolling out Wednesday, June 24th.** The Studio canvas is moving to one card per step instead of separate task and agent nodes, to streamline the canvas as we add new functionality soon. Your existing automations keep working with no changes needed — every task and agent setting is still available, just organized onto a single card.
</Note>
## Overview
On the Studio canvas, each step of work is represented by a **single card**. The card combines two things that used to live in separate nodes:

View File

@@ -4,6 +4,83 @@ description: "CrewAI의 제품 업데이트, 개선 사항 및 버그 수정"
icon: "clock"
mode: "wide"
---
<Update label="2026년 6월 24일">
## v1.14.8a5
[GitHub 릴리스 보기](https://github.com/crewAIInc/crewAI/releases/tag/1.14.8a5)
## 변경 사항
### 기능
- 선언적 참조가 흐름과 크루 간에 작동하도록 수정 (#6326)
### 버그 수정
- JSON 스키마 흐름 상태 시작 입력 수정 (#6325)
### 문서
- Crew Studio 아래에 단계별 One Card를 중첩하고 롤아웃 배너 제거 (AGE-107) (#6317)
- v1.14.8a4의 스냅샷 및 변경 로그 업데이트 (#6319)
### 리팩토링
- 흐름 상태 접근에서 `StateProxy` 제거 (#6327)
## 기여자
@jessemiller, @vinibrsl
</Update>
<Update label="2026년 6월 24일">
## v1.14.8a4
[GitHub 릴리스 보기](https://github.com/crewAIInc/crewAI/releases/tag/1.14.8a4)
## 변경 사항
### 기능
- CLI TUI에서 대화형 흐름 지원.
### 버그 수정
- 기술 아카이브 추출 시 심볼릭 링크 경로 탐색 문제 수정.
- 선언적 흐름 정의 경로 검증.
### 문서
- v1.14.8a3에 대한 스냅샷 및 변경 로그 업데이트.
## 기여자
@lorenzejay, @theCyberTech, @vinibrsl
</Update>
<Update label="2026년 6월 23일">
## v1.14.8a3
[GitHub 릴리스 보기](https://github.com/crewAIInc/crewAI/releases/tag/1.14.8a3)
## 변경 사항
### 기능
- 통합 선언적 흐름 로딩 추가
- crewai run 시작 UX 개선
- `crewai run`과 `crewai flow kickoff` 통합
- 중첩된 크루의 흐름 메서드 진행 상황 표시 유지
- 선언적 Flow CLI 지원 추가
- 흐름의 시작 메서드로 `@router()` 허용
- CrewAI 도구에 대한 타입이 지정된 출력 스키마 추가
### 버그 수정
- opentelemetry를 ~=1.42.0으로 고정
### 문서
- "단계당 한 카드" 스튜디오 페이지 추가
## 기여자
@jessemiller, @joaomdmoura, @lucasgomide, @vinibrsl
</Update>
<Update label="2026년 6월 18일">
## v1.14.8a2

View File

@@ -5,11 +5,6 @@ icon: "layer-group"
mode: "wide"
---
{/* CLEANUP: This <Note> banner is the only time-bound content on the page. After the feature ships (Wednesday, June 24th 2026), delete the banner below — the rest of the page is evergreen present-tense docs and needs no other edits. */}
<Note>
**6월 24일 수요일 출시.** Studio 캔버스가 작업과 에이전트를 별도의 노드로 표시하는 대신 단계당 하나의 카드로 전환됩니다. 곧 추가될 새로운 기능을 위해 캔버스를 간소화하기 위한 변경입니다. 기존 자동화는 아무런 변경 없이 그대로 동작하며, 모든 작업 및 에이전트 설정은 단일 카드에 정리되어 그대로 사용할 수 있습니다.
</Note>
## 개요
Studio 캔버스에서 각 작업 단계는 **하나의 카드**로 표현됩니다. 이 카드는 이전에 별도의 노드에 있던 두 가지를 결합합니다:

View File

@@ -4,6 +4,83 @@ description: "Atualizações de produto, melhorias e correções do CrewAI"
icon: "clock"
mode: "wide"
---
<Update label="24 jun 2026">
## v1.14.8a5
[Ver release no GitHub](https://github.com/crewAIInc/crewAI/releases/tag/1.14.8a5)
## O que mudou
### Recursos
- Fazer referências declarativas funcionarem em diferentes fluxos e equipes (#6326)
### Correções de Bugs
- Corrigir entradas de kickoff de estado de fluxo do esquema JSON (#6325)
### Documentação
- Aninhar One Card por Etapa sob Crew Studio e remover banner de rollout (AGE-107) (#6317)
- Atualizar snapshot e changelog para v1.14.8a4 (#6319)
### Refatoração
- Remover `StateProxy` do acesso ao estado de fluxo (#6327)
## Contribuidores
@jessemiller, @vinibrsl
</Update>
<Update label="24 jun 2026">
## v1.14.8a4
[Ver release no GitHub](https://github.com/crewAIInc/crewAI/releases/tag/1.14.8a4)
## O que Mudou
### Recursos
- Suporte a fluxos de conversa na TUI do CLI.
### Correções de Bugs
- Corrigir a travessia de caminho de symlink na extração de arquivo de habilidade.
- Validar os caminhos de definição de fluxo declarativo.
### Documentação
- Atualizar snapshot e changelog para v1.14.8a3.
## Contribuidores
@lorenzejay, @theCyberTech, @vinibrsl
</Update>
<Update label="23 jun 2026">
## v1.14.8a3
[Ver release no GitHub](https://github.com/crewAIInc/crewAI/releases/tag/1.14.8a3)
## O que Mudou
### Recursos
- Adicionar carregamento de fluxo declarativo unificado
- Melhorar a experiência de inicialização do crewai run
- Consolidar `crewai run` e `crewai flow kickoff`
- Manter o progresso do método de fluxo visível para equipes aninhadas
- Adicionar suporte a Flow CLI declarativo
- Permitir `@router()` como método de início de um fluxo
- Adicionar esquemas de saída tipados para ferramentas CrewAI
### Correções de Bugs
- Fixar opentelemetry em ~=1.42.0
### Documentação
- Adicionar página "Uma Cartão por Etapa" no Studio
## Contribuidores
@jessemiller, @joaomdmoura, @lucasgomide, @vinibrsl
</Update>
<Update label="18 jun 2026">
## v1.14.8a2

View File

@@ -5,11 +5,6 @@ icon: "layer-group"
mode: "wide"
---
{/* CLEANUP: This <Note> banner is the only time-bound content on the page. After the feature ships (Wednesday, June 24th 2026), delete the banner below — the rest of the page is evergreen present-tense docs and needs no other edits. */}
<Note>
**Lançamento na quarta-feira, 24 de junho.** O canvas do Studio passa a exibir um card por etapa, em vez de nós separados para tarefa e agente, para simplificar o canvas à medida que adicionamos novas funcionalidades em breve. Suas automações existentes continuam funcionando sem nenhuma alteração necessária — cada configuração de tarefa e de agente continua disponível, apenas organizada em um único card.
</Note>
## Visão geral
No canvas do Studio, cada etapa de trabalho é representada por um **único card**. O card combina dois elementos que antes ficavam em nós separados:

View File

@@ -5,11 +5,6 @@ icon: "layer-group"
mode: "wide"
---
{/* CLEANUP: This <Note> banner is the only time-bound content on the page. After the feature ships (Wednesday, June 24th 2026), delete the banner below — the rest of the page is evergreen present-tense docs and needs no other edits. */}
<Note>
**الإطلاق يوم الأربعاء 24 يونيو.** تنتقل لوحة Studio إلى بطاقة واحدة لكل خطوة بدلاً من عُقد منفصلة للمهمة والوكيل، وذلك لتبسيط اللوحة مع إضافتنا لوظائف جديدة قريبًا. تستمر أتمتتك الحالية في العمل دون أي تغييرات مطلوبة — تبقى جميع إعدادات المهمة والوكيل متاحة، ولكن منظّمة في بطاقة واحدة.
</Note>
## نظرة عامة
على لوحة Studio، تُمثَّل كل خطوة عمل بـ **بطاقة واحدة**. تجمع البطاقة بين عنصرين كانا في السابق في عُقد منفصلة:

View File

@@ -5,11 +5,6 @@ icon: "layer-group"
mode: "wide"
---
{/* CLEANUP: This <Note> banner is the only time-bound content on the page. After the feature ships (Wednesday, June 24th 2026), delete the banner below — the rest of the page is evergreen present-tense docs and needs no other edits. */}
<Note>
**Rolling out Wednesday, June 24th.** The Studio canvas is moving to one card per step instead of separate task and agent nodes, to streamline the canvas as we add new functionality soon. Your existing automations keep working with no changes needed — every task and agent setting is still available, just organized onto a single card.
</Note>
## Overview
On the Studio canvas, each step of work is represented by a **single card**. The card combines two things that used to live in separate nodes:

View File

@@ -5,11 +5,6 @@ icon: "layer-group"
mode: "wide"
---
{/* CLEANUP: This <Note> banner is the only time-bound content on the page. After the feature ships (Wednesday, June 24th 2026), delete the banner below — the rest of the page is evergreen present-tense docs and needs no other edits. */}
<Note>
**6월 24일 수요일 출시.** Studio 캔버스가 작업과 에이전트를 별도의 노드로 표시하는 대신 단계당 하나의 카드로 전환됩니다. 곧 추가될 새로운 기능을 위해 캔버스를 간소화하기 위한 변경입니다. 기존 자동화는 아무런 변경 없이 그대로 동작하며, 모든 작업 및 에이전트 설정은 단일 카드에 정리되어 그대로 사용할 수 있습니다.
</Note>
## 개요
Studio 캔버스에서 각 작업 단계는 **하나의 카드**로 표현됩니다. 이 카드는 이전에 별도의 노드에 있던 두 가지를 결합합니다:

View File

@@ -5,11 +5,6 @@ icon: "layer-group"
mode: "wide"
---
{/* CLEANUP: This <Note> banner is the only time-bound content on the page. After the feature ships (Wednesday, June 24th 2026), delete the banner below — the rest of the page is evergreen present-tense docs and needs no other edits. */}
<Note>
**Lançamento na quarta-feira, 24 de junho.** O canvas do Studio passa a exibir um card por etapa, em vez de nós separados para tarefa e agente, para simplificar o canvas à medida que adicionamos novas funcionalidades em breve. Suas automações existentes continuam funcionando sem nenhuma alteração necessária — cada configuração de tarefa e de agente continua disponível, apenas organizada em um único card.
</Note>
## Visão geral
No canvas do Studio, cada etapa de trabalho é representada por um **único card**. O card combina dois elementos que antes ficavam em nós separados:

View File

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

View File

@@ -1 +1 @@
__version__ = "1.14.8a2"
__version__ = "1.14.8a5"

View File

@@ -17,7 +17,7 @@ from textual.binding import Binding, BindingType
from textual.containers import Horizontal, Vertical, VerticalScroll
from textual.css.query import NoMatches
from textual.screen import ModalScreen
from textual.widgets import Button, Footer, Header, Static
from textual.widgets import Button, Footer, Header, Input, Static
_SPINNER = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"
@@ -382,6 +382,18 @@ Screen {
height: auto;
}
#conversation-input {
display: none;
height: 3;
border-top: hkey #333333;
background: #1c1c1c;
color: #e0e0e0;
}
#conversation-input:focus {
border-top: hkey #1F7982;
}
Header {
background: #1c1c1c;
color: #FF5A50;
@@ -483,6 +495,7 @@ FooterKey .footer-key--key {
total_tasks: int = 0,
agent_names: list[str] | None = None,
task_names: list[str] | None = None,
conversational: bool = False,
):
super().__init__()
self.title = f"CrewAI — {crew_name}"
@@ -544,6 +557,13 @@ FooterKey .footer-key--key {
self._event_handlers: list[tuple[type, Any]] = []
self._crew: Any = None
self._flow: Any = None
self._is_conversational = conversational
self._conversation_messages: list[tuple[str, str]] = []
self._conversation_turns = 0
self._conversation_turn_in_progress = False
self._conversation_previous_defer_trace_finalization: bool | None = None
self._conversation_exit_commands = {"exit", "quit"}
self._default_inputs: dict[str, Any] | None = None
self._crew_result: Any = None
self._crew_json_path: Any = None
@@ -566,6 +586,10 @@ FooterKey .footer-key--key {
yield Static(id="task-header")
with VerticalScroll(id="scroll-area"):
yield Static(id="main-content")
yield Input(
placeholder="Message the flow...",
id="conversation-input",
)
with VerticalScroll(id="log-panel"):
yield Static(id="log-content")
yield Footer()
@@ -574,7 +598,9 @@ FooterKey .footer-key--key {
self._start_time = time.time()
self._subscribe()
self._tick_timer = self.set_interval(1 / 8, self._tick)
if self._crew:
if self._is_conversational and self._flow:
self._start_conversational_session()
elif self._crew:
self._run_crew_worker()
elif self._crew_json_path:
self._load_and_run_worker()
@@ -725,6 +751,140 @@ FooterKey .footer-key--key {
self._tick_timer = self.set_interval(1 / 2, self._tick)
self._unsubscribe_if_no_running_memory_save(wait_for_queued=True)
# ── Conversational flow execution ───────────────────────
def _start_conversational_session(self) -> None:
from crewai.events.listeners.tracing.utils import (
set_suppress_tracing_messages,
set_tui_mode,
)
set_tui_mode(True)
set_suppress_tracing_messages(True)
with self._lock:
self._status = "chatting"
self._current_step = None
self._elapsed_frozen = None
self._conversation_previous_defer_trace_finalization = getattr(
self._flow, "defer_trace_finalization", False
)
self._flow.defer_trace_finalization = True
try:
input_widget = self.query_one("#conversation-input", Input)
input_widget.display = True
input_widget.focus()
except Exception: # noqa: S110
pass
def _finalize_conversational_session(self) -> None:
if not (self._is_conversational and self._flow):
return
try:
self._flow.finalize_session_traces()
except Exception: # noqa: S110
pass
previous = self._conversation_previous_defer_trace_finalization
if previous is not None:
try:
self._flow.defer_trace_finalization = previous
except Exception: # noqa: S110
pass
def on_input_submitted(self, event: Input.Submitted) -> None:
if event.input.id != "conversation-input":
return
if not self._is_conversational:
return
message = event.value.strip()
event.input.value = ""
if not message:
return
if message.lower() in self._conversation_exit_commands:
self._finalize_conversational_session()
self._unsubscribe()
self.exit(self._crew_result)
return
if self._conversation_turn_in_progress:
return
with self._lock:
self._conversation_messages.append(("user", message))
self._conversation_turn_in_progress = True
self._conversation_turns += 1
self._status = "working"
self._current_step = ("yellow", "Thinking…", "")
self._is_streaming = False
self._streaming_text = ""
self._task_full_output = ""
self._current_llm_text = ""
event.input.disabled = True
self._run_conversation_turn_worker(message)
@work(thread=True, exclusive=True, group="conversation")
def _run_conversation_turn_worker(self, message: str) -> None:
from crewai.events.listeners.tracing.utils import (
set_suppress_tracing_messages,
set_tui_mode,
)
set_tui_mode(True)
set_suppress_tracing_messages(True)
try:
result = self._flow.handle_turn(message)
if hasattr(result, "get_full_text") and hasattr(result, "result"):
for _chunk in result:
pass
result = result.result
self.call_from_thread(self._on_conversation_turn_done, result)
except Exception as e:
self.call_from_thread(self._on_conversation_turn_failed, str(e))
def _on_conversation_turn_done(self, result: Any) -> None:
with self._lock:
output = self._stringify_output(result)
self._conversation_messages.append(("assistant", output))
self._crew_result = result
self._conversation_turn_in_progress = False
self._status = "chatting"
self._is_streaming = False
self._streaming_text = ""
self._current_step = None
self._enable_conversation_input()
self._tick()
self._scroll_to_result()
def _on_conversation_turn_failed(self, error: str) -> None:
with self._lock:
self._status = "failed"
self._error = error
self._conversation_turn_in_progress = False
self._is_streaming = False
self._current_step = None
self._enable_conversation_input()
self._tick()
def _enable_conversation_input(self) -> None:
try:
input_widget = self.query_one("#conversation-input", Input)
input_widget.disabled = False
input_widget.focus()
except Exception: # noqa: S110
pass
def _stringify_output(self, result: Any) -> str:
raw_result = getattr(result, "raw", result)
if raw_result is None:
return ""
if isinstance(raw_result, str):
return raw_result
try:
return _json.dumps(raw_result, default=str, ensure_ascii=False)
except TypeError:
return str(raw_result)
# ── Actions ─────────────────────────────────────────────
def action_toggle_sidebar(self) -> None:
@@ -783,6 +943,7 @@ FooterKey .footer-key--key {
self._refresh_log_panel()
async def action_quit(self) -> None:
self._finalize_conversational_session()
self._unsubscribe()
self.exit(self._crew_result)
@@ -958,6 +1119,30 @@ FooterKey .footer-key--key {
t = Text()
sidebar_width = 30
if self._is_conversational:
t.append(" CONVERSATION\n", style=f"bold {_C_PRIMARY}")
t.append("\n")
if self._conversation_turn_in_progress:
t.append(f" {self._spinner()} ", style=_C_PRIMARY)
t.append("Working\n", style=f"bold {_C_TEXT}")
elif self._status == "failed":
t.append(" ✘ Failed\n", style=_C_RED)
else:
t.append(" ● Ready\n", style=_C_GREEN)
t.append(f" Turns {self._conversation_turns}\n", style=_C_DIM)
t.append("\n")
t.append(" TOKENS\n", style=f"bold {_C_PRIMARY}")
t.append("\n")
out = self._output_tokens + self._live_out_tokens
t.append(f"{self._input_tokens:,}\n", style=_C_DIM)
t.append(f"{out:,}\n", style=_C_DIM)
t.append("\n")
t.append(" COMMANDS\n", style=f"bold {_C_PRIMARY}")
t.append("\n")
t.append(" quit / exit\n", style=_C_DIM)
widget.update(t)
return
t.append(" TASKS\n", style=f"bold {_C_PRIMARY}")
t.append("\n")
@@ -1011,6 +1196,22 @@ FooterKey .footer-key--key {
widget = self.query_one("#task-header", Static)
t = Text()
if self._is_conversational:
if self._status == "failed":
t.append("", style=f"bold {_C_RED}")
t.append("Failed", style=f"bold {_C_RED}")
if self._error:
t.append(f"\n{self._error[:120]}", style=_C_RED)
elif self._conversation_turn_in_progress:
t.append(f"{self._spinner()} ", style=_C_PRIMARY)
t.append("Flow is responding", style=f"bold {_C_PRIMARY}")
else:
t.append("", style=f"bold {_C_GREEN}")
t.append("Conversational flow ready", style=f"bold {_C_GREEN}")
t.append(" Type a message below", style=_C_DIM)
widget.update(t)
return
if self._status == "completed":
elapsed = self._elapsed_frozen or (time.time() - self._start_time)
t.append("", style=f"bold {_C_GREEN}")
@@ -1062,6 +1263,41 @@ FooterKey .footer-key--key {
t = Text()
should_scroll = False
if self._is_conversational:
if not self._conversation_messages and not self._is_streaming:
t.append(" Start the conversation below.\n", style=_C_MUTED)
for role, content in self._conversation_messages:
if role == "user":
t.append("\n You\n", style=f"bold {_C_TEAL}")
else:
t.append("\n Assistant\n", style=f"bold {_C_PRIMARY}")
rendered = _format_json_in_text(_unescape_text(content))
for line in rendered.split("\n"):
style = _C_TEXT if role == "assistant" else _C_DIM
t.append(f" {line}\n", style=style)
if self._is_streaming and self._streaming_text:
text = _unescape_text(self._filtered_streaming_text())
if text.strip():
t.append("\n Assistant\n", style=f"bold {_C_PRIMARY}")
for line in text.rstrip().split("\n")[-40:]:
t.append(f" {line}\n", style=_C_TEXT)
should_scroll = True
if self._status == "failed" and self._error:
t.append("\n Error\n", style=f"bold {_C_RED}")
t.append(f" {self._error}\n", style=_C_RED)
widget.update(t)
if should_scroll:
try:
self.query_one("#scroll-area", VerticalScroll).scroll_end(
animate=False
)
except Exception: # noqa: S110
pass
return
# Plan section
if self._plan and self._plan.get("steps"):
plan_title = self._plan.get("plan", "Plan")

View File

@@ -378,12 +378,40 @@ class SkillCommand(BaseCommand, PlusAPIMixin):
def _safe_extractall(tf: tarfile.TarFile, dest: Path) -> None:
"""Path-traversal-safe extraction for Python < 3.12."""
"""Path-traversal-safe extraction for Python versions without tar filters.
Validates both the member's own path and, for symlink/hardlink members,
the link target. Without the link-target check a malicious archive can
plant a symlink that escapes ``dest`` (e.g. ``link -> /home/user/.ssh``)
followed by a regular member written *through* that link
(``link/authorized_keys``), escaping ``dest`` even though every member
name resolves inside it. This mirrors the protection that
``tarfile.extractall(..., filter="data")`` provides when available.
"""
dest_resolved = dest.resolve()
for member in tf.getmembers():
member_path = (dest / member.name).resolve()
if not member_path.is_relative_to(dest_resolved):
raise ValueError(f"Blocked path traversal attempt: {member.name!r}")
if not (member.isfile() or member.isdir() or member.issym() or member.islnk()):
raise ValueError(f"Blocked unsupported tar member: {member.name!r}")
if member.issym() or member.islnk():
link_target = member.linkname
# Absolute link targets always escape the destination.
if os.path.isabs(link_target):
raise ValueError(
f"Blocked link target escaping destination: "
f"{member.name!r} -> {link_target!r}"
)
# Hardlink names are relative to the archive root; symlink
# targets are relative to the member's own directory.
anchor = dest if member.islnk() else (dest / member.name).parent
resolved_target = (anchor / link_target).resolve()
if not resolved_target.is_relative_to(dest_resolved):
raise ValueError(
f"Blocked link target escaping destination: "
f"{member.name!r} -> {link_target!r}"
)
tf.extractall(dest) # noqa: S202

View File

@@ -0,0 +1,105 @@
from __future__ import annotations
import importlib
import inspect
from pathlib import Path
import subprocess
import sys
from typing import Any
import click
def _project_script_target(script_name: str) -> str | None:
try:
from crewai_cli.utils import read_toml
pyproject = read_toml()
except Exception:
return None
target = pyproject.get("project", {}).get("scripts", {}).get(script_name)
return target if isinstance(target, str) else None
def _prepare_project_import_path() -> None:
cwd = Path.cwd()
for path in (cwd / "src", cwd):
path_str = str(path)
if path.exists() and path_str not in sys.path:
sys.path.insert(0, path_str)
def _load_conversational_flow_from_kickoff_script() -> Any | None:
target = _project_script_target("kickoff")
if not target or ":" not in target:
return None
module_name, _callable_name = target.split(":", 1)
_prepare_project_import_path()
try:
module = importlib.import_module(module_name)
from crewai.flow.flow import Flow
except Exception:
return None
for value in vars(module).values():
if (
inspect.isclass(value)
and value is not Flow
and issubclass(value, Flow)
and getattr(value, "conversational", False)
):
return value()
for value in vars(module).values():
if (
isinstance(value, Flow)
and getattr(value, "conversational", False)
and callable(getattr(value, "handle_turn", None))
):
return value
return None
def _run_conversational_flow_tui(flow: Any) -> Any:
from crewai_cli.crew_run_tui import CrewRunApp
app = CrewRunApp(
crew_name=getattr(flow, "name", None) or type(flow).__name__,
conversational=True,
)
app._flow = flow
app.run()
if app._status == "failed":
raise SystemExit(1)
return app._crew_result
def kickoff_flow() -> None:
"""
Kickoff the flow by running a command in the UV environment.
"""
flow = _load_conversational_flow_from_kickoff_script()
if flow is not None:
_run_conversational_flow_tui(flow)
return
command = ["uv", "run", "kickoff"]
try:
result = subprocess.run(command, capture_output=False, text=True, check=True) # noqa: S603
if result.stderr:
click.echo(result.stderr, err=True)
except subprocess.CalledProcessError as e:
click.echo(f"An error occurred while running the flow: {e}", err=True)
click.echo(e.output, err=True)
except Exception as e:
click.echo(f"An unexpected error occurred: {e}", err=True)

View File

@@ -604,6 +604,16 @@ def _run_flow_project(
run_declarative_flow_in_project_env(definition=definition)
return
from crewai_cli.kickoff_flow import (
_load_conversational_flow_from_kickoff_script,
_run_conversational_flow_tui,
)
flow = _load_conversational_flow_from_kickoff_script()
if flow is not None:
_run_conversational_flow_tui(flow)
return
_execute_uv_script("kickoff", entity_type="flow")

View File

@@ -1,17 +1,18 @@
from __future__ import annotations
import json
from pathlib import Path
from pathlib import Path, PureWindowsPath
import subprocess
from typing import Any
import click
from pydantic import ValidationError
from crewai_cli.utils import build_env_with_all_tool_credentials
def run_declarative_flow_in_project_env(
definition: str, inputs: str | None = None
definition: str | Path, inputs: str | None = None
) -> None:
"""Run a declarative flow inside the project's Python environment."""
if is_declarative_flow_project_env() or not _has_project_file():
@@ -24,7 +25,7 @@ def run_declarative_flow_in_project_env(
_execute_declarative_flow_command(["uv", "run", "crewai", "run"])
def plot_declarative_flow_in_project_env(definition: str) -> None:
def plot_declarative_flow_in_project_env(definition: str | Path) -> None:
"""Plot a declarative flow inside the project's Python environment."""
if is_declarative_flow_project_env() or not _has_project_file():
plot_declarative_flow(definition=definition)
@@ -33,7 +34,7 @@ def plot_declarative_flow_in_project_env(definition: str) -> None:
_execute_declarative_flow_command(["uv", "run", "crewai", "flow", "plot"])
def run_declarative_flow(definition: str, inputs: str | None = None) -> None:
def run_declarative_flow(definition: str | Path, inputs: str | None = None) -> None:
"""Run a declarative flow from a definition path."""
parsed_inputs = _parse_inputs(inputs)
@@ -49,7 +50,7 @@ def run_declarative_flow(definition: str, inputs: str | None = None) -> None:
click.echo(_format_result(result))
def plot_declarative_flow(definition: str) -> None:
def plot_declarative_flow(definition: str | Path) -> None:
"""Plot a declarative flow from a definition path."""
try:
flow = load_declarative_flow(definition)
@@ -61,11 +62,10 @@ def plot_declarative_flow(definition: str) -> None:
raise SystemExit(1) from exc
def load_declarative_flow(definition: str) -> Any:
def load_declarative_flow(definition: str | Path) -> Any:
"""Load a declarative Flow instance from a definition path."""
try:
from crewai.flow.flow import Flow
from crewai.flow.flow_definition import FlowDefinition
except ImportError as exc:
click.echo(
"Running declarative flows requires the full crewai package.",
@@ -74,19 +74,36 @@ def load_declarative_flow(definition: str) -> Any:
raise SystemExit(1) from exc
definition_path = Path(definition).expanduser()
definition_source = _read_declarative_flow_source(definition_path, definition)
try:
if not definition_path.is_file():
if definition_path.exists():
click.echo(
f"Invalid --definition path: {definition} is not a file.",
err=True,
)
raise SystemExit(1)
click.echo(
f"Invalid --definition path: {definition} does not exist.", err=True
)
raise SystemExit(1)
except OSError as exc:
click.echo(f"Invalid --definition path: {definition} ({exc})", err=True)
raise SystemExit(1) from exc
flow_definition = _parse_declarative_flow(
FlowDefinition,
definition_source,
source_path=definition_path,
)
return Flow.from_definition(flow_definition)
try:
return Flow.from_declaration(path=definition_path)
except (OSError, UnicodeError, ValueError, ValidationError) as exc:
click.echo(
f"Unable to read --definition path {definition_path}: {exc}",
err=True,
)
raise SystemExit(1) from exc
def configured_project_declarative_flow(
pyproject_data: dict[str, Any] | None = None,
) -> str | None:
project_root: Path | None = None,
) -> Path | None:
"""Return the configured declarative flow source for flow projects."""
if pyproject_data is None:
try:
@@ -102,7 +119,66 @@ def configured_project_declarative_flow(
definition = crewai_config.get("definition")
if not isinstance(definition, str):
return None
return definition.strip() or None
definition = definition.strip()
if not definition:
return None
return _resolve_project_definition_path(
definition=definition,
project_root=project_root or Path.cwd(),
)
def _resolve_project_definition_path(definition: str, project_root: Path) -> Path:
definition_path = Path(definition)
windows_definition_path = PureWindowsPath(definition)
if definition.startswith("~"):
raise click.UsageError(
"[tool.crewai] definition must be a project-local path; "
f"got {definition!r}."
)
if definition_path.is_absolute() or windows_definition_path.is_absolute():
raise click.UsageError(
"[tool.crewai] definition must be relative to the project root; "
f"got {definition!r}."
)
try:
root = project_root.resolve(strict=True)
except OSError as exc:
raise click.UsageError(
f"Invalid project root for [tool.crewai] definition: {exc}"
) from exc
candidate = root / definition_path
try:
resolved_candidate = candidate.resolve(strict=False)
except OSError as exc:
raise click.UsageError(
f"Invalid [tool.crewai] definition path {definition!r}: {exc}"
) from exc
if not resolved_candidate.is_relative_to(root):
raise click.UsageError(
"[tool.crewai] definition must resolve inside the project root; "
f"got {definition!r}."
)
if not resolved_candidate.exists():
raise click.UsageError(
"[tool.crewai] definition must point to an existing file; "
f"got {definition!r}."
)
if not resolved_candidate.is_file():
raise click.UsageError(
"[tool.crewai] definition must point to a regular file; "
f"got {definition!r}."
)
return resolved_candidate
def _execute_declarative_flow_command(command: list[str]) -> None:
@@ -154,53 +230,6 @@ def _parse_inputs(inputs: str | None) -> dict[str, Any] | None:
return parsed
def _read_declarative_flow_source(path: Path, definition: str) -> str:
try:
if path.is_file():
source = _read_declarative_flow_file(path)
elif path.exists():
click.echo(
f"Invalid --definition path: {definition} is not a file.", err=True
)
raise SystemExit(1)
else:
click.echo(
f"Invalid --definition path: {definition} does not exist.", err=True
)
raise SystemExit(1)
except OSError as exc:
click.echo(f"Invalid --definition path: {definition} ({exc})", err=True)
raise SystemExit(1) from exc
return source
def _read_declarative_flow_file(path: Path) -> str:
try:
source = path.read_text(encoding="utf-8")
except (OSError, UnicodeError) as exc:
click.echo(
f"Unable to read --definition path {path}: {exc}",
err=True,
)
raise SystemExit(1) from exc
return source
def _parse_declarative_flow(
flow_definition_cls: type[Any], source: str, *, source_path: Path
) -> Any:
if _looks_like_json(source):
return flow_definition_cls.from_json(source, source_path=source_path)
return flow_definition_cls.from_yaml(source, source_path=source_path)
def _looks_like_json(source: str) -> bool:
stripped = source.lstrip()
return stripped.startswith("{")
def _format_result(result: Any) -> str:
raw_result = getattr(result, "raw", result)
if isinstance(raw_result, str):

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,140 @@
"""Regression tests for path-traversal-safe archive extraction.
Guards against symlink/hardlink-based path traversal in the fallback used on
Python versions without tarfile extraction filters. The filtered path relies on
`tarfile.extractall(..., filter="data")`; the fallback must provide the same
protection by validating link targets, not just member names.
"""
from __future__ import annotations
import io
import tarfile
from pathlib import Path
import pytest
from crewai_cli.experimental.skills.main import _safe_extractall
def _tar_from_members(build) -> tarfile.TarFile:
"""Build an in-memory tar archive via `build(tf)` and return it for reading."""
buf = io.BytesIO()
with tarfile.open(fileobj=buf, mode="w") as tf:
build(tf)
buf.seek(0)
return tarfile.open(fileobj=buf, mode="r")
def test_blocks_symlink_escaping_destination(tmp_path: Path) -> None:
"""A symlink whose target escapes dest, plus a file written through it,
must be rejected before anything is extracted."""
outside = tmp_path / "outside"
outside.mkdir()
dest = tmp_path / "dest"
dest.mkdir()
def build(tf: tarfile.TarFile) -> None:
link = tarfile.TarInfo("link")
link.type = tarfile.SYMTYPE
link.linkname = str(outside) # absolute path outside dest
tf.addfile(link)
payload = b"pwned"
info = tarfile.TarInfo("link/evil.txt")
info.size = len(payload)
tf.addfile(info, io.BytesIO(payload))
with _tar_from_members(build) as tf:
with pytest.raises(ValueError, match="escaping destination"):
_safe_extractall(tf, dest)
assert not (outside / "evil.txt").exists()
def test_blocks_relative_symlink_escaping_destination(tmp_path: Path) -> None:
"""A relative symlink (../..) that escapes dest is also rejected."""
dest = tmp_path / "dest"
dest.mkdir()
def build(tf: tarfile.TarFile) -> None:
link = tarfile.TarInfo("sub/link")
link.type = tarfile.SYMTYPE
link.linkname = "../../outside" # escapes dest from sub/
tf.addfile(link)
with _tar_from_members(build) as tf:
with pytest.raises(ValueError, match="escaping destination"):
_safe_extractall(tf, dest)
def test_blocks_hardlink_escaping_destination(tmp_path: Path) -> None:
"""A hardlink whose target escapes dest is rejected."""
dest = tmp_path / "dest"
dest.mkdir()
def build(tf: tarfile.TarFile) -> None:
link = tarfile.TarInfo("escape")
link.type = tarfile.LNKTYPE
link.linkname = "../outside.txt" # escapes archive root
tf.addfile(link)
with _tar_from_members(build) as tf:
with pytest.raises(ValueError, match="escaping destination"):
_safe_extractall(tf, dest)
def test_blocks_special_tar_member(tmp_path: Path) -> None:
"""Special tar members such as FIFOs are rejected."""
dest = tmp_path / "dest"
dest.mkdir()
def build(tf: tarfile.TarFile) -> None:
fifo = tarfile.TarInfo("pipe")
fifo.type = tarfile.FIFOTYPE
tf.addfile(fifo)
with _tar_from_members(build) as tf:
with pytest.raises(ValueError, match="unsupported tar member"):
_safe_extractall(tf, dest)
def test_allows_benign_relative_symlink(tmp_path: Path) -> None:
"""A symlink that stays within dest is permitted."""
dest = tmp_path / "dest"
dest.mkdir()
def build(tf: tarfile.TarFile) -> None:
payload = b"hi"
info = tarfile.TarInfo("real.txt")
info.size = len(payload)
tf.addfile(info, io.BytesIO(payload))
link = tarfile.TarInfo("alias.txt")
link.type = tarfile.SYMTYPE
link.linkname = "real.txt" # stays inside dest
tf.addfile(link)
with _tar_from_members(build) as tf:
_safe_extractall(tf, dest)
assert (dest / "real.txt").read_bytes() == b"hi"
assert (dest / "alias.txt").is_symlink()
assert (dest / "alias.txt").readlink() == Path("real.txt")
def test_allows_benign_archive(tmp_path: Path) -> None:
"""An ordinary archive of regular files extracts correctly."""
dest = tmp_path / "dest"
dest.mkdir()
def build(tf: tarfile.TarFile) -> None:
for name, body in (("SKILL.md", b"# skill"), ("scripts/run.py", b"print(1)")):
payload = body
info = tarfile.TarInfo(name)
info.size = len(payload)
tf.addfile(info, io.BytesIO(payload))
with _tar_from_members(build) as tf:
_safe_extractall(tf, dest)
assert (dest / "SKILL.md").read_bytes() == b"# skill"
assert (dest / "scripts" / "run.py").read_bytes() == b"print(1)"

View File

@@ -1,5 +1,8 @@
import json
import os
import shutil
import stat
import sys
import tempfile
import unittest
from datetime import datetime, timedelta
@@ -146,3 +149,55 @@ class TestSettings(unittest.TestCase):
settings = Settings(config_path=self.config_path)
self.assertIsNone(settings.tool_repository_username)
class TestSettingsFilePermissions(unittest.TestCase):
"""Regression tests: credentials in settings.json must not be world-readable."""
def setUp(self):
self.test_dir = Path(tempfile.mkdtemp())
def tearDown(self):
shutil.rmtree(self.test_dir, ignore_errors=True)
@unittest.skipIf(sys.platform == "win32", "POSIX permission semantics")
def test_dump_writes_owner_only_file(self):
config_path = self.test_dir / "settings.json"
old_umask = os.umask(0o022)
try:
settings = Settings(
config_path=config_path, tool_repository_password="hunter2"
)
settings.dump()
finally:
os.umask(old_umask)
mode = stat.S_IMODE(config_path.stat().st_mode)
self.assertEqual(mode, 0o600, f"expected 0o600, got {oct(mode)}")
@unittest.skipIf(sys.platform == "win32", "POSIX permission semantics")
def test_dedicated_config_dir_is_owner_only(self):
config_path = self.test_dir / "crewai" / "settings.json"
old_umask = os.umask(0o022)
try:
Settings(config_path=config_path, tool_repository_username="u")
finally:
os.umask(old_umask)
mode = stat.S_IMODE(config_path.parent.stat().st_mode)
self.assertEqual(mode, 0o700, f"expected 0o700, got {oct(mode)}")
@unittest.skipIf(sys.platform == "win32", "POSIX permission semantics")
def test_shared_fallback_dir_is_not_chmodded(self):
"""The system temp dir (a fallback parent) must never be globally chmod'd."""
from crewai_core.settings import _ensure_dir_mode
tmp_root = Path(tempfile.gettempdir())
before = stat.S_IMODE(tmp_root.stat().st_mode)
_ensure_dir_mode(tmp_root)
after = stat.S_IMODE(tmp_root.stat().st_mode)
self.assertEqual(before, after)
if __name__ == "__main__":
unittest.main()

View File

@@ -126,6 +126,52 @@ def test_chain_deploy_does_not_login_for_deploy_exit(monkeypatch, capsys) -> Non
assert "Deploy failed with exit code 42" in capsys.readouterr().out
def test_conversation_turn_done_records_assistant_message() -> None:
class RawResult:
raw = "hello from the flow"
app = CrewRunApp(conversational=True)
app._conversation_turn_in_progress = True
app._enable_conversation_input = lambda: None # type: ignore[method-assign]
app._tick = lambda: None # type: ignore[method-assign]
app._scroll_to_result = lambda: None # type: ignore[method-assign]
app._on_conversation_turn_done(RawResult())
assert app._conversation_messages == [("assistant", "hello from the flow")]
assert app._conversation_turn_in_progress is False
assert app._status == "chatting"
assert isinstance(app._crew_result, RawResult)
@pytest.mark.asyncio
async def test_conversation_input_submits_turn() -> None:
class FakeFlow:
defer_trace_finalization = False
def handle_turn(self, message: str) -> str:
return f"reply: {message}"
def finalize_session_traces(self) -> None:
pass
app = CrewRunApp(crew_name="Demo", conversational=True)
app._flow = FakeFlow()
async with app.run_test() as pilot:
await pilot.click("#conversation-input")
await pilot.press("h", "i", "enter")
for _ in range(50):
await pilot.pause(0.05)
if app._conversation_messages[-1:] == [("assistant", "reply: hi")]:
break
assert app._conversation_messages == [
("user", "hi"),
("assistant", "reply: hi"),
]
def test_plan_step_status_updates_only_the_explicit_step() -> None:
app = _app_with_plan()

View File

@@ -3,6 +3,7 @@ from __future__ import annotations
from pathlib import Path
import subprocess
import click
import pytest
from click.testing import CliRunner
@@ -107,6 +108,8 @@ def test_configured_project_declarative_flow(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
monkeypatch.chdir(tmp_path)
definition_path = tmp_path / "flow.yaml"
definition_path.write_text(FLOW_YAML, encoding="utf-8")
(tmp_path / "pyproject.toml").write_text(
'[tool.crewai]\ntype = "flow"\ndefinition = " flow.yaml "\n',
encoding="utf-8",
@@ -114,4 +117,132 @@ def test_configured_project_declarative_flow(
from crewai_cli.run_declarative_flow import configured_project_declarative_flow
assert configured_project_declarative_flow() == "flow.yaml"
assert configured_project_declarative_flow() == definition_path.resolve()
@pytest.mark.parametrize(
("definition", "expected_error"),
[
("C:/tmp/flow.yaml", "must be relative to the project root"),
("~/flow.yaml", "must be a project-local path"),
("../flow.yaml", "must resolve inside the project root"),
],
)
def test_configured_project_declarative_flow_rejects_unsafe_paths(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
definition: str,
expected_error: str,
) -> None:
monkeypatch.chdir(tmp_path)
(tmp_path / "pyproject.toml").write_text(
f'[tool.crewai]\ntype = "flow"\ndefinition = "{definition}"\n',
encoding="utf-8",
)
from crewai_cli.run_declarative_flow import configured_project_declarative_flow
with pytest.raises(click.UsageError) as exc_info:
configured_project_declarative_flow()
assert expected_error in exc_info.value.message
def test_configured_project_declarative_flow_allows_normalized_project_path(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
monkeypatch.chdir(tmp_path)
definition_path = tmp_path / "flow.yaml"
definition_path.write_text(FLOW_YAML, encoding="utf-8")
(tmp_path / "src").mkdir()
(tmp_path / "pyproject.toml").write_text(
'[tool.crewai]\ntype = "flow"\ndefinition = "src/../flow.yaml"\n',
encoding="utf-8",
)
from crewai_cli.run_declarative_flow import configured_project_declarative_flow
assert configured_project_declarative_flow() == definition_path.resolve()
def test_configured_project_declarative_flow_rejects_absolute_path(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
monkeypatch.chdir(tmp_path)
definition = tmp_path / "flow.yaml"
(tmp_path / "pyproject.toml").write_text(
f'[tool.crewai]\ntype = "flow"\ndefinition = "{definition.as_posix()}"\n',
encoding="utf-8",
)
from crewai_cli.run_declarative_flow import configured_project_declarative_flow
with pytest.raises(click.UsageError) as exc_info:
configured_project_declarative_flow()
assert "must be relative to the project root" in exc_info.value.message
def test_configured_project_declarative_flow_rejects_symlink_escape(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
monkeypatch.chdir(tmp_path)
outside_definition = tmp_path.parent / "outside-flow.yaml"
outside_definition.write_text(FLOW_YAML, encoding="utf-8")
link = tmp_path / "flow.yaml"
try:
link.symlink_to(outside_definition)
except (NotImplementedError, OSError) as exc:
pytest.skip(f"symlinks unavailable: {exc}")
(tmp_path / "pyproject.toml").write_text(
'[tool.crewai]\ntype = "flow"\ndefinition = "flow.yaml"\n',
encoding="utf-8",
)
from crewai_cli.run_declarative_flow import configured_project_declarative_flow
with pytest.raises(click.UsageError) as exc_info:
configured_project_declarative_flow()
assert "must resolve inside the project root" in exc_info.value.message
def test_configured_project_declarative_flow_rejects_missing_file(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
monkeypatch.chdir(tmp_path)
(tmp_path / "pyproject.toml").write_text(
'[tool.crewai]\ntype = "flow"\ndefinition = "missing-flow.yaml"\n',
encoding="utf-8",
)
from crewai_cli.run_declarative_flow import configured_project_declarative_flow
with pytest.raises(click.UsageError) as exc_info:
configured_project_declarative_flow()
assert "must point to an existing file" in exc_info.value.message
def test_configured_project_declarative_flow_rejects_directory(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
monkeypatch.chdir(tmp_path)
(tmp_path / "flow.yaml").mkdir()
(tmp_path / "pyproject.toml").write_text(
'[tool.crewai]\ntype = "flow"\ndefinition = "flow.yaml"\n',
encoding="utf-8",
)
from crewai_cli.run_declarative_flow import configured_project_declarative_flow
with pytest.raises(click.UsageError) as exc_info:
configured_project_declarative_flow()
assert "must point to a regular file" in exc_info.value.message

View File

@@ -0,0 +1,63 @@
from __future__ import annotations
import sys
from crewai_cli import kickoff_flow
def test_loads_conversational_flow_from_kickoff_script(tmp_path, monkeypatch) -> None:
package_dir = tmp_path / "src" / "demo_chat"
package_dir.mkdir(parents=True)
(package_dir / "__init__.py").write_text("")
(package_dir / "main.py").write_text(
"\n".join(
[
"from crewai.flow import Flow",
"",
"class DemoChatFlow(Flow):",
" conversational = True",
]
)
)
(tmp_path / "pyproject.toml").write_text(
"\n".join(
[
"[project]",
'name = "demo-chat"',
"[project.scripts]",
'kickoff = "demo_chat.main:kickoff"',
]
)
)
monkeypatch.chdir(tmp_path)
sys.modules.pop("demo_chat.main", None)
sys.modules.pop("demo_chat", None)
flow = kickoff_flow._load_conversational_flow_from_kickoff_script()
assert flow is not None
assert type(flow).__name__ == "DemoChatFlow"
assert flow.conversational is True
def test_kickoff_flow_falls_back_to_uv_when_no_conversational_flow(
monkeypatch,
) -> None:
calls: list[list[str]] = []
def fake_run(command, capture_output, text, check):
calls.append(command)
class Result:
stderr = ""
return Result()
monkeypatch.setattr(
kickoff_flow, "_load_conversational_flow_from_kickoff_script", lambda: None
)
monkeypatch.setattr(kickoff_flow.subprocess, "run", fake_run)
kickoff_flow.kickoff_flow()
assert calls == [["uv", "run", "kickoff"]]

View File

@@ -645,6 +645,10 @@ def test_run_crew_runs_python_flow_project(monkeypatch, capsys):
"_execute_uv_script",
lambda script_name, **kwargs: calls.append((script_name, kwargs)),
)
monkeypatch.setattr(
"crewai_cli.kickoff_flow._load_conversational_flow_from_kickoff_script",
lambda: None,
)
run_crew_module.run_crew()
@@ -652,6 +656,41 @@ def test_run_crew_runs_python_flow_project(monkeypatch, capsys):
assert calls == [("kickoff", {"entity_type": "flow"})]
def test_run_crew_runs_conversational_flow_tui(monkeypatch, capsys):
class Flow:
pass
flow = Flow()
calls = []
monkeypatch.setattr(run_crew_module, "_has_json_crew", lambda: False)
monkeypatch.setattr(
run_crew_module,
"read_toml",
lambda: {"tool": {"crewai": {"type": "flow"}}},
)
monkeypatch.setattr(
"crewai_cli.kickoff_flow._load_conversational_flow_from_kickoff_script",
lambda: flow,
)
monkeypatch.setattr(
"crewai_cli.kickoff_flow._run_conversational_flow_tui",
lambda loaded_flow: calls.append(loaded_flow),
)
monkeypatch.setattr(
run_crew_module,
"_execute_uv_script",
lambda *_args, **_kwargs: pytest.fail(
"conversational flows must use the TUI"
),
)
run_crew_module.run_crew()
assert capsys.readouterr().out == ""
assert calls == [flow]
def test_run_crew_rejects_filename_for_flow_project(monkeypatch):
monkeypatch.setattr(run_crew_module, "_has_json_crew", lambda: False)
monkeypatch.setattr(
@@ -666,9 +705,14 @@ def test_run_crew_rejects_filename_for_flow_project(monkeypatch):
assert "--filename can only be used when running crews" in exc_info.value.message
def test_run_crew_runs_configured_declarative_flow_project(monkeypatch, capsys):
def test_run_crew_runs_configured_declarative_flow_project(
monkeypatch, tmp_path: Path, capsys
):
calls = []
monkeypatch.chdir(tmp_path)
definition_path = tmp_path / "flow.yaml"
definition_path.write_text("schema: crewai.flow/v1\n", encoding="utf-8")
monkeypatch.setattr(run_crew_module, "_has_json_crew", lambda: False)
monkeypatch.setattr(
run_crew_module,
@@ -695,4 +739,4 @@ def test_run_crew_runs_configured_declarative_flow_project(monkeypatch, capsys):
run_crew_module.run_crew()
assert capsys.readouterr().out == ""
assert calls == [("flow.yaml", None)]
assert calls == [(definition_path.resolve(), None)]

View File

@@ -60,6 +60,43 @@ def test_run_declarative_flow_reports_missing_file(
)
def test_run_declarative_flow_reports_empty_file(
tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
definition_path = tmp_path / "flow.yaml"
definition_path.write_text(" \n", encoding="utf-8")
with pytest.raises(SystemExit):
run_declarative_flow_module.run_declarative_flow(str(definition_path))
assert "Flow declaration file is empty" in capsys.readouterr().err
@pytest.mark.parametrize(
"contents, expected_error",
[
("[]\n", "Flow declaration must contain a mapping"),
("schema: crewai.flow/v1\nmethods: {}\n", "Field required"),
],
)
def test_load_declarative_flow_reports_invalid_declarations(
tmp_path: Path,
capsys: pytest.CaptureFixture[str],
contents: str,
expected_error: str,
) -> None:
definition_path = tmp_path / "flow.yaml"
definition_path.write_text(contents, encoding="utf-8")
with pytest.raises(SystemExit) as exc_info:
run_declarative_flow_module.load_declarative_flow(str(definition_path))
assert exc_info.value.code == 1
stderr = capsys.readouterr().err
assert f"Unable to read --definition path {definition_path}:" in stderr
assert expected_error in stderr
def test_run_declarative_flow_in_project_env_uses_uv(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:

View File

@@ -1,6 +1,9 @@
"""Tests for TokenManager with atomic file operations."""
import json
import os
import stat
import sys
import tempfile
import unittest
from datetime import datetime, timedelta
@@ -285,5 +288,50 @@ class TestAtomicFileOperations(unittest.TestCase):
tm._delete_secure_file("nonexistent.txt")
class TestSecureStoragePathPermissions(unittest.TestCase):
"""Test that the credential directory is created with restrictive permissions."""
@unittest.skipIf(sys.platform == "win32", "POSIX permission semantics")
def test_storage_path_is_owner_only(self) -> None:
"""The credential directory must be mode 0o700 even under a permissive umask."""
with tempfile.TemporaryDirectory() as base:
old_umask = os.umask(0o022)
try:
with (
patch("crewai_core.token_manager.sys.platform", "linux"),
patch(
"crewai_core.token_manager.os.path.expanduser",
return_value=base,
),
):
storage_path = TokenManager._get_secure_storage_path()
finally:
os.umask(old_umask)
self.assertTrue(storage_path.is_dir())
mode = stat.S_IMODE(storage_path.stat().st_mode)
self.assertEqual(mode, 0o700, f"expected 0o700, got {oct(mode)}")
@unittest.skipIf(sys.platform == "win32", "POSIX permission semantics")
def test_existing_loose_dir_is_tightened(self) -> None:
"""A pre-existing world-traversable directory is corrected to 0o700."""
with tempfile.TemporaryDirectory() as base:
loose = Path(base) / "crewai" / "credentials"
loose.mkdir(parents=True)
loose.chmod(0o755)
with (
patch("crewai_core.token_manager.sys.platform", "linux"),
patch(
"crewai_core.token_manager.os.path.expanduser",
return_value=base,
),
):
storage_path = TokenManager._get_secure_storage_path()
mode = stat.S_IMODE(storage_path.stat().st_mode)
self.assertEqual(mode, 0o700, f"expected 0o700, got {oct(mode)}")
if __name__ == "__main__":
unittest.main()

View File

@@ -16,9 +16,9 @@ dependencies = [
"pyjwt>=2.13.0,<3",
"pydantic>=2.11.9,<2.13",
"rich>=13.7.1",
"opentelemetry-api~=1.34.0",
"opentelemetry-sdk~=1.34.0",
"opentelemetry-exporter-otlp-proto-http~=1.34.0",
"opentelemetry-api~=1.42.0",
"opentelemetry-sdk~=1.42.0",
"opentelemetry-exporter-otlp-proto-http~=1.42.0",
"tomli~=2.0.2",
]

View File

@@ -1 +1 @@
__version__ = "1.14.8a2"
__version__ = "1.14.8a5"

View File

@@ -4,6 +4,7 @@ from __future__ import annotations
import json
from logging import getLogger
import os
from pathlib import Path
import tempfile
from typing import Any
@@ -25,6 +26,53 @@ logger = getLogger(__name__)
DEFAULT_CONFIG_PATH = Path.home() / ".config" / "crewai" / "settings.json"
def _ensure_dir_mode(directory: Path) -> None:
"""Tighten a dedicated config directory to 0o700.
Skips directories shared with other users or content (the system temp dir
and the current working directory), which are used as best-effort fallbacks
by :func:`get_writable_config_path` and must not be globally chmod'd. Secret
files written there are still protected by their own 0o600 mode.
"""
try:
shared = {Path(tempfile.gettempdir()).resolve(), Path.cwd().resolve()}
if directory.resolve() in shared:
return
directory.chmod(0o700)
except OSError as e:
logger.debug(
"Could not enforce 0o700 on config directory %s (best-effort): %s",
directory,
e,
)
def _write_secure_json(path: Path, data: dict[str, Any]) -> None:
"""Atomically write ``data`` as JSON to ``path`` with owner-only (0o600) mode."""
fd, tmp = tempfile.mkstemp(dir=path.parent, prefix=f".{path.name}.")
fd_open = True
try:
with os.fdopen(fd, "w") as f:
fd_open = False
json.dump(data, f, indent=4)
os.chmod(tmp, 0o600)
os.replace(tmp, path)
except BaseException:
if fd_open:
try:
os.close(fd)
except OSError as close_error:
logger.debug(
"Could not close temporary settings file descriptor for %s "
"(best-effort cleanup): %s",
tmp,
close_error,
)
if os.path.exists(tmp):
os.unlink(tmp)
raise
def get_writable_config_path() -> Path | None:
"""Find a writable location for the config file with fallback options.
@@ -43,6 +91,7 @@ def get_writable_config_path() -> Path | None:
for config_path in fallback_paths:
try:
config_path.parent.mkdir(parents=True, exist_ok=True)
_ensure_dir_mode(config_path.parent)
test_file = config_path.parent / ".crewai_write_test"
try:
test_file.write_text("test")
@@ -153,6 +202,7 @@ class Settings(BaseModel):
try:
config_path.parent.mkdir(parents=True, exist_ok=True)
_ensure_dir_mode(config_path.parent)
except Exception:
merged_data = {**data}
super().__init__(config_path=Path("/dev/null"), **merged_data)
@@ -194,8 +244,7 @@ class Settings(BaseModel):
existing_data = {}
updated_data = {**existing_data, **self.model_dump(exclude_unset=True)}
with self.config_path.open("w") as f:
json.dump(updated_data, f, indent=4)
_write_secure_json(self.config_path, updated_data)
except Exception: # noqa: S110
pass

View File

@@ -95,6 +95,14 @@ class TokenManager:
storage_path = Path(base_path) / app_name
storage_path.mkdir(parents=True, exist_ok=True)
# Enforce the documented 0o700 mode: mkdir is subject to umask and does
# not adjust the mode of a pre-existing directory, so chmod explicitly.
try:
storage_path.chmod(0o700)
except OSError:
# Best-effort permission hardening only: some platforms/filesystems
# may reject chmod here, and token operations should still proceed.
pass
return storage_path

View File

@@ -152,4 +152,4 @@ __all__ = [
"wrap_file_source",
]
__version__ = "1.14.8a2"
__version__ = "1.14.8a5"

View File

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

View File

@@ -330,4 +330,4 @@ __all__ = [
"ZapierActionTools",
]
__version__ = "1.14.8a2"
__version__ = "1.14.8a5"

View File

@@ -8,8 +8,8 @@ authors = [
]
requires-python = ">=3.10, <3.14"
dependencies = [
"crewai-core==1.14.8a2",
"crewai-cli==1.14.8a2",
"crewai-core==1.14.8a5",
"crewai-cli==1.14.8a5",
# Core Dependencies
"pydantic>=2.11.9,<2.13",
"openai>=2.30.0,<3",
@@ -18,9 +18,9 @@ dependencies = [
"pdfplumber~=0.11.4",
"regex~=2026.1.15",
# Telemetry and Monitoring
"opentelemetry-api~=1.34.0",
"opentelemetry-sdk~=1.34.0",
"opentelemetry-exporter-otlp-proto-http~=1.34.0",
"opentelemetry-api~=1.42.0",
"opentelemetry-sdk~=1.42.0",
"opentelemetry-exporter-otlp-proto-http~=1.42.0",
# Data Handling
"chromadb~=1.1.0",
"tokenizers>=0.21,<1",
@@ -55,7 +55,7 @@ Repository = "https://github.com/crewAIInc/crewAI"
[project.optional-dependencies]
tools = [
"crewai-tools==1.14.8a2",
"crewai-tools==1.14.8a5",
]
embeddings = [
"tiktoken>=0.8.0,<0.13"

View File

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

View File

@@ -54,7 +54,7 @@ from crewai.events.types.tool_usage_events import (
ToolUsageFinishedEvent,
ToolUsageStartedEvent,
)
from crewai.flow.flow import Flow, StateProxy, listen, or_, router, start
from crewai.flow.flow import Flow, listen, or_, router, start
from crewai.flow.types import FlowMethodName
from crewai.hooks.llm_hooks import (
get_after_llm_call_hooks,
@@ -276,11 +276,6 @@ class AgentExecutor(Flow[AgentExecutorState], BaseAgentExecutor):
"""
return self.llm.supports_stop_words() if self.llm else False
@property
def state(self) -> AgentExecutorState:
"""Get thread-safe state proxy."""
return StateProxy(self._state, self._state_lock) # type: ignore[return-value]
@property # type: ignore[misc]
def iterations(self) -> int:
"""Compatibility property for mixin - returns state iterations."""

View File

@@ -9,6 +9,7 @@ from __future__ import annotations
from datetime import datetime, timezone
import json
import logging
import os
from pathlib import Path
import tarfile
from typing import TypedDict
@@ -127,12 +128,36 @@ class SkillCacheManager:
def _safe_extractall(tf: tarfile.TarFile, dest: Path) -> None:
"""Path-traversal-safe extraction for Python < 3.12."""
"""Path-traversal-safe extraction for Python versions without tar filters.
Validates both the member's own path and, for symlink/hardlink members,
the link target. Without the link-target check a malicious archive can
plant a symlink that escapes ``dest`` followed by a regular member written
through that link, escaping ``dest`` even though every member name resolves
inside it. This mirrors the protection that
``tarfile.extractall(..., filter="data")`` provides when available.
"""
dest_resolved = dest.resolve()
for member in tf.getmembers():
member_path = (dest / member.name).resolve()
if not member_path.is_relative_to(dest_resolved):
raise ValueError(f"Blocked path traversal attempt: {member.name!r}")
if not (member.isfile() or member.isdir() or member.issym() or member.islnk()):
raise ValueError(f"Blocked unsupported tar member: {member.name!r}")
if member.issym() or member.islnk():
link_target = member.linkname
if os.path.isabs(link_target):
raise ValueError(
f"Blocked link target escaping destination: "
f"{member.name!r} -> {link_target!r}"
)
anchor = dest if member.islnk() else (dest / member.name).parent
resolved_target = (anchor / link_target).resolve()
if not resolved_target.is_relative_to(dest_resolved):
raise ValueError(
f"Blocked link target escaping destination: "
f"{member.name!r} -> {link_target!r}"
)
tf.extractall(dest) # noqa: S202

View File

@@ -24,9 +24,6 @@ from crewai.flow.runtime import (
Flow as RuntimeFlow,
FlowMeta,
FlowState,
LockedDictProxy,
LockedListProxy,
StateProxy,
)
@@ -42,9 +39,6 @@ __all__ = [
"Flow",
"FlowMeta",
"FlowState",
"LockedDictProxy",
"LockedListProxy",
"StateProxy",
"and_",
"listen",
"or_",

View File

@@ -1,7 +1,7 @@
"""Flow Definition: the serializable, declarative Flow contract.
Defines :class:`FlowDefinition` and its sub-models — a static, declarative
(JSON/YAML) representation of a Flow: its methods, trigger conditions,
Defines :class:`FlowDefinition` and its sub-models — a static declarative
representation of a Flow: its methods, trigger conditions,
state, and configuration. It is independent of the Python authoring
layer that may have produced it and of the engine that runs it (see
``runtime``).
@@ -235,7 +235,7 @@ class FlowPersistenceDefinition(BaseModel):
``persistence`` may hold a live backend when the definition is built from
a decorated class — the engine then persists through the exact instance
the user configured; the JSON/YAML projection degrades it to its
the user configured; the declarative projection degrades it to its
serialized config.
"""
@@ -275,7 +275,7 @@ class FlowHumanFeedbackDefinition(BaseModel):
"""Static human feedback configuration.
``llm`` and ``provider`` may hold live Python objects when the definition
is built from a decorated class; the JSON/YAML projection degrades them to
is built from a decorated class; the declarative projection degrades them to
a serialized config (``llm``) or a ``module:qualname`` ref (``provider``).
"""
@@ -777,7 +777,7 @@ class FlowDefinition(BaseModel):
return self
def to_dict(self, *, exclude_none: bool = True) -> dict[str, Any]:
"""Serialize the definition to a JSON/YAML-ready dictionary."""
"""Serialize the definition to a declaration-ready dictionary."""
return self.model_dump(by_alias=True, exclude_none=exclude_none, mode="json")
def to_json(self, *, indent: int | None = 2, exclude_none: bool = True) -> str:
@@ -817,16 +817,37 @@ class FlowDefinition(BaseModel):
return definition
@classmethod
def from_json(cls, data: str, *, source_path: Path | None = None) -> FlowDefinition:
"""Load a definition from JSON."""
return cls.from_dict(json.loads(data), source_path=source_path)
def from_declaration(
cls,
*,
contents: FlowDefinition | str | dict[str, Any] | None = None,
path: Path | str | None = None,
) -> FlowDefinition:
"""Load a declarative flow from contents or a file path."""
if isinstance(contents, cls):
return contents
@classmethod
def from_yaml(cls, data: str, *, source_path: Path | None = None) -> FlowDefinition:
"""Load a definition from YAML."""
loaded = yaml.safe_load(data) or {}
source_path: Path | None = None
if contents is None:
if path is None:
raise ValueError("Provide contents or path")
source_path = Path(path)
contents = source_path.expanduser().read_text(encoding="utf-8")
if isinstance(contents, dict):
return cls.from_dict(contents)
if not isinstance(contents, str):
raise TypeError("Flow declaration contents must be a string or dictionary")
if not contents.strip():
if source_path is not None:
raise ValueError(f"Flow declaration file is empty: {source_path}")
raise ValueError("Flow declaration contents are empty")
loaded = yaml.safe_load(contents)
if not isinstance(loaded, dict):
raise ValueError("Flow definition YAML must contain a mapping")
raise ValueError("Flow declaration must contain a mapping")
return cls.from_dict(loaded, source_path=source_path)
@classmethod

View File

@@ -1,8 +1,8 @@
"""Flow Runtime: the engine that executes a Flow.
Provides the ``Flow`` class (kickoff/resume/listener dispatch), the
``FlowMeta`` metaclass, and the thread-safe state proxies. Flows
authored with the Python DSL (see ``dsl``) are described by a Flow
Provides the ``Flow`` class (kickoff/resume/listener dispatch) and the
``FlowMeta`` metaclass. Flows authored with the Python DSL (see ``dsl``)
are described by a Flow
Structure (see ``flow_definition``) and executed here.
"""
@@ -11,12 +11,8 @@ from __future__ import annotations
import asyncio
from collections.abc import (
Callable,
ItemsView,
Iterable,
Iterator,
KeysView,
Sequence,
ValuesView,
)
from concurrent.futures import Future, ThreadPoolExecutor
import contextvars
@@ -25,6 +21,7 @@ from datetime import datetime
import enum
import inspect
import logging
from pathlib import Path
import threading
from typing import (
TYPE_CHECKING,
@@ -34,10 +31,8 @@ from typing import (
Generic,
Literal,
ParamSpec,
SupportsIndex,
TypeVar,
cast,
overload,
)
from uuid import uuid4
@@ -122,7 +117,6 @@ 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._refs import resolve_instance_ref, resolve_ref
from crewai.flow.types import (
FlowExecutionData,
FlowMethodName,
@@ -136,6 +130,7 @@ from crewai.state.checkpoint_config import (
_coerce_checkpoint,
apply_checkpoint,
)
from crewai.utilities.declarative_refs import InvalidRefError, resolve_ref
if TYPE_CHECKING:
@@ -226,7 +221,12 @@ def _build_definition_state_model(
pass
model_class = StateWithId
return model_class(**kwargs)
try:
return model_class(**kwargs)
except ValidationError as e:
if any(error.get("type") != "missing" for error in e.errors()):
raise
return model_class.model_construct(**kwargs)
def _iter_condition_events(condition: FlowDefinitionCondition) -> Iterator[str]:
@@ -283,6 +283,18 @@ def _resolve_persistence(value: Any) -> Any:
return value
def _resolve_instance_ref(ref: str, *, field: str) -> Any:
target = resolve_ref(ref, field=field)
if not inspect.isclass(target):
return target
try:
return target()
except Exception as e:
raise InvalidRefError(
f"cannot instantiate {field} ref {ref!r} without arguments: {e}"
) from e
def _serialize_persistence(value: Any) -> dict[str, Any] | None:
if value is None:
return None
@@ -298,7 +310,7 @@ def _validate_input_provider(value: Any) -> Any:
if value is None or isinstance(value, InputProvider):
return value
if isinstance(value, str) and ":" in value:
resolved = resolve_instance_ref(value, field="input_provider")
resolved = _resolve_instance_ref(value, field="input_provider")
else:
from crewai.types.callback import _dotted_path_to_instance
@@ -365,304 +377,6 @@ R = TypeVar("R")
F = TypeVar("F", bound=Callable[..., Any])
class LockedListProxy(list, Generic[T]): # type: ignore[type-arg]
"""Thread-safe proxy for list operations.
Subclasses ``list`` so that ``isinstance(proxy, list)`` returns True,
which is required by libraries like LanceDB and Pydantic that do strict
type checks. All mutations go through the lock; reads delegate to the
underlying list.
"""
def __init__(self, lst: list[T], lock: threading.Lock) -> None:
super().__init__() # empty builtin list; all access goes through self._list
self._list = lst
self._lock = lock
def append(self, item: T) -> None:
with self._lock:
self._list.append(item)
def extend(self, items: Iterable[T]) -> None:
with self._lock:
self._list.extend(items)
def insert(self, index: SupportsIndex, item: T) -> None:
with self._lock:
self._list.insert(index, item)
def remove(self, item: T) -> None:
with self._lock:
self._list.remove(item)
def pop(self, index: SupportsIndex = -1) -> T:
with self._lock:
return self._list.pop(index)
def clear(self) -> None:
with self._lock:
self._list.clear()
@overload
def __setitem__(self, index: SupportsIndex, value: T) -> None: ...
@overload
def __setitem__(self, index: slice, value: Iterable[T]) -> None: ...
def __setitem__(self, index: Any, value: Any) -> None:
with self._lock:
self._list[index] = value
def __delitem__(self, index: SupportsIndex | slice) -> None:
with self._lock:
del self._list[index]
@overload
def __getitem__(self, index: SupportsIndex) -> T: ...
@overload
def __getitem__(self, index: slice) -> list[T]: ...
def __getitem__(self, index: Any) -> Any:
return self._list[index]
def __len__(self) -> int:
return len(self._list)
def __iter__(self) -> Iterator[T]:
return iter(self._list)
def __contains__(self, item: object) -> bool:
return item in self._list
def __repr__(self) -> str:
return repr(self._list)
def __bool__(self) -> bool:
return bool(self._list)
def index(
self, value: T, start: SupportsIndex = 0, stop: SupportsIndex | None = None
) -> int:
if stop is None:
return self._list.index(value, start)
return self._list.index(value, start, stop)
def count(self, value: T) -> int:
return self._list.count(value)
def sort(self, *, key: Any = None, reverse: bool = False) -> None:
with self._lock:
self._list.sort(key=key, reverse=reverse)
def reverse(self) -> None:
with self._lock:
self._list.reverse()
def copy(self) -> list[T]:
return self._list.copy()
def __add__(self, other: list[T]) -> list[T]: # type: ignore[override]
return self._list + other
def __radd__(self, other: list[T]) -> list[T]:
return other + self._list
def __iadd__(self, other: Iterable[T]) -> LockedListProxy[T]: # type: ignore[override]
with self._lock:
self._list += list(other)
return self
def __mul__(self, n: SupportsIndex) -> list[T]:
return self._list * n
def __rmul__(self, n: SupportsIndex) -> list[T]:
return self._list * n
def __imul__(self, n: SupportsIndex) -> LockedListProxy[T]:
with self._lock:
self._list *= n
return self
def __reversed__(self) -> Iterator[T]:
return reversed(self._list)
def __eq__(self, other: object) -> bool:
"""Compare based on the underlying list contents."""
if isinstance(other, LockedListProxy):
# Avoid deadlocks by acquiring locks in a consistent order.
first, second = (self, other) if id(self) <= id(other) else (other, self)
with first._lock:
with second._lock:
return first._list == second._list
with self._lock:
return self._list == other
def __ne__(self, other: object) -> bool:
return not self.__eq__(other)
class LockedDictProxy(dict, Generic[T]): # type: ignore[type-arg]
"""Thread-safe proxy for dict operations.
Subclasses ``dict`` so that ``isinstance(proxy, dict)`` returns True,
which is required by libraries like Pydantic that do strict type checks.
All mutations go through the lock; reads delegate to the underlying dict.
"""
def __init__(self, d: dict[str, T], lock: threading.Lock) -> None:
super().__init__() # empty builtin dict; all access goes through self._dict
self._dict = d
self._lock = lock
def __setitem__(self, key: str, value: T) -> None:
with self._lock:
self._dict[key] = value
def __delitem__(self, key: str) -> None:
with self._lock:
del self._dict[key]
def pop(self, key: str, *default: T) -> T: # type: ignore[override]
with self._lock:
return self._dict.pop(key, *default)
def update(self, other: dict[str, T]) -> None: # type: ignore[override]
with self._lock:
self._dict.update(other)
def clear(self) -> None:
with self._lock:
self._dict.clear()
def setdefault(self, key: str, default: T) -> T: # type: ignore[override]
with self._lock:
return self._dict.setdefault(key, default)
def __getitem__(self, key: str) -> T:
return self._dict[key]
def __len__(self) -> int:
return len(self._dict)
def __iter__(self) -> Iterator[str]:
return iter(self._dict)
def __contains__(self, key: object) -> bool:
return key in self._dict
def keys(self) -> KeysView[str]: # type: ignore[override]
return self._dict.keys()
def values(self) -> ValuesView[T]: # type: ignore[override]
return self._dict.values()
def items(self) -> ItemsView[str, T]: # type: ignore[override]
return self._dict.items()
def get(self, key: str, default: T | None = None) -> T | None: # type: ignore[override]
return self._dict.get(key, default)
def __repr__(self) -> str:
return repr(self._dict)
def __bool__(self) -> bool:
return bool(self._dict)
def copy(self) -> dict[str, T]:
return self._dict.copy()
def __or__(self, other: dict[str, T]) -> dict[str, T]: # type: ignore[override]
return self._dict | other
def __ror__(self, other: dict[str, T]) -> dict[str, T]: # type: ignore[override]
return other | self._dict
def __ior__(self, other: dict[str, T]) -> LockedDictProxy[T]: # type: ignore[override]
with self._lock:
self._dict |= other
return self
def __reversed__(self) -> Iterator[str]:
return reversed(self._dict)
def __eq__(self, other: object) -> bool:
"""Compare based on the underlying dict contents."""
if isinstance(other, LockedDictProxy):
# Avoid deadlocks by acquiring locks in a consistent order.
first, second = (self, other) if id(self) <= id(other) else (other, self)
with first._lock:
with second._lock:
return first._dict == second._dict
with self._lock:
return self._dict == other
def __ne__(self, other: object) -> bool:
return not self.__eq__(other)
class StateProxy(Generic[T]):
"""Proxy that provides thread-safe access to flow state.
Wraps state objects (dict or BaseModel) and uses a lock for all write
operations to prevent race conditions when parallel listeners modify state.
"""
__slots__ = ("_proxy_lock", "_proxy_state")
def __init__(self, state: T, lock: threading.Lock) -> None:
object.__setattr__(self, "_proxy_state", state)
object.__setattr__(self, "_proxy_lock", lock)
def __getattr__(self, name: str) -> Any:
value = getattr(object.__getattribute__(self, "_proxy_state"), name)
lock = object.__getattribute__(self, "_proxy_lock")
if isinstance(value, list):
return LockedListProxy(value, lock)
if isinstance(value, dict):
return LockedDictProxy(value, lock)
return value
def __setattr__(self, name: str, value: Any) -> None:
if name in ("_proxy_state", "_proxy_lock"):
object.__setattr__(self, name, value)
else:
if isinstance(value, LockedListProxy):
value = value._list
elif isinstance(value, LockedDictProxy):
value = value._dict
with object.__getattribute__(self, "_proxy_lock"):
setattr(object.__getattribute__(self, "_proxy_state"), name, value)
def __getitem__(self, key: str) -> Any:
return object.__getattribute__(self, "_proxy_state")[key]
def __setitem__(self, key: str, value: Any) -> None:
with object.__getattribute__(self, "_proxy_lock"):
object.__getattribute__(self, "_proxy_state")[key] = value
def __delitem__(self, key: str) -> None:
with object.__getattribute__(self, "_proxy_lock"):
del object.__getattribute__(self, "_proxy_state")[key]
def __contains__(self, key: str) -> bool:
return key in object.__getattribute__(self, "_proxy_state")
def __repr__(self) -> str:
return repr(object.__getattribute__(self, "_proxy_state"))
def _unwrap(self) -> T:
"""Return the underlying state object."""
return cast(T, object.__getattribute__(self, "_proxy_state"))
def model_dump(self, *args: Any, **kwargs: Any) -> dict[str, Any]:
"""Return state as a dictionary.
Works for both dict and BaseModel underlying states.
"""
state = object.__getattribute__(self, "_proxy_state")
if isinstance(state, dict):
return state
result: dict[str, Any] = state.model_dump(*args, **kwargs)
return result
class FlowMeta(ModelMetaclass):
def __new__(
mcs,
@@ -769,6 +483,21 @@ class Flow(BaseModel, Generic[T], metaclass=FlowMeta):
@classmethod
def from_definition(cls, definition: FlowDefinition, **kwargs: Any) -> Flow[Any]:
"""Build a runnable Flow directly from a definition; no subclass required."""
return cls.from_declaration(contents=definition, **kwargs)
@classmethod
def from_declaration(
cls,
*,
contents: FlowDefinition | str | dict[str, Any] | None = None,
path: Path | str | None = None,
**kwargs: Any,
) -> Flow[Any]:
"""Build a runnable declarative flow from contents or a file path."""
definition = FlowDefinition.from_declaration(
contents=contents,
path=path,
)
return cls.model_validate(
{**definition.config.model_dump(), **kwargs},
context={"flow_definition": definition},
@@ -992,7 +721,6 @@ class Flow(BaseModel, Generic[T], metaclass=FlowMeta):
)
_method_outputs: list[Any] = PrivateAttr(default_factory=list)
_definition: FlowDefinition = PrivateAttr()
_state_lock: threading.Lock = PrivateAttr(default_factory=threading.Lock)
_or_listeners_lock: threading.Lock = PrivateAttr(default_factory=threading.Lock)
_completed_methods: set[FlowMethodName] = PrivateAttr(default_factory=set)
_method_call_counts: dict[FlowMethodName, int] = PrivateAttr(default_factory=dict)
@@ -1914,7 +1642,7 @@ class Flow(BaseModel, Generic[T], metaclass=FlowMeta):
@property
def state(self) -> T:
return StateProxy(self._state, self._state_lock) # type: ignore[return-value]
return cast(T, self._state)
@property
def method_outputs(self) -> list[Any]:
@@ -3584,7 +3312,7 @@ class Flow(BaseModel, Generic[T], metaclass=FlowMeta):
) -> Any:
provider = feedback_definition.provider
if isinstance(provider, str):
provider = resolve_instance_ref(provider, field="human_feedback.provider")
provider = _resolve_instance_ref(provider, field="human_feedback.provider")
if provider is None:
from crewai.flow.flow_config import flow_config

View File

@@ -24,7 +24,11 @@ from crewai.flow.flow_definition import (
FlowToolActionDefinition,
)
from crewai.flow.runtime._outputs import outputs_by_name
from crewai.flow.runtime._refs import InvalidRefError, resolve_ref
from crewai.utilities.declarative_refs import (
InvalidRefError,
resolve_class_ref,
resolve_ref,
)
if TYPE_CHECKING:
@@ -103,16 +107,17 @@ class ToolAction:
)
def _build_tool(self) -> Any:
target = resolve_ref(self.definition.ref, field="do")
from crewai.tools import BaseTool
if not (inspect.isclass(target) and issubclass(target, BaseTool)):
raise InvalidRefError(
f"invalid tool ref {self.definition.ref!r}; expected a BaseTool class"
)
tool_cls = cast(
Callable[[], BaseTool],
resolve_class_ref(
self.definition.ref,
field="do",
base_class=BaseTool,
),
)
try:
tool_cls = cast(Callable[[], BaseTool], target)
return tool_cls()
except Exception as e:
raise InvalidRefError(

View File

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

View File

@@ -16,6 +16,8 @@ from urllib.parse import unquote, urlparse
from pydantic import BaseModel, ValidationError
from crewai.utilities.declarative_refs import InvalidRefError, resolve_class_ref
logger = logging.getLogger(__name__)
@@ -1820,6 +1822,9 @@ def _resolve_tools(tool_defs: list[Any], project_root: Path | None = None) -> li
if tool_def.startswith("custom:"):
tools.append(_resolve_custom_tool(tool_def[7:], project_root=project_root))
continue
if ":" in tool_def:
tools.append(_instantiate_tool_import_ref(tool_def))
continue
try:
tool_cls = _find_tool_class(tool_def)
except Exception as e:
@@ -1827,8 +1832,10 @@ def _resolve_tools(tool_defs: list[Any], project_root: Path | None = None) -> li
if tool_cls is None:
raise JSONProjectError(
f"Unknown tool '{tool_def}'. Tool names must match a class from "
f"the 'crewai_tools' package (e.g. 'SerperDevTool') or use the "
f"'custom:<name>' prefix for a tool defined in tools/<name>.py."
f"the 'crewai_tools' package (e.g. 'SerperDevTool'), use a "
f"'module:ClassName' import ref (e.g. 'crewai_tools:SerperDevTool'), "
f"or use the 'custom:<name>' prefix for a tool defined in "
f"tools/<name>.py."
)
try:
tools.append(tool_cls())
@@ -1839,6 +1846,32 @@ def _resolve_tools(tool_defs: list[Any], project_root: Path | None = None) -> li
return tools
def _instantiate_tool_import_ref(ref: str) -> Any:
from crewai.tools import BaseTool
try:
tool_cls = cast(
Callable[[], BaseTool],
resolve_class_ref(ref, field="tool", base_class=BaseTool),
)
except InvalidRefError as e:
message = str(e)
if (
message.startswith("unresolvable ")
or "expected 'module:qualname'" in message
):
raise JSONProjectError(str(e)) from e
raise JSONProjectError(
f"invalid tool ref {ref!r}; expected a BaseTool class"
) from e
try:
return tool_cls()
except Exception as e:
raise JSONProjectError(
f"cannot instantiate tool ref {ref!r} without arguments: {e}"
) from e
_tool_class_cache: dict[str, type | None] = {}

View File

@@ -0,0 +1,69 @@
"""Resolve Python refs used in project definitions.
A ref must use this form: ``module:qualname``. ``module`` must name a Python
module we can import. ``qualname`` must name something inside that module. For
example, ``crewai_tools:SerperDevTool`` imports ``crewai_tools`` and returns
``SerperDevTool`` from it. Dots in ``qualname`` mean nested attributes.
Examples:
- ``crewai_tools:SerperDevTool`` imports ``crewai_tools`` and returns
``SerperDevTool``.
- ``my_app.tools:Factory.build`` imports ``my_app.tools``, gets ``Factory``,
then gets ``build`` from ``Factory``.
- ``crewai_tools`` is invalid because it has no ``:``.
- ``crewai_tools:`` is invalid because it has no ``qualname``.
These helpers are the shared contract for YAML/JSON definitions:
- ``resolve_ref()`` checks the ref, imports the module, and returns the symbol
as-is.
- ``resolve_class_ref()`` does the same work, then checks that the symbol is a
class. It can also check that the class extends a base class. It does not
create an object.
These helpers import user code. Code that must avoid that should check the raw
string shape instead.
"""
from __future__ import annotations
import importlib
import inspect
from operator import attrgetter
from typing import Any
class InvalidRefError(ValueError):
"""A definition ref that cannot be resolved to a live Python symbol."""
def resolve_ref(ref: str, *, field: str) -> Any:
"""Return the Python symbol named by a project definition field."""
module_name, _, qualname = ref.partition(":")
if "<" in ref or not module_name or not qualname:
raise InvalidRefError(
f"invalid {field} ref {ref!r}; expected 'module:qualname'"
)
try:
return attrgetter(qualname)(importlib.import_module(module_name))
except (ImportError, AttributeError) as e:
raise InvalidRefError(f"unresolvable {field} ref {ref!r}") from e
def resolve_class_ref(
ref: str,
*,
field: str,
base_class: type[Any] | None = None,
) -> type[Any]:
"""Return the named class, with an optional base class check."""
target = resolve_ref(ref, field=field)
if not inspect.isclass(target):
raise InvalidRefError(f"invalid {field} ref {ref!r}; expected a class")
if base_class is not None and not issubclass(target, base_class):
raise InvalidRefError(
f"invalid {field} ref {ref!r}; expected a subclass of "
f"{base_class.__module__}.{base_class.__name__}"
)
return target

View File

@@ -7,6 +7,7 @@ flow methods, routing logic, and error handling.
from __future__ import annotations
import asyncio
import threading
from types import SimpleNamespace
import time
from typing import Any
@@ -39,8 +40,6 @@ def _build_executor(**kwargs: Any) -> AgentExecutor:
executor._human_feedback_method_outputs = {}
executor._input_history = []
executor._is_execution_resuming = False
import threading
executor._state_lock = threading.Lock()
executor._or_listeners_lock = threading.Lock()
executor._execution_lock = threading.Lock()
executor._finalize_lock = threading.Lock()

View File

@@ -8,7 +8,9 @@ import json
import tarfile
from pathlib import Path
from crewai.experimental.skills.cache import SkillCacheManager
import pytest
from crewai.experimental.skills.cache import SkillCacheManager, _safe_extractall
def _make_tar_gz(files: dict[str, str]) -> bytes:
@@ -35,6 +37,15 @@ def _make_tar_gz(files: dict[str, str]) -> bytes:
return out.getvalue()
def _tar_from_members(build) -> tarfile.TarFile:
"""Build an in-memory tar archive via `build(tf)` and return it for reading."""
buf = io.BytesIO()
with tarfile.open(fileobj=buf, mode="w") as tf:
build(tf)
buf.seek(0)
return tarfile.open(fileobj=buf, mode="r")
class TestSkillCacheManager:
def test_get_cached_path_missing(self, tmp_path: Path) -> None:
cache = SkillCacheManager(cache_root=tmp_path)
@@ -113,3 +124,85 @@ class TestSkillCacheManager:
dest = cache.store("acme", "my-skill", None, archive)
meta = json.loads((dest / ".crewai_meta.json").read_text())
assert meta["version"] is None
def test_safe_extractall_blocks_symlink_escaping_cache_destination(
tmp_path: Path,
) -> None:
"""A symlink whose target escapes dest is rejected before extraction."""
outside = tmp_path / "outside"
outside.mkdir()
dest = tmp_path / "dest"
dest.mkdir()
def build(tf: tarfile.TarFile) -> None:
link = tarfile.TarInfo("link")
link.type = tarfile.SYMTYPE
link.linkname = str(outside)
tf.addfile(link)
payload = b"pwned"
info = tarfile.TarInfo("link/evil.txt")
info.size = len(payload)
tf.addfile(info, io.BytesIO(payload))
with _tar_from_members(build) as tf:
with pytest.raises(ValueError, match="escaping destination"):
_safe_extractall(tf, dest)
assert not (outside / "evil.txt").exists()
def test_safe_extractall_blocks_hardlink_escaping_cache_destination(
tmp_path: Path,
) -> None:
"""A hardlink whose target escapes dest is rejected."""
dest = tmp_path / "dest"
dest.mkdir()
def build(tf: tarfile.TarFile) -> None:
link = tarfile.TarInfo("escape")
link.type = tarfile.LNKTYPE
link.linkname = "../outside.txt"
tf.addfile(link)
with _tar_from_members(build) as tf:
with pytest.raises(ValueError, match="escaping destination"):
_safe_extractall(tf, dest)
def test_safe_extractall_blocks_special_cache_tar_member(tmp_path: Path) -> None:
"""Special tar members such as FIFOs are rejected."""
dest = tmp_path / "dest"
dest.mkdir()
def build(tf: tarfile.TarFile) -> None:
fifo = tarfile.TarInfo("pipe")
fifo.type = tarfile.FIFOTYPE
tf.addfile(fifo)
with _tar_from_members(build) as tf:
with pytest.raises(ValueError, match="unsupported tar member"):
_safe_extractall(tf, dest)
def test_safe_extractall_allows_benign_cache_symlink(tmp_path: Path) -> None:
"""A symlink that stays within dest is permitted."""
dest = tmp_path / "dest"
dest.mkdir()
def build(tf: tarfile.TarFile) -> None:
payload = b"hi"
info = tarfile.TarInfo("real.txt")
info.size = len(payload)
tf.addfile(info, io.BytesIO(payload))
link = tarfile.TarInfo("alias.txt")
link.type = tarfile.SYMTYPE
link.linkname = "real.txt"
tf.addfile(link)
with _tar_from_members(build) as tf:
_safe_extractall(tf, dest)
assert (dest / "real.txt").read_bytes() == b"hi"
assert (dest / "alias.txt").is_symlink()
assert (dest / "alias.txt").readlink() == Path("real.txt")

View File

@@ -385,12 +385,52 @@ class TestLoadAgentFromDefinition:
class TestResolveTools:
def test_import_ref_tool_resolves(self, tmp_path, monkeypatch):
from crewai.project.json_loader import _resolve_tools
(tmp_path / "project_tools.py").write_text(
"from crewai.tools.base_tool import BaseTool\n"
"\n"
"class LookupTool(BaseTool):\n"
" name: str = 'lookup'\n"
" description: str = 'lookup input'\n"
"\n"
" def _run(self, text: str) -> str:\n"
" return text\n"
)
monkeypatch.syspath_prepend(str(tmp_path))
tools = _resolve_tools(["project_tools:LookupTool"])
assert len(tools) == 1
assert tools[0].name == "lookup"
def test_unknown_tool_raises_with_guidance(self):
from crewai.project.json_loader import JSONProjectError, _resolve_tools
with pytest.raises(JSONProjectError, match="Unknown tool 'NotARealToolXYZ'"):
_resolve_tools(["NotARealToolXYZ"])
def test_import_ref_tool_must_resolve_to_basetool_class(
self, tmp_path, monkeypatch
):
from crewai.project.json_loader import JSONProjectError, _resolve_tools
(tmp_path / "not_tools.py").write_text(
"class NotATool:\n"
" pass\n"
)
monkeypatch.syspath_prepend(str(tmp_path))
with pytest.raises(JSONProjectError, match="expected a BaseTool class"):
_resolve_tools(["not_tools:NotATool"])
def test_unresolvable_import_ref_tool_raises_guidance(self):
from crewai.project.json_loader import JSONProjectError, _resolve_tools
with pytest.raises(JSONProjectError, match="unresolvable tool ref"):
_resolve_tools(["not_a_real_module:MissingTool"])
def test_missing_custom_tool_raises(self, tmp_path, monkeypatch):
from crewai.project.json_loader import JSONProjectError, _resolve_tools
@@ -505,6 +545,30 @@ class TestValidationDoesNotExecuteTools:
assert not sentinel.exists(), "validation must not import Python refs"
def test_validate_does_not_import_tool_refs(
self, tmp_path, monkeypatch: pytest.MonkeyPatch
):
from crewai.project.json_loader import validate_crew_project
sentinel = tmp_path / "tool_ref_executed.txt"
(tmp_path / "project_tools.py").write_text(
"from pathlib import Path\n"
f"Path({str(sentinel)!r}).write_text('boom')\n"
"from crewai.tools.base_tool import BaseTool\n"
"class LookupTool(BaseTool):\n"
" name: str = 'lookup'\n"
" description: str = 'lookup input'\n"
" def _run(self, text: str) -> str:\n"
" return text\n"
)
monkeypatch.syspath_prepend(str(tmp_path))
sys.modules.pop("project_tools", None)
crew_path = self._write_project(tmp_path, tool_line='"project_tools:LookupTool"')
validate_crew_project(crew_path, tmp_path / "agents")
assert not sentinel.exists(), "validation must not import tool refs"
def test_validate_reports_missing_custom_tool_file(self, tmp_path):
from crewai.project.json_loader import (
JSONProjectValidationError,

View File

@@ -1510,42 +1510,36 @@ def test_conditional_router_events_exclusivity():
assert "handle_event_c" not in execution_order
def test_state_consistency_across_parallel_branches():
"""Test that state remains consistent when branches execute in parallel.
def test_and_join_waits_for_parallel_branches():
"""Test that sibling branches complete before a joined listener runs.
Note: Branches triggered by the same parent execute in parallel for efficiency.
Thread-safe state access via StateProxy ensures no race conditions.
We check the execution order to ensure the branches execute in parallel.
Branches triggered by the same parent execute in parallel for efficiency.
Shared state updates are not guaranteed to be atomic, so this test uses a
locked local recorder instead of branch state mutation.
"""
execution_order = []
execution_order_lock = threading.Lock()
def record(method_name: str) -> None:
with execution_order_lock:
execution_order.append(method_name)
class StateConsistencyFlow(Flow):
def __init__(self):
super().__init__()
self.state["counter"] = 0
self.state["branch_a_value"] = None
self.state["branch_b_value"] = None
@start()
def init(self):
execution_order.append("init")
self.state["counter"] = 10
record("init")
@listen(init)
def branch_a(self):
execution_order.append("branch_a")
self.state["branch_a_value"] = self.state["counter"]
self.state["counter"] += 1
record("branch_a")
@listen(init)
def branch_b(self):
execution_order.append("branch_b")
self.state["branch_b_value"] = self.state["counter"]
self.state["counter"] += 5
record("branch_b")
@listen(and_(branch_a, branch_b))
def verify_state(self):
execution_order.append("verify_state")
record("verify_state")
flow = StateConsistencyFlow()
flow.kickoff()
@@ -1554,10 +1548,8 @@ def test_state_consistency_across_parallel_branches():
assert "branch_b" in execution_order
assert "verify_state" in execution_order
assert flow.state["branch_a_value"] is not None
assert flow.state["branch_b_value"] is not None
assert flow.state["counter"] == 16
assert execution_order.index("branch_a") < execution_order.index("verify_state")
assert execution_order.index("branch_b") < execution_order.index("verify_state")
def test_deeply_nested_conditions():

View File

@@ -928,8 +928,6 @@ class TestConversationalFlow:
conversational = True
flow = BareChat()
# ``flow.state`` returns a ``StateProxy``; the underlying state is
# on ``flow._state``. Both views expose the same chat-shaped fields.
assert isinstance(flow._state, ConversationState)
assert flow.state.messages == []
assert flow.state.current_user_message is None

View File

@@ -613,7 +613,7 @@ def test_flow_definition_merges_stacked_listen_router():
assert methods["second_router"].emit == ["second_approval", "not_approved"]
def test_flow_definition_round_trips_json_and_yaml():
def test_flow_definition_round_trips_declaration_serialization():
class RoundTripFlow(Flow):
@start()
def begin(self):
@@ -629,16 +629,122 @@ def test_flow_definition_round_trips_json_and_yaml():
definition = RoundTripFlow.flow_definition()
json_round_trip = flow_definition.FlowDefinition.from_json(definition.to_json())
yaml_round_trip = flow_definition.FlowDefinition.from_yaml(definition.to_yaml())
round_trips = [
flow_definition.FlowDefinition.from_declaration(contents=definition.to_json()),
flow_definition.FlowDefinition.from_declaration(contents=definition.to_yaml()),
]
assert json_round_trip.to_dict() == definition.to_dict()
assert yaml_round_trip.to_dict() == definition.to_dict()
assert yaml_round_trip.methods["decide"].router is True
assert yaml_round_trip.methods["decide"].listen == "begin"
for round_trip in round_trips:
assert round_trip.to_dict() == definition.to_dict()
assert round_trip.methods["decide"].router is True
assert round_trip.methods["decide"].listen == "begin"
def test_each_action_round_trips_json_and_yaml():
def test_flow_definition_from_declaration_accepts_contents():
data = {
"schema": "crewai.flow/v1",
"name": "DeclarationFlow",
"methods": {
"begin": {
"start": True,
"do": {
"call": "expression",
"expr": "'started'",
},
},
},
}
definition = flow_definition.FlowDefinition.from_dict(data)
contents = [
definition,
data,
definition.to_json(),
definition.to_yaml(),
]
expected = definition.to_dict()
for content in contents:
loaded = flow_definition.FlowDefinition.from_declaration(contents=content)
assert loaded.to_dict() == expected
def test_flow_definition_from_declaration_rejects_empty_file(tmp_path: Path):
declaration_path = tmp_path / "flow.crewai"
declaration_path.write_text(" \n", encoding="utf-8")
with pytest.raises(ValueError, match="Flow declaration file is empty"):
flow_definition.FlowDefinition.from_declaration(path=declaration_path)
@pytest.mark.parametrize("contents", ["[]", "false", "0", "null", "~"])
def test_flow_definition_from_declaration_rejects_falsey_non_mapping_contents(
contents: str,
):
with pytest.raises(ValueError, match="Flow declaration must contain a mapping"):
flow_definition.FlowDefinition.from_declaration(contents=contents)
def test_flow_definition_from_declaration_accepts_paths(tmp_path: Path):
definition = flow_definition.FlowDefinition.from_dict(
{
"schema": "crewai.flow/v1",
"name": "DeclarationFlow",
"methods": {
"begin": {
"start": True,
"do": {
"call": "expression",
"expr": "'started'",
},
},
},
}
)
declaration_path = tmp_path / "flow.crewai"
declaration_path.write_text(definition.to_yaml(), encoding="utf-8")
path_inputs = [
declaration_path,
str(declaration_path),
]
for path_input in path_inputs:
loaded = flow_definition.FlowDefinition.from_declaration(path=path_input)
assert loaded.to_dict() == definition.to_dict()
assert loaded.source_path == declaration_path.resolve()
def test_flow_definition_from_declaration_requires_input():
with pytest.raises(ValueError, match="Provide contents or path"):
flow_definition.FlowDefinition.from_declaration()
def test_flow_definition_from_declaration_prefers_contents_over_path(
tmp_path: Path,
):
data = {
"schema": "crewai.flow/v1",
"name": "ContentsFlow",
"methods": {
"begin": {
"start": True,
"do": {"call": "expression", "expr": "'started'"},
},
},
}
declaration_path = tmp_path / "missing.crewai"
loaded = flow_definition.FlowDefinition.from_declaration(
contents=data,
path=declaration_path,
)
assert loaded.name == "ContentsFlow"
assert loaded.source_path is None
def test_each_action_round_trips_declaration_serialization():
definition = flow_definition.FlowDefinition.from_dict(
{
"schema": "crewai.flow/v1",
@@ -677,15 +783,17 @@ def test_each_action_round_trips_json_and_yaml():
}
)
json_round_trip = flow_definition.FlowDefinition.from_json(definition.to_json())
yaml_round_trip = flow_definition.FlowDefinition.from_yaml(definition.to_yaml())
round_trips = [
flow_definition.FlowDefinition.from_declaration(contents=definition.to_json()),
flow_definition.FlowDefinition.from_declaration(contents=definition.to_yaml()),
]
assert json_round_trip.to_dict() == definition.to_dict()
assert yaml_round_trip.to_dict() == definition.to_dict()
assert yaml_round_trip.methods["process_rows"].description == (
"Process every loaded row."
)
assert yaml_round_trip.methods["process_rows"].do.call == "each"
for round_trip in round_trips:
assert round_trip.to_dict() == definition.to_dict()
assert round_trip.methods["process_rows"].description == (
"Process every loaded row."
)
assert round_trip.methods["process_rows"].do.call == "each"
def test_flow_definition_rejects_invalid_method_names():

View File

@@ -357,6 +357,27 @@ methods:
listen: begin
"""
JSON_SCHEMA_REQUIRED_INPUT_STATE_YAML = """
schema: crewai.flow/v1
name: JsonSchemaRequiredInputStateFlow
state:
type: json_schema
json_schema:
title: LeadState
type: object
required:
- lead_name
properties:
lead_name:
type: string
methods:
begin:
start: true
do:
call: expression
expr: state.lead_name
"""
PYDANTIC_REF_WITH_SCHEMA_FALLBACK_YAML = f"""
schema: crewai.flow/v1
name: SchemaFallbackFlow
@@ -445,7 +466,8 @@ def _run_with_events(flow, inputs=None):
def _state_without_id(flow):
snapshot = dict(flow.state.model_dump())
state = flow.state
snapshot = dict(state if isinstance(state, dict) else state.model_dump())
snapshot.pop("id", None)
return snapshot
@@ -454,7 +476,7 @@ def assert_parity(flow_cls, yaml_str, inputs=None, ordered=True):
class_flow = flow_cls()
class_result, class_events = _run_with_events(class_flow, inputs)
definition = FlowDefinition.from_yaml(yaml_str)
definition = FlowDefinition.from_declaration(contents=yaml_str)
definition_flow = Flow.from_definition(definition)
definition_result, definition_events = _run_with_events(definition_flow, inputs)
@@ -477,6 +499,21 @@ def test_simple_chain_parity():
assert flow.method_outputs == ["hello", "HELLO", "confirmed:True"]
def test_flow_from_declaration_builds_runnable_flow():
flow = Flow.from_declaration(contents=CHAIN_YAML)
assert flow.kickoff() == "confirmed:True"
assert flow.method_outputs == ["hello", "HELLO", "confirmed:True"]
def test_flow_from_declaration_accepts_flow_definition():
definition = FlowDefinition.from_declaration(contents=CHAIN_YAML)
flow = Flow.from_declaration(contents=definition)
assert flow.kickoff() == "confirmed:True"
assert flow.method_outputs == ["hello", "HELLO", "confirmed:True"]
def test_and_or_merge_parity():
flow, _ = assert_parity(MergeFlow, MERGE_YAML, ordered=False)
assert flow.state["joined"] is True
@@ -499,7 +536,7 @@ def test_cyclic_flow_parity():
def test_definition_flow_events_use_definition_name():
definition = FlowDefinition.from_yaml(CHAIN_YAML)
definition = FlowDefinition.from_declaration(contents=CHAIN_YAML)
flow = Flow.from_definition(definition)
_, events = _run_with_events(flow)
assert events
@@ -588,7 +625,7 @@ methods:
start: true
"""
flow = Flow.from_definition(FlowDefinition.from_yaml(yaml_str))
flow = Flow.from_definition(FlowDefinition.from_declaration(contents=yaml_str))
assert flow.kickoff() == "found:ai agents"
@@ -639,7 +676,7 @@ methods:
listen: begin
"""
flow = Flow.from_definition(FlowDefinition.from_yaml(yaml_str))
flow = Flow.from_definition(FlowDefinition.from_declaration(contents=yaml_str))
assert flow.kickoff(inputs={"topic": "ai"}) == "found:ai agents"
@@ -758,7 +795,7 @@ methods:
listen: begin
"""
flow = Flow.from_definition(FlowDefinition.from_yaml(yaml_str))
flow = Flow.from_definition(FlowDefinition.from_declaration(contents=yaml_str))
assert flow.kickoff() == "search:hello agents"
@@ -783,7 +820,7 @@ methods:
listen: build_query
"""
flow = Flow.from_definition(FlowDefinition.from_yaml(yaml_str))
flow = Flow.from_definition(FlowDefinition.from_declaration(contents=yaml_str))
assert flow.kickoff() == "found:ai agents news"
@@ -803,7 +840,7 @@ methods:
start: true
"""
flow = Flow.from_definition(FlowDefinition.from_yaml(yaml_str))
flow = Flow.from_definition(FlowDefinition.from_declaration(contents=yaml_str))
assert (
flow.kickoff(inputs={"limit": 2, "domains": ["crewai.com", "example.com"]})
@@ -836,7 +873,7 @@ methods:
start: true
"""
flow = Flow.from_definition(FlowDefinition.from_yaml(yaml_str))
flow = Flow.from_definition(FlowDefinition.from_declaration(contents=yaml_str))
assert flow.kickoff(inputs={"question": "What is CrewAI?"}) == {
"agent": "Analyst",
@@ -874,7 +911,7 @@ methods:
start: true
"""
flow = Flow.from_definition(FlowDefinition.from_yaml(yaml_str))
flow = Flow.from_definition(FlowDefinition.from_declaration(contents=yaml_str))
assert flow.kickoff(inputs={"questions": ["one", "two"]}) == [
"Analyst:one",
@@ -905,7 +942,7 @@ def test_agent_action_round_trips_with_inline_definition():
}
)
round_trip = FlowDefinition.from_yaml(definition.to_yaml())
round_trip = FlowDefinition.from_declaration(contents=definition.to_yaml())
action = round_trip.to_dict()["methods"]["answer"]["do"]
assert action["call"] == "agent"
@@ -968,7 +1005,7 @@ methods:
"""
with pytest.raises(ValidationError, match="invalid CEL expression"):
FlowDefinition.from_yaml(yaml_str)
FlowDefinition.from_declaration(contents=yaml_str)
def test_crew_action_runs_inline_yaml_definition(monkeypatch: pytest.MonkeyPatch):
@@ -1010,7 +1047,7 @@ methods:
start: true
"""
flow = Flow.from_definition(FlowDefinition.from_yaml(yaml_str))
flow = Flow.from_definition(FlowDefinition.from_declaration(contents=yaml_str))
assert flow.kickoff(inputs={"topic": "AI"}) == {
"crew": "inline_research",
@@ -1086,7 +1123,7 @@ methods:
start: true
"""
flow = Flow.from_definition(FlowDefinition.from_yaml(yaml_str))
flow = Flow.from_definition(FlowDefinition.from_declaration(contents=yaml_str))
assert flow.kickoff(inputs={"topic": "AI"}) == {
"crew": "referenced_research",
@@ -1160,9 +1197,7 @@ methods:
other_cwd.mkdir()
monkeypatch.chdir(other_cwd)
flow = Flow.from_definition(
FlowDefinition.from_yaml(yaml_str, source_path=flow_path)
)
flow = Flow.from_definition(FlowDefinition.from_declaration(path=flow_path))
assert flow.kickoff(inputs={"topic": "AI"}) == {
"crew": "relative_research",
@@ -1185,10 +1220,9 @@ methods:
from_declaration: ../outside/crew.jsonc
start: true
"""
flow_path.write_text(yaml_str, encoding="utf-8")
flow = Flow.from_definition(
FlowDefinition.from_yaml(yaml_str, source_path=flow_path)
)
flow = Flow.from_definition(FlowDefinition.from_declaration(path=flow_path))
with pytest.raises(
ValueError,
@@ -1411,7 +1445,7 @@ methods:
"""
with pytest.raises(ValidationError, match="invalid CEL expression"):
FlowDefinition.from_yaml(yaml_str)
FlowDefinition.from_declaration(contents=yaml_str)
def test_code_action_renders_keyword_inputs():
@@ -1429,7 +1463,7 @@ methods:
start: true
"""
flow = Flow.from_definition(FlowDefinition.from_yaml(yaml_str))
flow = Flow.from_definition(FlowDefinition.from_declaration(contents=yaml_str))
assert flow.kickoff(inputs={"name": "hello"}) == "hello!"
@@ -1448,7 +1482,7 @@ methods:
start: true
"""
flow = Flow.from_definition(FlowDefinition.from_yaml(yaml_str))
flow = Flow.from_definition(FlowDefinition.from_declaration(contents=yaml_str))
assert flow.kickoff(inputs={"value": "ok"}) == "callable:ok"
@@ -1472,7 +1506,7 @@ methods:
start: true
"""
flow = Flow.from_definition(FlowDefinition.from_yaml(yaml_str))
flow = Flow.from_definition(FlowDefinition.from_declaration(contents=yaml_str))
assert flow.kickoff(inputs={"rows": ["a", "b"]}) == [
"normalized:a",
@@ -1499,7 +1533,7 @@ methods:
start: true
"""
flow = Flow.from_definition(FlowDefinition.from_yaml(yaml_str))
flow = Flow.from_definition(FlowDefinition.from_declaration(contents=yaml_str))
caller_thread_id = threading.get_ident()
assert flow.kickoff(inputs={"rows": ["a"]}) == ["process_rows:a"]
@@ -1526,7 +1560,7 @@ methods:
start: true
"""
flow = Flow.from_definition(FlowDefinition.from_yaml(yaml_str))
flow = Flow.from_definition(FlowDefinition.from_declaration(contents=yaml_str))
assert flow.kickoff(inputs={"rows": ["a", "b"]}) == ["async:a", "async:b"]
@@ -1548,7 +1582,7 @@ methods:
FlowScriptExecutionDisabledError,
match="CREWAI_ALLOW_FLOW_SCRIPT_EXECUTION=1",
) as exc_info:
Flow.from_definition(FlowDefinition.from_yaml(yaml_str))
Flow.from_definition(FlowDefinition.from_declaration(contents=yaml_str))
assert "methods with unresolvable actions" not in str(exc_info.value)
@@ -1572,7 +1606,7 @@ methods:
start: true
"""
flow = Flow.from_definition(FlowDefinition.from_yaml(yaml_str))
flow = Flow.from_definition(FlowDefinition.from_declaration(contents=yaml_str))
assert flow.kickoff(inputs={"raw_score": 3.2}) == "rounded:4"
assert flow.state["rounded"] == 4
@@ -1601,7 +1635,7 @@ methods:
listen: seed
"""
flow = Flow.from_definition(FlowDefinition.from_yaml(yaml_str))
flow = Flow.from_definition(FlowDefinition.from_declaration(contents=yaml_str))
assert flow.kickoff() == "alpha:alpha"
assert flow.state["input_matches_output"] is True
@@ -1639,7 +1673,7 @@ methods:
listen: seed
"""
flow = Flow.from_definition(FlowDefinition.from_yaml(yaml_str))
flow = Flow.from_definition(FlowDefinition.from_declaration(contents=yaml_str))
assert flow.kickoff(inputs={"rows": [" a ", " b "]}) == ["global:a", "global:b"]
@@ -1671,7 +1705,7 @@ methods:
start: true
"""
flow = Flow.from_definition(FlowDefinition.from_yaml(yaml_str))
flow = Flow.from_definition(FlowDefinition.from_declaration(contents=yaml_str))
assert flow.kickoff(inputs={"rows": ["a", "b"]}) == [
{"row": "a", "normalized": "saved:a"},
@@ -1700,7 +1734,7 @@ methods:
start: true
"""
flow = Flow.from_definition(FlowDefinition.from_yaml(yaml_str))
flow = Flow.from_definition(FlowDefinition.from_declaration(contents=yaml_str))
assert flow.kickoff(inputs={"rows": ["a", "b"]}) == ["a", "b"]
assert flow._method_outputs == [
@@ -1738,7 +1772,7 @@ methods:
listen: seed
"""
flow = Flow.from_definition(FlowDefinition.from_yaml(yaml_str))
flow = Flow.from_definition(FlowDefinition.from_declaration(contents=yaml_str))
assert flow.kickoff(inputs={"rows": ["a", "b"]}) == [
"local:a",
@@ -1777,7 +1811,7 @@ methods:
start: true
"""
flow = Flow.from_definition(FlowDefinition.from_yaml(yaml_str))
flow = Flow.from_definition(FlowDefinition.from_declaration(contents=yaml_str))
assert flow.kickoff(
inputs={
@@ -1811,7 +1845,7 @@ methods:
start: true
"""
flow = Flow.from_definition(FlowDefinition.from_yaml(yaml_str))
flow = Flow.from_definition(FlowDefinition.from_declaration(contents=yaml_str))
assert flow.kickoff(inputs={"rows": [{"kind": "keep", "value": "a"}]}) == ["a"]
@@ -1838,7 +1872,7 @@ methods:
start: true
"""
flow = Flow.from_definition(FlowDefinition.from_yaml(yaml_str))
flow = Flow.from_definition(FlowDefinition.from_declaration(contents=yaml_str))
assert flow.kickoff(
inputs={
@@ -1868,7 +1902,7 @@ methods:
start: true
"""
flow = Flow.from_definition(FlowDefinition.from_yaml(yaml_str))
flow = Flow.from_definition(FlowDefinition.from_declaration(contents=yaml_str))
with pytest.raises(ValueError, match="if expression must evaluate to a boolean"):
flow.kickoff(inputs={"rows": [{"value": "truthy"}]})
@@ -1898,7 +1932,7 @@ methods:
listen: process_rows
"""
flow = Flow.from_definition(FlowDefinition.from_yaml(yaml_str))
flow = Flow.from_definition(FlowDefinition.from_declaration(contents=yaml_str))
events = []
with crewai_event_bus.scoped_handlers():
@@ -2069,7 +2103,7 @@ methods:
start: true
"""
flow = Flow.from_definition(FlowDefinition.from_yaml(yaml_str))
flow = Flow.from_definition(FlowDefinition.from_declaration(contents=yaml_str))
with pytest.raises(RuntimeError, match="bad row"):
flow.kickoff(inputs={"rows": ["ok", "bad"]})
@@ -2190,7 +2224,7 @@ methods:
listen: right
"""
definition = FlowDefinition.from_yaml(yaml_str)
definition = FlowDefinition.from_declaration(contents=yaml_str)
assert Flow.from_definition(definition).kickoff(
inputs={"direction": "left"}
@@ -2213,7 +2247,7 @@ methods:
"""
with pytest.raises(ValidationError, match="invalid CEL expression"):
FlowDefinition.from_yaml(yaml_str)
FlowDefinition.from_declaration(contents=yaml_str)
def test_expression_action_rejects_unknown_cel_root():
@@ -2229,7 +2263,7 @@ methods:
"""
with pytest.raises(ValidationError, match="unknown CEL root"):
FlowDefinition.from_yaml(yaml_str)
FlowDefinition.from_declaration(contents=yaml_str)
def test_tool_action_requires_module_qualname_ref():
@@ -2263,14 +2297,16 @@ def test_pydantic_state_from_ref_parity():
def test_pydantic_state_default_overlay():
flow = Flow.from_definition(FlowDefinition.from_yaml(PYDANTIC_STATE_OVERLAY_YAML))
flow = Flow.from_definition(
FlowDefinition.from_declaration(contents=PYDANTIC_STATE_OVERLAY_YAML)
)
result = flow.kickoff()
assert result == "count=6"
assert flow.state.count == 6
def test_json_schema_state():
flow = Flow.from_definition(FlowDefinition.from_yaml(JSON_SCHEMA_STATE_YAML))
flow = Flow.from_definition(FlowDefinition.from_declaration(contents=JSON_SCHEMA_STATE_YAML))
result = flow.kickoff()
assert result == "count=1"
assert flow.state.count == 1
@@ -2279,14 +2315,26 @@ def test_json_schema_state():
def test_json_schema_state_validates_inputs():
flow = Flow.from_definition(FlowDefinition.from_yaml(JSON_SCHEMA_STATE_YAML))
flow = Flow.from_definition(FlowDefinition.from_declaration(contents=JSON_SCHEMA_STATE_YAML))
with pytest.raises(ValueError, match="Invalid inputs"):
flow.kickoff(inputs={"count": "not-a-number"})
def test_json_schema_state_required_fields_can_come_from_kickoff_inputs():
flow = Flow.from_definition(
FlowDefinition.from_declaration(contents=JSON_SCHEMA_REQUIRED_INPUT_STATE_YAML)
)
result = flow.kickoff(inputs={"lead_name": "Ada Lovelace"})
assert result == "Ada Lovelace"
assert flow.state.lead_name == "Ada Lovelace"
assert flow.state.id
def test_pydantic_state_falls_back_to_json_schema_when_ref_unimportable():
flow = Flow.from_definition(
FlowDefinition.from_yaml(PYDANTIC_REF_WITH_SCHEMA_FALLBACK_YAML)
FlowDefinition.from_declaration(contents=PYDANTIC_REF_WITH_SCHEMA_FALLBACK_YAML)
)
result = flow.kickoff()
assert result == "count=1"
@@ -2295,7 +2343,9 @@ def test_pydantic_state_falls_back_to_json_schema_when_ref_unimportable():
def test_pydantic_state_without_ref_or_schema_falls_back_to_dict(caplog):
with caplog.at_level("ERROR"):
flow = Flow.from_definition(FlowDefinition.from_yaml(UNRESOLVABLE_STATE_YAML))
flow = Flow.from_definition(
FlowDefinition.from_declaration(contents=UNRESOLVABLE_STATE_YAML)
)
assert "falling back to dict state" in caplog.text
result = flow.kickoff()
@@ -2305,7 +2355,7 @@ def test_pydantic_state_without_ref_or_schema_falls_back_to_dict(caplog):
def test_dict_state_is_a_copy_of_default_plus_id():
definition = FlowDefinition.from_yaml(DICT_STATE_YAML)
definition = FlowDefinition.from_declaration(contents=DICT_STATE_YAML)
flow = Flow.from_definition(definition)
assert flow.state["count"] == 5
@@ -2322,7 +2372,7 @@ def test_dict_state_is_a_copy_of_default_plus_id():
def test_unknown_state_type_falls_back_to_dict(caplog):
with caplog.at_level("WARNING"):
flow = Flow.from_definition(FlowDefinition.from_yaml(UNKNOWN_STATE_YAML))
flow = Flow.from_definition(FlowDefinition.from_declaration(contents=UNKNOWN_STATE_YAML))
assert "falling back to dict state" in caplog.text
result = flow.kickoff()
@@ -2395,7 +2445,7 @@ def _run_capturing_flow_lifecycle(yaml_str, event_types):
def capture(source, event):
events.append(event)
flow = Flow.from_definition(FlowDefinition.from_yaml(yaml_str))
flow = Flow.from_definition(FlowDefinition.from_declaration(contents=yaml_str))
result = flow.kickoff()
return flow, result, events
@@ -2409,7 +2459,7 @@ _LIFECYCLE_EVENTS = [
]
def test_config_suppress_flow_events_from_yaml():
def test_config_suppress_flow_events_from_declaration():
twin_events = []
with crewai_event_bus.scoped_handlers():
for event_type in _LIFECYCLE_EVENTS:
@@ -2432,14 +2482,14 @@ def test_config_suppress_flow_events_from_yaml():
)
def test_config_max_method_calls_from_yaml():
flow = Flow.from_definition(FlowDefinition.from_yaml(CAPPED_LOOP_YAML))
def test_config_max_method_calls_from_declaration():
flow = Flow.from_definition(FlowDefinition.from_declaration(contents=CAPPED_LOOP_YAML))
with pytest.raises(RecursionError, match="has been called 2 times"):
flow.kickoff()
def test_config_stream_from_yaml():
flow = Flow.from_definition(FlowDefinition.from_yaml(STREAMING_CHAIN_YAML))
def test_config_stream_from_declaration():
flow = Flow.from_definition(FlowDefinition.from_declaration(contents=STREAMING_CHAIN_YAML))
streaming = flow.kickoff()
assert isinstance(streaming, FlowStreamingOutput)
for _ in streaming:
@@ -2448,7 +2498,7 @@ def test_config_stream_from_yaml():
assert flow.stream is True
def test_config_defer_trace_finalization_from_yaml():
def test_config_defer_trace_finalization_from_declaration():
_, _, baseline_events = _run_capturing_flow_lifecycle(
CHAIN_YAML, [FlowFinishedEvent]
)
@@ -2462,7 +2512,7 @@ def test_config_defer_trace_finalization_from_yaml():
assert deferred_events == []
def test_config_checkpoint_from_yaml(tmp_path):
def test_config_checkpoint_from_declaration(tmp_path):
yaml_str = (
CHAIN_YAML
+ f"""
@@ -2471,19 +2521,23 @@ config:
location: {tmp_path}
"""
)
flow = Flow.from_definition(FlowDefinition.from_yaml(yaml_str))
flow = Flow.from_definition(FlowDefinition.from_declaration(contents=yaml_str))
assert isinstance(flow.checkpoint, CheckpointConfig)
assert flow.checkpoint.location == str(tmp_path)
def test_config_input_provider_from_yaml():
flow = Flow.from_definition(FlowDefinition.from_yaml(INPUT_PROVIDER_CHAIN_YAML))
def test_config_input_provider_from_declaration():
flow = Flow.from_definition(
FlowDefinition.from_declaration(contents=INPUT_PROVIDER_CHAIN_YAML)
)
assert isinstance(flow.input_provider, StubInputProvider)
def test_round_trip_config_equivalence():
class_flow = ConfiguredFlow()
definition = FlowDefinition.from_yaml(ConfiguredFlow.flow_definition().to_yaml())
definition = FlowDefinition.from_declaration(
contents=ConfiguredFlow.flow_definition().to_yaml()
)
definition_flow = Flow.from_definition(definition)
assert definition.config.suppress_flow_events is True
@@ -2653,9 +2707,9 @@ class MethodPersistedFlow(Flow):
return "two"
def test_flow_level_persist_from_yaml_saves_once_per_method():
def test_flow_level_persist_from_declaration_saves_once_per_method():
yaml_str = _flow_level_persist_yaml("yaml-flow-level")
flow = Flow.from_definition(FlowDefinition.from_yaml(yaml_str))
flow = Flow.from_definition(FlowDefinition.from_declaration(contents=yaml_str))
result = flow.kickoff()
assert result == "two"
@@ -2665,9 +2719,9 @@ def test_flow_level_persist_from_yaml_saves_once_per_method():
assert final_save["id"] == flow.state["id"]
def test_method_level_persist_from_yaml_saves_only_that_method():
def test_method_level_persist_from_declaration_saves_only_that_method():
yaml_str = _method_level_persist_yaml("yaml-method-level")
flow = Flow.from_definition(FlowDefinition.from_yaml(yaml_str))
flow = Flow.from_definition(FlowDefinition.from_declaration(contents=yaml_str))
flow.kickoff()
assert _saved_methods("yaml-method-level") == ["first"]
@@ -2696,20 +2750,20 @@ methods:
persist:
enabled: false
"""
flow = Flow.from_definition(FlowDefinition.from_yaml(yaml_str))
flow = Flow.from_definition(FlowDefinition.from_declaration(contents=yaml_str))
flow.kickoff()
assert _saved_methods("yaml-opt-out") == ["first"]
def test_persist_restore_by_id_from_yaml():
def test_persist_restore_by_id_from_declaration():
yaml_str = _flow_level_persist_yaml("yaml-restore")
flow1 = Flow.from_definition(FlowDefinition.from_yaml(yaml_str))
flow1 = Flow.from_definition(FlowDefinition.from_declaration(contents=yaml_str))
flow1.kickoff()
assert flow1.state["count"] == 2
flow2 = Flow.from_definition(FlowDefinition.from_yaml(yaml_str))
flow2 = Flow.from_definition(FlowDefinition.from_declaration(contents=yaml_str))
flow2.kickoff(inputs={"id": flow1.state["id"]})
assert flow2.state["count"] == 4
@@ -2729,7 +2783,9 @@ def test_method_level_persist_decorator_saves_only_that_method():
def test_round_trip_persist_equivalence():
definition = FlowDefinition.from_yaml(ClassPersistedFlow.flow_definition().to_yaml())
definition = FlowDefinition.from_declaration(
contents=ClassPersistedFlow.flow_definition().to_yaml()
)
before = len(DefinitionStoreBackend.saves["class-decorator"])
flow = Flow.from_definition(definition)
@@ -2738,7 +2794,7 @@ def test_round_trip_persist_equivalence():
assert _saved_methods("class-decorator")[before:] == ["first", "second"]
def test_method_persist_backend_overrides_flow_level_backend_from_yaml():
def test_method_persist_backend_overrides_flow_level_backend_from_declaration():
yaml_str = f"""
schema: crewai.flow/v1
name: PersistedFlow
@@ -2762,7 +2818,7 @@ methods:
persistence_type: DefinitionStoreBackend
store: yaml-mixed-method
"""
flow = Flow.from_definition(FlowDefinition.from_yaml(yaml_str))
flow = Flow.from_definition(FlowDefinition.from_declaration(contents=yaml_str))
flow.kickoff()
assert _saved_methods("yaml-mixed-flow") == ["first"]
@@ -2910,8 +2966,8 @@ methods:
"""
def test_human_feedback_from_yaml_default_outcome_routes():
flow = Flow.from_definition(FlowDefinition.from_yaml(REVIEW_YAML))
def test_human_feedback_from_declaration_default_outcome_routes():
flow = Flow.from_definition(FlowDefinition.from_declaration(contents=REVIEW_YAML))
with patch.object(flow, "_request_human_feedback", return_value="") as request:
result = flow.kickoff()
@@ -2922,8 +2978,8 @@ def test_human_feedback_from_yaml_default_outcome_routes():
assert flow.last_human_feedback.output == "draft-content"
def test_human_feedback_from_yaml_collapses_and_routes():
flow = Flow.from_definition(FlowDefinition.from_yaml(REVIEW_YAML))
def test_human_feedback_from_declaration_collapses_and_routes():
flow = Flow.from_definition(FlowDefinition.from_declaration(contents=REVIEW_YAML))
with (
patch.object(flow, "_request_human_feedback", return_value="ship it"),
@@ -2940,7 +2996,7 @@ def test_round_trip_human_feedback_equivalence():
with patch.object(class_flow, "_request_human_feedback", return_value=""):
class_result = class_flow.kickoff()
definition = FlowDefinition.from_yaml(ReviewFlow.flow_definition().to_yaml())
definition = FlowDefinition.from_declaration(contents=ReviewFlow.flow_definition().to_yaml())
twin = Flow.from_definition(definition)
with patch.object(twin, "_request_human_feedback", return_value=""):
twin_result = twin.kickoff()
@@ -2953,8 +3009,8 @@ def test_round_trip_human_feedback_equivalence():
)
def test_human_feedback_pending_and_resume_from_yaml():
definition = FlowDefinition.from_yaml(PENDING_REVIEW_YAML)
def test_human_feedback_pending_and_resume_from_declaration():
definition = FlowDefinition.from_declaration(contents=PENDING_REVIEW_YAML)
flow = Flow.from_definition(definition)
pending = flow.kickoff()
@@ -2975,7 +3031,7 @@ def test_human_feedback_pending_and_resume_from_yaml():
assert flow_id not in DefinitionStoreBackend.pending
def test_flow_config_provider_fallback_from_yaml():
def test_flow_config_provider_fallback_from_declaration():
yaml_str = f"""
schema: crewai.flow/v1
name: ConfigProviderFlow
@@ -3001,7 +3057,7 @@ methods:
return "from-config"
provider = RecordingProvider()
flow = Flow.from_definition(FlowDefinition.from_yaml(yaml_str))
flow = Flow.from_definition(FlowDefinition.from_declaration(contents=yaml_str))
previous = flow_config.hitl_provider
flow_config.hitl_provider = provider
@@ -3104,7 +3160,7 @@ methods:
message: "Review:"
provider: {__name__}:_NeedsArgsProvider
"""
flow = Flow.from_definition(FlowDefinition.from_yaml(yaml_str))
flow = Flow.from_definition(FlowDefinition.from_declaration(contents=yaml_str))
with pytest.raises(
ValueError, match="cannot instantiate human_feedback.provider ref"
@@ -3125,7 +3181,7 @@ methods:
message: "Review:"
provider: missing_module_xyz:Provider
"""
flow = Flow.from_definition(FlowDefinition.from_yaml(yaml_str))
flow = Flow.from_definition(FlowDefinition.from_declaration(contents=yaml_str))
with pytest.raises(
ValueError, match="unresolvable human_feedback.provider ref"
@@ -3137,7 +3193,7 @@ def _checkpoint_chain_flow(tmp_path):
from crewai.state.provider.json_provider import JsonProvider
from crewai.state.runtime import RuntimeState
definition = FlowDefinition.from_yaml(CHAIN_YAML)
definition = FlowDefinition.from_declaration(contents=CHAIN_YAML)
flow = Flow.from_definition(definition)
result = flow.kickoff()
assert result == "confirmed:True"
@@ -3177,7 +3233,7 @@ state:
methods: {}
"""
with pytest.raises(ValidationError, match="default"):
FlowDefinition.from_yaml(yaml_str)
FlowDefinition.from_declaration(contents=yaml_str)
def test_definition_method_missing_from_class_fails_loudly():

View File

@@ -233,7 +233,7 @@ def test_persistence_with_base_model(tmp_path):
assert message.role == "user"
assert message.type == "text"
assert message.content == "Hello, World!"
assert isinstance(flow.state._unwrap(), State)
assert isinstance(flow.state, State)
def test_fork_with_restore_from_state_id(tmp_path):

View File

@@ -1,3 +1,3 @@
"""CrewAI development tools."""
__version__ = "1.14.8a2"
__version__ = "1.14.8a5"

63
uv.lock generated
View File

@@ -13,7 +13,7 @@ resolution-markers = [
]
[options]
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 = "2026-06-20T16:46:21.117658Z"
exclude-newer-span = "P3D"
[options.exclude-newer-package]
@@ -1452,9 +1452,9 @@ requires-dist = [
{ name = "openai", specifier = ">=2.30.0,<3" },
{ name = "openpyxl", specifier = "~=3.1.5" },
{ name = "openpyxl", marker = "extra == 'openpyxl'", specifier = "~=3.1.5" },
{ name = "opentelemetry-api", specifier = "~=1.34.0" },
{ name = "opentelemetry-exporter-otlp-proto-http", specifier = "~=1.34.0" },
{ name = "opentelemetry-sdk", specifier = "~=1.34.0" },
{ name = "opentelemetry-api", specifier = "~=1.42.0" },
{ name = "opentelemetry-exporter-otlp-proto-http", specifier = "~=1.42.0" },
{ name = "opentelemetry-sdk", specifier = "~=1.42.0" },
{ name = "pandas", marker = "extra == 'pandas'", specifier = "~=2.2.3" },
{ name = "pdfplumber", specifier = "~=0.11.4" },
{ name = "portalocker", specifier = "~=2.7.0" },
@@ -1539,9 +1539,9 @@ requires-dist = [
{ name = "appdirs", specifier = "~=1.4.4" },
{ name = "cryptography", specifier = ">=42.0" },
{ name = "httpx", specifier = "~=0.28.1" },
{ name = "opentelemetry-api", specifier = "~=1.34.0" },
{ name = "opentelemetry-exporter-otlp-proto-http", specifier = "~=1.34.0" },
{ name = "opentelemetry-sdk", specifier = "~=1.34.0" },
{ name = "opentelemetry-api", specifier = "~=1.42.0" },
{ name = "opentelemetry-exporter-otlp-proto-http", specifier = "~=1.42.0" },
{ name = "opentelemetry-sdk", specifier = "~=1.42.0" },
{ name = "packaging", specifier = ">=23.0" },
{ name = "portalocker", specifier = "~=2.7.0" },
{ name = "pydantic", specifier = ">=2.11.9,<2.13" },
@@ -5585,45 +5585,44 @@ wheels = [
[[package]]
name = "opentelemetry-api"
version = "1.34.1"
version = "1.42.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "importlib-metadata" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/4d/5e/94a8cb759e4e409022229418294e098ca7feca00eb3c467bb20cbd329bda/opentelemetry_api-1.34.1.tar.gz", hash = "sha256:64f0bd06d42824843731d05beea88d4d4b6ae59f9fe347ff7dfa2cc14233bbb3", size = 64987, upload-time = "2025-06-10T08:55:19.818Z" }
sdist = { url = "https://files.pythonhosted.org/packages/b4/1c/125e1c936c0873796771b7f04f6c93b9f1bf5d424cea90fda94a99f61da8/opentelemetry_api-1.42.1.tar.gz", hash = "sha256:56c63bea9f77b62856be8c47600474acad853b2924b99b1687c4cb6297166716", size = 72296, upload-time = "2026-05-21T16:32:49.335Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a5/3a/2ba85557e8dc024c0842ad22c570418dc02c36cbd1ab4b832a93edf071b8/opentelemetry_api-1.34.1-py3-none-any.whl", hash = "sha256:b7df4cb0830d5a6c29ad0c0691dbae874d8daefa934b8b1d642de48323d32a8c", size = 65767, upload-time = "2025-06-10T08:54:56.717Z" },
{ url = "https://files.pythonhosted.org/packages/a3/ca/9520cc1f3dfbbd03ac5903bbf55833e257bc64b1cf30fa8b0d6df374d821/opentelemetry_api-1.42.1-py3-none-any.whl", hash = "sha256:51a69edacadbc03a8950ace1c4c21099cacc538820ac2c9e36277e78cebba714", size = 61311, upload-time = "2026-05-21T16:32:28.822Z" },
]
[[package]]
name = "opentelemetry-exporter-otlp"
version = "1.34.1"
version = "1.42.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "opentelemetry-exporter-otlp-proto-grpc" },
{ name = "opentelemetry-exporter-otlp-proto-http" },
]
sdist = { url = "https://files.pythonhosted.org/packages/44/ba/786b4de7e39d88043622d901b92c4485835f43e0be76c2824d2687911bc2/opentelemetry_exporter_otlp-1.34.1.tar.gz", hash = "sha256:71c9ad342d665d9e4235898d205db17c5764cd7a69acb8a5dcd6d5e04c4c9988", size = 6173, upload-time = "2025-06-10T08:55:21.595Z" }
sdist = { url = "https://files.pythonhosted.org/packages/08/94/8637919a5d01f81dacf510234bc0110b944f4687a6e96b0a02adf2f6bdce/opentelemetry_exporter_otlp-1.42.1.tar.gz", hash = "sha256:2d9ebaed714377a67d224d46795ddcc11d2c877fa5de35fda70b6f3b010729a9", size = 6086, upload-time = "2026-05-21T16:32:51.963Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/00/c1/259b8d8391c968e8f005d8a0ccefcb41aeef64cf55905cd0c0db4e22aaee/opentelemetry_exporter_otlp-1.34.1-py3-none-any.whl", hash = "sha256:f4a453e9cde7f6362fd4a090d8acf7881d1dc585540c7b65cbd63e36644238d4", size = 7040, upload-time = "2025-06-10T08:54:59.655Z" },
{ url = "https://files.pythonhosted.org/packages/6c/4d/c26080295a36fd22e201fefd7cb9c22cd203189b1af8cd73b158382b7ad8/opentelemetry_exporter_otlp-1.42.1-py3-none-any.whl", hash = "sha256:aedd54545bb0587cd45210abdc8be545af9c01413f3307786e276df1e3c83bee", size = 6733, upload-time = "2026-05-21T16:32:31.261Z" },
]
[[package]]
name = "opentelemetry-exporter-otlp-proto-common"
version = "1.34.1"
version = "1.42.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "opentelemetry-proto" },
]
sdist = { url = "https://files.pythonhosted.org/packages/86/f0/ff235936ee40db93360233b62da932d4fd9e8d103cd090c6bcb9afaf5f01/opentelemetry_exporter_otlp_proto_common-1.34.1.tar.gz", hash = "sha256:b59a20a927facd5eac06edaf87a07e49f9e4a13db487b7d8a52b37cb87710f8b", size = 20817, upload-time = "2025-06-10T08:55:22.55Z" }
sdist = { url = "https://files.pythonhosted.org/packages/0e/9c/216acfeaedadf2e1937f4373929b20f73197c5c4a2546d4f584b7fa63813/opentelemetry_exporter_otlp_proto_common-1.42.1.tar.gz", hash = "sha256:04f1f01fb597c4249dfcd7f8b861c902c2102369d376d9d346ff38de4469a2ee", size = 21433, upload-time = "2026-05-21T16:32:55.526Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/72/e8/8b292a11cc8d8d87ec0c4089ae21b6a58af49ca2e51fa916435bc922fdc7/opentelemetry_exporter_otlp_proto_common-1.34.1-py3-none-any.whl", hash = "sha256:8e2019284bf24d3deebbb6c59c71e6eef3307cd88eff8c633e061abba33f7e87", size = 18834, upload-time = "2025-06-10T08:55:00.806Z" },
{ url = "https://files.pythonhosted.org/packages/d6/43/2375e7612e1121a4518c17603b6e0b03ad94f565aafad53f464dc5be2bf6/opentelemetry_exporter_otlp_proto_common-1.42.1-py3-none-any.whl", hash = "sha256:f48d395ab815b444da118868977e9798ea354c25737d5cf39578ae894011c140", size = 17327, upload-time = "2026-05-21T16:32:33.387Z" },
]
[[package]]
name = "opentelemetry-exporter-otlp-proto-grpc"
version = "1.34.1"
version = "1.42.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "googleapis-common-protos" },
@@ -5634,14 +5633,14 @@ dependencies = [
{ name = "opentelemetry-sdk" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/41/f7/bb63837a3edb9ca857aaf5760796874e7cecddc88a2571b0992865a48fb6/opentelemetry_exporter_otlp_proto_grpc-1.34.1.tar.gz", hash = "sha256:7c841b90caa3aafcfc4fee58487a6c71743c34c6dc1787089d8b0578bbd794dd", size = 22566, upload-time = "2025-06-10T08:55:23.214Z" }
sdist = { url = "https://files.pythonhosted.org/packages/87/87/ca7fc790dfdbcf4f9e9aab14a39ef1b7508ead13707e283de0b3131478d2/opentelemetry_exporter_otlp_proto_grpc-1.42.1.tar.gz", hash = "sha256:975c4461f167dd8ed8857d68d3b6b25f3d272eab896f6a9470d0f5b90e2faf15", size = 27140, upload-time = "2026-05-21T16:32:56.162Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b4/42/0a4dd47e7ef54edf670c81fc06a83d68ea42727b82126a1df9dd0477695d/opentelemetry_exporter_otlp_proto_grpc-1.34.1-py3-none-any.whl", hash = "sha256:04bb8b732b02295be79f8a86a4ad28fae3d4ddb07307a98c7aa6f331de18cca6", size = 18615, upload-time = "2025-06-10T08:55:02.214Z" },
{ url = "https://files.pythonhosted.org/packages/89/2b/28ba5b128f47fe8c3bab541000d6feb4b5a9bd26623ca013406f01c0fb60/opentelemetry_exporter_otlp_proto_grpc-1.42.1-py3-none-any.whl", hash = "sha256:0ae1177e2038b18a929b3098215243631ef91136cba26b7e2b12790ceb7e87cc", size = 19617, upload-time = "2026-05-21T16:32:34.278Z" },
]
[[package]]
name = "opentelemetry-exporter-otlp-proto-http"
version = "1.34.1"
version = "1.42.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "googleapis-common-protos" },
@@ -5652,48 +5651,48 @@ dependencies = [
{ name = "requests" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/19/8f/954bc725961cbe425a749d55c0ba1df46832a5999eae764d1a7349ac1c29/opentelemetry_exporter_otlp_proto_http-1.34.1.tar.gz", hash = "sha256:aaac36fdce46a8191e604dcf632e1f9380c7d5b356b27b3e0edb5610d9be28ad", size = 15351, upload-time = "2025-06-10T08:55:24.657Z" }
sdist = { url = "https://files.pythonhosted.org/packages/77/32/826bfa1d80ecea24f47808de03cd4a0d13c17ecc07712f45123f0f61e4ac/opentelemetry_exporter_otlp_proto_http-1.42.1.tar.gz", hash = "sha256:bf142a21035d7571ac3a09cb2e5639f49886f243972883cfe777ed3bf02b734d", size = 25406, upload-time = "2026-05-21T16:32:56.807Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/79/54/b05251c04e30c1ac70cf4a7c5653c085dfcf2c8b98af71661d6a252adc39/opentelemetry_exporter_otlp_proto_http-1.34.1-py3-none-any.whl", hash = "sha256:5251f00ca85872ce50d871f6d3cc89fe203b94c3c14c964bbdc3883366c705d8", size = 17744, upload-time = "2025-06-10T08:55:03.802Z" },
{ url = "https://files.pythonhosted.org/packages/d3/96/82cb223a1502f0787d4bbff12907f5f8d870a50731febcd5818d93ef9555/opentelemetry_exporter_otlp_proto_http-1.42.1-py3-none-any.whl", hash = "sha256:00a16da1b312a1d6c7233d600d557c91df71125af73020f3b9a7765bd699d59d", size = 21793, upload-time = "2026-05-21T16:32:35.277Z" },
]
[[package]]
name = "opentelemetry-proto"
version = "1.34.1"
version = "1.42.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "protobuf" },
]
sdist = { url = "https://files.pythonhosted.org/packages/66/b3/c3158dd012463bb7c0eb7304a85a6f63baeeb5b4c93a53845cf89f848c7e/opentelemetry_proto-1.34.1.tar.gz", hash = "sha256:16286214e405c211fc774187f3e4bbb1351290b8dfb88e8948af209ce85b719e", size = 34344, upload-time = "2025-06-10T08:55:32.25Z" }
sdist = { url = "https://files.pythonhosted.org/packages/b4/55/63eac3e1089b768ba014091fdd2ae8a9a440c821ef5e2b786909c94c8836/opentelemetry_proto-1.42.1.tar.gz", hash = "sha256:c6a51e6b4f05ae63565f3a113217f3d2bfaec68f78c02d7a6c85f9010d1cfca6", size = 45839, upload-time = "2026-05-21T16:33:03.937Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/28/ab/4591bfa54e946350ce8b3f28e5c658fe9785e7cd11e9c11b1671a867822b/opentelemetry_proto-1.34.1-py3-none-any.whl", hash = "sha256:eb4bb5ac27f2562df2d6857fc557b3a481b5e298bc04f94cc68041f00cebcbd2", size = 55692, upload-time = "2025-06-10T08:55:14.904Z" },
{ url = "https://files.pythonhosted.org/packages/41/9d/171c02c84a76940b7e601805b3bb536985aded9168fbcc9ba52f0a730fa2/opentelemetry_proto-1.42.1-py3-none-any.whl", hash = "sha256:dedb74cba2886c59c7789b227a7a670613025a07489040050aedff6e5c0fb43c", size = 71782, upload-time = "2026-05-21T16:32:44.867Z" },
]
[[package]]
name = "opentelemetry-sdk"
version = "1.34.1"
version = "1.42.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "opentelemetry-api" },
{ name = "opentelemetry-semantic-conventions" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/6f/41/fe20f9036433da8e0fcef568984da4c1d1c771fa072ecd1a4d98779dccdd/opentelemetry_sdk-1.34.1.tar.gz", hash = "sha256:8091db0d763fcd6098d4781bbc80ff0971f94e260739aa6afe6fd379cdf3aa4d", size = 159441, upload-time = "2025-06-10T08:55:33.028Z" }
sdist = { url = "https://files.pythonhosted.org/packages/40/f7/b390bd9bfd703bf98a68fea1f27786c6872331fd617164a54b8a59bdc008/opentelemetry_sdk-1.42.1.tar.gz", hash = "sha256:8c834e8f8c9ba4171d4ec843d0cb8a67e4c7394d3f9e9297e582cbd9456ddbf7", size = 239262, upload-time = "2026-05-21T16:33:04.641Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/07/1b/def4fe6aa73f483cabf4c748f4c25070d5f7604dcc8b52e962983491b29e/opentelemetry_sdk-1.34.1-py3-none-any.whl", hash = "sha256:308effad4059562f1d92163c61c8141df649da24ce361827812c40abb2a1e96e", size = 118477, upload-time = "2025-06-10T08:55:16.02Z" },
{ url = "https://files.pythonhosted.org/packages/8f/6b/4287766cfbde577ae2272e8884abac325aeaac0d64f41c61d5b8cc595105/opentelemetry_sdk-1.42.1-py3-none-any.whl", hash = "sha256:083cd4bbfaa5aa7b5a9e552430d9951219967cfb27aa61feb13a77aba1fc839d", size = 170907, upload-time = "2026-05-21T16:32:45.894Z" },
]
[[package]]
name = "opentelemetry-semantic-conventions"
version = "0.55b1"
version = "0.63b1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "opentelemetry-api" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5d/f0/f33458486da911f47c4aa6db9bda308bb80f3236c111bf848bd870c16b16/opentelemetry_semantic_conventions-0.55b1.tar.gz", hash = "sha256:ef95b1f009159c28d7a7849f5cbc71c4c34c845bb514d66adfdf1b3fff3598b3", size = 119829, upload-time = "2025-06-10T08:55:33.881Z" }
sdist = { url = "https://files.pythonhosted.org/packages/93/99/4d7dd6df64795951413ce6e815f8cf1eb191daf7196ae86574589643d5f3/opentelemetry_semantic_conventions-0.63b1.tar.gz", hash = "sha256:3daf963611334b365e98a57438183eb012d3bfb40b2d931a9af613476b8701a9", size = 148340, upload-time = "2026-05-21T16:33:05.455Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1a/89/267b0af1b1d0ba828f0e60642b6a5116ac1fd917cde7fc02821627029bd1/opentelemetry_semantic_conventions-0.55b1-py3-none-any.whl", hash = "sha256:5da81dfdf7d52e3d37f8fe88d5e771e191de924cfff5f550ab0b8f7b2409baed", size = 196223, upload-time = "2025-06-10T08:55:17.638Z" },
{ url = "https://files.pythonhosted.org/packages/cb/7a/7fe66f5f3682b1dd47d88cc4e11f1c6c0966b737de2d16671146e23c39a5/opentelemetry_semantic_conventions-0.63b1-py3-none-any.whl", hash = "sha256:dfe5ef4dee82586b746f522b818ceb298d00b3d59f660042bd79404bff8d0682", size = 203713, upload-time = "2026-05-21T16:32:47.016Z" },
]
[[package]]