diff --git a/conftest.py b/conftest.py
index 27bf03a86..5987d653f 100644
--- a/conftest.py
+++ b/conftest.py
@@ -134,17 +134,21 @@ def bedrock_host_matcher(r1: Request, r2: Request) -> bool: # type: ignore[no-a
)
-def _patched_make_vcr_request(httpx_request: Any, **kwargs: Any) -> Any:
+def _patched_make_vcr_request(
+ httpx_request: Any, real_request_body: Any = None, **kwargs: Any
+) -> Any:
"""Patched version of VCR's _make_vcr_request that handles binary content.
The original implementation fails on binary request bodies (like file uploads)
because it assumes all content can be decoded as UTF-8.
"""
- raw_body = httpx_request.read()
- try:
- body = raw_body.decode("utf-8")
- except UnicodeDecodeError:
- body = base64.b64encode(raw_body).decode("ascii")
+ raw_body = real_request_body if real_request_body is not None else httpx_request.read()
+ body: Any = raw_body
+ if isinstance(raw_body, bytes):
+ try:
+ body = raw_body.decode("utf-8")
+ except UnicodeDecodeError:
+ body = base64.b64encode(raw_body).decode("ascii")
uri = str(httpx_request.url)
headers = dict(httpx_request.headers)
return Request(httpx_request.method, uri, body, headers)
diff --git a/docs/docs.json b/docs/docs.json
index 345490b9f..bc5f8c7bf 100644
--- a/docs/docs.json
+++ b/docs/docs.json
@@ -397,7 +397,14 @@
"group": "Build",
"pages": [
"edge/en/enterprise/features/automations",
- "edge/en/enterprise/features/crew-studio",
+ {
+ "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",
@@ -921,7 +928,14 @@
"group": "Build",
"pages": [
"v1.14.7/en/enterprise/features/automations",
- "v1.14.7/en/enterprise/features/crew-studio",
+ {
+ "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",
@@ -8547,7 +8561,14 @@
"group": "Construir",
"pages": [
"edge/pt-BR/enterprise/features/automations",
- "edge/pt-BR/enterprise/features/crew-studio",
+ {
+ "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",
@@ -9048,7 +9069,14 @@
"group": "Construir",
"pages": [
"v1.14.7/pt-BR/enterprise/features/automations",
- "v1.14.7/pt-BR/enterprise/features/crew-studio",
+ {
+ "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",
@@ -16411,7 +16439,14 @@
"group": "빌드",
"pages": [
"edge/ko/enterprise/features/automations",
- "edge/ko/enterprise/features/crew-studio",
+ {
+ "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",
@@ -16924,7 +16959,14 @@
"group": "빌드",
"pages": [
"v1.14.7/ko/enterprise/features/automations",
- "v1.14.7/ko/enterprise/features/crew-studio",
+ {
+ "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",
@@ -24467,7 +24509,14 @@
"group": "البناء",
"pages": [
"edge/ar/enterprise/features/automations",
- "edge/ar/enterprise/features/crew-studio",
+ {
+ "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",
@@ -24980,7 +25029,14 @@
"group": "البناء",
"pages": [
"v1.14.7/ar/enterprise/features/automations",
- "v1.14.7/ar/enterprise/features/crew-studio",
+ {
+ "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",
diff --git a/docs/edge/ar/changelog.mdx b/docs/edge/ar/changelog.mdx
index 6f3fc32f5..dab6bebce 100644
--- a/docs/edge/ar/changelog.mdx
+++ b/docs/edge/ar/changelog.mdx
@@ -4,6 +4,83 @@ description: "تحديثات المنتج والتحسينات وإصلاحات
icon: "clock"
mode: "wide"
---
+
+ ## 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
+
+
+
+
+ ## v1.14.8a4
+
+ [عرض الإصدار على GitHub](https://github.com/crewAIInc/crewAI/releases/tag/1.14.8a4)
+
+ ## ما الذي تغير
+
+ ### الميزات
+ - دعم تدفقات المحادثة في واجهة سطر الأوامر TUI.
+
+ ### إصلاحات الأخطاء
+ - إصلاح مسار التوجيه الرمزي في استخراج أرشيف المهارات.
+ - التحقق من صحة مسارات تعريف التدفق الإعلاني.
+
+ ### الوثائق
+ - تحديث اللقطة وسجل التغييرات للإصدار v1.14.8a3.
+
+ ## المساهمون
+
+ @lorenzejay, @theCyberTech, @vinibrsl
+
+
+
+
+ ## 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
+
+
+
## v1.14.8a2
@@ -64,7 +141,7 @@ mode: "wide"
- تنفيذ أدوات تشغيل تعريف التدفق بدون كود Python
- دفع التغذية الراجعة البشرية من تعريف التدفق
- توصيل التكوين والاستمرارية من FlowDefinition إلى وقت التشغيل
- - إضافة `crewai run --definition` التجريبية للتدفقات
+ - إضافة `crewai run --definition` للتدفقات التصريحية
- دعم تراجع نشر ZIP وتشغيل مشاريع الطاقم بتنسيق JSON
- تقديم الطواقم بتنسيق JSON أولاً
diff --git a/docs/edge/ar/concepts/flows.mdx b/docs/edge/ar/concepts/flows.mdx
index da162310f..62d34b335 100644
--- a/docs/edge/ar/concepts/flows.mdx
+++ b/docs/edge/ar/concepts/flows.mdx
@@ -959,7 +959,7 @@ source .venv/bin/activate
بعد تفعيل البيئة الافتراضية، يمكنك تشغيل التدفق بتنفيذ أحد الأوامر التالية:
```bash
-crewai flow kickoff
+crewai run
```
أو
@@ -1160,10 +1160,4 @@ crewai run
يكتشف هذا الأمر تلقائيًا ما إذا كان مشروعك تدفقًا (بناءً على إعداد `type = "flow"` في pyproject.toml الخاص بك) ويشغّله وفقًا لذلك. هذه هي الطريقة الموصى بها لتشغيل التدفقات من سطر الأوامر.
-للتوافق مع الإصدارات السابقة، يمكنك أيضًا استخدام:
-
-```shell
-crewai flow kickoff
-```
-
-ومع ذلك، فإن أمر `crewai run` هو الطريقة المفضلة الآن لأنه يعمل لكل من فرق Crew والتدفقات.
+أمر `crewai flow kickoff` القديم deprecated. استخدم `crewai run` لكل من فرق Crew والتدفقات.
diff --git a/docs/edge/ar/enterprise/features/merged-step-card.mdx b/docs/edge/ar/enterprise/features/merged-step-card.mdx
new file mode 100644
index 000000000..776edfe10
--- /dev/null
+++ b/docs/edge/ar/enterprise/features/merged-step-card.mdx
@@ -0,0 +1,82 @@
+---
+title: بطاقة واحدة لكل خطوة
+description: "كل خطوة على لوحة Studio هي بطاقة واحدة تجمع بين المهمة والوكيل الذي ينفّذها."
+icon: "layer-group"
+mode: "wide"
+---
+
+## نظرة عامة
+
+على لوحة Studio، تُمثَّل كل خطوة عمل بـ **بطاقة واحدة**. تجمع البطاقة بين عنصرين كانا في السابق في عُقد منفصلة:
+
+- **المهمة** — ماذا تفعل (الاسم، الوصف، المخرجات المتوقعة، وتنسيق الاستجابة).
+- **الوكيل** — من ينفّذها (الوكيل المُعيَّن ونموذجه وأدواته).
+
+الوكيل ليس مشاركًا مستقلاً في سير العمل لديك — بل هو سمة من سمات المهمة: *أي وكيل ينفّذ هذا العمل.* وضع المهمة والوكيل في بطاقة واحدة يجعل هذه العلاقة واضحة، ويحوّل أتمتتك إلى سلسلة واحدة من وحدات العمل من اليسار إلى اليمين يسهل قراءتها بنظرة واحدة.
+
+
+ 
+
+
+## على اللوحة
+
+تعرض كل بطاقة مطوية ما يلي:
+
+- **اسم المهمة ووصفها** في الأعلى.
+- **تذييل يلخّص الوكيل المُعيَّن** — الصورة الرمزية والاسم والنموذج والأدوات.
+
+لا توجد عقدة وكيل منفصلة ولا حافة عمودية من الوكيل ← المهمة. تتصل خطواتك مباشرةً ببعضها البعض بالترتيب الذي تُنفَّذ به.
+
+## في المحرّر
+
+افتح بطاقة لتحريرها. العرض الموسّع هو البطاقة نفسها في حالة مفصّلة — وليس شاشة مختلفة — منظّمة في قسمين موسومين بوضوح.
+
+
+ 
+
+
+### المهمة — ماذا تفعل
+
+مفتوحة افتراضيًا، لأنها ما تحرّره عادةً:
+
+- **الاسم**
+- **الوصف**
+- **المخرجات المتوقعة**
+- **تنسيق الاستجابة** — يظهر هنا لأنه يتحكم تحديدًا في ما تقرأه الخطوات اللاحقة (مثل التوجيه) من هذه الخطوة.
+
+### الوكيل — من ينفّذها
+
+يُعرض الوكيل المُعيَّن كملخّص — **الاسم والنموذج والأدوات في سطر واحد**. ويُحفَظ إعداده الأعمق خلف قسمين قابلين للطي:
+
+- **الدور والهدف والخلفية**
+- **إعدادات الوكيل** — الاستدلال، الحد الأقصى لمحاولات الاستدلال، السماح بالتفويض، الحد الأقصى للتكرارات، وإعدادات LLM.
+
+
+ الإعداد الكامل للوكيل — الدور، الهدف، الخلفية، النموذج، الأدوات، إعدادات LLM، وكامل كتلة إعدادات الوكيل — موجود خلف القسمين القابلين للطي **الدور والهدف والخلفية** و**إعدادات الوكيل**، منظّمًا حسب عدد مرّات تحريرك له.
+
+
+## التبديل مقابل تحرير الوكيل
+
+هناك طريقتان متمايزتان للتعامل مع الوكيل في البطاقة، وكل منهما تؤدي وظيفة مختلفة:
+
+- **التبديل (Swap)** يعيد تعيين *أي* وكيل ينفّذ هذه المهمة. استخدم عنصر التحكم **تبديل** لاختيار وكيل مختلف من هذا المشروع، أو اختيار واحد من مستودع الوكلاء، أو إنشاء وكيل جديد. هذا مقصور على نطاق المهمة.
+- **تحرير** الوكيل — بفتح **الدور والهدف والخلفية** أو **إعدادات الوكيل** — يغيّر الوكيل *نفسه*.
+
+
+ 
+
+
+
+ **الوكلاء قابلون لإعادة الاستخدام ومشتركون.** يمكن للوكيل نفسه تنفيذ أكثر من مهمة عبر مشروعك. تحرير دور الوكيل أو خلفيته أو إعداداته يحدّث ذلك الوكيل **في كل مكان يُستخدم فيه** — وليس فقط في البطاقة التي فتحتها. إذا أردت تطبيق تغيير على خطوة واحدة فقط، فقم **بالتبديل** إلى وكيل مختلف بدلاً من تحرير الوكيل المشترك.
+
+
+## ذات صلة
+
+
+
+ أنشئ الأتمتة بمساعدة الذكاء الاصطناعي ومحرّر مرئي.
+
+
+ إدارة الوكلاء وإعادة استخدامهم عبر أتمتتك.
+
+
diff --git a/docs/edge/ar/guides/flows/first-flow.mdx b/docs/edge/ar/guides/flows/first-flow.mdx
index 9ee804653..322e71c2a 100644
--- a/docs/edge/ar/guides/flows/first-flow.mdx
+++ b/docs/edge/ar/guides/flows/first-flow.mdx
@@ -172,7 +172,7 @@ crewai install
## الخطوة 8: تشغيل Flow
```bash
-crewai flow kickoff
+crewai run
```
عند تشغيل هذا الأمر، ستشاهد Flow يعمل:
diff --git a/docs/edge/en/changelog.mdx b/docs/edge/en/changelog.mdx
index 924463ddc..beb43c4c3 100644
--- a/docs/edge/en/changelog.mdx
+++ b/docs/edge/en/changelog.mdx
@@ -4,6 +4,83 @@ description: "Product updates, improvements, and bug fixes for CrewAI"
icon: "clock"
mode: "wide"
---
+
+ ## 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
+
+
+
+
+ ## 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
+
+
+
+
+ ## 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
+
+
+
## v1.14.8a2
@@ -64,7 +141,7 @@ mode: "wide"
- Implement Flow definition run tools without Python code
- Drive human feedback from the flow definition
- Wire config and persistence from FlowDefinition into the runtime
- - Add experimental `crewai run --definition` for flows
+ - Add `crewai run --definition` for declarative flows
- Support ZIP deployment fallback and JSON crew project env runs
- Introduce JSON first crews
diff --git a/docs/edge/en/concepts/flows.mdx b/docs/edge/en/concepts/flows.mdx
index 210c573ce..647512545 100644
--- a/docs/edge/en/concepts/flows.mdx
+++ b/docs/edge/en/concepts/flows.mdx
@@ -956,13 +956,13 @@ Once all of the dependencies are installed, you need to activate the virtual env
source .venv/bin/activate
```
-After activating the virtual environment, you can run the flow by executing one of the following commands:
+After activating the virtual environment, you can run the flow with the CrewAI CLI:
```bash
-crewai flow kickoff
+crewai run
```
-or
+You can also run the project script directly:
```bash
uv run kickoff
@@ -1160,10 +1160,4 @@ crewai run
This command automatically detects if your project is a flow (based on the `type = "flow"` setting in your pyproject.toml) and runs it accordingly. This is the recommended way to run flows from the command line.
-For backward compatibility, you can also use:
-
-```shell
-crewai flow kickoff
-```
-
-However, the `crewai run` command is now the preferred method as it works for both crews and flows.
+The legacy `crewai flow kickoff` command is deprecated. Use `crewai run` for both crews and flows.
diff --git a/docs/edge/en/concepts/tools.mdx b/docs/edge/en/concepts/tools.mdx
index 52e568073..da41da3b1 100644
--- a/docs/edge/en/concepts/tools.mdx
+++ b/docs/edge/en/concepts/tools.mdx
@@ -39,6 +39,7 @@ The Enterprise Tools Repository includes:
- **Error Handling**: Incorporates robust error handling mechanisms to ensure smooth operation.
- **Caching Mechanism**: Features intelligent caching to optimize performance and reduce redundant operations.
- **Asynchronous Support**: Handles both synchronous and asynchronous tools, enabling non-blocking operations.
+- **Typed Outputs**: Uses optional Pydantic models to give agents clear JSON fields while direct Python calls still receive the tool's normal return value.
## Using CrewAI Tools
@@ -184,6 +185,55 @@ class MyCustomTool(BaseTool):
return "Tool's result"
```
+### Typed Tool Outputs
+
+When a tool returns structured data, define a Pydantic output model. This gives the agent field names it can trust, such as `sku`, `quantity`, or `needs_reorder`.
+
+Direct Python calls still receive the value your tool returns. When an agent uses the tool, CrewAI sends the agent a JSON string based on the output model.
+
+```python Code
+from crewai.tools import BaseTool
+from pydantic import BaseModel
+
+class InventoryResult(BaseModel):
+ sku: str
+ quantity: int
+ needs_reorder: bool
+
+class InventoryTool(BaseTool):
+ name: str = "Inventory Check"
+ description: str = "Checks current stock for a product SKU."
+
+ def _run(self, sku: str) -> InventoryResult:
+ quantity = {"SKU-123": 14, "SKU-456": 0}.get(sku, 0)
+ return InventoryResult(sku=sku, quantity=quantity, needs_reorder=quantity < 5)
+
+tool = InventoryTool()
+
+# Direct calls receive the raw Pydantic object.
+result = tool.run(sku="SKU-123")
+print(result.quantity)
+```
+
+To send Markdown or another short text format to the agent, override `format_output_for_agent`. Direct calls to `tool.run(...)` still return the normal Python value.
+
+```python Code
+class InventoryTool(BaseTool):
+ name: str = "Inventory Check"
+ description: str = "Checks current stock for a product SKU."
+
+ def _run(self, sku: str) -> InventoryResult:
+ quantity = {"SKU-123": 14, "SKU-456": 0}.get(sku, 0)
+ return InventoryResult(sku=sku, quantity=quantity, needs_reorder=quantity < 5)
+
+ def format_output_for_agent(self, raw_result: object) -> str:
+ result = InventoryResult.model_validate(raw_result)
+ status = "reorder needed" if result.needs_reorder else "stock is healthy"
+ return f"{result.sku}: {result.quantity} units. {status}."
+```
+
+If you do not override `format_output_for_agent`, typed outputs are sent to the agent as JSON. Plain string results work as before.
+
## Asynchronous Tool Support
CrewAI supports asynchronous tools, allowing you to implement tools that perform non-blocking operations like network requests, file I/O, or other async operations without blocking the main execution thread.
diff --git a/docs/edge/en/enterprise/features/merged-step-card.mdx b/docs/edge/en/enterprise/features/merged-step-card.mdx
new file mode 100644
index 000000000..f0a3be647
--- /dev/null
+++ b/docs/edge/en/enterprise/features/merged-step-card.mdx
@@ -0,0 +1,82 @@
+---
+title: One Card per Step
+description: "Each step on the Studio canvas is a single card that combines the task and the agent that performs it."
+icon: "layer-group"
+mode: "wide"
+---
+
+## 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:
+
+- **The task** — what to do (name, description, expected output, and response format).
+- **The agent** — who does it (the assigned agent, its model, and its tools).
+
+An agent isn't an independent participant in your workflow — it's an attribute of the task: *which agent performs this work.* Putting the task and its agent on one card makes that relationship explicit and turns your automation into a single, left-to-right chain of work units that's easier to read at a glance.
+
+
+ 
+
+
+## On the canvas
+
+Each collapsed card shows:
+
+- The **task name and description** at the top.
+- A **footer summarizing the assigned agent** — avatar, name, model, and tools.
+
+There's no separate agent node and no vertical agent → task edge. Your steps connect directly to one another in the order they run.
+
+## In the editor
+
+Open a card to edit it. The expanded view is the same card in a detailed state — not a different screen — organized into two clearly labeled sections.
+
+
+ 
+
+
+### The task — what to do
+
+Open by default, since this is what you usually edit:
+
+- **Name**
+- **Description**
+- **Expected Output**
+- **Response Format** — surfaced here because it controls exactly what downstream steps (such as routing) read from this step.
+
+### The agent — who does it
+
+The assigned agent is shown as a summary — **name, model, and tools inline**. Its deeper configuration is preserved behind two disclosures:
+
+- **Role, goal & backstory**
+- **Agent settings** — reasoning, max reasoning attempts, allow delegation, max iterations, and LLM settings.
+
+
+ An agent's full configuration — Role, Goal, Backstory, Model, Tools, LLM Settings, and the complete Agent Settings block — lives behind the **Role, goal & backstory** and **Agent settings** disclosures, organized by how often you edit it.
+
+
+## Swapping vs. editing the agent
+
+There are two distinct ways to work with the agent on a card, and they do different things:
+
+- **Swap** reassigns *which* agent performs this task. Use the **Swap** control to pick a different agent from this project, choose one from your Agent Repository, or create a new agent. This is scoped to the task.
+- **Editing** the agent — opening **Role, goal & backstory** or **Agent settings** — changes the agent *itself*.
+
+
+ 
+
+
+
+ **Agents are reusable and shared.** The same agent can perform more than one task across your project. Editing an agent's role, backstory, or settings updates that agent **everywhere it's used** — not just on the card you opened. If you want a change to apply to only one step, **Swap** in a different agent instead of editing the shared one.
+
+
+## Related
+
+
+
+ Build automations with AI assistance and a visual editor.
+
+
+ Manage and reuse agents across your automations.
+
+
diff --git a/docs/edge/en/guides/flows/first-flow.mdx b/docs/edge/en/guides/flows/first-flow.mdx
index ad6638b77..d26a1eb2d 100644
--- a/docs/edge/en/guides/flows/first-flow.mdx
+++ b/docs/edge/en/guides/flows/first-flow.mdx
@@ -395,7 +395,7 @@ crewai install
Now it's time to see your flow in action! Run it using the CrewAI CLI:
```bash
-crewai flow kickoff
+crewai run
```
When you run this command, you'll see your flow spring to life:
diff --git a/docs/edge/en/guides/tools/publish-custom-tools.mdx b/docs/edge/en/guides/tools/publish-custom-tools.mdx
index 973856816..71dbfd622 100644
--- a/docs/edge/en/guides/tools/publish-custom-tools.mdx
+++ b/docs/edge/en/guides/tools/publish-custom-tools.mdx
@@ -65,7 +65,7 @@ Regardless of which approach you use, your tool must:
- Have a **`description`** — tells the agent when and how to use the tool. This directly affects how well agents use your tool, so be clear and specific.
- Implement **`_run`** (BaseTool) or provide a **function body** (@tool) — the synchronous execution logic.
- Use **type annotations** on all parameters and return values.
-- Return a **string** result (or something that can be meaningfully converted to one).
+- Return a **string** result, or define an optional Pydantic output schema for structured results.
### Optional: Async Support
@@ -104,6 +104,67 @@ class TranslateInput(BaseModel):
Explicit schemas are recommended for published tools — they produce better agent behavior and clearer documentation for your users.
+### Optional: Typed Outputs with `result_schema`
+
+If your tool returns structured data, define a Pydantic output model. This is a good default for published tools because users and agents can rely on named fields.
+
+Direct Python calls still receive the value your tool returns. When an agent uses the tool, CrewAI sends the agent JSON based on the output model.
+
+CrewAI can infer the output schema from a Pydantic return annotation:
+
+```python
+from crewai.tools import BaseTool
+from pydantic import BaseModel, Field
+
+
+class GeolocateResult(BaseModel):
+ latitude: float = Field(..., description="Latitude in decimal degrees.")
+ longitude: float = Field(..., description="Longitude in decimal degrees.")
+
+
+class GeolocateTool(BaseTool):
+ name: str = "Geolocate"
+ description: str = "Converts a street address into latitude/longitude coordinates."
+
+ def _run(self, address: str) -> GeolocateResult:
+ if "1600 Pennsylvania" in address:
+ return GeolocateResult(latitude=38.8977, longitude=-77.0365)
+ return GeolocateResult(latitude=40.7128, longitude=-74.0060)
+```
+
+Set `result_schema` explicitly when your tool returns a dictionary:
+
+```python
+class GeolocateTool(BaseTool):
+ name: str = "Geolocate"
+ description: str = "Converts a street address into latitude/longitude coordinates."
+ result_schema: type[BaseModel] = GeolocateResult
+
+ def _run(self, address: str) -> dict[str, float]:
+ if "1600 Pennsylvania" in address:
+ return {"latitude": 38.8977, "longitude": -77.0365}
+ return {"latitude": 40.7128, "longitude": -74.0060}
+```
+
+If agents should receive a short text summary instead of JSON, override `format_output_for_agent` on your `BaseTool` subclass.
+
+```python
+class GeolocateTool(BaseTool):
+ name: str = "Geolocate"
+ description: str = "Converts a street address into latitude/longitude coordinates."
+
+ def _run(self, address: str) -> GeolocateResult:
+ if "1600 Pennsylvania" in address:
+ return GeolocateResult(latitude=38.8977, longitude=-77.0365)
+ return GeolocateResult(latitude=40.7128, longitude=-74.0060)
+
+ def format_output_for_agent(self, raw_result: object) -> str:
+ result = GeolocateResult.model_validate(raw_result)
+ return f"Latitude {result.latitude}, longitude {result.longitude}"
+```
+
+The override only changes what the agent sees. Direct users of your package still receive the normal value from `tool.run(...)`.
+
### Optional: Environment Variables
If your tool requires API keys or other configuration, declare them with `env_vars` so users know what to set:
@@ -241,4 +302,4 @@ agent = Agent(
tools=[GeolocateTool()],
# ...
)
-```
\ No newline at end of file
+```
diff --git a/docs/edge/en/learn/create-custom-tools.mdx b/docs/edge/en/learn/create-custom-tools.mdx
index c1246f3fc..78205bf99 100644
--- a/docs/edge/en/learn/create-custom-tools.mdx
+++ b/docs/edge/en/learn/create-custom-tools.mdx
@@ -53,6 +53,111 @@ def my_simple_tool(question: str) -> str:
return "Tool output"
```
+### Best Practice: Define Typed Outputs
+
+When a tool returns structured data, define a Pydantic output model. This helps the agent read the result as clear fields instead of guessing from plain text.
+
+Typed outputs are useful for results with stable fields, such as IDs, status values, scores, prices, or lists. Plain strings are still fine for short prose results.
+
+Direct Python calls still receive the value your tool returns. When an agent uses a typed tool, CrewAI sends the agent JSON based on the output model.
+
+#### Return a Pydantic Model
+
+CrewAI infers the output schema when your `BaseTool` has a Pydantic return annotation.
+
+```python Code
+from crewai.tools import BaseTool
+from pydantic import BaseModel, Field
+
+class InventoryResult(BaseModel):
+ sku: str = Field(description="The product SKU.")
+ quantity: int = Field(description="Units available.")
+ needs_reorder: bool = Field(description="Whether the item should be reordered.")
+
+class InventoryTool(BaseTool):
+ name: str = "Inventory Check"
+ description: str = "Check current stock for a product SKU."
+
+ def _run(self, sku: str) -> InventoryResult:
+ quantity = {"SKU-123": 14, "SKU-456": 0}.get(sku, 0)
+ return InventoryResult(sku=sku, quantity=quantity, needs_reorder=quantity < 5)
+
+tool = InventoryTool()
+result = tool.run(sku="SKU-123")
+
+# Direct Python calls receive the raw Pydantic object.
+print(result.quantity)
+```
+
+When an agent calls `InventoryTool`, it receives JSON like this:
+
+```json
+{"sku":"SKU-123","quantity":14,"needs_reorder":false}
+```
+
+#### Use `result_schema` with Dictionary Results
+
+If your tool returns a dictionary, set `result_schema` explicitly. You can do this on a `BaseTool` subclass or with the `@tool` decorator:
+
+```python Code
+from crewai.tools import tool
+from pydantic import BaseModel, Field
+
+class ProductResult(BaseModel):
+ sku: str = Field(description="The product SKU.")
+ name: str = Field(description="The product name.")
+ in_stock: bool = Field(description="Whether the product is available.")
+
+@tool("Product Lookup", result_schema=ProductResult)
+def product_lookup(sku: str) -> dict[str, object]:
+ """Look up product availability by SKU."""
+ catalog = {
+ "SKU-123": ("Noise-canceling headset", True),
+ "SKU-456": ("USB-C dock", False),
+ }
+ name, in_stock = catalog.get(sku, ("Unknown product", False))
+ return {
+ "sku": sku,
+ "name": name,
+ "in_stock": in_stock,
+ }
+```
+
+#### Customize the Text Sent to the Agent
+
+By default, typed tool outputs are sent to the agent as JSON. If the agent should receive a short summary instead, subclass `BaseTool` and override `format_output_for_agent`.
+
+```python Code
+from crewai.tools import BaseTool
+from pydantic import BaseModel, Field
+
+class InventoryResult(BaseModel):
+ sku: str = Field(description="The product SKU.")
+ quantity: int = Field(description="Units available.")
+ needs_reorder: bool = Field(description="Whether the item should be reordered.")
+
+class InventoryTool(BaseTool):
+ name: str = "Inventory Check"
+ description: str = "Check current stock for a product SKU."
+
+ def _run(self, sku: str) -> InventoryResult:
+ quantity = {"SKU-123": 14, "SKU-456": 0}.get(sku, 0)
+ return InventoryResult(sku=sku, quantity=quantity, needs_reorder=quantity < 5)
+
+ def format_output_for_agent(self, raw_result: object) -> str:
+ result = InventoryResult.model_validate(raw_result)
+ status = "reorder needed" if result.needs_reorder else "stock is healthy"
+ return f"{result.sku}: {result.quantity} units. {status}."
+
+tool = InventoryTool()
+result = tool.run(sku="SKU-123")
+
+# Direct Python calls receive the raw Pydantic object.
+print(result.quantity)
+```
+
+The override only changes what the agent sees. Direct calls to `tool.run(...)` still return the normal Python value.
+
### Defining a Cache Function for the Tool
To optimize tool performance with caching, define custom caching strategies using the `cache_function` attribute.
diff --git a/docs/edge/en/learn/execution-hooks.mdx b/docs/edge/en/learn/execution-hooks.mdx
index 74234db97..f9f667e4f 100644
--- a/docs/edge/en/learn/execution-hooks.mdx
+++ b/docs/edge/en/learn/execution-hooks.mdx
@@ -195,9 +195,12 @@ class ToolCallHookContext:
agent: Agent | None # Agent executing
task: Task | None # Current task
crew: Crew | None # Crew instance
- tool_result: str | None # Tool result (after hooks)
+ tool_result: str | None # Agent-facing result string (after hooks)
+ raw_tool_result: Any | None # Raw Python result (after hooks)
```
+For typed tool outputs, `tool_result` is the string the agent sees. By default, this is JSON. If the tool uses custom formatting, it can be Markdown or another string. `raw_tool_result` is the original Python value returned by the tool.
+
## Common Patterns
### Safety and Validation
diff --git a/docs/edge/en/learn/tool-hooks.mdx b/docs/edge/en/learn/tool-hooks.mdx
index d1d727a5c..489463e81 100644
--- a/docs/edge/en/learn/tool-hooks.mdx
+++ b/docs/edge/en/learn/tool-hooks.mdx
@@ -60,9 +60,12 @@ class ToolCallHookContext:
agent: Agent | BaseAgent | None # Agent executing the tool
task: Task | None # Current task
crew: Crew | None # Crew instance
- tool_result: str | None # Tool result (after hooks only)
+ tool_result: str | None # Agent-facing result string (after hooks only)
+ raw_tool_result: Any | None # Raw Python result (after hooks only)
```
+For typed tool outputs, `tool_result` is the string the agent sees. By default, this is JSON. If the tool uses custom formatting, it can be Markdown or another string. Use `raw_tool_result` when your hook needs the typed object or dictionary.
+
### Modifying Tool Inputs
**Important:** Always modify tool inputs in-place:
diff --git a/docs/edge/ko/changelog.mdx b/docs/edge/ko/changelog.mdx
index aea038515..9488f8335 100644
--- a/docs/edge/ko/changelog.mdx
+++ b/docs/edge/ko/changelog.mdx
@@ -4,6 +4,83 @@ description: "CrewAI의 제품 업데이트, 개선 사항 및 버그 수정"
icon: "clock"
mode: "wide"
---
+
+ ## 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
+
+
+
+
+ ## v1.14.8a4
+
+ [GitHub 릴리스 보기](https://github.com/crewAIInc/crewAI/releases/tag/1.14.8a4)
+
+ ## 변경 사항
+
+ ### 기능
+ - CLI TUI에서 대화형 흐름 지원.
+
+ ### 버그 수정
+ - 기술 아카이브 추출 시 심볼릭 링크 경로 탐색 문제 수정.
+ - 선언적 흐름 정의 경로 검증.
+
+ ### 문서
+ - v1.14.8a3에 대한 스냅샷 및 변경 로그 업데이트.
+
+ ## 기여자
+
+ @lorenzejay, @theCyberTech, @vinibrsl
+
+
+
+
+ ## 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
+
+
+
## v1.14.8a2
@@ -64,7 +141,7 @@ mode: "wide"
- Python 코드 없이 Flow 정의 실행 도구 구현
- Flow 정의에서 인간 피드백 유도
- FlowDefinition의 구성 및 지속성을 런타임에 연결
- - 흐름을 위한 실험적 `crewai run --definition` 추가
+ - 선언적 흐름을 위한 `crewai run --definition` 추가
- ZIP 배포 대체 및 JSON 크루 프로젝트 환경 실행 지원
- JSON 우선 크루 도입
diff --git a/docs/edge/ko/concepts/flows.mdx b/docs/edge/ko/concepts/flows.mdx
index 91ca831fb..e168b7e3f 100644
--- a/docs/edge/ko/concepts/flows.mdx
+++ b/docs/edge/ko/concepts/flows.mdx
@@ -951,7 +951,7 @@ source .venv/bin/activate
가상 환경을 활성화한 후, 아래 명령어 중 하나를 실행하여 플로우를 실행할 수 있습니다:
```bash
-crewai flow kickoff
+crewai run
```
또는
@@ -1054,10 +1054,4 @@ crewai run
이 명령어는 프로젝트가 pyproject.toml의 `type = "flow"` 설정을 기반으로 flow인지 자동으로 감지하여 해당 방식으로 실행합니다. 명령줄에서 flow를 실행하는 권장 방법입니다.
-하위 호환성을 위해 다음 명령어도 사용할 수 있습니다:
-
-```shell
-crewai flow kickoff
-```
-
-하지만 `crewai run` 명령어가 이제 crew와 flow 모두에 작동하므로 더욱 선호되는 방법입니다.
+레거시 `crewai flow kickoff` 명령어는 deprecated되었습니다. crew와 flow 모두 `crewai run`을 사용하세요.
diff --git a/docs/edge/ko/enterprise/features/merged-step-card.mdx b/docs/edge/ko/enterprise/features/merged-step-card.mdx
new file mode 100644
index 000000000..d45301d56
--- /dev/null
+++ b/docs/edge/ko/enterprise/features/merged-step-card.mdx
@@ -0,0 +1,82 @@
+---
+title: 단계당 하나의 카드
+description: "Studio 캔버스의 각 단계는 작업과 이를 수행하는 에이전트를 하나로 결합한 단일 카드입니다."
+icon: "layer-group"
+mode: "wide"
+---
+
+## 개요
+
+Studio 캔버스에서 각 작업 단계는 **하나의 카드**로 표현됩니다. 이 카드는 이전에 별도의 노드에 있던 두 가지를 결합합니다:
+
+- **작업(Task)** — 무엇을 할지(이름, 설명, 예상 출력, 응답 형식).
+- **에이전트(Agent)** — 누가 수행하는지(할당된 에이전트, 모델, 도구).
+
+에이전트는 워크플로의 독립적인 참여자가 아니라 작업의 속성, 즉 *이 작업을 어떤 에이전트가 수행하는지*를 나타냅니다. 작업과 에이전트를 하나의 카드에 두면 이 관계가 명확해지고, 자동화가 왼쪽에서 오른쪽으로 이어지는 단일 작업 단위 체인이 되어 한눈에 읽기 쉬워집니다.
+
+
+ 
+
+
+## 캔버스에서
+
+접힌 각 카드는 다음을 표시합니다:
+
+- 상단의 **작업 이름과 설명**.
+- **할당된 에이전트를 요약한 푸터** — 아바타, 이름, 모델, 도구.
+
+별도의 에이전트 노드나 에이전트 → 작업 세로 연결선이 없습니다. 각 단계는 실행 순서대로 서로 직접 연결됩니다.
+
+## 에디터에서
+
+카드를 열어 편집합니다. 확장된 보기는 다른 화면이 아니라 동일한 카드의 상세 상태이며, 명확하게 구분된 두 개의 섹션으로 구성됩니다.
+
+
+ 
+
+
+### 작업 — 무엇을 할지
+
+가장 자주 편집하는 항목이므로 기본적으로 열려 있습니다:
+
+- **이름**
+- **설명**
+- **예상 출력**
+- **응답 형식** — 다운스트림 단계(예: 라우팅)가 이 단계에서 무엇을 읽을지 정확히 제어하므로 여기에 표시됩니다.
+
+### 에이전트 — 누가 수행하는지
+
+할당된 에이전트는 요약으로 표시됩니다 — **이름, 모델, 도구가 인라인으로** 표시됩니다. 더 깊은 구성은 두 개의 접이식 섹션 뒤에 보존됩니다:
+
+- **역할, 목표 및 배경 스토리**
+- **에이전트 설정** — 추론, 최대 추론 시도 횟수, 위임 허용, 최대 반복 횟수, LLM 설정.
+
+
+ 에이전트의 전체 구성 — 역할, 목표, 배경 스토리, 모델, 도구, LLM 설정 및 전체 에이전트 설정 블록 — 은 **역할, 목표 및 배경 스토리**와 **에이전트 설정** 접이식 섹션 뒤에 편집 빈도에 따라 정리되어 있습니다.
+
+
+## 에이전트 교체 vs. 편집
+
+카드에서 에이전트를 다루는 방식은 두 가지로 구분되며, 각각 다른 작업을 수행합니다:
+
+- **교체(Swap)** 는 *어떤* 에이전트가 이 작업을 수행할지 재할당합니다. **교체** 컨트롤을 사용하여 이 프로젝트의 다른 에이전트를 선택하거나, 에이전트 저장소에서 선택하거나, 새 에이전트를 만들 수 있습니다. 이는 작업 범위로 한정됩니다.
+- 에이전트 **편집** — **역할, 목표 및 배경 스토리** 또는 **에이전트 설정** 을 여는 것 — 은 에이전트 *자체*를 변경합니다.
+
+
+ 
+
+
+
+ **에이전트는 재사용 가능하며 공유됩니다.** 동일한 에이전트가 프로젝트 전반에서 둘 이상의 작업을 수행할 수 있습니다. 에이전트의 역할, 배경 스토리 또는 설정을 편집하면 열어 본 카드뿐만 아니라 **해당 에이전트가 사용되는 모든 곳**에서 업데이트됩니다. 변경 사항을 하나의 단계에만 적용하려면 공유 에이전트를 편집하지 말고 다른 에이전트로 **교체**하세요.
+
+
+## 관련 항목
+
+
+
+ AI 지원과 비주얼 에디터로 자동화를 구축합니다.
+
+
+ 자동화 전반에서 에이전트를 관리하고 재사용합니다.
+
+
diff --git a/docs/edge/ko/guides/flows/first-flow.mdx b/docs/edge/ko/guides/flows/first-flow.mdx
index b8a693086..04d0f3edf 100644
--- a/docs/edge/ko/guides/flows/first-flow.mdx
+++ b/docs/edge/ko/guides/flows/first-flow.mdx
@@ -393,7 +393,7 @@ crewai install
이제 여러분의 flow가 실제로 작동하는 모습을 볼 차례입니다! CrewAI CLI를 사용하여 flow를 실행하세요:
```bash
-crewai flow kickoff
+crewai run
```
이 명령어를 실행하면 flow가 다음과 같이 작동하는 것을 확인할 수 있습니다:
diff --git a/docs/edge/pt-BR/changelog.mdx b/docs/edge/pt-BR/changelog.mdx
index f611f48aa..32adbc9a1 100644
--- a/docs/edge/pt-BR/changelog.mdx
+++ b/docs/edge/pt-BR/changelog.mdx
@@ -4,6 +4,83 @@ description: "Atualizações de produto, melhorias e correções do CrewAI"
icon: "clock"
mode: "wide"
---
+
+ ## 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
+
+
+
+
+ ## 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
+
+
+
+
+ ## 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
+
+
+
## v1.14.8a2
@@ -64,7 +141,7 @@ mode: "wide"
- Implementar ferramentas de execução de definição de fluxo sem código Python
- Conduzir feedback humano a partir da definição de fluxo
- Conectar configuração e persistência do FlowDefinition ao tempo de execução
- - Adicionar `crewai run --definition` experimental para fluxos
+ - Adicionar `crewai run --definition` para fluxos declarativos
- Suportar fallback de implantação ZIP e execuções de projeto de equipe em JSON
- Introduzir equipes em JSON primeiro
diff --git a/docs/edge/pt-BR/concepts/flows.mdx b/docs/edge/pt-BR/concepts/flows.mdx
index 73ac5019a..8879edca8 100644
--- a/docs/edge/pt-BR/concepts/flows.mdx
+++ b/docs/edge/pt-BR/concepts/flows.mdx
@@ -948,7 +948,7 @@ source .venv/bin/activate
Com o ambiente ativado, execute o flow usando um dos comandos:
```bash
-crewai flow kickoff
+crewai run
```
ou
@@ -1052,10 +1052,4 @@ crewai run
O comando detecta automaticamente se seu projeto é um flow (com base na configuração `type = "flow"` no pyproject.toml) e executa conforme o esperado. Esse é o método recomendado para executar flows pelo terminal.
-Por compatibilidade retroativa, também é possível usar:
-
-```shell
-crewai flow kickoff
-```
-
-No entanto, o comando `crewai run` é agora o preferido, pois funciona tanto para crews quanto para flows.
+O comando legado `crewai flow kickoff` está deprecated. Use `crewai run` para crews e flows.
diff --git a/docs/edge/pt-BR/enterprise/features/merged-step-card.mdx b/docs/edge/pt-BR/enterprise/features/merged-step-card.mdx
new file mode 100644
index 000000000..3ca6cee60
--- /dev/null
+++ b/docs/edge/pt-BR/enterprise/features/merged-step-card.mdx
@@ -0,0 +1,82 @@
+---
+title: Um Card por Etapa
+description: "Cada etapa no canvas do Studio é um único card que combina a tarefa e o agente que a executa."
+icon: "layer-group"
+mode: "wide"
+---
+
+## 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:
+
+- **A tarefa** — o que fazer (nome, descrição, saída esperada e formato da resposta).
+- **O agente** — quem faz (o agente atribuído, seu modelo e suas ferramentas).
+
+Um agente não é um participante independente do seu fluxo de trabalho — ele é um atributo da tarefa: *qual agente executa este trabalho.* Colocar a tarefa e seu agente em um único card torna essa relação explícita e transforma sua automação em uma única cadeia de unidades de trabalho, da esquerda para a direita, mais fácil de ler em uma olhada.
+
+
+ 
+
+
+## No canvas
+
+Cada card recolhido mostra:
+
+- O **nome e a descrição da tarefa** no topo.
+- Um **rodapé resumindo o agente atribuído** — avatar, nome, modelo e ferramentas.
+
+Não há nó de agente separado nem aresta vertical de agente → tarefa. Suas etapas se conectam diretamente umas às outras na ordem em que são executadas.
+
+## No editor
+
+Abra um card para editá-lo. A visão expandida é o mesmo card em um estado detalhado — não uma tela diferente — organizada em duas seções claramente identificadas.
+
+
+ 
+
+
+### A tarefa — o que fazer
+
+Aberta por padrão, já que é o que você costuma editar:
+
+- **Nome**
+- **Descrição**
+- **Saída Esperada**
+- **Formato da Resposta** — exibido aqui porque controla exatamente o que as etapas seguintes (como o roteamento) leem desta etapa.
+
+### O agente — quem faz
+
+O agente atribuído é mostrado como um resumo — **nome, modelo e ferramentas em linha**. Sua configuração mais detalhada é preservada por trás de duas seções recolhíveis:
+
+- **Papel, objetivo e história**
+- **Configurações do agente** — raciocínio, máximo de tentativas de raciocínio, permitir delegação, máximo de iterações e configurações de LLM.
+
+
+ A configuração completa de um agente — Papel, Objetivo, História, Modelo, Ferramentas, Configurações de LLM e todo o bloco de Configurações do agente — fica por trás das seções recolhíveis **Papel, objetivo e história** e **Configurações do agente**, organizada pela frequência com que você a edita.
+
+
+## Trocar vs. editar o agente
+
+Há duas maneiras distintas de trabalhar com o agente em um card, e elas fazem coisas diferentes:
+
+- **Trocar** reatribui *qual* agente executa esta tarefa. Use o controle **Trocar** para escolher um agente diferente deste projeto, selecionar um do seu Repositório de Agentes ou criar um novo agente. Isso tem escopo limitado à tarefa.
+- **Editar** o agente — abrindo **Papel, objetivo e história** ou **Configurações do agente** — altera o agente *em si*.
+
+
+ 
+
+
+
+ **Os agentes são reutilizáveis e compartilhados.** O mesmo agente pode executar mais de uma tarefa em todo o seu projeto. Editar o papel, a história ou as configurações de um agente atualiza esse agente **em todos os lugares onde ele é usado** — não apenas no card que você abriu. Se quiser que uma alteração se aplique a apenas uma etapa, **Troque** por um agente diferente em vez de editar o agente compartilhado.
+
+
+## Relacionados
+
+
+
+ Crie automações com assistência de IA e um editor visual.
+
+
+ Gerencie e reutilize agentes em suas automações.
+
+
diff --git a/docs/edge/pt-BR/guides/flows/first-flow.mdx b/docs/edge/pt-BR/guides/flows/first-flow.mdx
index 0069cf85f..0ff1ba5d6 100644
--- a/docs/edge/pt-BR/guides/flows/first-flow.mdx
+++ b/docs/edge/pt-BR/guides/flows/first-flow.mdx
@@ -393,7 +393,7 @@ crewai install
Agora é hora de ver seu flow em ação! Execute-o usando a CLI do CrewAI:
```bash
-crewai flow kickoff
+crewai run
```
Quando você rodar esse comando, verá seu flow ganhando vida:
diff --git a/docs/images/enterprise/merged-step-card-canvas.png b/docs/images/enterprise/merged-step-card-canvas.png
new file mode 100644
index 000000000..a737acbff
Binary files /dev/null and b/docs/images/enterprise/merged-step-card-canvas.png differ
diff --git a/docs/images/enterprise/merged-step-card-editor.png b/docs/images/enterprise/merged-step-card-editor.png
new file mode 100644
index 000000000..40d529ede
Binary files /dev/null and b/docs/images/enterprise/merged-step-card-editor.png differ
diff --git a/docs/images/enterprise/merged-step-card-swap-agent.png b/docs/images/enterprise/merged-step-card-swap-agent.png
new file mode 100644
index 000000000..977bf9fc0
Binary files /dev/null and b/docs/images/enterprise/merged-step-card-swap-agent.png differ
diff --git a/docs/v1.14.7/ar/enterprise/features/merged-step-card.mdx b/docs/v1.14.7/ar/enterprise/features/merged-step-card.mdx
new file mode 100644
index 000000000..776edfe10
--- /dev/null
+++ b/docs/v1.14.7/ar/enterprise/features/merged-step-card.mdx
@@ -0,0 +1,82 @@
+---
+title: بطاقة واحدة لكل خطوة
+description: "كل خطوة على لوحة Studio هي بطاقة واحدة تجمع بين المهمة والوكيل الذي ينفّذها."
+icon: "layer-group"
+mode: "wide"
+---
+
+## نظرة عامة
+
+على لوحة Studio، تُمثَّل كل خطوة عمل بـ **بطاقة واحدة**. تجمع البطاقة بين عنصرين كانا في السابق في عُقد منفصلة:
+
+- **المهمة** — ماذا تفعل (الاسم، الوصف، المخرجات المتوقعة، وتنسيق الاستجابة).
+- **الوكيل** — من ينفّذها (الوكيل المُعيَّن ونموذجه وأدواته).
+
+الوكيل ليس مشاركًا مستقلاً في سير العمل لديك — بل هو سمة من سمات المهمة: *أي وكيل ينفّذ هذا العمل.* وضع المهمة والوكيل في بطاقة واحدة يجعل هذه العلاقة واضحة، ويحوّل أتمتتك إلى سلسلة واحدة من وحدات العمل من اليسار إلى اليمين يسهل قراءتها بنظرة واحدة.
+
+
+ 
+
+
+## على اللوحة
+
+تعرض كل بطاقة مطوية ما يلي:
+
+- **اسم المهمة ووصفها** في الأعلى.
+- **تذييل يلخّص الوكيل المُعيَّن** — الصورة الرمزية والاسم والنموذج والأدوات.
+
+لا توجد عقدة وكيل منفصلة ولا حافة عمودية من الوكيل ← المهمة. تتصل خطواتك مباشرةً ببعضها البعض بالترتيب الذي تُنفَّذ به.
+
+## في المحرّر
+
+افتح بطاقة لتحريرها. العرض الموسّع هو البطاقة نفسها في حالة مفصّلة — وليس شاشة مختلفة — منظّمة في قسمين موسومين بوضوح.
+
+
+ 
+
+
+### المهمة — ماذا تفعل
+
+مفتوحة افتراضيًا، لأنها ما تحرّره عادةً:
+
+- **الاسم**
+- **الوصف**
+- **المخرجات المتوقعة**
+- **تنسيق الاستجابة** — يظهر هنا لأنه يتحكم تحديدًا في ما تقرأه الخطوات اللاحقة (مثل التوجيه) من هذه الخطوة.
+
+### الوكيل — من ينفّذها
+
+يُعرض الوكيل المُعيَّن كملخّص — **الاسم والنموذج والأدوات في سطر واحد**. ويُحفَظ إعداده الأعمق خلف قسمين قابلين للطي:
+
+- **الدور والهدف والخلفية**
+- **إعدادات الوكيل** — الاستدلال، الحد الأقصى لمحاولات الاستدلال، السماح بالتفويض، الحد الأقصى للتكرارات، وإعدادات LLM.
+
+
+ الإعداد الكامل للوكيل — الدور، الهدف، الخلفية، النموذج، الأدوات، إعدادات LLM، وكامل كتلة إعدادات الوكيل — موجود خلف القسمين القابلين للطي **الدور والهدف والخلفية** و**إعدادات الوكيل**، منظّمًا حسب عدد مرّات تحريرك له.
+
+
+## التبديل مقابل تحرير الوكيل
+
+هناك طريقتان متمايزتان للتعامل مع الوكيل في البطاقة، وكل منهما تؤدي وظيفة مختلفة:
+
+- **التبديل (Swap)** يعيد تعيين *أي* وكيل ينفّذ هذه المهمة. استخدم عنصر التحكم **تبديل** لاختيار وكيل مختلف من هذا المشروع، أو اختيار واحد من مستودع الوكلاء، أو إنشاء وكيل جديد. هذا مقصور على نطاق المهمة.
+- **تحرير** الوكيل — بفتح **الدور والهدف والخلفية** أو **إعدادات الوكيل** — يغيّر الوكيل *نفسه*.
+
+
+ 
+
+
+
+ **الوكلاء قابلون لإعادة الاستخدام ومشتركون.** يمكن للوكيل نفسه تنفيذ أكثر من مهمة عبر مشروعك. تحرير دور الوكيل أو خلفيته أو إعداداته يحدّث ذلك الوكيل **في كل مكان يُستخدم فيه** — وليس فقط في البطاقة التي فتحتها. إذا أردت تطبيق تغيير على خطوة واحدة فقط، فقم **بالتبديل** إلى وكيل مختلف بدلاً من تحرير الوكيل المشترك.
+
+
+## ذات صلة
+
+
+
+ أنشئ الأتمتة بمساعدة الذكاء الاصطناعي ومحرّر مرئي.
+
+
+ إدارة الوكلاء وإعادة استخدامهم عبر أتمتتك.
+
+
diff --git a/docs/v1.14.7/en/enterprise/features/merged-step-card.mdx b/docs/v1.14.7/en/enterprise/features/merged-step-card.mdx
new file mode 100644
index 000000000..f0a3be647
--- /dev/null
+++ b/docs/v1.14.7/en/enterprise/features/merged-step-card.mdx
@@ -0,0 +1,82 @@
+---
+title: One Card per Step
+description: "Each step on the Studio canvas is a single card that combines the task and the agent that performs it."
+icon: "layer-group"
+mode: "wide"
+---
+
+## 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:
+
+- **The task** — what to do (name, description, expected output, and response format).
+- **The agent** — who does it (the assigned agent, its model, and its tools).
+
+An agent isn't an independent participant in your workflow — it's an attribute of the task: *which agent performs this work.* Putting the task and its agent on one card makes that relationship explicit and turns your automation into a single, left-to-right chain of work units that's easier to read at a glance.
+
+
+ 
+
+
+## On the canvas
+
+Each collapsed card shows:
+
+- The **task name and description** at the top.
+- A **footer summarizing the assigned agent** — avatar, name, model, and tools.
+
+There's no separate agent node and no vertical agent → task edge. Your steps connect directly to one another in the order they run.
+
+## In the editor
+
+Open a card to edit it. The expanded view is the same card in a detailed state — not a different screen — organized into two clearly labeled sections.
+
+
+ 
+
+
+### The task — what to do
+
+Open by default, since this is what you usually edit:
+
+- **Name**
+- **Description**
+- **Expected Output**
+- **Response Format** — surfaced here because it controls exactly what downstream steps (such as routing) read from this step.
+
+### The agent — who does it
+
+The assigned agent is shown as a summary — **name, model, and tools inline**. Its deeper configuration is preserved behind two disclosures:
+
+- **Role, goal & backstory**
+- **Agent settings** — reasoning, max reasoning attempts, allow delegation, max iterations, and LLM settings.
+
+
+ An agent's full configuration — Role, Goal, Backstory, Model, Tools, LLM Settings, and the complete Agent Settings block — lives behind the **Role, goal & backstory** and **Agent settings** disclosures, organized by how often you edit it.
+
+
+## Swapping vs. editing the agent
+
+There are two distinct ways to work with the agent on a card, and they do different things:
+
+- **Swap** reassigns *which* agent performs this task. Use the **Swap** control to pick a different agent from this project, choose one from your Agent Repository, or create a new agent. This is scoped to the task.
+- **Editing** the agent — opening **Role, goal & backstory** or **Agent settings** — changes the agent *itself*.
+
+
+ 
+
+
+
+ **Agents are reusable and shared.** The same agent can perform more than one task across your project. Editing an agent's role, backstory, or settings updates that agent **everywhere it's used** — not just on the card you opened. If you want a change to apply to only one step, **Swap** in a different agent instead of editing the shared one.
+
+
+## Related
+
+
+
+ Build automations with AI assistance and a visual editor.
+
+
+ Manage and reuse agents across your automations.
+
+
diff --git a/docs/v1.14.7/ko/enterprise/features/merged-step-card.mdx b/docs/v1.14.7/ko/enterprise/features/merged-step-card.mdx
new file mode 100644
index 000000000..d45301d56
--- /dev/null
+++ b/docs/v1.14.7/ko/enterprise/features/merged-step-card.mdx
@@ -0,0 +1,82 @@
+---
+title: 단계당 하나의 카드
+description: "Studio 캔버스의 각 단계는 작업과 이를 수행하는 에이전트를 하나로 결합한 단일 카드입니다."
+icon: "layer-group"
+mode: "wide"
+---
+
+## 개요
+
+Studio 캔버스에서 각 작업 단계는 **하나의 카드**로 표현됩니다. 이 카드는 이전에 별도의 노드에 있던 두 가지를 결합합니다:
+
+- **작업(Task)** — 무엇을 할지(이름, 설명, 예상 출력, 응답 형식).
+- **에이전트(Agent)** — 누가 수행하는지(할당된 에이전트, 모델, 도구).
+
+에이전트는 워크플로의 독립적인 참여자가 아니라 작업의 속성, 즉 *이 작업을 어떤 에이전트가 수행하는지*를 나타냅니다. 작업과 에이전트를 하나의 카드에 두면 이 관계가 명확해지고, 자동화가 왼쪽에서 오른쪽으로 이어지는 단일 작업 단위 체인이 되어 한눈에 읽기 쉬워집니다.
+
+
+ 
+
+
+## 캔버스에서
+
+접힌 각 카드는 다음을 표시합니다:
+
+- 상단의 **작업 이름과 설명**.
+- **할당된 에이전트를 요약한 푸터** — 아바타, 이름, 모델, 도구.
+
+별도의 에이전트 노드나 에이전트 → 작업 세로 연결선이 없습니다. 각 단계는 실행 순서대로 서로 직접 연결됩니다.
+
+## 에디터에서
+
+카드를 열어 편집합니다. 확장된 보기는 다른 화면이 아니라 동일한 카드의 상세 상태이며, 명확하게 구분된 두 개의 섹션으로 구성됩니다.
+
+
+ 
+
+
+### 작업 — 무엇을 할지
+
+가장 자주 편집하는 항목이므로 기본적으로 열려 있습니다:
+
+- **이름**
+- **설명**
+- **예상 출력**
+- **응답 형식** — 다운스트림 단계(예: 라우팅)가 이 단계에서 무엇을 읽을지 정확히 제어하므로 여기에 표시됩니다.
+
+### 에이전트 — 누가 수행하는지
+
+할당된 에이전트는 요약으로 표시됩니다 — **이름, 모델, 도구가 인라인으로** 표시됩니다. 더 깊은 구성은 두 개의 접이식 섹션 뒤에 보존됩니다:
+
+- **역할, 목표 및 배경 스토리**
+- **에이전트 설정** — 추론, 최대 추론 시도 횟수, 위임 허용, 최대 반복 횟수, LLM 설정.
+
+
+ 에이전트의 전체 구성 — 역할, 목표, 배경 스토리, 모델, 도구, LLM 설정 및 전체 에이전트 설정 블록 — 은 **역할, 목표 및 배경 스토리**와 **에이전트 설정** 접이식 섹션 뒤에 편집 빈도에 따라 정리되어 있습니다.
+
+
+## 에이전트 교체 vs. 편집
+
+카드에서 에이전트를 다루는 방식은 두 가지로 구분되며, 각각 다른 작업을 수행합니다:
+
+- **교체(Swap)** 는 *어떤* 에이전트가 이 작업을 수행할지 재할당합니다. **교체** 컨트롤을 사용하여 이 프로젝트의 다른 에이전트를 선택하거나, 에이전트 저장소에서 선택하거나, 새 에이전트를 만들 수 있습니다. 이는 작업 범위로 한정됩니다.
+- 에이전트 **편집** — **역할, 목표 및 배경 스토리** 또는 **에이전트 설정** 을 여는 것 — 은 에이전트 *자체*를 변경합니다.
+
+
+ 
+
+
+
+ **에이전트는 재사용 가능하며 공유됩니다.** 동일한 에이전트가 프로젝트 전반에서 둘 이상의 작업을 수행할 수 있습니다. 에이전트의 역할, 배경 스토리 또는 설정을 편집하면 열어 본 카드뿐만 아니라 **해당 에이전트가 사용되는 모든 곳**에서 업데이트됩니다. 변경 사항을 하나의 단계에만 적용하려면 공유 에이전트를 편집하지 말고 다른 에이전트로 **교체**하세요.
+
+
+## 관련 항목
+
+
+
+ AI 지원과 비주얼 에디터로 자동화를 구축합니다.
+
+
+ 자동화 전반에서 에이전트를 관리하고 재사용합니다.
+
+
diff --git a/docs/v1.14.7/pt-BR/enterprise/features/merged-step-card.mdx b/docs/v1.14.7/pt-BR/enterprise/features/merged-step-card.mdx
new file mode 100644
index 000000000..3ca6cee60
--- /dev/null
+++ b/docs/v1.14.7/pt-BR/enterprise/features/merged-step-card.mdx
@@ -0,0 +1,82 @@
+---
+title: Um Card por Etapa
+description: "Cada etapa no canvas do Studio é um único card que combina a tarefa e o agente que a executa."
+icon: "layer-group"
+mode: "wide"
+---
+
+## 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:
+
+- **A tarefa** — o que fazer (nome, descrição, saída esperada e formato da resposta).
+- **O agente** — quem faz (o agente atribuído, seu modelo e suas ferramentas).
+
+Um agente não é um participante independente do seu fluxo de trabalho — ele é um atributo da tarefa: *qual agente executa este trabalho.* Colocar a tarefa e seu agente em um único card torna essa relação explícita e transforma sua automação em uma única cadeia de unidades de trabalho, da esquerda para a direita, mais fácil de ler em uma olhada.
+
+
+ 
+
+
+## No canvas
+
+Cada card recolhido mostra:
+
+- O **nome e a descrição da tarefa** no topo.
+- Um **rodapé resumindo o agente atribuído** — avatar, nome, modelo e ferramentas.
+
+Não há nó de agente separado nem aresta vertical de agente → tarefa. Suas etapas se conectam diretamente umas às outras na ordem em que são executadas.
+
+## No editor
+
+Abra um card para editá-lo. A visão expandida é o mesmo card em um estado detalhado — não uma tela diferente — organizada em duas seções claramente identificadas.
+
+
+ 
+
+
+### A tarefa — o que fazer
+
+Aberta por padrão, já que é o que você costuma editar:
+
+- **Nome**
+- **Descrição**
+- **Saída Esperada**
+- **Formato da Resposta** — exibido aqui porque controla exatamente o que as etapas seguintes (como o roteamento) leem desta etapa.
+
+### O agente — quem faz
+
+O agente atribuído é mostrado como um resumo — **nome, modelo e ferramentas em linha**. Sua configuração mais detalhada é preservada por trás de duas seções recolhíveis:
+
+- **Papel, objetivo e história**
+- **Configurações do agente** — raciocínio, máximo de tentativas de raciocínio, permitir delegação, máximo de iterações e configurações de LLM.
+
+
+ A configuração completa de um agente — Papel, Objetivo, História, Modelo, Ferramentas, Configurações de LLM e todo o bloco de Configurações do agente — fica por trás das seções recolhíveis **Papel, objetivo e história** e **Configurações do agente**, organizada pela frequência com que você a edita.
+
+
+## Trocar vs. editar o agente
+
+Há duas maneiras distintas de trabalhar com o agente em um card, e elas fazem coisas diferentes:
+
+- **Trocar** reatribui *qual* agente executa esta tarefa. Use o controle **Trocar** para escolher um agente diferente deste projeto, selecionar um do seu Repositório de Agentes ou criar um novo agente. Isso tem escopo limitado à tarefa.
+- **Editar** o agente — abrindo **Papel, objetivo e história** ou **Configurações do agente** — altera o agente *em si*.
+
+
+ 
+
+
+
+ **Os agentes são reutilizáveis e compartilhados.** O mesmo agente pode executar mais de uma tarefa em todo o seu projeto. Editar o papel, a história ou as configurações de um agente atualiza esse agente **em todos os lugares onde ele é usado** — não apenas no card que você abriu. Se quiser que uma alteração se aplique a apenas uma etapa, **Troque** por um agente diferente em vez de editar o agente compartilhado.
+
+
+## Relacionados
+
+
+
+ Crie automações com assistência de IA e um editor visual.
+
+
+ Gerencie e reutilize agentes em suas automações.
+
+
diff --git a/lib/cli/pyproject.toml b/lib/cli/pyproject.toml
index f1daca897..320442750 100644
--- a/lib/cli/pyproject.toml
+++ b/lib/cli/pyproject.toml
@@ -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",
diff --git a/lib/cli/src/crewai_cli/__init__.py b/lib/cli/src/crewai_cli/__init__.py
index 41eae5edd..b47cad7ab 100644
--- a/lib/cli/src/crewai_cli/__init__.py
+++ b/lib/cli/src/crewai_cli/__init__.py
@@ -1 +1 @@
-__version__ = "1.14.8a2"
+__version__ = "1.14.8a5"
diff --git a/lib/cli/src/crewai_cli/cli.py b/lib/cli/src/crewai_cli/cli.py
index b153885f3..30eccdc61 100644
--- a/lib/cli/src/crewai_cli/cli.py
+++ b/lib/cli/src/crewai_cli/cli.py
@@ -40,14 +40,6 @@ def replay_task_command(*args: Any, **kwargs: Any) -> Any:
return _replay_task_command(*args, **kwargs)
-def run_flow_definition(*args: Any, **kwargs: Any) -> Any:
- from crewai_cli.run_flow_definition import (
- run_flow_definition as _run_flow_definition,
- )
-
- return _run_flow_definition(*args, **kwargs)
-
-
def run_crew(*args: Any, **kwargs: Any) -> Any:
from crewai_cli.run_crew import run_crew as _run_crew
@@ -155,12 +147,18 @@ def uv(uv_args: tuple[str, ...]) -> None:
is_flag=True,
help="Use classic Python/YAML project structure instead of JSON",
)
+@click.option(
+ "--declarative",
+ is_flag=True,
+ help="Create a declarative Flow project instead of a Python Flow project",
+)
def create(
type: str | None,
name: str | None,
provider: str | None,
skip_provider: bool = False,
classic: bool = False,
+ declarative: bool = False,
) -> None:
"""Create a new crew, or flow."""
dmn_mode = is_dmn_mode_enabled()
@@ -194,6 +192,8 @@ def create(
if dmn_mode:
skip_provider = True
if type == "crew":
+ if declarative:
+ raise click.UsageError("--declarative can only be used with flow projects")
if classic:
from crewai_cli.create_crew import create_crew
@@ -205,7 +205,7 @@ def create(
elif type == "flow":
from crewai_cli.create_flow import create_flow
- create_flow(name)
+ create_flow(name, declarative=declarative)
else:
click.secho("Error: Invalid type. Must be 'crew' or 'flow'.", fg="red")
@@ -468,7 +468,7 @@ def memory(
type=str,
default=None,
help=(
- "Path to a trained-agents pickle (produced by `crewai train -f`). "
+ "Crew-only: path to a trained-agents pickle (produced by `crewai train -f`). "
"When set, agents load suggestions from this file instead of the "
"default trained_agents_data.pkl. Equivalent to setting "
"CREWAI_TRAINED_AGENTS_FILE."
@@ -512,16 +512,13 @@ def install(context: click.Context) -> None:
"--definition",
type=str,
default=None,
- help=(
- "Experimental: path to a Flow Definition YAML/JSON file, "
- "or an inline YAML/JSON string."
- ),
+ help="Flow-only: path to a declarative flow definition.",
)
@click.option(
"--inputs",
type=str,
default=None,
- help='Experimental: JSON object passed to flow.kickoff(), e.g. \'{"topic":"AI"}\'.',
+ help='Flow-only: JSON object passed to the declarative flow, e.g. \'{"topic":"AI"}\'.',
)
def run(
trained_agents_file: str | None,
@@ -531,16 +528,14 @@ def run(
"""Run the Crew or Flow."""
if inputs is not None and definition is None:
raise click.UsageError("--inputs requires --definition")
+ if trained_agents_file is not None and definition is not None:
+ raise click.UsageError("--filename can only be used when running crews")
- if definition is not None:
- click.secho(
- "Warning: `crewai run --definition` is experimental and may change without notice.",
- fg="yellow",
- )
- run_flow_definition(definition=definition, inputs=inputs)
- return
-
- run_crew(trained_agents_file=trained_agents_file)
+ run_crew(
+ trained_agents_file=trained_agents_file,
+ definition=definition,
+ inputs=inputs,
+ )
@crewai.command()
@@ -795,10 +790,11 @@ def flow() -> None:
@flow.command(name="kickoff")
def flow_run() -> None:
"""Kickoff the Flow."""
- from crewai_cli.kickoff_flow import kickoff_flow
-
- click.echo("Running the Flow")
- kickoff_flow()
+ click.secho(
+ "The command 'crewai flow kickoff' is deprecated. Use 'crewai run' instead.",
+ fg="yellow",
+ )
+ run_crew(trained_agents_file=None, definition=None, inputs=None)
@flow.command(name="plot")
diff --git a/lib/cli/src/crewai_cli/create_flow.py b/lib/cli/src/crewai_cli/create_flow.py
index 5042d7679..adaa3d3bf 100644
--- a/lib/cli/src/crewai_cli/create_flow.py
+++ b/lib/cli/src/crewai_cli/create_flow.py
@@ -5,7 +5,10 @@ import click
from crewai_core.telemetry import Telemetry
-def create_flow(name: str) -> None:
+DECLARATIVE_FLOW_FOLDERS = ("crews", "tools", "knowledge", "skills")
+
+
+def create_flow(name: str, *, declarative: bool = False) -> None:
"""Create a new flow."""
folder_name = name.replace(" ", "_").replace("-", "_").lower()
class_name = name.replace("_", " ").replace("-", " ").title().replace(" ", "")
@@ -20,6 +23,17 @@ def create_flow(name: str) -> None:
telemetry = Telemetry()
telemetry.flow_creation_span(class_name)
+ if declarative:
+ _create_declarative_flow(name, class_name, folder_name, project_root)
+ else:
+ _create_python_flow(name, class_name, folder_name, project_root)
+
+ click.secho(f"Flow {name} created successfully!", fg="green", bold=True)
+
+
+def _create_python_flow(
+ name: str, class_name: str, folder_name: str, project_root: Path
+) -> None:
(project_root / "src" / folder_name).mkdir(parents=True)
(project_root / "src" / folder_name / "crews").mkdir(parents=True)
(project_root / "src" / folder_name / "tools").mkdir(parents=True)
@@ -92,4 +106,41 @@ def create_flow(name: str) -> None:
fg="yellow",
)
- click.secho(f"Flow {name} created successfully!", fg="green", bold=True)
+
+def _create_declarative_flow(
+ name: str, class_name: str, folder_name: str, project_root: Path
+) -> None:
+ project_root.mkdir(parents=True)
+ package_root = project_root / "src" / folder_name
+ package_root.mkdir(parents=True)
+ for folder in DECLARATIVE_FLOW_FOLDERS:
+ (package_root / folder).mkdir()
+
+ package_dir = Path(__file__).parent
+ templates_dir = package_dir / "templates" / "declarative_flow"
+
+ agents_md_src = package_dir / "templates" / "AGENTS.md"
+ if agents_md_src.exists():
+ shutil.copy2(agents_md_src, project_root / "AGENTS.md")
+
+ for src_file in templates_dir.rglob("*"):
+ if not src_file.is_file():
+ continue
+
+ relative_path = src_file.relative_to(templates_dir)
+ dst_file = (
+ project_root / relative_path
+ if relative_path.name in {".gitignore", "README.md", "pyproject.toml"}
+ else package_root / relative_path
+ )
+ dst_file.parent.mkdir(parents=True, exist_ok=True)
+ content = src_file.read_text(encoding="utf-8")
+ content = content.replace("{{name}}", name)
+ content = content.replace("{{flow_name}}", class_name)
+ content = content.replace("{{folder_name}}", folder_name)
+ dst_file.write_text(content, encoding="utf-8")
+
+ (project_root / ".env").write_text("OPENAI_API_KEY=YOUR_API_KEY", encoding="utf-8")
+ (package_root / "__init__.py").write_text("", encoding="utf-8")
+ for folder in DECLARATIVE_FLOW_FOLDERS:
+ (package_root / folder / ".gitkeep").write_text("", encoding="utf-8")
diff --git a/lib/cli/src/crewai_cli/create_json_crew.py b/lib/cli/src/crewai_cli/create_json_crew.py
index 1df9f0310..9cc0c3787 100644
--- a/lib/cli/src/crewai_cli/create_json_crew.py
+++ b/lib/cli/src/crewai_cli/create_json_crew.py
@@ -680,7 +680,7 @@ def _default_agents_and_tasks(
]
crew_settings = {
"process": "sequential",
- "memory": False,
+ "memory": True,
"inputs": {},
}
return agents, tasks, crew_settings
diff --git a/lib/cli/src/crewai_cli/crew_run_tui.py b/lib/cli/src/crewai_cli/crew_run_tui.py
index 9b3930350..81aae6c47 100644
--- a/lib/cli/src/crewai_cli/crew_run_tui.py
+++ b/lib/cli/src/crewai_cli/crew_run_tui.py
@@ -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")
diff --git a/lib/cli/src/crewai_cli/deploy/archive.py b/lib/cli/src/crewai_cli/deploy/archive.py
index 14835579d..38b733fe4 100644
--- a/lib/cli/src/crewai_cli/deploy/archive.py
+++ b/lib/cli/src/crewai_cli/deploy/archive.py
@@ -1,15 +1,11 @@
from __future__ import annotations
from pathlib import Path
-import re
import shutil
import tempfile
-from typing import Any
import zipfile
from crewai_cli import git
-from crewai_cli.deploy.validate import normalize_package_name
-from crewai_cli.utils import parse_toml
_EXCLUDED_DIRS = {
@@ -38,8 +34,6 @@ _EXCLUDED_SUFFIXES = {
".pyc",
".pyo",
}
-_SCRIPT_KEY_PATTERN = re.compile(r"^\s*(?P[A-Za-z0-9_.-]+|\"[^\"]+\"|'[^']+')\s*=")
-_SECTION_PATTERN = re.compile(r"^\s*\[[^\]]+\]\s*(?:#.*)?$")
def create_project_zip(
@@ -143,267 +137,7 @@ def _stage_project(root: Path, files: list[Path]) -> Path:
destination = staging_root / relative_path
destination.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(source, destination)
-
- if _is_json_crew_project(staging_root):
- _add_json_crew_deploy_wrapper(staging_root)
except Exception:
shutil.rmtree(staging_root, ignore_errors=True)
raise
return staging_root
-
-
-def _is_json_crew_project(root: Path) -> bool:
- """Return True for JSON crew projects that need a Python deploy wrapper."""
- if not ((root / "crew.jsonc").is_file() or (root / "crew.json").is_file()):
- return False
-
- project = _read_pyproject(root)
- tool_config = project.get("tool") or {}
- crewai_config = tool_config.get("crewai") if isinstance(tool_config, dict) else None
- declared_type = (
- crewai_config.get("type") if isinstance(crewai_config, dict) else None
- )
- if declared_type == "flow":
- return False
-
- package_name = _package_name(root)
- if package_name is None:
- raise ValueError(
- "Could not derive a valid Python package name from [project].name."
- )
-
- return not (root / "src" / package_name / "crew.py").is_file()
-
-
-def _read_pyproject(root: Path) -> dict[str, Any]:
- """Read pyproject.toml, returning an empty mapping on missing or invalid data."""
- pyproject_path = root / "pyproject.toml"
- if not pyproject_path.is_file():
- return {}
- try:
- pyproject = parse_toml(pyproject_path.read_text())
- except Exception:
- return {}
- return pyproject if isinstance(pyproject, dict) else {}
-
-
-def _package_name(root: Path) -> str | None:
- """Return the normalized Python package name for the project."""
- project = _read_pyproject(root).get("project")
- if not isinstance(project, dict):
- return None
-
- name = project.get("name")
- if not isinstance(name, str) or not name.strip():
- return None
-
- package_name = normalize_package_name(name)
- return package_name or None
-
-
-def _class_name(package_name: str) -> str:
- """Return the generated wrapper class name for a package."""
- parts = [part for part in re.split(r"[^a-zA-Z0-9]+", package_name) if part]
- class_name = "".join(part[:1].upper() + part[1:] for part in parts)
- if not class_name:
- return "JsonCrew"
- if class_name[0].isdigit():
- return f"Crew{class_name}"
- return class_name
-
-
-def _add_json_crew_deploy_wrapper(root: Path) -> None:
- """Add Python wrapper files required to deploy a JSON crew project."""
- package_name = _package_name(root)
- if package_name is None:
- raise ValueError(
- "Could not derive a valid Python package name from [project].name."
- )
-
- package_dir = root / "src" / package_name
- config_dir = package_dir / "config"
- config_dir.mkdir(parents=True, exist_ok=True)
-
- class_name = _class_name(package_name)
- crew_filename = "crew.jsonc" if (root / "crew.jsonc").is_file() else "crew.json"
-
- (package_dir / "__init__.py").write_text("", encoding="utf-8")
- (config_dir / "agents.yaml").write_text("{}\n", encoding="utf-8")
- (config_dir / "tasks.yaml").write_text("{}\n", encoding="utf-8")
- (package_dir / "crew.py").write_text(
- _json_crew_py(class_name, crew_filename),
- encoding="utf-8",
- )
- (package_dir / "main.py").write_text(
- _json_main_py(package_name, class_name),
- encoding="utf-8",
- )
- _ensure_project_scripts(root, package_name)
-
-
-def _json_crew_py(class_name: str, crew_filename: str) -> str:
- """Render the generated crew.py module for a JSON crew."""
- return f'''from pathlib import Path
-
-from crewai import Crew
-from crewai.project import CrewBase, crew
-from crewai.project.crew_loader import load_crew
-
-
-def _crew_path() -> Path:
- return Path(__file__).resolve().parents[2] / "{crew_filename}"
-
-
-@CrewBase
-class {class_name}:
- """Compatibility wrapper for a JSON-defined CrewAI project."""
-
- @crew
- def crew(self) -> Crew:
- crew_instance, default_inputs = load_crew(_crew_path())
- self.default_inputs = default_inputs
- return crew_instance
-'''
-
-
-def _json_main_py(package_name: str, class_name: str) -> str:
- """Render the generated main.py entrypoints for a JSON crew."""
- return f"""#!/usr/bin/env python
-import json
-import sys
-
-from {package_name}.crew import {class_name}
-
-
-def _load():
- wrapper = {class_name}()
- crew = wrapper.crew()
- return crew, getattr(wrapper, "default_inputs", {{}})
-
-
-def run():
- crew, inputs = _load()
- return crew.kickoff(inputs=inputs)
-
-
-def train():
- crew, inputs = _load()
- return crew.train(
- n_iterations=int(sys.argv[1]),
- filename=sys.argv[2],
- inputs=inputs,
- )
-
-
-def replay():
- crew, _ = _load()
- return crew.replay(task_id=sys.argv[1])
-
-
-def test():
- crew, inputs = _load()
- return crew.test(
- n_iterations=int(sys.argv[1]),
- eval_llm=sys.argv[2],
- inputs=inputs,
- )
-
-
-def run_with_trigger():
- if len(sys.argv) < 2:
- raise ValueError("No trigger payload provided.")
-
- crew, inputs = _load()
- trigger_payload = json.loads(sys.argv[1])
- return crew.kickoff(
- inputs={{**inputs, "crewai_trigger_payload": trigger_payload}}
- )
-"""
-
-
-def _ensure_project_scripts(root: Path, package_name: str) -> None:
- """Ensure generated wrappers have project script entrypoints."""
- pyproject_path = root / "pyproject.toml"
- if not pyproject_path.is_file():
- return
-
- content = pyproject_path.read_text(encoding="utf-8")
- entries = _project_script_entries(package_name)
- pyproject_path.write_text(
- _update_project_scripts(content, entries),
- encoding="utf-8",
- )
-
-
-def _project_script_entries(package_name: str) -> dict[str, str]:
- """Return script entrypoints required by the generated JSON wrapper."""
- return {
- package_name: f"{package_name}.main:run",
- "run_crew": f"{package_name}.main:run",
- "train": f"{package_name}.main:train",
- "replay": f"{package_name}.main:replay",
- "test": f"{package_name}.main:test",
- "run_with_trigger": f"{package_name}.main:run_with_trigger",
- }
-
-
-def _update_project_scripts(content: str, entries: dict[str, str]) -> str:
- """Add or replace generated script entries in pyproject.toml content."""
- lines = content.rstrip().splitlines()
- header_index = _project_scripts_header_index(lines)
- if header_index is None:
- return content.rstrip() + _project_scripts_block(entries)
-
- end_index = _section_end_index(lines, header_index + 1)
- seen: set[str] = set()
- for index in range(header_index + 1, end_index):
- key = _script_key(lines[index])
- if key in entries:
- lines[index] = _script_line(key, entries[key])
- seen.add(key)
-
- missing_lines = [
- _script_line(key, value) for key, value in entries.items() if key not in seen
- ]
- lines[end_index:end_index] = missing_lines
- return "\n".join(lines).rstrip() + "\n"
-
-
-def _project_scripts_header_index(lines: list[str]) -> int | None:
- """Return the line index of the project scripts table, if present."""
- for index, line in enumerate(lines):
- if line.strip() == "[project.scripts]":
- return index
- return None
-
-
-def _section_end_index(lines: list[str], start_index: int) -> int:
- """Return the exclusive end index for a TOML table section."""
- for index in range(start_index, len(lines)):
- if _SECTION_PATTERN.match(lines[index]):
- return index
- return len(lines)
-
-
-def _script_key(line: str) -> str | None:
- """Return the script key for a pyproject script line."""
- match = _SCRIPT_KEY_PATTERN.match(line)
- if not match:
- return None
-
- key = match.group("key")
- if key.startswith(("'", '"')) and key.endswith(("'", '"')):
- return key[1:-1]
- return key
-
-
-def _script_line(key: str, value: str) -> str:
- """Render a project script TOML entry."""
- return f'{key} = "{value}"'
-
-
-def _project_scripts_block(entries: dict[str, str]) -> str:
- """Render a project scripts TOML table."""
- lines = ["", "", "[project.scripts]"]
- lines.extend(_script_line(key, value) for key, value in entries.items())
- return "\n".join(lines) + "\n"
diff --git a/lib/cli/src/crewai_cli/deploy/validate.py b/lib/cli/src/crewai_cli/deploy/validate.py
index 544ff20cf..b76303d56 100644
--- a/lib/cli/src/crewai_cli/deploy/validate.py
+++ b/lib/cli/src/crewai_cli/deploy/validate.py
@@ -212,8 +212,16 @@ class DeployValidator:
if crew_path is None:
return self.results
+ agents_dir = self.project_root / "agents"
+
+ self._check_pyproject()
+ self._check_lockfile()
+ agents_dir_ok = self._check_json_agents_dir(agents_dir)
+
+ project = None
try:
- project = validate_crew_project(crew_path, self.project_root / "agents")
+ if agents_dir_ok:
+ project = validate_crew_project(crew_path, agents_dir)
except JSONProjectValidationError as e:
self._add(
Severity.ERROR,
@@ -232,15 +240,27 @@ class DeployValidator:
)
return self.results
- agents_dir = self.project_root / "agents"
-
- self._check_pyproject()
- self._check_lockfile()
- self._check_env_vars_json(crew_path, agents_dir, project.agent_names)
+ if project is not None:
+ self._check_env_vars_json(crew_path, agents_dir, project.agent_names)
self._check_version_vs_lockfile()
return self.results
+ def _check_json_agents_dir(self, agents_dir: Path) -> bool:
+ if agents_dir.is_dir():
+ return True
+ self._add(
+ Severity.ERROR,
+ "missing_agents_dir",
+ "Cannot find agents/ directory",
+ detail=(
+ "JSON crew projects load agent definitions from "
+ f"{agents_dir.relative_to(self.project_root)}/*.jsonc or *.json."
+ ),
+ hint="Create agents/ and add one JSON or JSONC file per agent.",
+ )
+ return False
+
def _check_env_vars_json(
self, crew_path: Path, agents_dir: Path, agent_names: list[str]
) -> None:
diff --git a/lib/cli/src/crewai_cli/experimental/skills/main.py b/lib/cli/src/crewai_cli/experimental/skills/main.py
index 81f9b3fc5..612b85d82 100644
--- a/lib/cli/src/crewai_cli/experimental/skills/main.py
+++ b/lib/cli/src/crewai_cli/experimental/skills/main.py
@@ -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
diff --git a/lib/cli/src/crewai_cli/kickoff_flow.py b/lib/cli/src/crewai_cli/kickoff_flow.py
index b5bc0d81e..bde1ddee7 100644
--- a/lib/cli/src/crewai_cli/kickoff_flow.py
+++ b/lib/cli/src/crewai_cli/kickoff_flow.py
@@ -1,12 +1,94 @@
+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:
diff --git a/lib/cli/src/crewai_cli/plot_flow.py b/lib/cli/src/crewai_cli/plot_flow.py
index d97ccba77..d79bdc58b 100644
--- a/lib/cli/src/crewai_cli/plot_flow.py
+++ b/lib/cli/src/crewai_cli/plot_flow.py
@@ -5,19 +5,27 @@ import click
def plot_flow() -> None:
"""
- Plot the flow by running a command in the UV environment.
+ Plot the flow from declarative config or the Python UV entrypoint.
"""
- command = ["uv", "run", "plot"]
+ from crewai_cli.run_declarative_flow import (
+ configured_project_declarative_flow,
+ plot_declarative_flow_in_project_env,
+ )
- try:
- result = subprocess.run(command, capture_output=False, text=True, check=True) # noqa: S603
+ if definition := configured_project_declarative_flow():
+ plot_declarative_flow_in_project_env(definition)
+ else:
+ command = ["uv", "run", "plot"]
- if result.stderr:
- click.echo(result.stderr, err=True)
+ try:
+ subprocess.run( # noqa: S603
+ command, capture_output=False, text=True, check=True
+ )
- except subprocess.CalledProcessError as e:
- click.echo(f"An error occurred while plotting the flow: {e}", err=True)
- click.echo(e.output, err=True)
+ except subprocess.CalledProcessError as e:
+ click.echo(f"An error occurred while plotting the flow: {e}", err=True)
+ raise SystemExit(1) from e
- except Exception as e:
- click.echo(f"An unexpected error occurred: {e}", err=True)
+ except Exception as e:
+ click.echo(f"An unexpected error occurred: {e}", err=True)
+ raise SystemExit(1) from e
diff --git a/lib/cli/src/crewai_cli/run_crew.py b/lib/cli/src/crewai_cli/run_crew.py
index cbf2445d1..de6c8c412 100644
--- a/lib/cli/src/crewai_cli/run_crew.py
+++ b/lib/cli/src/crewai_cli/run_crew.py
@@ -2,7 +2,6 @@ from __future__ import annotations
from collections.abc import Callable
from contextlib import AbstractContextManager, nullcontext
-from enum import Enum
import os
from pathlib import Path
import re
@@ -27,11 +26,6 @@ if TYPE_CHECKING:
from crewai_cli.crew_run_tui import CrewRunApp
-class CrewType(Enum):
- STANDARD = "standard"
- FLOW = "flow"
-
-
# Must accept the same names as the kickoff interpolation pattern in
# crewai.utilities.string_utils (_VARIABLE_PATTERN), including hyphens —
# otherwise placeholders are interpolated at runtime but never prompted for.
@@ -537,7 +531,11 @@ def _print_post_tui_summary(app: CrewRunApp) -> None:
)
-def run_crew(trained_agents_file: str | None = None) -> None:
+def run_crew(
+ trained_agents_file: str | None = None,
+ definition: str | None = None,
+ inputs: str | None = None,
+) -> None:
"""Run the crew or flow.
Args:
@@ -545,15 +543,98 @@ def run_crew(trained_agents_file: str | None = None) -> None:
by ``crewai train -f``. When set, exported as
``CREWAI_TRAINED_AGENTS_FILE`` so agents load suggestions from this
file instead of the default ``trained_agents_data.pkl``.
+ definition: Optional path to a declarative Flow definition.
+ inputs: Optional JSON object passed to a declarative Flow.
"""
- # JSON crew projects take precedence
+ if inputs is not None and definition is None:
+ raise click.UsageError("--inputs requires --definition")
+
+ if definition is not None:
+ _run_explicit_declarative_flow(
+ definition=definition,
+ inputs=inputs,
+ trained_agents_file=trained_agents_file,
+ )
+ return
+
if _has_json_crew():
_run_json_crew_in_project_env(trained_agents_file=trained_agents_file)
return
+ pyproject_data = read_toml()
+ _warn_if_old_poetry_project(pyproject_data)
+ project_type = _get_project_type(pyproject_data)
+
+ if project_type == "flow":
+ _run_flow_project(
+ pyproject_data=pyproject_data,
+ trained_agents_file=trained_agents_file,
+ )
+ return
+
+ _run_classic_crew_project(
+ pyproject_data=pyproject_data,
+ trained_agents_file=trained_agents_file,
+ )
+
+
+def _run_explicit_declarative_flow(
+ definition: str, inputs: str | None, trained_agents_file: str | None
+) -> None:
+ if trained_agents_file is not None:
+ raise click.UsageError("--filename can only be used when running crews")
+
+ from crewai_cli.run_declarative_flow import run_declarative_flow
+
+ run_declarative_flow(definition=definition, inputs=inputs)
+
+
+def _run_flow_project(
+ pyproject_data: dict[str, Any], trained_agents_file: str | None
+) -> None:
+ if trained_agents_file is not None:
+ raise click.UsageError("--filename can only be used when running crews")
+
+ from crewai_cli.run_declarative_flow import (
+ configured_project_declarative_flow,
+ run_declarative_flow_in_project_env,
+ )
+
+ if definition := configured_project_declarative_flow(pyproject_data):
+ 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")
+
+
+def _run_classic_crew_project(
+ pyproject_data: dict[str, Any], trained_agents_file: str | None
+) -> None:
+ _execute_uv_script(
+ "run_crew",
+ entity_type="crew",
+ trained_agents_file=trained_agents_file,
+ )
+
+
+def _get_project_type(pyproject_data: dict[str, Any]) -> str | None:
+ project_type = pyproject_data.get("tool", {}).get("crewai", {}).get("type")
+ return project_type if isinstance(project_type, str) else None
+
+
+def _warn_if_old_poetry_project(pyproject_data: dict[str, Any]) -> None:
crewai_version = get_crewai_version()
min_required_version = "0.71.0"
- pyproject_data = read_toml()
if pyproject_data.get("tool", {}).get("poetry") and (
version.parse(crewai_version) < version.parse(min_required_version)
@@ -564,25 +645,22 @@ def run_crew(trained_agents_file: str | None = None) -> None:
fg="red",
)
- is_flow = pyproject_data.get("tool", {}).get("crewai", {}).get("type") == "flow"
- crew_type = CrewType.FLOW if is_flow else CrewType.STANDARD
- click.echo(f"Running the {'Flow' if is_flow else 'Crew'}")
-
- execute_command(crew_type, trained_agents_file=trained_agents_file)
-
-
-def execute_command(
- crew_type: CrewType, trained_agents_file: str | None = None
+def _execute_uv_script(
+ script_name: str,
+ *,
+ entity_type: str,
+ trained_agents_file: str | None = None,
) -> None:
- """Execute the appropriate command based on crew type.
+ """Execute a project script through uv.
Args:
- crew_type: The type of crew to run.
+ script_name: The project script to run.
+ entity_type: The user-facing entity being run.
trained_agents_file: Optional trained-agents pickle path forwarded to
the subprocess via the ``CREWAI_TRAINED_AGENTS_FILE`` env var.
"""
- command = ["uv", "run", "kickoff" if crew_type == CrewType.FLOW else "run_crew"]
+ command = ["uv", "run", script_name]
env = build_env_with_all_tool_credentials()
if trained_agents_file:
@@ -592,21 +670,20 @@ def execute_command(
subprocess.run(command, capture_output=False, text=True, check=True, env=env) # noqa: S603
except subprocess.CalledProcessError as e:
- handle_error(e, crew_type)
+ _handle_run_error(e, entity_type)
except Exception as e:
click.echo(f"An unexpected error occurred: {e}", err=True)
-def handle_error(error: subprocess.CalledProcessError, crew_type: CrewType) -> None:
+def _handle_run_error(error: subprocess.CalledProcessError, entity_type: str) -> None:
"""
Handle subprocess errors with appropriate messaging.
Args:
error: The subprocess error that occurred
- crew_type: The type of crew that was being run
+ entity_type: The type of entity that was being run
"""
- entity_type = "flow" if crew_type == CrewType.FLOW else "crew"
click.echo(f"An error occurred while running the {entity_type}: {error}", err=True)
if error.output:
diff --git a/lib/cli/src/crewai_cli/run_declarative_flow.py b/lib/cli/src/crewai_cli/run_declarative_flow.py
new file mode 100644
index 000000000..ea289d00b
--- /dev/null
+++ b/lib/cli/src/crewai_cli/run_declarative_flow.py
@@ -0,0 +1,241 @@
+from __future__ import annotations
+
+import json
+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 | 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():
+ run_declarative_flow(definition=definition, inputs=inputs)
+ return
+
+ if inputs is not None:
+ raise click.UsageError("--inputs is only supported with --definition")
+
+ _execute_declarative_flow_command(["uv", "run", "crewai", "run"])
+
+
+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)
+ return
+
+ _execute_declarative_flow_command(["uv", "run", "crewai", "flow", "plot"])
+
+
+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)
+
+ try:
+ flow = load_declarative_flow(definition)
+ result = flow.kickoff(inputs=parsed_inputs)
+ except Exception as exc:
+ click.echo(
+ f"An error occurred while running the declarative flow: {exc}", err=True
+ )
+ raise SystemExit(1) from exc
+
+ click.echo(_format_result(result))
+
+
+def plot_declarative_flow(definition: str | Path) -> None:
+ """Plot a declarative flow from a definition path."""
+ try:
+ flow = load_declarative_flow(definition)
+ flow.plot()
+ except Exception as exc:
+ click.echo(
+ f"An error occurred while plotting the declarative flow: {exc}", err=True
+ )
+ raise SystemExit(1) from exc
+
+
+def load_declarative_flow(definition: str | Path) -> Any:
+ """Load a declarative Flow instance from a definition path."""
+ try:
+ from crewai.flow.flow import Flow
+ except ImportError as exc:
+ click.echo(
+ "Running declarative flows requires the full crewai package.",
+ err=True,
+ )
+ raise SystemExit(1) from exc
+
+ definition_path = Path(definition).expanduser()
+ 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
+
+ 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,
+ project_root: Path | None = None,
+) -> Path | None:
+ """Return the configured declarative flow source for flow projects."""
+ if pyproject_data is None:
+ try:
+ from crewai_cli.utils import read_toml
+
+ pyproject_data = read_toml()
+ except Exception:
+ return None
+
+ crewai_config = pyproject_data.get("tool", {}).get("crewai", {})
+ if crewai_config.get("type") != "flow":
+ return None
+ definition = crewai_config.get("definition")
+ if not isinstance(definition, str):
+ return 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:
+ env = build_env_with_all_tool_credentials()
+
+ try:
+ subprocess.run( # noqa: S603
+ command,
+ capture_output=False,
+ text=True,
+ check=True,
+ env=env,
+ )
+ except subprocess.CalledProcessError as e:
+ raise SystemExit(e.returncode) from e
+ except Exception as e:
+ click.echo(
+ f"An unexpected error occurred while running the declarative flow: {e}",
+ err=True,
+ )
+ raise SystemExit(1) from e
+
+
+def is_declarative_flow_project_env() -> bool:
+ import os
+
+ return os.environ.get("UV_RUN_RECURSION_DEPTH") is not None
+
+
+def _has_project_file(project_root: Path | None = None) -> bool:
+ root = project_root or Path.cwd()
+ return (root / "pyproject.toml").is_file()
+
+
+def _parse_inputs(inputs: str | None) -> dict[str, Any] | None:
+ if inputs is None:
+ return None
+
+ try:
+ parsed = json.loads(inputs)
+ except json.JSONDecodeError as exc:
+ click.echo(f"Invalid --inputs JSON: {exc}", err=True)
+ raise SystemExit(1) from exc
+
+ if not isinstance(parsed, dict):
+ click.echo("Invalid --inputs JSON: expected an object.", err=True)
+ raise SystemExit(1)
+
+ return parsed
+
+
+def _format_result(result: Any) -> str:
+ raw_result = getattr(result, "raw", result)
+ if isinstance(raw_result, str):
+ return raw_result
+
+ try:
+ return json.dumps(raw_result, default=str)
+ except TypeError:
+ return str(raw_result)
diff --git a/lib/cli/src/crewai_cli/run_flow_definition.py b/lib/cli/src/crewai_cli/run_flow_definition.py
deleted file mode 100644
index 7acb6d9fe..000000000
--- a/lib/cli/src/crewai_cli/run_flow_definition.py
+++ /dev/null
@@ -1,113 +0,0 @@
-from __future__ import annotations
-
-import json
-from pathlib import Path
-from typing import Any
-
-import click
-
-
-def run_flow_definition(definition: str, inputs: str | None = None) -> None:
- """Run a flow from a Flow Definition YAML/JSON string or file path."""
- try:
- from crewai.flow.flow import Flow
- from crewai.flow.flow_definition import FlowDefinition
- except ImportError as exc:
- click.echo(
- "Running flows from definitions requires the full crewai package.",
- err=True,
- )
- raise SystemExit(1) from exc
-
- parsed_inputs = _parse_inputs(inputs)
- definition_source = _read_definition_source(definition)
-
- try:
- flow_definition = _parse_flow_definition(FlowDefinition, definition_source)
- flow = Flow.from_definition(flow_definition)
- result = flow.kickoff(inputs=parsed_inputs)
- except Exception as exc:
- click.echo(
- f"An error occurred while running the flow definition: {exc}", err=True
- )
- raise SystemExit(1) from exc
-
- click.echo(_format_result(result))
-
-
-def _parse_inputs(inputs: str | None) -> dict[str, Any] | None:
- if inputs is None:
- return None
-
- try:
- parsed = json.loads(inputs)
- except json.JSONDecodeError as exc:
- click.echo(f"Invalid --inputs JSON: {exc}", err=True)
- raise SystemExit(1) from exc
-
- if not isinstance(parsed, dict):
- click.echo("Invalid --inputs JSON: expected an object.", err=True)
- raise SystemExit(1)
-
- return parsed
-
-
-def _read_definition_source(definition: str) -> str:
- path = Path(definition).expanduser()
- try:
- is_file = path.is_file()
- except OSError as exc:
- if _looks_like_inline_definition(definition):
- return definition
- click.echo(f"Invalid --definition path: {definition} ({exc})", err=True)
- raise SystemExit(1) from exc
-
- if is_file:
- try:
- return 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
-
- try:
- if path.exists():
- click.echo(
- f"Invalid --definition path: {definition} is not a file.", 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 definition
-
-
-def _looks_like_inline_definition(definition: str) -> bool:
- stripped = definition.lstrip()
- return "\n" in definition or stripped.startswith(("{", "---")) or ":" in stripped
-
-
-def _parse_flow_definition(flow_definition_cls: type[Any], source: str) -> Any:
- if _looks_like_json(source):
- return flow_definition_cls.from_json(source)
-
- return flow_definition_cls.from_yaml(source)
-
-
-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):
- return raw_result
-
- try:
- return json.dumps(raw_result, default=str)
- except TypeError:
- return str(raw_result)
diff --git a/lib/cli/src/crewai_cli/templates/AGENTS.md b/lib/cli/src/crewai_cli/templates/AGENTS.md
index cb9fff256..8f39289e7 100644
--- a/lib/cli/src/crewai_cli/templates/AGENTS.md
+++ b/lib/cli/src/crewai_cli/templates/AGENTS.md
@@ -62,7 +62,7 @@ crewai create flow --skip_provider # New flow project
# Running
crewai run # Run crew or flow (auto-detects from pyproject.toml)
-crewai flow kickoff # Legacy flow execution
+crewai flow kickoff # Deprecated compatibility alias for crewai run
# Testing & training
crewai test # Test crew (default: 2 iterations, gpt-4o-mini)
diff --git a/lib/cli/src/crewai_cli/templates/crew/pyproject.toml b/lib/cli/src/crewai_cli/templates/crew/pyproject.toml
index bcf00bf71..666d71f8a 100644
--- a/lib/cli/src/crewai_cli/templates/crew/pyproject.toml
+++ b/lib/cli/src/crewai_cli/templates/crew/pyproject.toml
@@ -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]
diff --git a/lib/cli/src/crewai_cli/templates/declarative_flow/.gitignore b/lib/cli/src/crewai_cli/templates/declarative_flow/.gitignore
new file mode 100644
index 000000000..9b826004b
--- /dev/null
+++ b/lib/cli/src/crewai_cli/templates/declarative_flow/.gitignore
@@ -0,0 +1,5 @@
+.env
+.venv/
+__pycache__/
+.crewai/
+output/
diff --git a/lib/cli/src/crewai_cli/templates/declarative_flow/README.md b/lib/cli/src/crewai_cli/templates/declarative_flow/README.md
new file mode 100644
index 000000000..697c0aa32
--- /dev/null
+++ b/lib/cli/src/crewai_cli/templates/declarative_flow/README.md
@@ -0,0 +1,17 @@
+# {{name}} Flow
+
+This project defines a declarative CrewAI Flow in `src/{{folder_name}}/flow.yaml`.
+
+## Install
+
+```bash
+crewai install
+```
+
+## Run
+
+```bash
+crewai run
+```
+
+Edit the declarative flow definition at `src/{{folder_name}}/flow.yaml` to change the flow. Add reusable crews under `src/{{folder_name}}/crews/`, custom Python tools under `src/{{folder_name}}/tools/`, and shared knowledge files under `src/{{folder_name}}/knowledge/`.
diff --git a/lib/cli/src/crewai_cli/templates/declarative_flow/flow.yaml b/lib/cli/src/crewai_cli/templates/declarative_flow/flow.yaml
new file mode 100644
index 000000000..3b07891fe
--- /dev/null
+++ b/lib/cli/src/crewai_cli/templates/declarative_flow/flow.yaml
@@ -0,0 +1,15 @@
+schema: crewai.flow/v1
+name: {{flow_name}}
+description: A declarative CrewAI Flow.
+
+state:
+ type: dict
+ default:
+ topic: AI agents
+
+methods:
+ start:
+ start: true
+ do:
+ call: expression
+ expr: state.topic
diff --git a/lib/cli/src/crewai_cli/templates/declarative_flow/pyproject.toml b/lib/cli/src/crewai_cli/templates/declarative_flow/pyproject.toml
new file mode 100644
index 000000000..986f21873
--- /dev/null
+++ b/lib/cli/src/crewai_cli/templates/declarative_flow/pyproject.toml
@@ -0,0 +1,20 @@
+[project]
+name = "{{folder_name}}"
+version = "0.1.0"
+description = "{{name}} using crewAI"
+authors = [{ name = "Your Name", email = "you@example.com" }]
+requires-python = ">=3.10,<3.14"
+dependencies = [
+ "crewai[tools]==1.14.8a5"
+]
+
+[build-system]
+requires = ["hatchling"]
+build-backend = "hatchling.build"
+
+[tool.hatch.build.targets.wheel]
+packages = ["src/{{folder_name}}"]
+
+[tool.crewai]
+type = "flow"
+definition = "src/{{folder_name}}/flow.yaml"
diff --git a/lib/cli/src/crewai_cli/templates/flow/pyproject.toml b/lib/cli/src/crewai_cli/templates/flow/pyproject.toml
index 067dc6165..ddb387be4 100644
--- a/lib/cli/src/crewai_cli/templates/flow/pyproject.toml
+++ b/lib/cli/src/crewai_cli/templates/flow/pyproject.toml
@@ -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]
diff --git a/lib/cli/src/crewai_cli/templates/tool/pyproject.toml b/lib/cli/src/crewai_cli/templates/tool/pyproject.toml
index e9b08f453..4df1533b3 100644
--- a/lib/cli/src/crewai_cli/templates/tool/pyproject.toml
+++ b/lib/cli/src/crewai_cli/templates/tool/pyproject.toml
@@ -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]
diff --git a/lib/cli/tests/deploy/test_archive.py b/lib/cli/tests/deploy/test_archive.py
index 1d0e2cc1a..dff77a29b 100644
--- a/lib/cli/tests/deploy/test_archive.py
+++ b/lib/cli/tests/deploy/test_archive.py
@@ -132,7 +132,7 @@ def test_create_project_zip_excludes_symlinked_files(tmp_path: Path):
assert names == {"pyproject.toml"}
-def test_create_project_zip_adds_json_project_wrapper(tmp_path: Path):
+def test_create_project_zip_preserves_json_project_shape(tmp_path: Path):
(tmp_path / "pyproject.toml").write_text(
"""
[project]
@@ -157,8 +157,6 @@ type = "crew"
try:
with zipfile.ZipFile(archive_path) as archive:
names = set(archive.namelist())
- crew_py = archive.read("src/json_crew/crew.py").decode()
- main_py = archive.read("src/json_crew/main.py").decode()
pyproject = archive.read("pyproject.toml").decode()
finally:
archive_path.unlink(missing_ok=True)
@@ -166,18 +164,50 @@ type = "crew"
assert "uv.lock" not in names
assert "crew.jsonc" in names
assert "agents/researcher.jsonc" in names
- assert "src/json_crew/__init__.py" in names
- assert "src/json_crew/crew.py" in names
- assert "src/json_crew/main.py" in names
- assert "src/json_crew/config/agents.yaml" in names
- assert "src/json_crew/config/tasks.yaml" in names
- assert "load_crew(_crew_path())" in crew_py
- assert "JsonCrew" in crew_py
- assert "from json_crew.crew import JsonCrew" in main_py
- assert "run_crew = \"json_crew.main:run\"" in pyproject
+ assert all(not name.startswith("src/") for name in names)
+ assert "run_crew" not in pyproject
+ assert "json_crew =" not in pyproject
+ assert "[project.scripts]" not in pyproject
-def test_create_project_zip_updates_existing_json_project_scripts(tmp_path: Path):
+def test_create_project_zip_keeps_json_project_root_shape(tmp_path: Path):
+ (tmp_path / "pyproject.toml").write_text(
+ """
+[project]
+name = "json_crew"
+version = "0.1.0"
+dependencies = ["crewai[tools]==1.14.8a1"]
+
+[tool.crewai]
+type = "crew"
+""".strip()
+ + "\n"
+ )
+ (tmp_path / "uv.lock").write_text("# lock\n")
+ (tmp_path / "agents").mkdir()
+ (tmp_path / "agents" / "foo.jsonc").write_text("{}\n")
+ (tmp_path / "crew.jsonc").write_text("{}\n")
+
+ archive_path = create_project_zip("json_crew", project_dir=tmp_path)
+ try:
+ with zipfile.ZipFile(archive_path) as archive:
+ names = set(archive.namelist())
+ pyproject = archive.read("pyproject.toml").decode()
+ finally:
+ archive_path.unlink(missing_ok=True)
+
+ assert names == {
+ "agents/foo.jsonc",
+ "crew.jsonc",
+ "pyproject.toml",
+ "uv.lock",
+ }
+ assert "run_crew" not in pyproject
+ assert "json_crew =" not in pyproject
+ assert "[project.scripts]" not in pyproject
+
+
+def test_create_project_zip_does_not_rewrite_json_project_scripts(tmp_path: Path):
(tmp_path / "pyproject.toml").write_text(
"""
[project]
@@ -203,14 +233,10 @@ type = "crew"
finally:
archive_path.unlink(missing_ok=True)
- assert 'json_crew = "json_crew.main:run"' in pyproject
- assert 'run_crew = "json_crew.main:run"' in pyproject
- assert 'train = "json_crew.main:train"' in pyproject
- assert 'replay = "json_crew.main:replay"' in pyproject
- assert 'test = "json_crew.main:test"' in pyproject
- assert 'run_with_trigger = "json_crew.main:run_with_trigger"' in pyproject
+ assert 'json_crew = "old.module:run"' in pyproject
+ assert 'run_crew = "old.module:run"' in pyproject
assert 'custom = "custom.module:main"' in pyproject
- assert "old.module:run" not in pyproject
+ assert pyproject.count("[project.scripts]") == 1
assert "[tool.crewai]" in pyproject
@@ -221,7 +247,7 @@ type = "crew"
'[tool]\ncrewai = "invalid"\n',
],
)
-def test_create_project_zip_adds_json_wrapper_for_malformed_tool_config(
+def test_create_project_zip_preserves_json_project_with_malformed_tool_config(
tmp_path: Path, tool_config: str
):
(tmp_path / "pyproject.toml").write_text(
@@ -244,12 +270,13 @@ version = "0.1.0"
finally:
archive_path.unlink(missing_ok=True)
- assert "src/json_crew/crew.py" in names
- assert "src/json_crew/main.py" in names
- assert "run_crew = \"json_crew.main:run\"" in pyproject
+ assert names == {"crew.jsonc", "pyproject.toml"}
+ assert "run_crew" not in pyproject
+ assert "json_crew =" not in pyproject
+ assert "[project.scripts]" not in pyproject
-def test_create_project_zip_rejects_empty_normalized_package_name(tmp_path: Path):
+def test_create_project_zip_accepts_json_project_without_package_name(tmp_path: Path):
(tmp_path / "pyproject.toml").write_text(
"""
[project]
@@ -263,8 +290,15 @@ type = "crew"
)
(tmp_path / "crew.jsonc").write_text("{}\n")
- with pytest.raises(
- ValueError,
- match=r"Could not derive a valid Python package name",
- ):
- create_project_zip("invalid", project_dir=tmp_path)
+ archive_path = create_project_zip("invalid", project_dir=tmp_path)
+ try:
+ with zipfile.ZipFile(archive_path) as archive:
+ names = set(archive.namelist())
+ pyproject = archive.read("pyproject.toml").decode()
+ finally:
+ archive_path.unlink(missing_ok=True)
+
+ assert names == {"crew.jsonc", "pyproject.toml"}
+ assert "run_crew" not in pyproject
+ assert "json_crew =" not in pyproject
+ assert "[project.scripts]" not in pyproject
diff --git a/lib/cli/tests/deploy/test_validate.py b/lib/cli/tests/deploy/test_validate.py
index 07ceef14f..1176fbbdf 100644
--- a/lib/cli/tests/deploy/test_validate.py
+++ b/lib/cli/tests/deploy/test_validate.py
@@ -200,6 +200,41 @@ def test_json_runtime_fields_are_deploy_errors(tmp_path: Path) -> None:
assert "runtime-only" in finding.detail
+def test_json_crew_requires_agents_dir_without_classic_errors(tmp_path: Path) -> None:
+ _scaffold_json_crew(tmp_path)
+ for path in (tmp_path / "agents").iterdir():
+ path.unlink()
+ (tmp_path / "agents").rmdir()
+
+ v = DeployValidator(project_root=tmp_path)
+ v.run()
+
+ codes = _codes(v)
+ assert "missing_agents_dir" in codes
+ assert "missing_src_dir" not in codes
+ assert "missing_crew_py" not in codes
+ assert "missing_agents_yaml" not in codes
+ assert "missing_tasks_yaml" not in codes
+
+
+def test_json_crew_reports_project_metadata_before_invalid_json(
+ tmp_path: Path,
+) -> None:
+ _scaffold_json_crew(tmp_path)
+ (tmp_path / "pyproject.toml").unlink()
+ (tmp_path / "uv.lock").unlink()
+ (tmp_path / "crew.jsonc").write_text('{"agents": ["researcher"], "tasks": []}\n')
+
+ v = DeployValidator(project_root=tmp_path)
+ v.run()
+
+ codes = _codes(v)
+ assert "missing_pyproject" in codes
+ assert "missing_lockfile" in codes
+ assert "invalid_crew_json" in codes
+ assert "missing_src_dir" not in codes
+
+
def test_missing_pyproject_errors(tmp_path: Path) -> None:
v = _run_without_import_check(tmp_path)
assert "missing_pyproject" in _codes(v)
diff --git a/lib/cli/tests/skills/test_safe_extract.py b/lib/cli/tests/skills/test_safe_extract.py
new file mode 100644
index 000000000..f1083f5fa
--- /dev/null
+++ b/lib/cli/tests/skills/test_safe_extract.py
@@ -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)"
diff --git a/lib/cli/tests/test_cli.py b/lib/cli/tests/test_cli.py
index 3b5ce277f..acdeee7ff 100644
--- a/lib/cli/tests/test_cli.py
+++ b/lib/cli/tests/test_cli.py
@@ -12,6 +12,7 @@ from crewai_cli.cli import (
deploy_remove,
deply_status,
flow_add_crew,
+ flow_run,
login,
reset_memories,
run,
@@ -126,38 +127,75 @@ def test_run_uses_project_runner_by_default(run_crew, runner):
result = runner.invoke(run)
assert result.exit_code == 0
- run_crew.assert_called_once_with(trained_agents_file=None)
+ run_crew.assert_called_once_with(
+ trained_agents_file=None,
+ definition=None,
+ inputs=None,
+ )
assert "experimental" not in result.output.lower()
-@mock.patch("crewai_cli.cli.run_flow_definition")
-def test_run_with_definition_uses_definition_runner(run_flow_definition, runner):
+@mock.patch("crewai_cli.cli.run_crew")
+def test_run_with_definition_uses_project_runner(run_crew, runner):
result = runner.invoke(
run,
["--definition", "flow.yaml", "--inputs", '{"topic":"AI"}'],
)
assert result.exit_code == 0
- assert (
- "Warning: `crewai run --definition` is experimental and may change without notice."
- in result.output
- )
- run_flow_definition.assert_called_once_with(
- definition="flow.yaml", inputs='{"topic":"AI"}'
+ run_crew.assert_called_once_with(
+ trained_agents_file=None,
+ definition="flow.yaml",
+ inputs='{"topic":"AI"}',
)
@mock.patch("crewai_cli.cli.run_crew")
-@mock.patch("crewai_cli.cli.run_flow_definition")
-def test_run_rejects_inputs_without_definition(run_flow_definition, run_crew, runner):
+def test_run_rejects_inputs_without_definition(run_crew, runner):
result = runner.invoke(run, ["--inputs", '{"topic":"AI"}'])
assert result.exit_code == 2
assert "Error: --inputs requires --definition" in result.output
- run_flow_definition.assert_not_called()
run_crew.assert_not_called()
+@mock.patch("crewai_cli.cli.run_crew")
+def test_run_rejects_filename_with_definition(run_crew, runner):
+ result = runner.invoke(run, ["--definition", "flow.yaml", "--filename", "x.pkl"])
+
+ assert result.exit_code == 2
+ assert "Error: --filename can only be used when running crews" in result.output
+ run_crew.assert_not_called()
+
+
+@mock.patch("crewai_cli.cli.run_crew")
+def test_run_passes_filename_to_project_runner(run_crew, runner):
+ result = runner.invoke(run, ["--filename", "trained.pkl"])
+
+ assert result.exit_code == 0
+ run_crew.assert_called_once_with(
+ trained_agents_file="trained.pkl",
+ definition=None,
+ inputs=None,
+ )
+
+
+@mock.patch("crewai_cli.cli.run_crew")
+def test_flow_kickoff_is_deprecated_and_uses_run_path(run_crew, runner):
+ result = runner.invoke(flow_run)
+
+ assert result.exit_code == 0
+ run_crew.assert_called_once_with(
+ trained_agents_file=None,
+ definition=None,
+ inputs=None,
+ )
+ assert (
+ "The command 'crewai flow kickoff' is deprecated. Use 'crewai run' instead."
+ in result.output
+ )
+
+
@mock.patch("crewai_cli.create_json_crew.create_json_crew")
def test_create_crew_in_dmn_mode_skips_provider_prompts(create_json_crew, runner):
result = runner.invoke(create, ["crew", "DMN Crew"], env={"CREWAI_DMN": "True"})
@@ -166,6 +204,23 @@ def test_create_crew_in_dmn_mode_skips_provider_prompts(create_json_crew, runner
create_json_crew.assert_called_once_with("DMN Crew", None, True)
+@mock.patch("crewai_cli.create_flow.create_flow")
+def test_create_flow_declarative_uses_declarative_scaffold(create_flow, runner):
+ result = runner.invoke(create, ["flow", "My Flow", "--declarative"])
+
+ assert result.exit_code == 0
+ create_flow.assert_called_once_with("My Flow", declarative=True)
+
+
+@mock.patch("crewai_cli.create_json_crew.create_json_crew")
+def test_create_crew_rejects_declarative_flag(create_json_crew, runner):
+ result = runner.invoke(create, ["crew", "My Crew", "--declarative"])
+
+ assert result.exit_code == 2
+ assert "--declarative can only be used with flow projects" in result.output
+ create_json_crew.assert_not_called()
+
+
def test_create_requires_type_in_dmn_mode(runner):
result = runner.invoke(create, env={"CREWAI_DMN": "True"})
diff --git a/lib/cli/tests/test_create_crew.py b/lib/cli/tests/test_create_crew.py
index adfe1757f..5d87f8f3e 100644
--- a/lib/cli/tests/test_create_crew.py
+++ b/lib/cli/tests/test_create_crew.py
@@ -712,8 +712,26 @@ def test_json_create_provider_preselects_default_model(tmp_path, monkeypatch):
default_llm="openai/gpt-5.5",
)
assert (tmp_path / "json_crew" / "crew.jsonc").exists()
+ assert not (tmp_path / "json_crew" / "src").exists()
assert not (tmp_path / "json_crew" / "tests").exists()
assert not (tmp_path / "json_crew" / "config.jsonc").exists()
+ generated_paths = {
+ path.relative_to(tmp_path / "json_crew").as_posix()
+ for path in (tmp_path / "json_crew").rglob("*")
+ if path.is_file()
+ }
+ assert not any(
+ path.endswith("/crew.py") or path == "crew.py" for path in generated_paths
+ )
+ assert not any(
+ path.endswith("/agents.yaml") or path == "agents.yaml"
+ for path in generated_paths
+ )
+ assert not any(
+ path.endswith("/tasks.yaml") or path == "tasks.yaml"
+ for path in generated_paths
+ )
+ assert not any(path.startswith("src/") for path in generated_paths)
pyproject = tomli.loads((tmp_path / "json_crew" / "pyproject.toml").read_text())
dependency = pyproject["project"]["dependencies"][0]
@@ -849,7 +867,7 @@ def test_json_create_dmn_mode_uses_non_interactive_defaults(tmp_path, monkeypatc
crew_template = (project_root / "crew.jsonc").read_text()
agent_template = (project_root / "agents" / "researcher.jsonc").read_text()
- assert '"memory": false' in crew_template
+ assert '"memory": true' in crew_template
assert '"description": "Research current AI trends and write a concise summary."' in (
crew_template
)
diff --git a/lib/cli/tests/test_create_flow.py b/lib/cli/tests/test_create_flow.py
new file mode 100644
index 000000000..21487a7a1
--- /dev/null
+++ b/lib/cli/tests/test_create_flow.py
@@ -0,0 +1,35 @@
+from __future__ import annotations
+
+from pathlib import Path
+
+from click.testing import CliRunner
+from pytest import MonkeyPatch
+import tomli
+
+from crewai_cli.cli import crewai
+from crewai_cli.create_flow import create_flow
+
+
+def test_create_flow_declarative_project_can_run(
+ tmp_path: Path, monkeypatch: MonkeyPatch
+):
+ monkeypatch.chdir(tmp_path)
+ create_flow("Research Flow", declarative=True)
+
+ project_root = tmp_path / "research_flow"
+ assert project_root.is_dir()
+
+ pyproject = tomli.loads(
+ (project_root / "pyproject.toml").read_text(encoding="utf-8")
+ )
+ assert pyproject["project"]["name"] == "research_flow"
+ assert pyproject["project"]["requires-python"]
+ assert pyproject["project"]["dependencies"]
+ assert (project_root / pyproject["tool"]["crewai"]["definition"]).is_file()
+
+ monkeypatch.chdir(project_root)
+ result = CliRunner().invoke(crewai, ["run"], env={"UV_RUN_RECURSION_DEPTH": "1"})
+
+ assert result.exit_code == 0
+ assert "Running the Flow" not in result.output
+ assert "AI agents" in result.output
diff --git a/lib/cli/tests/test_crew_run_tui.py b/lib/cli/tests/test_crew_run_tui.py
index 969bc5ae2..5c49dabf1 100644
--- a/lib/cli/tests/test_crew_run_tui.py
+++ b/lib/cli/tests/test_crew_run_tui.py
@@ -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()
diff --git a/lib/cli/tests/test_flow_commands.py b/lib/cli/tests/test_flow_commands.py
new file mode 100644
index 000000000..5743275e7
--- /dev/null
+++ b/lib/cli/tests/test_flow_commands.py
@@ -0,0 +1,248 @@
+from __future__ import annotations
+
+from pathlib import Path
+import subprocess
+
+import click
+import pytest
+from click.testing import CliRunner
+
+from crewai_cli.cli import flow_run
+import crewai_cli.plot_flow as plot_flow_module
+
+
+FLOW_YAML = """\
+schema: crewai.flow/v1
+name: TestFlow
+config:
+ suppress_flow_events: true
+methods:
+ begin:
+ start: true
+ do:
+ call: expression
+ expr: "'AI'"
+"""
+
+
+def _write_flow_project(project_root: Path) -> None:
+ (project_root / "flow.yaml").write_text(FLOW_YAML, encoding="utf-8")
+ (project_root / "pyproject.toml").write_text(
+ '[project]\nname = "demo"\n\n'
+ '[tool.crewai]\ntype = "flow"\ndefinition = "flow.yaml"\n',
+ encoding="utf-8",
+ )
+
+
+def test_flow_kickoff_runs_configured_declarative_definition(
+ monkeypatch: pytest.MonkeyPatch,
+ tmp_path: Path,
+) -> None:
+ _write_flow_project(tmp_path)
+ monkeypatch.chdir(tmp_path)
+ monkeypatch.setenv("UV_RUN_RECURSION_DEPTH", "1")
+
+ result = CliRunner().invoke(flow_run)
+
+ assert result.exit_code == 0
+ assert (
+ "The command 'crewai flow kickoff' is deprecated. Use 'crewai run' instead."
+ in result.output
+ )
+ assert "AI\n" in result.output
+ assert "Running the Flow" not in result.output
+
+
+def test_plot_flow_runs_configured_declarative_definition(
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
+) -> None:
+ _write_flow_project(tmp_path)
+ monkeypatch.chdir(tmp_path)
+ monkeypatch.setenv("UV_RUN_RECURSION_DEPTH", "1")
+
+ plot_flow_module.plot_flow()
+
+
+def test_flow_kickoff_delegates_to_run_crew(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ calls = []
+
+ monkeypatch.setattr(
+ "crewai_cli.cli.run_crew",
+ lambda **kwargs: calls.append(kwargs),
+ )
+
+ result = CliRunner().invoke(flow_run)
+
+ assert result.exit_code == 0
+ assert calls == [
+ {"trained_agents_file": None, "definition": None, "inputs": None},
+ ]
+
+
+def test_plot_flow_keeps_python_entrypoint_without_definition(
+ monkeypatch: pytest.MonkeyPatch,
+ tmp_path: Path,
+) -> None:
+ subprocess_calls = []
+
+ monkeypatch.chdir(tmp_path)
+ monkeypatch.setattr(
+ subprocess,
+ "run",
+ lambda command, **kwargs: subprocess_calls.append((command, kwargs)),
+ )
+
+ plot_flow_module.plot_flow()
+
+ assert subprocess_calls == [
+ (
+ ["uv", "run", "plot"],
+ {"capture_output": False, "text": True, "check": True},
+ )
+ ]
+
+
+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",
+ )
+
+ from crewai_cli.run_declarative_flow import configured_project_declarative_flow
+
+ 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
diff --git a/lib/cli/tests/test_kickoff_flow.py b/lib/cli/tests/test_kickoff_flow.py
new file mode 100644
index 000000000..52eb299ee
--- /dev/null
+++ b/lib/cli/tests/test_kickoff_flow.py
@@ -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"]]
diff --git a/lib/cli/tests/test_run_crew.py b/lib/cli/tests/test_run_crew.py
index ebb48d72b..6db073919 100644
--- a/lib/cli/tests/test_run_crew.py
+++ b/lib/cli/tests/test_run_crew.py
@@ -568,3 +568,175 @@ def test_has_json_crew_true_without_pyproject(monkeypatch, tmp_path: Path):
(tmp_path / "crew.jsonc").write_text("{}")
assert run_crew_module._has_json_crew() is True
+
+
+def test_run_crew_rejects_inputs_without_definition():
+ with pytest.raises(click.UsageError) as exc_info:
+ run_crew_module.run_crew(inputs='{"topic":"AI"}')
+
+ assert "--inputs requires --definition" in exc_info.value.message
+
+
+def test_run_crew_rejects_filename_with_explicit_definition():
+ with pytest.raises(click.UsageError) as exc_info:
+ run_crew_module.run_crew(
+ trained_agents_file="trained.pkl",
+ definition="flow.yaml",
+ )
+
+ assert "--filename can only be used when running crews" in exc_info.value.message
+
+
+def test_run_crew_runs_explicit_declarative_definition(monkeypatch, capsys):
+ calls = []
+
+ def fake_run_declarative_flow(definition: str, inputs: str | None = None):
+ calls.append((definition, inputs))
+
+ monkeypatch.setattr(
+ "crewai_cli.run_declarative_flow.run_declarative_flow",
+ fake_run_declarative_flow,
+ )
+
+ run_crew_module.run_crew(definition="flow.yaml", inputs='{"topic":"AI"}')
+
+ captured = capsys.readouterr()
+ assert "experimental" not in captured.out.lower()
+ assert calls == [("flow.yaml", '{"topic":"AI"}')]
+
+
+def test_run_crew_runs_classic_crew_project(monkeypatch, capsys):
+ calls = []
+
+ monkeypatch.setattr(run_crew_module, "_has_json_crew", lambda: False)
+ monkeypatch.setattr(
+ run_crew_module,
+ "read_toml",
+ lambda: {"tool": {"crewai": {"type": "crew"}}},
+ )
+ monkeypatch.setattr(
+ run_crew_module,
+ "_execute_uv_script",
+ lambda script_name, **kwargs: calls.append((script_name, kwargs)),
+ )
+
+ run_crew_module.run_crew(trained_agents_file="trained.pkl")
+
+ assert capsys.readouterr().out == ""
+ assert calls == [
+ (
+ "run_crew",
+ {"entity_type": "crew", "trained_agents_file": "trained.pkl"},
+ )
+ ]
+
+
+def test_run_crew_runs_python_flow_project(monkeypatch, capsys):
+ 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(
+ run_crew_module,
+ "_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()
+
+ assert capsys.readouterr().out == ""
+ 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(
+ run_crew_module,
+ "read_toml",
+ lambda: {"tool": {"crewai": {"type": "flow"}}},
+ )
+
+ with pytest.raises(click.UsageError) as exc_info:
+ run_crew_module.run_crew(trained_agents_file="trained.pkl")
+
+ assert "--filename can only be used when running crews" in exc_info.value.message
+
+
+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,
+ "read_toml",
+ lambda: {
+ "tool": {
+ "crewai": {
+ "type": "flow",
+ "definition": "flow.yaml",
+ }
+ }
+ },
+ )
+ monkeypatch.setattr(
+ "crewai_cli.run_declarative_flow.run_declarative_flow_in_project_env",
+ lambda definition, inputs=None: calls.append((definition, inputs)),
+ )
+ monkeypatch.setattr(
+ run_crew_module,
+ "_execute_uv_script",
+ lambda *_args, **_kwargs: pytest.fail("declarative flows must not run kickoff"),
+ )
+
+ run_crew_module.run_crew()
+
+ assert capsys.readouterr().out == ""
+ assert calls == [(definition_path.resolve(), None)]
diff --git a/lib/cli/tests/test_run_declarative_flow.py b/lib/cli/tests/test_run_declarative_flow.py
new file mode 100644
index 000000000..8d5435c8f
--- /dev/null
+++ b/lib/cli/tests/test_run_declarative_flow.py
@@ -0,0 +1,148 @@
+from __future__ import annotations
+
+from pathlib import Path
+
+import pytest
+
+import crewai_cli.run_declarative_flow as run_declarative_flow_module
+
+
+FLOW_YAML = """\
+schema: crewai.flow/v1
+name: TestFlow
+config:
+ suppress_flow_events: true
+methods:
+ begin:
+ start: true
+ do:
+ call: expression
+ expr: state.topic
+"""
+
+
+def test_run_declarative_flow_reads_definition_file(
+ tmp_path: Path, capsys: pytest.CaptureFixture[str]
+) -> None:
+ definition_path = tmp_path / "flow.yaml"
+ definition_path.write_text(FLOW_YAML, encoding="utf-8")
+
+ run_declarative_flow_module.run_declarative_flow(
+ str(definition_path), '{"topic":"AI"}'
+ )
+
+ assert capsys.readouterr().out == "AI\n"
+
+
+def test_run_declarative_flow_rejects_non_object_inputs(
+ tmp_path: Path, capsys: pytest.CaptureFixture[str]
+) -> None:
+ definition_path = tmp_path / "flow.yaml"
+ definition_path.write_text(FLOW_YAML, encoding="utf-8")
+
+ with pytest.raises(SystemExit):
+ run_declarative_flow_module.run_declarative_flow(
+ str(definition_path), '["not", "an", "object"]'
+ )
+
+ assert "Invalid --inputs JSON: expected an object." in capsys.readouterr().err
+
+
+def test_run_declarative_flow_reports_missing_file(
+ capsys: pytest.CaptureFixture[str],
+) -> None:
+ with pytest.raises(SystemExit):
+ run_declarative_flow_module.run_declarative_flow("missing-flow.yaml")
+
+ assert (
+ "Invalid --definition path: missing-flow.yaml does not exist."
+ in capsys.readouterr().err
+ )
+
+
+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:
+ subprocess_calls = []
+
+ monkeypatch.chdir(tmp_path)
+ monkeypatch.delenv("UV_RUN_RECURSION_DEPTH", raising=False)
+ (tmp_path / "pyproject.toml").write_text("[project]\nname = 'demo'\n")
+ monkeypatch.setattr(
+ run_declarative_flow_module,
+ "build_env_with_all_tool_credentials",
+ lambda: {"EXISTING": "value"},
+ )
+ monkeypatch.setattr(
+ run_declarative_flow_module.subprocess,
+ "run",
+ lambda command, **kwargs: subprocess_calls.append((command, kwargs)),
+ )
+
+ run_declarative_flow_module.run_declarative_flow_in_project_env("flow.yaml")
+
+ assert subprocess_calls == [
+ (
+ ["uv", "run", "crewai", "run"],
+ {
+ "capture_output": False,
+ "text": True,
+ "check": True,
+ "env": {"EXISTING": "value"},
+ },
+ )
+ ]
+
+
+def test_run_declarative_flow_in_process_inside_uv(
+ monkeypatch: pytest.MonkeyPatch,
+ tmp_path: Path,
+ capsys: pytest.CaptureFixture[str],
+) -> None:
+ monkeypatch.chdir(tmp_path)
+ monkeypatch.setenv("UV_RUN_RECURSION_DEPTH", "1")
+ (tmp_path / "pyproject.toml").write_text("[project]\nname = 'demo'\n")
+ (tmp_path / "flow.yaml").write_text(FLOW_YAML, encoding="utf-8")
+
+ run_declarative_flow_module.run_declarative_flow_in_project_env(
+ "flow.yaml", '{"topic":"AI"}'
+ )
+
+ assert capsys.readouterr().out == "AI\n"
diff --git a/lib/cli/tests/test_run_flow_definition.py b/lib/cli/tests/test_run_flow_definition.py
deleted file mode 100644
index 532f810be..000000000
--- a/lib/cli/tests/test_run_flow_definition.py
+++ /dev/null
@@ -1,156 +0,0 @@
-from __future__ import annotations
-
-import json
-import sys
-import types
-
-import pytest
-import yaml
-
-from crewai_cli.run_flow_definition import run_flow_definition
-
-
-class _FakeFlow:
- def __init__(self, definition):
- self.definition = definition
-
- def kickoff(self, inputs=None):
- return {
- "flow": self.definition["name"],
- "inputs": inputs or {},
- }
-
-
-class _FakeFlowFactory:
- @classmethod
- def from_definition(cls, definition):
- return _FakeFlow(definition)
-
-
-class _FakeFlowDefinition:
- @classmethod
- def from_yaml(cls, source):
- return yaml.safe_load(source)
-
- @classmethod
- def from_json(cls, source):
- return json.loads(source)
-
-
-@pytest.fixture
-def fake_flow_runtime(monkeypatch):
- crewai_module = types.ModuleType("crewai")
- flow_package = types.ModuleType("crewai.flow")
- flow_module = types.ModuleType("crewai.flow.flow")
- flow_definition_module = types.ModuleType("crewai.flow.flow_definition")
-
- flow_module.Flow = _FakeFlowFactory
- flow_definition_module.FlowDefinition = _FakeFlowDefinition
-
- monkeypatch.setitem(sys.modules, "crewai", crewai_module)
- monkeypatch.setitem(sys.modules, "crewai.flow", flow_package)
- monkeypatch.setitem(sys.modules, "crewai.flow.flow", flow_module)
- monkeypatch.setitem(
- sys.modules, "crewai.flow.flow_definition", flow_definition_module
- )
-
-
-def _captured_json(capsys):
- return json.loads(capsys.readouterr().out)
-
-
-def test_run_flow_definition_reads_definition_file(
- tmp_path, capsys, fake_flow_runtime
-):
- definition_path = tmp_path / "flow.yaml"
- definition_path.write_text("schema: crewai.flow/v1\nname: TestFlow\n")
-
- run_flow_definition(str(definition_path), '{"topic":"AI"}')
-
- assert _captured_json(capsys) == {
- "flow": "TestFlow",
- "inputs": {"topic": "AI"},
- }
-
-
-@pytest.mark.parametrize(
- ("definition_source", "expected_flow_name"),
- [
- pytest.param(
- "schema: crewai.flow/v1\nname: InlineFlow\n",
- "InlineFlow",
- id="inline-yaml",
- ),
- pytest.param(
- '{"schema":"crewai.flow/v1","name":"InlineJsonFlow"}',
- "InlineJsonFlow",
- id="inline-json",
- ),
- pytest.param(
- '{"schema":"crewai.flow/v1","name":"' + ("JsonFlow" * 500) + '"}',
- "JsonFlow" * 500,
- id="large-inline-json",
- ),
- ],
-)
-def test_run_flow_definition_accepts_inline_definitions(
- definition_source, expected_flow_name, capsys, fake_flow_runtime
-):
- run_flow_definition(definition_source)
-
- assert _captured_json(capsys) == {"flow": expected_flow_name, "inputs": {}}
-
-
-@pytest.mark.parametrize(
- ("filename", "definition_source", "expected_flow_name"),
- [
- pytest.param(
- "flow.yaml",
- "schema: crewai.flow/v1\nname: YamlFileFlow\n",
- "YamlFileFlow",
- id="yaml-file",
- ),
- pytest.param(
- "flow.json",
- '{"schema":"crewai.flow/v1","name":"JsonFlow"}',
- "JsonFlow",
- id="json-file",
- ),
- ],
-)
-def test_run_flow_definition_accepts_definition_files(
- filename, definition_source, expected_flow_name, tmp_path, capsys, fake_flow_runtime
-):
- definition_path = tmp_path / filename
- definition_path.write_text(definition_source)
-
- run_flow_definition(str(definition_path))
-
- assert _captured_json(capsys) == {"flow": expected_flow_name, "inputs": {}}
-
-
-def test_run_flow_definition_rejects_non_object_inputs(fake_flow_runtime, capsys):
- with pytest.raises(SystemExit):
- run_flow_definition("name: TestFlow", '["not", "an", "object"]')
-
- assert "Invalid --inputs JSON: expected an object." in capsys.readouterr().err
-
-
-def test_run_flow_definition_reports_unreadable_file(
- monkeypatch, tmp_path, capsys, fake_flow_runtime
-):
- definition_path = tmp_path / "flow.yaml"
- definition_path.write_text("schema: crewai.flow/v1\nname: TestFlow\n")
-
- def raise_permission_error(self, *args, **kwargs):
- raise PermissionError("no access")
-
- monkeypatch.setattr("pathlib.Path.read_text", raise_permission_error)
-
- with pytest.raises(SystemExit):
- run_flow_definition(str(definition_path))
-
- err = capsys.readouterr().err
- assert "Unable to read --definition path" in err
- assert str(definition_path) in err
- assert "no access" in err
diff --git a/lib/crewai-core/pyproject.toml b/lib/crewai-core/pyproject.toml
index e641548e4..405dd4d46 100644
--- a/lib/crewai-core/pyproject.toml
+++ b/lib/crewai-core/pyproject.toml
@@ -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",
]
diff --git a/lib/crewai-core/src/crewai_core/__init__.py b/lib/crewai-core/src/crewai_core/__init__.py
index 41eae5edd..b47cad7ab 100644
--- a/lib/crewai-core/src/crewai_core/__init__.py
+++ b/lib/crewai-core/src/crewai_core/__init__.py
@@ -1 +1 @@
-__version__ = "1.14.8a2"
+__version__ = "1.14.8a5"
diff --git a/lib/crewai-files/src/crewai_files/__init__.py b/lib/crewai-files/src/crewai_files/__init__.py
index d1053421d..70165a462 100644
--- a/lib/crewai-files/src/crewai_files/__init__.py
+++ b/lib/crewai-files/src/crewai_files/__init__.py
@@ -152,4 +152,4 @@ __all__ = [
"wrap_file_source",
]
-__version__ = "1.14.8a2"
+__version__ = "1.14.8a5"
diff --git a/lib/crewai-tools/pyproject.toml b/lib/crewai-tools/pyproject.toml
index 0489a4e8a..3dd5faf58 100644
--- a/lib/crewai-tools/pyproject.toml
+++ b/lib/crewai-tools/pyproject.toml
@@ -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",
@@ -131,7 +131,7 @@ postgresql = [
]
bedrock = [
"beautifulsoup4>=4.13.4",
- "bedrock-agentcore>=0.1.0",
+ "bedrock-agentcore>=1.7.0,<1.8.0",
"playwright>=1.52.0",
"nest-asyncio>=1.6.0",
]
diff --git a/lib/crewai-tools/src/crewai_tools/__init__.py b/lib/crewai-tools/src/crewai_tools/__init__.py
index 2f8853585..c4cfbab33 100644
--- a/lib/crewai-tools/src/crewai_tools/__init__.py
+++ b/lib/crewai-tools/src/crewai_tools/__init__.py
@@ -330,4 +330,4 @@ __all__ = [
"ZapierActionTools",
]
-__version__ = "1.14.8a2"
+__version__ = "1.14.8a5"
diff --git a/lib/crewai/pyproject.toml b/lib/crewai/pyproject.toml
index b0cfc7564..a5751278b 100644
--- a/lib/crewai/pyproject.toml
+++ b/lib/crewai/pyproject.toml
@@ -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"
@@ -78,8 +78,8 @@ qdrant = [
"qdrant-client[fastembed]~=1.14.3",
]
aws = [
- "boto3~=1.42.79",
- "aiobotocore~=3.4.0",
+ "boto3~=1.42.90",
+ "aiobotocore~=3.5.0",
]
watson = [
"ibm-watsonx-ai~=1.3.39",
@@ -91,7 +91,7 @@ litellm = [
"litellm>=1.84.0,<2",
]
bedrock = [
- "boto3~=1.42.79",
+ "boto3~=1.42.90",
]
google-genai = [
"google-genai~=1.65.0",
diff --git a/lib/crewai/src/crewai/__init__.py b/lib/crewai/src/crewai/__init__.py
index 1d4e6af3a..3b9745160 100644
--- a/lib/crewai/src/crewai/__init__.py
+++ b/lib/crewai/src/crewai/__init__.py
@@ -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"),
diff --git a/lib/crewai/src/crewai/agents/crew_agent_executor.py b/lib/crewai/src/crewai/agents/crew_agent_executor.py
index 92a1ce5fb..de2315e3a 100644
--- a/lib/crewai/src/crewai/agents/crew_agent_executor.py
+++ b/lib/crewai/src/crewai/agents/crew_agent_executor.py
@@ -57,6 +57,7 @@ from crewai.utilities.agent_utils import (
convert_tools_to_openai_schema,
enforce_rpm_limit,
format_message_for_llm,
+ format_native_tool_output_for_agent,
get_llm_response,
handle_agent_action_core,
handle_context_length,
@@ -907,19 +908,31 @@ class CrewAgentExecutor(BaseAgentExecutor):
):
max_usage_reached = True
+ structured_tool: CrewStructuredTool | None = None
+ if original_tool is not None:
+ for structured in self.tools or []:
+ if getattr(structured, "_original_tool", None) is original_tool:
+ structured_tool = structured
+ break
+ if structured_tool is None:
+ for structured in self.tools or []:
+ if sanitize_tool_name(structured.name) == func_name:
+ structured_tool = structured
+ break
+
+ output_tool = original_tool or structured_tool
+
from_cache = False
result: str = "Tool not found"
+ raw_tool_result: Any = result
input_str = json.dumps(args_dict) if args_dict else ""
- if self.tools_handler and self.tools_handler.cache:
+ if self.tools_handler and self.tools_handler.cache and output_tool is not None:
cached_result = self.tools_handler.cache.read(
tool=func_name, input=input_str
)
if cached_result is not None:
- result = (
- str(cached_result)
- if not isinstance(cached_result, str)
- else cached_result
- )
+ raw_tool_result = cached_result
+ result = format_native_tool_output_for_agent(output_tool, cached_result)
from_cache = True
agent_key = getattr(self.agent, "key", "unknown") if self.agent else "unknown"
@@ -938,18 +951,6 @@ class CrewAgentExecutor(BaseAgentExecutor):
track_delegation_if_needed(func_name, args_dict or {}, self.task)
- structured_tool: CrewStructuredTool | None = None
- if original_tool is not None:
- for structured in self.tools or []:
- if getattr(structured, "_original_tool", None) is original_tool:
- structured_tool = structured
- break
- if structured_tool is None:
- for structured in self.tools or []:
- if sanitize_tool_name(structured.name) == func_name:
- structured_tool = structured
- break
-
hook_blocked = False
before_hook_context = ToolCallHookContext(
tool_name=func_name,
@@ -975,11 +976,18 @@ class CrewAgentExecutor(BaseAgentExecutor):
if hook_blocked:
result = f"Tool execution blocked by hook. Tool: {func_name}"
+ raw_tool_result = result
elif max_usage_reached and original_tool:
result = f"Tool '{func_name}' has reached its usage limit of {original_tool.max_usage_count} times and cannot be used anymore."
- elif not from_cache and func_name in available_functions:
+ raw_tool_result = result
+ elif (
+ not from_cache
+ and func_name in available_functions
+ and output_tool is not None
+ ):
try:
raw_result = available_functions[func_name](**(args_dict or {}))
+ raw_tool_result = raw_result
if self.tools_handler and self.tools_handler.cache:
should_cache = True
@@ -996,11 +1004,10 @@ class CrewAgentExecutor(BaseAgentExecutor):
tool=func_name, input=input_str, output=raw_result
)
- result = (
- str(raw_result) if not isinstance(raw_result, str) else raw_result
- )
+ result = format_native_tool_output_for_agent(output_tool, raw_result)
except Exception as e:
result = f"Error executing tool: {e}"
+ raw_tool_result = result
if self.task:
self.task.increment_tools_errors()
crewai_event_bus.emit(
@@ -1024,6 +1031,7 @@ class CrewAgentExecutor(BaseAgentExecutor):
task=self.task,
crew=self.crew,
tool_result=result,
+ raw_tool_result=raw_tool_result,
)
after_hooks = get_after_tool_call_hooks()
try:
diff --git a/lib/crewai/src/crewai/agents/tools_handler.py b/lib/crewai/src/crewai/agents/tools_handler.py
index 8ab759b85..c56226bc1 100644
--- a/lib/crewai/src/crewai/agents/tools_handler.py
+++ b/lib/crewai/src/crewai/agents/tools_handler.py
@@ -3,6 +3,7 @@
from __future__ import annotations
import json
+from typing import Any
from pydantic import BaseModel, Field
@@ -25,14 +26,14 @@ class ToolsHandler(BaseModel):
def on_tool_use(
self,
calling: ToolCalling | InstructorToolCalling,
- output: str,
+ output: Any,
should_cache: bool = True,
) -> None:
"""Run when tool ends running.
Args:
calling: The tool calling instance.
- output: The output from the tool execution.
+ output: The raw output from the tool execution.
should_cache: Whether to cache the tool output.
"""
self.last_used_tool = calling
diff --git a/lib/crewai/src/crewai/events/utils/console_formatter.py b/lib/crewai/src/crewai/events/utils/console_formatter.py
index fdecf93cf..604c7b051 100644
--- a/lib/crewai/src/crewai/events/utils/console_formatter.py
+++ b/lib/crewai/src/crewai/events/utils/console_formatter.py
@@ -373,9 +373,6 @@ To enable tracing, do any one of these:
status: str = "running",
) -> None:
"""Show method status panel."""
- if not self.verbose:
- return
-
if status == "running":
style = "yellow"
panel_title = "🔄 Flow Method Running"
diff --git a/lib/crewai/src/crewai/experimental/agent_executor.py b/lib/crewai/src/crewai/experimental/agent_executor.py
index c026c7509..0ecd8e63a 100644
--- a/lib/crewai/src/crewai/experimental/agent_executor.py
+++ b/lib/crewai/src/crewai/experimental/agent_executor.py
@@ -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,
@@ -80,6 +80,7 @@ from crewai.utilities.agent_utils import (
enforce_rpm_limit,
extract_tool_call_info,
format_message_for_llm,
+ format_native_tool_output_for_agent,
get_llm_response,
handle_agent_action_core,
handle_context_length,
@@ -275,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."""
@@ -1905,19 +1901,32 @@ class AgentExecutor(Flow[AgentExecutorState], BaseAgentExecutor):
):
max_usage_reached = True
+ structured_tool: CrewStructuredTool | None = None
+ if original_tool is not None:
+ for structured in self.tools or []:
+ if getattr(structured, "_original_tool", None) is original_tool:
+ structured_tool = structured
+ break
+ if structured_tool is None:
+ for structured in self.tools or []:
+ if sanitize_tool_name(structured.name) == func_name:
+ structured_tool = structured
+ break
+
+ output_tool = original_tool or structured_tool
+
# Check cache before executing
from_cache = False
+ result = "Tool not found"
+ raw_tool_result: Any = result
input_str = json.dumps(args_dict) if args_dict else ""
- if self.tools_handler and self.tools_handler.cache:
+ if self.tools_handler and self.tools_handler.cache and output_tool is not None:
cached_result = self.tools_handler.cache.read(
tool=func_name, input=input_str
)
if cached_result is not None:
- result = (
- str(cached_result)
- if not isinstance(cached_result, str)
- else cached_result
- )
+ raw_tool_result = cached_result
+ result = format_native_tool_output_for_agent(output_tool, cached_result)
from_cache = True
# Emit tool usage started event
@@ -1936,18 +1945,6 @@ class AgentExecutor(Flow[AgentExecutorState], BaseAgentExecutor):
track_delegation_if_needed(func_name, args_dict, self.task)
- structured_tool: CrewStructuredTool | None = None
- if original_tool is not None:
- for structured in self.tools or []:
- if getattr(structured, "_original_tool", None) is original_tool:
- structured_tool = structured
- break
- if structured_tool is None:
- for structured in self.tools or []:
- if sanitize_tool_name(structured.name) == func_name:
- structured_tool = structured
- break
-
hook_blocked = False
before_hook_context = ToolCallHookContext(
tool_name=func_name,
@@ -1973,12 +1970,13 @@ class AgentExecutor(Flow[AgentExecutorState], BaseAgentExecutor):
if hook_blocked:
result = f"Tool execution blocked by hook. Tool: {func_name}"
- elif not from_cache and not max_usage_reached:
- result = "Tool not found"
+ raw_tool_result = result
+ elif not from_cache and not max_usage_reached and output_tool is not None:
if func_name in self._available_functions:
try:
tool_func = self._available_functions[func_name]
raw_result = tool_func(**args_dict)
+ raw_tool_result = raw_result
# Add to cache after successful execution (before string conversion)
if self.tools_handler and self.tools_handler.cache:
@@ -1992,14 +1990,12 @@ class AgentExecutor(Flow[AgentExecutorState], BaseAgentExecutor):
tool=func_name, input=input_str, output=raw_result
)
- # Convert to string for message
- result = (
- str(raw_result)
- if not isinstance(raw_result, str)
- else raw_result
+ result = format_native_tool_output_for_agent(
+ output_tool, raw_result
)
except Exception as e:
result = f"Error executing tool: {e}"
+ raw_tool_result = result
if self.task:
self.task.increment_tools_errors()
# Emit tool usage error event
@@ -2021,6 +2017,7 @@ class AgentExecutor(Flow[AgentExecutorState], BaseAgentExecutor):
result = f"Tool '{func_name}' has reached its usage limit of {original_tool.max_usage_count} times and cannot be used anymore."
else:
result = f"Tool '{func_name}' has reached its maximum usage limit and cannot be used anymore."
+ raw_tool_result = result
# Execute after_tool_call hooks (even if blocked, to allow logging/monitoring)
after_hook_context = ToolCallHookContext(
@@ -2031,6 +2028,7 @@ class AgentExecutor(Flow[AgentExecutorState], BaseAgentExecutor):
task=self.task,
crew=self.crew,
tool_result=result,
+ raw_tool_result=raw_tool_result,
)
after_hooks = get_after_tool_call_hooks()
try:
diff --git a/lib/crewai/src/crewai/experimental/skills/cache.py b/lib/crewai/src/crewai/experimental/skills/cache.py
index e9752c4e8..065c2c521 100644
--- a/lib/crewai/src/crewai/experimental/skills/cache.py
+++ b/lib/crewai/src/crewai/experimental/skills/cache.py
@@ -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
diff --git a/lib/crewai/src/crewai/flow/dsl/_listen.py b/lib/crewai/src/crewai/flow/dsl/_listen.py
index 37c9a9d25..b964532a2 100644
--- a/lib/crewai/src/crewai/flow/dsl/_listen.py
+++ b/lib/crewai/src/crewai/flow/dsl/_listen.py
@@ -8,8 +8,8 @@ from crewai.flow.dsl._types import FlowMethodDecorator, FlowTrigger
from crewai.flow.dsl._utils import (
P,
R,
+ _merge_flow_method_definition,
_method_action,
- _set_flow_method_definition,
)
from crewai.flow.flow_definition import FlowMethodDefinition
from crewai.flow.flow_wrappers import ListenMethod
@@ -45,7 +45,7 @@ def listen(condition: FlowTrigger) -> FlowMethodDecorator:
def decorator(func: Callable[P, R]) -> ListenMethod[P, R]:
wrapper = ListenMethod(func)
- _set_flow_method_definition(
+ _merge_flow_method_definition(
wrapper,
FlowMethodDefinition(
do=_method_action(func),
diff --git a/lib/crewai/src/crewai/flow/dsl/_router.py b/lib/crewai/src/crewai/flow/dsl/_router.py
index 3edbf33ba..7f9941e42 100644
--- a/lib/crewai/src/crewai/flow/dsl/_router.py
+++ b/lib/crewai/src/crewai/flow/dsl/_router.py
@@ -19,8 +19,8 @@ from crewai.flow.dsl._types import FlowMethodDecorator, FlowTrigger
from crewai.flow.dsl._utils import (
P,
R,
+ _merge_flow_method_definition,
_method_action,
- _set_flow_method_definition,
)
from crewai.flow.flow_definition import FlowMethodDefinition
from crewai.flow.flow_wrappers import RouterMethod
@@ -95,7 +95,7 @@ def _normalize_router_emit(value: Sequence[Any] | str) -> list[str]:
def router(
- condition: FlowTrigger,
+ condition: FlowTrigger | None = None,
*,
emit: Sequence[str] | str | None = None,
) -> FlowMethodDecorator:
@@ -107,6 +107,7 @@ def router(
Args:
condition: Specifies when the router should execute. Can be:
+ - None: no listen trigger, used when stacking with @start() or @listen()
- str: Route label or method name that triggers this router
- FlowCondition: Result from or_() or and_(), including nested conditions
- Flow method reference: A method whose completion triggers this router
@@ -146,14 +147,17 @@ def router(
else:
router_events = _get_router_return_events(func) or []
- _set_flow_method_definition(
+ method_definition_kwargs: dict[str, Any] = {
+ "do": _method_action(func),
+ "router": True,
+ "emit": router_events or None,
+ }
+ if condition is not None:
+ method_definition_kwargs["listen"] = _to_definition_condition(condition)
+
+ _merge_flow_method_definition(
wrapper,
- FlowMethodDefinition(
- do=_method_action(func),
- listen=_to_definition_condition(condition),
- router=True,
- emit=router_events or None,
- ),
+ FlowMethodDefinition(**method_definition_kwargs),
)
return wrapper
diff --git a/lib/crewai/src/crewai/flow/dsl/_start.py b/lib/crewai/src/crewai/flow/dsl/_start.py
index fe9f82974..5a41bc9df 100644
--- a/lib/crewai/src/crewai/flow/dsl/_start.py
+++ b/lib/crewai/src/crewai/flow/dsl/_start.py
@@ -8,8 +8,8 @@ from crewai.flow.dsl._types import FlowMethodDecorator, FlowTrigger
from crewai.flow.dsl._utils import (
P,
R,
+ _merge_flow_method_definition,
_method_action,
- _set_flow_method_definition,
)
from crewai.flow.flow_definition import FlowMethodDefinition
from crewai.flow.flow_wrappers import StartMethod
@@ -54,7 +54,7 @@ def start(
def decorator(func: Callable[P, R]) -> StartMethod[P, R]:
wrapper = StartMethod(func)
- _set_flow_method_definition(
+ _merge_flow_method_definition(
wrapper,
FlowMethodDefinition(
do=_method_action(func),
diff --git a/lib/crewai/src/crewai/flow/dsl/_utils.py b/lib/crewai/src/crewai/flow/dsl/_utils.py
index 99d60a9e3..684264b28 100644
--- a/lib/crewai/src/crewai/flow/dsl/_utils.py
+++ b/lib/crewai/src/crewai/flow/dsl/_utils.py
@@ -106,6 +106,25 @@ def _get_flow_method_definition(method: Any) -> FlowMethodDefinition | None:
return None
+def _merge_flow_method_definition(
+ wrapper: FlowMethod[P, R],
+ definition: FlowMethodDefinition,
+) -> None:
+ existing = _get_flow_method_definition(wrapper)
+ if existing is None:
+ _set_flow_method_definition(wrapper, definition)
+ return
+
+ updates = {
+ field_name: getattr(definition, field_name)
+ for field_name in definition.model_fields_set
+ }
+ _set_flow_method_definition(
+ wrapper,
+ existing.model_copy(deep=True, update=updates),
+ )
+
+
def _is_json_serializable(value: Any) -> bool:
try:
json.dumps(value)
diff --git a/lib/crewai/src/crewai/flow/flow.py b/lib/crewai/src/crewai/flow/flow.py
index 19c161ffb..8635e2953 100644
--- a/lib/crewai/src/crewai/flow/flow.py
+++ b/lib/crewai/src/crewai/flow/flow.py
@@ -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_",
diff --git a/lib/crewai/src/crewai/flow/flow_definition.py b/lib/crewai/src/crewai/flow/flow_definition.py
index a566fcadd..fa9c89ea8 100644
--- a/lib/crewai/src/crewai/flow/flow_definition.py
+++ b/lib/crewai/src/crewai/flow/flow_definition.py
@@ -1,7 +1,7 @@
-"""Flow Structure: the serializable, language-agnostic Flow contract.
+"""Flow Definition: the serializable, declarative Flow contract.
-Defines :class:`FlowDefinition` and its sub-models — a static, textual
-(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``).
@@ -11,6 +11,7 @@ from __future__ import annotations
import json
import logging
+from pathlib import Path
import re
from typing import Annotated, Any, Literal, TypeAlias, cast
@@ -18,6 +19,7 @@ from pydantic import (
BaseModel,
ConfigDict,
Field,
+ PrivateAttr,
field_serializer,
model_validator,
)
@@ -233,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.
"""
@@ -273,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``).
"""
@@ -406,10 +408,19 @@ class FlowCrewActionDefinition(BaseModel):
)
call: Literal["crew"] = Field(
- description="Action discriminator. Use crew to run an inline Crew definition.",
+ description=(
+ "Action discriminator. Use crew to run an inline or referenced Crew "
+ "definition."
+ ),
examples=["crew"],
)
- with_: CrewDefinition = Field(
+ from_declaration: str | None = Field(
+ default=None,
+ description="Path to a JSON/JSONC Crew declaration file or folder.",
+ examples=["crews/research_crew"],
+ )
+ with_: CrewDefinition | None = Field(
+ default=None,
alias="with",
description="Inline Crew definition to load and execute for this action.",
examples=[
@@ -430,10 +441,26 @@ class FlowCrewActionDefinition(BaseModel):
"agent": "researcher",
}
],
- "inputs": {"topic": "${state.topic}"},
}
],
)
+ inputs: dict[str, ExpressionData] | None = Field(
+ default=None,
+ description=(
+ "Input overrides passed to the Crew. String values are evaluated as CEL "
+ "only when the trimmed value starts with ${ and ends with }; all other "
+ "values are literal."
+ ),
+ examples=[{"topic": "${state.topic}"}],
+ )
+
+ @model_validator(mode="after")
+ def _validate_crew_source(self) -> FlowCrewActionDefinition:
+ if bool(self.from_declaration) == (self.with_ is not None):
+ raise ValueError(
+ "crew action requires exactly one of from_declaration or with"
+ )
+ return self
class FlowAgentActionDefinition(BaseModel):
@@ -684,10 +711,12 @@ class FlowDefinition(BaseModel):
arbitrary_types_allowed=True,
)
+ _source_path: Path | None = PrivateAttr(default=None)
+
schema_: Literal["crewai.flow/v1"] = Field(
default="crewai.flow/v1",
alias="schema",
- description="Flow Definition schema identifier and version.",
+ description="Declarative Flow schema identifier and version.",
examples=["crewai.flow/v1"],
)
name: str = Field(
@@ -748,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:
@@ -764,29 +793,66 @@ class FlowDefinition(BaseModel):
allow_unicode=True,
)
+ @property
+ def source_path(self) -> Path | None:
+ """Original definition file path, when loaded from a file."""
+ return self._source_path
+
+ @property
+ def source_dir(self) -> Path | None:
+ """Directory used to resolve relative paths in the definition."""
+ if self._source_path is None:
+ return None
+ return self._source_path.parent
+
@classmethod
- def from_dict(cls, data: dict[str, Any]) -> FlowDefinition:
+ def from_dict(
+ cls, data: dict[str, Any], *, source_path: Path | None = None
+ ) -> FlowDefinition:
"""Load a definition from a dictionary."""
definition = cls.model_validate(data)
+ if source_path is not None:
+ definition._source_path = source_path.expanduser().resolve()
log_flow_definition_issues(definition)
return definition
@classmethod
- def from_json(cls, data: str) -> FlowDefinition:
- """Load a definition from JSON."""
- return cls.from_dict(json.loads(data))
+ 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) -> 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")
- return cls.from_dict(loaded)
+ raise ValueError("Flow declaration must contain a mapping")
+ return cls.from_dict(loaded, source_path=source_path)
@classmethod
def json_schema(cls) -> dict[str, Any]:
- """Return the JSON Schema for the Flow Definition contract."""
+ """Return the JSON Schema for the declarative Flow contract."""
return cls.model_json_schema(by_alias=True)
@@ -826,10 +892,16 @@ def _validate_action_cel(
return
if isinstance(action, FlowCrewActionDefinition):
- Expression(cast(ExpressionData, action.with_.inputs)).validate_template(
- allowed_roots=allowed_roots,
- source=f"{path}.with.inputs",
- )
+ if action.with_ is not None:
+ Expression(cast(ExpressionData, action.with_.inputs)).validate_template(
+ allowed_roots=allowed_roots,
+ source=f"{path}.with.inputs",
+ )
+ if action.inputs is not None:
+ Expression(cast(ExpressionData, action.inputs)).validate_template(
+ allowed_roots=allowed_roots,
+ source=f"{path}.inputs",
+ )
return
if isinstance(action, FlowAgentActionDefinition):
@@ -870,14 +942,6 @@ def _validate_action_cel(
def log_flow_definition_issues(definition: FlowDefinition) -> None:
for method_name, method in definition.methods.items():
path = f"methods.{method_name}"
- if method.router and not method.is_start and method.listen is None:
- _log_flow_definition_issue(
- definition.name,
- code="router_without_trigger",
- severity="error",
- path=path,
- message="router: true requires either start or listen",
- )
if method.emit and not method.router:
_log_flow_definition_issue(
definition.name,
diff --git a/lib/crewai/src/crewai/flow/runtime/__init__.py b/lib/crewai/src/crewai/flow/runtime/__init__.py
index 85f150546..8de5be409 100644
--- a/lib/crewai/src/crewai/flow/runtime/__init__.py
+++ b/lib/crewai/src/crewai/flow/runtime/__init__.py
@@ -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]:
@@ -2455,11 +2183,6 @@ class Flow(BaseModel, Generic[T], metaclass=FlowMeta):
object.__setattr__(
self, "_deferred_flow_started_event_id", started_event.event_id
)
- if not self.suppress_flow_events:
- self._log_flow_event(
- f"Flow started with ID: {self.flow_id}", color="bold magenta"
- )
-
# After FlowStarted: env events must not pre-empt trace batch init
# with implicit "crew" execution_type.
get_env_context()
@@ -3007,6 +2730,7 @@ class Flow(BaseModel, Generic[T], metaclass=FlowMeta):
"""
# First, handle routers repeatedly until no router triggers anymore
router_results = []
+ router_result_payloads: dict[str, Any] = {}
router_result_to_feedback: dict[
str, Any
] = {} # Map outcome -> HumanFeedbackResult
@@ -3044,6 +2768,11 @@ class Flow(BaseModel, Generic[T], metaclass=FlowMeta):
router_result_str = str(router_result)
router_result_event = FlowMethodName(router_result_str)
router_results.append(router_result_event)
+ router_result_payloads[router_result_str] = (
+ self.last_human_feedback
+ if self.last_human_feedback is not None
+ else router_result
+ )
if self.last_human_feedback is not None:
router_result_to_feedback[router_result_str] = (
@@ -3064,7 +2793,7 @@ class Flow(BaseModel, Generic[T], metaclass=FlowMeta):
current_trigger, router_only=False
)
if listeners_triggered:
- listener_result = router_result_to_feedback.get(
+ listener_result = router_result_payloads.get(
str(current_trigger), result
)
racing_group = self._get_racing_group_for_listeners(
@@ -3583,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
diff --git a/lib/crewai/src/crewai/flow/runtime/_actions.py b/lib/crewai/src/crewai/flow/runtime/_actions.py
index c437e274b..9b5c8831f 100644
--- a/lib/crewai/src/crewai/flow/runtime/_actions.py
+++ b/lib/crewai/src/crewai/flow/runtime/_actions.py
@@ -8,6 +8,7 @@ from collections.abc import Awaitable, Callable
import contextvars
import inspect
import os
+from pathlib import Path
from typing import TYPE_CHECKING, Any, Protocol, cast
from crewai.flow.expressions import Expression, ExpressionData
@@ -23,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:
@@ -102,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(
@@ -128,16 +134,34 @@ class CrewAction:
self.definition = definition
async def run(self, *_args: Any, **kwargs: Any) -> Any:
- from crewai.project.crew_loader import load_crew_from_definition
+ from crewai.project.crew_loader import load_crew, load_crew_from_definition
local_context = _pop_local_context(kwargs)
- crew_definition = self.definition.with_
+ if self.definition.from_declaration is not None:
+ crew, default_inputs = load_crew(
+ _resolve_crew_declaration(
+ self.definition.from_declaration,
+ base_dir=self.flow._definition.source_dir,
+ )
+ )
+ input_template = {**default_inputs, **(self.definition.inputs or {})}
+ else:
+ crew_definition = self.definition.with_
+ if crew_definition is None:
+ raise ValueError(
+ "crew action requires exactly one of from_declaration or with"
+ )
+ input_template = {
+ **crew_definition.inputs,
+ **(self.definition.inputs or {}),
+ }
+ crew, _ = load_crew_from_definition(crew_definition, source="crew action")
+
inputs = Expression.from_flow(
- cast(ExpressionData, crew_definition.inputs),
+ cast(ExpressionData, input_template),
self.flow,
local_context=local_context,
).render_template()
- crew, _ = load_crew_from_definition(crew_definition, source="crew action")
return await crew.kickoff_async(inputs=inputs)
@@ -359,3 +383,29 @@ def _pop_local_context(kwargs: dict[str, Any]) -> LocalContext | None:
if not isinstance(local_context, dict):
raise TypeError("flow definition local context must be a mapping")
return cast(LocalContext, local_context)
+
+
+def _resolve_crew_declaration(
+ from_declaration: str, *, base_dir: Path | None = None
+) -> Path:
+ path = Path(from_declaration).expanduser()
+ if base_dir is not None:
+ resolved_base_dir = base_dir.expanduser().resolve()
+ if not path.is_absolute():
+ path = resolved_base_dir / path
+ resolved_path = path.resolve()
+ if not resolved_path.is_relative_to(resolved_base_dir):
+ raise ValueError(
+ "crew declaration path must be within the flow definition directory"
+ )
+ path = resolved_path
+
+ if not path.is_dir():
+ return path
+
+ for name in ("crew.jsonc", "crew.json"):
+ candidate = path / name
+ if candidate.is_file():
+ return candidate
+
+ return path / "crew.jsonc"
diff --git a/lib/crewai/src/crewai/flow/runtime/_refs.py b/lib/crewai/src/crewai/flow/runtime/_refs.py
deleted file mode 100644
index 23ddafadb..000000000
--- a/lib/crewai/src/crewai/flow/runtime/_refs.py
+++ /dev/null
@@ -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
diff --git a/lib/crewai/src/crewai/hooks/tool_hooks.py b/lib/crewai/src/crewai/hooks/tool_hooks.py
index b54ca7b10..a860c01d4 100644
--- a/lib/crewai/src/crewai/hooks/tool_hooks.py
+++ b/lib/crewai/src/crewai/hooks/tool_hooks.py
@@ -40,6 +40,8 @@ class ToolCallHookContext:
crew: Crew instance (may be None)
tool_result: Tool execution result (only set for after_tool_call hooks).
Can be modified by returning a new string from after_tool_call hook.
+ raw_tool_result: Raw Python tool execution result (only set for
+ after_tool_call hooks). This is not modified by after hooks.
"""
def __init__(
@@ -51,6 +53,7 @@ class ToolCallHookContext:
task: Task | None = None,
crew: Crew | None = None,
tool_result: str | None = None,
+ raw_tool_result: Any | None = None,
) -> None:
"""Initialize tool call hook context.
@@ -62,6 +65,7 @@ class ToolCallHookContext:
task: Optional current task
crew: Optional crew instance
tool_result: Optional tool result (for after hooks)
+ raw_tool_result: Optional raw tool result (for after hooks)
"""
self.tool_name = tool_name
self.tool_input = tool_input
@@ -70,6 +74,7 @@ class ToolCallHookContext:
self.task = task
self.crew = crew
self.tool_result = tool_result
+ self.raw_tool_result = raw_tool_result
def request_human_input(
self,
diff --git a/lib/crewai/src/crewai/project/json_loader.py b/lib/crewai/src/crewai/project/json_loader.py
index 0c6d7cbba..2c2a229fb 100644
--- a/lib/crewai/src/crewai/project/json_loader.py
+++ b/lib/crewai/src/crewai/project/json_loader.py
@@ -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:' prefix for a tool defined in tools/.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:' prefix for a tool defined in "
+ f"tools/.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] = {}
diff --git a/lib/crewai/src/crewai/tools/base_tool.py b/lib/crewai/src/crewai/tools/base_tool.py
index 31c5009bd..c6c3dba15 100644
--- a/lib/crewai/src/crewai/tools/base_tool.py
+++ b/lib/crewai/src/crewai/tools/base_tool.py
@@ -33,6 +33,8 @@ from typing_extensions import TypeIs
from crewai.tools.structured_tool import (
CrewStructuredTool,
_deserialize_schema,
+ _format_tool_output_for_agent,
+ _infer_result_schema_from_callable,
_serialize_schema,
build_schema_hint,
)
@@ -149,6 +151,11 @@ class BaseTool(BaseModel, ABC):
validate_default=True,
description="The schema for the arguments that the tool accepts.",
)
+ result_schema: type[PydanticBaseModel] | None = Field(
+ default=None,
+ validate_default=True,
+ description="The schema for the output that the tool returns.",
+ )
@field_serializer("args_schema", when_used="json")
def _serialize_args_schema(
@@ -156,6 +163,12 @@ class BaseTool(BaseModel, ABC):
) -> dict[str, Any] | None:
return _serialize_schema(schema)
+ @field_serializer("result_schema", when_used="json")
+ def _serialize_result_schema(
+ self, schema: type[PydanticBaseModel] | None
+ ) -> dict[str, Any] | None:
+ return _serialize_schema(schema)
+
description_updated: bool = Field(
default=False, description="Flag to check if the description has been updated."
)
@@ -233,6 +246,17 @@ class BaseTool(BaseModel, ABC):
return create_model(f"{cls.__name__}Schema", **fields)
+ @field_validator("result_schema", mode="before")
+ @classmethod
+ def _default_result_schema(
+ cls, v: type[PydanticBaseModel] | dict[str, Any] | None
+ ) -> type[PydanticBaseModel] | None:
+ if isinstance(v, dict):
+ return _deserialize_schema(v)
+ if v is not None:
+ return v
+ return _infer_result_schema_from_callable(cls._run)
+
@field_validator("max_usage_count", mode="before")
@classmethod
def validate_max_usage_count(cls, v: int | None) -> int | None:
@@ -340,6 +364,10 @@ class BaseTool(BaseModel, ABC):
"Override _arun for async support or use run() for sync execution."
)
+ def format_output_for_agent(self, raw_result: Any) -> str:
+ """Format a raw tool result into the string representation sent to an agent."""
+ return _format_tool_output_for_agent(self, raw_result)
+
def reset_usage_count(self) -> None:
"""Reset the current usage count to zero."""
self.current_usage_count = 0
@@ -369,6 +397,7 @@ class BaseTool(BaseModel, ABC):
name=self.name,
description=self.description,
args_schema=self.args_schema,
+ result_schema=self.result_schema,
func=self._run,
result_as_answer=self.result_as_answer,
max_usage_count=self.max_usage_count,
@@ -390,6 +419,9 @@ class BaseTool(BaseModel, ABC):
raise ValueError("The provided tool must have a callable 'func' attribute.")
args_schema = getattr(tool, "args_schema", None)
+ result_schema = getattr(tool, "result_schema", None)
+ if result_schema is None:
+ result_schema = _infer_result_schema_from_callable(tool.func)
if args_schema is None:
func_signature = signature(tool.func)
@@ -420,6 +452,7 @@ class BaseTool(BaseModel, ABC):
description=getattr(tool, "description", ""),
func=tool.func,
args_schema=args_schema,
+ result_schema=result_schema,
)
def _set_args_schema(self) -> None:
@@ -568,6 +601,9 @@ class Tool(BaseTool, Generic[P, R]):
raise ValueError("The provided tool must have a callable 'func' attribute.")
args_schema = getattr(tool, "args_schema", None)
+ result_schema = getattr(tool, "result_schema", None)
+ if result_schema is None:
+ result_schema = _infer_result_schema_from_callable(tool.func)
if args_schema is None:
func_signature = signature(tool.func)
@@ -598,6 +634,7 @@ class Tool(BaseTool, Generic[P, R]):
description=getattr(tool, "description", ""),
func=tool.func,
args_schema=args_schema,
+ result_schema=result_schema,
)
@@ -621,6 +658,7 @@ def tool(
name: str,
/,
*,
+ result_schema: type[BaseModel] | None = ...,
result_as_answer: bool = ...,
max_usage_count: int | None = ...,
) -> Callable[[Callable[P2, R2]], Tool[P2, R2]]: ...
@@ -629,6 +667,7 @@ def tool(
@overload
def tool(
*,
+ result_schema: type[BaseModel] | None = ...,
result_as_answer: bool = ...,
max_usage_count: int | None = ...,
) -> Callable[[Callable[P2, R2]], Tool[P2, R2]]: ...
@@ -636,6 +675,7 @@ def tool(
def tool(
*args: Callable[P2, R2] | str,
+ result_schema: type[BaseModel] | None = None,
result_as_answer: bool = False,
max_usage_count: int | None = None,
) -> Tool[P2, R2] | Callable[[Callable[P2, R2]], Tool[P2, R2]]:
@@ -649,6 +689,7 @@ def tool(
Args:
*args: Either the function to decorate or a custom tool name.
result_as_answer: If True, the tool result becomes the final agent answer.
+ result_schema: Optional schema for the output that the tool returns.
max_usage_count: Maximum times this tool can be used. None means unlimited.
Returns:
@@ -690,12 +731,16 @@ def tool(
class_name = "".join(tool_name.split()).title()
args_schema = create_model(class_name, **fields)
+ resolved_result_schema = (
+ result_schema or _infer_result_schema_from_callable(f)
+ )
return Tool(
name=tool_name,
description=f.__doc__,
func=f,
args_schema=args_schema,
+ result_schema=resolved_result_schema,
result_as_answer=result_as_answer,
max_usage_count=max_usage_count,
current_usage_count=0,
diff --git a/lib/crewai/src/crewai/tools/structured_tool.py b/lib/crewai/src/crewai/tools/structured_tool.py
index 6c24f52dc..8ecba8549 100644
--- a/lib/crewai/src/crewai/tools/structured_tool.py
+++ b/lib/crewai/src/crewai/tools/structured_tool.py
@@ -5,7 +5,8 @@ from collections.abc import Callable
import inspect
import json
import textwrap
-from typing import TYPE_CHECKING, Annotated, Any, get_type_hints
+from typing import TYPE_CHECKING, Annotated, Any, cast, get_type_hints
+import warnings
from pydantic import (
BaseModel,
@@ -36,6 +37,52 @@ def _deserialize_schema(v: Any) -> type[BaseModel] | None:
return None
+def _infer_result_schema_from_callable(
+ func: Callable[..., Any],
+) -> type[BaseModel] | None:
+ try:
+ return_annotation = get_type_hints(func).get("return", inspect.Signature.empty)
+ except Exception:
+ return_annotation = inspect.signature(func).return_annotation
+
+ if isinstance(return_annotation, type) and issubclass(return_annotation, BaseModel):
+ return return_annotation
+
+ return None
+
+
+def _format_tool_output_for_agent(tool: Any, raw_result: Any) -> str:
+ original_tool = getattr(tool, "_original_tool", None)
+ if original_tool is not None:
+ return cast(str, original_tool.format_output_for_agent(raw_result))
+
+ result_schema = getattr(tool, "result_schema", None)
+ if not (isinstance(result_schema, type) and issubclass(result_schema, BaseModel)):
+ return str(raw_result)
+
+ try:
+ validation_input = raw_result
+ if isinstance(raw_result, BaseModel) and not isinstance(
+ raw_result, result_schema
+ ):
+ validation_input = raw_result.model_dump()
+
+ validated = result_schema.model_validate(validation_input)
+ return validated.model_dump_json()
+ except Exception as exc:
+ warnings.warn(
+ (
+ f"Failed to validate or serialize output from tool "
+ f"'{getattr(tool, 'name', '')}' using result_schema "
+ f"'{result_schema.__name__}': {exc.__class__.__name__}. "
+ "Falling back to str(raw_result)."
+ ),
+ RuntimeWarning,
+ stacklevel=2,
+ )
+ return str(raw_result)
+
+
if TYPE_CHECKING:
pass
@@ -81,6 +128,11 @@ class CrewStructuredTool(BaseModel):
BeforeValidator(_deserialize_schema),
PlainSerializer(_serialize_schema),
] = Field(default=None)
+ result_schema: Annotated[
+ type[BaseModel] | None,
+ BeforeValidator(_deserialize_schema),
+ PlainSerializer(_serialize_schema),
+ ] = Field(default=None)
func: Any = Field(default=None, exclude=True)
result_as_answer: bool = Field(default=False)
max_usage_count: int | None = Field(default=None)
@@ -103,6 +155,7 @@ class CrewStructuredTool(BaseModel):
description: str | None = None,
return_direct: bool = False,
args_schema: type[BaseModel] | None = None,
+ result_schema: type[BaseModel] | None = None,
infer_schema: bool = True,
**kwargs: Any,
) -> CrewStructuredTool:
@@ -114,6 +167,7 @@ class CrewStructuredTool(BaseModel):
description: The description of the tool. Defaults to the function docstring
return_direct: Whether to return the output directly
args_schema: Optional schema for the function arguments
+ result_schema: Optional schema for the function output
infer_schema: Whether to infer the schema from the function signature
**kwargs: Additional arguments to pass to the tool
@@ -149,10 +203,16 @@ class CrewStructuredTool(BaseModel):
name=name,
description=description,
args_schema=schema,
+ result_schema=result_schema or _infer_result_schema_from_callable(func),
func=func,
result_as_answer=return_direct,
+ **kwargs,
)
+ def format_output_for_agent(self, raw_result: Any) -> str:
+ """Format a raw tool result into the string representation sent to an agent."""
+ return _format_tool_output_for_agent(self, raw_result)
+
@staticmethod
def _create_schema_from_function(
name: str,
diff --git a/lib/crewai/src/crewai/tools/tool_usage.py b/lib/crewai/src/crewai/tools/tool_usage.py
index b34921839..e92ba03ee 100644
--- a/lib/crewai/src/crewai/tools/tool_usage.py
+++ b/lib/crewai/src/crewai/tools/tool_usage.py
@@ -62,6 +62,9 @@ OPENAI_BIGGER_MODELS: list[
]
+_RAW_RESULT_UNSET = object()
+
+
class ToolUsageError(Exception):
"""Exception raised for errors in the tool usage."""
@@ -106,6 +109,7 @@ class ToolUsage:
self.action = action
self.function_calling_llm = function_calling_llm
self.fingerprint_context = fingerprint_context or {}
+ self.last_raw_result: Any = _RAW_RESULT_UNSET
if (
self.function_calling_llm
@@ -120,6 +124,11 @@ class ToolUsage:
"""Parse the tool string and return the tool calling."""
return self._tool_calling(tool_string)
+ def get_last_raw_result(self, fallback: Any) -> Any:
+ if self.last_raw_result is _RAW_RESULT_UNSET:
+ return fallback
+ return self.last_raw_result
+
def use(
self, calling: ToolCalling | InstructorToolCalling, tool_string: str
) -> str:
@@ -231,6 +240,7 @@ class ToolUsage:
result = I18N_DEFAULT.errors("task_repeated_usage").format(
tool_names=self.tools_names
)
+ self.last_raw_result = result
self._telemetry.tool_repeated_usage(
llm=self.function_calling_llm,
tool_name=sanitize_tool_name(tool.name),
@@ -298,6 +308,7 @@ class ToolUsage:
)
if usage_limit_error:
result = usage_limit_error
+ self.last_raw_result = result
self._telemetry.tool_usage_error(llm=self.function_calling_llm)
result = self._format_result(result=result)
elif result is None:
@@ -359,7 +370,10 @@ class ToolUsage:
tool_name=sanitize_tool_name(tool.name),
attempts=self._run_attempts,
)
- result = self._format_result(result=result)
+ self.last_raw_result = result
+ result = self._format_result(
+ result=tool.format_output_for_agent(result)
+ )
data = {
"result": result,
"tool_name": sanitize_tool_name(tool.name),
@@ -421,6 +435,7 @@ class ToolUsage:
result = ToolUsageError(
f"\n{error_message}.\nMoving on then. {I18N_DEFAULT.slice('format').format(tool_names=self.tools_names)}"
).message
+ self.last_raw_result = result
if self.task:
self.task.increment_tools_errors()
if self.agent and self.agent.verbose:
@@ -430,7 +445,10 @@ class ToolUsage:
self.task.increment_tools_errors()
should_retry = True
else:
- result = self._format_result(result=result)
+ self.last_raw_result = result
+ result = self._format_result(
+ result=tool.format_output_for_agent(result)
+ )
finally:
if started_event_emitted and not error_event_emitted:
@@ -460,6 +478,7 @@ class ToolUsage:
result = I18N_DEFAULT.errors("task_repeated_usage").format(
tool_names=self.tools_names
)
+ self.last_raw_result = result
self._telemetry.tool_repeated_usage(
llm=self.function_calling_llm,
tool_name=sanitize_tool_name(tool.name),
@@ -529,6 +548,7 @@ class ToolUsage:
)
if usage_limit_error:
result = usage_limit_error
+ self.last_raw_result = result
self._telemetry.tool_usage_error(llm=self.function_calling_llm)
result = self._format_result(result=result)
elif result is None:
@@ -590,7 +610,10 @@ class ToolUsage:
tool_name=sanitize_tool_name(tool.name),
attempts=self._run_attempts,
)
- result = self._format_result(result=result)
+ self.last_raw_result = result
+ result = self._format_result(
+ result=tool.format_output_for_agent(result)
+ )
data = {
"result": result,
"tool_name": sanitize_tool_name(tool.name),
@@ -652,6 +675,7 @@ class ToolUsage:
result = ToolUsageError(
f"\n{error_message}.\nMoving on then. {I18N_DEFAULT.slice('format').format(tool_names=self.tools_names)}"
).message
+ self.last_raw_result = result
if self.task:
self.task.increment_tools_errors()
if self.agent and self.agent.verbose:
@@ -661,7 +685,10 @@ class ToolUsage:
self.task.increment_tools_errors()
should_retry = True
else:
- result = self._format_result(result=result)
+ self.last_raw_result = result
+ result = self._format_result(
+ result=tool.format_output_for_agent(result)
+ )
finally:
if started_event_emitted and not error_event_emitted:
diff --git a/lib/crewai/src/crewai/utilities/agent_utils.py b/lib/crewai/src/crewai/utilities/agent_utils.py
index 80f8ab242..e933a38a8 100644
--- a/lib/crewai/src/crewai/utilities/agent_utils.py
+++ b/lib/crewai/src/crewai/utilities/agent_utils.py
@@ -1383,6 +1383,19 @@ class NativeToolCallResult:
tool_message: LLMMessage = field(default_factory=dict) # type: ignore[assignment]
+def format_native_tool_output_for_agent(tool: Any, raw_result: Any) -> str:
+ """Format native tool output when a tool explicitly defines a formatter."""
+ formatter = inspect.getattr_static(tool, "format_output_for_agent", None)
+ if formatter is None:
+ return str(raw_result)
+
+ runtime_formatter = getattr(tool, "format_output_for_agent", None)
+ if not callable(runtime_formatter):
+ return str(raw_result)
+
+ return str(runtime_formatter(raw_result))
+
+
def execute_single_native_tool_call(
tool_call: Any,
*,
@@ -1456,18 +1469,24 @@ def execute_single_native_tool_call(
original_tool = tool
break
+ structured_tool: CrewStructuredTool | None = None
+ for structured in structured_tools or []:
+ if sanitize_tool_name(structured.name) == func_name:
+ structured_tool = structured
+ break
+
+ output_tool = original_tool or structured_tool
+
from_cache = False
input_str = json.dumps(args_dict) if args_dict else ""
result = "Tool not found"
+ raw_tool_result: Any = result
- if tools_handler and tools_handler.cache:
+ if tools_handler and tools_handler.cache and output_tool is not None:
cached_result = tools_handler.cache.read(tool=func_name, input=input_str)
if cached_result is not None:
- result = (
- str(cached_result)
- if not isinstance(cached_result, str)
- else cached_result
- )
+ raw_tool_result = cached_result
+ result = format_native_tool_output_for_agent(output_tool, cached_result)
from_cache = True
started_at = datetime.now()
@@ -1486,12 +1505,6 @@ def execute_single_native_tool_call(
track_delegation_if_needed(func_name, args_dict, task)
- structured_tool: CrewStructuredTool | None = None
- for structured in structured_tools or []:
- if sanitize_tool_name(structured.name) == func_name:
- structured_tool = structured
- break
-
hook_blocked = False
before_hook_context = ToolCallHookContext(
tool_name=func_name,
@@ -1512,11 +1525,13 @@ def execute_single_native_tool_call(
error_event_emitted = False
if hook_blocked:
result = f"Tool execution blocked by hook. Tool: {func_name}"
+ raw_tool_result = result
elif not from_cache:
- if func_name in available_functions:
+ if func_name in available_functions and output_tool is not None:
try:
tool_func = available_functions[func_name]
raw_result = tool_func(**args_dict)
+ raw_tool_result = raw_result
if tools_handler and tools_handler.cache:
should_cache = True
@@ -1529,11 +1544,10 @@ def execute_single_native_tool_call(
tool=func_name, input=input_str, output=raw_result
)
- result = (
- str(raw_result) if not isinstance(raw_result, str) else raw_result
- )
+ result = format_native_tool_output_for_agent(output_tool, raw_result)
except Exception as e:
result = f"Error executing tool: {e}"
+ raw_tool_result = result
if task:
task.increment_tools_errors()
crewai_event_bus.emit(
@@ -1559,6 +1573,7 @@ def execute_single_native_tool_call(
task=task,
crew=crew,
tool_result=result,
+ raw_tool_result=raw_tool_result,
)
try:
for after_hook in get_after_tool_call_hooks():
diff --git a/lib/crewai/src/crewai/utilities/declarative_refs.py b/lib/crewai/src/crewai/utilities/declarative_refs.py
new file mode 100644
index 000000000..5779fe129
--- /dev/null
+++ b/lib/crewai/src/crewai/utilities/declarative_refs.py
@@ -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
diff --git a/lib/crewai/src/crewai/utilities/tool_utils.py b/lib/crewai/src/crewai/utilities/tool_utils.py
index e19c3c81a..dcb25594c 100644
--- a/lib/crewai/src/crewai/utilities/tool_utils.py
+++ b/lib/crewai/src/crewai/utilities/tool_utils.py
@@ -116,6 +116,7 @@ async def aexecute_tool_and_check_finality(
logger.log("error", f"Error in before_tool_call hook: {e}")
tool_result = await tool_usage.ause(tool_calling, agent_action.text)
+ raw_tool_result = tool_usage.get_last_raw_result(tool_result)
after_hook_context = ToolCallHookContext(
tool_name=sanitized_tool_name,
@@ -125,6 +126,7 @@ async def aexecute_tool_and_check_finality(
task=task,
crew=crew,
tool_result=tool_result,
+ raw_tool_result=raw_tool_result,
)
after_hooks = get_after_tool_call_hooks()
@@ -234,6 +236,7 @@ def execute_tool_and_check_finality(
logger.log("error", f"Error in before_tool_call hook: {e}")
tool_result = tool_usage.use(tool_calling, agent_action.text)
+ raw_tool_result = tool_usage.get_last_raw_result(tool_result)
after_hook_context = ToolCallHookContext(
tool_name=sanitized_tool_name,
@@ -243,6 +246,7 @@ def execute_tool_and_check_finality(
task=task,
crew=crew,
tool_result=tool_result,
+ raw_tool_result=raw_tool_result,
)
after_hooks = get_after_tool_call_hooks()
diff --git a/lib/crewai/tests/agents/test_agent_executor.py b/lib/crewai/tests/agents/test_agent_executor.py
index 992f7460b..e4de4a484 100644
--- a/lib/crewai/tests/agents/test_agent_executor.py
+++ b/lib/crewai/tests/agents/test_agent_executor.py
@@ -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()
diff --git a/lib/crewai/tests/agents/test_native_tool_calling.py b/lib/crewai/tests/agents/test_native_tool_calling.py
index b7e0df199..894c0bd45 100644
--- a/lib/crewai/tests/agents/test_native_tool_calling.py
+++ b/lib/crewai/tests/agents/test_native_tool_calling.py
@@ -7,6 +7,7 @@ when the LLM supports it, across multiple providers.
from __future__ import annotations
from collections.abc import Generator
+import json
import os
import threading
import time
@@ -20,7 +21,7 @@ from crewai import Agent, Crew, Task
from crewai.agents.parser import AgentFinish
from crewai.events import crewai_event_bus
from crewai.hooks import register_after_tool_call_hook, register_before_tool_call_hook
-from crewai.hooks.tool_hooks import ToolCallHookContext
+from crewai.hooks.tool_hooks import ToolCallHookContext, clear_after_tool_call_hooks
from crewai.llm import LLM
from crewai.tools.base_tool import BaseTool
@@ -1197,6 +1198,76 @@ class TestNativeToolCallingJsonParseError:
assert result["result"] == "ran: print(1)"
+ def test_typed_output_is_json_agent_text(self) -> None:
+ class SearchOutput(BaseModel):
+ query: str
+ score: float
+
+ class TypedSearchTool(BaseTool):
+ name: str = "typed_search"
+ description: str = "Search for information"
+ result_schema: type[BaseModel] = SearchOutput
+
+ def _run(self, query: str) -> SearchOutput:
+ return SearchOutput(query=query, score=0.8)
+
+ tool = TypedSearchTool()
+ executor = self._make_executor([tool])
+
+ from crewai.utilities.agent_utils import convert_tools_to_openai_schema
+
+ _, available_functions, _ = convert_tools_to_openai_schema([tool])
+
+ result = executor._execute_single_native_tool_call(
+ call_id="call_typed",
+ func_name="typed_search",
+ func_args='{"query": "crew"}',
+ available_functions=available_functions,
+ )
+
+ assert json.loads(result["result"]) == {"query": "crew", "score": 0.8}
+
+ def test_typed_output_after_hook_includes_raw_tool_result(self) -> None:
+ from crewai.utilities.agent_utils import convert_tools_to_openai_schema
+
+ class SearchOutput(BaseModel):
+ query: str
+ score: float
+
+ class TypedSearchTool(BaseTool):
+ name: str = "typed_search"
+ description: str = "Search for information"
+ result_schema: type[BaseModel] = SearchOutput
+
+ def _run(self, query: str) -> SearchOutput:
+ return SearchOutput(query=query, score=0.8)
+
+ seen_results: list[tuple[str | None, object]] = []
+
+ def after_hook(context: ToolCallHookContext) -> None:
+ seen_results.append((context.tool_result, context.raw_tool_result))
+
+ tool = TypedSearchTool()
+ executor = self._make_executor([tool])
+ _, available_functions, _ = convert_tools_to_openai_schema([tool])
+
+ clear_after_tool_call_hooks()
+ register_after_tool_call_hook(after_hook)
+ try:
+ result = executor._execute_single_native_tool_call(
+ call_id="call_typed",
+ func_name="typed_search",
+ func_args='{"query": "crew"}',
+ available_functions=available_functions,
+ )
+ finally:
+ clear_after_tool_call_hooks()
+
+ assert json.loads(result["result"]) == {"query": "crew", "score": 0.8}
+ assert seen_results == [
+ ('{"query":"crew","score":0.8}', SearchOutput(query="crew", score=0.8))
+ ]
+
def test_native_tool_loop_falls_back_when_provider_rejects_tools(self) -> None:
"""Unsupported native tools errors should continue through ReAct."""
diff --git a/lib/crewai/tests/cli/test_run_crew.py b/lib/crewai/tests/cli/test_run_crew.py
index 34074e526..c58772083 100644
--- a/lib/crewai/tests/cli/test_run_crew.py
+++ b/lib/crewai/tests/cli/test_run_crew.py
@@ -11,11 +11,10 @@ import pytest
from crewai_cli.cli import run
from crewai_cli.run_crew import (
- CrewType,
+ _execute_uv_script,
_load_json_crew_for_tui,
_missing_input_names,
_prompt_for_missing_inputs,
- execute_command,
)
@@ -30,6 +29,8 @@ def test_run_passes_filename_to_run_crew(run_crew_mock: mock.Mock, runner: CliRu
run_crew_mock.assert_called_once_with(
trained_agents_file="my_custom_trained.pkl",
+ definition=None,
+ inputs=None,
)
assert result.exit_code == 0
@@ -38,7 +39,11 @@ def test_run_passes_filename_to_run_crew(run_crew_mock: mock.Mock, runner: CliRu
def test_run_without_filename_passes_none(run_crew_mock: mock.Mock, runner: CliRunner) -> None:
result = runner.invoke(run)
- run_crew_mock.assert_called_once_with(trained_agents_file=None)
+ run_crew_mock.assert_called_once_with(
+ trained_agents_file=None,
+ definition=None,
+ inputs=None,
+ )
assert result.exit_code == 0
@@ -50,7 +55,11 @@ def test_run_without_filename_passes_none(run_crew_mock: mock.Mock, runner: CliR
def test_execute_command_sets_env_var_when_filename_provided(
_build_env: mock.Mock, subprocess_run: mock.Mock
) -> None:
- execute_command(CrewType.STANDARD, trained_agents_file="my_custom_trained.pkl")
+ _execute_uv_script(
+ "run_crew",
+ entity_type="crew",
+ trained_agents_file="my_custom_trained.pkl",
+ )
_, kwargs = subprocess_run.call_args
assert kwargs["env"]["CREWAI_TRAINED_AGENTS_FILE"] == "my_custom_trained.pkl"
@@ -65,7 +74,7 @@ def test_execute_command_sets_env_var_when_filename_provided(
def test_execute_command_omits_env_var_when_filename_absent(
_build_env: mock.Mock, subprocess_run: mock.Mock
) -> None:
- execute_command(CrewType.STANDARD)
+ _execute_uv_script("run_crew", entity_type="crew")
_, kwargs = subprocess_run.call_args
assert "CREWAI_TRAINED_AGENTS_FILE" not in kwargs["env"]
diff --git a/lib/crewai/tests/experimental/skills/test_cache.py b/lib/crewai/tests/experimental/skills/test_cache.py
index 8b458bb3e..b6601dccf 100644
--- a/lib/crewai/tests/experimental/skills/test_cache.py
+++ b/lib/crewai/tests/experimental/skills/test_cache.py
@@ -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")
diff --git a/lib/crewai/tests/hooks/test_tool_hooks.py b/lib/crewai/tests/hooks/test_tool_hooks.py
index 347eb56e5..14c6848a8 100644
--- a/lib/crewai/tests/hooks/test_tool_hooks.py
+++ b/lib/crewai/tests/hooks/test_tool_hooks.py
@@ -91,20 +91,24 @@ class TestToolCallHookContext:
assert context.task == mock_task
assert context.crew == mock_crew
assert context.tool_result is None
+ assert context.raw_tool_result is None
def test_context_with_result(self, mock_tool):
"""Test that context includes result when provided."""
tool_input = {"arg1": "value1"}
tool_result = "Test tool result"
+ raw_tool_result = {"value": 42}
context = ToolCallHookContext(
tool_name="test_tool",
tool_input=tool_input,
tool=mock_tool,
tool_result=tool_result,
+ raw_tool_result=raw_tool_result,
)
assert context.tool_result == tool_result
+ assert context.raw_tool_result == raw_tool_result
def test_tool_input_is_mutable_reference(self, mock_tool):
"""Test that modifying context.tool_input modifies the original dict."""
diff --git a/lib/crewai/tests/project/test_json_loader.py b/lib/crewai/tests/project/test_json_loader.py
index a5a8f85fa..ff2d5d48c 100644
--- a/lib/crewai/tests/project/test_json_loader.py
+++ b/lib/crewai/tests/project/test_json_loader.py
@@ -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,
diff --git a/lib/crewai/tests/test_flow.py b/lib/crewai/tests/test_flow.py
index e7bae8023..4b8a66671 100644
--- a/lib/crewai/tests/test_flow.py
+++ b/lib/crewai/tests/test_flow.py
@@ -386,6 +386,54 @@ def test_router_runtime_uses_flow_definition_without_legacy_router_metadata():
assert execution_order == ["begin", "decide", "handle_left"]
+def test_start_router_runtime_routes_public_dsl_return_value():
+ execution_order = []
+
+ class StartRouterFlow(Flow):
+ @start()
+ @router(emit=["continue"])
+ def decide(self):
+ execution_order.append("decide")
+ return "continue"
+
+ @listen("continue")
+ def handle_continue(self, result):
+ execution_order.append(f"handle_continue:{result}")
+ return "done"
+
+ assert StartRouterFlow().kickoff() == "done"
+ assert execution_order == ["decide", "handle_continue:continue"]
+
+
+def test_start_router_runtime_chains_to_stacked_listener_router():
+ execution_order = []
+
+ class ChainedStartRouterFlow(Flow):
+ @start()
+ @router(emit=["approved", "not_approved"])
+ def first_router(self):
+ execution_order.append("first_router")
+ return "approved"
+
+ @listen("approved")
+ @router(emit=["second_approval", "not_approved"])
+ def second_router(self):
+ execution_order.append("second_router")
+ return "second_approval"
+
+ @listen("second_approval")
+ def handle_second_approval(self, result):
+ execution_order.append(f"handle_second_approval:{result}")
+ return "done"
+
+ assert ChainedStartRouterFlow().kickoff() == "done"
+ assert execution_order == [
+ "first_router",
+ "second_router",
+ "handle_second_approval:second_approval",
+ ]
+
+
def test_router_falsy_result_emits_runtime_event():
execution_order = []
@@ -1462,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()
@@ -1506,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():
diff --git a/lib/crewai/tests/test_flow_conversation.py b/lib/crewai/tests/test_flow_conversation.py
index 3fea6b471..d8cc0bd37 100644
--- a/lib/crewai/tests/test_flow_conversation.py
+++ b/lib/crewai/tests/test_flow_conversation.py
@@ -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
diff --git a/lib/crewai/tests/test_flow_definition.py b/lib/crewai/tests/test_flow_definition.py
index 4b5b8d37b..89df6c902 100644
--- a/lib/crewai/tests/test_flow_definition.py
+++ b/lib/crewai/tests/test_flow_definition.py
@@ -565,7 +565,55 @@ def test_flow_definition_classifies_start_router_from_human_feedback_emit():
assert entry_point.emit is None
-def test_flow_definition_round_trips_json_and_yaml():
+def test_flow_definition_classifies_public_dsl_start_router():
+ class StartRouterFlow(Flow):
+ @start()
+ @router(emit=["continue", "stop"])
+ def entry_point(self):
+ return "continue"
+
+ @router(emit=["resume"])
+ @start()
+ def alternate_entry_point(self):
+ return "resume"
+
+ entry_point = StartRouterFlow.flow_definition().methods["entry_point"]
+ alternate_entry_point = StartRouterFlow.flow_definition().methods[
+ "alternate_entry_point"
+ ]
+
+ assert entry_point.is_start is True
+ assert entry_point.router is True
+ assert entry_point.listen is None
+ assert entry_point.emit == ["continue", "stop"]
+ assert alternate_entry_point.is_start is True
+ assert alternate_entry_point.router is True
+ assert alternate_entry_point.listen is None
+ assert alternate_entry_point.emit == ["resume"]
+
+
+def test_flow_definition_merges_stacked_listen_router():
+ class ChainedRouterFlow(Flow):
+ @start()
+ @router(emit=["approved", "not_approved"])
+ def first_router(self):
+ return "approved"
+
+ @listen("approved")
+ @router(emit=["second_approval", "not_approved"])
+ def second_router(self):
+ return "second_approval"
+
+ methods = ChainedRouterFlow.flow_definition().methods
+
+ assert methods["first_router"].is_start is True
+ assert methods["first_router"].listen is None
+ assert methods["second_router"].router is True
+ assert methods["second_router"].listen == "approved"
+ assert methods["second_router"].emit == ["second_approval", "not_approved"]
+
+
+def test_flow_definition_round_trips_declaration_serialization():
class RoundTripFlow(Flow):
@start()
def begin(self):
@@ -581,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",
@@ -629,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():
@@ -883,7 +1039,7 @@ def test_flow_definition_ignores_legacy_diagnostics_loaded_from_contract():
assert "diagnostics" not in definition.to_dict()
-def test_router_start_false_without_listen_logs_missing_trigger(caplog):
+def test_router_start_false_without_listen_is_allowed(caplog):
caplog.set_level(logging.ERROR, logger="crewai.flow.flow_definition")
flow_definition.FlowDefinition.from_dict(
@@ -901,12 +1057,7 @@ def test_router_start_false_without_listen_logs_missing_trigger(caplog):
}
)
- assert any(
- record.levelno == logging.ERROR
- and "router_without_trigger" in record.message
- and "methods.decision" in record.message
- for record in caplog.records
- )
+ assert not caplog.records
def test_router_human_feedback_preserves_existing_router_metadata():
@@ -1048,7 +1199,7 @@ def test_flow_definition_cache_is_not_reused_by_subclasses():
assert set(child_definition.methods) == {"child_step"}
-def test_flow_definition_logs_validation_issues_when_loaded_from_contract(caplog):
+def test_flow_definition_allows_router_without_trigger(caplog):
caplog.set_level(logging.WARNING, logger="crewai.flow.flow_definition")
flow_definition.FlowDefinition.from_dict(
@@ -1065,9 +1216,11 @@ def test_flow_definition_logs_validation_issues_when_loaded_from_contract(caplog
}
)
- assert any(
- record.levelno == logging.ERROR
- and "LoadedFlow" in record.message
- and "router_without_trigger" in record.message
- for record in caplog.records
- )
+ class StandaloneRouterFlow(Flow):
+ @router(emit=["continue"])
+ def decision(self):
+ return "continue"
+
+ StandaloneRouterFlow.flow_definition()
+
+ assert not caplog.records
diff --git a/lib/crewai/tests/test_flow_from_definition.py b/lib/crewai/tests/test_flow_from_definition.py
index 1ed8dbcf9..0c822c483 100644
--- a/lib/crewai/tests/test_flow_from_definition.py
+++ b/lib/crewai/tests/test_flow_from_definition.py
@@ -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):
@@ -1005,12 +1042,12 @@ methods:
description: Research {topic}
expected_output: Findings about {topic}
agent: researcher
- inputs:
- topic: "${state.topic}"
+ inputs:
+ topic: "${state.topic}"
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",
@@ -1020,6 +1057,180 @@ methods:
}
+def test_crew_action_runs_crew_from_declaration(
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
+):
+ from crewai import Crew
+
+ project_root = tmp_path / "project"
+ crew_root = project_root / "crews" / "research_crew"
+ agents_root = crew_root / "agents"
+ agents_root.mkdir(parents=True)
+ (agents_root / "researcher.jsonc").write_text(
+ """
+{
+ "role": "Researcher",
+ "goal": "Research {topic}",
+ "backstory": "Knows things."
+}
+""",
+ encoding="utf-8",
+ )
+ (crew_root / "crew.jsonc").write_text(
+ """
+{
+ "name": "referenced_research",
+ "agents": ["researcher"],
+ "tasks": [
+ {
+ "name": "research_task",
+ "description": "Research {topic}",
+ "expected_output": "Findings about {topic}",
+ "agent": "researcher"
+ }
+ ],
+ "inputs": {
+ "topic": "Default topic",
+ "audience": "developers"
+ }
+}
+""",
+ encoding="utf-8",
+ )
+
+ async def fake_kickoff_async(
+ self: Crew, inputs: dict[str, Any] | None = None, **_kwargs: Any
+ ) -> dict[str, Any]:
+ return {
+ "crew": self.name,
+ "tasks": [task.description for task in self.tasks],
+ "inputs": inputs,
+ }
+
+ monkeypatch.setattr(Crew, "kickoff_async", fake_kickoff_async)
+ monkeypatch.chdir(project_root)
+
+ yaml_str = """
+schema: crewai.flow/v1
+name: CrewFlow
+methods:
+ research:
+ do:
+ call: crew
+ from_declaration: crews/research_crew
+ inputs:
+ topic: "${state.topic}"
+ start: true
+"""
+
+ flow = Flow.from_definition(FlowDefinition.from_declaration(contents=yaml_str))
+
+ assert flow.kickoff(inputs={"topic": "AI"}) == {
+ "crew": "referenced_research",
+ "tasks": ["Research {topic}"],
+ "inputs": {"topic": "AI", "audience": "developers"},
+ }
+
+
+def test_crew_action_from_declaration_resolves_relative_to_flow_file(
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
+):
+ from crewai import Crew
+
+ project_root = tmp_path / "project"
+ crew_root = project_root / "crews" / "research_crew"
+ agents_root = crew_root / "agents"
+ agents_root.mkdir(parents=True)
+ (agents_root / "researcher.jsonc").write_text(
+ """
+{
+ "role": "Researcher",
+ "goal": "Research {topic}",
+ "backstory": "Knows things."
+}
+""",
+ encoding="utf-8",
+ )
+ (crew_root / "crew.jsonc").write_text(
+ """
+{
+ "name": "relative_research",
+ "agents": ["researcher"],
+ "tasks": [
+ {
+ "description": "Research {topic}",
+ "expected_output": "Findings about {topic}",
+ "agent": "researcher"
+ }
+ ],
+ "inputs": {
+ "topic": "Default topic"
+ }
+}
+""",
+ encoding="utf-8",
+ )
+
+ async def fake_kickoff_async(
+ self: Crew, inputs: dict[str, Any] | None = None, **_kwargs: Any
+ ) -> dict[str, Any]:
+ return {"crew": self.name, "inputs": inputs}
+
+ monkeypatch.setattr(Crew, "kickoff_async", fake_kickoff_async)
+
+ flow_path = project_root / "flow.yaml"
+ yaml_str = """
+schema: crewai.flow/v1
+name: CrewFlow
+methods:
+ research:
+ do:
+ call: crew
+ from_declaration: crews/research_crew
+ inputs:
+ topic: "${state.topic}"
+ start: true
+"""
+ flow_path.write_text(yaml_str, encoding="utf-8")
+
+ other_cwd = tmp_path / "other"
+ other_cwd.mkdir()
+ monkeypatch.chdir(other_cwd)
+
+ flow = Flow.from_definition(FlowDefinition.from_declaration(path=flow_path))
+
+ assert flow.kickoff(inputs={"topic": "AI"}) == {
+ "crew": "relative_research",
+ "inputs": {"topic": "AI"},
+ }
+
+
+def test_crew_action_from_declaration_rejects_paths_outside_flow_file(
+ tmp_path: Path,
+):
+ flow_path = tmp_path / "project" / "flow.yaml"
+ flow_path.parent.mkdir()
+ yaml_str = """
+schema: crewai.flow/v1
+name: CrewFlow
+methods:
+ research:
+ do:
+ call: crew
+ from_declaration: ../outside/crew.jsonc
+ start: true
+"""
+ flow_path.write_text(yaml_str, encoding="utf-8")
+
+ flow = Flow.from_definition(FlowDefinition.from_declaration(path=flow_path))
+
+ with pytest.raises(
+ ValueError,
+ match="crew declaration path must be within the flow definition directory",
+ ):
+ flow.kickoff()
+
+
def test_crew_action_round_trips_with_inline_definition():
definition = FlowDefinition.from_dict(
{
@@ -1047,8 +1258,8 @@ def test_crew_action_round_trips_with_inline_definition():
"agent": "researcher",
}
],
- "inputs": {"topic": "${state.topic}"},
},
+ "inputs": {"topic": "${state.topic}"},
},
}
},
@@ -1062,6 +1273,9 @@ def test_crew_action_round_trips_with_inline_definition():
]["role"]
== "Researcher"
)
+ assert definition.to_dict()["methods"]["research"]["do"]["inputs"] == {
+ "topic": "${state.topic}"
+ }
def test_crew_action_normalizes_named_agent_list_definition():
@@ -1162,7 +1376,7 @@ def test_crew_action_rejects_incomplete_inline_agent_definition():
)
-def test_crew_action_rejects_ref():
+def test_crew_action_rejects_python_ref_field():
with pytest.raises(ValidationError, match="ref"):
FlowDefinition.from_dict(
{
@@ -1174,7 +1388,6 @@ def test_crew_action_rejects_ref():
"do": {
"call": "crew",
"ref": "project.crew:build_crew",
- "with": {"inputs": {"topic": "AI"}},
},
}
},
@@ -1232,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():
@@ -1250,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!"
@@ -1269,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"
@@ -1293,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",
@@ -1320,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"]
@@ -1347,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"]
@@ -1369,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)
@@ -1393,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
@@ -1422,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
@@ -1460,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"]
@@ -1492,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"},
@@ -1521,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 == [
@@ -1559,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",
@@ -1598,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={
@@ -1632,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"]
@@ -1659,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={
@@ -1689,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"}]})
@@ -1719,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():
@@ -1890,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"]})
@@ -2011,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"}
@@ -2034,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():
@@ -2050,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():
@@ -2084,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
@@ -2100,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"
@@ -2116,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()
@@ -2126,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
@@ -2143,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()
@@ -2216,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
@@ -2230,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:
@@ -2253,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:
@@ -2269,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]
)
@@ -2283,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"""
@@ -2292,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
@@ -2474,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"
@@ -2486,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"]
@@ -2517,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
@@ -2550,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)
@@ -2559,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
@@ -2583,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"]
@@ -2731,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()
@@ -2743,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"),
@@ -2761,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()
@@ -2774,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()
@@ -2796,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
@@ -2822,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
@@ -2925,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"
@@ -2946,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"
@@ -2958,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"
@@ -2998,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():
diff --git a/lib/crewai/tests/test_flow_persistence.py b/lib/crewai/tests/test_flow_persistence.py
index e5331f7c0..b405cc64d 100644
--- a/lib/crewai/tests/test_flow_persistence.py
+++ b/lib/crewai/tests/test_flow_persistence.py
@@ -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):
diff --git a/lib/crewai/tests/tools/test_base_tool.py b/lib/crewai/tests/tools/test_base_tool.py
index 7648ad73b..52661fffc 100644
--- a/lib/crewai/tests/tools/test_base_tool.py
+++ b/lib/crewai/tests/tools/test_base_tool.py
@@ -1,12 +1,13 @@
import asyncio
from collections.abc import Callable
+import json
from unittest.mock import patch
from crewai.agent import Agent
from crewai.crew import Crew
from crewai.task import Task
from crewai.tools import BaseTool, tool
-from pydantic import BaseModel, Field
+from pydantic import BaseModel, Field, RootModel
import pytest
@@ -351,6 +352,262 @@ class TestToolDecoratorRunValidation:
assert result == "Hello, World!"
+class SearchOutput(BaseModel):
+ query: str
+ score: float
+
+
+class SearchResults(RootModel[list[SearchOutput]]):
+ pass
+
+
+class ExplicitSearchTool(BaseTool):
+ name: str = "search"
+ description: str = "Search for a query"
+ result_schema: type[BaseModel] = SearchOutput
+
+ def _run(self, query: str) -> dict[str, object]:
+ return {"query": query, "score": 0.8}
+
+
+class InferredSearchTool(BaseTool):
+ name: str = "search"
+ description: str = "Search for a query"
+
+ def _run(self, query: str) -> SearchOutput:
+ return SearchOutput(query=query, score=0.7)
+
+
+class RootSearchTool(BaseTool):
+ name: str = "search"
+ description: str = "Search for a query"
+
+ def _run(self, query: str) -> SearchResults:
+ return SearchResults([SearchOutput(query=query, score=1.0)])
+
+
+class DictAnnotatedSearchTool(BaseTool):
+ name: str = "search"
+ description: str = "Search for a query"
+
+ def _run(self, query: str) -> dict[str, object]:
+ return {"query": query, "score": 0.5}
+
+
+def _make_explicit_decorator_tool() -> BaseTool:
+ @tool("search", result_schema=SearchOutput)
+ def search(query: str) -> dict[str, object]:
+ """Search for a query."""
+ return {"query": query, "score": 0.8}
+
+ return search
+
+
+def _make_inferred_decorator_tool() -> BaseTool:
+ @tool("search")
+ def search(query: str) -> SearchOutput:
+ """Search for a query."""
+ return SearchOutput(query=query, score=0.6)
+
+ return search
+
+
+def _make_root_decorator_tool() -> BaseTool:
+ @tool("search")
+ def search(query: str) -> SearchResults:
+ """Search for a query."""
+ return SearchResults([SearchOutput(query=query, score=1.0)])
+
+ return search
+
+
+class TestToolOutputSchema:
+ @pytest.mark.parametrize(
+ ("tool_cls", "expected_raw", "expected_agent_payload"),
+ [
+ pytest.param(
+ ExplicitSearchTool,
+ {"query": "crew", "score": 0.8},
+ {"query": "crew", "score": 0.8},
+ id="explicit-schema",
+ ),
+ pytest.param(
+ InferredSearchTool,
+ SearchOutput(query="crew", score=0.7),
+ {"query": "crew", "score": 0.7},
+ id="inferred-base-model",
+ ),
+ pytest.param(
+ RootSearchTool,
+ SearchResults([SearchOutput(query="crew", score=1.0)]),
+ [{"query": "crew", "score": 1.0}],
+ id="inferred-root-model",
+ ),
+ ],
+ )
+ def test_base_tools_return_raw_result_and_json_agent_text(
+ self,
+ tool_cls: type[BaseTool],
+ expected_raw: object,
+ expected_agent_payload: object,
+ ) -> None:
+ t = tool_cls()
+
+ raw_result = t.run(query="crew")
+
+ assert raw_result == expected_raw
+ assert json.loads(t.format_output_for_agent(raw_result)) == (
+ expected_agent_payload
+ )
+
+ def test_base_tool_does_not_infer_non_pydantic_return_annotation(self) -> None:
+ t = DictAnnotatedSearchTool()
+
+ raw_result = t.run(query="crew")
+
+ assert raw_result == {"query": "crew", "score": 0.5}
+ assert t.format_output_for_agent(raw_result) == str(raw_result)
+
+ @pytest.mark.parametrize(
+ ("make_tool", "expected_raw", "expected_agent_payload"),
+ [
+ pytest.param(
+ _make_explicit_decorator_tool,
+ {"query": "crew", "score": 0.8},
+ {"query": "crew", "score": 0.8},
+ id="explicit-schema",
+ ),
+ pytest.param(
+ _make_inferred_decorator_tool,
+ SearchOutput(query="crew", score=0.6),
+ {"query": "crew", "score": 0.6},
+ id="inferred-base-model",
+ ),
+ pytest.param(
+ _make_root_decorator_tool,
+ SearchResults([SearchOutput(query="crew", score=1.0)]),
+ [{"query": "crew", "score": 1.0}],
+ id="inferred-root-model",
+ ),
+ ],
+ )
+ def test_decorator_tools_return_raw_result_and_json_agent_text(
+ self,
+ make_tool: Callable[[], BaseTool],
+ expected_raw: object,
+ expected_agent_payload: object,
+ ) -> None:
+ search = make_tool()
+
+ raw_result = search.run(query="crew")
+
+ assert raw_result == expected_raw
+ assert json.loads(search.format_output_for_agent(raw_result)) == (
+ expected_agent_payload
+ )
+
+ def test_decorator_tool_does_not_infer_non_pydantic_return_annotation(
+ self,
+ ) -> None:
+ @tool("search")
+ def search(query: str) -> dict[str, object]:
+ """Search for a query."""
+ return {"query": query, "score": 0.5}
+
+ raw_result = search.run(query="crew")
+
+ assert raw_result == {"query": "crew", "score": 0.5}
+ assert search.format_output_for_agent(raw_result) == str(raw_result)
+
+ def test_explicit_result_schema_wins_over_return_annotation(self) -> None:
+ class AlternateOutput(BaseModel):
+ value: str
+
+ @tool("search", result_schema=AlternateOutput)
+ def search(query: str) -> SearchOutput:
+ """Search for a query."""
+ return SearchOutput(query=query, score=0.6)
+
+ raw_result = search.run(query="crew")
+
+ with pytest.warns(RuntimeWarning, match="AlternateOutput"):
+ agent_text = search.format_output_for_agent(raw_result)
+
+ assert raw_result == SearchOutput(query="crew", score=0.6)
+ assert agent_text == str(raw_result)
+
+ def test_invalid_typed_output_warns_and_uses_string_agent_text(
+ self,
+ ) -> None:
+ @tool("search", result_schema=SearchOutput)
+ def search(query: str) -> dict[str, object]:
+ """Search for a query."""
+ return {"query": query, "score": "not-a-float"}
+
+ raw_result = search.run(query="crew")
+
+ with pytest.warns(RuntimeWarning, match="Failed to validate or serialize"):
+ agent_text = search.format_output_for_agent(raw_result)
+
+ assert raw_result == {"query": "crew", "score": "not-a-float"}
+ assert agent_text == str(raw_result)
+
+ def test_unserializable_typed_output_warns_and_uses_string_agent_text(
+ self,
+ ) -> None:
+ class OpaqueOutput(BaseModel):
+ value: object
+
+ raw_result = OpaqueOutput(value=object())
+
+ @tool("opaque", result_schema=OpaqueOutput)
+ def opaque() -> OpaqueOutput:
+ """Return an opaque object."""
+ return raw_result
+
+ result = opaque.run()
+
+ with pytest.warns(RuntimeWarning, match="Failed to validate or serialize"):
+ agent_text = opaque.format_output_for_agent(result)
+
+ assert result is raw_result
+ assert agent_text == str(raw_result)
+
+ def test_result_schema_behavior_carries_over_to_structured_tool(self) -> None:
+ structured = ExplicitSearchTool().to_structured_tool()
+
+ raw_result = structured.invoke({"query": "crew"})
+
+ assert raw_result == {"query": "crew", "score": 0.8}
+ assert json.loads(structured.format_output_for_agent(raw_result)) == {
+ "query": "crew",
+ "score": 0.8,
+ }
+
+ def test_custom_agent_output_formatter_carries_over_to_structured_tool(
+ self,
+ ) -> None:
+ class MarkdownSearchTool(BaseTool):
+ name: str = "markdown_search"
+ description: str = "Search for information"
+ result_schema: type[BaseModel] = SearchOutput
+
+ def _run(self, query: str) -> SearchOutput:
+ return SearchOutput(query=query, score=0.8)
+
+ def format_output_for_agent(self, raw_result: object) -> str:
+ result = self.result_schema.model_validate(raw_result)
+ return f"### Search result\n\n- Query: `{result.query}`\n- Score: {result.score}"
+
+ structured = MarkdownSearchTool().to_structured_tool()
+
+ raw_result = structured.invoke({"query": "crew"})
+
+ assert raw_result == SearchOutput(query="crew", score=0.8)
+ assert structured.format_output_for_agent(raw_result) == (
+ "### Search result\n\n- Query: `crew`\n- Score: 0.8"
+ )
+
# Async arun() Schema Validation Tests
diff --git a/lib/crewai/tests/tools/test_structured_tool.py b/lib/crewai/tests/tools/test_structured_tool.py
index 27c463d47..0241abbcf 100644
--- a/lib/crewai/tests/tools/test_structured_tool.py
+++ b/lib/crewai/tests/tools/test_structured_tool.py
@@ -1,5 +1,7 @@
+import json
+
from crewai.tools.structured_tool import CrewStructuredTool
-from pydantic import BaseModel, Field
+from pydantic import BaseModel, Field, RootModel
import pytest
@@ -86,6 +88,118 @@ def test_from_function(basic_function):
assert isinstance(tool.args_schema, type(BaseModel))
+class StructuredOutput(BaseModel):
+ value: str
+ count: int
+
+
+class StructuredOutputList(RootModel[list[StructuredOutput]]):
+ pass
+
+
+def _build_explicit_structured_value(value: str) -> dict[str, object]:
+ """Build a value."""
+ return {"value": value, "count": 1}
+
+
+def _build_inferred_structured_value(value: str) -> StructuredOutput:
+ """Build a value."""
+ return StructuredOutput(value=value, count=1)
+
+
+def _build_structured_values(value: str) -> StructuredOutputList:
+ """Build values."""
+ return StructuredOutputList([StructuredOutput(value=value, count=1)])
+
+
+def _build_plain_structured_value(value: str) -> dict[str, object]:
+ """Build a value."""
+ return {"value": value, "count": 1}
+
+
+@pytest.mark.parametrize(
+ ("func", "result_schema", "expected_raw", "expected_agent_payload"),
+ [
+ pytest.param(
+ _build_explicit_structured_value,
+ StructuredOutput,
+ {"value": "crew", "count": 1},
+ {"value": "crew", "count": 1},
+ id="explicit-schema",
+ ),
+ pytest.param(
+ _build_inferred_structured_value,
+ None,
+ StructuredOutput(value="crew", count=1),
+ {"value": "crew", "count": 1},
+ id="inferred-base-model",
+ ),
+ pytest.param(
+ _build_structured_values,
+ None,
+ StructuredOutputList([StructuredOutput(value="crew", count=1)]),
+ [{"value": "crew", "count": 1}],
+ id="inferred-root-model",
+ ),
+ ],
+)
+def test_from_function_returns_raw_result_and_json_agent_text(
+ func,
+ result_schema,
+ expected_raw,
+ expected_agent_payload,
+):
+ kwargs = {"result_schema": result_schema} if result_schema is not None else {}
+ tool = CrewStructuredTool.from_function(
+ func=func,
+ name="build_value",
+ **kwargs,
+ )
+
+ raw_result = tool.invoke({"value": "crew"})
+
+ assert raw_result == expected_raw
+ assert json.loads(tool.format_output_for_agent(raw_result)) == (
+ expected_agent_payload
+ )
+
+
+def test_from_function_does_not_infer_non_pydantic_result_schema():
+ tool = CrewStructuredTool.from_function(
+ func=_build_plain_structured_value,
+ name="build_value",
+ )
+
+ raw_result = tool.invoke({"value": "crew"})
+
+ assert raw_result == {"value": "crew", "count": 1}
+ assert tool.format_output_for_agent(raw_result) == str(raw_result)
+
+
+def test_invalid_typed_output_warns_and_uses_string_agent_text():
+ def build_value(value: str) -> dict[str, object]:
+ """Build a value."""
+ return {"value": value, "count": "wrong"}
+
+ tool = CrewStructuredTool.from_function(
+ func=build_value,
+ name="build_value",
+ result_schema=StructuredOutput,
+ )
+ raw_result = tool.invoke({"value": "crew"})
+
+ with pytest.warns(
+ RuntimeWarning, match="Failed to validate or serialize"
+ ) as warnings:
+ agent_text = tool.format_output_for_agent(raw_result)
+
+ assert raw_result == {"value": "crew", "count": "wrong"}
+ assert agent_text == str(raw_result)
+ warning_message = str(warnings[0].message)
+ assert "ValidationError" in warning_message
+ assert "wrong" not in warning_message
+
+
def test_validate_function_signature(basic_function, schema_class):
"""Test function signature validation"""
tool = CrewStructuredTool(
diff --git a/lib/crewai/tests/tools/test_tool_usage.py b/lib/crewai/tests/tools/test_tool_usage.py
index ba4fe72dd..9d61c93a9 100644
--- a/lib/crewai/tests/tools/test_tool_usage.py
+++ b/lib/crewai/tests/tools/test_tool_usage.py
@@ -1,4 +1,5 @@
import datetime
+from collections.abc import Callable
import json
import random
import threading
@@ -6,6 +7,9 @@ import time
from unittest.mock import MagicMock, patch
from crewai import Agent, Task
+from crewai.agents.cache.cache_handler import CacheHandler
+from crewai.agents.parser import AgentAction
+from crewai.agents.tools_handler import ToolsHandler
from crewai.events.event_bus import crewai_event_bus
from crewai.events.types.tool_usage_events import (
ToolSelectionErrorEvent,
@@ -14,8 +18,15 @@ from crewai.events.types.tool_usage_events import (
ToolUsageStartedEvent,
ToolValidateInputErrorEvent,
)
+from crewai.hooks.tool_hooks import (
+ ToolCallHookContext,
+ clear_after_tool_call_hooks,
+ register_after_tool_call_hook,
+)
from crewai.tools import BaseTool
+from crewai.tools.tool_calling import ToolCalling
from crewai.tools.tool_usage import ToolUsage
+from crewai.utilities.tool_utils import execute_tool_and_check_finality
from pydantic import BaseModel, Field
import pytest
@@ -38,6 +49,19 @@ class RandomNumberTool(BaseTool):
return random.randint(min_value, max_value) # noqa: S311
+class SearchOutput(BaseModel):
+ query: str
+ score: float
+
+
+class TypedSearchTool(BaseTool):
+ name: str = "typed_search"
+ description: str = "Search for a query"
+
+ def _run(self, query: str) -> SearchOutput:
+ return SearchOutput(query=query, score=0.7)
+
+
# Example agent and task
example_agent = Agent(
role="Number Generator",
@@ -117,6 +141,126 @@ def test_tool_usage_render():
assert '"description": "The maximum value of the range (inclusive)"' in rendered
+def test_tool_usage_returns_json_agent_text_for_typed_output():
+ tool = TypedSearchTool().to_structured_tool()
+ tool_usage = ToolUsage(
+ tools_handler=None,
+ tools=[tool],
+ task=None,
+ function_calling_llm=MagicMock(),
+ agent=None,
+ action=MagicMock(),
+ )
+
+ result = tool_usage.use(
+ calling=ToolCalling(
+ tool_name="typed_search",
+ arguments={"query": "crew"},
+ ),
+ tool_string='Action: typed_search\nAction Input: {"query": "crew"}',
+ )
+
+ assert json.loads(result) == {"query": "crew", "score": 0.7}
+
+
+def test_tool_usage_cache_callback_receives_raw_typed_output():
+ raw_results: list[object] = []
+
+ def cache_result(_args: object, result: object) -> bool:
+ raw_results.append(result)
+ return True
+
+ class CacheAwareTypedSearchTool(TypedSearchTool):
+ cache_function: Callable = cache_result
+
+ tools_handler = MagicMock()
+ tools_handler.cache = None
+ tools_handler.last_used_tool = None
+ tool = CacheAwareTypedSearchTool().to_structured_tool()
+ tool_usage = ToolUsage(
+ tools_handler=tools_handler,
+ tools=[tool],
+ task=None,
+ function_calling_llm=MagicMock(),
+ agent=None,
+ action=MagicMock(),
+ )
+
+ result = tool_usage.use(
+ calling=ToolCalling(
+ tool_name="typed_search",
+ arguments={"query": "crew"},
+ ),
+ tool_string='Action: typed_search\nAction Input: {"query": "crew"}',
+ )
+
+ assert json.loads(result) == {"query": "crew", "score": 0.7}
+ assert raw_results == [SearchOutput(query="crew", score=0.7)]
+ tools_handler.on_tool_use.assert_called_once()
+ assert tools_handler.on_tool_use.call_args.kwargs["output"] == SearchOutput(
+ query="crew",
+ score=0.7,
+ )
+
+
+def test_react_tool_hooks_receive_agent_text_and_raw_cached_typed_output():
+ structured_tool = TypedSearchTool().to_structured_tool()
+ tools_handler = ToolsHandler(cache=CacheHandler())
+ seen_results: list[tuple[str | None, object]] = []
+
+ def after_hook(context: ToolCallHookContext) -> None:
+ seen_results.append((context.tool_result, context.raw_tool_result))
+
+ clear_after_tool_call_hooks()
+ register_after_tool_call_hook(after_hook)
+
+ action = AgentAction(
+ thought="",
+ tool="typed_search",
+ tool_input='{"query": "crew"}',
+ text='Action: typed_search\nAction Input: {"query": "crew"}',
+ )
+
+ try:
+ first = execute_tool_and_check_finality(
+ agent_action=action,
+ tools=[structured_tool],
+ tools_handler=tools_handler,
+ )
+ tools_handler.last_used_tool = None
+ second = execute_tool_and_check_finality(
+ agent_action=action,
+ tools=[structured_tool],
+ tools_handler=tools_handler,
+ )
+ finally:
+ clear_after_tool_call_hooks()
+
+ assert json.loads(first.result) == {"query": "crew", "score": 0.7}
+ assert json.loads(second.result) == {"query": "crew", "score": 0.7}
+ assert seen_results == [
+ ('{"query":"crew","score":0.7}', SearchOutput(query="crew", score=0.7)),
+ ('{"query":"crew","score":0.7}', SearchOutput(query="crew", score=0.7)),
+ ]
+
+
+def test_last_raw_result_falls_back_only_until_recorded():
+ tool_usage = ToolUsage(
+ tools_handler=None,
+ tools=[],
+ task=None,
+ function_calling_llm=MagicMock(),
+ agent=None,
+ action=MagicMock(),
+ )
+
+ assert tool_usage.get_last_raw_result("formatted result") == "formatted result"
+
+ tool_usage.last_raw_result = None
+
+ assert tool_usage.get_last_raw_result("formatted result") is None
+
+
def test_validate_tool_input_booleans_and_none():
tool_usage = ToolUsage(
tools_handler=MagicMock(),
diff --git a/lib/crewai/tests/utilities/test_agent_utils.py b/lib/crewai/tests/utilities/test_agent_utils.py
index de3ed411b..9cf4a2d2a 100644
--- a/lib/crewai/tests/utilities/test_agent_utils.py
+++ b/lib/crewai/tests/utilities/test_agent_utils.py
@@ -3,12 +3,19 @@
from __future__ import annotations
import asyncio
+import json
from typing import Any, Literal, Optional
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from pydantic import BaseModel, Field
+from crewai.hooks.tool_hooks import (
+ ToolCallHookContext,
+ clear_after_tool_call_hooks,
+ clear_before_tool_call_hooks,
+ register_after_tool_call_hook,
+)
from crewai.tools.base_tool import BaseTool
from crewai.utilities.agent_utils import (
_asummarize_chunks,
@@ -1030,6 +1037,142 @@ class TestParseToolCallArgs:
class TestExecuteSingleNativeToolCall:
"""Tests for execute_single_native_tool_call."""
+ def test_typed_tool_output_is_json_agent_text(self) -> None:
+ clear_before_tool_call_hooks()
+ clear_after_tool_call_hooks()
+
+ class SearchOutput(BaseModel):
+ query: str
+ score: float
+
+ class TypedSearchTool(BaseTool):
+ name: str = "typed_search"
+ description: str = "Search for a query"
+ result_schema: type[BaseModel] = SearchOutput
+
+ def _run(self, query: str) -> SearchOutput:
+ return SearchOutput(query=query, score=0.9)
+
+ tool = TypedSearchTool()
+ tool_call = MagicMock()
+ tool_call.id = "call_1"
+ tool_call.function.name = "typed_search"
+ tool_call.function.arguments = '{"query": "crew"}'
+
+ result = execute_single_native_tool_call(
+ tool_call,
+ available_functions={"typed_search": tool._run},
+ original_tools=[tool],
+ structured_tools=[tool.to_structured_tool()],
+ tools_handler=None,
+ agent=None,
+ task=None,
+ crew=None,
+ event_source=MagicMock(),
+ printer=None,
+ verbose=False,
+ )
+
+ assert json.loads(result.result) == {"query": "crew", "score": 0.9}
+ assert json.loads(result.tool_message["content"]) == {
+ "query": "crew",
+ "score": 0.9,
+ }
+
+ def test_custom_agent_output_formatter_is_used_from_structured_tool(
+ self,
+ ) -> None:
+ clear_before_tool_call_hooks()
+ clear_after_tool_call_hooks()
+
+ class SearchOutput(BaseModel):
+ query: str
+ score: float
+
+ class MarkdownSearchTool(BaseTool):
+ name: str = "markdown_search"
+ description: str = "Search for a query"
+ result_schema: type[BaseModel] = SearchOutput
+
+ def _run(self, query: str) -> SearchOutput:
+ return SearchOutput(query=query, score=0.9)
+
+ def format_output_for_agent(self, raw_result: Any) -> str:
+ result = self.result_schema.model_validate(raw_result)
+ return f"### {result.query}\n\nScore: **{result.score}**"
+
+ tool = MarkdownSearchTool()
+ tool_call = MagicMock()
+ tool_call.id = "call_1"
+ tool_call.function.name = "markdown_search"
+ tool_call.function.arguments = '{"query": "crew"}'
+
+ result = execute_single_native_tool_call(
+ tool_call,
+ available_functions={"markdown_search": tool._run},
+ original_tools=[],
+ structured_tools=[tool.to_structured_tool()],
+ tools_handler=None,
+ agent=None,
+ task=None,
+ crew=None,
+ event_source=MagicMock(),
+ printer=None,
+ verbose=False,
+ )
+
+ assert result.result == "### crew\n\nScore: **0.9**"
+ assert result.tool_message["content"] == "### crew\n\nScore: **0.9**"
+
+ def test_after_hook_includes_raw_tool_result_for_typed_output(self) -> None:
+ clear_after_tool_call_hooks()
+
+ class SearchOutput(BaseModel):
+ query: str
+ score: float
+
+ class TypedSearchTool(BaseTool):
+ name: str = "typed_search"
+ description: str = "Search for a query"
+ result_schema: type[BaseModel] = SearchOutput
+
+ def _run(self, query: str) -> SearchOutput:
+ return SearchOutput(query=query, score=0.9)
+
+ seen_results: list[tuple[str | None, object]] = []
+
+ def after_hook(context: ToolCallHookContext) -> None:
+ seen_results.append((context.tool_result, context.raw_tool_result))
+
+ tool = TypedSearchTool()
+ tool_call = MagicMock()
+ tool_call.id = "call_1"
+ tool_call.function.name = "typed_search"
+ tool_call.function.arguments = '{"query": "crew"}'
+
+ register_after_tool_call_hook(after_hook)
+ try:
+ result = execute_single_native_tool_call(
+ tool_call,
+ available_functions={"typed_search": tool._run},
+ original_tools=[tool],
+ structured_tools=[tool.to_structured_tool()],
+ tools_handler=None,
+ agent=None,
+ task=None,
+ crew=None,
+ event_source=MagicMock(),
+ printer=None,
+ verbose=False,
+ )
+ finally:
+ clear_after_tool_call_hooks()
+
+ assert json.loads(result.result) == {"query": "crew", "score": 0.9}
+ assert seen_results == [
+ ('{"query":"crew","score":0.9}', SearchOutput(query="crew", score=0.9))
+ ]
+
def test_result_as_answer_false_on_tool_error(self) -> None:
"""When a tool with result_as_answer=True raises, result_as_answer must be False.
diff --git a/lib/crewai/tests/utilities/test_console_formatter_pause_resume.py b/lib/crewai/tests/utilities/test_console_formatter_pause_resume.py
index 0adb43d83..1ffbb3850 100644
--- a/lib/crewai/tests/utilities/test_console_formatter_pause_resume.py
+++ b/lib/crewai/tests/utilities/test_console_formatter_pause_resume.py
@@ -46,6 +46,16 @@ class TestConsoleFormatterPauseResume:
formatter.resume_live_updates()
+ def test_flow_method_status_ignores_formatter_verbose(self):
+ formatter = ConsoleFormatter(verbose=False)
+
+ with patch.object(formatter, "print_panel") as mock_print_panel:
+ formatter.handle_method_status("categorize_tickets")
+
+ mock_print_panel.assert_called_once()
+ _, kwargs = mock_print_panel.call_args
+ assert kwargs["is_flow"] is True
+
def test_streaming_after_pause_resume_creates_new_session(self):
"""Test that streaming after pause/resume creates new Live session."""
formatter = ConsoleFormatter()
diff --git a/lib/devtools/src/crewai_devtools/__init__.py b/lib/devtools/src/crewai_devtools/__init__.py
index f5ec9a4c9..086c71f4f 100644
--- a/lib/devtools/src/crewai_devtools/__init__.py
+++ b/lib/devtools/src/crewai_devtools/__init__.py
@@ -1,3 +1,3 @@
"""CrewAI development tools."""
-__version__ = "1.14.8a2"
+__version__ = "1.14.8a5"
diff --git a/pyproject.toml b/pyproject.toml
index d36586f4a..845e365ad 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -15,7 +15,7 @@ dev = [
"pytest==9.0.3",
"pytest-asyncio==1.3.0",
"pytest-subprocess==1.5.3",
- "vcrpy==7.0.0", # pinned, less versions break pytest-recording
+ "vcrpy==8.2.1", # pinned, lower versions break pytest-recording
"pytest-recording==0.13.4",
"pytest-randomly==4.0.1",
"pytest-timeout==2.4.0",
@@ -171,8 +171,8 @@ info = "Commits must follow Conventional Commits 1.0.0."
[tool.uv]
exclude-newer = "3 days"
-# pypdf 6.13.3 is a security fix newer than the global supply-chain cutoff.
-exclude-newer-package = { pypdf = "2026-06-18T00:00:00Z" }
+# These security fixes are newer than the global supply-chain cutoff.
+exclude-newer-package = { pypdf = "2026-06-18T00:00:00Z", msgpack = "2026-06-20T00:00:00Z", pydantic-settings = "2026-06-20T00:00:00Z", langsmith = "2026-06-20T00:00:00Z" }
# composio-core pins rich<14 but textual requires rich>=14.
# onnxruntime 1.24+ dropped Python 3.10 wheels; cap it so qdrant[fastembed] resolves on 3.10.
@@ -188,7 +188,8 @@ exclude-newer-package = { pypdf = "2026-06-18T00:00:00Z" }
# python-multipart <0.0.27 has GHSA-pp6c-gr5w-3c5g (DoS via unbounded multipart headers).
# gitpython <3.1.50 has GHSA-mv93-w799-cj2w (config_writer newline injection bypassing the 3.1.49 patch -> RCE via core.hooksPath).
# urllib3 <2.7.0 has GHSA-qccp-gfcp-xxvc (ProxyManager cross-origin redirect leaks Authorization/Cookie) and GHSA-mf9v-mfxr-j63j (streaming decompression-bomb bypass); force 2.7.0+.
-# langsmith <0.8.0 has GHSA-3644-q5cj-c5c7 (public prompt manifest deserialization, SSRF/secret disclosure); force 0.8.0+.
+# langsmith <0.8.18 has GHSA-3644-q5cj-c5c7 (public prompt manifest deserialization, SSRF/secret disclosure)
+# and GHSA-f4xh-w4cj-qxq8; force 0.8.18+.
# authlib <1.6.12 has GHSA-jj8c-mmj3-mmgv (CSRF bypass in cache-based state storage) and PYSEC-2026-188.
# pip 26.1.1 has PYSEC-2026-196; force 26.1.2+.
# aiohttp <=3.13.x has GHSA-jg22-mg44-37j8, GHSA-hg6j-4rv6-33pg; fixed in 3.14.0; force 3.14.0+.
@@ -196,6 +197,8 @@ exclude-newer-package = { pypdf = "2026-06-18T00:00:00Z" }
# pip <26.1.1 has GHSA-58qw-9mgm-455v (archive handling); OSV considers 26.1.1 unaffected.
# paramiko <5.0.0 has GHSA-r374-rxx8-8654 (SHA-1 in rsakey.py); OSV considers 5.0.0 unaffected. Transitive via composio-core.
# starlette <1.3.1 has PYSEC-2026-161, GHSA-jp82-jpqv-5vv3, and GHSA-82w8-qh3p-5jfq. Transitive via fastapi.
+# msgpack <1.2.1 has GHSA-6v7p-g79w-8964; transitive via pip-audit[filecache].
+# pydantic-settings <2.14.2 has GHSA-4xgf-cpjx-pc3j.
# Keep OpenAI on the SDK range required by CrewAI when transitive dependencies
# loosen or pin their own lower versions.
override-dependencies = [
@@ -212,16 +215,17 @@ override-dependencies = [
"uv>=0.11.15,<1",
"python-multipart>=0.0.27,<1",
"gitpython>=3.1.50,<4",
- "langsmith>=0.8.0,<1",
+ "langsmith>=0.8.18,<1",
"authlib>=1.6.12",
"pip>=26.1.2",
"aiohttp>=3.14.0",
# [chunking] carried here because override-dependencies replace the whole
# requirement; without it the docling extra's chunking deps get stripped.
"docling-core[chunking]>=2.74.1",
- "pydantic-settings>=2.14.0",
"paramiko>=5.0.0",
"starlette>=1.3.1",
+ "msgpack>=1.2.1",
+ "pydantic-settings>=2.14.2",
]
[tool.uv.workspace]
diff --git a/uv.lock b/uv.lock
index d2a5c37fc..93e0f8be8 100644
--- a/uv.lock
+++ b/uv.lock
@@ -13,11 +13,14 @@ 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]
+msgpack = "2026-06-20T00:00:00Z"
+langsmith = "2026-06-20T00:00:00Z"
pypdf = "2026-06-18T00:00:00Z"
+pydantic-settings = "2026-06-20T00:00:00Z"
[manifest]
members = [
@@ -36,13 +39,14 @@ overrides = [
{ name = "gitpython", specifier = ">=3.1.50,<4" },
{ name = "langchain-core", specifier = ">=1.3.3,<2" },
{ name = "langchain-text-splitters", specifier = ">=1.1.2,<2" },
- { name = "langsmith", specifier = ">=0.8.0,<1" },
+ { name = "langsmith", specifier = ">=0.8.18,<1" },
+ { name = "msgpack", specifier = ">=1.2.1" },
{ name = "onnxruntime", marker = "python_full_version < '3.11'", specifier = "<1.24" },
{ name = "openai", specifier = ">=2.30.0,<3" },
{ name = "paramiko", specifier = ">=5.0.0" },
{ name = "pillow", specifier = ">=12.1.1" },
{ name = "pip", specifier = ">=26.1.2" },
- { name = "pydantic-settings", specifier = ">=2.14.0" },
+ { name = "pydantic-settings", specifier = ">=2.14.2" },
{ name = "pypdf", specifier = ">=6.13.3,<7" },
{ name = "python-multipart", specifier = ">=0.0.27,<1" },
{ name = "rich", specifier = ">=13.7.1" },
@@ -77,7 +81,7 @@ dev = [
{ name = "types-redis", specifier = "~=4.6" },
{ name = "types-regex", specifier = "==2026.1.15.*" },
{ name = "types-requests", specifier = "~=2.31.0.6" },
- { name = "vcrpy", specifier = "==7.0.0" },
+ { name = "vcrpy", specifier = "==8.2.1" },
]
[[package]]
@@ -117,7 +121,7 @@ wheels = [
[[package]]
name = "aiobotocore"
-version = "3.4.0"
+version = "3.5.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiohttp" },
@@ -129,9 +133,9 @@ dependencies = [
{ name = "typing-extensions", marker = "python_full_version < '3.11'" },
{ name = "wrapt" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/b8/50/a48ed11b15f926ce3dbb33e7fb0f25af17dbb99bcb7ae3b30c763723eca7/aiobotocore-3.4.0.tar.gz", hash = "sha256:a918b5cb903f81feba7e26835aed4b5e6bb2d0149d7f42bb2dd7d8089e3d9000", size = 122360, upload-time = "2026-04-07T06:12:24.884Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/e6/89/9533b377e9412013cc43a539d81bc5f8feeb4b6830643821ad612f78b09b/aiobotocore-3.5.0.tar.gz", hash = "sha256:d45d1c4659ad0e48b694a5aa4ff18829100386f7de96c8d146ec7757a6f12918", size = 123061, upload-time = "2026-04-21T07:25:26.993Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/df/d8/ce9386e6d76ea79e61dee15e62aa48cff6be69e89246b0ac4a11857cb02c/aiobotocore-3.4.0-py3-none-any.whl", hash = "sha256:26290eb6830ea92d8a6f5f90b56e9f5cedd6d126074d5db63b195e281d982465", size = 88018, upload-time = "2026-04-07T06:12:22.684Z" },
+ { url = "https://files.pythonhosted.org/packages/2d/05/6eeeadef45c24630af0ceae4d038b883e9a394786300529286ba8cc1e62d/aiobotocore-3.5.0-py3-none-any.whl", hash = "sha256:49ce35bb8b96b85d3251c2cbbb2ed7a028dc0cb0d0d0801f9ccca1ccd0d41ded", size = 88281, upload-time = "2026-04-21T07:25:25.258Z" },
]
[[package]]
@@ -657,7 +661,7 @@ wheels = [
[[package]]
name = "bedrock-agentcore"
-version = "1.6.0"
+version = "1.7.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "boto3" },
@@ -669,9 +673,9 @@ dependencies = [
{ name = "uvicorn" },
{ name = "websockets" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/ca/f6/2884c954343e794e3419348f5ffb0276a26f57b30af11f9fe63c4ca535c0/bedrock_agentcore-1.6.0.tar.gz", hash = "sha256:7ea176c3226dc6af8c399a8f9abb58629948cd8ed8675ece9f2f32b94e861b92", size = 512010, upload-time = "2026-03-31T23:10:06.561Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/01/65/69a66d812c5f86b902234fe91146004efcea907444a60f024f9afe13d150/bedrock_agentcore-1.7.0.tar.gz", hash = "sha256:cf632892f6bd055ce047eb91fe4d72f86569234faf3eb5cd1b2b614261a77d7f", size = 540824, upload-time = "2026-04-28T19:29:02.749Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/a2/f8/bcf158979324f4f4d171588afffadb2154fa8499701290bfc7bdaf82bd3a/bedrock_agentcore-1.6.0-py3-none-any.whl", hash = "sha256:a4cd02f2bfb80fcc7a8c8835be8d55c778339f8286b071ac3aae579460dd2eb2", size = 164034, upload-time = "2026-03-31T23:10:04.902Z" },
+ { url = "https://files.pythonhosted.org/packages/ba/9d/5f590afd5351e206d9a02f96777a69d1fc3edecfaa39bbba310248f21ea9/bedrock_agentcore-1.7.0-py3-none-any.whl", hash = "sha256:ee49695e613973baf01b4be400d3bc4b20ddedf3638765fb3bc6931a87fa0cd9", size = 178978, upload-time = "2026-04-28T19:29:00.944Z" },
]
[[package]]
@@ -685,16 +689,16 @@ wheels = [
[[package]]
name = "boto3"
-version = "1.42.84"
+version = "1.42.91"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "botocore" },
{ name = "jmespath" },
{ name = "s3transfer" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/88/89/2d647bd717da55a8cc68602b197f53a5fa36fb95a2f9e76c4aff11a9cfd1/boto3-1.42.84.tar.gz", hash = "sha256:6a84b3293a5d8b3adf827a54588e7dcffcf0a85410d7dadca615544f97d27579", size = 112816, upload-time = "2026-04-06T19:39:07.585Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/a7/c0/98b8cec7ca22dde776df48c58940ae1abc425593959b7226e270760d726f/boto3-1.42.91.tar.gz", hash = "sha256:03d70532b17f7f84df37ca7e8c21553280454dea53ae12b15d1cfef9b16fcb8a", size = 113181, upload-time = "2026-04-17T19:31:06.251Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/2d/31/cdf4326841613d1d181a77b3038a988800fb3373ca50de1639fba9fa87de/boto3-1.42.84-py3-none-any.whl", hash = "sha256:4d03ad3211832484037337292586f71f48707141288d9ac23049c04204f4ab03", size = 140555, upload-time = "2026-04-06T19:39:06.009Z" },
+ { url = "https://files.pythonhosted.org/packages/02/29/faba6521257c34085cc9b439ef98235b581772580f417fa3629728007270/boto3-1.42.91-py3-none-any.whl", hash = "sha256:04e72071cde022951ce7f81bd9933c90095ab8923e8ced61c8dacfe9edac0f5c", size = 140553, upload-time = "2026-04-17T19:31:02.57Z" },
]
[[package]]
@@ -718,16 +722,16 @@ bedrock-runtime = [
[[package]]
name = "botocore"
-version = "1.42.84"
+version = "1.42.91"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "jmespath" },
{ name = "python-dateutil" },
{ name = "urllib3" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/b4/b7/1c03423843fb0d1795b686511c00ee63fed1234c2400f469aeedfd42212f/botocore-1.42.84.tar.gz", hash = "sha256:234064604c80d9272a5e9f6b3566d260bcaa053a5e05246db90d7eca1c2cf44b", size = 15148615, upload-time = "2026-04-06T19:38:56.673Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/21/bc/a4b7c46471c2e789ad8c4c7acfd7f302fdb481d93ff870f441249b924ae6/botocore-1.42.91.tar.gz", hash = "sha256:d252e27bc454afdbf5ed3dc617aa423f2c855c081e98b7963093399483ecc698", size = 15213010, upload-time = "2026-04-17T19:30:50.793Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/e3/37/0c0c90361c8a1b9e6c75222ca24ae12996a298c0e18822a72ab229c37207/botocore-1.42.84-py3-none-any.whl", hash = "sha256:15f3fe07dfa6545e46a60c4b049fe2bdf63803c595ae4a4eec90e8f8172764f3", size = 14827061, upload-time = "2026-04-06T19:38:53.613Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/fc/24cc0a47c824f13933e210e9ad034b4fba22f7185b8d904c0fbf5a3b2be8/botocore-1.42.91-py3-none-any.whl", hash = "sha256:7a28c3cc6bfab5724ad18899d52402b776a0de7d87fa20c3c5270bcaaf199ce8", size = 14897344, upload-time = "2026-04-17T19:30:44.245Z" },
]
[[package]]
@@ -1413,7 +1417,7 @@ watson = [
[package.metadata]
requires-dist = [
{ name = "a2a-sdk", marker = "extra == 'a2a'", specifier = "~=0.3.10" },
- { name = "aiobotocore", marker = "extra == 'aws'", specifier = "~=3.4.0" },
+ { name = "aiobotocore", marker = "extra == 'aws'", specifier = "~=3.5.0" },
{ name = "aiocache", extras = ["memcached", "redis"], marker = "extra == 'a2a'", specifier = "~=0.12.3" },
{ name = "aiofiles", specifier = "~=24.1.0" },
{ name = "aiosqlite", specifier = "~=0.21.0" },
@@ -1421,8 +1425,8 @@ requires-dist = [
{ name = "appdirs", specifier = "~=1.4.4" },
{ name = "azure-ai-inference", marker = "extra == 'azure-ai-inference'", specifier = "~=1.0.0b9" },
{ name = "azure-identity", marker = "extra == 'azure-ai-inference'", specifier = ">=1.17.0,<2" },
- { name = "boto3", marker = "extra == 'aws'", specifier = "~=1.42.79" },
- { name = "boto3", marker = "extra == 'bedrock'", specifier = "~=1.42.79" },
+ { name = "boto3", marker = "extra == 'aws'", specifier = "~=1.42.90" },
+ { name = "boto3", marker = "extra == 'bedrock'", specifier = "~=1.42.90" },
{ name = "cel-python", specifier = ">=0.5.0,<0.6" },
{ name = "chromadb", specifier = "~=1.1.0" },
{ name = "click", specifier = ">=8.1.7,<9" },
@@ -1448,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" },
@@ -1535,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" },
@@ -1734,7 +1738,7 @@ requires-dist = [
{ name = "beautifulsoup4", specifier = "~=4.13.4" },
{ name = "beautifulsoup4", marker = "extra == 'beautifulsoup4'", specifier = ">=4.12.3" },
{ name = "beautifulsoup4", marker = "extra == 'bedrock'", specifier = ">=4.13.4" },
- { name = "bedrock-agentcore", marker = "extra == 'bedrock'", specifier = ">=0.1.0" },
+ { name = "bedrock-agentcore", marker = "extra == 'bedrock'", specifier = ">=1.7.0,<1.8.0" },
{ name = "browserbase", marker = "extra == 'browserbase'", specifier = ">=1.0.5" },
{ name = "composio-core", marker = "extra == 'composio-core'", specifier = ">=0.6.11.post1" },
{ name = "contextual-client", marker = "extra == 'contextual'", specifier = ">=0.1.0" },
@@ -3946,7 +3950,7 @@ sdist = { url = "https://files.pythonhosted.org/packages/0e/72/a3add0e4eec4eb9e2
[[package]]
name = "langsmith"
-version = "0.8.11"
+version = "0.8.18"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "httpx" },
@@ -3960,9 +3964,9 @@ dependencies = [
{ name = "xxhash" },
{ name = "zstandard" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/ea/0d/082410ece26ff9f3ed4f87b014a8675be47cbd7d65f06b922045dfc21c47/langsmith-0.8.11.tar.gz", hash = "sha256:d9b3496f8f7ca63f4f2d1dfd368afd6c527923fff2ce4026c82ce85f37db3965", size = 4495842, upload-time = "2026-06-08T22:54:44.395Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/9a/d9/a6681aa9847bbbc5ec21abe20a5e233b94e5edcfe39624db607ac7e8ccb4/langsmith-0.8.18.tar.gz", hash = "sha256:32dde9c0e67e053e0fb738921fc8ced768af7b8fa83d7a0e3fd63597cf8776dd", size = 4526988, upload-time = "2026-06-19T13:12:17.123Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/b4/65/f9c9dc19b21a9076286fafdb0ab732c9019ddf71aa7e7d720a830a98fe2a/langsmith-0.8.11-py3-none-any.whl", hash = "sha256:08aa5e84b00703ecc11dbeafda78d84b92da4e8c6114e0be9b59df9e71afc59b", size = 478985, upload-time = "2026-06-08T22:54:42.349Z" },
+ { url = "https://files.pythonhosted.org/packages/03/70/0e0cc80a3b064c8d6c8d697c3125ed86e39d5a7393ec6dc8b07cb1cf13c4/langsmith-0.8.18-py3-none-any.whl", hash = "sha256:3940183349993faef48e6c7d08e4822ee9cefd906b362d0e3c2d650314d2f282", size = 508108, upload-time = "2026-06-19T13:12:15.348Z" },
]
[[package]]
@@ -4702,45 +4706,53 @@ wheels = [
[[package]]
name = "msgpack"
-version = "1.1.2"
+version = "1.2.1"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/4d/f2/bfb55a6236ed8725a96b0aa3acbd0ec17588e6a2c3b62a93eb513ed8783f/msgpack-1.1.2.tar.gz", hash = "sha256:3b60763c1373dd60f398488069bcdc703cd08a711477b5d480eecc9f9626f47e", size = 173581, upload-time = "2025-10-08T09:15:56.596Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/31/f9/c0a1c127f9049db9155afc316952ea571720dd01833ff5e4d7e8e6352dbb/msgpack-1.2.1.tar.gz", hash = "sha256:04c721c2c7448767e9e3f2520a475663d8ee0f09c31890f6d2bd70fd636a9647", size = 183960, upload-time = "2026-06-18T16:13:52.594Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/f5/a2/3b68a9e769db68668b25c6108444a35f9bd163bb848c0650d516761a59c0/msgpack-1.1.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0051fffef5a37ca2cd16978ae4f0aef92f164df86823871b5162812bebecd8e2", size = 81318, upload-time = "2025-10-08T09:14:38.722Z" },
- { url = "https://files.pythonhosted.org/packages/5b/e1/2b720cc341325c00be44e1ed59e7cfeae2678329fbf5aa68f5bda57fe728/msgpack-1.1.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a605409040f2da88676e9c9e5853b3449ba8011973616189ea5ee55ddbc5bc87", size = 83786, upload-time = "2025-10-08T09:14:40.082Z" },
- { url = "https://files.pythonhosted.org/packages/71/e5/c2241de64bfceac456b140737812a2ab310b10538a7b34a1d393b748e095/msgpack-1.1.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b696e83c9f1532b4af884045ba7f3aa741a63b2bc22617293a2c6a7c645f251", size = 398240, upload-time = "2025-10-08T09:14:41.151Z" },
- { url = "https://files.pythonhosted.org/packages/b7/09/2a06956383c0fdebaef5aa9246e2356776f12ea6f2a44bd1368abf0e46c4/msgpack-1.1.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:365c0bbe981a27d8932da71af63ef86acc59ed5c01ad929e09a0b88c6294e28a", size = 406070, upload-time = "2025-10-08T09:14:42.821Z" },
- { url = "https://files.pythonhosted.org/packages/0e/74/2957703f0e1ef20637d6aead4fbb314330c26f39aa046b348c7edcf6ca6b/msgpack-1.1.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:41d1a5d875680166d3ac5c38573896453bbbea7092936d2e107214daf43b1d4f", size = 393403, upload-time = "2025-10-08T09:14:44.38Z" },
- { url = "https://files.pythonhosted.org/packages/a5/09/3bfc12aa90f77b37322fc33e7a8a7c29ba7c8edeadfa27664451801b9860/msgpack-1.1.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:354e81bcdebaab427c3df4281187edc765d5d76bfb3a7c125af9da7a27e8458f", size = 398947, upload-time = "2025-10-08T09:14:45.56Z" },
- { url = "https://files.pythonhosted.org/packages/4b/4f/05fcebd3b4977cb3d840f7ef6b77c51f8582086de5e642f3fefee35c86fc/msgpack-1.1.2-cp310-cp310-win32.whl", hash = "sha256:e64c8d2f5e5d5fda7b842f55dec6133260ea8f53c4257d64494c534f306bf7a9", size = 64769, upload-time = "2025-10-08T09:14:47.334Z" },
- { url = "https://files.pythonhosted.org/packages/d0/3e/b4547e3a34210956382eed1c85935fff7e0f9b98be3106b3745d7dec9c5e/msgpack-1.1.2-cp310-cp310-win_amd64.whl", hash = "sha256:db6192777d943bdaaafb6ba66d44bf65aa0e9c5616fa1d2da9bb08828c6b39aa", size = 71293, upload-time = "2025-10-08T09:14:48.665Z" },
- { url = "https://files.pythonhosted.org/packages/2c/97/560d11202bcd537abca693fd85d81cebe2107ba17301de42b01ac1677b69/msgpack-1.1.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2e86a607e558d22985d856948c12a3fa7b42efad264dca8a3ebbcfa2735d786c", size = 82271, upload-time = "2025-10-08T09:14:49.967Z" },
- { url = "https://files.pythonhosted.org/packages/83/04/28a41024ccbd67467380b6fb440ae916c1e4f25e2cd4c63abe6835ac566e/msgpack-1.1.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:283ae72fc89da59aa004ba147e8fc2f766647b1251500182fac0350d8af299c0", size = 84914, upload-time = "2025-10-08T09:14:50.958Z" },
- { url = "https://files.pythonhosted.org/packages/71/46/b817349db6886d79e57a966346cf0902a426375aadc1e8e7a86a75e22f19/msgpack-1.1.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:61c8aa3bd513d87c72ed0b37b53dd5c5a0f58f2ff9f26e1555d3bd7948fb7296", size = 416962, upload-time = "2025-10-08T09:14:51.997Z" },
- { url = "https://files.pythonhosted.org/packages/da/e0/6cc2e852837cd6086fe7d8406af4294e66827a60a4cf60b86575a4a65ca8/msgpack-1.1.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:454e29e186285d2ebe65be34629fa0e8605202c60fbc7c4c650ccd41870896ef", size = 426183, upload-time = "2025-10-08T09:14:53.477Z" },
- { url = "https://files.pythonhosted.org/packages/25/98/6a19f030b3d2ea906696cedd1eb251708e50a5891d0978b012cb6107234c/msgpack-1.1.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7bc8813f88417599564fafa59fd6f95be417179f76b40325b500b3c98409757c", size = 411454, upload-time = "2025-10-08T09:14:54.648Z" },
- { url = "https://files.pythonhosted.org/packages/b7/cd/9098fcb6adb32187a70b7ecaabf6339da50553351558f37600e53a4a2a23/msgpack-1.1.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bafca952dc13907bdfdedfc6a5f579bf4f292bdd506fadb38389afa3ac5b208e", size = 422341, upload-time = "2025-10-08T09:14:56.328Z" },
- { url = "https://files.pythonhosted.org/packages/e6/ae/270cecbcf36c1dc85ec086b33a51a4d7d08fc4f404bdbc15b582255d05ff/msgpack-1.1.2-cp311-cp311-win32.whl", hash = "sha256:602b6740e95ffc55bfb078172d279de3773d7b7db1f703b2f1323566b878b90e", size = 64747, upload-time = "2025-10-08T09:14:57.882Z" },
- { url = "https://files.pythonhosted.org/packages/2a/79/309d0e637f6f37e83c711f547308b91af02b72d2326ddd860b966080ef29/msgpack-1.1.2-cp311-cp311-win_amd64.whl", hash = "sha256:d198d275222dc54244bf3327eb8cbe00307d220241d9cec4d306d49a44e85f68", size = 71633, upload-time = "2025-10-08T09:14:59.177Z" },
- { url = "https://files.pythonhosted.org/packages/73/4d/7c4e2b3d9b1106cd0aa6cb56cc57c6267f59fa8bfab7d91df5adc802c847/msgpack-1.1.2-cp311-cp311-win_arm64.whl", hash = "sha256:86f8136dfa5c116365a8a651a7d7484b65b13339731dd6faebb9a0242151c406", size = 64755, upload-time = "2025-10-08T09:15:00.48Z" },
- { url = "https://files.pythonhosted.org/packages/ad/bd/8b0d01c756203fbab65d265859749860682ccd2a59594609aeec3a144efa/msgpack-1.1.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:70a0dff9d1f8da25179ffcf880e10cf1aad55fdb63cd59c9a49a1b82290062aa", size = 81939, upload-time = "2025-10-08T09:15:01.472Z" },
- { url = "https://files.pythonhosted.org/packages/34/68/ba4f155f793a74c1483d4bdef136e1023f7bcba557f0db4ef3db3c665cf1/msgpack-1.1.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:446abdd8b94b55c800ac34b102dffd2f6aa0ce643c55dfc017ad89347db3dbdb", size = 85064, upload-time = "2025-10-08T09:15:03.764Z" },
- { url = "https://files.pythonhosted.org/packages/f2/60/a064b0345fc36c4c3d2c743c82d9100c40388d77f0b48b2f04d6041dbec1/msgpack-1.1.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c63eea553c69ab05b6747901b97d620bb2a690633c77f23feb0c6a947a8a7b8f", size = 417131, upload-time = "2025-10-08T09:15:05.136Z" },
- { url = "https://files.pythonhosted.org/packages/65/92/a5100f7185a800a5d29f8d14041f61475b9de465ffcc0f3b9fba606e4505/msgpack-1.1.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:372839311ccf6bdaf39b00b61288e0557916c3729529b301c52c2d88842add42", size = 427556, upload-time = "2025-10-08T09:15:06.837Z" },
- { url = "https://files.pythonhosted.org/packages/f5/87/ffe21d1bf7d9991354ad93949286f643b2bb6ddbeab66373922b44c3b8cc/msgpack-1.1.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2929af52106ca73fcb28576218476ffbb531a036c2adbcf54a3664de124303e9", size = 404920, upload-time = "2025-10-08T09:15:08.179Z" },
- { url = "https://files.pythonhosted.org/packages/ff/41/8543ed2b8604f7c0d89ce066f42007faac1eaa7d79a81555f206a5cdb889/msgpack-1.1.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:be52a8fc79e45b0364210eef5234a7cf8d330836d0a64dfbb878efa903d84620", size = 415013, upload-time = "2025-10-08T09:15:09.83Z" },
- { url = "https://files.pythonhosted.org/packages/41/0d/2ddfaa8b7e1cee6c490d46cb0a39742b19e2481600a7a0e96537e9c22f43/msgpack-1.1.2-cp312-cp312-win32.whl", hash = "sha256:1fff3d825d7859ac888b0fbda39a42d59193543920eda9d9bea44d958a878029", size = 65096, upload-time = "2025-10-08T09:15:11.11Z" },
- { url = "https://files.pythonhosted.org/packages/8c/ec/d431eb7941fb55a31dd6ca3404d41fbb52d99172df2e7707754488390910/msgpack-1.1.2-cp312-cp312-win_amd64.whl", hash = "sha256:1de460f0403172cff81169a30b9a92b260cb809c4cb7e2fc79ae8d0510c78b6b", size = 72708, upload-time = "2025-10-08T09:15:12.554Z" },
- { url = "https://files.pythonhosted.org/packages/c5/31/5b1a1f70eb0e87d1678e9624908f86317787b536060641d6798e3cf70ace/msgpack-1.1.2-cp312-cp312-win_arm64.whl", hash = "sha256:be5980f3ee0e6bd44f3a9e9dea01054f175b50c3e6cdb692bc9424c0bbb8bf69", size = 64119, upload-time = "2025-10-08T09:15:13.589Z" },
- { url = "https://files.pythonhosted.org/packages/6b/31/b46518ecc604d7edf3a4f94cb3bf021fc62aa301f0cb849936968164ef23/msgpack-1.1.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4efd7b5979ccb539c221a4c4e16aac1a533efc97f3b759bb5a5ac9f6d10383bf", size = 81212, upload-time = "2025-10-08T09:15:14.552Z" },
- { url = "https://files.pythonhosted.org/packages/92/dc/c385f38f2c2433333345a82926c6bfa5ecfff3ef787201614317b58dd8be/msgpack-1.1.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:42eefe2c3e2af97ed470eec850facbe1b5ad1d6eacdbadc42ec98e7dcf68b4b7", size = 84315, upload-time = "2025-10-08T09:15:15.543Z" },
- { url = "https://files.pythonhosted.org/packages/d3/68/93180dce57f684a61a88a45ed13047558ded2be46f03acb8dec6d7c513af/msgpack-1.1.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1fdf7d83102bf09e7ce3357de96c59b627395352a4024f6e2458501f158bf999", size = 412721, upload-time = "2025-10-08T09:15:16.567Z" },
- { url = "https://files.pythonhosted.org/packages/5d/ba/459f18c16f2b3fc1a1ca871f72f07d70c07bf768ad0a507a698b8052ac58/msgpack-1.1.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fac4be746328f90caa3cd4bc67e6fe36ca2bf61d5c6eb6d895b6527e3f05071e", size = 424657, upload-time = "2025-10-08T09:15:17.825Z" },
- { url = "https://files.pythonhosted.org/packages/38/f8/4398c46863b093252fe67368b44edc6c13b17f4e6b0e4929dbf0bdb13f23/msgpack-1.1.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:fffee09044073e69f2bad787071aeec727183e7580443dfeb8556cbf1978d162", size = 402668, upload-time = "2025-10-08T09:15:19.003Z" },
- { url = "https://files.pythonhosted.org/packages/28/ce/698c1eff75626e4124b4d78e21cca0b4cc90043afb80a507626ea354ab52/msgpack-1.1.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5928604de9b032bc17f5099496417f113c45bc6bc21b5c6920caf34b3c428794", size = 419040, upload-time = "2025-10-08T09:15:20.183Z" },
- { url = "https://files.pythonhosted.org/packages/67/32/f3cd1667028424fa7001d82e10ee35386eea1408b93d399b09fb0aa7875f/msgpack-1.1.2-cp313-cp313-win32.whl", hash = "sha256:a7787d353595c7c7e145e2331abf8b7ff1e6673a6b974ded96e6d4ec09f00c8c", size = 65037, upload-time = "2025-10-08T09:15:21.416Z" },
- { url = "https://files.pythonhosted.org/packages/74/07/1ed8277f8653c40ebc65985180b007879f6a836c525b3885dcc6448ae6cb/msgpack-1.1.2-cp313-cp313-win_amd64.whl", hash = "sha256:a465f0dceb8e13a487e54c07d04ae3ba131c7c5b95e2612596eafde1dccf64a9", size = 72631, upload-time = "2025-10-08T09:15:22.431Z" },
- { url = "https://files.pythonhosted.org/packages/e5/db/0314e4e2db56ebcf450f277904ffd84a7988b9e5da8d0d61ab2d057df2b6/msgpack-1.1.2-cp313-cp313-win_arm64.whl", hash = "sha256:e69b39f8c0aa5ec24b57737ebee40be647035158f14ed4b40e6f150077e21a84", size = 64118, upload-time = "2025-10-08T09:15:23.402Z" },
+ { url = "https://files.pythonhosted.org/packages/5b/16/f70100614b69feb3ade7285f08c9c52d6cda0a5c03f3f5e2facd63acb211/msgpack-1.2.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:8c7b398c56ff125feae96c2737abfec5595f1fa0aa186df60c56040b8accb95c", size = 82926, upload-time = "2026-06-18T16:12:31.531Z" },
+ { url = "https://files.pythonhosted.org/packages/e4/3c/08ecd5cdfe4e2de43aec79062028ad0f7b2d9b1fea5430068c198ba570da/msgpack-1.2.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1548006a91aa93c5da81f3bdcebc1a0d10cea2d25969754fbe848da622b2b895", size = 82730, upload-time = "2026-06-18T16:12:32.894Z" },
+ { url = "https://files.pythonhosted.org/packages/19/9f/a70c9cb1a04ecc134005149367dcfe35d167284e8f65035a1e4156ad17b5/msgpack-1.2.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1dabedcd0f23559f3596428c6589c1cd8c6eaed3a0d720795b07b0225d769203", size = 400729, upload-time = "2026-06-18T16:12:34.052Z" },
+ { url = "https://files.pythonhosted.org/packages/fa/7f/5ce020168cf0439041526e95aa068c722c016aee21624e331aeabeee2e8e/msgpack-1.2.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:83efa1c898e0fc5380fc0cabbf75164c52e3b5cbb45973710d75821928380c73", size = 407625, upload-time = "2026-06-18T16:12:35.239Z" },
+ { url = "https://files.pythonhosted.org/packages/79/70/fb7668ce0386819303047057aef6fc1da73b584291d9cff82b821744e2ef/msgpack-1.2.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:01e2dd6c9b19d333a00282330cc8a73d38d8dabc306dc5b42cd668c3ac82e833", size = 377891, upload-time = "2026-06-18T16:12:36.684Z" },
+ { url = "https://files.pythonhosted.org/packages/3d/dc/9ebe654a73c3aed2e40aa6b52e3c2a02b5f53ef0085fa235a45d5b367f87/msgpack-1.2.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:350cb813d0af6e65d2f7ef0d729f7ff5be5a8bce03665892f43e5883d4ecc1b8", size = 391987, upload-time = "2026-06-18T16:12:37.839Z" },
+ { url = "https://files.pythonhosted.org/packages/42/eb/b67cf64218a2fa25e1c671fe1d3dbb06cbeb973e71bc4b822da079862d0b/msgpack-1.2.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:ee1d9ed27d0497b848923746cf762ed2e7db24f4be7eec8e5cbe8c766aa707b7", size = 374603, upload-time = "2026-06-18T16:12:39.221Z" },
+ { url = "https://files.pythonhosted.org/packages/a2/2e/9ee200cde32fd1a0101b4006202fde554c1860adfb9bf7bff31ea4c08df8/msgpack-1.2.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:633727297ed063441fd1cda2288865487f33ad14eeb8831afb5f0c396a62cfce", size = 405121, upload-time = "2026-06-18T16:12:40.524Z" },
+ { url = "https://files.pythonhosted.org/packages/43/b6/f10117be7ca7a51e8feed699a907b8e663a8cd66e115ae6b4fb30cc7945c/msgpack-1.2.1-cp310-cp310-win32.whl", hash = "sha256:298872ecf9e61950f1c6af4ca969b859ee91783bb920ef6e6172697d0c8aad74", size = 64088, upload-time = "2026-06-18T16:12:41.762Z" },
+ { url = "https://files.pythonhosted.org/packages/ba/93/89976c696fb0224662239d952c47b4d1661b34d79a332ef5584facaa8579/msgpack-1.2.1-cp310-cp310-win_amd64.whl", hash = "sha256:2ff164c1b0bcb740b073b99e945234d0212852fa378e44a208c425379140dbeb", size = 70113, upload-time = "2026-06-18T16:12:42.78Z" },
+ { url = "https://files.pythonhosted.org/packages/f4/6b/e9b1cdc042c4458801d2545ed782a95f3d6ba8e270cce8745b8603c7f748/msgpack-1.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:29a3f6e9667868429d8240dfd063ea5ffdc1321c13d783aa23827a38de0dcb22", size = 82812, upload-time = "2026-06-18T16:12:45.022Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/3a/dd518a1bf78ed1e9ad8afe57307c079a00eafe4b3068932a27ca1ea56b4f/msgpack-1.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:aded5bdf32609dc7987a49bbbd15a8ef096193f96dd8bbeb791de729e650acf5", size = 82739, upload-time = "2026-06-18T16:12:46.025Z" },
+ { url = "https://files.pythonhosted.org/packages/70/e0/7ba9e1542bf0771a27b8b37c1316e3f95ae9d748fd765284655c476ad4ef/msgpack-1.2.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:146ee4e9ce80b365c6d4c47073da9da7bcec473e58194ceee5dd7620ace77e06", size = 414233, upload-time = "2026-06-18T16:12:47.029Z" },
+ { url = "https://files.pythonhosted.org/packages/03/8d/671d81534ea0e2b0e8a121be100020da09eb78861fe3aa8f3ef7dcd3bed1/msgpack-1.2.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a28d076ca7c82b9c8728ad90b7147489449557038bed50e4241eb832395169b4", size = 423843, upload-time = "2026-06-18T16:12:48.19Z" },
+ { url = "https://files.pythonhosted.org/packages/d2/b6/e5c737515ed1f166664b87601b532f58cbb73d8aa6a90b99f7c2c5037e8e/msgpack-1.2.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7d31c0ac0c640f877804c67cb2bc9f4e23dc2db97e96c2e67fa27d38283b41f8", size = 390772, upload-time = "2026-06-18T16:12:49.624Z" },
+ { url = "https://files.pythonhosted.org/packages/a8/46/62ed8c2e87d7021eab19921594d961ef3aa3794eec76c716dc30f3bfd433/msgpack-1.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8ff92d7feeaf5bc26c51495b69e2f99ed97ab79346fb6555f44be7dd2ac6503b", size = 409559, upload-time = "2026-06-18T16:12:50.936Z" },
+ { url = "https://files.pythonhosted.org/packages/70/ff/59aa3887b860bbf43532835e192b1c388a17590d6068ae4f8b2bc74c906e/msgpack-1.2.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:779197a6513bab3c3632265e3d0f7cb3227e62510841a6f34f1eaa37efbb345e", size = 387838, upload-time = "2026-06-18T16:12:52.161Z" },
+ { url = "https://files.pythonhosted.org/packages/09/11/f8563e471093420cf6478cb3271a0175d8402b82d879783d4035d2d03360/msgpack-1.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:67f6dd22fa72a93752643f07889796d62739a13415ee630169a8ce764f86cf9f", size = 421732, upload-time = "2026-06-18T16:12:53.556Z" },
+ { url = "https://files.pythonhosted.org/packages/57/cf/e673683c4c6c90c1022b24c65af4b03eda72b182a1176ef6449069d66acc/msgpack-1.2.1-cp311-cp311-win32.whl", hash = "sha256:91054a783328e0ea7954b8771095705c8d2243b814743fbaadf14552c9c52c5d", size = 64091, upload-time = "2026-06-18T16:12:54.821Z" },
+ { url = "https://files.pythonhosted.org/packages/3f/07/ca212739d179f9083bff2c7c08c24101c3555a334fadc2b876b18768a3ae/msgpack-1.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2eda0b7ebb1283a98d3e4492ac933c8af6aff59fd3df1c3ed024f536af4b1dc8", size = 70462, upload-time = "2026-06-18T16:12:55.898Z" },
+ { url = "https://files.pythonhosted.org/packages/6d/be/6798347b425e26f35db82e69dd83c09716c856a3714e7bffc4c0860fd830/msgpack-1.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:6ee967f7c7e1df2890c671ff2ee51a28ded0efc95da3e507176dee881ce36c66", size = 65059, upload-time = "2026-06-18T16:12:57.053Z" },
+ { url = "https://files.pythonhosted.org/packages/bc/dd/9e8cbd8f5582ca4b590336f2b91ee5662f6a6ca562b565abaf696a0f81ff/msgpack-1.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2ef59c659f289eddf8aa6623823f19fa2f40a4029266889eac7a2505dd210c35", size = 83531, upload-time = "2026-06-18T16:12:58.249Z" },
+ { url = "https://files.pythonhosted.org/packages/50/2e/ebdb85a8da151397a2790363676b7ed7c125924fe618e4c6d8befb0cc62c/msgpack-1.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d3567748a5107cb40cdf66a275430c2f87c07777698f4bfd25c35f44d533258c", size = 82657, upload-time = "2026-06-18T16:12:59.396Z" },
+ { url = "https://files.pythonhosted.org/packages/26/aa/753ad8b007b464e1d8aa0c8e650b9c5f4f725e658fc5ac8a7635c55b7f6e/msgpack-1.2.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:60926b75d00c8e816ef98f3034f484a8bc64242d66839cef4cf7e503142316a0", size = 410634, upload-time = "2026-06-18T16:13:00.383Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/fd/6adabd4f6d5e686f97dd02ce7fce3fe4cf672cbac36b8f67ff4040e8ad8b/msgpack-1.2.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:020e881a764b20d8d7ca1a54fc01b8175519d108e3c3f194fddc200bda95951a", size = 419989, upload-time = "2026-06-18T16:13:01.776Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/cc/85039b7b0eb168aaad7383a23c97e291a11f08351cb45a606ce865e4e3f1/msgpack-1.2.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4202c74688ca06591f78cb18988228bd4cca2cc75d57b60008372892d2f1e6e6", size = 377544, upload-time = "2026-06-18T16:13:03.637Z" },
+ { url = "https://files.pythonhosted.org/packages/ed/bf/35963899493b32030c85fc513b723ae66144ac70c11ebc52e889e16e3d99/msgpack-1.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8b267ce94efb76fbd1b3373511420074ee3187f0f7811bf394531de13294735a", size = 400842, upload-time = "2026-06-18T16:13:05.012Z" },
+ { url = "https://files.pythonhosted.org/packages/a6/df/8e2ac970c8f99264cd9997d1c73df5466bc19da3301d7dc5500862a9b089/msgpack-1.2.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:e4f1d0f8f98ade9634e01fb704a408f9336c0a8f1117b369f5db83dc7551d8b1", size = 374108, upload-time = "2026-06-18T16:13:06.232Z" },
+ { url = "https://files.pythonhosted.org/packages/17/dd/fa8bd265110dfa51c20cb529f9e6d240a16fafe7e645004c6af2d01353ba/msgpack-1.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f02cf17a6ca1abe29b5f980644f7551f94d71f2011509b26d8625ce038f0df64", size = 414939, upload-time = "2026-06-18T16:13:07.478Z" },
+ { url = "https://files.pythonhosted.org/packages/2e/b9/8377a5ad8953fc0437c70cc98d9ae29f27fe5ac5109fbec0812085865735/msgpack-1.2.1-cp312-cp312-win32.whl", hash = "sha256:0c0d9802354507bcba62af19c17918e3eb437cc25e6f50657d511b5856a77aac", size = 64504, upload-time = "2026-06-18T16:13:08.822Z" },
+ { url = "https://files.pythonhosted.org/packages/57/7f/ce1e377df7e62461fefd9eb23bfb93a4a523f40a517b377b8f844d836828/msgpack-1.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:5c24aa15d5963051e1a5c62b12c50cd705992502b5ec1f3bece6046f33c9fc24", size = 71421, upload-time = "2026-06-18T16:13:09.828Z" },
+ { url = "https://files.pythonhosted.org/packages/8f/32/ebfe84c9929f08f188d56c7a2fd913406a9ddad76a634697c1c43b8112e6/msgpack-1.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:4227224aaec8f7fbcbfbd4272319347b2bb4030366502600f8c45588c5187b07", size = 64775, upload-time = "2026-06-18T16:13:11.056Z" },
+ { url = "https://files.pythonhosted.org/packages/b0/ac/dcddcab6f6c20ecb387ca5e980371cdb3f87ff69aeca388be97eebc4c074/msgpack-1.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0a70e3cf2804a300d921bb0940426e35f4e489a23adfb77a808892241db0a064", size = 83151, upload-time = "2026-06-18T16:13:12.173Z" },
+ { url = "https://files.pythonhosted.org/packages/64/71/fbcfa83a1d6a9c6091942d1cfd070962244664b87427a9a49a6897b1b219/msgpack-1.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:491cc39455ca765fad51fb451bf2915eb2cf41192ab5801ce8d67c1d614fe056", size = 82351, upload-time = "2026-06-18T16:13:13.194Z" },
+ { url = "https://files.pythonhosted.org/packages/e3/10/ddf7b06db879e8792d13934ddda09ff20bd2a583fd84c9b59aae9b0e650b/msgpack-1.2.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f310233ef7fb9c14e201c93639fe5f5260b005f56f0b29048e999c30935596cc", size = 407518, upload-time = "2026-06-18T16:13:14.233Z" },
+ { url = "https://files.pythonhosted.org/packages/79/d3/36a46a8ed992b781acbc05928bd5bee3c810cb0c3563bf81a7b0c04a1a76/msgpack-1.2.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:787c9bebb5833e8f6fc8abca3c0597683d8d87f56a8842b6b89c75a5f3176e2d", size = 416405, upload-time = "2026-06-18T16:13:15.435Z" },
+ { url = "https://files.pythonhosted.org/packages/f9/84/e8e9598b557c0ba6ddae901a73780a4c75ac667dddf59414b1e56a42fb34/msgpack-1.2.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dc871b997a9370d855b7394465f2f350e847a5b806dd38dcc9c989e7d87da155", size = 376257, upload-time = "2026-06-18T16:13:17.022Z" },
+ { url = "https://files.pythonhosted.org/packages/40/16/738fe6d875ad7e2a9429c165322a4ec088f4f273cdfae63d96a89c467961/msgpack-1.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:85f57e960d877f2977f6430896191b04a21f8901b3b4baf2e4604329f4db5402", size = 397469, upload-time = "2026-06-18T16:13:18.287Z" },
+ { url = "https://files.pythonhosted.org/packages/ca/be/6d5952df75a7f24f35833af764c3a6860780364cb3a0030beb8099e1b2b4/msgpack-1.2.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:1233ee2dd0cefba127583de50ea654677277047d238303521db35def3d7b2e7c", size = 372802, upload-time = "2026-06-18T16:13:19.685Z" },
+ { url = "https://files.pythonhosted.org/packages/e1/39/e2ef7dbf0473bcb8dc7c50bf782a892d67414877b63e47fc88eb189ef5e6/msgpack-1.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e3dc2feb0876209d9c38aa56cb1de169bd6c4348f1aa48271f241226590993e6", size = 411273, upload-time = "2026-06-18T16:13:21.028Z" },
+ { url = "https://files.pythonhosted.org/packages/ef/c5/133f4512a56e983a93445c836c9d94d88f3bc2e0980ff4b9e577bd8416ce/msgpack-1.2.1-cp313-cp313-win32.whl", hash = "sha256:6d09badf350af2be9d189184e04e64cf54ad93569ab3d96fca58bd3e84aad707", size = 64471, upload-time = "2026-06-18T16:13:22.293Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/98/577e10b055096a7dd40732358cabaf7180a20c79ed1dcdbb618e4b9deac7/msgpack-1.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:33f14fba63278b714efe6ad07e50ea5f03d91537aa6a1c5f1ceca4cf44013ca9", size = 71274, upload-time = "2026-06-18T16:13:23.455Z" },
+ { url = "https://files.pythonhosted.org/packages/ba/ee/0c0048e7cfbef23c6a94791b8959ab28155232e7956de8a305b5ff588f05/msgpack-1.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:afc5febcd4c99effbc02b528e49d6fd0760b2b7d48c05239e345a5fa6e743d9a", size = 64795, upload-time = "2026-06-18T16:13:24.687Z" },
]
[[package]]
@@ -5573,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" },
@@ -5622,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" },
@@ -5640,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]]
@@ -6989,16 +7000,16 @@ wheels = [
[[package]]
name = "pydantic-settings"
-version = "2.14.1"
+version = "2.14.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pydantic" },
{ name = "python-dotenv" },
{ name = "typing-inspection" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/07/60/1d1e59c9c90d54591469ada7d268251f71c24bdb765f1a8a832cee8c6653/pydantic_settings-2.14.1.tar.gz", hash = "sha256:e874d3bec7e787b0c9958277956ed9b4dd5de6a80e162188fdaff7c5e26fd5fa", size = 235551, upload-time = "2026-05-08T13:40:06.542Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/5c/b5/8f48e906c3e0205276e8bd8cb7512217a87b2685304d64be27cad5b3019f/pydantic_settings-2.14.2.tar.gz", hash = "sha256:c19dd64b19097f1de80184f0cc7b0272a13ae6e170cbf240a3e27e381ed14a5f", size = 237700, upload-time = "2026-06-19T13:44:56.324Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/ae/8d/f1af3832f5e6eb13ba94ee809e72b8ecb5eef226d27ee0bef7d963d943c7/pydantic_settings-2.14.1-py3-none-any.whl", hash = "sha256:6e3c7edfd8277687cdc598f56e5cff0e9bfff0910a3749deaa8d4401c3a2b9de", size = 60964, upload-time = "2026-05-08T13:40:04.958Z" },
+ { url = "https://files.pythonhosted.org/packages/77/c1/6e422f34e569cf8e18df68d1939c81c099d2b61e4f7d9621c8a77560799c/pydantic_settings-2.14.2-py3-none-any.whl", hash = "sha256:a20c97b37910b6550d5ea50fbcc2d4187defe58cd57070b73863d069419c9440", size = 61715, upload-time = "2026-06-19T13:44:55.02Z" },
]
[[package]]
@@ -9698,17 +9709,15 @@ wheels = [
[[package]]
name = "vcrpy"
-version = "7.0.0"
+version = "8.2.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pyyaml" },
- { name = "urllib3" },
{ name = "wrapt" },
- { name = "yarl" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/25/d3/856e06184d4572aada1dd559ddec3bedc46df1f2edc5ab2c91121a2cccdb/vcrpy-7.0.0.tar.gz", hash = "sha256:176391ad0425edde1680c5b20738ea3dc7fb942520a48d2993448050986b3a50", size = 85502, upload-time = "2024-12-31T00:07:57.894Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/08/db/08183b845b0040bb877dad2bd7e4e0976fc232bb3476d7ee369c6c4f8b5a/vcrpy-8.2.1.tar.gz", hash = "sha256:d73a6e4eb6dae8148e659764b7a00e68cc51ba29ba9e6a85e1f0790ad96b97df", size = 90511, upload-time = "2026-06-16T13:20:52.906Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/13/5d/1f15b252890c968d42b348d1e9b0aa12d5bf3e776704178ec37cceccdb63/vcrpy-7.0.0-py2.py3-none-any.whl", hash = "sha256:55791e26c18daa363435054d8b35bd41a4ac441b6676167635d1b37a71dbe124", size = 42321, upload-time = "2024-12-31T00:07:55.277Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/7c/0e812ab83f5289404c674f3461ba783250b967d34b5ab034d361236ec042/vcrpy-8.2.1-py3-none-any.whl", hash = "sha256:7ce58c9e2792b246f79d6f4b3e9660676cc6f853be17e1547305b4437ab1ff85", size = 44925, upload-time = "2026-06-16T13:20:51.734Z" },
]
[[package]]