mirror of
https://github.com/crewAIInc/crewAI.git
synced 2026-06-25 10:08:11 +00:00
Compare commits
15 Commits
docs/cor-3
...
1.14.8a5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
178c2d212c | ||
|
|
563b55f7ca | ||
|
|
340d23ae5d | ||
|
|
7738a1d30c | ||
|
|
156b3500b4 | ||
|
|
5827abbc17 | ||
|
|
9d0e3a841b | ||
|
|
12a5e91efb | ||
|
|
fac3e3579b | ||
|
|
a046e6a50b | ||
|
|
1862ff8f6c | ||
|
|
f2a074e35b | ||
|
|
658b8ee8b9 | ||
|
|
3452e5c187 | ||
|
|
793539173d |
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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، تُمثَّل كل خطوة عمل بـ **بطاقة واحدة**. تجمع البطاقة بين عنصرين كانا في السابق في عُقد منفصلة:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 캔버스에서 각 작업 단계는 **하나의 카드**로 표현됩니다. 이 카드는 이전에 별도의 노드에 있던 두 가지를 결합합니다:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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، تُمثَّل كل خطوة عمل بـ **بطاقة واحدة**. تجمع البطاقة بين عنصرين كانا في السابق في عُقد منفصلة:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 캔버스에서 각 작업 단계는 **하나의 카드**로 표현됩니다. 이 카드는 이전에 별도의 노드에 있던 두 가지를 결합합니다:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "1.14.8a2"
|
||||
__version__ = "1.14.8a5"
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
105
lib/cli/src/crewai_cli/kickoff_flow.py
Normal file
105
lib/cli/src/crewai_cli/kickoff_flow.py
Normal 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)
|
||||
@@ -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")
|
||||
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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]
|
||||
|
||||
140
lib/cli/tests/skills/test_safe_extract.py
Normal file
140
lib/cli/tests/skills/test_safe_extract.py
Normal 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)"
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
63
lib/cli/tests/test_kickoff_flow.py
Normal file
63
lib/cli/tests/test_kickoff_flow.py
Normal 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"]]
|
||||
@@ -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)]
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "1.14.8a2"
|
||||
__version__ = "1.14.8a5"
|
||||
|
||||
@@ -152,4 +152,4 @@ __all__ = [
|
||||
"wrap_file_source",
|
||||
]
|
||||
|
||||
__version__ = "1.14.8a2"
|
||||
__version__ = "1.14.8a5"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -330,4 +330,4 @@ __all__ = [
|
||||
"ZapierActionTools",
|
||||
]
|
||||
|
||||
__version__ = "1.14.8a2"
|
||||
__version__ = "1.14.8a5"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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_",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
@@ -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] = {}
|
||||
|
||||
|
||||
|
||||
69
lib/crewai/src/crewai/utilities/declarative_refs.py
Normal file
69
lib/crewai/src/crewai/utilities/declarative_refs.py
Normal 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
|
||||
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""CrewAI development tools."""
|
||||
|
||||
__version__ = "1.14.8a2"
|
||||
__version__ = "1.14.8a5"
|
||||
|
||||
63
uv.lock
generated
63
uv.lock
generated
@@ -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]]
|
||||
|
||||
Reference in New Issue
Block a user