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، تُمثَّل كل خطوة عمل بـ **بطاقة واحدة**. تجمع البطاقة بين عنصرين كانا في السابق في عُقد منفصلة: + +- **المهمة** — ماذا تفعل (الاسم، الوصف، المخرجات المتوقعة، وتنسيق الاستجابة). +- **الوكيل** — من ينفّذها (الوكيل المُعيَّن ونموذجه وأدواته). + +الوكيل ليس مشاركًا مستقلاً في سير العمل لديك — بل هو سمة من سمات المهمة: *أي وكيل ينفّذ هذا العمل.* وضع المهمة والوكيل في بطاقة واحدة يجعل هذه العلاقة واضحة، ويحوّل أتمتتك إلى سلسلة واحدة من وحدات العمل من اليسار إلى اليمين يسهل قراءتها بنظرة واحدة. + + + ![بطاقات الخطوات الموحّدة على اللوحة](/images/enterprise/merged-step-card-canvas.png) + + +## على اللوحة + +تعرض كل بطاقة مطوية ما يلي: + +- **اسم المهمة ووصفها** في الأعلى. +- **تذييل يلخّص الوكيل المُعيَّن** — الصورة الرمزية والاسم والنموذج والأدوات. + +لا توجد عقدة وكيل منفصلة ولا حافة عمودية من الوكيل ← المهمة. تتصل خطواتك مباشرةً ببعضها البعض بالترتيب الذي تُنفَّذ به. + +## في المحرّر + +افتح بطاقة لتحريرها. العرض الموسّع هو البطاقة نفسها في حالة مفصّلة — وليس شاشة مختلفة — منظّمة في قسمين موسومين بوضوح. + + + ![محرّر الخطوة الموسّع](/images/enterprise/merged-step-card-editor.png) + + +### المهمة — ماذا تفعل + +مفتوحة افتراضيًا، لأنها ما تحرّره عادةً: + +- **الاسم** +- **الوصف** +- **المخرجات المتوقعة** +- **تنسيق الاستجابة** — يظهر هنا لأنه يتحكم تحديدًا في ما تقرأه الخطوات اللاحقة (مثل التوجيه) من هذه الخطوة. + +### الوكيل — من ينفّذها + +يُعرض الوكيل المُعيَّن كملخّص — **الاسم والنموذج والأدوات في سطر واحد**. ويُحفَظ إعداده الأعمق خلف قسمين قابلين للطي: + +- **الدور والهدف والخلفية** +- **إعدادات الوكيل** — الاستدلال، الحد الأقصى لمحاولات الاستدلال، السماح بالتفويض، الحد الأقصى للتكرارات، وإعدادات LLM. + + + الإعداد الكامل للوكيل — الدور، الهدف، الخلفية، النموذج، الأدوات، إعدادات LLM، وكامل كتلة إعدادات الوكيل — موجود خلف القسمين القابلين للطي **الدور والهدف والخلفية** و**إعدادات الوكيل**، منظّمًا حسب عدد مرّات تحريرك له. + + +## التبديل مقابل تحرير الوكيل + +هناك طريقتان متمايزتان للتعامل مع الوكيل في البطاقة، وكل منهما تؤدي وظيفة مختلفة: + +- **التبديل (Swap)** يعيد تعيين *أي* وكيل ينفّذ هذه المهمة. استخدم عنصر التحكم **تبديل** لاختيار وكيل مختلف من هذا المشروع، أو اختيار واحد من مستودع الوكلاء، أو إنشاء وكيل جديد. هذا مقصور على نطاق المهمة. +- **تحرير** الوكيل — بفتح **الدور والهدف والخلفية** أو **إعدادات الوكيل** — يغيّر الوكيل *نفسه*. + + + ![لوحة تبديل الوكيل](/images/enterprise/merged-step-card-swap-agent.png) + + + + **الوكلاء قابلون لإعادة الاستخدام ومشتركون.** يمكن للوكيل نفسه تنفيذ أكثر من مهمة عبر مشروعك. تحرير دور الوكيل أو خلفيته أو إعداداته يحدّث ذلك الوكيل **في كل مكان يُستخدم فيه** — وليس فقط في البطاقة التي فتحتها. إذا أردت تطبيق تغيير على خطوة واحدة فقط، فقم **بالتبديل** إلى وكيل مختلف بدلاً من تحرير الوكيل المشترك. + + +## ذات صلة + + + + أنشئ الأتمتة بمساعدة الذكاء الاصطناعي ومحرّر مرئي. + + + إدارة الوكلاء وإعادة استخدامهم عبر أتمتتك. + + 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. + + + ![Merged step cards on the canvas](/images/enterprise/merged-step-card-canvas.png) + + +## 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. + + + ![Expanded step editor](/images/enterprise/merged-step-card-editor.png) + + +### 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*. + + + ![Swap agent panel](/images/enterprise/merged-step-card-swap-agent.png) + + + + **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)** — 누가 수행하는지(할당된 에이전트, 모델, 도구). + +에이전트는 워크플로의 독립적인 참여자가 아니라 작업의 속성, 즉 *이 작업을 어떤 에이전트가 수행하는지*를 나타냅니다. 작업과 에이전트를 하나의 카드에 두면 이 관계가 명확해지고, 자동화가 왼쪽에서 오른쪽으로 이어지는 단일 작업 단위 체인이 되어 한눈에 읽기 쉬워집니다. + + + ![캔버스의 통합 단계 카드](/images/enterprise/merged-step-card-canvas.png) + + +## 캔버스에서 + +접힌 각 카드는 다음을 표시합니다: + +- 상단의 **작업 이름과 설명**. +- **할당된 에이전트를 요약한 푸터** — 아바타, 이름, 모델, 도구. + +별도의 에이전트 노드나 에이전트 → 작업 세로 연결선이 없습니다. 각 단계는 실행 순서대로 서로 직접 연결됩니다. + +## 에디터에서 + +카드를 열어 편집합니다. 확장된 보기는 다른 화면이 아니라 동일한 카드의 상세 상태이며, 명확하게 구분된 두 개의 섹션으로 구성됩니다. + + + ![확장된 단계 에디터](/images/enterprise/merged-step-card-editor.png) + + +### 작업 — 무엇을 할지 + +가장 자주 편집하는 항목이므로 기본적으로 열려 있습니다: + +- **이름** +- **설명** +- **예상 출력** +- **응답 형식** — 다운스트림 단계(예: 라우팅)가 이 단계에서 무엇을 읽을지 정확히 제어하므로 여기에 표시됩니다. + +### 에이전트 — 누가 수행하는지 + +할당된 에이전트는 요약으로 표시됩니다 — **이름, 모델, 도구가 인라인으로** 표시됩니다. 더 깊은 구성은 두 개의 접이식 섹션 뒤에 보존됩니다: + +- **역할, 목표 및 배경 스토리** +- **에이전트 설정** — 추론, 최대 추론 시도 횟수, 위임 허용, 최대 반복 횟수, LLM 설정. + + + 에이전트의 전체 구성 — 역할, 목표, 배경 스토리, 모델, 도구, LLM 설정 및 전체 에이전트 설정 블록 — 은 **역할, 목표 및 배경 스토리**와 **에이전트 설정** 접이식 섹션 뒤에 편집 빈도에 따라 정리되어 있습니다. + + +## 에이전트 교체 vs. 편집 + +카드에서 에이전트를 다루는 방식은 두 가지로 구분되며, 각각 다른 작업을 수행합니다: + +- **교체(Swap)** 는 *어떤* 에이전트가 이 작업을 수행할지 재할당합니다. **교체** 컨트롤을 사용하여 이 프로젝트의 다른 에이전트를 선택하거나, 에이전트 저장소에서 선택하거나, 새 에이전트를 만들 수 있습니다. 이는 작업 범위로 한정됩니다. +- 에이전트 **편집** — **역할, 목표 및 배경 스토리** 또는 **에이전트 설정** 을 여는 것 — 은 에이전트 *자체*를 변경합니다. + + + ![에이전트 교체 패널](/images/enterprise/merged-step-card-swap-agent.png) + + + + **에이전트는 재사용 가능하며 공유됩니다.** 동일한 에이전트가 프로젝트 전반에서 둘 이상의 작업을 수행할 수 있습니다. 에이전트의 역할, 배경 스토리 또는 설정을 편집하면 열어 본 카드뿐만 아니라 **해당 에이전트가 사용되는 모든 곳**에서 업데이트됩니다. 변경 사항을 하나의 단계에만 적용하려면 공유 에이전트를 편집하지 말고 다른 에이전트로 **교체**하세요. + + +## 관련 항목 + + + + 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. + + + ![Cards de etapa unificados no canvas](/images/enterprise/merged-step-card-canvas.png) + + +## 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. + + + ![Editor de etapa expandido](/images/enterprise/merged-step-card-editor.png) + + +### 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*. + + + ![Painel de troca de agente](/images/enterprise/merged-step-card-swap-agent.png) + + + + **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، تُمثَّل كل خطوة عمل بـ **بطاقة واحدة**. تجمع البطاقة بين عنصرين كانا في السابق في عُقد منفصلة: + +- **المهمة** — ماذا تفعل (الاسم، الوصف، المخرجات المتوقعة، وتنسيق الاستجابة). +- **الوكيل** — من ينفّذها (الوكيل المُعيَّن ونموذجه وأدواته). + +الوكيل ليس مشاركًا مستقلاً في سير العمل لديك — بل هو سمة من سمات المهمة: *أي وكيل ينفّذ هذا العمل.* وضع المهمة والوكيل في بطاقة واحدة يجعل هذه العلاقة واضحة، ويحوّل أتمتتك إلى سلسلة واحدة من وحدات العمل من اليسار إلى اليمين يسهل قراءتها بنظرة واحدة. + + + ![بطاقات الخطوات الموحّدة على اللوحة](/images/enterprise/merged-step-card-canvas.png) + + +## على اللوحة + +تعرض كل بطاقة مطوية ما يلي: + +- **اسم المهمة ووصفها** في الأعلى. +- **تذييل يلخّص الوكيل المُعيَّن** — الصورة الرمزية والاسم والنموذج والأدوات. + +لا توجد عقدة وكيل منفصلة ولا حافة عمودية من الوكيل ← المهمة. تتصل خطواتك مباشرةً ببعضها البعض بالترتيب الذي تُنفَّذ به. + +## في المحرّر + +افتح بطاقة لتحريرها. العرض الموسّع هو البطاقة نفسها في حالة مفصّلة — وليس شاشة مختلفة — منظّمة في قسمين موسومين بوضوح. + + + ![محرّر الخطوة الموسّع](/images/enterprise/merged-step-card-editor.png) + + +### المهمة — ماذا تفعل + +مفتوحة افتراضيًا، لأنها ما تحرّره عادةً: + +- **الاسم** +- **الوصف** +- **المخرجات المتوقعة** +- **تنسيق الاستجابة** — يظهر هنا لأنه يتحكم تحديدًا في ما تقرأه الخطوات اللاحقة (مثل التوجيه) من هذه الخطوة. + +### الوكيل — من ينفّذها + +يُعرض الوكيل المُعيَّن كملخّص — **الاسم والنموذج والأدوات في سطر واحد**. ويُحفَظ إعداده الأعمق خلف قسمين قابلين للطي: + +- **الدور والهدف والخلفية** +- **إعدادات الوكيل** — الاستدلال، الحد الأقصى لمحاولات الاستدلال، السماح بالتفويض، الحد الأقصى للتكرارات، وإعدادات LLM. + + + الإعداد الكامل للوكيل — الدور، الهدف، الخلفية، النموذج، الأدوات، إعدادات LLM، وكامل كتلة إعدادات الوكيل — موجود خلف القسمين القابلين للطي **الدور والهدف والخلفية** و**إعدادات الوكيل**، منظّمًا حسب عدد مرّات تحريرك له. + + +## التبديل مقابل تحرير الوكيل + +هناك طريقتان متمايزتان للتعامل مع الوكيل في البطاقة، وكل منهما تؤدي وظيفة مختلفة: + +- **التبديل (Swap)** يعيد تعيين *أي* وكيل ينفّذ هذه المهمة. استخدم عنصر التحكم **تبديل** لاختيار وكيل مختلف من هذا المشروع، أو اختيار واحد من مستودع الوكلاء، أو إنشاء وكيل جديد. هذا مقصور على نطاق المهمة. +- **تحرير** الوكيل — بفتح **الدور والهدف والخلفية** أو **إعدادات الوكيل** — يغيّر الوكيل *نفسه*. + + + ![لوحة تبديل الوكيل](/images/enterprise/merged-step-card-swap-agent.png) + + + + **الوكلاء قابلون لإعادة الاستخدام ومشتركون.** يمكن للوكيل نفسه تنفيذ أكثر من مهمة عبر مشروعك. تحرير دور الوكيل أو خلفيته أو إعداداته يحدّث ذلك الوكيل **في كل مكان يُستخدم فيه** — وليس فقط في البطاقة التي فتحتها. إذا أردت تطبيق تغيير على خطوة واحدة فقط، فقم **بالتبديل** إلى وكيل مختلف بدلاً من تحرير الوكيل المشترك. + + +## ذات صلة + + + + أنشئ الأتمتة بمساعدة الذكاء الاصطناعي ومحرّر مرئي. + + + إدارة الوكلاء وإعادة استخدامهم عبر أتمتتك. + + 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. + + + ![Merged step cards on the canvas](/images/enterprise/merged-step-card-canvas.png) + + +## 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. + + + ![Expanded step editor](/images/enterprise/merged-step-card-editor.png) + + +### 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*. + + + ![Swap agent panel](/images/enterprise/merged-step-card-swap-agent.png) + + + + **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)** — 누가 수행하는지(할당된 에이전트, 모델, 도구). + +에이전트는 워크플로의 독립적인 참여자가 아니라 작업의 속성, 즉 *이 작업을 어떤 에이전트가 수행하는지*를 나타냅니다. 작업과 에이전트를 하나의 카드에 두면 이 관계가 명확해지고, 자동화가 왼쪽에서 오른쪽으로 이어지는 단일 작업 단위 체인이 되어 한눈에 읽기 쉬워집니다. + + + ![캔버스의 통합 단계 카드](/images/enterprise/merged-step-card-canvas.png) + + +## 캔버스에서 + +접힌 각 카드는 다음을 표시합니다: + +- 상단의 **작업 이름과 설명**. +- **할당된 에이전트를 요약한 푸터** — 아바타, 이름, 모델, 도구. + +별도의 에이전트 노드나 에이전트 → 작업 세로 연결선이 없습니다. 각 단계는 실행 순서대로 서로 직접 연결됩니다. + +## 에디터에서 + +카드를 열어 편집합니다. 확장된 보기는 다른 화면이 아니라 동일한 카드의 상세 상태이며, 명확하게 구분된 두 개의 섹션으로 구성됩니다. + + + ![확장된 단계 에디터](/images/enterprise/merged-step-card-editor.png) + + +### 작업 — 무엇을 할지 + +가장 자주 편집하는 항목이므로 기본적으로 열려 있습니다: + +- **이름** +- **설명** +- **예상 출력** +- **응답 형식** — 다운스트림 단계(예: 라우팅)가 이 단계에서 무엇을 읽을지 정확히 제어하므로 여기에 표시됩니다. + +### 에이전트 — 누가 수행하는지 + +할당된 에이전트는 요약으로 표시됩니다 — **이름, 모델, 도구가 인라인으로** 표시됩니다. 더 깊은 구성은 두 개의 접이식 섹션 뒤에 보존됩니다: + +- **역할, 목표 및 배경 스토리** +- **에이전트 설정** — 추론, 최대 추론 시도 횟수, 위임 허용, 최대 반복 횟수, LLM 설정. + + + 에이전트의 전체 구성 — 역할, 목표, 배경 스토리, 모델, 도구, LLM 설정 및 전체 에이전트 설정 블록 — 은 **역할, 목표 및 배경 스토리**와 **에이전트 설정** 접이식 섹션 뒤에 편집 빈도에 따라 정리되어 있습니다. + + +## 에이전트 교체 vs. 편집 + +카드에서 에이전트를 다루는 방식은 두 가지로 구분되며, 각각 다른 작업을 수행합니다: + +- **교체(Swap)** 는 *어떤* 에이전트가 이 작업을 수행할지 재할당합니다. **교체** 컨트롤을 사용하여 이 프로젝트의 다른 에이전트를 선택하거나, 에이전트 저장소에서 선택하거나, 새 에이전트를 만들 수 있습니다. 이는 작업 범위로 한정됩니다. +- 에이전트 **편집** — **역할, 목표 및 배경 스토리** 또는 **에이전트 설정** 을 여는 것 — 은 에이전트 *자체*를 변경합니다. + + + ![에이전트 교체 패널](/images/enterprise/merged-step-card-swap-agent.png) + + + + **에이전트는 재사용 가능하며 공유됩니다.** 동일한 에이전트가 프로젝트 전반에서 둘 이상의 작업을 수행할 수 있습니다. 에이전트의 역할, 배경 스토리 또는 설정을 편집하면 열어 본 카드뿐만 아니라 **해당 에이전트가 사용되는 모든 곳**에서 업데이트됩니다. 변경 사항을 하나의 단계에만 적용하려면 공유 에이전트를 편집하지 말고 다른 에이전트로 **교체**하세요. + + +## 관련 항목 + + + + 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. + + + ![Cards de etapa unificados no canvas](/images/enterprise/merged-step-card-canvas.png) + + +## 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. + + + ![Editor de etapa expandido](/images/enterprise/merged-step-card-editor.png) + + +### 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*. + + + ![Painel de troca de agente](/images/enterprise/merged-step-card-swap-agent.png) + + + + **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]]