mirror of
https://github.com/crewAIInc/crewAI.git
synced 2026-06-02 23:08:10 +00:00
Compare commits
12 Commits
1.14.6
...
flow-struc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
60d5d48cd6 | ||
|
|
c81b4fe11e | ||
|
|
a9cb7867bb | ||
|
|
383ae66b55 | ||
|
|
774fd871a8 | ||
|
|
4a0769d97c | ||
|
|
fee5b3e395 | ||
|
|
3010f1286f | ||
|
|
e53a676c04 | ||
|
|
1aba9fe415 | ||
|
|
4dafb05735 | ||
|
|
5cdc420c50 |
6
.github/workflows/vulnerability-scan.yml
vendored
6
.github/workflows/vulnerability-scan.yml
vendored
@@ -71,7 +71,8 @@ jobs:
|
||||
--ignore-vuln PYSEC-2025-215 \
|
||||
--ignore-vuln PYSEC-2025-216 \
|
||||
--ignore-vuln PYSEC-2025-217 \
|
||||
--ignore-vuln PYSEC-2025-218
|
||||
--ignore-vuln PYSEC-2025-218 \
|
||||
--ignore-vuln GHSA-f4j7-r4q5-qw2c
|
||||
# Ignored CVEs:
|
||||
# PYSEC-2024-277 - joblib 1.5.3: disputed; NumpyArrayWrapper only used with trusted caches
|
||||
# PYSEC-2026-89 - markdown 3.10.2: DoS via malformed HTML; fix 3.8.1 — already past, advisory range is stale
|
||||
@@ -81,6 +82,9 @@ jobs:
|
||||
# PYSEC-2025-189..197 - torch 2.11.0: memory-corruption/DoS in functions only reachable via untrusted models; no fix available
|
||||
# PYSEC-2025-210, PYSEC-2026-139 - torch 2.11.0: profiler/deserialization issues; no fix available
|
||||
# PYSEC-2025-211..218 - transformers 5.5.4: deserialization/code injection via malicious model checkpoints; no fix available
|
||||
# GHSA-f4j7-r4q5-qw2c - chromadb 1.1.1 (CVE-2026-45829): pre-auth RCE via /api/v2/tenants/{tenant}/databases/{db}/collections when trust_remote_code=true.
|
||||
# Advisory: vulnerable >=1.0.0,<=1.5.9, firstPatchedVersion=none. We only use chromadb.PersistentClient (lib/crewai/src/crewai/rag/chromadb/factory.py)
|
||||
# and chromadb.utils.embedding_functions; the chromadb HTTP server is never started, so the vulnerable route is not exposed.
|
||||
continue-on-error: true
|
||||
|
||||
- name: Display results
|
||||
|
||||
@@ -28,7 +28,34 @@ repos:
|
||||
hooks:
|
||||
- id: pip-audit
|
||||
name: pip-audit
|
||||
entry: bash -c 'source .venv/bin/activate && uv run pip-audit --skip-editable --ignore-vuln CVE-2026-3219' --
|
||||
# Keep this ignore list in sync with .github/workflows/vulnerability-scan.yml.
|
||||
entry: >-
|
||||
bash -c 'source .venv/bin/activate && uv run pip-audit --skip-editable
|
||||
--ignore-vuln PYSEC-2024-277
|
||||
--ignore-vuln PYSEC-2026-89
|
||||
--ignore-vuln PYSEC-2026-97
|
||||
--ignore-vuln PYSEC-2025-148
|
||||
--ignore-vuln PYSEC-2025-183
|
||||
--ignore-vuln PYSEC-2025-189
|
||||
--ignore-vuln PYSEC-2025-190
|
||||
--ignore-vuln PYSEC-2025-191
|
||||
--ignore-vuln PYSEC-2025-192
|
||||
--ignore-vuln PYSEC-2025-193
|
||||
--ignore-vuln PYSEC-2025-194
|
||||
--ignore-vuln PYSEC-2025-195
|
||||
--ignore-vuln PYSEC-2025-196
|
||||
--ignore-vuln PYSEC-2025-197
|
||||
--ignore-vuln PYSEC-2025-210
|
||||
--ignore-vuln PYSEC-2026-139
|
||||
--ignore-vuln PYSEC-2025-211
|
||||
--ignore-vuln PYSEC-2025-212
|
||||
--ignore-vuln PYSEC-2025-213
|
||||
--ignore-vuln PYSEC-2025-214
|
||||
--ignore-vuln PYSEC-2025-215
|
||||
--ignore-vuln PYSEC-2025-216
|
||||
--ignore-vuln PYSEC-2025-217
|
||||
--ignore-vuln PYSEC-2025-218
|
||||
--ignore-vuln GHSA-f4j7-r4q5-qw2c' --
|
||||
language: system
|
||||
pass_filenames: false
|
||||
stages: [pre-push, manual]
|
||||
|
||||
@@ -107,7 +107,7 @@ mode: "wide"
|
||||
</Tabs>
|
||||
|
||||
<Info>
|
||||
يوفر CrewAI تكاملات SDK أصلية لـ OpenAI و Anthropic و Google (Gemini API) و Azure و AWS Bedrock -- لا حاجة لتثبيت إضافي بخلاف الملحقات الخاصة بالمزود (مثل `uv add "crewai[openai]"`).
|
||||
يوفر CrewAI تكاملات SDK أصلية لـ OpenAI و Anthropic و Google (Gemini API) و Azure و AWS Bedrock و Snowflake Cortex -- لا حاجة لتثبيت إضافي بخلاف الملحقات الخاصة بالمزود (مثل `uv add "crewai[openai]"`).
|
||||
|
||||
جميع المزودين الآخرين مدعومون بواسطة **LiteLLM**. إذا كنت تخطط لاستخدام أي منهم، أضفه كتبعية لمشروعك:
|
||||
```bash
|
||||
@@ -291,6 +291,55 @@ mode: "wide"
|
||||
```
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Snowflake Cortex">
|
||||
يوفر CrewAI تكاملًا أصليًا مع Snowflake Cortex REST API عبر endpoint Chat Completions المتوافق مع OpenAI. تستخدم نماذج `snowflake/...` هذا المسار بدون fallback إلى LiteLLM. يدعم Snowflake Cortex في CrewAI حاليًا Chat Completions فقط، لذلك استخدم وضع `api` الافتراضي ولا تضبط `api="responses"`.
|
||||
|
||||
```toml Code
|
||||
# Required
|
||||
SNOWFLAKE_PAT=<your-programmatic-access-token>
|
||||
SNOWFLAKE_ACCOUNT_URL=https://<account-identifier>.snowflakecomputing.com
|
||||
|
||||
# Alternative account configuration
|
||||
SNOWFLAKE_ACCOUNT=<account-identifier>
|
||||
```
|
||||
|
||||
**الاستخدام الأساسي:**
|
||||
```python Code
|
||||
from crewai import LLM
|
||||
|
||||
llm = LLM(
|
||||
model="snowflake/openai-gpt-4.1",
|
||||
temperature=0.7,
|
||||
max_completion_tokens=1024,
|
||||
)
|
||||
```
|
||||
|
||||
**نماذج Claude على Cortex:**
|
||||
```python Code
|
||||
from crewai import LLM
|
||||
|
||||
llm = LLM(
|
||||
model="snowflake/claude-sonnet-4-5",
|
||||
max_completion_tokens=1024,
|
||||
stream=True,
|
||||
)
|
||||
```
|
||||
|
||||
**متغيرات البيئة المدعومة:**
|
||||
- `SNOWFLAKE_PAT` أو `SNOWFLAKE_TOKEN` أو `SNOWFLAKE_JWT`: الرمز المستخدم كاعتماد Bearer
|
||||
- `SNOWFLAKE_ACCOUNT_URL`: عنوان URL الكامل لحساب Snowflake
|
||||
- `SNOWFLAKE_ACCOUNT` أو `SNOWFLAKE_ACCOUNT_ID` أو `SNOWFLAKE_ACCOUNT_IDENTIFIER`: معرف الحساب المستخدم لبناء URL
|
||||
|
||||
تستخدم طلبات Snowflake REST الدور الافتراضي للمستخدم. تأكد من أن هذا الدور لديه `SNOWFLAKE.CORTEX_USER` أو `SNOWFLAKE.CORTEX_REST_API_USER`. لا يتطلب endpoint Cortex REST Chat Completions معاملات database أو schema أو warehouse أو role صريح.
|
||||
|
||||
**الميزات:**
|
||||
- اختيار provider أصلي باستخدام `model="snowflake/<model-name>"`
|
||||
- Chat Completions مع streaming وبدونه فقط؛ `api="responses"` غير مدعوم
|
||||
- تتبع استخدام الرموز
|
||||
- استدعاء الدوال لنماذج OpenAI و Claude المستضافة في Snowflake
|
||||
- إزالة assistant prefill النهائي غير الصالح تلقائيًا لنماذج Claude في Snowflake
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Anthropic">
|
||||
يوفر CrewAI تكاملًا أصليًا مع Anthropic من خلال Anthropic Python SDK.
|
||||
|
||||
|
||||
123
docs/ar/enterprise/integrations/databricks.mdx
Normal file
123
docs/ar/enterprise/integrations/databricks.mdx
Normal file
@@ -0,0 +1,123 @@
|
||||
---
|
||||
title: تكامل Databricks
|
||||
description: "اربط وكلاء CrewAI بـ Databricks Genie وSQL وUnity Catalog Functions وVector Search عبر خوادم MCP المُدارة من Databricks."
|
||||
icon: "layer-group"
|
||||
mode: "wide"
|
||||
---
|
||||
|
||||
## نظرة عامة
|
||||
|
||||
اربط وكلاء CrewAI مباشرةً بمساحة عمل Databricks الخاصة بك عبر [خوادم MCP المُدارة من Databricks](https://docs.databricks.com/aws/en/generative-ai/mcp/managed-mcp). يتيح تكامل Databricks لوكلائك طرح أسئلة بلغة طبيعية باستخدام **Genie**، وتنفيذ **SQL** خاضع للحوكمة، واستدعاء **Unity Catalog Functions**، واسترجاع المستندات باستخدام **Vector Search** — كل ذلك دون كتابة أو استضافة أي كود موصِّل، مع تطبيق أذونات Unity Catalog في كل استدعاء.
|
||||
|
||||
في الخلفية، يُعدّ تكامل Databricks غلافًا مُدارًا حول دعم [خوادم MCP المخصصة](/ar/enterprise/guides/custom-mcp-server) في CrewAI. تكشف Databricks عن كل قدرة كنقطة نهاية [Model Context Protocol](https://modelcontextprotocol.io/) خاصة بها، ويتصل بها CrewAI بأمان نيابةً عنك. ولأن كل خادم يُضاف بشكل منفصل، يمكنك تفعيل القدرات التي تحتاجها فرقك (crews) بالضبط.
|
||||
|
||||
## القدرات الرئيسية
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="Genie" icon="comments">
|
||||
اطرح أسئلة بلغة طبيعية واحصل على إجابات مستندة إلى بياناتك باستخدام [Genie](https://docs.databricks.com/aws/en/genie/)، الذي يستعلم من Genie Spaces وUnity Catalog ويوفّر روابط تعود إلى واجهة Databricks.
|
||||
</Card>
|
||||
<Card title="Databricks SQL" icon="database">
|
||||
نفّذ SQL خاضعًا للحوكمة على مستودعات Databricks لديك للاستعلام عن البيانات وتحويلها وإنشاء خطوط أنابيب البيانات مباشرةً من وكلائك.
|
||||
</Card>
|
||||
<Card title="Unity Catalog Functions" icon="function">
|
||||
استدعِ [دوال Unity Catalog](https://docs.databricks.com/aws/en/udf/unity-catalog) لتنفيذ SQL مُعرّف مسبقًا ومنطق أعمال مخصّص كأدوات قابلة لإعادة الاستخدام وخاضعة للحوكمة.
|
||||
</Card>
|
||||
<Card title="Vector Search" icon="magnifying-glass">
|
||||
استرجع المستندات ذات الصلة لسير عمل RAG والمعرفة من فهارس [Mosaic AI Vector Search](https://docs.databricks.com/aws/en/generative-ai/vector-search) باستخدام التشابه الدلالي.
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
تعمل جميع الخوادم خلف Unity AI Gateway وتطبّق ضوابط الوصول في Unity Catalog، بحيث لا يرى وكلاؤك سوى البيانات والأدوات المصرَّح لهم باستخدامها.
|
||||
|
||||
## المتطلبات المسبقة
|
||||
|
||||
قبل استخدام تكامل Databricks، تأكّد من توفّر ما يلي:
|
||||
|
||||
- حساب [CrewAI AMP](https://app.crewai.com) باشتراك نشط
|
||||
- مساحة عمل Databricks تحتوي على القدرات التي تريد كشفها (Genie Spaces، مستودعات SQL، دوال Unity Catalog، أو فهارس Vector Search)
|
||||
- [امتيازات Unity Catalog](https://docs.databricks.com/aws/en/data-governance/unity-catalog) المناسبة على الكائنات الأساسية
|
||||
- اسم مضيف مساحة عمل Databricks الخاص بك (مثال: `your-workspace.cloud.databricks.com`)
|
||||
|
||||
## خوادم MCP المُدارة من Databricks
|
||||
|
||||
تنشر Databricks خادم MCP مُدارًا منفصلًا لكل قدرة. يكشف CrewAI عنها كاتصالات فردية، يُهيَّأ كل منها باستخدام مضيف مساحة العمل ومعرّفات Unity Catalog ذات الصلة. تتبع نقاط النهاية الأنماط التالية:
|
||||
|
||||
| الخادم | الوظيفة | نمط عنوان MCP |
|
||||
|--------|---------|---------------|
|
||||
| **Genie** | أسئلة وأجوبة بلغة طبيعية على Genie Space | `https://<workspace-hostname>/api/2.0/mcp/genie/{genie_space_id}` |
|
||||
| **Databricks SQL** | تنفيذ SQL على مستودعاتك | `https://<workspace-hostname>/api/2.0/mcp/sql` |
|
||||
| **Unity Catalog Functions** | تشغيل دوال UC المسجّلة | `https://<workspace-hostname>/api/2.0/mcp/functions/{catalog}/{schema}` |
|
||||
| **Vector Search** | الاستعلام من فهرس Vector Search | `https://<workspace-hostname>/api/2.0/mcp/vector-search/{catalog}/{schema}` |
|
||||
|
||||
<Note>
|
||||
لا حاجة لإنشاء عناوين URL هذه يدويًا — يُنشئ CrewAI كل نقطة نهاية من مضيف مساحة العمل والمعرّفات (Genie Space ID، أو catalog/schema) التي تقدّمها عند تهيئة الاتصال. للاطّلاع على المواصفات الكاملة وأحدث تفاصيل نقاط النهاية، راجع [وثائق MCP المُدارة من Databricks](https://docs.databricks.com/aws/en/generative-ai/mcp/managed-mcp).
|
||||
</Note>
|
||||
|
||||
## ربط Databricks في CrewAI AMP
|
||||
|
||||
<Frame>
|
||||
<img src="/images/enterprise/databricks-configure.png" alt="تهيئة خادم MCP مُدار من Databricks في CrewAI AMP" />
|
||||
</Frame>
|
||||
|
||||
تظهر كل قدرة من قدرات Databricks — **Databricks Genie** و**Databricks SQL** و**Databricks Unity Catalog Functions** و**Databricks Vector Search** — كخادم MCP خاص بها ضمن مجموعة Databricks في صفحة **Tools & Integrations**. هيّئ ما تحتاجه:
|
||||
|
||||
<Steps>
|
||||
<Step title="افتح Tools & Integrations">
|
||||
انتقل إلى **Tools & Integrations** في الشريط الجانبي الأيسر في CrewAI AMP وحدِّد مجموعة **Databricks** في قائمة Connections. سترى خوادم Genie وSQL وUnity Catalog Functions وVector Search مُدرجة أسفلها.
|
||||
</Step>
|
||||
|
||||
<Step title="هيّئ خادمًا">
|
||||
انقر على **Configure** بجوار القدرة التي تريد تفعيلها وقدّم تفاصيل الاتصال الخاصة بها:
|
||||
|
||||
- **Workspace Host** — اسم مضيف مساحة عمل Databricks الخاص بك (مثال: `my-workspace.cloud.databricks.com`).
|
||||
- **Genie** — **Genie Space ID** المراد الاستعلام عنه.
|
||||
- **Unity Catalog Functions** — الـ **catalog** والـ **schema** اللذان يحتويان على دوالك.
|
||||
- **Vector Search** — الـ **catalog** والـ **schema** اللذان يحتويان على الفهرس.
|
||||
- **Databricks SQL** — لا توجد معرّفات إضافية؛ تُنفَّذ الاستعلامات على مستودعات SQL في مساحة عملك.
|
||||
</Step>
|
||||
|
||||
<Step title="اختر طريقة المصادقة">
|
||||
اختر كيف يصادق CrewAI على Databricks. يُوصى باستخدام **OAuth**.
|
||||
|
||||
- **Use OAuth** — اتصل بأمان باستخدام OAuth 2.0. يصادق كل مستخدم على حدة، وتُصدر Databricks رموزًا (tokens) محدّدة النطاق للقدرة (`genie` أو `sql` أو `unity-catalog` أو `vector-search`). يتولّى CrewAI تدفّق التفويض ويُجدّد الرموز تلقائيًا.
|
||||
- **Use personal access token** — صادِق باستخدام [رمز وصول شخصي من Databricks](https://docs.databricks.com/aws/en/dev-tools/auth/pat). استخدم هوية بأقل الامتيازات للحدّ من التعرّض.
|
||||
</Step>
|
||||
|
||||
<Step title="صادِق">
|
||||
أكمل المصادقة. بمجرد الاتصال، تصبح أدوات الخادم متاحة لفرقك. كرّر العملية لأي قدرات Databricks أخرى تريد تفعيلها.
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
<Tip>
|
||||
لأن كل قدرة هي اتصال منفصل، يمكنك المزج والمطابقة — على سبيل المثال، فعّل Genie وVector Search لفريق بحث، مع حجز SQL وUnity Catalog Functions لفريق هندسة البيانات. تتيح لك إعدادات الرؤية (Visibility) التحكّم في أعضاء الفريق الذين يمكنهم استخدام كل منها.
|
||||
</Tip>
|
||||
|
||||
## استخدام أدوات Databricks في فرقك
|
||||
|
||||
بمجرد الاتصال، تظهر الأدوات التي يكشفها كل خادم MCP جنبًا إلى جنب مع الاتصالات المدمجة في صفحة **Tools & Integrations**. يمكنك:
|
||||
|
||||
- **إسناد الأدوات إلى الوكلاء** في فرقك تمامًا مثل أي أداة أخرى في CrewAI.
|
||||
- **إدارة الرؤية** للتحكّم في أعضاء الفريق الذين يمكنهم استخدام كل اتصال.
|
||||
- **تعديل أو إزالة** أي اتصال في أي وقت من قائمة Connections.
|
||||
|
||||
يمكن لوكلائك الآن طلب إجابات مستندة من Genie، وتنفيذ SQL على مستودعاتك، واستدعاء دوال Unity Catalog، والبحث في فهارس Vector Search — مع تدفّق النتائج تلقائيًا إلى استدلالهم.
|
||||
|
||||
<Warning>
|
||||
تطبّق Databricks الحوكمة عبر Unity Catalog وUnity AI Gateway: لا يمكن للمستخدم اكتشاف الأدوات واستدعاؤها إلا تلك المصرَّح بها لهوية مساحة عمله. إذا فشل استدعاء أداة، فتأكّد من أن المستخدم المتصل (أو هوية الرمز) يمتلك امتيازات Unity Catalog المطلوبة على Genie Space أو المستودع أو الدالة أو الفهرس. تُنفَّذ بعض استعلامات Genie وSQL بشكل غير متزامن وقد تستغرق لحظة لإرجاع النتائج.
|
||||
</Warning>
|
||||
|
||||
## مزيد من المعلومات
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="خوادم MCP المُدارة من Databricks" icon="layer-group" href="https://docs.databricks.com/aws/en/generative-ai/mcp/managed-mcp">
|
||||
وثائق Databricks الرسمية لخوادم MCP المُدارة Genie وSQL وUnity Catalog Functions وVector Search.
|
||||
</Card>
|
||||
<Card title="خوادم MCP المخصصة في CrewAI" icon="plug" href="/ar/enterprise/guides/custom-mcp-server">
|
||||
تعرّف على كيفية اتصال CrewAI بأي خادم MCP، وهو الأساس الذي يُبنى عليه تكامل Databricks.
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
<Card title="بحاجة إلى مساعدة؟" icon="headset" href="mailto:support@crewai.com">
|
||||
تواصل مع فريق الدعم للحصول على المساعدة في تهيئة تكامل Databricks أو في حل المشكلات.
|
||||
</Card>
|
||||
134
docs/ar/enterprise/integrations/snowflake.mdx
Normal file
134
docs/ar/enterprise/integrations/snowflake.mdx
Normal file
@@ -0,0 +1,134 @@
|
||||
---
|
||||
title: تكامل Snowflake
|
||||
description: "ربط وكلاء CrewAI بـ Snowflake Cortex Analyst و Cortex Search وتنفيذ SQL من خلال خادم MCP المُدار من Snowflake."
|
||||
icon: "snowflake"
|
||||
mode: "wide"
|
||||
---
|
||||
|
||||
## نظرة عامة
|
||||
|
||||
اربط وكلاء CrewAI مباشرة ببيانات Snowflake الخاصة بك من خلال [خادم MCP المُدار من Snowflake](https://docs.snowflake.com/en/user-guide/snowflake-cortex/cortex-agents-mcp). يتيح تكامل Snowflake لوكلائك الاستعلام عن البيانات المنظمة باستخدام **Cortex Analyst**، والبحث في البيانات غير المنظمة باستخدام **Cortex Search**، وتنفيذ SQL مُدار على مستودعات البيانات الخاصة بك — كل ذلك دون كتابة أو استضافة أي كود للموصّل.
|
||||
|
||||
داخلياً، تكامل Snowflake هو غلاف مُدار حول دعم [Custom MCP Server](/ar/enterprise/guides/custom-mcp-server) في CrewAI. يكشف Snowflake عن قدرات Cortex AI الخاصة به من خلال نقطة نهاية [Model Context Protocol](https://modelcontextprotocol.io/)، ويتصل CrewAI بها بشكل آمن نيابةً عنك. أي أداة تكشفها على جانب Snowflake — Cortex Analyst أو Cortex Search أو تنفيذ SQL أو Cortex Agents أو أدواتك المخصصة — تصبح متاحة لطواقمك.
|
||||
|
||||
## القدرات الرئيسية
|
||||
|
||||
<CardGroup cols={3}>
|
||||
<Card title="Cortex Analyst" icon="chart-bar">
|
||||
اطرح أسئلة بلغة طبيعية ودع [Cortex Analyst](https://docs.snowflake.com/en/user-guide/snowflake-cortex/cortex-analyst) يولّد وينفذ SQL على بياناتك **المنظمة** باستخدام نماذج دلالية غنية.
|
||||
</Card>
|
||||
<Card title="Cortex Search" icon="magnifying-glass">
|
||||
استرجع البيانات **غير المنظمة** ذات الصلة لسير عمل RAG والمعرفة باستخدام [Cortex Search](https://docs.snowflake.com/en/user-guide/snowflake-cortex/cortex-search/cortex-search-overview)، خدمة البحث المُدارة بالكامل من Snowflake.
|
||||
</Card>
|
||||
<Card title="تنفيذ SQL" icon="database">
|
||||
نفّذ استعلامات SQL مُدارة مباشرة على مستودعات Snowflake الخاصة بك، مع وضع القراءة فقط القابل للتكوين، والمهلات الزمنية، واختيار المستودع.
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
نظراً لأن التكامل يكشف عن أي أدوات ينشرها خادم MCP الخاص بك، يمكنك أيضاً كشف **Cortex Agents** و**الأدوات المخصصة** (الدوال المعرّفة من المستخدم والإجراءات المخزّنة) لوكلاء CrewAI.
|
||||
|
||||
## المتطلبات الأساسية
|
||||
|
||||
قبل استخدام تكامل Snowflake، تأكد من توفر ما يلي:
|
||||
|
||||
- حساب [CrewAI AMP](https://app.crewai.com) مع اشتراك فعّال
|
||||
- حساب Snowflake مع إمكانية الوصول إلى ميزات Cortex AI
|
||||
- [خادم MCP مُدار من Snowflake](https://docs.snowflake.com/en/user-guide/snowflake-cortex/cortex-agents-mcp) مُكوّن بالأدوات التي تريد كشفها
|
||||
- صلاحيات Snowflake المناسبة (USAGE/SELECT) على خادم MCP والكائنات الأساسية
|
||||
|
||||
## إعداد خادم Snowflake MCP
|
||||
|
||||
يعمل خادم MCP المُدار من Snowflake داخل حساب Snowflake الخاص بك ويحدد الأدوات المتاحة للعملاء الخارجيين مثل CrewAI. أنشئ واحداً باستخدام أمر [`CREATE MCP SERVER`](https://docs.snowflake.com/en/sql-reference/sql/create-mcp-server)، مع سرد خدمات Cortex Search وعروض Cortex Analyst الدلالية وأدوات SQL التي تريد كشفها.
|
||||
|
||||
```sql
|
||||
CREATE MCP SERVER my_mcp_server
|
||||
FROM SPECIFICATION $$
|
||||
tools:
|
||||
- name: "sales_analyst"
|
||||
type: "CORTEX_ANALYST"
|
||||
identifier: "MY_DATABASE.MY_SCHEMA.sales_semantic_view"
|
||||
description: "Answer questions about sales metrics"
|
||||
- name: "docs_search"
|
||||
type: "CORTEX_SEARCH_SERVICE_QUERY"
|
||||
identifier: "MY_DATABASE.MY_SCHEMA.support_docs_search"
|
||||
description: "Search internal support documentation"
|
||||
- name: "run_sql"
|
||||
type: "SQL_EXECUTION"
|
||||
description: "Execute read-only SQL queries"
|
||||
$$;
|
||||
```
|
||||
|
||||
<Note>
|
||||
تتبع نقطة نهاية MCP التنسيق `https://<account_URL>/api/v2/databases/{database}/schemas/{schema}/mcp-servers/{name}`. يبني CrewAI هذا العنوان تلقائياً من **عنوان URL للحساب** و**قاعدة البيانات** و**المخطط** و**اسم خادم MCP** الذي تقدمه عند تكوين التكامل.
|
||||
</Note>
|
||||
|
||||
للمواصفات الكاملة — بما في ذلك Cortex Agents والأدوات المخصصة وحدود حجم الاستجابة وخيارات الحوكمة — راجع [وثائق خادم MCP المُدار من Snowflake](https://docs.snowflake.com/en/user-guide/snowflake-cortex/cortex-agents-mcp).
|
||||
|
||||
## ربط Snowflake في CrewAI AMP
|
||||
|
||||
<Frame>
|
||||
<img src="/images/enterprise/snowflake-configure.png" alt="تكوين تكامل Snowflake في CrewAI AMP" />
|
||||
</Frame>
|
||||
|
||||
<Steps>
|
||||
<Step title="فتح الأدوات والتكاملات">
|
||||
انتقل إلى **الأدوات والتكاملات** في الشريط الجانبي الأيسر لـ CrewAI AMP، وابحث عن **Snowflake** في قائمة التطبيقات، وافتح لوحة التكوين الخاصة به.
|
||||
</Step>
|
||||
|
||||
<Step title="تقديم تفاصيل الاتصال">
|
||||
املأ حقول الاتصال التي يستخدمها CrewAI للوصول إلى خادم Snowflake MCP الخاص بك:
|
||||
|
||||
| الحقل | مطلوب | الوصف |
|
||||
|-------|-------|-------|
|
||||
| **الاسم** | نعم | اسم وصفي لهذا الاتصال (القيمة الافتراضية `Snowflake`). |
|
||||
| **الوصف** | لا | ملخص اختياري لما يوفره هذا الاتصال. |
|
||||
| **عنوان URL للحساب** | نعم | عنوان URL لحساب Snowflake الخاص بك، مثل `xy12345.us-east-1.snowflakecomputing.com`. |
|
||||
| **قاعدة البيانات** | نعم | قاعدة البيانات التي تحتوي على خادم MCP الخاص بك (مثل `MY_DATABASE`). |
|
||||
| **المخطط** | نعم | المخطط الذي يحتوي على خادم MCP الخاص بك (مثل `MY_SCHEMA`). |
|
||||
| **اسم خادم MCP** | نعم | اسم كائن خادم MCP الذي أنشأته في Snowflake (مثل `MY_MCP_SERVER`). |
|
||||
</Step>
|
||||
|
||||
<Step title="اختيار طريقة المصادقة">
|
||||
اختر كيفية مصادقة CrewAI مع Snowflake. يُوصى باستخدام **OAuth**.
|
||||
|
||||
- **استخدام OAuth** — اتصل بشكل آمن باستخدام OAuth 2.0 للمصادقة القائمة على الرموز دون مشاركة بيانات الاعتماد الخاصة بك. يتعامل CrewAI مع تدفق التفويض الكامل ويجدد الرموز تلقائياً. انسخ **عنوان URI لإعادة التوجيه** المعروض في النموذج (`https://oauth.crewai.com/oauth/add`) وسجّله كعنوان URI لإعادة التوجيه المعتمد في [تكامل أمان OAuth](https://docs.snowflake.com/en/user-guide/oauth-custom) في Snowflake.
|
||||
- **استخدام رمز وصول شخصي** — المصادقة باستخدام [رمز وصول برمجي](https://docs.snowflake.com/en/user-guide/programmatic-access-tokens) مُنشأ من إعدادات حساب Snowflake الخاص بك. قم بتعيين دور بأقل صلاحيات للرمز للحد من التعرض.
|
||||
</Step>
|
||||
|
||||
<Step title="المصادقة">
|
||||
انقر على **المصادقة**. بالنسبة لـ OAuth، ستتم إعادة توجيهك إلى Snowflake لتفويض الوصول. بمجرد المصادقة، يظهر خادم Snowflake في قائمة الاتصالات وتصبح أدواته متاحة لطواقمك.
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
<Tip>
|
||||
مع OAuth، يتم مصادقة كل مستخدم بشكل فردي وتُنفّذ الاستعلامات بدور `DEFAULT_ROLE` الخاص به في Snowflake. تأكد من أن المستخدمين المتصلين لديهم دور ومستودع افتراضي محدد (`ALTER USER <username> SET DEFAULT_ROLE = '<role>' DEFAULT_WAREHOUSE = '<warehouse>'`) حتى تتوفر موارد الحوسبة لأدوات Cortex Analyst و SQL.
|
||||
</Tip>
|
||||
|
||||
## استخدام أدوات Snowflake في طواقمك
|
||||
|
||||
بمجرد الاتصال، تظهر الأدوات التي يكشفها خادم MCP الخاص بك إلى جانب الاتصالات المدمجة في صفحة **الأدوات والتكاملات**. يمكنك:
|
||||
|
||||
- **تعيين الأدوات للوكلاء** في طواقمك تماماً مثل أي أداة CrewAI أخرى.
|
||||
- **إدارة الرؤية** للتحكم في أعضاء الفريق الذين يمكنهم استخدام الاتصال.
|
||||
- **تعديل أو إزالة** الاتصال في أي وقت من قائمة الاتصالات.
|
||||
|
||||
يمكن لوكلائك الآن سؤال Cortex Analyst عن المقاييس، وتشغيل Cortex Search على مستنداتك، وتنفيذ SQL — مع تدفق النتائج تلقائياً إلى استدلالهم.
|
||||
|
||||
<Warning>
|
||||
يفرض Snowflake الحوكمة على خادم MCP: يحدد التحكم في الوصول القائم على الأدوار الأدوات التي يمكن للمستخدم اكتشافها واستدعاؤها، وتنطبق حدود على حجم الاستجابة وعدد الأدوات (بحد أقصى 50 لكل خادم) وعمق التكرار. إذا فشل استدعاء أداة، تأكد من أن دور المستخدم المتصل لديه الصلاحيات المطلوبة على خادم MCP والكائنات الأساسية.
|
||||
</Warning>
|
||||
|
||||
## معرفة المزيد
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="خادم MCP المُدار من Snowflake" icon="snowflake" href="https://docs.snowflake.com/en/user-guide/snowflake-cortex/cortex-agents-mcp">
|
||||
الوثائق الرسمية من Snowflake لإنشاء وإدارة خادم MCP.
|
||||
</Card>
|
||||
<Card title="خوادم Custom MCP في CrewAI" icon="plug" href="/ar/enterprise/guides/custom-mcp-server">
|
||||
تعرّف على كيفية اتصال CrewAI بأي خادم MCP، الأساس الذي يبني عليه تكامل Snowflake.
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
<Card title="تحتاج مساعدة؟" icon="headset" href="mailto:support@crewai.com">
|
||||
تواصل مع فريق الدعم للحصول على المساعدة في تكامل Snowflake أو استكشاف الأخطاء وإصلاحها.
|
||||
</Card>
|
||||
120
docs/docs.json
120
docs/docs.json
@@ -460,6 +460,7 @@
|
||||
"en/enterprise/integrations/asana",
|
||||
"en/enterprise/integrations/box",
|
||||
"en/enterprise/integrations/clickup",
|
||||
"en/enterprise/integrations/databricks",
|
||||
"en/enterprise/integrations/github",
|
||||
"en/enterprise/integrations/gmail",
|
||||
"en/enterprise/integrations/google_calendar",
|
||||
@@ -481,6 +482,7 @@
|
||||
"en/enterprise/integrations/salesforce",
|
||||
"en/enterprise/integrations/shopify",
|
||||
"en/enterprise/integrations/slack",
|
||||
"en/enterprise/integrations/snowflake",
|
||||
"en/enterprise/integrations/stripe",
|
||||
"en/enterprise/integrations/zendesk"
|
||||
]
|
||||
@@ -977,6 +979,7 @@
|
||||
"en/enterprise/integrations/asana",
|
||||
"en/enterprise/integrations/box",
|
||||
"en/enterprise/integrations/clickup",
|
||||
"en/enterprise/integrations/databricks",
|
||||
"en/enterprise/integrations/github",
|
||||
"en/enterprise/integrations/gmail",
|
||||
"en/enterprise/integrations/google_calendar",
|
||||
@@ -998,6 +1001,7 @@
|
||||
"en/enterprise/integrations/salesforce",
|
||||
"en/enterprise/integrations/shopify",
|
||||
"en/enterprise/integrations/slack",
|
||||
"en/enterprise/integrations/snowflake",
|
||||
"en/enterprise/integrations/stripe",
|
||||
"en/enterprise/integrations/zendesk"
|
||||
]
|
||||
@@ -1461,6 +1465,7 @@
|
||||
"en/enterprise/integrations/asana",
|
||||
"en/enterprise/integrations/box",
|
||||
"en/enterprise/integrations/clickup",
|
||||
"en/enterprise/integrations/databricks",
|
||||
"en/enterprise/integrations/github",
|
||||
"en/enterprise/integrations/gmail",
|
||||
"en/enterprise/integrations/google_calendar",
|
||||
@@ -1482,6 +1487,7 @@
|
||||
"en/enterprise/integrations/salesforce",
|
||||
"en/enterprise/integrations/shopify",
|
||||
"en/enterprise/integrations/slack",
|
||||
"en/enterprise/integrations/snowflake",
|
||||
"en/enterprise/integrations/stripe",
|
||||
"en/enterprise/integrations/zendesk"
|
||||
]
|
||||
@@ -1944,6 +1950,7 @@
|
||||
"en/enterprise/integrations/asana",
|
||||
"en/enterprise/integrations/box",
|
||||
"en/enterprise/integrations/clickup",
|
||||
"en/enterprise/integrations/databricks",
|
||||
"en/enterprise/integrations/github",
|
||||
"en/enterprise/integrations/gmail",
|
||||
"en/enterprise/integrations/google_calendar",
|
||||
@@ -1965,6 +1972,7 @@
|
||||
"en/enterprise/integrations/salesforce",
|
||||
"en/enterprise/integrations/shopify",
|
||||
"en/enterprise/integrations/slack",
|
||||
"en/enterprise/integrations/snowflake",
|
||||
"en/enterprise/integrations/stripe",
|
||||
"en/enterprise/integrations/zendesk"
|
||||
]
|
||||
@@ -2427,6 +2435,7 @@
|
||||
"en/enterprise/integrations/asana",
|
||||
"en/enterprise/integrations/box",
|
||||
"en/enterprise/integrations/clickup",
|
||||
"en/enterprise/integrations/databricks",
|
||||
"en/enterprise/integrations/github",
|
||||
"en/enterprise/integrations/gmail",
|
||||
"en/enterprise/integrations/google_calendar",
|
||||
@@ -2448,6 +2457,7 @@
|
||||
"en/enterprise/integrations/salesforce",
|
||||
"en/enterprise/integrations/shopify",
|
||||
"en/enterprise/integrations/slack",
|
||||
"en/enterprise/integrations/snowflake",
|
||||
"en/enterprise/integrations/stripe",
|
||||
"en/enterprise/integrations/zendesk"
|
||||
]
|
||||
@@ -2920,6 +2930,7 @@
|
||||
"en/enterprise/integrations/asana",
|
||||
"en/enterprise/integrations/box",
|
||||
"en/enterprise/integrations/clickup",
|
||||
"en/enterprise/integrations/databricks",
|
||||
"en/enterprise/integrations/github",
|
||||
"en/enterprise/integrations/gmail",
|
||||
"en/enterprise/integrations/google_calendar",
|
||||
@@ -2941,6 +2952,7 @@
|
||||
"en/enterprise/integrations/salesforce",
|
||||
"en/enterprise/integrations/shopify",
|
||||
"en/enterprise/integrations/slack",
|
||||
"en/enterprise/integrations/snowflake",
|
||||
"en/enterprise/integrations/stripe",
|
||||
"en/enterprise/integrations/zendesk"
|
||||
]
|
||||
@@ -3413,6 +3425,7 @@
|
||||
"en/enterprise/integrations/asana",
|
||||
"en/enterprise/integrations/box",
|
||||
"en/enterprise/integrations/clickup",
|
||||
"en/enterprise/integrations/databricks",
|
||||
"en/enterprise/integrations/github",
|
||||
"en/enterprise/integrations/gmail",
|
||||
"en/enterprise/integrations/google_calendar",
|
||||
@@ -3434,6 +3447,7 @@
|
||||
"en/enterprise/integrations/salesforce",
|
||||
"en/enterprise/integrations/shopify",
|
||||
"en/enterprise/integrations/slack",
|
||||
"en/enterprise/integrations/snowflake",
|
||||
"en/enterprise/integrations/stripe",
|
||||
"en/enterprise/integrations/zendesk"
|
||||
]
|
||||
@@ -3906,6 +3920,7 @@
|
||||
"en/enterprise/integrations/asana",
|
||||
"en/enterprise/integrations/box",
|
||||
"en/enterprise/integrations/clickup",
|
||||
"en/enterprise/integrations/databricks",
|
||||
"en/enterprise/integrations/github",
|
||||
"en/enterprise/integrations/gmail",
|
||||
"en/enterprise/integrations/google_calendar",
|
||||
@@ -3927,6 +3942,7 @@
|
||||
"en/enterprise/integrations/salesforce",
|
||||
"en/enterprise/integrations/shopify",
|
||||
"en/enterprise/integrations/slack",
|
||||
"en/enterprise/integrations/snowflake",
|
||||
"en/enterprise/integrations/stripe",
|
||||
"en/enterprise/integrations/zendesk"
|
||||
]
|
||||
@@ -4399,6 +4415,7 @@
|
||||
"en/enterprise/integrations/asana",
|
||||
"en/enterprise/integrations/box",
|
||||
"en/enterprise/integrations/clickup",
|
||||
"en/enterprise/integrations/databricks",
|
||||
"en/enterprise/integrations/github",
|
||||
"en/enterprise/integrations/gmail",
|
||||
"en/enterprise/integrations/google_calendar",
|
||||
@@ -4420,6 +4437,7 @@
|
||||
"en/enterprise/integrations/salesforce",
|
||||
"en/enterprise/integrations/shopify",
|
||||
"en/enterprise/integrations/slack",
|
||||
"en/enterprise/integrations/snowflake",
|
||||
"en/enterprise/integrations/stripe",
|
||||
"en/enterprise/integrations/zendesk"
|
||||
]
|
||||
@@ -4881,6 +4899,7 @@
|
||||
"en/enterprise/integrations/asana",
|
||||
"en/enterprise/integrations/box",
|
||||
"en/enterprise/integrations/clickup",
|
||||
"en/enterprise/integrations/databricks",
|
||||
"en/enterprise/integrations/github",
|
||||
"en/enterprise/integrations/gmail",
|
||||
"en/enterprise/integrations/google_calendar",
|
||||
@@ -4902,6 +4921,7 @@
|
||||
"en/enterprise/integrations/salesforce",
|
||||
"en/enterprise/integrations/shopify",
|
||||
"en/enterprise/integrations/slack",
|
||||
"en/enterprise/integrations/snowflake",
|
||||
"en/enterprise/integrations/stripe",
|
||||
"en/enterprise/integrations/zendesk"
|
||||
]
|
||||
@@ -5363,6 +5383,7 @@
|
||||
"en/enterprise/integrations/asana",
|
||||
"en/enterprise/integrations/box",
|
||||
"en/enterprise/integrations/clickup",
|
||||
"en/enterprise/integrations/databricks",
|
||||
"en/enterprise/integrations/github",
|
||||
"en/enterprise/integrations/gmail",
|
||||
"en/enterprise/integrations/google_calendar",
|
||||
@@ -5384,6 +5405,7 @@
|
||||
"en/enterprise/integrations/salesforce",
|
||||
"en/enterprise/integrations/shopify",
|
||||
"en/enterprise/integrations/slack",
|
||||
"en/enterprise/integrations/snowflake",
|
||||
"en/enterprise/integrations/stripe",
|
||||
"en/enterprise/integrations/zendesk"
|
||||
]
|
||||
@@ -5845,6 +5867,7 @@
|
||||
"en/enterprise/integrations/asana",
|
||||
"en/enterprise/integrations/box",
|
||||
"en/enterprise/integrations/clickup",
|
||||
"en/enterprise/integrations/databricks",
|
||||
"en/enterprise/integrations/github",
|
||||
"en/enterprise/integrations/gmail",
|
||||
"en/enterprise/integrations/google_calendar",
|
||||
@@ -5866,6 +5889,7 @@
|
||||
"en/enterprise/integrations/salesforce",
|
||||
"en/enterprise/integrations/shopify",
|
||||
"en/enterprise/integrations/slack",
|
||||
"en/enterprise/integrations/snowflake",
|
||||
"en/enterprise/integrations/stripe",
|
||||
"en/enterprise/integrations/zendesk"
|
||||
]
|
||||
@@ -6329,6 +6353,7 @@
|
||||
"en/enterprise/integrations/asana",
|
||||
"en/enterprise/integrations/box",
|
||||
"en/enterprise/integrations/clickup",
|
||||
"en/enterprise/integrations/databricks",
|
||||
"en/enterprise/integrations/github",
|
||||
"en/enterprise/integrations/gmail",
|
||||
"en/enterprise/integrations/google_calendar",
|
||||
@@ -6350,6 +6375,7 @@
|
||||
"en/enterprise/integrations/salesforce",
|
||||
"en/enterprise/integrations/shopify",
|
||||
"en/enterprise/integrations/slack",
|
||||
"en/enterprise/integrations/snowflake",
|
||||
"en/enterprise/integrations/stripe",
|
||||
"en/enterprise/integrations/zendesk"
|
||||
]
|
||||
@@ -6811,6 +6837,7 @@
|
||||
"en/enterprise/integrations/asana",
|
||||
"en/enterprise/integrations/box",
|
||||
"en/enterprise/integrations/clickup",
|
||||
"en/enterprise/integrations/databricks",
|
||||
"en/enterprise/integrations/github",
|
||||
"en/enterprise/integrations/gmail",
|
||||
"en/enterprise/integrations/google_calendar",
|
||||
@@ -6832,6 +6859,7 @@
|
||||
"en/enterprise/integrations/salesforce",
|
||||
"en/enterprise/integrations/shopify",
|
||||
"en/enterprise/integrations/slack",
|
||||
"en/enterprise/integrations/snowflake",
|
||||
"en/enterprise/integrations/stripe",
|
||||
"en/enterprise/integrations/zendesk"
|
||||
]
|
||||
@@ -7296,6 +7324,7 @@
|
||||
"en/enterprise/integrations/asana",
|
||||
"en/enterprise/integrations/box",
|
||||
"en/enterprise/integrations/clickup",
|
||||
"en/enterprise/integrations/databricks",
|
||||
"en/enterprise/integrations/github",
|
||||
"en/enterprise/integrations/gmail",
|
||||
"en/enterprise/integrations/google_calendar",
|
||||
@@ -7317,6 +7346,7 @@
|
||||
"en/enterprise/integrations/salesforce",
|
||||
"en/enterprise/integrations/shopify",
|
||||
"en/enterprise/integrations/slack",
|
||||
"en/enterprise/integrations/snowflake",
|
||||
"en/enterprise/integrations/stripe",
|
||||
"en/enterprise/integrations/zendesk"
|
||||
]
|
||||
@@ -7820,6 +7850,7 @@
|
||||
"pt-BR/enterprise/integrations/asana",
|
||||
"pt-BR/enterprise/integrations/box",
|
||||
"pt-BR/enterprise/integrations/clickup",
|
||||
"pt-BR/enterprise/integrations/databricks",
|
||||
"pt-BR/enterprise/integrations/github",
|
||||
"pt-BR/enterprise/integrations/gmail",
|
||||
"pt-BR/enterprise/integrations/google_calendar",
|
||||
@@ -7841,6 +7872,7 @@
|
||||
"pt-BR/enterprise/integrations/salesforce",
|
||||
"pt-BR/enterprise/integrations/shopify",
|
||||
"pt-BR/enterprise/integrations/slack",
|
||||
"pt-BR/enterprise/integrations/snowflake",
|
||||
"pt-BR/enterprise/integrations/stripe",
|
||||
"pt-BR/enterprise/integrations/zendesk"
|
||||
]
|
||||
@@ -8314,6 +8346,7 @@
|
||||
"pt-BR/enterprise/integrations/asana",
|
||||
"pt-BR/enterprise/integrations/box",
|
||||
"pt-BR/enterprise/integrations/clickup",
|
||||
"pt-BR/enterprise/integrations/databricks",
|
||||
"pt-BR/enterprise/integrations/github",
|
||||
"pt-BR/enterprise/integrations/gmail",
|
||||
"pt-BR/enterprise/integrations/google_calendar",
|
||||
@@ -8335,6 +8368,7 @@
|
||||
"pt-BR/enterprise/integrations/salesforce",
|
||||
"pt-BR/enterprise/integrations/shopify",
|
||||
"pt-BR/enterprise/integrations/slack",
|
||||
"pt-BR/enterprise/integrations/snowflake",
|
||||
"pt-BR/enterprise/integrations/stripe",
|
||||
"pt-BR/enterprise/integrations/zendesk"
|
||||
]
|
||||
@@ -8775,6 +8809,7 @@
|
||||
"pt-BR/enterprise/integrations/asana",
|
||||
"pt-BR/enterprise/integrations/box",
|
||||
"pt-BR/enterprise/integrations/clickup",
|
||||
"pt-BR/enterprise/integrations/databricks",
|
||||
"pt-BR/enterprise/integrations/github",
|
||||
"pt-BR/enterprise/integrations/gmail",
|
||||
"pt-BR/enterprise/integrations/google_calendar",
|
||||
@@ -8796,6 +8831,7 @@
|
||||
"pt-BR/enterprise/integrations/salesforce",
|
||||
"pt-BR/enterprise/integrations/shopify",
|
||||
"pt-BR/enterprise/integrations/slack",
|
||||
"pt-BR/enterprise/integrations/snowflake",
|
||||
"pt-BR/enterprise/integrations/stripe",
|
||||
"pt-BR/enterprise/integrations/zendesk"
|
||||
]
|
||||
@@ -9236,6 +9272,7 @@
|
||||
"pt-BR/enterprise/integrations/asana",
|
||||
"pt-BR/enterprise/integrations/box",
|
||||
"pt-BR/enterprise/integrations/clickup",
|
||||
"pt-BR/enterprise/integrations/databricks",
|
||||
"pt-BR/enterprise/integrations/github",
|
||||
"pt-BR/enterprise/integrations/gmail",
|
||||
"pt-BR/enterprise/integrations/google_calendar",
|
||||
@@ -9257,6 +9294,7 @@
|
||||
"pt-BR/enterprise/integrations/salesforce",
|
||||
"pt-BR/enterprise/integrations/shopify",
|
||||
"pt-BR/enterprise/integrations/slack",
|
||||
"pt-BR/enterprise/integrations/snowflake",
|
||||
"pt-BR/enterprise/integrations/stripe",
|
||||
"pt-BR/enterprise/integrations/zendesk"
|
||||
]
|
||||
@@ -9696,6 +9734,7 @@
|
||||
"pt-BR/enterprise/integrations/asana",
|
||||
"pt-BR/enterprise/integrations/box",
|
||||
"pt-BR/enterprise/integrations/clickup",
|
||||
"pt-BR/enterprise/integrations/databricks",
|
||||
"pt-BR/enterprise/integrations/github",
|
||||
"pt-BR/enterprise/integrations/gmail",
|
||||
"pt-BR/enterprise/integrations/google_calendar",
|
||||
@@ -9717,6 +9756,7 @@
|
||||
"pt-BR/enterprise/integrations/salesforce",
|
||||
"pt-BR/enterprise/integrations/shopify",
|
||||
"pt-BR/enterprise/integrations/slack",
|
||||
"pt-BR/enterprise/integrations/snowflake",
|
||||
"pt-BR/enterprise/integrations/stripe",
|
||||
"pt-BR/enterprise/integrations/zendesk"
|
||||
]
|
||||
@@ -10166,6 +10206,7 @@
|
||||
"pt-BR/enterprise/integrations/asana",
|
||||
"pt-BR/enterprise/integrations/box",
|
||||
"pt-BR/enterprise/integrations/clickup",
|
||||
"pt-BR/enterprise/integrations/databricks",
|
||||
"pt-BR/enterprise/integrations/github",
|
||||
"pt-BR/enterprise/integrations/gmail",
|
||||
"pt-BR/enterprise/integrations/google_calendar",
|
||||
@@ -10187,6 +10228,7 @@
|
||||
"pt-BR/enterprise/integrations/salesforce",
|
||||
"pt-BR/enterprise/integrations/shopify",
|
||||
"pt-BR/enterprise/integrations/slack",
|
||||
"pt-BR/enterprise/integrations/snowflake",
|
||||
"pt-BR/enterprise/integrations/stripe",
|
||||
"pt-BR/enterprise/integrations/zendesk"
|
||||
]
|
||||
@@ -10636,6 +10678,7 @@
|
||||
"pt-BR/enterprise/integrations/asana",
|
||||
"pt-BR/enterprise/integrations/box",
|
||||
"pt-BR/enterprise/integrations/clickup",
|
||||
"pt-BR/enterprise/integrations/databricks",
|
||||
"pt-BR/enterprise/integrations/github",
|
||||
"pt-BR/enterprise/integrations/gmail",
|
||||
"pt-BR/enterprise/integrations/google_calendar",
|
||||
@@ -10657,6 +10700,7 @@
|
||||
"pt-BR/enterprise/integrations/salesforce",
|
||||
"pt-BR/enterprise/integrations/shopify",
|
||||
"pt-BR/enterprise/integrations/slack",
|
||||
"pt-BR/enterprise/integrations/snowflake",
|
||||
"pt-BR/enterprise/integrations/stripe",
|
||||
"pt-BR/enterprise/integrations/zendesk"
|
||||
]
|
||||
@@ -11106,6 +11150,7 @@
|
||||
"pt-BR/enterprise/integrations/asana",
|
||||
"pt-BR/enterprise/integrations/box",
|
||||
"pt-BR/enterprise/integrations/clickup",
|
||||
"pt-BR/enterprise/integrations/databricks",
|
||||
"pt-BR/enterprise/integrations/github",
|
||||
"pt-BR/enterprise/integrations/gmail",
|
||||
"pt-BR/enterprise/integrations/google_calendar",
|
||||
@@ -11127,6 +11172,7 @@
|
||||
"pt-BR/enterprise/integrations/salesforce",
|
||||
"pt-BR/enterprise/integrations/shopify",
|
||||
"pt-BR/enterprise/integrations/slack",
|
||||
"pt-BR/enterprise/integrations/snowflake",
|
||||
"pt-BR/enterprise/integrations/stripe",
|
||||
"pt-BR/enterprise/integrations/zendesk"
|
||||
]
|
||||
@@ -11576,6 +11622,7 @@
|
||||
"pt-BR/enterprise/integrations/asana",
|
||||
"pt-BR/enterprise/integrations/box",
|
||||
"pt-BR/enterprise/integrations/clickup",
|
||||
"pt-BR/enterprise/integrations/databricks",
|
||||
"pt-BR/enterprise/integrations/github",
|
||||
"pt-BR/enterprise/integrations/gmail",
|
||||
"pt-BR/enterprise/integrations/google_calendar",
|
||||
@@ -11597,6 +11644,7 @@
|
||||
"pt-BR/enterprise/integrations/salesforce",
|
||||
"pt-BR/enterprise/integrations/shopify",
|
||||
"pt-BR/enterprise/integrations/slack",
|
||||
"pt-BR/enterprise/integrations/snowflake",
|
||||
"pt-BR/enterprise/integrations/stripe",
|
||||
"pt-BR/enterprise/integrations/zendesk"
|
||||
]
|
||||
@@ -12036,6 +12084,7 @@
|
||||
"pt-BR/enterprise/integrations/asana",
|
||||
"pt-BR/enterprise/integrations/box",
|
||||
"pt-BR/enterprise/integrations/clickup",
|
||||
"pt-BR/enterprise/integrations/databricks",
|
||||
"pt-BR/enterprise/integrations/github",
|
||||
"pt-BR/enterprise/integrations/gmail",
|
||||
"pt-BR/enterprise/integrations/google_calendar",
|
||||
@@ -12057,6 +12106,7 @@
|
||||
"pt-BR/enterprise/integrations/salesforce",
|
||||
"pt-BR/enterprise/integrations/shopify",
|
||||
"pt-BR/enterprise/integrations/slack",
|
||||
"pt-BR/enterprise/integrations/snowflake",
|
||||
"pt-BR/enterprise/integrations/stripe",
|
||||
"pt-BR/enterprise/integrations/zendesk"
|
||||
]
|
||||
@@ -12496,6 +12546,7 @@
|
||||
"pt-BR/enterprise/integrations/asana",
|
||||
"pt-BR/enterprise/integrations/box",
|
||||
"pt-BR/enterprise/integrations/clickup",
|
||||
"pt-BR/enterprise/integrations/databricks",
|
||||
"pt-BR/enterprise/integrations/github",
|
||||
"pt-BR/enterprise/integrations/gmail",
|
||||
"pt-BR/enterprise/integrations/google_calendar",
|
||||
@@ -12517,6 +12568,7 @@
|
||||
"pt-BR/enterprise/integrations/salesforce",
|
||||
"pt-BR/enterprise/integrations/shopify",
|
||||
"pt-BR/enterprise/integrations/slack",
|
||||
"pt-BR/enterprise/integrations/snowflake",
|
||||
"pt-BR/enterprise/integrations/stripe",
|
||||
"pt-BR/enterprise/integrations/zendesk"
|
||||
]
|
||||
@@ -12956,6 +13008,7 @@
|
||||
"pt-BR/enterprise/integrations/asana",
|
||||
"pt-BR/enterprise/integrations/box",
|
||||
"pt-BR/enterprise/integrations/clickup",
|
||||
"pt-BR/enterprise/integrations/databricks",
|
||||
"pt-BR/enterprise/integrations/github",
|
||||
"pt-BR/enterprise/integrations/gmail",
|
||||
"pt-BR/enterprise/integrations/google_calendar",
|
||||
@@ -12977,6 +13030,7 @@
|
||||
"pt-BR/enterprise/integrations/salesforce",
|
||||
"pt-BR/enterprise/integrations/shopify",
|
||||
"pt-BR/enterprise/integrations/slack",
|
||||
"pt-BR/enterprise/integrations/snowflake",
|
||||
"pt-BR/enterprise/integrations/stripe",
|
||||
"pt-BR/enterprise/integrations/zendesk"
|
||||
]
|
||||
@@ -13415,6 +13469,7 @@
|
||||
"pt-BR/enterprise/integrations/asana",
|
||||
"pt-BR/enterprise/integrations/box",
|
||||
"pt-BR/enterprise/integrations/clickup",
|
||||
"pt-BR/enterprise/integrations/databricks",
|
||||
"pt-BR/enterprise/integrations/github",
|
||||
"pt-BR/enterprise/integrations/gmail",
|
||||
"pt-BR/enterprise/integrations/google_calendar",
|
||||
@@ -13436,6 +13491,7 @@
|
||||
"pt-BR/enterprise/integrations/salesforce",
|
||||
"pt-BR/enterprise/integrations/shopify",
|
||||
"pt-BR/enterprise/integrations/slack",
|
||||
"pt-BR/enterprise/integrations/snowflake",
|
||||
"pt-BR/enterprise/integrations/stripe",
|
||||
"pt-BR/enterprise/integrations/zendesk"
|
||||
]
|
||||
@@ -13874,6 +13930,7 @@
|
||||
"pt-BR/enterprise/integrations/asana",
|
||||
"pt-BR/enterprise/integrations/box",
|
||||
"pt-BR/enterprise/integrations/clickup",
|
||||
"pt-BR/enterprise/integrations/databricks",
|
||||
"pt-BR/enterprise/integrations/github",
|
||||
"pt-BR/enterprise/integrations/gmail",
|
||||
"pt-BR/enterprise/integrations/google_calendar",
|
||||
@@ -13895,6 +13952,7 @@
|
||||
"pt-BR/enterprise/integrations/salesforce",
|
||||
"pt-BR/enterprise/integrations/shopify",
|
||||
"pt-BR/enterprise/integrations/slack",
|
||||
"pt-BR/enterprise/integrations/snowflake",
|
||||
"pt-BR/enterprise/integrations/stripe",
|
||||
"pt-BR/enterprise/integrations/zendesk"
|
||||
]
|
||||
@@ -14334,6 +14392,7 @@
|
||||
"pt-BR/enterprise/integrations/asana",
|
||||
"pt-BR/enterprise/integrations/box",
|
||||
"pt-BR/enterprise/integrations/clickup",
|
||||
"pt-BR/enterprise/integrations/databricks",
|
||||
"pt-BR/enterprise/integrations/github",
|
||||
"pt-BR/enterprise/integrations/gmail",
|
||||
"pt-BR/enterprise/integrations/google_calendar",
|
||||
@@ -14355,6 +14414,7 @@
|
||||
"pt-BR/enterprise/integrations/salesforce",
|
||||
"pt-BR/enterprise/integrations/shopify",
|
||||
"pt-BR/enterprise/integrations/slack",
|
||||
"pt-BR/enterprise/integrations/snowflake",
|
||||
"pt-BR/enterprise/integrations/stripe",
|
||||
"pt-BR/enterprise/integrations/zendesk"
|
||||
]
|
||||
@@ -14870,6 +14930,7 @@
|
||||
"ko/enterprise/integrations/asana",
|
||||
"ko/enterprise/integrations/box",
|
||||
"ko/enterprise/integrations/clickup",
|
||||
"ko/enterprise/integrations/databricks",
|
||||
"ko/enterprise/integrations/github",
|
||||
"ko/enterprise/integrations/gmail",
|
||||
"ko/enterprise/integrations/google_calendar",
|
||||
@@ -14891,6 +14952,7 @@
|
||||
"ko/enterprise/integrations/salesforce",
|
||||
"ko/enterprise/integrations/shopify",
|
||||
"ko/enterprise/integrations/slack",
|
||||
"ko/enterprise/integrations/snowflake",
|
||||
"ko/enterprise/integrations/stripe",
|
||||
"ko/enterprise/integrations/zendesk"
|
||||
]
|
||||
@@ -15376,6 +15438,7 @@
|
||||
"ko/enterprise/integrations/asana",
|
||||
"ko/enterprise/integrations/box",
|
||||
"ko/enterprise/integrations/clickup",
|
||||
"ko/enterprise/integrations/databricks",
|
||||
"ko/enterprise/integrations/github",
|
||||
"ko/enterprise/integrations/gmail",
|
||||
"ko/enterprise/integrations/google_calendar",
|
||||
@@ -15397,6 +15460,7 @@
|
||||
"ko/enterprise/integrations/salesforce",
|
||||
"ko/enterprise/integrations/shopify",
|
||||
"ko/enterprise/integrations/slack",
|
||||
"ko/enterprise/integrations/snowflake",
|
||||
"ko/enterprise/integrations/stripe",
|
||||
"ko/enterprise/integrations/zendesk"
|
||||
]
|
||||
@@ -15849,6 +15913,7 @@
|
||||
"ko/enterprise/integrations/asana",
|
||||
"ko/enterprise/integrations/box",
|
||||
"ko/enterprise/integrations/clickup",
|
||||
"ko/enterprise/integrations/databricks",
|
||||
"ko/enterprise/integrations/github",
|
||||
"ko/enterprise/integrations/gmail",
|
||||
"ko/enterprise/integrations/google_calendar",
|
||||
@@ -15870,6 +15935,7 @@
|
||||
"ko/enterprise/integrations/salesforce",
|
||||
"ko/enterprise/integrations/shopify",
|
||||
"ko/enterprise/integrations/slack",
|
||||
"ko/enterprise/integrations/snowflake",
|
||||
"ko/enterprise/integrations/stripe",
|
||||
"ko/enterprise/integrations/zendesk"
|
||||
]
|
||||
@@ -16322,6 +16388,7 @@
|
||||
"ko/enterprise/integrations/asana",
|
||||
"ko/enterprise/integrations/box",
|
||||
"ko/enterprise/integrations/clickup",
|
||||
"ko/enterprise/integrations/databricks",
|
||||
"ko/enterprise/integrations/github",
|
||||
"ko/enterprise/integrations/gmail",
|
||||
"ko/enterprise/integrations/google_calendar",
|
||||
@@ -16343,6 +16410,7 @@
|
||||
"ko/enterprise/integrations/salesforce",
|
||||
"ko/enterprise/integrations/shopify",
|
||||
"ko/enterprise/integrations/slack",
|
||||
"ko/enterprise/integrations/snowflake",
|
||||
"ko/enterprise/integrations/stripe",
|
||||
"ko/enterprise/integrations/zendesk"
|
||||
]
|
||||
@@ -16795,6 +16863,7 @@
|
||||
"ko/enterprise/integrations/asana",
|
||||
"ko/enterprise/integrations/box",
|
||||
"ko/enterprise/integrations/clickup",
|
||||
"ko/enterprise/integrations/databricks",
|
||||
"ko/enterprise/integrations/github",
|
||||
"ko/enterprise/integrations/gmail",
|
||||
"ko/enterprise/integrations/google_calendar",
|
||||
@@ -16816,6 +16885,7 @@
|
||||
"ko/enterprise/integrations/salesforce",
|
||||
"ko/enterprise/integrations/shopify",
|
||||
"ko/enterprise/integrations/slack",
|
||||
"ko/enterprise/integrations/snowflake",
|
||||
"ko/enterprise/integrations/stripe",
|
||||
"ko/enterprise/integrations/zendesk"
|
||||
]
|
||||
@@ -17278,6 +17348,7 @@
|
||||
"ko/enterprise/integrations/asana",
|
||||
"ko/enterprise/integrations/box",
|
||||
"ko/enterprise/integrations/clickup",
|
||||
"ko/enterprise/integrations/databricks",
|
||||
"ko/enterprise/integrations/github",
|
||||
"ko/enterprise/integrations/gmail",
|
||||
"ko/enterprise/integrations/google_calendar",
|
||||
@@ -17299,6 +17370,7 @@
|
||||
"ko/enterprise/integrations/salesforce",
|
||||
"ko/enterprise/integrations/shopify",
|
||||
"ko/enterprise/integrations/slack",
|
||||
"ko/enterprise/integrations/snowflake",
|
||||
"ko/enterprise/integrations/stripe",
|
||||
"ko/enterprise/integrations/zendesk"
|
||||
]
|
||||
@@ -17761,6 +17833,7 @@
|
||||
"ko/enterprise/integrations/asana",
|
||||
"ko/enterprise/integrations/box",
|
||||
"ko/enterprise/integrations/clickup",
|
||||
"ko/enterprise/integrations/databricks",
|
||||
"ko/enterprise/integrations/github",
|
||||
"ko/enterprise/integrations/gmail",
|
||||
"ko/enterprise/integrations/google_calendar",
|
||||
@@ -17782,6 +17855,7 @@
|
||||
"ko/enterprise/integrations/salesforce",
|
||||
"ko/enterprise/integrations/shopify",
|
||||
"ko/enterprise/integrations/slack",
|
||||
"ko/enterprise/integrations/snowflake",
|
||||
"ko/enterprise/integrations/stripe",
|
||||
"ko/enterprise/integrations/zendesk"
|
||||
]
|
||||
@@ -18244,6 +18318,7 @@
|
||||
"ko/enterprise/integrations/asana",
|
||||
"ko/enterprise/integrations/box",
|
||||
"ko/enterprise/integrations/clickup",
|
||||
"ko/enterprise/integrations/databricks",
|
||||
"ko/enterprise/integrations/github",
|
||||
"ko/enterprise/integrations/gmail",
|
||||
"ko/enterprise/integrations/google_calendar",
|
||||
@@ -18265,6 +18340,7 @@
|
||||
"ko/enterprise/integrations/salesforce",
|
||||
"ko/enterprise/integrations/shopify",
|
||||
"ko/enterprise/integrations/slack",
|
||||
"ko/enterprise/integrations/snowflake",
|
||||
"ko/enterprise/integrations/stripe",
|
||||
"ko/enterprise/integrations/zendesk"
|
||||
]
|
||||
@@ -18727,6 +18803,7 @@
|
||||
"ko/enterprise/integrations/asana",
|
||||
"ko/enterprise/integrations/box",
|
||||
"ko/enterprise/integrations/clickup",
|
||||
"ko/enterprise/integrations/databricks",
|
||||
"ko/enterprise/integrations/github",
|
||||
"ko/enterprise/integrations/gmail",
|
||||
"ko/enterprise/integrations/google_calendar",
|
||||
@@ -18748,6 +18825,7 @@
|
||||
"ko/enterprise/integrations/salesforce",
|
||||
"ko/enterprise/integrations/shopify",
|
||||
"ko/enterprise/integrations/slack",
|
||||
"ko/enterprise/integrations/snowflake",
|
||||
"ko/enterprise/integrations/stripe",
|
||||
"ko/enterprise/integrations/zendesk"
|
||||
]
|
||||
@@ -19200,6 +19278,7 @@
|
||||
"ko/enterprise/integrations/asana",
|
||||
"ko/enterprise/integrations/box",
|
||||
"ko/enterprise/integrations/clickup",
|
||||
"ko/enterprise/integrations/databricks",
|
||||
"ko/enterprise/integrations/github",
|
||||
"ko/enterprise/integrations/gmail",
|
||||
"ko/enterprise/integrations/google_calendar",
|
||||
@@ -19221,6 +19300,7 @@
|
||||
"ko/enterprise/integrations/salesforce",
|
||||
"ko/enterprise/integrations/shopify",
|
||||
"ko/enterprise/integrations/slack",
|
||||
"ko/enterprise/integrations/snowflake",
|
||||
"ko/enterprise/integrations/stripe",
|
||||
"ko/enterprise/integrations/zendesk"
|
||||
]
|
||||
@@ -19673,6 +19753,7 @@
|
||||
"ko/enterprise/integrations/asana",
|
||||
"ko/enterprise/integrations/box",
|
||||
"ko/enterprise/integrations/clickup",
|
||||
"ko/enterprise/integrations/databricks",
|
||||
"ko/enterprise/integrations/github",
|
||||
"ko/enterprise/integrations/gmail",
|
||||
"ko/enterprise/integrations/google_calendar",
|
||||
@@ -19694,6 +19775,7 @@
|
||||
"ko/enterprise/integrations/salesforce",
|
||||
"ko/enterprise/integrations/shopify",
|
||||
"ko/enterprise/integrations/slack",
|
||||
"ko/enterprise/integrations/snowflake",
|
||||
"ko/enterprise/integrations/stripe",
|
||||
"ko/enterprise/integrations/zendesk"
|
||||
]
|
||||
@@ -20146,6 +20228,7 @@
|
||||
"ko/enterprise/integrations/asana",
|
||||
"ko/enterprise/integrations/box",
|
||||
"ko/enterprise/integrations/clickup",
|
||||
"ko/enterprise/integrations/databricks",
|
||||
"ko/enterprise/integrations/github",
|
||||
"ko/enterprise/integrations/gmail",
|
||||
"ko/enterprise/integrations/google_calendar",
|
||||
@@ -20167,6 +20250,7 @@
|
||||
"ko/enterprise/integrations/salesforce",
|
||||
"ko/enterprise/integrations/shopify",
|
||||
"ko/enterprise/integrations/slack",
|
||||
"ko/enterprise/integrations/snowflake",
|
||||
"ko/enterprise/integrations/stripe",
|
||||
"ko/enterprise/integrations/zendesk"
|
||||
]
|
||||
@@ -20618,6 +20702,7 @@
|
||||
"ko/enterprise/integrations/asana",
|
||||
"ko/enterprise/integrations/box",
|
||||
"ko/enterprise/integrations/clickup",
|
||||
"ko/enterprise/integrations/databricks",
|
||||
"ko/enterprise/integrations/github",
|
||||
"ko/enterprise/integrations/gmail",
|
||||
"ko/enterprise/integrations/google_calendar",
|
||||
@@ -20639,6 +20724,7 @@
|
||||
"ko/enterprise/integrations/salesforce",
|
||||
"ko/enterprise/integrations/shopify",
|
||||
"ko/enterprise/integrations/slack",
|
||||
"ko/enterprise/integrations/snowflake",
|
||||
"ko/enterprise/integrations/stripe",
|
||||
"ko/enterprise/integrations/zendesk"
|
||||
]
|
||||
@@ -21090,6 +21176,7 @@
|
||||
"ko/enterprise/integrations/asana",
|
||||
"ko/enterprise/integrations/box",
|
||||
"ko/enterprise/integrations/clickup",
|
||||
"ko/enterprise/integrations/databricks",
|
||||
"ko/enterprise/integrations/github",
|
||||
"ko/enterprise/integrations/gmail",
|
||||
"ko/enterprise/integrations/google_calendar",
|
||||
@@ -21111,6 +21198,7 @@
|
||||
"ko/enterprise/integrations/salesforce",
|
||||
"ko/enterprise/integrations/shopify",
|
||||
"ko/enterprise/integrations/slack",
|
||||
"ko/enterprise/integrations/snowflake",
|
||||
"ko/enterprise/integrations/stripe",
|
||||
"ko/enterprise/integrations/zendesk"
|
||||
]
|
||||
@@ -21563,6 +21651,7 @@
|
||||
"ko/enterprise/integrations/asana",
|
||||
"ko/enterprise/integrations/box",
|
||||
"ko/enterprise/integrations/clickup",
|
||||
"ko/enterprise/integrations/databricks",
|
||||
"ko/enterprise/integrations/github",
|
||||
"ko/enterprise/integrations/gmail",
|
||||
"ko/enterprise/integrations/google_calendar",
|
||||
@@ -21584,6 +21673,7 @@
|
||||
"ko/enterprise/integrations/salesforce",
|
||||
"ko/enterprise/integrations/shopify",
|
||||
"ko/enterprise/integrations/slack",
|
||||
"ko/enterprise/integrations/snowflake",
|
||||
"ko/enterprise/integrations/stripe",
|
||||
"ko/enterprise/integrations/zendesk"
|
||||
]
|
||||
@@ -22099,6 +22189,7 @@
|
||||
"ar/enterprise/integrations/asana",
|
||||
"ar/enterprise/integrations/box",
|
||||
"ar/enterprise/integrations/clickup",
|
||||
"ar/enterprise/integrations/databricks",
|
||||
"ar/enterprise/integrations/github",
|
||||
"ar/enterprise/integrations/gmail",
|
||||
"ar/enterprise/integrations/google_calendar",
|
||||
@@ -22120,6 +22211,7 @@
|
||||
"ar/enterprise/integrations/salesforce",
|
||||
"ar/enterprise/integrations/shopify",
|
||||
"ar/enterprise/integrations/slack",
|
||||
"ar/enterprise/integrations/snowflake",
|
||||
"ar/enterprise/integrations/stripe",
|
||||
"ar/enterprise/integrations/zendesk"
|
||||
]
|
||||
@@ -22605,6 +22697,7 @@
|
||||
"ar/enterprise/integrations/asana",
|
||||
"ar/enterprise/integrations/box",
|
||||
"ar/enterprise/integrations/clickup",
|
||||
"ar/enterprise/integrations/databricks",
|
||||
"ar/enterprise/integrations/github",
|
||||
"ar/enterprise/integrations/gmail",
|
||||
"ar/enterprise/integrations/google_calendar",
|
||||
@@ -22626,6 +22719,7 @@
|
||||
"ar/enterprise/integrations/salesforce",
|
||||
"ar/enterprise/integrations/shopify",
|
||||
"ar/enterprise/integrations/slack",
|
||||
"ar/enterprise/integrations/snowflake",
|
||||
"ar/enterprise/integrations/stripe",
|
||||
"ar/enterprise/integrations/zendesk"
|
||||
]
|
||||
@@ -23078,6 +23172,7 @@
|
||||
"ar/enterprise/integrations/asana",
|
||||
"ar/enterprise/integrations/box",
|
||||
"ar/enterprise/integrations/clickup",
|
||||
"ar/enterprise/integrations/databricks",
|
||||
"ar/enterprise/integrations/github",
|
||||
"ar/enterprise/integrations/gmail",
|
||||
"ar/enterprise/integrations/google_calendar",
|
||||
@@ -23099,6 +23194,7 @@
|
||||
"ar/enterprise/integrations/salesforce",
|
||||
"ar/enterprise/integrations/shopify",
|
||||
"ar/enterprise/integrations/slack",
|
||||
"ar/enterprise/integrations/snowflake",
|
||||
"ar/enterprise/integrations/stripe",
|
||||
"ar/enterprise/integrations/zendesk"
|
||||
]
|
||||
@@ -23551,6 +23647,7 @@
|
||||
"ar/enterprise/integrations/asana",
|
||||
"ar/enterprise/integrations/box",
|
||||
"ar/enterprise/integrations/clickup",
|
||||
"ar/enterprise/integrations/databricks",
|
||||
"ar/enterprise/integrations/github",
|
||||
"ar/enterprise/integrations/gmail",
|
||||
"ar/enterprise/integrations/google_calendar",
|
||||
@@ -23572,6 +23669,7 @@
|
||||
"ar/enterprise/integrations/salesforce",
|
||||
"ar/enterprise/integrations/shopify",
|
||||
"ar/enterprise/integrations/slack",
|
||||
"ar/enterprise/integrations/snowflake",
|
||||
"ar/enterprise/integrations/stripe",
|
||||
"ar/enterprise/integrations/zendesk"
|
||||
]
|
||||
@@ -24024,6 +24122,7 @@
|
||||
"ar/enterprise/integrations/asana",
|
||||
"ar/enterprise/integrations/box",
|
||||
"ar/enterprise/integrations/clickup",
|
||||
"ar/enterprise/integrations/databricks",
|
||||
"ar/enterprise/integrations/github",
|
||||
"ar/enterprise/integrations/gmail",
|
||||
"ar/enterprise/integrations/google_calendar",
|
||||
@@ -24045,6 +24144,7 @@
|
||||
"ar/enterprise/integrations/salesforce",
|
||||
"ar/enterprise/integrations/shopify",
|
||||
"ar/enterprise/integrations/slack",
|
||||
"ar/enterprise/integrations/snowflake",
|
||||
"ar/enterprise/integrations/stripe",
|
||||
"ar/enterprise/integrations/zendesk"
|
||||
]
|
||||
@@ -24507,6 +24607,7 @@
|
||||
"ar/enterprise/integrations/asana",
|
||||
"ar/enterprise/integrations/box",
|
||||
"ar/enterprise/integrations/clickup",
|
||||
"ar/enterprise/integrations/databricks",
|
||||
"ar/enterprise/integrations/github",
|
||||
"ar/enterprise/integrations/gmail",
|
||||
"ar/enterprise/integrations/google_calendar",
|
||||
@@ -24528,6 +24629,7 @@
|
||||
"ar/enterprise/integrations/salesforce",
|
||||
"ar/enterprise/integrations/shopify",
|
||||
"ar/enterprise/integrations/slack",
|
||||
"ar/enterprise/integrations/snowflake",
|
||||
"ar/enterprise/integrations/stripe",
|
||||
"ar/enterprise/integrations/zendesk"
|
||||
]
|
||||
@@ -24990,6 +25092,7 @@
|
||||
"ar/enterprise/integrations/asana",
|
||||
"ar/enterprise/integrations/box",
|
||||
"ar/enterprise/integrations/clickup",
|
||||
"ar/enterprise/integrations/databricks",
|
||||
"ar/enterprise/integrations/github",
|
||||
"ar/enterprise/integrations/gmail",
|
||||
"ar/enterprise/integrations/google_calendar",
|
||||
@@ -25011,6 +25114,7 @@
|
||||
"ar/enterprise/integrations/salesforce",
|
||||
"ar/enterprise/integrations/shopify",
|
||||
"ar/enterprise/integrations/slack",
|
||||
"ar/enterprise/integrations/snowflake",
|
||||
"ar/enterprise/integrations/stripe",
|
||||
"ar/enterprise/integrations/zendesk"
|
||||
]
|
||||
@@ -25473,6 +25577,7 @@
|
||||
"ar/enterprise/integrations/asana",
|
||||
"ar/enterprise/integrations/box",
|
||||
"ar/enterprise/integrations/clickup",
|
||||
"ar/enterprise/integrations/databricks",
|
||||
"ar/enterprise/integrations/github",
|
||||
"ar/enterprise/integrations/gmail",
|
||||
"ar/enterprise/integrations/google_calendar",
|
||||
@@ -25494,6 +25599,7 @@
|
||||
"ar/enterprise/integrations/salesforce",
|
||||
"ar/enterprise/integrations/shopify",
|
||||
"ar/enterprise/integrations/slack",
|
||||
"ar/enterprise/integrations/snowflake",
|
||||
"ar/enterprise/integrations/stripe",
|
||||
"ar/enterprise/integrations/zendesk"
|
||||
]
|
||||
@@ -25956,6 +26062,7 @@
|
||||
"ar/enterprise/integrations/asana",
|
||||
"ar/enterprise/integrations/box",
|
||||
"ar/enterprise/integrations/clickup",
|
||||
"ar/enterprise/integrations/databricks",
|
||||
"ar/enterprise/integrations/github",
|
||||
"ar/enterprise/integrations/gmail",
|
||||
"ar/enterprise/integrations/google_calendar",
|
||||
@@ -25977,6 +26084,7 @@
|
||||
"ar/enterprise/integrations/salesforce",
|
||||
"ar/enterprise/integrations/shopify",
|
||||
"ar/enterprise/integrations/slack",
|
||||
"ar/enterprise/integrations/snowflake",
|
||||
"ar/enterprise/integrations/stripe",
|
||||
"ar/enterprise/integrations/zendesk"
|
||||
]
|
||||
@@ -26429,6 +26537,7 @@
|
||||
"ar/enterprise/integrations/asana",
|
||||
"ar/enterprise/integrations/box",
|
||||
"ar/enterprise/integrations/clickup",
|
||||
"ar/enterprise/integrations/databricks",
|
||||
"ar/enterprise/integrations/github",
|
||||
"ar/enterprise/integrations/gmail",
|
||||
"ar/enterprise/integrations/google_calendar",
|
||||
@@ -26450,6 +26559,7 @@
|
||||
"ar/enterprise/integrations/salesforce",
|
||||
"ar/enterprise/integrations/shopify",
|
||||
"ar/enterprise/integrations/slack",
|
||||
"ar/enterprise/integrations/snowflake",
|
||||
"ar/enterprise/integrations/stripe",
|
||||
"ar/enterprise/integrations/zendesk"
|
||||
]
|
||||
@@ -26902,6 +27012,7 @@
|
||||
"ar/enterprise/integrations/asana",
|
||||
"ar/enterprise/integrations/box",
|
||||
"ar/enterprise/integrations/clickup",
|
||||
"ar/enterprise/integrations/databricks",
|
||||
"ar/enterprise/integrations/github",
|
||||
"ar/enterprise/integrations/gmail",
|
||||
"ar/enterprise/integrations/google_calendar",
|
||||
@@ -26923,6 +27034,7 @@
|
||||
"ar/enterprise/integrations/salesforce",
|
||||
"ar/enterprise/integrations/shopify",
|
||||
"ar/enterprise/integrations/slack",
|
||||
"ar/enterprise/integrations/snowflake",
|
||||
"ar/enterprise/integrations/stripe",
|
||||
"ar/enterprise/integrations/zendesk"
|
||||
]
|
||||
@@ -27375,6 +27487,7 @@
|
||||
"ar/enterprise/integrations/asana",
|
||||
"ar/enterprise/integrations/box",
|
||||
"ar/enterprise/integrations/clickup",
|
||||
"ar/enterprise/integrations/databricks",
|
||||
"ar/enterprise/integrations/github",
|
||||
"ar/enterprise/integrations/gmail",
|
||||
"ar/enterprise/integrations/google_calendar",
|
||||
@@ -27396,6 +27509,7 @@
|
||||
"ar/enterprise/integrations/salesforce",
|
||||
"ar/enterprise/integrations/shopify",
|
||||
"ar/enterprise/integrations/slack",
|
||||
"ar/enterprise/integrations/snowflake",
|
||||
"ar/enterprise/integrations/stripe",
|
||||
"ar/enterprise/integrations/zendesk"
|
||||
]
|
||||
@@ -27847,6 +27961,7 @@
|
||||
"ar/enterprise/integrations/asana",
|
||||
"ar/enterprise/integrations/box",
|
||||
"ar/enterprise/integrations/clickup",
|
||||
"ar/enterprise/integrations/databricks",
|
||||
"ar/enterprise/integrations/github",
|
||||
"ar/enterprise/integrations/gmail",
|
||||
"ar/enterprise/integrations/google_calendar",
|
||||
@@ -27868,6 +27983,7 @@
|
||||
"ar/enterprise/integrations/salesforce",
|
||||
"ar/enterprise/integrations/shopify",
|
||||
"ar/enterprise/integrations/slack",
|
||||
"ar/enterprise/integrations/snowflake",
|
||||
"ar/enterprise/integrations/stripe",
|
||||
"ar/enterprise/integrations/zendesk"
|
||||
]
|
||||
@@ -28319,6 +28435,7 @@
|
||||
"ar/enterprise/integrations/asana",
|
||||
"ar/enterprise/integrations/box",
|
||||
"ar/enterprise/integrations/clickup",
|
||||
"ar/enterprise/integrations/databricks",
|
||||
"ar/enterprise/integrations/github",
|
||||
"ar/enterprise/integrations/gmail",
|
||||
"ar/enterprise/integrations/google_calendar",
|
||||
@@ -28340,6 +28457,7 @@
|
||||
"ar/enterprise/integrations/salesforce",
|
||||
"ar/enterprise/integrations/shopify",
|
||||
"ar/enterprise/integrations/slack",
|
||||
"ar/enterprise/integrations/snowflake",
|
||||
"ar/enterprise/integrations/stripe",
|
||||
"ar/enterprise/integrations/zendesk"
|
||||
]
|
||||
@@ -28792,6 +28910,7 @@
|
||||
"ar/enterprise/integrations/asana",
|
||||
"ar/enterprise/integrations/box",
|
||||
"ar/enterprise/integrations/clickup",
|
||||
"ar/enterprise/integrations/databricks",
|
||||
"ar/enterprise/integrations/github",
|
||||
"ar/enterprise/integrations/gmail",
|
||||
"ar/enterprise/integrations/google_calendar",
|
||||
@@ -28813,6 +28932,7 @@
|
||||
"ar/enterprise/integrations/salesforce",
|
||||
"ar/enterprise/integrations/shopify",
|
||||
"ar/enterprise/integrations/slack",
|
||||
"ar/enterprise/integrations/snowflake",
|
||||
"ar/enterprise/integrations/stripe",
|
||||
"ar/enterprise/integrations/zendesk"
|
||||
]
|
||||
|
||||
@@ -107,7 +107,7 @@ There are different places in CrewAI code where you can specify the model to use
|
||||
</Tabs>
|
||||
|
||||
<Info>
|
||||
CrewAI provides native SDK integrations for OpenAI, Anthropic, Google (Gemini API), Azure, and AWS Bedrock — no extra install needed beyond the provider-specific extras (e.g. `uv add "crewai[openai]"`).
|
||||
CrewAI provides native SDK integrations for OpenAI, Anthropic, Google (Gemini API), Azure, AWS Bedrock, and Snowflake Cortex — no extra install needed beyond the provider-specific extras (e.g. `uv add "crewai[openai]"`).
|
||||
|
||||
All other providers are powered by **LiteLLM**. If you plan to use any of them, add it as a dependency to your project:
|
||||
```bash
|
||||
@@ -291,6 +291,55 @@ In this section, you'll find detailed examples that help you select, configure,
|
||||
```
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Snowflake Cortex">
|
||||
CrewAI provides native integration with the Snowflake Cortex REST API through its OpenAI-compatible Chat Completions endpoint. This avoids LiteLLM fallback for `snowflake/...` models. Snowflake Cortex currently supports Chat Completions only in CrewAI, so use the default `api` mode and do not set `api="responses"`.
|
||||
|
||||
```toml Code
|
||||
# Required
|
||||
SNOWFLAKE_PAT=<your-programmatic-access-token>
|
||||
SNOWFLAKE_ACCOUNT_URL=https://<account-identifier>.snowflakecomputing.com
|
||||
|
||||
# Alternative account configuration
|
||||
SNOWFLAKE_ACCOUNT=<account-identifier>
|
||||
```
|
||||
|
||||
**Basic Usage:**
|
||||
```python Code
|
||||
from crewai import LLM
|
||||
|
||||
llm = LLM(
|
||||
model="snowflake/openai-gpt-4.1",
|
||||
temperature=0.7,
|
||||
max_completion_tokens=1024,
|
||||
)
|
||||
```
|
||||
|
||||
**Claude Models on Cortex:**
|
||||
```python Code
|
||||
from crewai import LLM
|
||||
|
||||
llm = LLM(
|
||||
model="snowflake/claude-sonnet-4-5",
|
||||
max_completion_tokens=1024,
|
||||
stream=True,
|
||||
)
|
||||
```
|
||||
|
||||
**Supported Environment Variables:**
|
||||
- `SNOWFLAKE_PAT`, `SNOWFLAKE_TOKEN`, or `SNOWFLAKE_JWT`: token used as the Bearer credential
|
||||
- `SNOWFLAKE_ACCOUNT_URL`: full Snowflake account URL
|
||||
- `SNOWFLAKE_ACCOUNT`, `SNOWFLAKE_ACCOUNT_ID`, or `SNOWFLAKE_ACCOUNT_IDENTIFIER`: account identifier used to build the account URL
|
||||
|
||||
Snowflake REST requests use the user's default Snowflake role. Make sure that role has `SNOWFLAKE.CORTEX_USER` or `SNOWFLAKE.CORTEX_REST_API_USER`. Database, schema, warehouse, and explicit role parameters are not required by the Cortex REST Chat Completions endpoint.
|
||||
|
||||
**Features:**
|
||||
- Native provider selection with `model="snowflake/<model-name>"`
|
||||
- Streaming and non-streaming Chat Completions only; `api="responses"` is not supported
|
||||
- Token usage tracking
|
||||
- Function calling for Snowflake-hosted OpenAI and Claude models
|
||||
- Automatic removal of invalid trailing assistant prefill for Snowflake Claude models
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Anthropic">
|
||||
CrewAI provides native integration with Anthropic through the Anthropic Python SDK.
|
||||
|
||||
|
||||
@@ -187,7 +187,7 @@ flowchart TD
|
||||
- **Filename Requirement:** Ensure that the filename ends with `.pkl`. The code will raise a `ValueError` if this condition is not met.
|
||||
- **Error Handling:** The code handles subprocess errors and unexpected exceptions, providing error messages to the user.
|
||||
- Trained guidance is applied at prompt time; it does not modify your Python/YAML agent configuration.
|
||||
- Agents automatically load trained suggestions from a file named `trained_agents_data.pkl` located in the current working directory. If you trained to a different filename, either rename it to `trained_agents_data.pkl` before running, or adjust the loader in code.
|
||||
- Agents automatically load trained suggestions from a file named `trained_agents_data.pkl` located in the current working directory. If you trained to a different filename, pass that path with `Crew(trained_agents_file="my_custom_trained.pkl")`, set `CREWAI_TRAINED_AGENTS_FILE`, or use `crewai run -f my_custom_trained.pkl`.
|
||||
- You can change the output filename when calling `crewai train` with `-f/--filename`. Absolute paths are supported if you want to save outside the CWD.
|
||||
|
||||
It is important to note that the training process may take some time, depending on the complexity of your agents and will also require your feedback on each iteration.
|
||||
|
||||
123
docs/en/enterprise/integrations/databricks.mdx
Normal file
123
docs/en/enterprise/integrations/databricks.mdx
Normal file
@@ -0,0 +1,123 @@
|
||||
---
|
||||
title: Databricks Integration
|
||||
description: "Connect CrewAI agents to Databricks Genie, SQL, Unity Catalog Functions, and Vector Search through Databricks managed MCP servers."
|
||||
icon: "layer-group"
|
||||
mode: "wide"
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Connect your CrewAI agents directly to your Databricks workspace through [Databricks managed MCP servers](https://docs.databricks.com/aws/en/generative-ai/mcp/managed-mcp). The Databricks integration lets your agents ask natural-language questions with **Genie**, run governed **SQL**, call **Unity Catalog Functions**, and retrieve documents with **Vector Search** — all without writing or hosting any connector code, and with Unity Catalog permissions enforced on every call.
|
||||
|
||||
Under the hood, the Databricks integration is a managed wrapper around CrewAI's [Custom MCP Server](/en/enterprise/guides/custom-mcp-server) support. Databricks exposes each capability as its own [Model Context Protocol](https://modelcontextprotocol.io/) endpoint, and CrewAI connects to them securely on your behalf. Because each server is added separately, you can enable exactly the capabilities your crews need.
|
||||
|
||||
## Key Capabilities
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="Genie" icon="comments">
|
||||
Ask questions in plain language and get grounded answers from your data with [Genie](https://docs.databricks.com/aws/en/genie/), which queries Genie Spaces and Unity Catalog and links back to the Databricks UI.
|
||||
</Card>
|
||||
<Card title="Databricks SQL" icon="database">
|
||||
Run governed SQL against your Databricks warehouses to query, transform, and author data pipelines directly from your agents.
|
||||
</Card>
|
||||
<Card title="Unity Catalog Functions" icon="function">
|
||||
Invoke [Unity Catalog functions](https://docs.databricks.com/aws/en/udf/unity-catalog) to run predefined SQL and custom business logic as governed, reusable tools.
|
||||
</Card>
|
||||
<Card title="Vector Search" icon="magnifying-glass">
|
||||
Retrieve relevant documents for RAG and knowledge workflows from [Mosaic AI Vector Search](https://docs.databricks.com/aws/en/generative-ai/vector-search) indexes using semantic similarity.
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
Every server runs behind the Unity AI Gateway and enforces Unity Catalog access controls, so your agents only ever see the data and tools they're permitted to use.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Before using the Databricks integration, ensure you have:
|
||||
|
||||
- A [CrewAI AMP](https://app.crewai.com) account with an active subscription
|
||||
- A Databricks workspace with the capabilities you want to expose (Genie Spaces, SQL warehouses, Unity Catalog functions, or Vector Search indexes)
|
||||
- Appropriate [Unity Catalog privileges](https://docs.databricks.com/aws/en/data-governance/unity-catalog) on the underlying objects
|
||||
- Your Databricks workspace hostname (e.g. `your-workspace.cloud.databricks.com`)
|
||||
|
||||
## Databricks Managed MCP Servers
|
||||
|
||||
Databricks publishes a separate managed MCP server for each capability. CrewAI exposes these as individual connections, each configured with your workspace host and the relevant Unity Catalog identifiers. The endpoints follow these patterns:
|
||||
|
||||
| Server | What it does | MCP URL pattern |
|
||||
|--------|--------------|-----------------|
|
||||
| **Genie** | Natural-language Q&A over a Genie Space | `https://<workspace-hostname>/api/2.0/mcp/genie/{genie_space_id}` |
|
||||
| **Databricks SQL** | Execute SQL against your warehouses | `https://<workspace-hostname>/api/2.0/mcp/sql` |
|
||||
| **Unity Catalog Functions** | Run registered UC functions | `https://<workspace-hostname>/api/2.0/mcp/functions/{catalog}/{schema}` |
|
||||
| **Vector Search** | Query a Vector Search index | `https://<workspace-hostname>/api/2.0/mcp/vector-search/{catalog}/{schema}` |
|
||||
|
||||
<Note>
|
||||
You don't construct these URLs by hand — CrewAI builds each endpoint from the workspace host and identifiers (Genie Space ID, or catalog/schema) you provide when configuring the connection. For the full specification and the latest endpoint details, see the [Databricks managed MCP documentation](https://docs.databricks.com/aws/en/generative-ai/mcp/managed-mcp).
|
||||
</Note>
|
||||
|
||||
## Connecting Databricks in CrewAI AMP
|
||||
|
||||
<Frame>
|
||||
<img src="/images/enterprise/databricks-configure.png" alt="Configure a Databricks managed MCP server in CrewAI AMP" />
|
||||
</Frame>
|
||||
|
||||
Each Databricks capability — **Databricks Genie**, **Databricks SQL**, **Databricks Unity Catalog Functions**, and **Databricks Vector Search** — appears as its own MCP server under the Databricks group on the **Tools & Integrations** page. Configure the ones you need:
|
||||
|
||||
<Steps>
|
||||
<Step title="Open Tools & Integrations">
|
||||
Navigate to **Tools & Integrations** in the left sidebar of CrewAI AMP and locate the **Databricks** group in the Connections list. You'll see the Genie, SQL, Unity Catalog Functions, and Vector Search servers listed beneath it.
|
||||
</Step>
|
||||
|
||||
<Step title="Configure a server">
|
||||
Click **Configure** next to the capability you want to enable and provide its connection details:
|
||||
|
||||
- **Workspace Host** — your Databricks workspace hostname (e.g. `my-workspace.cloud.databricks.com`).
|
||||
- **Genie** — the **Genie Space ID** to query.
|
||||
- **Unity Catalog Functions** — the **catalog** and **schema** that contain your functions.
|
||||
- **Vector Search** — the **catalog** and **schema** that contain your index.
|
||||
- **Databricks SQL** — no additional identifiers; queries run against your workspace's SQL warehouses.
|
||||
</Step>
|
||||
|
||||
<Step title="Choose an authentication method">
|
||||
Select how CrewAI authenticates to Databricks. **OAuth** is recommended.
|
||||
|
||||
- **Use OAuth** — Connect securely using OAuth 2.0. Each user authenticates individually, and Databricks issues tokens scoped to the capability (`genie`, `sql`, `unity-catalog`, or `vector-search`). CrewAI handles the authorization flow and refreshes tokens automatically.
|
||||
- **Use personal access token** — Authenticate with a [Databricks personal access token](https://docs.databricks.com/aws/en/dev-tools/auth/pat). Use a least-privileged identity to limit exposure.
|
||||
</Step>
|
||||
|
||||
<Step title="Authenticate">
|
||||
Complete authentication. Once connected, the server's tools become available to your crews. Repeat for any other Databricks capabilities you want to enable.
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
<Tip>
|
||||
Because each capability is a separate connection, you can mix and match — for example, enable Genie and Vector Search for a research crew while reserving SQL and Unity Catalog Functions for a data-engineering crew. Visibility settings let you control which team members can use each one.
|
||||
</Tip>
|
||||
|
||||
## Using Databricks Tools in Your Crews
|
||||
|
||||
Once connected, the tools each MCP server exposes appear alongside built-in connections on the **Tools & Integrations** page. You can:
|
||||
|
||||
- **Assign tools to agents** in your crews just like any other CrewAI tool.
|
||||
- **Manage visibility** to control which team members can use each connection.
|
||||
- **Edit or remove** any connection at any time from the Connections list.
|
||||
|
||||
Your agents can now ask Genie for grounded answers, run SQL against your warehouses, call Unity Catalog functions, and search Vector Search indexes — with results flowing back into their reasoning automatically.
|
||||
|
||||
<Warning>
|
||||
Databricks enforces governance through Unity Catalog and the Unity AI Gateway: a user can only discover and invoke tools their workspace identity is permitted to use. If a tool call fails, confirm the connecting user (or token identity) has the required Unity Catalog privileges on the Genie Space, warehouse, function, or index. Some Genie and SQL queries run asynchronously and may take a moment to return results.
|
||||
</Warning>
|
||||
|
||||
## Learn More
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="Databricks Managed MCP Servers" icon="layer-group" href="https://docs.databricks.com/aws/en/generative-ai/mcp/managed-mcp">
|
||||
Official Databricks documentation for the managed Genie, SQL, Unity Catalog Functions, and Vector Search MCP servers.
|
||||
</Card>
|
||||
<Card title="Custom MCP Servers in CrewAI" icon="plug" href="/en/enterprise/guides/custom-mcp-server">
|
||||
Learn how CrewAI connects to any MCP server, the foundation the Databricks integration builds on.
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
<Card title="Need Help?" icon="headset" href="mailto:support@crewai.com">
|
||||
Contact our support team for assistance with the Databricks integration or troubleshooting.
|
||||
</Card>
|
||||
134
docs/en/enterprise/integrations/snowflake.mdx
Normal file
134
docs/en/enterprise/integrations/snowflake.mdx
Normal file
@@ -0,0 +1,134 @@
|
||||
---
|
||||
title: Snowflake Integration
|
||||
description: "Connect CrewAI agents to Snowflake Cortex Analyst, Cortex Search, and SQL execution through the Snowflake-managed MCP server."
|
||||
icon: "snowflake"
|
||||
mode: "wide"
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Connect your CrewAI agents directly to your Snowflake data through the [Snowflake-managed MCP server](https://docs.snowflake.com/en/user-guide/snowflake-cortex/cortex-agents-mcp). The Snowflake integration lets your agents query structured data with **Cortex Analyst**, search unstructured data with **Cortex Search**, and run governed SQL against your warehouses — all without writing or hosting any connector code.
|
||||
|
||||
Under the hood, the Snowflake integration is a managed wrapper around CrewAI's [Custom MCP Server](/en/enterprise/guides/custom-mcp-server) support. Snowflake exposes its Cortex AI capabilities through a [Model Context Protocol](https://modelcontextprotocol.io/) endpoint, and CrewAI connects to it securely on your behalf. Any tool you expose on the Snowflake side — Cortex Analyst, Cortex Search, SQL execution, Cortex Agents, or your own custom tools — becomes available to your crews.
|
||||
|
||||
## Key Capabilities
|
||||
|
||||
<CardGroup cols={3}>
|
||||
<Card title="Cortex Analyst" icon="chart-bar">
|
||||
Ask questions in natural language and let [Cortex Analyst](https://docs.snowflake.com/en/user-guide/snowflake-cortex/cortex-analyst) generate and run SQL against your **structured** data using rich semantic models.
|
||||
</Card>
|
||||
<Card title="Cortex Search" icon="magnifying-glass">
|
||||
Retrieve relevant **unstructured** data for RAG and knowledge workflows with [Cortex Search](https://docs.snowflake.com/en/user-guide/snowflake-cortex/cortex-search/cortex-search-overview), Snowflake's fully managed search service.
|
||||
</Card>
|
||||
<Card title="SQL Execution" icon="database">
|
||||
Run governed SQL queries directly against your Snowflake warehouses, with configurable read-only mode, timeouts, and warehouse selection.
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
Because the integration surfaces whatever tools your MCP server publishes, you can also expose **Cortex Agents** and **custom tools** (user-defined functions and stored procedures) to your CrewAI agents.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Before using the Snowflake integration, ensure you have:
|
||||
|
||||
- A [CrewAI AMP](https://app.crewai.com) account with an active subscription
|
||||
- A Snowflake account with access to Cortex AI features
|
||||
- A [Snowflake-managed MCP server](https://docs.snowflake.com/en/user-guide/snowflake-cortex/cortex-agents-mcp) configured with the tools you want to expose
|
||||
- Appropriate Snowflake privileges (USAGE/SELECT) on the MCP server and its underlying objects
|
||||
|
||||
## Setting Up the Snowflake MCP Server
|
||||
|
||||
The Snowflake-managed MCP server runs inside your Snowflake account and defines which tools are available to external clients like CrewAI. Create one with the [`CREATE MCP SERVER`](https://docs.snowflake.com/en/sql-reference/sql/create-mcp-server) command, listing the Cortex Search services, Cortex Analyst semantic views, and SQL tools you want to expose.
|
||||
|
||||
```sql
|
||||
CREATE MCP SERVER my_mcp_server
|
||||
FROM SPECIFICATION $$
|
||||
tools:
|
||||
- name: "sales_analyst"
|
||||
type: "CORTEX_ANALYST"
|
||||
identifier: "MY_DATABASE.MY_SCHEMA.sales_semantic_view"
|
||||
description: "Answer questions about sales metrics"
|
||||
- name: "docs_search"
|
||||
type: "CORTEX_SEARCH_SERVICE_QUERY"
|
||||
identifier: "MY_DATABASE.MY_SCHEMA.support_docs_search"
|
||||
description: "Search internal support documentation"
|
||||
- name: "run_sql"
|
||||
type: "SQL_EXECUTION"
|
||||
description: "Execute read-only SQL queries"
|
||||
$$;
|
||||
```
|
||||
|
||||
<Note>
|
||||
The MCP endpoint follows the format `https://<account_URL>/api/v2/databases/{database}/schemas/{schema}/mcp-servers/{name}`. CrewAI builds this URL automatically from the **Account URL**, **Database**, **Schema**, and **MCP Server Name** you provide when configuring the integration.
|
||||
</Note>
|
||||
|
||||
For the complete specification — including Cortex Agents, custom tools, response-size limits, and governance options — see the [Snowflake-managed MCP server documentation](https://docs.snowflake.com/en/user-guide/snowflake-cortex/cortex-agents-mcp).
|
||||
|
||||
## Connecting Snowflake in CrewAI AMP
|
||||
|
||||
<Frame>
|
||||
<img src="/images/enterprise/snowflake-configure.png" alt="Configure Snowflake integration in CrewAI AMP" />
|
||||
</Frame>
|
||||
|
||||
<Steps>
|
||||
<Step title="Open Tools & Integrations">
|
||||
Navigate to **Tools & Integrations** in the left sidebar of CrewAI AMP, find **Snowflake** in the list of applications, and open its configuration panel.
|
||||
</Step>
|
||||
|
||||
<Step title="Provide connection details">
|
||||
Fill in the connection fields that CrewAI uses to reach your Snowflake MCP server:
|
||||
|
||||
| Field | Required | Description |
|
||||
|-------|----------|-------------|
|
||||
| **Name** | Yes | A descriptive name for this connection (defaults to `Snowflake`). |
|
||||
| **Description** | No | An optional summary of what this connection provides. |
|
||||
| **Account URL** | Yes | Your Snowflake account URL, e.g. `xy12345.us-east-1.snowflakecomputing.com`. |
|
||||
| **Database** | Yes | The database that contains your MCP server (e.g. `MY_DATABASE`). |
|
||||
| **Schema** | Yes | The schema that contains your MCP server (e.g. `MY_SCHEMA`). |
|
||||
| **MCP Server Name** | Yes | The name of the MCP server object you created in Snowflake (e.g. `MY_MCP_SERVER`). |
|
||||
</Step>
|
||||
|
||||
<Step title="Choose an authentication method">
|
||||
Select how CrewAI authenticates to Snowflake. **OAuth** is recommended.
|
||||
|
||||
- **Use OAuth** — Connect securely using OAuth 2.0 for token-based authentication without sharing your credentials. CrewAI handles the full authorization flow and refreshes tokens automatically. Copy the **Redirect URI** shown in the form (`https://oauth.crewai.com/oauth/add`) and register it as an authorized redirect URI in your Snowflake [OAuth security integration](https://docs.snowflake.com/en/user-guide/oauth-custom).
|
||||
- **Use personal access token** — Authenticate using a [programmatic access token](https://docs.snowflake.com/en/user-guide/programmatic-access-tokens) generated from your Snowflake account settings. Assign a least-privileged role to the token to limit exposure.
|
||||
</Step>
|
||||
|
||||
<Step title="Authenticate">
|
||||
Click **Authenticate**. For OAuth, you'll be redirected to Snowflake to authorize access. Once authenticated, the Snowflake server appears in your Connections and its tools become available to your crews.
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
<Tip>
|
||||
With OAuth, each user authenticates individually and queries run with their Snowflake `DEFAULT_ROLE`. Make sure connecting users have a default role and warehouse set (`ALTER USER <username> SET DEFAULT_ROLE = '<role>' DEFAULT_WAREHOUSE = '<warehouse>'`) so Cortex Analyst and SQL tools have compute to run on.
|
||||
</Tip>
|
||||
|
||||
## Using Snowflake Tools in Your Crews
|
||||
|
||||
Once connected, the tools your MCP server exposes appear alongside built-in connections on the **Tools & Integrations** page. You can:
|
||||
|
||||
- **Assign tools to agents** in your crews just like any other CrewAI tool.
|
||||
- **Manage visibility** to control which team members can use the connection.
|
||||
- **Edit or remove** the connection at any time from the Connections list.
|
||||
|
||||
Your agents can now ask Cortex Analyst for metrics, run Cortex Search over your documents, and execute SQL — with results flowing back into their reasoning automatically.
|
||||
|
||||
<Warning>
|
||||
Snowflake enforces governance on the MCP server: role-based access control determines which tools a user can discover and invoke, and limits apply to response size, tool count (max 50 per server), and recursion depth. If a tool call fails, confirm the connecting user's role has the required privileges on the MCP server and its underlying objects.
|
||||
</Warning>
|
||||
|
||||
## Learn More
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="Snowflake-managed MCP Server" icon="snowflake" href="https://docs.snowflake.com/en/user-guide/snowflake-cortex/cortex-agents-mcp">
|
||||
Official Snowflake documentation for creating and governing the MCP server.
|
||||
</Card>
|
||||
<Card title="Custom MCP Servers in CrewAI" icon="plug" href="/en/enterprise/guides/custom-mcp-server">
|
||||
Learn how CrewAI connects to any MCP server, the foundation the Snowflake integration builds on.
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
<Card title="Need Help?" icon="headset" href="mailto:support@crewai.com">
|
||||
Contact our support team for assistance with the Snowflake integration or troubleshooting.
|
||||
</Card>
|
||||
BIN
docs/images/enterprise/databricks-configure.png
Normal file
BIN
docs/images/enterprise/databricks-configure.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.0 MiB |
BIN
docs/images/enterprise/snowflake-configure.png
Normal file
BIN
docs/images/enterprise/snowflake-configure.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.7 MiB |
@@ -106,7 +106,7 @@ CrewAI 코드 내에는 사용할 모델을 지정할 수 있는 여러 위치
|
||||
</Tabs>
|
||||
|
||||
<Info>
|
||||
CrewAI는 OpenAI, Anthropic, Google (Gemini API), Azure, AWS Bedrock에 대해 네이티브 SDK 통합을 제공합니다 — 제공자별 extras(예: `uv add "crewai[openai]"`) 외에 추가 설치가 필요하지 않습니다.
|
||||
CrewAI는 OpenAI, Anthropic, Google (Gemini API), Azure, AWS Bedrock, Snowflake Cortex에 대해 네이티브 SDK 통합을 제공합니다 — 제공자별 extras(예: `uv add "crewai[openai]"`) 외에 추가 설치가 필요하지 않습니다.
|
||||
|
||||
그 외 모든 제공자는 **LiteLLM**을 통해 지원됩니다. 이를 사용하려면 프로젝트에 의존성으로 추가하세요:
|
||||
```bash
|
||||
@@ -230,6 +230,55 @@ CrewAI는 고유한 기능, 인증 방법, 모델 역량을 제공하는 다양
|
||||
```
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Snowflake Cortex">
|
||||
CrewAI는 OpenAI 호환 Chat Completions 엔드포인트를 통해 Snowflake Cortex REST API와 네이티브로 통합됩니다. `snowflake/...` 모델은 LiteLLM fallback 없이 사용됩니다. CrewAI에서 Snowflake Cortex는 현재 Chat Completions만 지원하므로 기본 `api` 모드를 사용하고 `api="responses"`를 설정하지 마세요.
|
||||
|
||||
```toml Code
|
||||
# Required
|
||||
SNOWFLAKE_PAT=<your-programmatic-access-token>
|
||||
SNOWFLAKE_ACCOUNT_URL=https://<account-identifier>.snowflakecomputing.com
|
||||
|
||||
# Alternative account configuration
|
||||
SNOWFLAKE_ACCOUNT=<account-identifier>
|
||||
```
|
||||
|
||||
**기본 사용법:**
|
||||
```python Code
|
||||
from crewai import LLM
|
||||
|
||||
llm = LLM(
|
||||
model="snowflake/openai-gpt-4.1",
|
||||
temperature=0.7,
|
||||
max_completion_tokens=1024,
|
||||
)
|
||||
```
|
||||
|
||||
**Cortex의 Claude 모델:**
|
||||
```python Code
|
||||
from crewai import LLM
|
||||
|
||||
llm = LLM(
|
||||
model="snowflake/claude-sonnet-4-5",
|
||||
max_completion_tokens=1024,
|
||||
stream=True,
|
||||
)
|
||||
```
|
||||
|
||||
**지원 환경 변수:**
|
||||
- `SNOWFLAKE_PAT`, `SNOWFLAKE_TOKEN`, 또는 `SNOWFLAKE_JWT`: Bearer 자격 증명으로 사용할 토큰
|
||||
- `SNOWFLAKE_ACCOUNT_URL`: 전체 Snowflake 계정 URL
|
||||
- `SNOWFLAKE_ACCOUNT`, `SNOWFLAKE_ACCOUNT_ID`, 또는 `SNOWFLAKE_ACCOUNT_IDENTIFIER`: 계정 URL을 만들 계정 식별자
|
||||
|
||||
Snowflake REST 요청은 사용자의 기본 Snowflake role을 사용합니다. 해당 role에 `SNOWFLAKE.CORTEX_USER` 또는 `SNOWFLAKE.CORTEX_REST_API_USER`가 있는지 확인하세요. Cortex REST Chat Completions 엔드포인트에는 database, schema, warehouse, 명시적 role 파라미터가 필요하지 않습니다.
|
||||
|
||||
**기능:**
|
||||
- `model="snowflake/<model-name>"`을 통한 네이티브 provider 선택
|
||||
- Streaming 및 non-streaming Chat Completions만 지원; `api="responses"`는 지원되지 않음
|
||||
- 토큰 사용량 추적
|
||||
- Snowflake 호스팅 OpenAI 및 Claude 모델의 함수 호출
|
||||
- Snowflake Claude 모델에서 유효하지 않은 마지막 assistant prefill 자동 제거
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Anthropic">
|
||||
```toml Code
|
||||
# Required
|
||||
|
||||
123
docs/ko/enterprise/integrations/databricks.mdx
Normal file
123
docs/ko/enterprise/integrations/databricks.mdx
Normal file
@@ -0,0 +1,123 @@
|
||||
---
|
||||
title: Databricks 연동
|
||||
description: "Databricks 관리형 MCP 서버를 통해 CrewAI 에이전트를 Databricks Genie, SQL, Unity Catalog Functions, Vector Search에 연결하세요."
|
||||
icon: "layer-group"
|
||||
mode: "wide"
|
||||
---
|
||||
|
||||
## 개요
|
||||
|
||||
[Databricks 관리형 MCP 서버](https://docs.databricks.com/aws/en/generative-ai/mcp/managed-mcp)를 통해 CrewAI 에이전트를 Databricks 워크스페이스에 직접 연결하세요. Databricks 연동을 사용하면 에이전트가 **Genie**로 자연어 질문을 하고, 거버넌스가 적용된 **SQL**을 실행하며, **Unity Catalog Functions**를 호출하고, **Vector Search**로 문서를 검색할 수 있습니다. 커넥터 코드를 작성하거나 호스팅할 필요가 없으며, 모든 호출에 Unity Catalog 권한이 적용됩니다.
|
||||
|
||||
내부적으로 Databricks 연동은 CrewAI의 [커스텀 MCP 서버](/ko/enterprise/guides/custom-mcp-server) 지원을 감싼 관리형 래퍼입니다. Databricks는 각 기능을 개별 [Model Context Protocol](https://modelcontextprotocol.io/) 엔드포인트로 노출하며, CrewAI가 사용자를 대신해 안전하게 연결합니다. 각 서버를 개별적으로 추가하므로 크루에 필요한 기능만 정확히 활성화할 수 있습니다.
|
||||
|
||||
## 주요 기능
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="Genie" icon="comments">
|
||||
[Genie](https://docs.databricks.com/aws/en/genie/)로 자연어 질문을 하고 데이터 기반의 근거 있는 답변을 받으세요. Genie는 Genie Spaces와 Unity Catalog를 조회하고 Databricks UI로 연결되는 링크를 제공합니다.
|
||||
</Card>
|
||||
<Card title="Databricks SQL" icon="database">
|
||||
에이전트에서 직접 Databricks 웨어하우스에 거버넌스가 적용된 SQL을 실행하여 데이터를 조회, 변환하고 데이터 파이프라인을 작성하세요.
|
||||
</Card>
|
||||
<Card title="Unity Catalog Functions" icon="function">
|
||||
[Unity Catalog 함수](https://docs.databricks.com/aws/en/udf/unity-catalog)를 호출하여 사전 정의된 SQL과 맞춤형 비즈니스 로직을 거버넌스가 적용된 재사용 가능한 도구로 실행하세요.
|
||||
</Card>
|
||||
<Card title="Vector Search" icon="magnifying-glass">
|
||||
[Mosaic AI Vector Search](https://docs.databricks.com/aws/en/generative-ai/vector-search) 인덱스에서 의미 유사도를 사용해 RAG 및 지식 워크플로우에 필요한 관련 문서를 검색하세요.
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
모든 서버는 Unity AI Gateway 뒤에서 실행되며 Unity Catalog 접근 제어를 적용하므로, 에이전트는 허용된 데이터와 도구만 볼 수 있습니다.
|
||||
|
||||
## 사전 준비사항
|
||||
|
||||
Databricks 연동을 사용하기 전에 다음을 준비해야 합니다:
|
||||
|
||||
- 활성 구독이 있는 [CrewAI AMP](https://app.crewai.com) 계정
|
||||
- 노출하려는 기능이 있는 Databricks 워크스페이스(Genie Spaces, SQL 웨어하우스, Unity Catalog 함수 또는 Vector Search 인덱스)
|
||||
- 기본 객체에 대한 적절한 [Unity Catalog 권한](https://docs.databricks.com/aws/en/data-governance/unity-catalog)
|
||||
- Databricks 워크스페이스 호스트명(예: `your-workspace.cloud.databricks.com`)
|
||||
|
||||
## Databricks 관리형 MCP 서버
|
||||
|
||||
Databricks는 각 기능마다 별도의 관리형 MCP 서버를 게시합니다. CrewAI는 이를 개별 연결로 노출하며, 각 연결은 워크스페이스 호스트와 관련 Unity Catalog 식별자로 구성됩니다. 엔드포인트는 다음 패턴을 따릅니다:
|
||||
|
||||
| 서버 | 기능 | MCP URL 패턴 |
|
||||
|------|------|--------------|
|
||||
| **Genie** | Genie Space에 대한 자연어 Q&A | `https://<workspace-hostname>/api/2.0/mcp/genie/{genie_space_id}` |
|
||||
| **Databricks SQL** | 웨어하우스에 SQL 실행 | `https://<workspace-hostname>/api/2.0/mcp/sql` |
|
||||
| **Unity Catalog Functions** | 등록된 UC 함수 실행 | `https://<workspace-hostname>/api/2.0/mcp/functions/{catalog}/{schema}` |
|
||||
| **Vector Search** | Vector Search 인덱스 조회 | `https://<workspace-hostname>/api/2.0/mcp/vector-search/{catalog}/{schema}` |
|
||||
|
||||
<Note>
|
||||
이러한 URL을 직접 만들 필요는 없습니다. CrewAI는 연결을 구성할 때 입력한 워크스페이스 호스트와 식별자(Genie Space ID 또는 catalog/schema)로 각 엔드포인트를 생성합니다. 전체 사양과 최신 엔드포인트 세부 정보는 [Databricks 관리형 MCP 문서](https://docs.databricks.com/aws/en/generative-ai/mcp/managed-mcp)를 참고하세요.
|
||||
</Note>
|
||||
|
||||
## CrewAI AMP에서 Databricks 연결하기
|
||||
|
||||
<Frame>
|
||||
<img src="/images/enterprise/databricks-configure.png" alt="CrewAI AMP에서 Databricks 관리형 MCP 서버 구성" />
|
||||
</Frame>
|
||||
|
||||
각 Databricks 기능(**Databricks Genie**, **Databricks SQL**, **Databricks Unity Catalog Functions**, **Databricks Vector Search**)은 **Tools & Integrations** 페이지의 Databricks 그룹 아래에 별도의 MCP 서버로 표시됩니다. 필요한 것을 구성하세요:
|
||||
|
||||
<Steps>
|
||||
<Step title="Tools & Integrations 열기">
|
||||
CrewAI AMP 왼쪽 사이드바에서 **Tools & Integrations**로 이동하여 Connections 목록에서 **Databricks** 그룹을 찾습니다. 그 아래에 Genie, SQL, Unity Catalog Functions, Vector Search 서버가 나열됩니다.
|
||||
</Step>
|
||||
|
||||
<Step title="서버 구성하기">
|
||||
활성화하려는 기능 옆의 **Configure**를 클릭하고 연결 세부 정보를 입력합니다:
|
||||
|
||||
- **Workspace Host** — Databricks 워크스페이스 호스트명(예: `my-workspace.cloud.databricks.com`).
|
||||
- **Genie** — 조회할 **Genie Space ID**.
|
||||
- **Unity Catalog Functions** — 함수가 포함된 **catalog**와 **schema**.
|
||||
- **Vector Search** — 인덱스가 포함된 **catalog**와 **schema**.
|
||||
- **Databricks SQL** — 추가 식별자가 필요 없으며, 쿼리는 워크스페이스의 SQL 웨어하우스에서 실행됩니다.
|
||||
</Step>
|
||||
|
||||
<Step title="인증 방법 선택하기">
|
||||
CrewAI가 Databricks에 인증하는 방법을 선택합니다. **OAuth**를 권장합니다.
|
||||
|
||||
- **Use OAuth** — OAuth 2.0으로 안전하게 연결합니다. 각 사용자가 개별적으로 인증하며, Databricks는 해당 기능(`genie`, `sql`, `unity-catalog` 또는 `vector-search`)으로 범위가 지정된 토큰을 발급합니다. CrewAI가 인증 흐름을 처리하고 토큰을 자동으로 갱신합니다.
|
||||
- **Use personal access token** — [Databricks 개인 액세스 토큰](https://docs.databricks.com/aws/en/dev-tools/auth/pat)으로 인증합니다. 노출을 제한하려면 최소 권한 ID를 사용하세요.
|
||||
</Step>
|
||||
|
||||
<Step title="인증하기">
|
||||
인증을 완료합니다. 연결되면 해당 서버의 도구를 크루에서 사용할 수 있습니다. 활성화하려는 다른 Databricks 기능에 대해서도 반복합니다.
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
<Tip>
|
||||
각 기능이 별도의 연결이므로 자유롭게 조합할 수 있습니다. 예를 들어 리서치 크루에는 Genie와 Vector Search를 활성화하고, 데이터 엔지니어링 크루에는 SQL과 Unity Catalog Functions를 사용하도록 할 수 있습니다. 가시성(Visibility) 설정으로 각 기능을 사용할 수 있는 팀원을 제어할 수 있습니다.
|
||||
</Tip>
|
||||
|
||||
## 크루에서 Databricks 도구 사용하기
|
||||
|
||||
연결되면 각 MCP 서버가 노출하는 도구가 **Tools & Integrations** 페이지의 기본 제공 연결과 함께 표시됩니다. 다음을 할 수 있습니다:
|
||||
|
||||
- 다른 CrewAI 도구와 마찬가지로 크루의 에이전트에 **도구 할당**.
|
||||
- 각 연결을 사용할 수 있는 팀원을 제어하는 **가시성 관리**.
|
||||
- Connections 목록에서 언제든지 연결 **편집 또는 제거**.
|
||||
|
||||
이제 에이전트는 Genie에 근거 있는 답변을 요청하고, 웨어하우스에 SQL을 실행하며, Unity Catalog 함수를 호출하고, Vector Search 인덱스를 검색할 수 있으며, 그 결과가 자동으로 추론에 반영됩니다.
|
||||
|
||||
<Warning>
|
||||
Databricks는 Unity Catalog와 Unity AI Gateway를 통해 거버넌스를 적용합니다. 사용자는 워크스페이스 ID에 허용된 도구만 검색하고 호출할 수 있습니다. 도구 호출이 실패하면 연결하는 사용자(또는 토큰 ID)가 Genie Space, 웨어하우스, 함수 또는 인덱스에 필요한 Unity Catalog 권한을 가지고 있는지 확인하세요. 일부 Genie 및 SQL 쿼리는 비동기로 실행되어 결과를 반환하는 데 시간이 걸릴 수 있습니다.
|
||||
</Warning>
|
||||
|
||||
## 더 알아보기
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="Databricks 관리형 MCP 서버" icon="layer-group" href="https://docs.databricks.com/aws/en/generative-ai/mcp/managed-mcp">
|
||||
관리형 Genie, SQL, Unity Catalog Functions, Vector Search MCP 서버에 대한 공식 Databricks 문서입니다.
|
||||
</Card>
|
||||
<Card title="CrewAI의 커스텀 MCP 서버" icon="plug" href="/ko/enterprise/guides/custom-mcp-server">
|
||||
Databricks 연동의 기반이 되는, CrewAI가 모든 MCP 서버에 연결하는 방법을 알아보세요.
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
<Card title="도움이 필요하신가요?" icon="headset" href="mailto:support@crewai.com">
|
||||
Databricks 연동 구성 또는 문제 해결에 대한 지원이 필요하면 지원팀에 문의하세요.
|
||||
</Card>
|
||||
134
docs/ko/enterprise/integrations/snowflake.mdx
Normal file
134
docs/ko/enterprise/integrations/snowflake.mdx
Normal file
@@ -0,0 +1,134 @@
|
||||
---
|
||||
title: Snowflake 연동
|
||||
description: "Snowflake 관리형 MCP 서버를 통해 CrewAI 에이전트를 Snowflake Cortex Analyst, Cortex Search 및 SQL 실행에 연결합니다."
|
||||
icon: "snowflake"
|
||||
mode: "wide"
|
||||
---
|
||||
|
||||
## 개요
|
||||
|
||||
[Snowflake 관리형 MCP 서버](https://docs.snowflake.com/en/user-guide/snowflake-cortex/cortex-agents-mcp)를 통해 CrewAI 에이전트를 Snowflake 데이터에 직접 연결하세요. Snowflake 연동을 사용하면 에이전트가 **Cortex Analyst**로 구조화된 데이터를 쿼리하고, **Cortex Search**로 비구조화된 데이터를 검색하며, 커넥터 코드를 작성하거나 호스팅할 필요 없이 웨어하우스에 대해 관리되는 SQL을 실행할 수 있습니다.
|
||||
|
||||
내부적으로 Snowflake 연동은 CrewAI의 [Custom MCP Server](/ko/enterprise/guides/custom-mcp-server) 지원을 기반으로 하는 관리형 래퍼입니다. Snowflake는 [Model Context Protocol](https://modelcontextprotocol.io/) 엔드포인트를 통해 Cortex AI 기능을 노출하며, CrewAI가 이를 안전하게 연결합니다. Snowflake 측에서 노출하는 모든 도구 — Cortex Analyst, Cortex Search, SQL 실행, Cortex Agents 또는 사용자 정의 도구 — 가 크루에서 사용할 수 있게 됩니다.
|
||||
|
||||
## 주요 기능
|
||||
|
||||
<CardGroup cols={3}>
|
||||
<Card title="Cortex Analyst" icon="chart-bar">
|
||||
자연어로 질문하고 [Cortex Analyst](https://docs.snowflake.com/en/user-guide/snowflake-cortex/cortex-analyst)가 풍부한 시맨틱 모델을 사용하여 **구조화된** 데이터에 대해 SQL을 생성하고 실행하도록 합니다.
|
||||
</Card>
|
||||
<Card title="Cortex Search" icon="magnifying-glass">
|
||||
Snowflake의 완전 관리형 검색 서비스인 [Cortex Search](https://docs.snowflake.com/en/user-guide/snowflake-cortex/cortex-search/cortex-search-overview)를 사용하여 RAG 및 지식 워크플로우를 위한 관련 **비구조화된** 데이터를 검색합니다.
|
||||
</Card>
|
||||
<Card title="SQL 실행" icon="database">
|
||||
구성 가능한 읽기 전용 모드, 타임아웃 및 웨어하우스 선택을 통해 Snowflake 웨어하우스에 대해 관리되는 SQL 쿼리를 직접 실행합니다.
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
연동이 MCP 서버가 게시하는 도구를 노출하므로, **Cortex Agents** 및 **사용자 정의 도구**(사용자 정의 함수 및 저장 프로시저)도 CrewAI 에이전트에 노출할 수 있습니다.
|
||||
|
||||
## 사전 준비 사항
|
||||
|
||||
Snowflake 연동을 사용하기 전에 다음을 확인하십시오:
|
||||
|
||||
- 활성 구독이 있는 [CrewAI AMP](https://app.crewai.com) 계정
|
||||
- Cortex AI 기능에 액세스할 수 있는 Snowflake 계정
|
||||
- 노출하려는 도구가 구성된 [Snowflake 관리형 MCP 서버](https://docs.snowflake.com/en/user-guide/snowflake-cortex/cortex-agents-mcp)
|
||||
- MCP 서버 및 기본 객체에 대한 적절한 Snowflake 권한(USAGE/SELECT)
|
||||
|
||||
## Snowflake MCP 서버 설정
|
||||
|
||||
Snowflake 관리형 MCP 서버는 Snowflake 계정 내에서 실행되며 CrewAI와 같은 외부 클라이언트에서 사용할 수 있는 도구를 정의합니다. [`CREATE MCP SERVER`](https://docs.snowflake.com/en/sql-reference/sql/create-mcp-server) 명령을 사용하여 노출하려는 Cortex Search 서비스, Cortex Analyst 시맨틱 뷰 및 SQL 도구를 나열하여 생성합니다.
|
||||
|
||||
```sql
|
||||
CREATE MCP SERVER my_mcp_server
|
||||
FROM SPECIFICATION $$
|
||||
tools:
|
||||
- name: "sales_analyst"
|
||||
type: "CORTEX_ANALYST"
|
||||
identifier: "MY_DATABASE.MY_SCHEMA.sales_semantic_view"
|
||||
description: "Answer questions about sales metrics"
|
||||
- name: "docs_search"
|
||||
type: "CORTEX_SEARCH_SERVICE_QUERY"
|
||||
identifier: "MY_DATABASE.MY_SCHEMA.support_docs_search"
|
||||
description: "Search internal support documentation"
|
||||
- name: "run_sql"
|
||||
type: "SQL_EXECUTION"
|
||||
description: "Execute read-only SQL queries"
|
||||
$$;
|
||||
```
|
||||
|
||||
<Note>
|
||||
MCP 엔드포인트는 `https://<account_URL>/api/v2/databases/{database}/schemas/{schema}/mcp-servers/{name}` 형식을 따릅니다. CrewAI는 연동 구성 시 제공하는 **계정 URL**, **데이터베이스**, **스키마** 및 **MCP 서버 이름**을 사용하여 이 URL을 자동으로 구성합니다.
|
||||
</Note>
|
||||
|
||||
Cortex Agents, 사용자 정의 도구, 응답 크기 제한 및 거버넌스 옵션을 포함한 전체 사양은 [Snowflake 관리형 MCP 서버 문서](https://docs.snowflake.com/en/user-guide/snowflake-cortex/cortex-agents-mcp)를 참조하세요.
|
||||
|
||||
## CrewAI AMP에서 Snowflake 연결
|
||||
|
||||
<Frame>
|
||||
<img src="/images/enterprise/snowflake-configure.png" alt="CrewAI AMP에서 Snowflake 연동 구성" />
|
||||
</Frame>
|
||||
|
||||
<Steps>
|
||||
<Step title="도구 및 연동 열기">
|
||||
CrewAI AMP 왼쪽 사이드바에서 **도구 및 연동**으로 이동하고, 애플리케이션 목록에서 **Snowflake**를 찾아 구성 패널을 엽니다.
|
||||
</Step>
|
||||
|
||||
<Step title="연결 세부 정보 제공">
|
||||
CrewAI가 Snowflake MCP 서버에 연결하는 데 사용하는 연결 필드를 채웁니다:
|
||||
|
||||
| 필드 | 필수 | 설명 |
|
||||
|------|------|------|
|
||||
| **이름** | 예 | 이 연결의 설명적 이름(기본값: `Snowflake`). |
|
||||
| **설명** | 아니오 | 이 연결이 제공하는 내용에 대한 선택적 요약. |
|
||||
| **계정 URL** | 예 | Snowflake 계정 URL, 예: `xy12345.us-east-1.snowflakecomputing.com`. |
|
||||
| **데이터베이스** | 예 | MCP 서버가 포함된 데이터베이스(예: `MY_DATABASE`). |
|
||||
| **스키마** | 예 | MCP 서버가 포함된 스키마(예: `MY_SCHEMA`). |
|
||||
| **MCP 서버 이름** | 예 | Snowflake에서 생성한 MCP 서버 객체의 이름(예: `MY_MCP_SERVER`). |
|
||||
</Step>
|
||||
|
||||
<Step title="인증 방법 선택">
|
||||
CrewAI가 Snowflake에 인증하는 방법을 선택합니다. **OAuth**가 권장됩니다.
|
||||
|
||||
- **OAuth 사용** — 자격 증명을 공유하지 않고 토큰 기반 인증을 위해 OAuth 2.0을 사용하여 안전하게 연결합니다. CrewAI가 전체 인증 흐름을 처리하고 자동으로 토큰을 갱신합니다. 양식에 표시된 **리디렉트 URI**(`https://oauth.crewai.com/oauth/add`)를 복사하여 Snowflake [OAuth 보안 연동](https://docs.snowflake.com/en/user-guide/oauth-custom)에 인증된 리디렉트 URI로 등록하세요.
|
||||
- **개인 액세스 토큰 사용** — Snowflake 계정 설정에서 생성한 [프로그래밍 방식 액세스 토큰](https://docs.snowflake.com/en/user-guide/programmatic-access-tokens)을 사용하여 인증합니다. 노출을 제한하기 위해 토큰에 최소 권한 역할을 할당하세요.
|
||||
</Step>
|
||||
|
||||
<Step title="인증">
|
||||
**인증**을 클릭합니다. OAuth의 경우 Snowflake로 리디렉션되어 액세스를 승인합니다. 인증되면 Snowflake 서버가 연결 목록에 나타나고 해당 도구를 크루에서 사용할 수 있게 됩니다.
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
<Tip>
|
||||
OAuth를 사용하면 각 사용자가 개별적으로 인증하며 쿼리는 해당 Snowflake `DEFAULT_ROLE`로 실행됩니다. 연결하는 사용자에게 기본 역할과 웨어하우스가 설정되어 있는지 확인하세요(`ALTER USER <username> SET DEFAULT_ROLE = '<role>' DEFAULT_WAREHOUSE = '<warehouse>'`). 그래야 Cortex Analyst 및 SQL 도구에 실행할 컴퓨팅이 있습니다.
|
||||
</Tip>
|
||||
|
||||
## 크루에서 Snowflake 도구 사용
|
||||
|
||||
연결되면 MCP 서버가 노출하는 도구가 **도구 및 연동** 페이지에서 기본 연결과 함께 표시됩니다. 다음을 수행할 수 있습니다:
|
||||
|
||||
- 다른 CrewAI 도구처럼 크루의 **에이전트에 도구를 할당**합니다.
|
||||
- **가시성을 관리**하여 어떤 팀원이 연결을 사용할 수 있는지 제어합니다.
|
||||
- 연결 목록에서 언제든지 연결을 **편집하거나 제거**합니다.
|
||||
|
||||
이제 에이전트가 Cortex Analyst에 메트릭을 요청하고, 문서에 대해 Cortex Search를 실행하고, SQL을 실행할 수 있으며 — 결과가 자동으로 추론에 반영됩니다.
|
||||
|
||||
<Warning>
|
||||
Snowflake는 MCP 서버에 거버넌스를 적용합니다: 역할 기반 액세스 제어가 사용자가 발견하고 호출할 수 있는 도구를 결정하며, 응답 크기, 도구 수(서버당 최대 50개) 및 재귀 깊이에 제한이 적용됩니다. 도구 호출이 실패하면 연결하는 사용자의 역할에 MCP 서버 및 기본 객체에 대한 필수 권한이 있는지 확인하세요.
|
||||
</Warning>
|
||||
|
||||
## 자세히 알아보기
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="Snowflake 관리형 MCP 서버" icon="snowflake" href="https://docs.snowflake.com/en/user-guide/snowflake-cortex/cortex-agents-mcp">
|
||||
MCP 서버를 생성하고 관리하기 위한 공식 Snowflake 문서.
|
||||
</Card>
|
||||
<Card title="CrewAI의 Custom MCP 서버" icon="plug" href="/ko/enterprise/guides/custom-mcp-server">
|
||||
CrewAI가 모든 MCP 서버에 연결하는 방법을 알아보세요. Snowflake 연동이 기반으로 하는 기초입니다.
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
<Card title="도움이 필요하신가요?" icon="headset" href="mailto:support@crewai.com">
|
||||
Snowflake 연동 또는 문제 해결에 대해 지원팀에 문의하세요.
|
||||
</Card>
|
||||
@@ -106,7 +106,7 @@ Existem diferentes locais no código do CrewAI onde você pode especificar o mod
|
||||
</Tabs>
|
||||
|
||||
<Info>
|
||||
O CrewAI oferece integrações nativas via SDK para OpenAI, Anthropic, Google (Gemini API), Azure e AWS Bedrock — sem necessidade de instalação extra além dos extras específicos do provedor (ex.: `uv add "crewai[openai]"`).
|
||||
O CrewAI oferece integrações nativas via SDK para OpenAI, Anthropic, Google (Gemini API), Azure, AWS Bedrock e Snowflake Cortex — sem necessidade de instalação extra além dos extras específicos do provedor (ex.: `uv add "crewai[openai]"`).
|
||||
|
||||
Todos os outros provedores são alimentados pelo **LiteLLM**. Se você planeja usar algum deles, adicione-o como dependência ao seu projeto:
|
||||
```bash
|
||||
@@ -230,6 +230,55 @@ Nesta seção, você encontrará exemplos detalhados que ajudam a selecionar, co
|
||||
```
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Snowflake Cortex">
|
||||
O CrewAI oferece integração nativa com a API REST do Snowflake Cortex pelo endpoint Chat Completions compatível com OpenAI. Isso evita fallback para LiteLLM em modelos `snowflake/...`. Atualmente, o Snowflake Cortex no CrewAI oferece suporte apenas a Chat Completions, então use o modo `api` padrão e não defina `api="responses"`.
|
||||
|
||||
```toml Code
|
||||
# Obrigatório
|
||||
SNOWFLAKE_PAT=<your-programmatic-access-token>
|
||||
SNOWFLAKE_ACCOUNT_URL=https://<account-identifier>.snowflakecomputing.com
|
||||
|
||||
# Configuração alternativa da conta
|
||||
SNOWFLAKE_ACCOUNT=<account-identifier>
|
||||
```
|
||||
|
||||
**Uso básico:**
|
||||
```python Code
|
||||
from crewai import LLM
|
||||
|
||||
llm = LLM(
|
||||
model="snowflake/openai-gpt-4.1",
|
||||
temperature=0.7,
|
||||
max_completion_tokens=1024,
|
||||
)
|
||||
```
|
||||
|
||||
**Modelos Claude no Cortex:**
|
||||
```python Code
|
||||
from crewai import LLM
|
||||
|
||||
llm = LLM(
|
||||
model="snowflake/claude-sonnet-4-5",
|
||||
max_completion_tokens=1024,
|
||||
stream=True,
|
||||
)
|
||||
```
|
||||
|
||||
**Variáveis de ambiente suportadas:**
|
||||
- `SNOWFLAKE_PAT`, `SNOWFLAKE_TOKEN` ou `SNOWFLAKE_JWT`: token usado como credencial Bearer
|
||||
- `SNOWFLAKE_ACCOUNT_URL`: URL completa da conta Snowflake
|
||||
- `SNOWFLAKE_ACCOUNT`, `SNOWFLAKE_ACCOUNT_ID` ou `SNOWFLAKE_ACCOUNT_IDENTIFIER`: identificador da conta usado para montar a URL
|
||||
|
||||
As requisições REST do Snowflake usam a role padrão do usuário. Garanta que essa role tenha `SNOWFLAKE.CORTEX_USER` ou `SNOWFLAKE.CORTEX_REST_API_USER`. Parâmetros de banco de dados, schema, warehouse e role explícita não são exigidos pelo endpoint Cortex REST Chat Completions.
|
||||
|
||||
**Recursos:**
|
||||
- Seleção nativa com `model="snowflake/<model-name>"`
|
||||
- Chat Completions com e sem streaming apenas; `api="responses"` não é compatível
|
||||
- Rastreamento de uso de tokens
|
||||
- Chamadas de função para modelos OpenAI e Claude hospedados no Snowflake
|
||||
- Remoção automática de prefill final de assistant inválido para modelos Claude no Snowflake
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Anthropic">
|
||||
```toml Code
|
||||
# Obrigatório
|
||||
|
||||
123
docs/pt-BR/enterprise/integrations/databricks.mdx
Normal file
123
docs/pt-BR/enterprise/integrations/databricks.mdx
Normal file
@@ -0,0 +1,123 @@
|
||||
---
|
||||
title: Integração com Databricks
|
||||
description: "Conecte agentes CrewAI ao Databricks Genie, SQL, Unity Catalog Functions e Vector Search por meio dos servidores MCP gerenciados do Databricks."
|
||||
icon: "layer-group"
|
||||
mode: "wide"
|
||||
---
|
||||
|
||||
## Visão geral
|
||||
|
||||
Conecte seus agentes CrewAI diretamente ao seu workspace do Databricks por meio dos [servidores MCP gerenciados do Databricks](https://docs.databricks.com/aws/en/generative-ai/mcp/managed-mcp). A integração com o Databricks permite que seus agentes façam perguntas em linguagem natural com o **Genie**, executem **SQL** governado, chamem **Unity Catalog Functions** e recuperem documentos com o **Vector Search** — tudo sem escrever ou hospedar qualquer código de conector, e com as permissões do Unity Catalog aplicadas em cada chamada.
|
||||
|
||||
Nos bastidores, a integração com o Databricks é um wrapper gerenciado sobre o suporte a [Servidores MCP personalizados](/pt-BR/enterprise/guides/custom-mcp-server) do CrewAI. O Databricks expõe cada recurso como seu próprio endpoint do [Model Context Protocol](https://modelcontextprotocol.io/), e o CrewAI se conecta a eles com segurança em seu nome. Como cada servidor é adicionado separadamente, você pode habilitar exatamente os recursos de que suas crews precisam.
|
||||
|
||||
## Principais recursos
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="Genie" icon="comments">
|
||||
Faça perguntas em linguagem natural e obtenha respostas fundamentadas em seus dados com o [Genie](https://docs.databricks.com/aws/en/genie/), que consulta Genie Spaces e o Unity Catalog e fornece links de volta para a interface do Databricks.
|
||||
</Card>
|
||||
<Card title="Databricks SQL" icon="database">
|
||||
Execute SQL governado nos seus warehouses do Databricks para consultar, transformar e criar pipelines de dados diretamente a partir dos seus agentes.
|
||||
</Card>
|
||||
<Card title="Unity Catalog Functions" icon="function">
|
||||
Invoque [funções do Unity Catalog](https://docs.databricks.com/aws/en/udf/unity-catalog) para executar SQL predefinido e lógica de negócio personalizada como ferramentas governadas e reutilizáveis.
|
||||
</Card>
|
||||
<Card title="Vector Search" icon="magnifying-glass">
|
||||
Recupere documentos relevantes para fluxos de RAG e de conhecimento a partir de índices do [Mosaic AI Vector Search](https://docs.databricks.com/aws/en/generative-ai/vector-search) usando similaridade semântica.
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
Todos os servidores são executados por trás do Unity AI Gateway e aplicam os controles de acesso do Unity Catalog, de modo que seus agentes só enxergam os dados e as ferramentas que têm permissão para usar.
|
||||
|
||||
## Pré-requisitos
|
||||
|
||||
Antes de usar a integração com o Databricks, certifique-se de ter:
|
||||
|
||||
- Uma conta [CrewAI AMP](https://app.crewai.com) com assinatura ativa
|
||||
- Um workspace do Databricks com os recursos que você deseja expor (Genie Spaces, warehouses SQL, funções do Unity Catalog ou índices do Vector Search)
|
||||
- [Privilégios apropriados do Unity Catalog](https://docs.databricks.com/aws/en/data-governance/unity-catalog) nos objetos subjacentes
|
||||
- O hostname do seu workspace do Databricks (ex.: `your-workspace.cloud.databricks.com`)
|
||||
|
||||
## Servidores MCP gerenciados do Databricks
|
||||
|
||||
O Databricks publica um servidor MCP gerenciado separado para cada recurso. O CrewAI os expõe como conexões individuais, cada uma configurada com o host do seu workspace e os identificadores relevantes do Unity Catalog. Os endpoints seguem estes padrões:
|
||||
|
||||
| Servidor | O que faz | Padrão de URL MCP |
|
||||
|----------|-----------|-------------------|
|
||||
| **Genie** | Perguntas e respostas em linguagem natural sobre um Genie Space | `https://<workspace-hostname>/api/2.0/mcp/genie/{genie_space_id}` |
|
||||
| **Databricks SQL** | Executa SQL nos seus warehouses | `https://<workspace-hostname>/api/2.0/mcp/sql` |
|
||||
| **Unity Catalog Functions** | Executa funções UC registradas | `https://<workspace-hostname>/api/2.0/mcp/functions/{catalog}/{schema}` |
|
||||
| **Vector Search** | Consulta um índice do Vector Search | `https://<workspace-hostname>/api/2.0/mcp/vector-search/{catalog}/{schema}` |
|
||||
|
||||
<Note>
|
||||
Você não precisa construir essas URLs manualmente — o CrewAI cria cada endpoint a partir do host do workspace e dos identificadores (Genie Space ID, ou catalog/schema) que você fornece ao configurar a conexão. Para a especificação completa e os detalhes mais recentes dos endpoints, consulte a [documentação de MCP gerenciado do Databricks](https://docs.databricks.com/aws/en/generative-ai/mcp/managed-mcp).
|
||||
</Note>
|
||||
|
||||
## Conectando o Databricks no CrewAI AMP
|
||||
|
||||
<Frame>
|
||||
<img src="/images/enterprise/databricks-configure.png" alt="Configurar um servidor MCP gerenciado do Databricks no CrewAI AMP" />
|
||||
</Frame>
|
||||
|
||||
Cada recurso do Databricks — **Databricks Genie**, **Databricks SQL**, **Databricks Unity Catalog Functions** e **Databricks Vector Search** — aparece como seu próprio servidor MCP no grupo Databricks da página **Tools & Integrations**. Configure os que você precisar:
|
||||
|
||||
<Steps>
|
||||
<Step title="Abra Tools & Integrations">
|
||||
Navegue até **Tools & Integrations** na barra lateral esquerda do CrewAI AMP e localize o grupo **Databricks** na lista de Connections. Você verá os servidores Genie, SQL, Unity Catalog Functions e Vector Search listados abaixo dele.
|
||||
</Step>
|
||||
|
||||
<Step title="Configure um servidor">
|
||||
Clique em **Configure** ao lado do recurso que deseja habilitar e forneça os detalhes da conexão:
|
||||
|
||||
- **Workspace Host** — o hostname do seu workspace do Databricks (ex.: `my-workspace.cloud.databricks.com`).
|
||||
- **Genie** — o **Genie Space ID** a ser consultado.
|
||||
- **Unity Catalog Functions** — o **catalog** e o **schema** que contêm suas funções.
|
||||
- **Vector Search** — o **catalog** e o **schema** que contêm seu índice.
|
||||
- **Databricks SQL** — sem identificadores adicionais; as consultas são executadas nos warehouses SQL do seu workspace.
|
||||
</Step>
|
||||
|
||||
<Step title="Escolha um método de autenticação">
|
||||
Selecione como o CrewAI se autentica no Databricks. **OAuth** é recomendado.
|
||||
|
||||
- **Use OAuth** — Conecte-se com segurança usando OAuth 2.0. Cada usuário se autentica individualmente, e o Databricks emite tokens com escopo para o recurso (`genie`, `sql`, `unity-catalog` ou `vector-search`). O CrewAI gerencia o fluxo de autorização e renova os tokens automaticamente.
|
||||
- **Use personal access token** — Autentique-se com um [token de acesso pessoal do Databricks](https://docs.databricks.com/aws/en/dev-tools/auth/pat). Use uma identidade com privilégios mínimos para limitar a exposição.
|
||||
</Step>
|
||||
|
||||
<Step title="Autentique">
|
||||
Conclua a autenticação. Uma vez conectado, as ferramentas do servidor ficam disponíveis para suas crews. Repita para qualquer outro recurso do Databricks que você queira habilitar.
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
<Tip>
|
||||
Como cada recurso é uma conexão separada, você pode combiná-los livremente — por exemplo, habilitar Genie e Vector Search para uma crew de pesquisa e reservar SQL e Unity Catalog Functions para uma crew de engenharia de dados. As configurações de visibilidade permitem controlar quais membros da equipe podem usar cada um.
|
||||
</Tip>
|
||||
|
||||
## Usando as ferramentas do Databricks nas suas crews
|
||||
|
||||
Uma vez conectado, as ferramentas que cada servidor MCP expõe aparecem junto às conexões integradas na página **Tools & Integrations**. Você pode:
|
||||
|
||||
- **Atribuir ferramentas aos agentes** nas suas crews, como qualquer outra ferramenta do CrewAI.
|
||||
- **Gerenciar a visibilidade** para controlar quais membros da equipe podem usar cada conexão.
|
||||
- **Editar ou remover** qualquer conexão a qualquer momento na lista de Connections.
|
||||
|
||||
Seus agentes agora podem pedir respostas fundamentadas ao Genie, executar SQL nos seus warehouses, chamar funções do Unity Catalog e pesquisar índices do Vector Search — com os resultados retornando automaticamente ao raciocínio deles.
|
||||
|
||||
<Warning>
|
||||
O Databricks aplica governança por meio do Unity Catalog e do Unity AI Gateway: um usuário só pode descobrir e invocar ferramentas que a identidade do seu workspace tem permissão para usar. Se uma chamada de ferramenta falhar, confirme se o usuário (ou a identidade do token) que está conectando tem os privilégios necessários do Unity Catalog no Genie Space, warehouse, função ou índice. Algumas consultas do Genie e do SQL são executadas de forma assíncrona e podem levar um momento para retornar resultados.
|
||||
</Warning>
|
||||
|
||||
## Saiba mais
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="Servidores MCP gerenciados do Databricks" icon="layer-group" href="https://docs.databricks.com/aws/en/generative-ai/mcp/managed-mcp">
|
||||
Documentação oficial do Databricks para os servidores MCP gerenciados Genie, SQL, Unity Catalog Functions e Vector Search.
|
||||
</Card>
|
||||
<Card title="Servidores MCP personalizados no CrewAI" icon="plug" href="/pt-BR/enterprise/guides/custom-mcp-server">
|
||||
Saiba como o CrewAI se conecta a qualquer servidor MCP, a base sobre a qual a integração com o Databricks é construída.
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
<Card title="Precisa de ajuda?" icon="headset" href="mailto:support@crewai.com">
|
||||
Entre em contato com nossa equipe de suporte para obter ajuda com a configuração da integração com o Databricks ou com a solução de problemas.
|
||||
</Card>
|
||||
134
docs/pt-BR/enterprise/integrations/snowflake.mdx
Normal file
134
docs/pt-BR/enterprise/integrations/snowflake.mdx
Normal file
@@ -0,0 +1,134 @@
|
||||
---
|
||||
title: Integração com Snowflake
|
||||
description: "Conecte agentes CrewAI ao Snowflake Cortex Analyst, Cortex Search e execução SQL através do servidor MCP gerenciado pelo Snowflake."
|
||||
icon: "snowflake"
|
||||
mode: "wide"
|
||||
---
|
||||
|
||||
## Visão Geral
|
||||
|
||||
Conecte seus agentes CrewAI diretamente aos seus dados no Snowflake através do [servidor MCP gerenciado pelo Snowflake](https://docs.snowflake.com/en/user-guide/snowflake-cortex/cortex-agents-mcp). A integração com o Snowflake permite que seus agentes consultem dados estruturados com **Cortex Analyst**, pesquisem dados não estruturados com **Cortex Search** e executem SQL governado nos seus warehouses — tudo sem escrever ou hospedar nenhum código de conector.
|
||||
|
||||
Internamente, a integração com o Snowflake é um wrapper gerenciado em torno do suporte a [Custom MCP Server](/pt-BR/enterprise/guides/custom-mcp-server) do CrewAI. O Snowflake expõe suas capacidades de Cortex AI através de um endpoint [Model Context Protocol](https://modelcontextprotocol.io/), e o CrewAI se conecta a ele de forma segura em seu nome. Qualquer ferramenta que você exponha no lado do Snowflake — Cortex Analyst, Cortex Search, execução SQL, Cortex Agents ou suas próprias ferramentas personalizadas — fica disponível para suas crews.
|
||||
|
||||
## Capacidades Principais
|
||||
|
||||
<CardGroup cols={3}>
|
||||
<Card title="Cortex Analyst" icon="chart-bar">
|
||||
Faça perguntas em linguagem natural e deixe o [Cortex Analyst](https://docs.snowflake.com/en/user-guide/snowflake-cortex/cortex-analyst) gerar e executar SQL nos seus dados **estruturados** usando modelos semânticos ricos.
|
||||
</Card>
|
||||
<Card title="Cortex Search" icon="magnifying-glass">
|
||||
Recupere dados **não estruturados** relevantes para fluxos de trabalho de RAG e conhecimento com o [Cortex Search](https://docs.snowflake.com/en/user-guide/snowflake-cortex/cortex-search/cortex-search-overview), o serviço de busca totalmente gerenciado do Snowflake.
|
||||
</Card>
|
||||
<Card title="Execução SQL" icon="database">
|
||||
Execute consultas SQL governadas diretamente nos seus warehouses Snowflake, com modo somente leitura configurável, timeouts e seleção de warehouse.
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
Como a integração expõe quaisquer ferramentas que seu servidor MCP publica, você também pode expor **Cortex Agents** e **ferramentas personalizadas** (funções definidas pelo usuário e stored procedures) para seus agentes CrewAI.
|
||||
|
||||
## Pré-requisitos
|
||||
|
||||
Antes de usar a integração com o Snowflake, certifique-se de que você tenha:
|
||||
|
||||
- Uma conta [CrewAI AMP](https://app.crewai.com) com assinatura ativa
|
||||
- Uma conta Snowflake com acesso aos recursos de Cortex AI
|
||||
- Um [servidor MCP gerenciado pelo Snowflake](https://docs.snowflake.com/en/user-guide/snowflake-cortex/cortex-agents-mcp) configurado com as ferramentas que você deseja expor
|
||||
- Privilégios Snowflake apropriados (USAGE/SELECT) no servidor MCP e seus objetos subjacentes
|
||||
|
||||
## Configurando o Servidor Snowflake MCP
|
||||
|
||||
O servidor MCP gerenciado pelo Snowflake é executado dentro da sua conta Snowflake e define quais ferramentas estão disponíveis para clientes externos como o CrewAI. Crie um com o comando [`CREATE MCP SERVER`](https://docs.snowflake.com/en/sql-reference/sql/create-mcp-server), listando os serviços Cortex Search, visualizações semânticas do Cortex Analyst e ferramentas SQL que você deseja expor.
|
||||
|
||||
```sql
|
||||
CREATE MCP SERVER my_mcp_server
|
||||
FROM SPECIFICATION $$
|
||||
tools:
|
||||
- name: "sales_analyst"
|
||||
type: "CORTEX_ANALYST"
|
||||
identifier: "MY_DATABASE.MY_SCHEMA.sales_semantic_view"
|
||||
description: "Answer questions about sales metrics"
|
||||
- name: "docs_search"
|
||||
type: "CORTEX_SEARCH_SERVICE_QUERY"
|
||||
identifier: "MY_DATABASE.MY_SCHEMA.support_docs_search"
|
||||
description: "Search internal support documentation"
|
||||
- name: "run_sql"
|
||||
type: "SQL_EXECUTION"
|
||||
description: "Execute read-only SQL queries"
|
||||
$$;
|
||||
```
|
||||
|
||||
<Note>
|
||||
O endpoint MCP segue o formato `https://<account_URL>/api/v2/databases/{database}/schemas/{schema}/mcp-servers/{name}`. O CrewAI constrói esta URL automaticamente a partir do **URL da Conta**, **Banco de Dados**, **Schema** e **Nome do Servidor MCP** que você fornece ao configurar a integração.
|
||||
</Note>
|
||||
|
||||
Para a especificação completa — incluindo Cortex Agents, ferramentas personalizadas, limites de tamanho de resposta e opções de governança — consulte a [documentação do servidor MCP gerenciado pelo Snowflake](https://docs.snowflake.com/en/user-guide/snowflake-cortex/cortex-agents-mcp).
|
||||
|
||||
## Conectando o Snowflake no CrewAI AMP
|
||||
|
||||
<Frame>
|
||||
<img src="/images/enterprise/snowflake-configure.png" alt="Configurar integração Snowflake no CrewAI AMP" />
|
||||
</Frame>
|
||||
|
||||
<Steps>
|
||||
<Step title="Abrir Ferramentas e Integrações">
|
||||
Navegue até **Ferramentas e Integrações** na barra lateral esquerda do CrewAI AMP, encontre **Snowflake** na lista de aplicações e abra seu painel de configuração.
|
||||
</Step>
|
||||
|
||||
<Step title="Fornecer detalhes da conexão">
|
||||
Preencha os campos de conexão que o CrewAI usa para acessar seu servidor Snowflake MCP:
|
||||
|
||||
| Campo | Obrigatório | Descrição |
|
||||
|-------|-------------|-----------|
|
||||
| **Nome** | Sim | Um nome descritivo para esta conexão (padrão: `Snowflake`). |
|
||||
| **Descrição** | Não | Um resumo opcional do que esta conexão fornece. |
|
||||
| **URL da Conta** | Sim | A URL da sua conta Snowflake, ex.: `xy12345.us-east-1.snowflakecomputing.com`. |
|
||||
| **Banco de Dados** | Sim | O banco de dados que contém seu servidor MCP (ex.: `MY_DATABASE`). |
|
||||
| **Schema** | Sim | O schema que contém seu servidor MCP (ex.: `MY_SCHEMA`). |
|
||||
| **Nome do Servidor MCP** | Sim | O nome do objeto de servidor MCP que você criou no Snowflake (ex.: `MY_MCP_SERVER`). |
|
||||
</Step>
|
||||
|
||||
<Step title="Escolher um método de autenticação">
|
||||
Selecione como o CrewAI se autentica no Snowflake. **OAuth** é recomendado.
|
||||
|
||||
- **Usar OAuth** — Conecte-se de forma segura usando OAuth 2.0 para autenticação baseada em tokens sem compartilhar suas credenciais. O CrewAI gerencia todo o fluxo de autorização e renova os tokens automaticamente. Copie o **URI de Redirecionamento** mostrado no formulário (`https://oauth.crewai.com/oauth/add`) e registre-o como um URI de redirecionamento autorizado na sua [integração de segurança OAuth](https://docs.snowflake.com/en/user-guide/oauth-custom) do Snowflake.
|
||||
- **Usar token de acesso pessoal** — Autentique usando um [token de acesso programático](https://docs.snowflake.com/en/user-guide/programmatic-access-tokens) gerado nas configurações da sua conta Snowflake. Atribua uma role com privilégios mínimos ao token para limitar a exposição.
|
||||
</Step>
|
||||
|
||||
<Step title="Autenticar">
|
||||
Clique em **Autenticar**. Para OAuth, você será redirecionado ao Snowflake para autorizar o acesso. Após autenticado, o servidor Snowflake aparece na sua lista de Conexões e suas ferramentas ficam disponíveis para suas crews.
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
<Tip>
|
||||
Com OAuth, cada usuário se autentica individualmente e as consultas são executadas com seu `DEFAULT_ROLE` do Snowflake. Certifique-se de que os usuários que se conectam tenham uma role e warehouse padrão definidos (`ALTER USER <username> SET DEFAULT_ROLE = '<role>' DEFAULT_WAREHOUSE = '<warehouse>'`) para que as ferramentas Cortex Analyst e SQL tenham capacidade de computação para execução.
|
||||
</Tip>
|
||||
|
||||
## Usando Ferramentas Snowflake nas Suas Crews
|
||||
|
||||
Uma vez conectado, as ferramentas que seu servidor MCP expõe aparecem junto com as conexões integradas na página **Ferramentas e Integrações**. Você pode:
|
||||
|
||||
- **Atribuir ferramentas a agentes** nas suas crews como qualquer outra ferramenta CrewAI.
|
||||
- **Gerenciar visibilidade** para controlar quais membros do time podem usar a conexão.
|
||||
- **Editar ou remover** a conexão a qualquer momento na lista de Conexões.
|
||||
|
||||
Seus agentes agora podem solicitar métricas ao Cortex Analyst, executar Cortex Search nos seus documentos e executar SQL — com os resultados fluindo automaticamente para o raciocínio deles.
|
||||
|
||||
<Warning>
|
||||
O Snowflake impõe governança no servidor MCP: o controle de acesso baseado em roles determina quais ferramentas um usuário pode descobrir e invocar, e limites se aplicam ao tamanho da resposta, contagem de ferramentas (máximo de 50 por servidor) e profundidade de recursão. Se uma chamada de ferramenta falhar, confirme que a role do usuário conectado possui os privilégios necessários no servidor MCP e seus objetos subjacentes.
|
||||
</Warning>
|
||||
|
||||
## Saiba Mais
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="Servidor MCP Gerenciado pelo Snowflake" icon="snowflake" href="https://docs.snowflake.com/en/user-guide/snowflake-cortex/cortex-agents-mcp">
|
||||
Documentação oficial do Snowflake para criar e governar o servidor MCP.
|
||||
</Card>
|
||||
<Card title="Servidores Custom MCP no CrewAI" icon="plug" href="/pt-BR/enterprise/guides/custom-mcp-server">
|
||||
Saiba como o CrewAI se conecta a qualquer servidor MCP, a base sobre a qual a integração Snowflake é construída.
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
<Card title="Precisa de Ajuda?" icon="headset" href="mailto:support@crewai.com">
|
||||
Entre em contato com nossa equipe de suporte para obter ajuda com a integração Snowflake ou solução de problemas.
|
||||
</Card>
|
||||
@@ -9,13 +9,13 @@ authors = [
|
||||
requires-python = ">=3.10, <3.14"
|
||||
dependencies = [
|
||||
"crewai-core==1.14.6",
|
||||
"click~=8.1.7",
|
||||
"click>=8.1.7,<9",
|
||||
"pydantic>=2.11.9,<2.13",
|
||||
"pydantic-settings~=2.10.1",
|
||||
"appdirs~=1.4.4",
|
||||
"cryptography>=42.0",
|
||||
"httpx~=0.28.1",
|
||||
"pyjwt>=2.9.0,<3",
|
||||
"pyjwt>=2.13.0,<3",
|
||||
"rich>=13.7.1",
|
||||
"tomli~=2.0.2",
|
||||
"tomli-w~=1.1.0",
|
||||
|
||||
@@ -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.5a2"
|
||||
"crewai[tools]==1.14.6"
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
|
||||
@@ -5,7 +5,7 @@ description = "{{name}} using crewAI"
|
||||
authors = [{ name = "Your Name", email = "you@example.com" }]
|
||||
requires-python = ">=3.10,<3.14"
|
||||
dependencies = [
|
||||
"crewai[tools]==1.14.5a2"
|
||||
"crewai[tools]==1.14.6"
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
|
||||
@@ -5,7 +5,7 @@ description = "Power up your crews with {{folder_name}}"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10,<3.14"
|
||||
dependencies = [
|
||||
"crewai[tools]==1.14.5a2"
|
||||
"crewai[tools]==1.14.6"
|
||||
]
|
||||
|
||||
[tool.crewai]
|
||||
|
||||
131
lib/cli/tests/test_click_compatibility.py
Normal file
131
lib/cli/tests/test_click_compatibility.py
Normal file
@@ -0,0 +1,131 @@
|
||||
"""Tests for click dependency compatibility.
|
||||
|
||||
Regression tests for https://github.com/crewAIInc/crewAI/issues/6002
|
||||
The click dependency was previously pinned to ~=8.1.7 (i.e. >=8.1.7,<8.2.0)
|
||||
which prevented users from upgrading to click 8.2+ as required by their
|
||||
security policies. The constraint has been widened to >=8.1.7,<9 to allow
|
||||
newer click 8.x releases while still guarding against a future major version
|
||||
break.
|
||||
"""
|
||||
|
||||
from importlib.metadata import requires
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
import pytest
|
||||
from click.testing import CliRunner
|
||||
from packaging.requirements import Requirement
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Verify the runtime click version satisfies the declared constraint
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _get_click_requirement_from_pyproject(package_dir: str) -> Requirement:
|
||||
"""Parse the click requirement directly from a pyproject.toml file."""
|
||||
import tomli
|
||||
|
||||
pyproject_path = Path(__file__).resolve().parents[3] / package_dir / "pyproject.toml"
|
||||
with open(pyproject_path, "rb") as f:
|
||||
data = tomli.load(f)
|
||||
deps = data["project"]["dependencies"]
|
||||
for dep in deps:
|
||||
req = Requirement(dep)
|
||||
if req.name == "click":
|
||||
return req
|
||||
raise ValueError(f"click not found in {pyproject_path}")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"package_dir",
|
||||
[
|
||||
"lib/crewai",
|
||||
"lib/cli",
|
||||
"lib/devtools",
|
||||
],
|
||||
)
|
||||
def test_click_constraint_allows_8_3_3(package_dir: str):
|
||||
"""The declared click constraint must accept click 8.3.3 (issue #6002)."""
|
||||
req = _get_click_requirement_from_pyproject(package_dir)
|
||||
# packaging's Requirement.specifier supports `__contains__` for version checks
|
||||
assert "8.3.3" in req.specifier, (
|
||||
f"{package_dir}: click constraint {req.specifier} does not allow 8.3.3"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"package_dir",
|
||||
[
|
||||
"lib/crewai",
|
||||
"lib/cli",
|
||||
"lib/devtools",
|
||||
],
|
||||
)
|
||||
def test_click_constraint_allows_8_1_7(package_dir: str):
|
||||
"""The declared click constraint must still accept the original minimum (8.1.7)."""
|
||||
req = _get_click_requirement_from_pyproject(package_dir)
|
||||
assert "8.1.7" in req.specifier, (
|
||||
f"{package_dir}: click constraint {req.specifier} does not allow 8.1.7"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"package_dir",
|
||||
[
|
||||
"lib/crewai",
|
||||
"lib/cli",
|
||||
"lib/devtools",
|
||||
],
|
||||
)
|
||||
def test_click_constraint_rejects_next_major(package_dir: str):
|
||||
"""The declared click constraint must reject click 9.0.0."""
|
||||
req = _get_click_requirement_from_pyproject(package_dir)
|
||||
assert "9.0.0" not in req.specifier, (
|
||||
f"{package_dir}: click constraint {req.specifier} should not allow 9.0.0"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Verify the installed click version works with the CLI
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_click_version_is_compatible():
|
||||
"""The installed click version must be within the 8.x range."""
|
||||
major = int(click.__version__.split(".")[0])
|
||||
assert major == 8, f"Expected click 8.x, got {click.__version__}"
|
||||
|
||||
|
||||
def test_cli_runner_works_with_installed_click():
|
||||
"""Smoke-test: CliRunner from the installed click can invoke a trivial command."""
|
||||
|
||||
@click.command()
|
||||
@click.option("--name", default="world")
|
||||
def hello(name: str) -> None:
|
||||
click.echo(f"Hello {name}!")
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(hello, ["--name", "crewai"])
|
||||
assert result.exit_code == 0
|
||||
assert "Hello crewai!" in result.output
|
||||
|
||||
|
||||
def test_cli_group_works_with_installed_click():
|
||||
"""Smoke-test: click.group, click.option, click.argument all work."""
|
||||
|
||||
@click.group()
|
||||
def grp() -> None:
|
||||
pass
|
||||
|
||||
@grp.command()
|
||||
@click.argument("task")
|
||||
@click.option("--verbose", is_flag=True)
|
||||
def run(task: str, verbose: bool) -> None:
|
||||
if verbose:
|
||||
click.echo(f"Running {task} (verbose)")
|
||||
else:
|
||||
click.echo(f"Running {task}")
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(grp, ["run", "test-task", "--verbose"])
|
||||
assert result.exit_code == 0
|
||||
assert "Running test-task (verbose)" in result.output
|
||||
@@ -13,7 +13,7 @@ dependencies = [
|
||||
"httpx~=0.28.1",
|
||||
"packaging>=23.0",
|
||||
"portalocker~=2.7.0",
|
||||
"pyjwt>=2.9.0,<3",
|
||||
"pyjwt>=2.13.0,<3",
|
||||
"pydantic>=2.11.9,<2.13",
|
||||
"rich>=13.7.1",
|
||||
"opentelemetry-api~=1.34.0",
|
||||
|
||||
@@ -27,9 +27,9 @@ dependencies = [
|
||||
"openpyxl~=3.1.5",
|
||||
# Authentication and Security
|
||||
"python-dotenv>=1.2.2,<2",
|
||||
"pyjwt>=2.9.0,<3",
|
||||
"pyjwt>=2.13.0,<3",
|
||||
# Configuration and Utils
|
||||
"click~=8.1.7",
|
||||
"click>=8.1.7,<9",
|
||||
"appdirs~=1.4.4",
|
||||
"jsonref~=1.1.0",
|
||||
"json-repair~=0.25.2",
|
||||
|
||||
@@ -1219,9 +1219,17 @@ class Agent(BaseAgent):
|
||||
|
||||
def _use_trained_data(self, task_prompt: str) -> str:
|
||||
"""Use trained data for the agent task prompt to improve output."""
|
||||
trained_file = os.getenv(
|
||||
CREWAI_TRAINED_AGENTS_FILE_ENV, TRAINED_AGENTS_DATA_FILE
|
||||
crew_trained_agents_file = (
|
||||
getattr(self.crew, "trained_agents_file", None)
|
||||
if self.crew and not isinstance(self.crew, str)
|
||||
else None
|
||||
)
|
||||
trained_file = (
|
||||
os.fspath(crew_trained_agents_file)
|
||||
if crew_trained_agents_file
|
||||
else os.getenv(CREWAI_TRAINED_AGENTS_FILE_ENV, TRAINED_AGENTS_DATA_FILE)
|
||||
)
|
||||
|
||||
if data := CrewTrainingHandler(trained_file).load():
|
||||
if trained_data_output := data.get(self.role):
|
||||
task_prompt += (
|
||||
|
||||
@@ -179,6 +179,7 @@ class Crew(FlowTrackable, BaseModel):
|
||||
max_rpm: Maximum number of requests per minute for the crew execution to
|
||||
be respected.
|
||||
prompt_file: Path to the prompt json file to be used for the crew.
|
||||
trained_agents_file: Path to trained agent suggestions loaded during inference.
|
||||
id: A unique identifier for the crew instance.
|
||||
task_callback: Callback to be executed after each task for every agents
|
||||
execution.
|
||||
@@ -303,6 +304,13 @@ class Crew(FlowTrackable, BaseModel):
|
||||
default=None,
|
||||
description="Path to the prompt json file to be used for the crew.",
|
||||
)
|
||||
trained_agents_file: str | Path | None = Field(
|
||||
default=None,
|
||||
description=(
|
||||
"Path to a trained-agents pickle produced by train(). "
|
||||
"When set, agents load suggestions from this file during inference."
|
||||
),
|
||||
)
|
||||
output_log_file: bool | str | None = Field(
|
||||
default=None,
|
||||
description="Path to the log file to be saved",
|
||||
|
||||
@@ -6,7 +6,6 @@ from crewai.flow.async_feedback import (
|
||||
)
|
||||
from crewai.flow.flow import Flow, and_, listen, or_, router, start
|
||||
from crewai.flow.flow_config import flow_config
|
||||
from crewai.flow.flow_serializer import flow_structure
|
||||
from crewai.flow.human_feedback import HumanFeedbackResult, human_feedback
|
||||
from crewai.flow.input_provider import InputProvider, InputResponse
|
||||
from crewai.flow.persistence import persist
|
||||
@@ -30,7 +29,6 @@ __all__ = [
|
||||
"and_",
|
||||
"build_flow_structure",
|
||||
"flow_config",
|
||||
"flow_structure",
|
||||
"human_feedback",
|
||||
"listen",
|
||||
"or_",
|
||||
|
||||
938
lib/crewai/src/crewai/flow/dsl.py
Normal file
938
lib/crewai/src/crewai/flow/dsl.py
Normal file
@@ -0,0 +1,938 @@
|
||||
"""Flow authoring DSL: the ``@start`` / ``@listen`` / ``@router`` decorators
|
||||
plus the ``or_`` / ``and_`` condition combinators.
|
||||
|
||||
These decorators wrap user methods into the typed wrappers defined in
|
||||
``flow_wrappers`` and record their trigger conditions. This module also
|
||||
projects Python Flow classes into the neutral Flow Definition contract.
|
||||
Execution happens in ``runtime``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable, Sequence
|
||||
from enum import Enum
|
||||
import inspect
|
||||
import json
|
||||
import logging
|
||||
from typing import (
|
||||
Any,
|
||||
Literal,
|
||||
ParamSpec,
|
||||
TypeVar,
|
||||
get_args,
|
||||
get_origin,
|
||||
get_type_hints,
|
||||
)
|
||||
|
||||
from pydantic import BaseModel
|
||||
from typing_extensions import TypeIs
|
||||
|
||||
from crewai.flow.constants import AND_CONDITION, OR_CONDITION
|
||||
from crewai.flow.flow_definition import (
|
||||
FlowConfigDefinition,
|
||||
FlowDefinition,
|
||||
FlowDefinitionCondition,
|
||||
FlowDefinitionDiagnostic,
|
||||
FlowHumanFeedbackDefinition,
|
||||
FlowMethodDefinition,
|
||||
FlowPersistenceDefinition,
|
||||
FlowStateDefinition,
|
||||
)
|
||||
from crewai.flow.flow_wrappers import (
|
||||
FlowCondition,
|
||||
FlowConditions,
|
||||
FlowMethod,
|
||||
ListenMethod,
|
||||
RouterMethod,
|
||||
SimpleFlowCondition,
|
||||
StartMethod,
|
||||
)
|
||||
from crewai.flow.types import FlowMethodCallable, FlowMethodName
|
||||
|
||||
|
||||
P = ParamSpec("P")
|
||||
R = TypeVar("R")
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
__all__ = ["and_", "listen", "or_", "router", "start"]
|
||||
|
||||
|
||||
def is_flow_method_name(obj: Any) -> TypeIs[FlowMethodName]:
|
||||
"""Check if the object is a valid flow method name.
|
||||
|
||||
Args:
|
||||
obj: The object to check.
|
||||
Returns:
|
||||
True if the object is a valid flow method name, False otherwise.
|
||||
"""
|
||||
return isinstance(obj, str)
|
||||
|
||||
|
||||
def is_flow_method_callable(obj: Any) -> TypeIs[FlowMethodCallable[..., Any]]:
|
||||
"""Check if the object is a callable flow method.
|
||||
|
||||
Args:
|
||||
obj: The object to check.
|
||||
|
||||
Returns:
|
||||
True if the object is a callable, False otherwise.
|
||||
"""
|
||||
return callable(obj) and hasattr(obj, "__name__")
|
||||
|
||||
|
||||
def is_flow_condition_list(obj: Any) -> TypeIs[FlowConditions]:
|
||||
"""Check if the object is a list of FlowCondition dictionaries.
|
||||
|
||||
Args:
|
||||
obj: The object to check.
|
||||
|
||||
Returns:
|
||||
True if the object is a list of FlowCondition dictionaries, False otherwise.
|
||||
"""
|
||||
if not isinstance(obj, list):
|
||||
return False
|
||||
|
||||
for item in obj:
|
||||
if not (is_flow_method_name(item) or is_flow_condition_dict(item)):
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def is_simple_flow_condition(obj: Any) -> TypeIs[SimpleFlowCondition]:
|
||||
"""Check if the object is a simple flow condition tuple.
|
||||
|
||||
Args:
|
||||
obj: The object to check.
|
||||
|
||||
Returns:
|
||||
True if the object is a (condition_type, methods) tuple, False otherwise.
|
||||
"""
|
||||
return (
|
||||
isinstance(obj, tuple)
|
||||
and len(obj) == 2
|
||||
and isinstance(obj[0], str)
|
||||
and isinstance(obj[1], list)
|
||||
)
|
||||
|
||||
|
||||
def is_flow_method(obj: Any) -> TypeIs[FlowMethod[Any, Any]]:
|
||||
"""Check if the object carries Flow method wrapper metadata.
|
||||
|
||||
Args:
|
||||
obj: The object to check.
|
||||
|
||||
Returns:
|
||||
True if the object is a FlowMethod wrapper or compatible method object.
|
||||
"""
|
||||
return (
|
||||
hasattr(obj, "__is_flow_method__")
|
||||
or hasattr(obj, "__is_start_method__")
|
||||
or hasattr(obj, "__trigger_methods__")
|
||||
or hasattr(obj, "__is_router__")
|
||||
)
|
||||
|
||||
|
||||
def is_flow_condition_dict(obj: Any) -> TypeIs[FlowCondition]:
|
||||
"""Check if the object matches the FlowCondition structure.
|
||||
|
||||
Args:
|
||||
obj: The object to check.
|
||||
|
||||
Returns:
|
||||
True if the object is a valid FlowCondition dictionary, False otherwise.
|
||||
"""
|
||||
if not isinstance(obj, dict):
|
||||
return False
|
||||
|
||||
type_value = obj.get("type")
|
||||
if type_value not in ("AND", "OR"):
|
||||
return False
|
||||
|
||||
if "conditions" in obj:
|
||||
conditions = obj["conditions"]
|
||||
if not isinstance(conditions, list):
|
||||
return False
|
||||
for cond in conditions:
|
||||
if not (
|
||||
isinstance(cond, str)
|
||||
or (isinstance(cond, dict) and is_flow_condition_dict(cond))
|
||||
):
|
||||
return False
|
||||
|
||||
if "methods" in obj:
|
||||
methods = obj["methods"]
|
||||
if not (isinstance(methods, list) and all(isinstance(m, str) for m in methods)):
|
||||
return False
|
||||
|
||||
allowed_keys = {"type", "conditions", "methods"}
|
||||
if not set(obj).issubset(allowed_keys):
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def _extract_all_methods_recursive(
|
||||
condition: str | FlowCondition | dict[str, Any] | list[Any],
|
||||
flow: Any | None = None,
|
||||
) -> list[FlowMethodName]:
|
||||
if is_flow_method_name(condition):
|
||||
if flow is not None:
|
||||
if condition in flow._methods:
|
||||
return [condition]
|
||||
return []
|
||||
return [condition]
|
||||
if is_flow_condition_dict(condition):
|
||||
normalized = _normalize_condition(condition)
|
||||
methods = []
|
||||
for sub_cond in normalized.get("conditions", []):
|
||||
methods.extend(_extract_all_methods_recursive(sub_cond, flow))
|
||||
return methods
|
||||
if isinstance(condition, list):
|
||||
methods = []
|
||||
for item in condition:
|
||||
methods.extend(_extract_all_methods_recursive(item, flow))
|
||||
return methods
|
||||
return []
|
||||
|
||||
|
||||
def _normalize_condition(
|
||||
condition: FlowConditions | FlowCondition | FlowMethodName,
|
||||
) -> FlowCondition:
|
||||
if is_flow_method_name(condition):
|
||||
return {"type": OR_CONDITION, "conditions": [condition]}
|
||||
if is_flow_condition_dict(condition):
|
||||
if "conditions" in condition:
|
||||
return condition
|
||||
if "methods" in condition:
|
||||
return {"type": condition["type"], "conditions": condition["methods"]}
|
||||
return condition
|
||||
if is_flow_condition_list(condition):
|
||||
return {"type": OR_CONDITION, "conditions": condition}
|
||||
|
||||
raise ValueError(f"Cannot normalize condition: {condition}")
|
||||
|
||||
|
||||
def _extract_all_methods(
|
||||
condition: str | FlowCondition | dict[str, Any] | list[Any],
|
||||
) -> list[FlowMethodName]:
|
||||
if is_flow_method_name(condition):
|
||||
return [condition]
|
||||
if is_flow_condition_dict(condition):
|
||||
normalized = _normalize_condition(condition)
|
||||
cond_type = normalized.get("type", OR_CONDITION)
|
||||
|
||||
if cond_type == AND_CONDITION:
|
||||
return [
|
||||
sub_cond
|
||||
for sub_cond in normalized.get("conditions", [])
|
||||
if is_flow_method_name(sub_cond)
|
||||
]
|
||||
return []
|
||||
if isinstance(condition, list):
|
||||
methods = []
|
||||
for item in condition:
|
||||
methods.extend(_extract_all_methods(item))
|
||||
return methods
|
||||
return []
|
||||
|
||||
|
||||
def _unwrap_function(function: Any) -> Any:
|
||||
if hasattr(function, "__func__"):
|
||||
function = function.__func__
|
||||
|
||||
if hasattr(function, "__wrapped__"):
|
||||
wrapped = function.__wrapped__
|
||||
if hasattr(wrapped, "unwrap"):
|
||||
return wrapped.unwrap()
|
||||
return wrapped
|
||||
|
||||
if hasattr(function, "unwrap"):
|
||||
return function.unwrap()
|
||||
|
||||
return function
|
||||
|
||||
|
||||
def _string_values_from_annotation(annotation: Any) -> list[str]:
|
||||
if annotation is inspect.Signature.empty or isinstance(annotation, str):
|
||||
return []
|
||||
if isinstance(annotation, type) and issubclass(annotation, Enum):
|
||||
return [member.value for member in annotation if isinstance(member.value, str)]
|
||||
|
||||
origin = get_origin(annotation)
|
||||
if origin is None:
|
||||
return []
|
||||
|
||||
args = get_args(annotation)
|
||||
if origin is Literal or getattr(origin, "__name__", "") == "Literal":
|
||||
return [arg for arg in args if isinstance(arg, str)]
|
||||
|
||||
values: list[str] = []
|
||||
for arg in args:
|
||||
values.extend(_string_values_from_annotation(arg))
|
||||
return values
|
||||
|
||||
|
||||
def _return_annotation(function: Any) -> Any:
|
||||
unwrapped = _unwrap_function(function)
|
||||
|
||||
try:
|
||||
return get_type_hints(unwrapped, include_extras=True).get(
|
||||
"return", inspect.Signature.empty
|
||||
)
|
||||
except (NameError, TypeError, ValueError):
|
||||
try:
|
||||
return inspect.signature(unwrapped).return_annotation
|
||||
except (TypeError, ValueError):
|
||||
return inspect.Signature.empty
|
||||
|
||||
|
||||
def _get_router_return_paths(function: Any) -> list[str] | None:
|
||||
values = _string_values_from_annotation(_return_annotation(function))
|
||||
return list(dict.fromkeys(values)) if values else None
|
||||
|
||||
|
||||
def _normalize_router_paths(paths: Sequence[Any] | str) -> list[str]:
|
||||
if isinstance(paths, str):
|
||||
return [str(paths)]
|
||||
return list(dict.fromkeys(str(path) for path in paths))
|
||||
|
||||
|
||||
def start(
|
||||
condition: str | FlowCondition | Callable[..., Any] | None = None,
|
||||
) -> Callable[[Callable[P, R]], StartMethod[P, R]]:
|
||||
"""Marks a method as a flow's starting point.
|
||||
|
||||
This decorator designates a method as an entry point for the flow execution.
|
||||
It can optionally specify conditions that trigger the start based on other
|
||||
method executions.
|
||||
|
||||
Args:
|
||||
condition: Defines when the start method should execute. Can be:
|
||||
- str: Name of a method that triggers this start
|
||||
- FlowCondition: Result from or_() or and_(), including nested conditions
|
||||
- Callable[..., Any]: A method reference that triggers this start
|
||||
Default is None, meaning unconditional start.
|
||||
|
||||
Returns:
|
||||
A decorator function that wraps the method as a flow start point and preserves its signature.
|
||||
|
||||
Raises:
|
||||
ValueError: If the condition format is invalid.
|
||||
|
||||
Examples:
|
||||
>>> @start() # Unconditional start
|
||||
>>> def begin_flow(self):
|
||||
... pass
|
||||
|
||||
>>> @start("method_name") # Start after specific method
|
||||
>>> def conditional_start(self):
|
||||
... pass
|
||||
|
||||
>>> @start(and_("method1", "method2")) # Start after multiple methods
|
||||
>>> def complex_start(self):
|
||||
... pass
|
||||
"""
|
||||
|
||||
def decorator(func: Callable[P, R]) -> StartMethod[P, R]:
|
||||
wrapper = StartMethod(func)
|
||||
|
||||
if condition is not None:
|
||||
if is_flow_method_name(condition):
|
||||
wrapper.__trigger_methods__ = [condition]
|
||||
wrapper.__condition_type__ = OR_CONDITION
|
||||
elif is_flow_condition_dict(condition):
|
||||
if "conditions" in condition:
|
||||
wrapper.__trigger_condition__ = condition
|
||||
wrapper.__trigger_methods__ = _extract_all_methods(condition)
|
||||
wrapper.__condition_type__ = condition["type"]
|
||||
elif "methods" in condition:
|
||||
wrapper.__trigger_methods__ = condition["methods"]
|
||||
wrapper.__condition_type__ = condition["type"]
|
||||
else:
|
||||
raise ValueError(
|
||||
"Condition dict must contain 'conditions' or 'methods'"
|
||||
)
|
||||
elif is_flow_method_callable(condition):
|
||||
wrapper.__trigger_methods__ = [condition.__name__]
|
||||
wrapper.__condition_type__ = OR_CONDITION
|
||||
else:
|
||||
raise ValueError(
|
||||
"Condition must be a method, string, or a result of or_() or and_()"
|
||||
)
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def listen(
|
||||
condition: str | FlowCondition | Callable[..., Any],
|
||||
) -> Callable[[Callable[P, R]], ListenMethod[P, R]]:
|
||||
"""Creates a listener that executes when specified conditions are met.
|
||||
|
||||
This decorator sets up a method to execute in response to other method
|
||||
executions in the flow. It supports both simple and complex triggering
|
||||
conditions.
|
||||
|
||||
Args:
|
||||
condition: Specifies when the listener should execute.
|
||||
|
||||
Returns:
|
||||
A decorator function that wraps the method as a flow listener and preserves its signature.
|
||||
|
||||
Raises:
|
||||
ValueError: If the condition format is invalid.
|
||||
|
||||
Examples:
|
||||
>>> @listen("process_data")
|
||||
>>> def handle_processed_data(self):
|
||||
... pass
|
||||
|
||||
>>> @listen("method_name")
|
||||
>>> def handle_completion(self):
|
||||
... pass
|
||||
"""
|
||||
|
||||
def decorator(func: Callable[P, R]) -> ListenMethod[P, R]:
|
||||
wrapper = ListenMethod(func)
|
||||
|
||||
if is_flow_method_name(condition):
|
||||
wrapper.__trigger_methods__ = [condition]
|
||||
wrapper.__condition_type__ = OR_CONDITION
|
||||
elif is_flow_condition_dict(condition):
|
||||
if "conditions" in condition:
|
||||
wrapper.__trigger_condition__ = condition
|
||||
wrapper.__trigger_methods__ = _extract_all_methods(condition)
|
||||
wrapper.__condition_type__ = condition["type"]
|
||||
elif "methods" in condition:
|
||||
wrapper.__trigger_methods__ = condition["methods"]
|
||||
wrapper.__condition_type__ = condition["type"]
|
||||
else:
|
||||
raise ValueError(
|
||||
"Condition dict must contain 'conditions' or 'methods'"
|
||||
)
|
||||
elif is_flow_method_callable(condition):
|
||||
wrapper.__trigger_methods__ = [condition.__name__]
|
||||
wrapper.__condition_type__ = OR_CONDITION
|
||||
else:
|
||||
raise ValueError(
|
||||
"Condition must be a method, string, or a result of or_() or and_()"
|
||||
)
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def router(
|
||||
condition: str | FlowCondition | Callable[..., Any],
|
||||
*,
|
||||
paths: Sequence[str] | str | None = None,
|
||||
) -> Callable[[Callable[P, R]], RouterMethod[P, R]]:
|
||||
"""Creates a routing method that directs flow execution based on conditions.
|
||||
|
||||
This decorator marks a method as a router, which can dynamically determine
|
||||
the next steps in the flow based on its return value. Routers are triggered
|
||||
by specified conditions and can return constants that determine which path
|
||||
the flow should take.
|
||||
|
||||
Args:
|
||||
condition: Specifies when the router should execute. Can be:
|
||||
- str: Name of a method that triggers this router
|
||||
- FlowCondition: Result from or_() or and_(), including nested conditions
|
||||
- Callable[..., Any]: A method reference that triggers this router
|
||||
paths: Optional explicit router output labels for static FlowDefinition
|
||||
and visualization. If omitted, Literal/Enum return annotations are
|
||||
used when available.
|
||||
|
||||
Returns:
|
||||
A decorator function that wraps the method as a router and preserves its signature.
|
||||
|
||||
Raises:
|
||||
ValueError: If the condition format is invalid.
|
||||
|
||||
Examples:
|
||||
>>> @router("check_status")
|
||||
>>> def route_based_on_status(self):
|
||||
... if self.state.status == "success":
|
||||
... return "SUCCESS"
|
||||
... return "FAILURE"
|
||||
|
||||
>>> @router(and_("validate", "process"))
|
||||
>>> def complex_routing(self):
|
||||
... if all([self.state.valid, self.state.processed]):
|
||||
... return "CONTINUE"
|
||||
... return "STOP"
|
||||
|
||||
>>> @router("check_status", paths=["SUCCESS", "FAILURE"])
|
||||
>>> def explicit_routing(self):
|
||||
... return "SUCCESS"
|
||||
"""
|
||||
|
||||
def decorator(func: Callable[P, R]) -> RouterMethod[P, R]:
|
||||
wrapper = RouterMethod(func)
|
||||
|
||||
if is_flow_method_name(condition):
|
||||
wrapper.__trigger_methods__ = [condition]
|
||||
wrapper.__condition_type__ = OR_CONDITION
|
||||
elif is_flow_condition_dict(condition):
|
||||
if "conditions" in condition:
|
||||
wrapper.__trigger_condition__ = condition
|
||||
wrapper.__trigger_methods__ = _extract_all_methods(condition)
|
||||
wrapper.__condition_type__ = condition["type"]
|
||||
elif "methods" in condition:
|
||||
wrapper.__trigger_methods__ = condition["methods"]
|
||||
wrapper.__condition_type__ = condition["type"]
|
||||
else:
|
||||
raise ValueError(
|
||||
"Condition dict must contain 'conditions' or 'methods'"
|
||||
)
|
||||
elif is_flow_method_callable(condition):
|
||||
wrapper.__trigger_methods__ = [condition.__name__]
|
||||
wrapper.__condition_type__ = OR_CONDITION
|
||||
else:
|
||||
raise ValueError(
|
||||
"Condition must be a method, string, or a result of or_() or and_()"
|
||||
)
|
||||
|
||||
if paths is not None:
|
||||
wrapper.__router_paths__ = _normalize_router_paths(paths)
|
||||
else:
|
||||
inferred_paths = _get_router_return_paths(func)
|
||||
if inferred_paths:
|
||||
wrapper.__router_paths__ = inferred_paths
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def or_(*conditions: str | FlowCondition | Callable[..., Any]) -> FlowCondition:
|
||||
"""Combines multiple conditions with OR logic for flow control.
|
||||
|
||||
Creates a condition that is satisfied when any of the specified conditions
|
||||
are met. This is used with @start, @listen, or @router decorators to create
|
||||
complex triggering conditions.
|
||||
|
||||
Args:
|
||||
conditions: Variable number of conditions that can be method names, existing condition dictionaries, or method references.
|
||||
|
||||
Returns:
|
||||
A condition dictionary with format {"type": "OR", "conditions": list_of_conditions} where each condition can be a string (method name) or a nested dict
|
||||
|
||||
Raises:
|
||||
ValueError: If condition format is invalid.
|
||||
|
||||
Examples:
|
||||
>>> @listen(or_("success", "timeout"))
|
||||
>>> def handle_completion(self):
|
||||
... pass
|
||||
|
||||
>>> @listen(or_(and_("step1", "step2"), "step3"))
|
||||
>>> def handle_nested(self):
|
||||
... pass
|
||||
"""
|
||||
processed_conditions: FlowConditions = []
|
||||
for condition in conditions:
|
||||
if is_flow_condition_dict(condition) or is_flow_method_name(condition):
|
||||
processed_conditions.append(condition)
|
||||
elif is_flow_method_callable(condition):
|
||||
processed_conditions.append(condition.__name__)
|
||||
else:
|
||||
raise ValueError("Invalid condition in or_()")
|
||||
return {"type": OR_CONDITION, "conditions": processed_conditions}
|
||||
|
||||
|
||||
def and_(*conditions: str | FlowCondition | Callable[..., Any]) -> FlowCondition:
|
||||
"""Combines multiple conditions with AND logic for flow control.
|
||||
|
||||
Creates a condition that is satisfied only when all specified conditions
|
||||
are met. This is used with @start, @listen, or @router decorators to create
|
||||
complex triggering conditions.
|
||||
|
||||
Args:
|
||||
*conditions: Variable number of conditions that can be method names, existing condition dictionaries, or method references.
|
||||
|
||||
Returns:
|
||||
A condition dictionary with format {"type": "AND", "conditions": list_of_conditions}
|
||||
where each condition can be a string (method name) or a nested dict
|
||||
|
||||
Raises:
|
||||
ValueError: If any condition is invalid.
|
||||
|
||||
Examples:
|
||||
>>> @listen(and_("validated", "processed"))
|
||||
>>> def handle_complete_data(self):
|
||||
... pass
|
||||
|
||||
>>> @listen(and_(or_("step1", "step2"), "step3"))
|
||||
>>> def handle_nested(self):
|
||||
... pass
|
||||
"""
|
||||
processed_conditions: FlowConditions = []
|
||||
for condition in conditions:
|
||||
if is_flow_condition_dict(condition) or is_flow_method_name(condition):
|
||||
processed_conditions.append(condition)
|
||||
elif is_flow_method_callable(condition):
|
||||
processed_conditions.append(condition.__name__)
|
||||
else:
|
||||
raise ValueError("Invalid condition in and_()")
|
||||
return {"type": AND_CONDITION, "conditions": processed_conditions}
|
||||
|
||||
|
||||
def _object_ref(value: Any) -> str:
|
||||
target = value if isinstance(value, type) else type(value)
|
||||
module = getattr(target, "__module__", "")
|
||||
qualname = getattr(target, "__qualname__", getattr(target, "__name__", ""))
|
||||
return f"{module}:{qualname}" if module and qualname else repr(value)
|
||||
|
||||
|
||||
def _is_json_serializable(value: Any) -> bool:
|
||||
try:
|
||||
json.dumps(value)
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _serialize_static_value(
|
||||
value: Any,
|
||||
diagnostics: list[FlowDefinitionDiagnostic],
|
||||
path: str,
|
||||
) -> Any:
|
||||
if value is None or _is_json_serializable(value):
|
||||
return value
|
||||
|
||||
to_config = getattr(value, "to_config_dict", None)
|
||||
if callable(to_config):
|
||||
try:
|
||||
config = to_config()
|
||||
if _is_json_serializable(config):
|
||||
return config
|
||||
except Exception:
|
||||
logger.debug(
|
||||
"Failed to serialize %s via to_config_dict().",
|
||||
path,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
if isinstance(value, BaseModel):
|
||||
try:
|
||||
data = value.model_dump(mode="json")
|
||||
if _is_json_serializable(data):
|
||||
return data
|
||||
except Exception:
|
||||
logger.debug(
|
||||
"Failed to serialize %s via Pydantic model_dump().",
|
||||
path,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
ref = _object_ref(value)
|
||||
diagnostics.append(
|
||||
FlowDefinitionDiagnostic(
|
||||
code="non_serializable_value",
|
||||
path=path,
|
||||
message=f"value is not fully serializable; preserved import reference {ref}",
|
||||
)
|
||||
)
|
||||
return {"ref": ref}
|
||||
|
||||
|
||||
def _state_ref(value: Any) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
target = value if isinstance(value, type) else type(value)
|
||||
module = getattr(target, "__module__", None)
|
||||
qualname = getattr(target, "__qualname__", None)
|
||||
if module and qualname:
|
||||
return f"{module}:{qualname}"
|
||||
return None
|
||||
|
||||
|
||||
def _build_state_definition(
|
||||
flow_class: type,
|
||||
diagnostics: list[FlowDefinitionDiagnostic],
|
||||
) -> FlowStateDefinition | None:
|
||||
from pydantic import BaseModel as PydanticBaseModel
|
||||
|
||||
state_value = getattr(flow_class, "_initial_state_t", None)
|
||||
initial_state = getattr(flow_class, "initial_state", None)
|
||||
if initial_state is not None:
|
||||
state_value = initial_state
|
||||
|
||||
if state_value is None:
|
||||
return None
|
||||
if state_value is dict or isinstance(state_value, dict):
|
||||
default = None
|
||||
if isinstance(state_value, dict):
|
||||
default = _serialize_static_value(state_value, diagnostics, "state.default")
|
||||
return FlowStateDefinition(type="dict", default=default)
|
||||
if isinstance(state_value, type) and issubclass(state_value, PydanticBaseModel):
|
||||
return FlowStateDefinition(type="pydantic", ref=_state_ref(state_value))
|
||||
if isinstance(state_value, PydanticBaseModel):
|
||||
return FlowStateDefinition(
|
||||
type="pydantic",
|
||||
ref=_state_ref(state_value),
|
||||
default=_serialize_static_value(state_value, diagnostics, "state.default"),
|
||||
)
|
||||
diagnostics.append(
|
||||
FlowDefinitionDiagnostic(
|
||||
code="unknown_state_type",
|
||||
path="state",
|
||||
message=f"could not serialize state type {_object_ref(state_value)}",
|
||||
)
|
||||
)
|
||||
return FlowStateDefinition(type="unknown", ref=_state_ref(state_value))
|
||||
|
||||
|
||||
def _build_config_definition(
|
||||
flow_class: type,
|
||||
diagnostics: list[FlowDefinitionDiagnostic],
|
||||
) -> FlowConfigDefinition:
|
||||
config_field_names = set(FlowConfigDefinition.model_fields)
|
||||
field_defaults = {
|
||||
name: field.default
|
||||
for name, field in getattr(flow_class, "model_fields", {}).items()
|
||||
if name in config_field_names
|
||||
}
|
||||
values: dict[str, Any] = {}
|
||||
for field_name, default in field_defaults.items():
|
||||
value = getattr(flow_class, field_name, default)
|
||||
values[field_name] = _serialize_static_value(
|
||||
value, diagnostics, f"config.{field_name}"
|
||||
)
|
||||
return FlowConfigDefinition(**values)
|
||||
|
||||
|
||||
def _definition_condition_from_runtime(condition: Any) -> FlowDefinitionCondition:
|
||||
if is_flow_method_name(condition):
|
||||
return str(condition)
|
||||
if is_flow_method_callable(condition):
|
||||
return str(condition.__name__)
|
||||
if is_flow_condition_dict(condition):
|
||||
normalized = _normalize_condition(condition)
|
||||
key = "and" if normalized.get("type") == AND_CONDITION else "or"
|
||||
return {
|
||||
key: [
|
||||
_definition_condition_from_runtime(sub_condition)
|
||||
for sub_condition in normalized.get("conditions", [])
|
||||
]
|
||||
}
|
||||
if isinstance(condition, list):
|
||||
return {"or": [_definition_condition_from_runtime(item) for item in condition]}
|
||||
return str(condition)
|
||||
|
||||
|
||||
def _condition_from_method_metadata(method: Any) -> FlowDefinitionCondition | None:
|
||||
trigger_condition = getattr(method, "__trigger_condition__", None)
|
||||
if trigger_condition is not None:
|
||||
return _definition_condition_from_runtime(trigger_condition)
|
||||
|
||||
trigger_methods = getattr(method, "__trigger_methods__", None)
|
||||
if trigger_methods is None:
|
||||
return None
|
||||
condition_type = getattr(method, "__condition_type__", OR_CONDITION)
|
||||
method_names = [str(method_name) for method_name in trigger_methods]
|
||||
if condition_type == AND_CONDITION:
|
||||
return {"and": method_names}
|
||||
if len(method_names) == 1:
|
||||
return method_names[0]
|
||||
return {"or": method_names}
|
||||
|
||||
|
||||
def _build_human_feedback_definition(
|
||||
method: Any,
|
||||
diagnostics: list[FlowDefinitionDiagnostic],
|
||||
path: str,
|
||||
) -> FlowHumanFeedbackDefinition | None:
|
||||
config = getattr(method, "__human_feedback_config__", None)
|
||||
if config is None:
|
||||
return None
|
||||
emit = getattr(config, "emit", None)
|
||||
return FlowHumanFeedbackDefinition(
|
||||
message=str(config.message),
|
||||
emit=[str(value) for value in emit] if emit is not None else None,
|
||||
llm=_serialize_static_value(
|
||||
getattr(config, "llm", None), diagnostics, f"{path}.llm"
|
||||
),
|
||||
default_outcome=getattr(config, "default_outcome", None),
|
||||
metadata=getattr(config, "metadata", None),
|
||||
provider=_serialize_static_value(
|
||||
getattr(config, "provider", None), diagnostics, f"{path}.provider"
|
||||
),
|
||||
learn=bool(getattr(config, "learn", False)),
|
||||
learn_source=str(getattr(config, "learn_source", "hitl")),
|
||||
learn_strict=bool(getattr(config, "learn_strict", False)),
|
||||
)
|
||||
|
||||
|
||||
def _build_persistence_definition(
|
||||
value: Any,
|
||||
diagnostics: list[FlowDefinitionDiagnostic],
|
||||
path: str,
|
||||
) -> FlowPersistenceDefinition | None:
|
||||
config = getattr(value, "__flow_persistence_config__", None)
|
||||
if config is None:
|
||||
return None
|
||||
persistence = getattr(config, "persistence", None)
|
||||
verbose = bool(getattr(config, "verbose", False))
|
||||
return FlowPersistenceDefinition(
|
||||
enabled=True,
|
||||
verbose=verbose,
|
||||
persistence=_serialize_static_value(
|
||||
persistence, diagnostics, f"{path}.persistence"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _iter_flow_methods(flow_class: type) -> dict[str, Any]:
|
||||
methods: dict[str, Any] = {}
|
||||
for attr_name in dir(flow_class):
|
||||
if attr_name.startswith("_"):
|
||||
continue
|
||||
try:
|
||||
attr_value = getattr(flow_class, attr_name)
|
||||
except AttributeError:
|
||||
continue
|
||||
if is_flow_method(attr_value):
|
||||
methods[attr_name] = attr_value
|
||||
|
||||
# A wrapped method whose name collides with a base Flow model field
|
||||
# (e.g. ``checkpoint``) is absorbed by Pydantic as a field; the underlying
|
||||
# function is preserved as the field default. Recover those so the
|
||||
# definition still reflects every method once the class is built.
|
||||
for field_name, field in getattr(flow_class, "model_fields", {}).items():
|
||||
if field_name in methods or field_name.startswith("_"):
|
||||
continue
|
||||
default = getattr(field, "default", None)
|
||||
if is_flow_method(default):
|
||||
methods[field_name] = default
|
||||
return methods
|
||||
|
||||
|
||||
def _build_flow_definition_from_class(
|
||||
flow_class: type,
|
||||
namespace: dict[str, Any] | None = None,
|
||||
) -> FlowDefinition:
|
||||
diagnostics: list[FlowDefinitionDiagnostic] = []
|
||||
methods: dict[str, FlowMethodDefinition] = {}
|
||||
flow_methods = _iter_flow_methods(flow_class)
|
||||
if namespace is not None:
|
||||
for attr_name, attr_value in namespace.items():
|
||||
if is_flow_method(attr_value):
|
||||
flow_methods[attr_name] = attr_value
|
||||
|
||||
for method_name, method in flow_methods.items():
|
||||
is_start = bool(getattr(method, "__is_start_method__", False))
|
||||
is_router = bool(getattr(method, "__is_router__", False))
|
||||
condition = _condition_from_method_metadata(method)
|
||||
human_feedback = _build_human_feedback_definition(
|
||||
method, diagnostics, f"methods.{method_name}.human_feedback"
|
||||
)
|
||||
if human_feedback and human_feedback.emit:
|
||||
is_router = True
|
||||
|
||||
if not is_start:
|
||||
start_value: bool | FlowDefinitionCondition | None = None
|
||||
elif condition is not None:
|
||||
start_value = condition
|
||||
else:
|
||||
start_value = True
|
||||
|
||||
method_definition = FlowMethodDefinition(
|
||||
start=start_value,
|
||||
listen=condition if not is_start else None,
|
||||
router=is_router,
|
||||
human_feedback=human_feedback,
|
||||
persist=_build_persistence_definition(
|
||||
method, diagnostics, f"methods.{method_name}.persist"
|
||||
),
|
||||
)
|
||||
|
||||
router_paths = getattr(method, "__router_paths__", None)
|
||||
if router_paths and not (human_feedback and human_feedback.emit):
|
||||
method_definition.returns = [str(path) for path in router_paths]
|
||||
|
||||
methods[method_name] = method_definition
|
||||
|
||||
description = None
|
||||
docstring = flow_class.__doc__
|
||||
if docstring:
|
||||
description = docstring.strip()
|
||||
|
||||
definition = FlowDefinition(
|
||||
name=getattr(flow_class, "__name__", "Flow"),
|
||||
description=description,
|
||||
state=_build_state_definition(flow_class, diagnostics),
|
||||
config=_build_config_definition(flow_class, diagnostics),
|
||||
persist=_build_persistence_definition(flow_class, diagnostics, "persist"),
|
||||
methods=methods,
|
||||
diagnostics=diagnostics,
|
||||
)
|
||||
definition.diagnostics.extend(definition.validate_contract())
|
||||
definition.log_diagnostics()
|
||||
return definition
|
||||
|
||||
|
||||
def build_flow_definition(
|
||||
flow_class: type,
|
||||
namespace: dict[str, Any] | None = None,
|
||||
) -> FlowDefinition:
|
||||
"""Build a FlowDefinition from a Python Flow class."""
|
||||
return _build_flow_definition_from_class(flow_class, namespace)
|
||||
|
||||
|
||||
def extract_flow_definition(
|
||||
namespace: dict[str, Any],
|
||||
) -> tuple[list[str], dict[str, Any], set[str], dict[str, Any]]:
|
||||
"""Extract the structural flow registries from a Python class namespace."""
|
||||
start_methods = []
|
||||
listeners = {}
|
||||
router_paths = {}
|
||||
routers = set()
|
||||
|
||||
for attr_name, attr_value in namespace.items():
|
||||
if is_flow_method(attr_value):
|
||||
if hasattr(attr_value, "__is_start_method__"):
|
||||
start_methods.append(attr_name)
|
||||
|
||||
if (
|
||||
hasattr(attr_value, "__trigger_methods__")
|
||||
and attr_value.__trigger_methods__ is not None
|
||||
):
|
||||
methods = attr_value.__trigger_methods__
|
||||
condition_type = getattr(attr_value, "__condition_type__", OR_CONDITION)
|
||||
|
||||
if (
|
||||
hasattr(attr_value, "__trigger_condition__")
|
||||
and attr_value.__trigger_condition__ is not None
|
||||
):
|
||||
listeners[attr_name] = attr_value.__trigger_condition__
|
||||
else:
|
||||
listeners[attr_name] = (condition_type, methods)
|
||||
|
||||
if hasattr(attr_value, "__is_router__") and attr_value.__is_router__:
|
||||
routers.add(attr_name)
|
||||
if (
|
||||
hasattr(attr_value, "__router_paths__")
|
||||
and attr_value.__router_paths__
|
||||
):
|
||||
router_paths[attr_name] = attr_value.__router_paths__
|
||||
else:
|
||||
router_paths[attr_name] = []
|
||||
|
||||
if (
|
||||
hasattr(attr_value, "__is_start_method__")
|
||||
and hasattr(attr_value, "__is_router__")
|
||||
and attr_value.__is_router__
|
||||
):
|
||||
routers.add(attr_name)
|
||||
if (
|
||||
hasattr(attr_value, "__router_paths__")
|
||||
and attr_value.__router_paths__
|
||||
):
|
||||
router_paths[attr_name] = attr_value.__router_paths__
|
||||
else:
|
||||
router_paths[attr_name] = []
|
||||
|
||||
return start_methods, listeners, routers, router_paths
|
||||
File diff suppressed because it is too large
Load Diff
279
lib/crewai/src/crewai/flow/flow_definition.py
Normal file
279
lib/crewai/src/crewai/flow/flow_definition.py
Normal file
@@ -0,0 +1,279 @@
|
||||
"""Serializable Flow Definition contract."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, Literal as TypingLiteral, cast
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
import yaml
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
FlowDefinitionCondition = str | dict[str, Any]
|
||||
|
||||
__all__ = [
|
||||
"FlowConfigDefinition",
|
||||
"FlowDefinition",
|
||||
"FlowDefinitionCondition",
|
||||
"FlowDefinitionDiagnostic",
|
||||
"FlowHumanFeedbackDefinition",
|
||||
"FlowMethodDefinition",
|
||||
"FlowPersistenceDefinition",
|
||||
"FlowStateDefinition",
|
||||
]
|
||||
|
||||
|
||||
class FlowDefinitionDiagnostic(BaseModel):
|
||||
"""A non-fatal Flow Definition build or validation diagnostic."""
|
||||
|
||||
code: str
|
||||
message: str
|
||||
severity: TypingLiteral["warning", "error"] = "warning"
|
||||
path: str | None = None
|
||||
|
||||
|
||||
class FlowStateDefinition(BaseModel):
|
||||
"""Static description of a Flow state contract."""
|
||||
|
||||
type: TypingLiteral["dict", "pydantic", "unknown"] = "dict"
|
||||
ref: str | None = None
|
||||
default: Any = None
|
||||
|
||||
|
||||
class FlowConfigDefinition(BaseModel):
|
||||
"""Serializable Flow-level configuration."""
|
||||
|
||||
tracing: bool | None = None
|
||||
stream: bool = False
|
||||
memory: Any = None
|
||||
input_provider: Any = None
|
||||
suppress_flow_events: bool = False
|
||||
max_method_calls: int = 100
|
||||
|
||||
|
||||
class FlowPersistenceDefinition(BaseModel):
|
||||
"""Static persistence configuration."""
|
||||
|
||||
enabled: bool = False
|
||||
verbose: bool = False
|
||||
persistence: Any = None
|
||||
|
||||
|
||||
class FlowHumanFeedbackDefinition(BaseModel):
|
||||
"""Static human feedback configuration."""
|
||||
|
||||
message: str
|
||||
emit: list[str] | None = None
|
||||
llm: Any = "gpt-4o-mini"
|
||||
default_outcome: str | None = None
|
||||
metadata: dict[str, Any] | None = None
|
||||
provider: Any = None
|
||||
learn: bool = False
|
||||
learn_source: str = "hitl"
|
||||
learn_strict: bool = False
|
||||
|
||||
|
||||
class FlowMethodDefinition(BaseModel):
|
||||
"""Static definition of one Flow method and its execution roles."""
|
||||
|
||||
start: bool | FlowDefinitionCondition | None = None
|
||||
listen: FlowDefinitionCondition | None = None
|
||||
router: bool = False
|
||||
returns: list[str] | None = None
|
||||
human_feedback: FlowHumanFeedbackDefinition | None = None
|
||||
persist: FlowPersistenceDefinition | None = None
|
||||
|
||||
@property
|
||||
def is_start(self) -> bool:
|
||||
"""Whether this method is a start method.
|
||||
|
||||
A loaded contract may carry ``start: false`` to mark a non-start
|
||||
method explicitly, so falsy values (``False``/``None``/empty string)
|
||||
are treated as "not a start method".
|
||||
"""
|
||||
return bool(self.start)
|
||||
|
||||
|
||||
class FlowDefinition(BaseModel):
|
||||
"""Static, serializable definition of a Flow."""
|
||||
|
||||
model_config = ConfigDict(populate_by_name=True, arbitrary_types_allowed=True)
|
||||
|
||||
schema_: str = Field(default="crewai.flow/v1", alias="schema")
|
||||
name: str
|
||||
description: str | None = None
|
||||
state: FlowStateDefinition | None = None
|
||||
config: FlowConfigDefinition = Field(default_factory=FlowConfigDefinition)
|
||||
persist: FlowPersistenceDefinition | None = None
|
||||
methods: dict[str, FlowMethodDefinition] = Field(default_factory=dict)
|
||||
diagnostics: list[FlowDefinitionDiagnostic] = Field(default_factory=list)
|
||||
|
||||
def to_dict(self, *, exclude_none: bool = True) -> dict[str, Any]:
|
||||
"""Serialize the definition to a JSON/YAML-ready dictionary."""
|
||||
return self.model_dump(by_alias=True, exclude_none=exclude_none, mode="json")
|
||||
|
||||
def to_json(self, *, indent: int | None = 2, exclude_none: bool = True) -> str:
|
||||
"""Serialize the definition to JSON."""
|
||||
data = self.to_dict(exclude_none=exclude_none)
|
||||
return json.dumps(data, indent=indent)
|
||||
|
||||
def to_yaml(self, *, exclude_none: bool = True) -> str:
|
||||
"""Serialize the definition to YAML."""
|
||||
return yaml.safe_dump(
|
||||
self.to_dict(exclude_none=exclude_none),
|
||||
sort_keys=False,
|
||||
allow_unicode=True,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> FlowDefinition:
|
||||
"""Load a definition from a dictionary and attach diagnostics."""
|
||||
serialized_diagnostics = _deserialize_diagnostics(data.get("diagnostics", []))
|
||||
definition = cls.model_validate(data)
|
||||
definition.diagnostics = _merge_diagnostics(
|
||||
serialized_diagnostics, definition.validate_contract()
|
||||
)
|
||||
definition.log_diagnostics()
|
||||
return definition
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, data: str) -> FlowDefinition:
|
||||
"""Load a definition from JSON."""
|
||||
return cls.from_dict(json.loads(data))
|
||||
|
||||
@classmethod
|
||||
def from_yaml(cls, data: str) -> FlowDefinition:
|
||||
"""Load a definition from YAML."""
|
||||
loaded = yaml.safe_load(data) or {}
|
||||
if not isinstance(loaded, dict):
|
||||
raise ValueError("Flow definition YAML must contain a mapping")
|
||||
return cls.from_dict(loaded)
|
||||
|
||||
@classmethod
|
||||
def json_schema(cls) -> dict[str, Any]:
|
||||
"""Return the JSON Schema for the Flow Definition contract."""
|
||||
return cls.model_json_schema(by_alias=True)
|
||||
|
||||
def validate_contract(self) -> list[FlowDefinitionDiagnostic]:
|
||||
"""Validate the static contract without rejecting dynamic routing."""
|
||||
diagnostics: list[FlowDefinitionDiagnostic] = []
|
||||
for method_name, method in self.methods.items():
|
||||
path = f"methods.{method_name}"
|
||||
if method.router and method.listen is None and method.start is None:
|
||||
diagnostics.append(
|
||||
FlowDefinitionDiagnostic(
|
||||
code="router_without_trigger",
|
||||
severity="error",
|
||||
path=path,
|
||||
message="router: true requires either start or listen",
|
||||
)
|
||||
)
|
||||
if method.returns and not method.router:
|
||||
diagnostics.append(
|
||||
FlowDefinitionDiagnostic(
|
||||
code="returns_without_router",
|
||||
path=f"{path}.returns",
|
||||
message="returns is only used as a router visualization hint",
|
||||
)
|
||||
)
|
||||
if method.human_feedback:
|
||||
hf = method.human_feedback
|
||||
if hf.emit and not hf.llm:
|
||||
diagnostics.append(
|
||||
FlowDefinitionDiagnostic(
|
||||
code="human_feedback_llm_required",
|
||||
severity="error",
|
||||
path=f"{path}.human_feedback.llm",
|
||||
message="llm is required when human_feedback.emit is set",
|
||||
)
|
||||
)
|
||||
if hf.default_outcome is not None and not hf.emit:
|
||||
diagnostics.append(
|
||||
FlowDefinitionDiagnostic(
|
||||
code="human_feedback_default_requires_emit",
|
||||
severity="error",
|
||||
path=f"{path}.human_feedback.default_outcome",
|
||||
message="default_outcome requires human_feedback.emit",
|
||||
)
|
||||
)
|
||||
elif hf.default_outcome is not None and hf.emit:
|
||||
if hf.default_outcome not in hf.emit:
|
||||
diagnostics.append(
|
||||
FlowDefinitionDiagnostic(
|
||||
code="human_feedback_default_not_in_emit",
|
||||
severity="error",
|
||||
path=f"{path}.human_feedback.default_outcome",
|
||||
message="default_outcome must be one of human_feedback.emit",
|
||||
)
|
||||
)
|
||||
|
||||
return diagnostics
|
||||
|
||||
def with_diagnostics(self) -> FlowDefinition:
|
||||
"""Attach fresh diagnostics and return this definition."""
|
||||
self.diagnostics = self.validate_contract()
|
||||
self.log_diagnostics()
|
||||
return self
|
||||
|
||||
def log_diagnostics(self) -> None:
|
||||
"""Emit all attached diagnostics through the flow definition logger."""
|
||||
_log_flow_definition_diagnostics(self.name, self.diagnostics)
|
||||
|
||||
|
||||
def _log_flow_definition_diagnostics(
|
||||
definition_name: str,
|
||||
diagnostics: list[FlowDefinitionDiagnostic],
|
||||
) -> None:
|
||||
for diagnostic in diagnostics:
|
||||
level = logging.ERROR if diagnostic.severity == "error" else logging.WARNING
|
||||
path = f" at {diagnostic.path}" if diagnostic.path else ""
|
||||
logger.log(
|
||||
level,
|
||||
"Flow definition diagnostic for %s%s [%s]: %s",
|
||||
definition_name,
|
||||
path,
|
||||
diagnostic.code,
|
||||
diagnostic.message,
|
||||
)
|
||||
|
||||
|
||||
def _deserialize_diagnostics(value: Any) -> list[FlowDefinitionDiagnostic]:
|
||||
return [FlowDefinitionDiagnostic.model_validate(item) for item in value or []]
|
||||
|
||||
|
||||
def _merge_diagnostics(
|
||||
*diagnostic_groups: list[FlowDefinitionDiagnostic],
|
||||
) -> list[FlowDefinitionDiagnostic]:
|
||||
diagnostics: list[FlowDefinitionDiagnostic] = []
|
||||
seen: set[tuple[str, str, str | None, str]] = set()
|
||||
for group in diagnostic_groups:
|
||||
for diagnostic in group:
|
||||
key = (
|
||||
diagnostic.code,
|
||||
diagnostic.severity,
|
||||
diagnostic.path,
|
||||
diagnostic.message,
|
||||
)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
diagnostics.append(diagnostic)
|
||||
return diagnostics
|
||||
|
||||
|
||||
def _extract_definition_condition_atoms(
|
||||
condition: FlowDefinitionCondition | None,
|
||||
) -> list[str]:
|
||||
if condition is None:
|
||||
return []
|
||||
if isinstance(condition, str):
|
||||
return [condition]
|
||||
|
||||
atoms: list[str] = []
|
||||
for key in ("or", "and"):
|
||||
for item in cast(list[FlowDefinitionCondition], condition.get(key, [])):
|
||||
atoms.extend(_extract_definition_condition_atoms(item))
|
||||
return atoms
|
||||
@@ -1,592 +0,0 @@
|
||||
"""Flow structure serializer for introspecting Flow classes.
|
||||
|
||||
This module provides the flow_structure() function that analyzes a Flow class
|
||||
and returns a JSON-serializable dictionary describing its graph structure.
|
||||
This is used by Studio UI to render a visual flow graph.
|
||||
|
||||
Example:
|
||||
>>> from crewai.flow import Flow, start, listen
|
||||
>>> from crewai.flow.flow_serializer import flow_structure
|
||||
>>>
|
||||
>>> class MyFlow(Flow):
|
||||
... @start()
|
||||
... def begin(self):
|
||||
... return "started"
|
||||
...
|
||||
... @listen(begin)
|
||||
... def process(self):
|
||||
... return "done"
|
||||
>>>
|
||||
>>> structure = flow_structure(MyFlow)
|
||||
>>> print(structure["name"])
|
||||
'MyFlow'
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
import logging
|
||||
import re
|
||||
import textwrap
|
||||
from typing import Any, TypedDict, get_args, get_origin
|
||||
|
||||
from pydantic import BaseModel
|
||||
from pydantic_core import PydanticUndefined
|
||||
|
||||
from crewai.flow.flow_wrappers import (
|
||||
FlowCondition,
|
||||
FlowMethod,
|
||||
ListenMethod,
|
||||
RouterMethod,
|
||||
StartMethod,
|
||||
)
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MethodInfo(TypedDict, total=False):
|
||||
"""Information about a single flow method.
|
||||
|
||||
Attributes:
|
||||
name: The method name.
|
||||
type: Method type - start, listen, router, or start_router.
|
||||
trigger_methods: List of method names that trigger this method.
|
||||
condition_type: 'AND' or 'OR' for composite conditions, null otherwise.
|
||||
router_paths: For routers, the possible route names returned.
|
||||
has_human_feedback: Whether the method has @human_feedback decorator.
|
||||
has_crew: Whether the method body references a Crew.
|
||||
"""
|
||||
|
||||
name: str
|
||||
type: str
|
||||
trigger_methods: list[str]
|
||||
condition_type: str | None
|
||||
router_paths: list[str]
|
||||
has_human_feedback: bool
|
||||
has_crew: bool
|
||||
|
||||
|
||||
class EdgeInfo(TypedDict, total=False):
|
||||
"""Information about an edge between flow methods.
|
||||
|
||||
Attributes:
|
||||
from_method: Source method name.
|
||||
to_method: Target method name.
|
||||
edge_type: Type of edge - 'listen' or 'route'.
|
||||
condition: Route name for router edges, null for listen edges.
|
||||
"""
|
||||
|
||||
from_method: str
|
||||
to_method: str
|
||||
edge_type: str
|
||||
condition: str | None
|
||||
|
||||
|
||||
class StateFieldInfo(TypedDict, total=False):
|
||||
"""Information about a state field.
|
||||
|
||||
Attributes:
|
||||
name: Field name.
|
||||
type: Field type as string.
|
||||
default: Default value if any.
|
||||
"""
|
||||
|
||||
name: str
|
||||
type: str
|
||||
default: Any
|
||||
|
||||
|
||||
class StateSchemaInfo(TypedDict, total=False):
|
||||
"""Information about the flow's state schema.
|
||||
|
||||
Attributes:
|
||||
fields: List of field information.
|
||||
"""
|
||||
|
||||
fields: list[StateFieldInfo]
|
||||
|
||||
|
||||
class FlowStructureInfo(TypedDict, total=False):
|
||||
"""Complete flow structure information.
|
||||
|
||||
Attributes:
|
||||
name: Flow class name.
|
||||
description: Flow docstring if available.
|
||||
methods: List of method information.
|
||||
edges: List of edge information.
|
||||
state_schema: State schema if typed, null otherwise.
|
||||
inputs: Detected flow inputs if available.
|
||||
"""
|
||||
|
||||
name: str
|
||||
description: str | None
|
||||
methods: list[MethodInfo]
|
||||
edges: list[EdgeInfo]
|
||||
state_schema: StateSchemaInfo | None
|
||||
inputs: list[str]
|
||||
|
||||
|
||||
def _get_method_type(
|
||||
method_name: str,
|
||||
method: Any,
|
||||
start_methods: list[str],
|
||||
routers: set[str],
|
||||
) -> str:
|
||||
"""Determine the type of a flow method.
|
||||
|
||||
Args:
|
||||
method_name: Name of the method.
|
||||
method: The method object.
|
||||
start_methods: List of start method names.
|
||||
routers: Set of router method names.
|
||||
|
||||
Returns:
|
||||
One of: 'start', 'listen', 'router', or 'start_router'.
|
||||
"""
|
||||
is_start = method_name in start_methods or getattr(
|
||||
method, "__is_start_method__", False
|
||||
)
|
||||
is_router = method_name in routers or getattr(method, "__is_router__", False)
|
||||
|
||||
if is_start and is_router:
|
||||
return "start_router"
|
||||
if is_start:
|
||||
return "start"
|
||||
if is_router:
|
||||
return "router"
|
||||
return "listen"
|
||||
|
||||
|
||||
def _has_human_feedback(method: Any) -> bool:
|
||||
"""Check if a method has the @human_feedback decorator.
|
||||
|
||||
Args:
|
||||
method: The method object to check.
|
||||
|
||||
Returns:
|
||||
True if the method has __human_feedback_config__ attribute.
|
||||
"""
|
||||
return hasattr(method, "__human_feedback_config__")
|
||||
|
||||
|
||||
def _detect_crew_reference(method: Any) -> bool:
|
||||
"""Detect if a method body references a Crew.
|
||||
|
||||
Checks for patterns like:
|
||||
- .crew() method calls
|
||||
- Crew( instantiation
|
||||
- References to Crew class in type hints
|
||||
|
||||
Note:
|
||||
This is a **best-effort heuristic for UI hints**, not a guarantee.
|
||||
Uses inspect.getsource + regex which can false-positive on comments
|
||||
or string literals, and may fail on dynamically generated methods
|
||||
or lambdas. Do not rely on this for correctness-critical logic.
|
||||
|
||||
Args:
|
||||
method: The method object to inspect.
|
||||
|
||||
Returns:
|
||||
True if crew reference detected, False otherwise.
|
||||
"""
|
||||
try:
|
||||
func = method
|
||||
if hasattr(method, "_meth"):
|
||||
func = method._meth
|
||||
elif hasattr(method, "__wrapped__"):
|
||||
func = method.__wrapped__
|
||||
|
||||
source = inspect.getsource(func)
|
||||
source = textwrap.dedent(source)
|
||||
|
||||
crew_patterns = [
|
||||
r"\.crew\(\)", # .crew() method call
|
||||
r"Crew\s*\(", # Crew( instantiation
|
||||
r":\s*Crew\b", # Type hint with Crew
|
||||
r"->.*Crew", # Return type hint with Crew
|
||||
]
|
||||
|
||||
for pattern in crew_patterns:
|
||||
if re.search(pattern, source):
|
||||
return True
|
||||
|
||||
return False
|
||||
except (OSError, TypeError):
|
||||
return False
|
||||
|
||||
|
||||
def _extract_trigger_methods(method: Any) -> tuple[list[str], str | None]:
|
||||
"""Extract trigger methods and condition type from a method.
|
||||
|
||||
Args:
|
||||
method: The method object to inspect.
|
||||
|
||||
Returns:
|
||||
Tuple of (trigger_methods list, condition_type or None).
|
||||
"""
|
||||
trigger_methods: list[str] = []
|
||||
condition_type: str | None = None
|
||||
|
||||
if hasattr(method, "__trigger_methods__") and method.__trigger_methods__:
|
||||
trigger_methods = [str(m) for m in method.__trigger_methods__]
|
||||
|
||||
# For complex conditions (or_/and_ combinators), extract from __trigger_condition__
|
||||
if (
|
||||
not trigger_methods
|
||||
and hasattr(method, "__trigger_condition__")
|
||||
and method.__trigger_condition__
|
||||
):
|
||||
trigger_condition = method.__trigger_condition__
|
||||
trigger_methods = _extract_all_methods_from_condition(trigger_condition)
|
||||
|
||||
if hasattr(method, "__condition_type__") and method.__condition_type__:
|
||||
condition_type = str(method.__condition_type__)
|
||||
|
||||
return trigger_methods, condition_type
|
||||
|
||||
|
||||
def _extract_router_paths(
|
||||
method: Any, router_paths_registry: dict[str, list[str]]
|
||||
) -> list[str]:
|
||||
"""Extract router paths for a router method.
|
||||
|
||||
Args:
|
||||
method: The method object.
|
||||
router_paths_registry: The class-level _router_paths dict.
|
||||
|
||||
Returns:
|
||||
List of possible route names.
|
||||
"""
|
||||
method_name = getattr(method, "__name__", "")
|
||||
|
||||
if hasattr(method, "__router_paths__") and method.__router_paths__:
|
||||
return [str(p) for p in method.__router_paths__]
|
||||
|
||||
if method_name in router_paths_registry:
|
||||
return [str(p) for p in router_paths_registry[method_name]]
|
||||
|
||||
return []
|
||||
|
||||
|
||||
def _extract_all_methods_from_condition(
|
||||
condition: str | FlowCondition | dict[str, Any] | list[Any],
|
||||
) -> list[str]:
|
||||
"""Extract all method names from a condition tree recursively.
|
||||
|
||||
Args:
|
||||
condition: Can be a string, FlowCondition tuple, dict, or list.
|
||||
|
||||
Returns:
|
||||
List of all method names found in the condition.
|
||||
"""
|
||||
if isinstance(condition, str):
|
||||
return [condition]
|
||||
if isinstance(condition, tuple) and len(condition) == 2:
|
||||
# FlowCondition: (condition_type, methods_list)
|
||||
_, methods = condition
|
||||
if isinstance(methods, list):
|
||||
result: list[str] = []
|
||||
for m in methods:
|
||||
result.extend(_extract_all_methods_from_condition(m))
|
||||
return result
|
||||
return []
|
||||
if isinstance(condition, dict):
|
||||
conditions_list = condition.get("conditions", [])
|
||||
dict_methods: list[str] = []
|
||||
for sub_cond in conditions_list:
|
||||
dict_methods.extend(_extract_all_methods_from_condition(sub_cond))
|
||||
return dict_methods
|
||||
if isinstance(condition, list):
|
||||
list_methods: list[str] = []
|
||||
for item in condition:
|
||||
list_methods.extend(_extract_all_methods_from_condition(item))
|
||||
return list_methods
|
||||
return []
|
||||
|
||||
|
||||
def _generate_edges(
|
||||
listeners: dict[str, tuple[str, list[str]] | FlowCondition],
|
||||
routers: set[str],
|
||||
router_paths: dict[str, list[str]],
|
||||
all_methods: set[str],
|
||||
) -> list[EdgeInfo]:
|
||||
"""Generate edges from listeners and routers.
|
||||
|
||||
Args:
|
||||
listeners: Map of listener_name -> (condition_type, trigger_methods) or FlowCondition.
|
||||
routers: Set of router method names.
|
||||
router_paths: Map of router_name -> possible return values.
|
||||
all_methods: Set of all method names in the flow.
|
||||
|
||||
Returns:
|
||||
List of EdgeInfo dictionaries.
|
||||
"""
|
||||
edges: list[EdgeInfo] = []
|
||||
|
||||
for listener_name, condition_data in listeners.items():
|
||||
trigger_methods: list[str] = []
|
||||
|
||||
if isinstance(condition_data, tuple) and len(condition_data) == 2:
|
||||
_condition_type, methods = condition_data
|
||||
trigger_methods = [str(m) for m in methods]
|
||||
elif isinstance(condition_data, dict):
|
||||
trigger_methods = _extract_all_methods_from_condition(condition_data)
|
||||
|
||||
edges.extend(
|
||||
EdgeInfo(
|
||||
from_method=trigger,
|
||||
to_method=listener_name,
|
||||
edge_type="listen",
|
||||
condition=None,
|
||||
)
|
||||
for trigger in trigger_methods
|
||||
if trigger in all_methods
|
||||
)
|
||||
|
||||
for router_name, paths in router_paths.items():
|
||||
for path in paths:
|
||||
for listener_name, condition_data in listeners.items():
|
||||
path_triggers: list[str] = []
|
||||
|
||||
if isinstance(condition_data, tuple) and len(condition_data) == 2:
|
||||
_, methods = condition_data
|
||||
path_triggers = [str(m) for m in methods]
|
||||
elif isinstance(condition_data, dict):
|
||||
path_triggers = _extract_all_methods_from_condition(condition_data)
|
||||
|
||||
if str(path) in path_triggers:
|
||||
edges.append(
|
||||
EdgeInfo(
|
||||
from_method=router_name,
|
||||
to_method=listener_name,
|
||||
edge_type="route",
|
||||
condition=str(path),
|
||||
)
|
||||
)
|
||||
|
||||
return edges
|
||||
|
||||
|
||||
def _extract_state_schema(flow_class: type) -> StateSchemaInfo | None:
|
||||
"""Extract state schema from a Flow class.
|
||||
|
||||
Checks for:
|
||||
- Generic type parameter (Flow[MyState])
|
||||
- initial_state class attribute
|
||||
|
||||
Args:
|
||||
flow_class: The Flow class to inspect.
|
||||
|
||||
Returns:
|
||||
StateSchemaInfo if a Pydantic model state is detected, None otherwise.
|
||||
"""
|
||||
state_type: type | None = None
|
||||
|
||||
# _initial_state_t is set by Flow.__class_getitem__
|
||||
if hasattr(flow_class, "_initial_state_t"):
|
||||
state_type = flow_class._initial_state_t
|
||||
|
||||
if state_type is None and hasattr(flow_class, "initial_state"):
|
||||
initial_state = flow_class.initial_state
|
||||
if isinstance(initial_state, type) and issubclass(initial_state, BaseModel):
|
||||
state_type = initial_state
|
||||
elif isinstance(initial_state, BaseModel):
|
||||
state_type = type(initial_state)
|
||||
|
||||
if state_type is None and hasattr(flow_class, "__orig_bases__"):
|
||||
for base in flow_class.__orig_bases__:
|
||||
origin = get_origin(base)
|
||||
if origin is not None:
|
||||
args = get_args(base)
|
||||
if args:
|
||||
candidate = args[0]
|
||||
if isinstance(candidate, type) and issubclass(candidate, BaseModel):
|
||||
state_type = candidate
|
||||
break
|
||||
|
||||
if state_type is None or not issubclass(state_type, BaseModel):
|
||||
return None
|
||||
|
||||
fields: list[StateFieldInfo] = []
|
||||
try:
|
||||
model_fields = state_type.model_fields
|
||||
for field_name, field_info in model_fields.items():
|
||||
field_type_str = "Any"
|
||||
if field_info.annotation is not None:
|
||||
field_type_str = str(field_info.annotation)
|
||||
field_type_str = field_type_str.replace("typing.", "")
|
||||
field_type_str = field_type_str.replace("<class '", "").replace(
|
||||
"'>", ""
|
||||
)
|
||||
|
||||
default_value = None
|
||||
if (
|
||||
field_info.default is not PydanticUndefined
|
||||
and field_info.default is not None
|
||||
and not callable(field_info.default)
|
||||
):
|
||||
try:
|
||||
default_value = field_info.default
|
||||
except Exception:
|
||||
default_value = str(field_info.default)
|
||||
|
||||
fields.append(
|
||||
StateFieldInfo(
|
||||
name=field_name,
|
||||
type=field_type_str,
|
||||
default=default_value,
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
logger.debug(
|
||||
"Failed to extract state schema fields for %s", flow_class.__name__
|
||||
)
|
||||
|
||||
return StateSchemaInfo(fields=fields) if fields else None
|
||||
|
||||
|
||||
def _detect_flow_inputs(flow_class: type) -> list[str]:
|
||||
"""Detect flow input parameters.
|
||||
|
||||
Inspects the __init__ signature for custom parameters beyond standard Flow params.
|
||||
|
||||
Args:
|
||||
flow_class: The Flow class to inspect.
|
||||
|
||||
Returns:
|
||||
List of detected input names.
|
||||
"""
|
||||
inputs: list[str] = []
|
||||
|
||||
try:
|
||||
init_method = flow_class.__init__ # type: ignore[misc]
|
||||
init_sig = inspect.signature(init_method)
|
||||
standard_params = {
|
||||
"self",
|
||||
"persistence",
|
||||
"tracing",
|
||||
"suppress_flow_events",
|
||||
"max_method_calls",
|
||||
"kwargs",
|
||||
}
|
||||
inputs.extend(
|
||||
param_name
|
||||
for param_name in init_sig.parameters
|
||||
if param_name not in standard_params and not param_name.startswith("_")
|
||||
)
|
||||
except Exception:
|
||||
logger.debug(
|
||||
"Failed to detect inputs from __init__ for %s", flow_class.__name__
|
||||
)
|
||||
|
||||
return inputs
|
||||
|
||||
|
||||
def flow_structure(flow_class: type) -> FlowStructureInfo:
|
||||
"""Introspect a Flow class and return its structure as a JSON-serializable dict.
|
||||
|
||||
This function analyzes a Flow CLASS (not instance) and returns complete
|
||||
information about its graph structure including methods, edges, and state.
|
||||
|
||||
Args:
|
||||
flow_class: A Flow class (not an instance) to introspect.
|
||||
|
||||
Returns:
|
||||
FlowStructureInfo dictionary containing:
|
||||
- name: Flow class name
|
||||
- description: Docstring if available
|
||||
- methods: List of method info dicts
|
||||
- edges: List of edge info dicts
|
||||
- state_schema: State schema if typed, None otherwise
|
||||
- inputs: Detected input names
|
||||
|
||||
Raises:
|
||||
TypeError: If flow_class is not a class.
|
||||
|
||||
Example:
|
||||
>>> structure = flow_structure(MyFlow)
|
||||
>>> print(structure["name"])
|
||||
'MyFlow'
|
||||
>>> for method in structure["methods"]:
|
||||
... print(method["name"], method["type"])
|
||||
"""
|
||||
if not isinstance(flow_class, type):
|
||||
raise TypeError(
|
||||
f"flow_structure requires a Flow class, not an instance. "
|
||||
f"Got {type(flow_class).__name__}"
|
||||
)
|
||||
|
||||
start_methods: list[str] = getattr(flow_class, "_start_methods", [])
|
||||
listeners: dict[str, Any] = getattr(flow_class, "_listeners", {})
|
||||
routers: set[str] = getattr(flow_class, "_routers", set())
|
||||
router_paths_registry: dict[str, list[str]] = getattr(
|
||||
flow_class, "_router_paths", {}
|
||||
)
|
||||
|
||||
methods: list[MethodInfo] = []
|
||||
all_method_names: set[str] = set()
|
||||
|
||||
for attr_name in dir(flow_class):
|
||||
if attr_name.startswith("_"):
|
||||
continue
|
||||
|
||||
try:
|
||||
attr = getattr(flow_class, attr_name)
|
||||
except AttributeError:
|
||||
continue
|
||||
|
||||
is_flow_method = (
|
||||
isinstance(attr, (FlowMethod, StartMethod, ListenMethod, RouterMethod))
|
||||
or hasattr(attr, "__is_flow_method__")
|
||||
or hasattr(attr, "__is_start_method__")
|
||||
or hasattr(attr, "__trigger_methods__")
|
||||
or hasattr(attr, "__is_router__")
|
||||
)
|
||||
|
||||
if not is_flow_method:
|
||||
continue
|
||||
|
||||
all_method_names.add(attr_name)
|
||||
|
||||
method_type = _get_method_type(attr_name, attr, start_methods, routers)
|
||||
|
||||
trigger_methods, condition_type = _extract_trigger_methods(attr)
|
||||
|
||||
router_paths_list: list[str] = []
|
||||
if method_type in ("router", "start_router"):
|
||||
router_paths_list = _extract_router_paths(attr, router_paths_registry)
|
||||
|
||||
has_hf = _has_human_feedback(attr)
|
||||
|
||||
has_crew = _detect_crew_reference(attr)
|
||||
|
||||
method_info = MethodInfo(
|
||||
name=attr_name,
|
||||
type=method_type,
|
||||
trigger_methods=trigger_methods,
|
||||
condition_type=condition_type,
|
||||
router_paths=router_paths_list,
|
||||
has_human_feedback=has_hf,
|
||||
has_crew=has_crew,
|
||||
)
|
||||
methods.append(method_info)
|
||||
|
||||
edges = _generate_edges(listeners, routers, router_paths_registry, all_method_names)
|
||||
|
||||
state_schema = _extract_state_schema(flow_class)
|
||||
|
||||
inputs = _detect_flow_inputs(flow_class)
|
||||
|
||||
description: str | None = None
|
||||
if flow_class.__doc__:
|
||||
description = flow_class.__doc__.strip()
|
||||
|
||||
return FlowStructureInfo(
|
||||
name=flow_class.__name__,
|
||||
description=description,
|
||||
methods=methods,
|
||||
edges=edges,
|
||||
state_schema=state_schema,
|
||||
inputs=inputs,
|
||||
)
|
||||
@@ -18,6 +18,17 @@ R = TypeVar("R")
|
||||
FlowConditionType: TypeAlias = Literal["OR", "AND"]
|
||||
SimpleFlowCondition: TypeAlias = tuple[FlowConditionType, list[FlowMethodName]]
|
||||
|
||||
__all__ = [
|
||||
"FlowCondition",
|
||||
"FlowConditionType",
|
||||
"FlowConditions",
|
||||
"FlowMethod",
|
||||
"ListenMethod",
|
||||
"RouterMethod",
|
||||
"SimpleFlowCondition",
|
||||
"StartMethod",
|
||||
]
|
||||
|
||||
|
||||
class FlowCondition(TypedDict, total=False):
|
||||
"""Type definition for flow trigger conditions.
|
||||
@@ -75,6 +86,7 @@ class FlowMethod(Generic[P, R]):
|
||||
"__is_router__",
|
||||
"__router_paths__",
|
||||
"__human_feedback_config__",
|
||||
"__flow_persistence_config__",
|
||||
"_hf_llm", # Live LLM object for HITL resume
|
||||
]:
|
||||
if hasattr(meth, attr):
|
||||
@@ -165,3 +177,4 @@ class RouterMethod(FlowMethod[P, R]):
|
||||
__trigger_methods__: list[FlowMethodName] | None = None
|
||||
__condition_type__: FlowConditionType | None = None
|
||||
__trigger_condition__: FlowCondition | None = None
|
||||
__router_paths__: list[str] | None = None
|
||||
|
||||
@@ -78,14 +78,10 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
F = TypeVar("F", bound=Callable[..., Any])
|
||||
|
||||
__all__ = ["HumanFeedbackResult", "human_feedback"]
|
||||
|
||||
|
||||
def _serialize_llm_for_context(llm: Any) -> dict[str, Any] | str | None:
|
||||
"""Serialize a BaseLLM object to a dict preserving full config.
|
||||
|
||||
Delegates to ``llm.to_config_dict()`` when available (BaseLLM and
|
||||
subclasses). Falls back to extracting the model string with provider
|
||||
prefix for unknown LLM types.
|
||||
"""
|
||||
to_config: Callable[[], dict[str, Any]] | None = getattr(
|
||||
llm, "to_config_dict", None
|
||||
)
|
||||
@@ -103,13 +99,6 @@ def _serialize_llm_for_context(llm: Any) -> dict[str, Any] | str | None:
|
||||
def _deserialize_llm_from_context(
|
||||
llm_data: dict[str, Any] | str | None,
|
||||
) -> BaseLLM | None:
|
||||
"""Reconstruct an LLM instance from serialized context data.
|
||||
|
||||
Handles both the new dict format (with full config) and the legacy
|
||||
string format (model name only) for backward compatibility.
|
||||
|
||||
Returns a BaseLLM instance, or None if llm_data is None.
|
||||
"""
|
||||
if llm_data is None:
|
||||
return None
|
||||
|
||||
@@ -356,20 +345,12 @@ def human_feedback(
|
||||
raise ValueError("default_outcome requires emit to be specified.")
|
||||
|
||||
def decorator(func: F) -> F:
|
||||
"""Inner decorator that wraps the function."""
|
||||
|
||||
def _get_hitl_prompt(key: str) -> str:
|
||||
"""Read a HITL prompt from the i18n translations."""
|
||||
from crewai.utilities.i18n import I18N_DEFAULT
|
||||
|
||||
return I18N_DEFAULT.slice(key)
|
||||
|
||||
def _resolve_llm_instance() -> Any:
|
||||
"""Resolve the ``llm`` parameter to a BaseLLM instance.
|
||||
|
||||
Uses the SAME model specified in the decorator so pre-review,
|
||||
distillation, and outcome collapsing all share one model.
|
||||
"""
|
||||
if llm is None:
|
||||
from crewai.llm import LLM
|
||||
|
||||
@@ -383,7 +364,6 @@ def human_feedback(
|
||||
def _pre_review_with_lessons(
|
||||
flow_instance: Flow[Any], method_output: Any
|
||||
) -> Any:
|
||||
"""Recall past HITL lessons and use LLM to pre-review the output."""
|
||||
try:
|
||||
mem = flow_instance.memory
|
||||
if mem is None:
|
||||
@@ -431,7 +411,6 @@ def human_feedback(
|
||||
def _distill_and_store_lessons(
|
||||
flow_instance: Flow[Any], method_output: Any, raw_feedback: str
|
||||
) -> None:
|
||||
"""Extract generalizable lessons from output + feedback, store in memory."""
|
||||
try:
|
||||
mem = flow_instance.memory
|
||||
if mem is None:
|
||||
@@ -485,7 +464,6 @@ def human_feedback(
|
||||
def _build_feedback_context(
|
||||
flow_instance: Flow[Any], method_output: Any
|
||||
) -> tuple[Any, Any]:
|
||||
"""Build the PendingFeedbackContext and resolve the effective provider."""
|
||||
from crewai.flow.async_feedback.types import PendingFeedbackContext
|
||||
|
||||
context = PendingFeedbackContext(
|
||||
@@ -509,7 +487,6 @@ def human_feedback(
|
||||
return context, effective_provider
|
||||
|
||||
def _request_feedback(flow_instance: Flow[Any], method_output: Any) -> str:
|
||||
"""Request feedback using provider or default console (sync)."""
|
||||
context, effective_provider = _build_feedback_context(
|
||||
flow_instance, method_output
|
||||
)
|
||||
@@ -535,7 +512,6 @@ def human_feedback(
|
||||
async def _request_feedback_async(
|
||||
flow_instance: Flow[Any], method_output: Any
|
||||
) -> str:
|
||||
"""Request feedback, awaiting the provider if it returns a coroutine."""
|
||||
context, effective_provider = _build_feedback_context(
|
||||
flow_instance, method_output
|
||||
)
|
||||
@@ -559,7 +535,6 @@ def human_feedback(
|
||||
method_output: Any,
|
||||
raw_feedback: str,
|
||||
) -> HumanFeedbackResult | str:
|
||||
"""Process feedback and return result or outcome."""
|
||||
collapsed_outcome: str | None = None
|
||||
|
||||
if not raw_feedback.strip():
|
||||
@@ -661,6 +636,9 @@ def human_feedback(
|
||||
"__condition_type__",
|
||||
"__trigger_condition__",
|
||||
"__is_flow_method__",
|
||||
"__flow_persistence_config__",
|
||||
"__is_router__",
|
||||
"__router_paths__",
|
||||
]:
|
||||
if hasattr(func, attr):
|
||||
setattr(wrapper, attr, getattr(func, attr))
|
||||
|
||||
@@ -31,7 +31,7 @@ import logging
|
||||
from typing import TYPE_CHECKING, Any, Final, TypeVar, cast
|
||||
|
||||
from crewai_core.printer import PRINTER
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
from crewai.flow.persistence.base import FlowPersistence
|
||||
from crewai.flow.persistence.sqlite import SQLiteFlowPersistence
|
||||
@@ -44,6 +44,8 @@ if TYPE_CHECKING:
|
||||
logger = logging.getLogger(__name__)
|
||||
T = TypeVar("T")
|
||||
|
||||
__all__ = ["PersistenceDecorator", "persist"]
|
||||
|
||||
LOG_MESSAGES: Final[dict[str, str]] = {
|
||||
"save_state": "Saving flow state to memory for ID: {}",
|
||||
"save_error": "Failed to persist state for method {}: {}",
|
||||
@@ -52,6 +54,37 @@ LOG_MESSAGES: Final[dict[str, str]] = {
|
||||
}
|
||||
|
||||
|
||||
class _FlowPersistenceConfig(BaseModel):
|
||||
persistence: Any = None
|
||||
verbose: bool = False
|
||||
|
||||
model_config = ConfigDict(arbitrary_types_allowed=True)
|
||||
|
||||
|
||||
def _stamp_persistence_metadata(
|
||||
target: Any,
|
||||
persistence: FlowPersistence,
|
||||
verbose: bool,
|
||||
) -> None:
|
||||
target.__flow_persistence_config__ = _FlowPersistenceConfig(
|
||||
persistence=persistence,
|
||||
verbose=verbose,
|
||||
)
|
||||
|
||||
|
||||
_PRESERVED_FLOW_ATTRS: Final[tuple[str, ...]] = (
|
||||
"__is_start_method__",
|
||||
"__trigger_methods__",
|
||||
"__condition_type__",
|
||||
"__trigger_condition__",
|
||||
"__is_router__",
|
||||
"__router_paths__",
|
||||
"__human_feedback_config__",
|
||||
"__flow_persistence_config__",
|
||||
"_hf_llm",
|
||||
)
|
||||
|
||||
|
||||
class PersistenceDecorator:
|
||||
"""Class to handle flow state persistence with consistent logging."""
|
||||
|
||||
@@ -163,10 +196,10 @@ def persist(
|
||||
"""
|
||||
|
||||
def decorator(target: type | Callable[..., T]) -> type | Callable[..., T]:
|
||||
"""Decorator that handles both class and method decoration."""
|
||||
actual_persistence = persistence or SQLiteFlowPersistence()
|
||||
|
||||
if isinstance(target, type):
|
||||
_stamp_persistence_metadata(target, actual_persistence, verbose)
|
||||
original_init = target.__init__ # type: ignore[misc]
|
||||
|
||||
@functools.wraps(original_init)
|
||||
@@ -211,12 +244,7 @@ def persist(
|
||||
|
||||
wrapped = create_async_wrapper(name, method)
|
||||
|
||||
for attr in [
|
||||
"__is_start_method__",
|
||||
"__trigger_methods__",
|
||||
"__condition_type__",
|
||||
"__is_router__",
|
||||
]:
|
||||
for attr in _PRESERVED_FLOW_ATTRS:
|
||||
if hasattr(method, attr):
|
||||
setattr(wrapped, attr, getattr(method, attr))
|
||||
wrapped.__is_flow_method__ = True # type: ignore[attr-defined]
|
||||
@@ -239,12 +267,7 @@ def persist(
|
||||
|
||||
wrapped = create_sync_wrapper(name, method)
|
||||
|
||||
for attr in [
|
||||
"__is_start_method__",
|
||||
"__trigger_methods__",
|
||||
"__condition_type__",
|
||||
"__is_router__",
|
||||
]:
|
||||
for attr in _PRESERVED_FLOW_ATTRS:
|
||||
if hasattr(method, attr):
|
||||
setattr(wrapped, attr, getattr(method, attr))
|
||||
wrapped.__is_flow_method__ = True # type: ignore[attr-defined]
|
||||
@@ -254,6 +277,7 @@ def persist(
|
||||
return target
|
||||
method = target
|
||||
method.__is_flow_method__ = True # type: ignore[attr-defined]
|
||||
_stamp_persistence_metadata(method, actual_persistence, verbose)
|
||||
|
||||
if asyncio.iscoroutinefunction(method):
|
||||
|
||||
@@ -271,15 +295,13 @@ def persist(
|
||||
)
|
||||
return cast(T, result)
|
||||
|
||||
for attr in [
|
||||
"__is_start_method__",
|
||||
"__trigger_methods__",
|
||||
"__condition_type__",
|
||||
"__is_router__",
|
||||
]:
|
||||
for attr in _PRESERVED_FLOW_ATTRS:
|
||||
if hasattr(method, attr):
|
||||
setattr(method_async_wrapper, attr, getattr(method, attr))
|
||||
method_async_wrapper.__is_flow_method__ = True # type: ignore[attr-defined]
|
||||
_stamp_persistence_metadata(
|
||||
method_async_wrapper, actual_persistence, verbose
|
||||
)
|
||||
return cast(Callable[..., T], method_async_wrapper)
|
||||
|
||||
@functools.wraps(method)
|
||||
@@ -290,15 +312,11 @@ def persist(
|
||||
)
|
||||
return result
|
||||
|
||||
for attr in [
|
||||
"__is_start_method__",
|
||||
"__trigger_methods__",
|
||||
"__condition_type__",
|
||||
"__is_router__",
|
||||
]:
|
||||
for attr in _PRESERVED_FLOW_ATTRS:
|
||||
if hasattr(method, attr):
|
||||
setattr(method_sync_wrapper, attr, getattr(method, attr))
|
||||
method_sync_wrapper.__is_flow_method__ = True # type: ignore[attr-defined]
|
||||
_stamp_persistence_metadata(method_sync_wrapper, actual_persistence, verbose)
|
||||
return cast(Callable[..., T], method_sync_wrapper)
|
||||
|
||||
return decorator
|
||||
|
||||
3334
lib/crewai/src/crewai/flow/runtime.py
Normal file
3334
lib/crewai/src/crewai/flow/runtime.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,954 +0,0 @@
|
||||
"""
|
||||
Utility functions for flow visualization and dependency analysis.
|
||||
|
||||
This module provides core functionality for analyzing and manipulating flow structures,
|
||||
including node level calculation, ancestor tracking, and return value analysis.
|
||||
Functions in this module are primarily used by the visualization system to create
|
||||
accurate and informative flow diagrams.
|
||||
|
||||
Example
|
||||
-------
|
||||
>>> flow = Flow()
|
||||
>>> node_levels = calculate_node_levels(flow)
|
||||
>>> ancestors = build_ancestor_dict(flow)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
from collections import defaultdict, deque
|
||||
from enum import Enum
|
||||
import inspect
|
||||
import textwrap
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from crewai_core.printer import PRINTER
|
||||
from typing_extensions import TypeIs
|
||||
|
||||
from crewai.flow.constants import AND_CONDITION, OR_CONDITION
|
||||
from crewai.flow.flow_wrappers import (
|
||||
FlowCondition,
|
||||
FlowConditions,
|
||||
FlowMethod,
|
||||
SimpleFlowCondition,
|
||||
)
|
||||
from crewai.flow.types import FlowMethodCallable, FlowMethodName
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from crewai.flow.flow import Flow
|
||||
|
||||
|
||||
def _extract_string_literals_from_type_annotation(
|
||||
node: ast.expr,
|
||||
function_globals: dict[str, Any] | None = None,
|
||||
) -> list[str]:
|
||||
"""Extract string literals from a type annotation AST node.
|
||||
|
||||
Handles:
|
||||
- Literal["a", "b", "c"]
|
||||
- "a" | "b" | "c" (union of string literals)
|
||||
- Just "a" (single string constant annotation)
|
||||
- Enum types with string values (e.g., class MyEnum(str, Enum))
|
||||
|
||||
Args:
|
||||
node: The AST node representing a type annotation.
|
||||
function_globals: The globals dict from the function, used to resolve Enum types.
|
||||
|
||||
Returns:
|
||||
List of string literals found in the annotation.
|
||||
"""
|
||||
|
||||
strings: list[str] = []
|
||||
|
||||
if isinstance(node, ast.Constant) and isinstance(node.value, str):
|
||||
strings.append(node.value)
|
||||
|
||||
elif isinstance(node, ast.Name) and function_globals:
|
||||
enum_class = function_globals.get(node.id)
|
||||
if (
|
||||
enum_class is not None
|
||||
and isinstance(enum_class, type)
|
||||
and issubclass(enum_class, Enum)
|
||||
):
|
||||
strings.extend(
|
||||
member.value for member in enum_class if isinstance(member.value, str)
|
||||
)
|
||||
|
||||
elif isinstance(node, ast.Attribute) and function_globals:
|
||||
try:
|
||||
if isinstance(node.value, ast.Name):
|
||||
module = function_globals.get(node.value.id)
|
||||
if module is not None:
|
||||
enum_class = getattr(module, node.attr, None)
|
||||
if (
|
||||
enum_class is not None
|
||||
and isinstance(enum_class, type)
|
||||
and issubclass(enum_class, Enum)
|
||||
):
|
||||
strings.extend(
|
||||
member.value
|
||||
for member in enum_class
|
||||
if isinstance(member.value, str)
|
||||
)
|
||||
except (AttributeError, TypeError):
|
||||
pass
|
||||
|
||||
elif isinstance(node, ast.Subscript):
|
||||
is_literal = False
|
||||
if isinstance(node.value, ast.Name) and node.value.id == "Literal":
|
||||
is_literal = True
|
||||
elif isinstance(node.value, ast.Attribute) and node.value.attr == "Literal":
|
||||
is_literal = True
|
||||
|
||||
if is_literal:
|
||||
if isinstance(node.slice, ast.Tuple):
|
||||
strings.extend(
|
||||
elt.value
|
||||
for elt in node.slice.elts
|
||||
if isinstance(elt, ast.Constant) and isinstance(elt.value, str)
|
||||
)
|
||||
elif isinstance(node.slice, ast.Constant) and isinstance(
|
||||
node.slice.value, str
|
||||
):
|
||||
strings.append(node.slice.value)
|
||||
|
||||
elif isinstance(node, ast.BinOp) and isinstance(node.op, ast.BitOr):
|
||||
strings.extend(
|
||||
_extract_string_literals_from_type_annotation(node.left, function_globals)
|
||||
)
|
||||
strings.extend(
|
||||
_extract_string_literals_from_type_annotation(node.right, function_globals)
|
||||
)
|
||||
|
||||
return strings
|
||||
|
||||
|
||||
def _unwrap_function(function: Any) -> Any:
|
||||
"""Unwrap a function to get the original function with correct globals.
|
||||
|
||||
Flow methods are wrapped by decorators like @router, @listen, etc.
|
||||
This function unwraps them to get the original function which has
|
||||
the correct __globals__ for resolving type annotations like Enums.
|
||||
|
||||
Args:
|
||||
function: The potentially wrapped function.
|
||||
|
||||
Returns:
|
||||
The unwrapped original function.
|
||||
"""
|
||||
if hasattr(function, "__func__"):
|
||||
function = function.__func__
|
||||
|
||||
if hasattr(function, "__wrapped__"):
|
||||
wrapped = function.__wrapped__
|
||||
if hasattr(wrapped, "unwrap"):
|
||||
return wrapped.unwrap()
|
||||
return wrapped
|
||||
|
||||
return function
|
||||
|
||||
|
||||
def get_possible_return_constants(
|
||||
function: Any, verbose: bool = True
|
||||
) -> list[str] | None:
|
||||
"""Extract possible string return values from a function using AST parsing.
|
||||
|
||||
This function analyzes the source code of a router method to identify
|
||||
all possible string values it might return. It handles:
|
||||
- Return type annotations: -> Literal["a", "b"] or -> "a" | "b" | "c"
|
||||
- Enum type annotations: -> MyEnum (extracts string values from members)
|
||||
- Direct string literals: return "value"
|
||||
- Variable assignments: x = "value"; return x
|
||||
- Dictionary lookups: d = {"k": "v"}; return d[key]
|
||||
- Conditional returns: return "a" if cond else "b"
|
||||
- State attributes: return self.state.attr (infers from class context)
|
||||
|
||||
Args:
|
||||
function: The function to analyze.
|
||||
|
||||
Returns:
|
||||
List of possible string return values, or None if analysis fails.
|
||||
"""
|
||||
unwrapped = _unwrap_function(function)
|
||||
|
||||
try:
|
||||
source = inspect.getsource(function)
|
||||
except OSError:
|
||||
return None
|
||||
except Exception as e:
|
||||
if verbose:
|
||||
PRINTER.print(
|
||||
f"Error retrieving source code for function {function.__name__}: {e}",
|
||||
color="red",
|
||||
)
|
||||
return None
|
||||
|
||||
try:
|
||||
source = textwrap.dedent(source)
|
||||
code_ast = ast.parse(source)
|
||||
except IndentationError as e:
|
||||
if verbose:
|
||||
PRINTER.print(
|
||||
f"IndentationError while parsing source code of {function.__name__}: {e}",
|
||||
color="red",
|
||||
)
|
||||
PRINTER.print(f"Source code:\n{source}", color="yellow")
|
||||
return None
|
||||
except SyntaxError as e:
|
||||
if verbose:
|
||||
PRINTER.print(
|
||||
f"SyntaxError while parsing source code of {function.__name__}: {e}",
|
||||
color="red",
|
||||
)
|
||||
PRINTER.print(f"Source code:\n{source}", color="yellow")
|
||||
return None
|
||||
except Exception as e:
|
||||
if verbose:
|
||||
PRINTER.print(
|
||||
f"Unexpected error while parsing source code of {function.__name__}: {e}",
|
||||
color="red",
|
||||
)
|
||||
PRINTER.print(f"Source code:\n{source}", color="yellow")
|
||||
return None
|
||||
|
||||
return_values: set[str] = set()
|
||||
|
||||
function_globals = getattr(unwrapped, "__globals__", None)
|
||||
|
||||
for node in ast.walk(code_ast):
|
||||
if isinstance(node, ast.FunctionDef):
|
||||
if node.returns:
|
||||
annotation_values = _extract_string_literals_from_type_annotation(
|
||||
node.returns, function_globals
|
||||
)
|
||||
return_values.update(annotation_values)
|
||||
break # Only process the first function definition
|
||||
dict_definitions: dict[str, list[str]] = {}
|
||||
variable_values: dict[str, list[str]] = {}
|
||||
state_attribute_values: dict[str, list[str]] = {}
|
||||
|
||||
def extract_string_constants(node: ast.expr) -> list[str]:
|
||||
"""Recursively extract all string constants from an AST node."""
|
||||
strings: list[str] = []
|
||||
if isinstance(node, ast.Constant) and isinstance(node.value, str):
|
||||
strings.append(node.value)
|
||||
elif isinstance(node, ast.IfExp):
|
||||
strings.extend(extract_string_constants(node.body))
|
||||
strings.extend(extract_string_constants(node.orelse))
|
||||
elif isinstance(node, ast.Call):
|
||||
if (
|
||||
isinstance(node.func, ast.Attribute)
|
||||
and node.func.attr == "get"
|
||||
and len(node.args) >= 2
|
||||
):
|
||||
default_arg = node.args[1]
|
||||
if isinstance(default_arg, ast.Constant) and isinstance(
|
||||
default_arg.value, str
|
||||
):
|
||||
strings.append(default_arg.value)
|
||||
return strings
|
||||
|
||||
class VariableAssignmentVisitor(ast.NodeVisitor):
|
||||
def visit_Assign(self, node: ast.Assign) -> None:
|
||||
if isinstance(node.value, ast.Dict) and len(node.targets) == 1:
|
||||
target = node.targets[0]
|
||||
if isinstance(target, ast.Name):
|
||||
var_name = target.id
|
||||
dict_values = [
|
||||
val.value
|
||||
for val in node.value.values
|
||||
if isinstance(val, ast.Constant) and isinstance(val.value, str)
|
||||
]
|
||||
if dict_values:
|
||||
dict_definitions[var_name] = dict_values
|
||||
|
||||
if len(node.targets) == 1:
|
||||
target = node.targets[0]
|
||||
var_name_alt: str | None = None
|
||||
if isinstance(target, ast.Name):
|
||||
var_name_alt = target.id
|
||||
elif isinstance(target, ast.Attribute):
|
||||
var_name_alt = f"{target.value.id if isinstance(target.value, ast.Name) else '_'}.{target.attr}"
|
||||
|
||||
if var_name_alt:
|
||||
strings = extract_string_constants(node.value)
|
||||
if strings:
|
||||
variable_values[var_name_alt] = strings
|
||||
|
||||
self.generic_visit(node)
|
||||
|
||||
def get_attribute_chain(node: ast.expr) -> str | None:
|
||||
"""Extract the full attribute chain from an AST node.
|
||||
|
||||
Examples:
|
||||
self.state.run_type -> "self.state.run_type"
|
||||
x.y.z -> "x.y.z"
|
||||
simple_var -> "simple_var"
|
||||
"""
|
||||
if isinstance(node, ast.Name):
|
||||
return node.id
|
||||
if isinstance(node, ast.Attribute):
|
||||
base = get_attribute_chain(node.value)
|
||||
if base:
|
||||
return f"{base}.{node.attr}"
|
||||
return None
|
||||
|
||||
class ReturnVisitor(ast.NodeVisitor):
|
||||
def visit_Return(self, node: ast.Return) -> None:
|
||||
if (
|
||||
node.value
|
||||
and isinstance(node.value, ast.Constant)
|
||||
and isinstance(node.value.value, str)
|
||||
):
|
||||
return_values.add(node.value.value)
|
||||
elif node.value and isinstance(node.value, ast.Subscript):
|
||||
if isinstance(node.value.value, ast.Name):
|
||||
var_name_dict = node.value.value.id
|
||||
if var_name_dict in dict_definitions:
|
||||
for v in dict_definitions[var_name_dict]:
|
||||
return_values.add(v)
|
||||
elif node.value:
|
||||
var_name_ret = get_attribute_chain(node.value)
|
||||
|
||||
if var_name_ret and var_name_ret in variable_values:
|
||||
for v in variable_values[var_name_ret]:
|
||||
return_values.add(v)
|
||||
elif var_name_ret and var_name_ret in state_attribute_values:
|
||||
for v in state_attribute_values[var_name_ret]:
|
||||
return_values.add(v)
|
||||
|
||||
self.generic_visit(node)
|
||||
|
||||
def visit_If(self, node: ast.If) -> None:
|
||||
self.generic_visit(node)
|
||||
|
||||
try:
|
||||
if hasattr(function, "__self__"):
|
||||
class_obj = function.__self__.__class__
|
||||
elif hasattr(function, "__qualname__") and "." in function.__qualname__:
|
||||
class_name = function.__qualname__.rsplit(".", 1)[0]
|
||||
if hasattr(function, "__globals__"):
|
||||
class_obj = function.__globals__.get(class_name)
|
||||
else:
|
||||
class_obj = None
|
||||
else:
|
||||
class_obj = None
|
||||
|
||||
if class_obj is not None:
|
||||
try:
|
||||
class_source = inspect.getsource(class_obj)
|
||||
class_source = textwrap.dedent(class_source)
|
||||
class_ast = ast.parse(class_source)
|
||||
|
||||
class StateAttributeVisitor(ast.NodeVisitor):
|
||||
def visit_Compare(self, node: ast.Compare) -> None:
|
||||
"""Find comparisons like: self.state.attr == "value" """
|
||||
left_attr = get_attribute_chain(node.left)
|
||||
|
||||
if left_attr:
|
||||
for comparator in node.comparators:
|
||||
if isinstance(comparator, ast.Constant) and isinstance(
|
||||
comparator.value, str
|
||||
):
|
||||
if left_attr not in state_attribute_values:
|
||||
state_attribute_values[left_attr] = []
|
||||
if (
|
||||
comparator.value
|
||||
not in state_attribute_values[left_attr]
|
||||
):
|
||||
state_attribute_values[left_attr].append(
|
||||
comparator.value
|
||||
)
|
||||
|
||||
for comparator in node.comparators:
|
||||
right_attr = get_attribute_chain(comparator)
|
||||
if (
|
||||
right_attr
|
||||
and isinstance(node.left, ast.Constant)
|
||||
and isinstance(node.left.value, str)
|
||||
):
|
||||
if right_attr not in state_attribute_values:
|
||||
state_attribute_values[right_attr] = []
|
||||
if (
|
||||
node.left.value
|
||||
not in state_attribute_values[right_attr]
|
||||
):
|
||||
state_attribute_values[right_attr].append(
|
||||
node.left.value
|
||||
)
|
||||
|
||||
self.generic_visit(node)
|
||||
|
||||
StateAttributeVisitor().visit(class_ast)
|
||||
except Exception as e:
|
||||
if verbose:
|
||||
PRINTER.print(
|
||||
f"Could not analyze class context for {function.__name__}: {e}",
|
||||
color="yellow",
|
||||
)
|
||||
except Exception as e:
|
||||
if verbose:
|
||||
PRINTER.print(
|
||||
f"Could not introspect class for {function.__name__}: {e}",
|
||||
color="yellow",
|
||||
)
|
||||
|
||||
VariableAssignmentVisitor().visit(code_ast)
|
||||
ReturnVisitor().visit(code_ast)
|
||||
|
||||
return list(return_values) if return_values else None
|
||||
|
||||
|
||||
def calculate_node_levels(flow: Any) -> dict[str, int]:
|
||||
"""
|
||||
Calculate the hierarchical level of each node in the flow.
|
||||
|
||||
Performs a breadth-first traversal of the flow graph to assign levels
|
||||
to nodes, starting with start methods at level 0.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
flow : Any
|
||||
The flow instance containing methods, listeners, and router configurations.
|
||||
|
||||
Returns
|
||||
-------
|
||||
Dict[str, int]
|
||||
Dictionary mapping method names to their hierarchical levels.
|
||||
|
||||
Notes
|
||||
-----
|
||||
- Start methods are assigned level 0
|
||||
- Each subsequent connected node is assigned level = parent_level + 1
|
||||
- Handles both OR and AND conditions for listeners
|
||||
- Processes router paths separately
|
||||
"""
|
||||
levels: dict[str, int] = {}
|
||||
queue: deque[str] = deque()
|
||||
visited: set[str] = set()
|
||||
pending_and_listeners: dict[str, set[str]] = {}
|
||||
|
||||
for method_name, method in flow._methods.items():
|
||||
if hasattr(method, "__is_start_method__"):
|
||||
levels[method_name] = 0
|
||||
queue.append(method_name)
|
||||
|
||||
or_listeners = defaultdict(list)
|
||||
and_listeners = defaultdict(set)
|
||||
for listener_name, condition_data in flow._listeners.items():
|
||||
if isinstance(condition_data, tuple):
|
||||
condition_type, trigger_methods = condition_data
|
||||
elif isinstance(condition_data, dict):
|
||||
trigger_methods = _extract_all_methods_recursive(condition_data, flow)
|
||||
condition_type = condition_data.get("type", "OR")
|
||||
else:
|
||||
continue
|
||||
|
||||
if condition_type == "OR":
|
||||
for method in trigger_methods:
|
||||
or_listeners[method].append(listener_name)
|
||||
elif condition_type == "AND":
|
||||
and_listeners[listener_name] = set(trigger_methods)
|
||||
|
||||
while queue:
|
||||
current = queue.popleft()
|
||||
current_level = levels[current]
|
||||
visited.add(current)
|
||||
|
||||
for listener_name in or_listeners[current]:
|
||||
if listener_name not in levels or levels[listener_name] > current_level + 1:
|
||||
levels[listener_name] = current_level + 1
|
||||
if listener_name not in visited:
|
||||
queue.append(listener_name)
|
||||
|
||||
for listener_name, required_methods in and_listeners.items():
|
||||
if current in required_methods:
|
||||
if listener_name not in pending_and_listeners:
|
||||
pending_and_listeners[listener_name] = set()
|
||||
pending_and_listeners[listener_name].add(current)
|
||||
|
||||
if required_methods == pending_and_listeners[listener_name]:
|
||||
if (
|
||||
listener_name not in levels
|
||||
or levels[listener_name] > current_level + 1
|
||||
):
|
||||
levels[listener_name] = current_level + 1
|
||||
if listener_name not in visited:
|
||||
queue.append(listener_name)
|
||||
|
||||
process_router_paths(flow, current, current_level, levels, queue)
|
||||
|
||||
max_level = max(levels.values()) if levels else 0
|
||||
for method_name in flow._methods:
|
||||
if method_name not in levels:
|
||||
levels[method_name] = max_level + 1
|
||||
|
||||
return levels
|
||||
|
||||
|
||||
def count_outgoing_edges(flow: Any) -> dict[str, int]:
|
||||
"""
|
||||
Count the number of outgoing edges for each method in the flow.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
flow : Any
|
||||
The flow instance to analyze.
|
||||
|
||||
Returns
|
||||
-------
|
||||
Dict[str, int]
|
||||
Dictionary mapping method names to their outgoing edge count.
|
||||
"""
|
||||
counts = {}
|
||||
for method_name in flow._methods:
|
||||
counts[method_name] = 0
|
||||
for condition_data in flow._listeners.values():
|
||||
if isinstance(condition_data, tuple):
|
||||
_, trigger_methods = condition_data
|
||||
elif isinstance(condition_data, dict):
|
||||
trigger_methods = _extract_all_methods_recursive(condition_data, flow)
|
||||
else:
|
||||
continue
|
||||
|
||||
for trigger in trigger_methods:
|
||||
if trigger in flow._methods:
|
||||
counts[trigger] += 1
|
||||
return counts
|
||||
|
||||
|
||||
def build_ancestor_dict(flow: Any) -> dict[str, set[str]]:
|
||||
"""
|
||||
Build a dictionary mapping each node to its ancestor nodes.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
flow : Any
|
||||
The flow instance to analyze.
|
||||
|
||||
Returns
|
||||
-------
|
||||
Dict[str, Set[str]]
|
||||
Dictionary mapping each node to a set of its ancestor nodes.
|
||||
"""
|
||||
ancestors: dict[str, set[str]] = {node: set() for node in flow._methods}
|
||||
visited: set[str] = set()
|
||||
for node in flow._methods:
|
||||
if node not in visited:
|
||||
dfs_ancestors(node, ancestors, visited, flow)
|
||||
return ancestors
|
||||
|
||||
|
||||
def dfs_ancestors(
|
||||
node: str, ancestors: dict[str, set[str]], visited: set[str], flow: Any
|
||||
) -> None:
|
||||
"""
|
||||
Perform depth-first search to build ancestor relationships.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
node : str
|
||||
Current node being processed.
|
||||
ancestors : Dict[str, Set[str]]
|
||||
Dictionary tracking ancestor relationships.
|
||||
visited : Set[str]
|
||||
Set of already visited nodes.
|
||||
flow : Any
|
||||
The flow instance being analyzed.
|
||||
|
||||
Notes
|
||||
-----
|
||||
This function modifies the ancestors dictionary in-place to build
|
||||
the complete ancestor graph.
|
||||
"""
|
||||
if node in visited:
|
||||
return
|
||||
visited.add(node)
|
||||
|
||||
for listener_name, condition_data in flow._listeners.items():
|
||||
if isinstance(condition_data, tuple):
|
||||
_, trigger_methods = condition_data
|
||||
elif isinstance(condition_data, dict):
|
||||
trigger_methods = _extract_all_methods_recursive(condition_data, flow)
|
||||
else:
|
||||
continue
|
||||
|
||||
if node in trigger_methods:
|
||||
ancestors[listener_name].add(node)
|
||||
ancestors[listener_name].update(ancestors[node])
|
||||
dfs_ancestors(listener_name, ancestors, visited, flow)
|
||||
|
||||
if node in flow._routers:
|
||||
router_method_name = node
|
||||
paths = flow._router_paths.get(router_method_name, [])
|
||||
for path in paths:
|
||||
for listener_name, condition_data in flow._listeners.items():
|
||||
if isinstance(condition_data, tuple):
|
||||
_, trigger_methods = condition_data
|
||||
elif isinstance(condition_data, dict):
|
||||
trigger_methods = _extract_all_methods_recursive(
|
||||
condition_data, flow
|
||||
)
|
||||
else:
|
||||
continue
|
||||
|
||||
if path in trigger_methods:
|
||||
ancestors[listener_name].update(ancestors[node])
|
||||
dfs_ancestors(listener_name, ancestors, visited, flow)
|
||||
|
||||
|
||||
def is_ancestor(
|
||||
node: str, ancestor_candidate: str, ancestors: dict[str, set[str]]
|
||||
) -> bool:
|
||||
"""
|
||||
Check if one node is an ancestor of another.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
node : str
|
||||
The node to check ancestors for.
|
||||
ancestor_candidate : str
|
||||
The potential ancestor node.
|
||||
ancestors : Dict[str, Set[str]]
|
||||
Dictionary containing ancestor relationships.
|
||||
|
||||
Returns
|
||||
-------
|
||||
bool
|
||||
True if ancestor_candidate is an ancestor of node, False otherwise.
|
||||
"""
|
||||
return ancestor_candidate in ancestors.get(node, set())
|
||||
|
||||
|
||||
def build_parent_children_dict(flow: Any) -> dict[str, list[str]]:
|
||||
"""
|
||||
Build a dictionary mapping parent nodes to their children.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
flow : Any
|
||||
The flow instance to analyze.
|
||||
|
||||
Returns
|
||||
-------
|
||||
Dict[str, List[str]]
|
||||
Dictionary mapping parent method names to lists of their child method names.
|
||||
|
||||
Notes
|
||||
-----
|
||||
- Maps listeners to their trigger methods
|
||||
- Maps router methods to their paths and listeners
|
||||
- Children lists are sorted for consistent ordering
|
||||
"""
|
||||
parent_children: dict[str, list[str]] = {}
|
||||
|
||||
for listener_name, condition_data in flow._listeners.items():
|
||||
if isinstance(condition_data, tuple):
|
||||
_, trigger_methods = condition_data
|
||||
elif isinstance(condition_data, dict):
|
||||
trigger_methods = _extract_all_methods_recursive(condition_data, flow)
|
||||
else:
|
||||
continue
|
||||
|
||||
for trigger in trigger_methods:
|
||||
if trigger not in parent_children:
|
||||
parent_children[trigger] = []
|
||||
if listener_name not in parent_children[trigger]:
|
||||
parent_children[trigger].append(listener_name)
|
||||
|
||||
for router_method_name, paths in flow._router_paths.items():
|
||||
for path in paths:
|
||||
for listener_name, condition_data in flow._listeners.items():
|
||||
if isinstance(condition_data, tuple):
|
||||
_, trigger_methods = condition_data
|
||||
elif isinstance(condition_data, dict):
|
||||
trigger_methods = _extract_all_methods_recursive(
|
||||
condition_data, flow
|
||||
)
|
||||
else:
|
||||
continue
|
||||
|
||||
if path in trigger_methods:
|
||||
if router_method_name not in parent_children:
|
||||
parent_children[router_method_name] = []
|
||||
if listener_name not in parent_children[router_method_name]:
|
||||
parent_children[router_method_name].append(listener_name)
|
||||
|
||||
return parent_children
|
||||
|
||||
|
||||
def get_child_index(
|
||||
parent: str, child: str, parent_children: dict[str, list[str]]
|
||||
) -> int:
|
||||
"""
|
||||
Get the index of a child node in its parent's sorted children list.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
parent : str
|
||||
The parent node name.
|
||||
child : str
|
||||
The child node name to find the index for.
|
||||
parent_children : Dict[str, List[str]]
|
||||
Dictionary mapping parents to their children lists.
|
||||
|
||||
Returns
|
||||
-------
|
||||
int
|
||||
Zero-based index of the child in its parent's sorted children list.
|
||||
"""
|
||||
children = parent_children.get(parent, [])
|
||||
children.sort()
|
||||
return children.index(child)
|
||||
|
||||
|
||||
def process_router_paths(
|
||||
flow: Any,
|
||||
current: str,
|
||||
current_level: int,
|
||||
levels: dict[str, int],
|
||||
queue: deque[str],
|
||||
) -> None:
|
||||
"""Handle the router connections for the current node."""
|
||||
if current in flow._routers:
|
||||
paths = flow._router_paths.get(current, [])
|
||||
for path in paths:
|
||||
for listener_name, condition_data in flow._listeners.items():
|
||||
if isinstance(condition_data, tuple):
|
||||
_condition_type, trigger_methods = condition_data
|
||||
elif isinstance(condition_data, dict):
|
||||
trigger_methods = _extract_all_methods_recursive(
|
||||
condition_data, flow
|
||||
)
|
||||
else:
|
||||
continue
|
||||
|
||||
if path in trigger_methods:
|
||||
if (
|
||||
listener_name not in levels
|
||||
or levels[listener_name] > current_level + 1
|
||||
):
|
||||
levels[listener_name] = current_level + 1
|
||||
queue.append(listener_name)
|
||||
|
||||
|
||||
def is_flow_method_name(obj: Any) -> TypeIs[FlowMethodName]:
|
||||
"""Check if the object is a valid flow method name.
|
||||
|
||||
Args:
|
||||
obj: The object to check.
|
||||
Returns:
|
||||
True if the object is a valid flow method name, False otherwise.
|
||||
"""
|
||||
return isinstance(obj, str)
|
||||
|
||||
|
||||
def is_flow_method_callable(obj: Any) -> TypeIs[FlowMethodCallable[..., Any]]:
|
||||
"""Check if the object is a callable flow method.
|
||||
|
||||
Args:
|
||||
obj: The object to check.
|
||||
|
||||
Returns:
|
||||
True if the object is a callable, False otherwise.
|
||||
"""
|
||||
return callable(obj) and hasattr(obj, "__name__")
|
||||
|
||||
|
||||
def is_flow_condition_list(obj: Any) -> TypeIs[FlowConditions]:
|
||||
"""Check if the object is a list of FlowCondition dictionaries.
|
||||
|
||||
Args:
|
||||
obj: The object to check.
|
||||
|
||||
Returns:
|
||||
True if the object is a list of FlowCondition dictionaries, False otherwise.
|
||||
"""
|
||||
if not isinstance(obj, list):
|
||||
return False
|
||||
|
||||
for item in obj:
|
||||
if not (is_flow_method_name(item) or is_flow_condition_dict(item)):
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def is_simple_flow_condition(obj: Any) -> TypeIs[SimpleFlowCondition]:
|
||||
"""Check if the object is a simple flow condition tuple.
|
||||
|
||||
Args:
|
||||
obj: The object to check.
|
||||
|
||||
Returns:
|
||||
True if the object is a (condition_type, methods) tuple, False otherwise.
|
||||
"""
|
||||
return (
|
||||
isinstance(obj, tuple)
|
||||
and len(obj) == 2
|
||||
and isinstance(obj[0], str)
|
||||
and isinstance(obj[1], list)
|
||||
)
|
||||
|
||||
|
||||
def is_flow_method(obj: Any) -> TypeIs[FlowMethod[Any, Any]]:
|
||||
"""Check if the object is a flow method wrapper.
|
||||
|
||||
Checks for attributes added by @start, @listen, or @router decorators.
|
||||
|
||||
Args:
|
||||
obj: The object to check.
|
||||
|
||||
Returns:
|
||||
True if the object is a FlowMethod subclass (StartMethod, ListenMethod, or RouterMethod).
|
||||
"""
|
||||
return (
|
||||
hasattr(obj, "__is_flow_method__")
|
||||
or hasattr(obj, "__is_start_method__")
|
||||
or hasattr(obj, "__trigger_methods__")
|
||||
or hasattr(obj, "__is_router__")
|
||||
)
|
||||
|
||||
|
||||
def is_flow_condition_dict(obj: Any) -> TypeIs[FlowCondition]:
|
||||
"""Check if the object matches the FlowCondition structure.
|
||||
|
||||
Args:
|
||||
obj: The object to check.
|
||||
|
||||
Returns:
|
||||
True if the object is a valid FlowCondition dictionary, False otherwise.
|
||||
"""
|
||||
if not isinstance(obj, dict):
|
||||
return False
|
||||
|
||||
type_value = obj.get("type")
|
||||
if type_value not in ("AND", "OR"):
|
||||
return False
|
||||
|
||||
if "conditions" in obj:
|
||||
conditions = obj["conditions"]
|
||||
if not isinstance(conditions, list):
|
||||
return False
|
||||
for cond in conditions:
|
||||
if not (
|
||||
isinstance(cond, str)
|
||||
or (isinstance(cond, dict) and is_flow_condition_dict(cond))
|
||||
):
|
||||
return False
|
||||
|
||||
if "methods" in obj:
|
||||
methods = obj["methods"]
|
||||
if not (isinstance(methods, list) and all(isinstance(m, str) for m in methods)):
|
||||
return False
|
||||
|
||||
allowed_keys = {"type", "conditions", "methods"}
|
||||
if not set(obj).issubset(allowed_keys):
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def _extract_all_methods_recursive(
|
||||
condition: str | FlowCondition | dict[str, Any] | list[Any],
|
||||
flow: Flow[Any] | None = None,
|
||||
) -> list[FlowMethodName]:
|
||||
"""Extract ALL method names from a condition tree recursively.
|
||||
|
||||
This function recursively extracts every method name from the entire
|
||||
condition tree, regardless of nesting. Used for visualization and debugging.
|
||||
|
||||
Note: Only extracts actual method names, not router output strings.
|
||||
If flow is provided, it will filter out strings that are not in flow._methods.
|
||||
|
||||
Args:
|
||||
condition: Can be a string, dict, or list
|
||||
flow: Optional flow instance to filter out non-method strings
|
||||
|
||||
Returns:
|
||||
List of all method names found in the condition tree
|
||||
"""
|
||||
if is_flow_method_name(condition):
|
||||
if flow is not None:
|
||||
if condition in flow._methods:
|
||||
return [condition]
|
||||
return []
|
||||
return [condition]
|
||||
if is_flow_condition_dict(condition):
|
||||
normalized = _normalize_condition(condition)
|
||||
methods = []
|
||||
for sub_cond in normalized.get("conditions", []):
|
||||
methods.extend(_extract_all_methods_recursive(sub_cond, flow))
|
||||
return methods
|
||||
if isinstance(condition, list):
|
||||
methods = []
|
||||
for item in condition:
|
||||
methods.extend(_extract_all_methods_recursive(item, flow))
|
||||
return methods
|
||||
return []
|
||||
|
||||
|
||||
def _normalize_condition(
|
||||
condition: FlowConditions | FlowCondition | FlowMethodName,
|
||||
) -> FlowCondition:
|
||||
"""Normalize a condition to standard format with 'conditions' key.
|
||||
|
||||
Args:
|
||||
condition: Can be a string (method name), dict (condition), or list
|
||||
|
||||
Returns:
|
||||
Normalized dict with 'type' and 'conditions' keys
|
||||
"""
|
||||
if is_flow_method_name(condition):
|
||||
return {"type": OR_CONDITION, "conditions": [condition]}
|
||||
if is_flow_condition_dict(condition):
|
||||
if "conditions" in condition:
|
||||
return condition
|
||||
if "methods" in condition:
|
||||
return {"type": condition["type"], "conditions": condition["methods"]}
|
||||
return condition
|
||||
if is_flow_condition_list(condition):
|
||||
return {"type": OR_CONDITION, "conditions": condition}
|
||||
|
||||
raise ValueError(f"Cannot normalize condition: {condition}")
|
||||
|
||||
|
||||
def _extract_all_methods(
|
||||
condition: str | FlowCondition | dict[str, Any] | list[Any],
|
||||
) -> list[FlowMethodName]:
|
||||
"""Extract all method names from a condition (including nested).
|
||||
|
||||
For AND conditions, this extracts methods that must ALL complete.
|
||||
For OR conditions nested inside AND, we don't extract their methods
|
||||
since only one branch of the OR needs to trigger, not all methods.
|
||||
|
||||
This function is used for runtime execution logic, where we need to know
|
||||
which methods must complete for AND conditions. For visualization purposes,
|
||||
use _extract_all_methods_recursive() instead.
|
||||
|
||||
Args:
|
||||
condition: Can be a string, dict, or list
|
||||
|
||||
Returns:
|
||||
List of all method names in the condition tree that must complete
|
||||
"""
|
||||
if is_flow_method_name(condition):
|
||||
return [condition]
|
||||
if is_flow_condition_dict(condition):
|
||||
normalized = _normalize_condition(condition)
|
||||
cond_type = normalized.get("type", OR_CONDITION)
|
||||
|
||||
if cond_type == AND_CONDITION:
|
||||
return [
|
||||
sub_cond
|
||||
for sub_cond in normalized.get("conditions", [])
|
||||
if is_flow_method_name(sub_cond)
|
||||
]
|
||||
return []
|
||||
if isinstance(condition, list):
|
||||
methods = []
|
||||
for item in condition:
|
||||
methods.extend(_extract_all_methods(item))
|
||||
return methods
|
||||
return []
|
||||
259
lib/crewai/src/crewai/flow/visualization/analysis.py
Normal file
259
lib/crewai/src/crewai/flow/visualization/analysis.py
Normal file
@@ -0,0 +1,259 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict, deque
|
||||
from typing import Any, cast
|
||||
|
||||
from crewai.flow.constants import AND_CONDITION, OR_CONDITION
|
||||
from crewai.flow.flow_definition import (
|
||||
FlowDefinition,
|
||||
FlowDefinitionCondition,
|
||||
FlowMethodDefinition,
|
||||
_extract_definition_condition_atoms,
|
||||
)
|
||||
|
||||
|
||||
__all__: list[str] = []
|
||||
|
||||
|
||||
def _definition_from(value: Any) -> FlowDefinition:
|
||||
if isinstance(value, FlowDefinition):
|
||||
return value
|
||||
|
||||
flow_class = value if isinstance(value, type) else value.__class__
|
||||
flow_definition = getattr(flow_class, "flow_definition", None)
|
||||
if callable(flow_definition):
|
||||
return cast(FlowDefinition, flow_definition())
|
||||
raise TypeError(
|
||||
"Flow visualization analysis requires a FlowDefinition or a Flow class/instance "
|
||||
"with flow_definition()."
|
||||
)
|
||||
|
||||
|
||||
def _method_condition(method: FlowMethodDefinition) -> FlowDefinitionCondition | None:
|
||||
if method.listen is not None:
|
||||
return method.listen
|
||||
if isinstance(method.start, str | dict):
|
||||
return method.start
|
||||
return None
|
||||
|
||||
|
||||
def _condition_type(condition: FlowDefinitionCondition | None) -> str | None:
|
||||
if isinstance(condition, str):
|
||||
return OR_CONDITION
|
||||
if isinstance(condition, dict):
|
||||
if "and" in condition:
|
||||
return AND_CONDITION
|
||||
if "or" in condition:
|
||||
return OR_CONDITION
|
||||
return None
|
||||
|
||||
|
||||
def _router_paths(method: FlowMethodDefinition) -> list[str]:
|
||||
if method.human_feedback and method.human_feedback.emit:
|
||||
return [str(path) for path in method.human_feedback.emit]
|
||||
if method.returns:
|
||||
return [str(path) for path in method.returns]
|
||||
return []
|
||||
|
||||
|
||||
def _condition_atoms(method: FlowMethodDefinition) -> list[str]:
|
||||
condition = _method_condition(method)
|
||||
if condition is None:
|
||||
return []
|
||||
return _extract_definition_condition_atoms(condition)
|
||||
|
||||
|
||||
def _direct_or_triggers(method: FlowMethodDefinition) -> list[str]:
|
||||
condition = _method_condition(method)
|
||||
if condition is None:
|
||||
return []
|
||||
return _extract_direct_or_triggers(condition)
|
||||
|
||||
|
||||
def _extract_direct_or_triggers(condition: FlowDefinitionCondition) -> list[str]:
|
||||
if isinstance(condition, str):
|
||||
return [condition]
|
||||
if "and" in condition:
|
||||
return []
|
||||
triggers: list[str] = []
|
||||
for sub_condition in cast(list[FlowDefinitionCondition], condition.get("or", [])):
|
||||
triggers.extend(_extract_direct_or_triggers(sub_condition))
|
||||
return triggers
|
||||
|
||||
|
||||
def _parent_children_from_definition(
|
||||
definition: FlowDefinition,
|
||||
) -> dict[str, list[str]]:
|
||||
method_names = set(definition.methods)
|
||||
parent_children: dict[str, list[str]] = {}
|
||||
|
||||
for child_name, method in definition.methods.items():
|
||||
for trigger in _condition_atoms(method):
|
||||
if trigger in method_names:
|
||||
parent_children.setdefault(trigger, [])
|
||||
if child_name not in parent_children[trigger]:
|
||||
parent_children[trigger].append(child_name)
|
||||
|
||||
for router_name, method in definition.methods.items():
|
||||
if not method.router:
|
||||
continue
|
||||
for path in _router_paths(method):
|
||||
for child_name, child in definition.methods.items():
|
||||
if child_name == router_name:
|
||||
continue
|
||||
if path in _direct_or_triggers(child):
|
||||
parent_children.setdefault(router_name, [])
|
||||
if child_name not in parent_children[router_name]:
|
||||
parent_children[router_name].append(child_name)
|
||||
|
||||
return parent_children
|
||||
|
||||
|
||||
def _calculate_node_levels(flow_or_definition: Any) -> dict[str, int]:
|
||||
definition = _definition_from(flow_or_definition)
|
||||
levels: dict[str, int] = {}
|
||||
queue: deque[str] = deque()
|
||||
visited: set[str] = set()
|
||||
pending_and_listeners: dict[str, set[str]] = {}
|
||||
|
||||
for method_name, method in definition.methods.items():
|
||||
if method.is_start:
|
||||
levels[method_name] = 0
|
||||
queue.append(method_name)
|
||||
|
||||
or_listeners = defaultdict(list)
|
||||
and_listeners = defaultdict(set)
|
||||
for listener_name, method in definition.methods.items():
|
||||
condition = _method_condition(method)
|
||||
condition_type = _condition_type(condition)
|
||||
trigger_methods = _condition_atoms(method)
|
||||
|
||||
if condition_type == OR_CONDITION:
|
||||
for trigger in trigger_methods:
|
||||
or_listeners[trigger].append(listener_name)
|
||||
elif condition_type == AND_CONDITION:
|
||||
and_listeners[listener_name] = set(trigger_methods)
|
||||
|
||||
while queue:
|
||||
current = queue.popleft()
|
||||
current_level = levels[current]
|
||||
visited.add(current)
|
||||
|
||||
for listener_name in or_listeners[current]:
|
||||
if listener_name not in levels or levels[listener_name] > current_level + 1:
|
||||
levels[listener_name] = current_level + 1
|
||||
if listener_name not in visited:
|
||||
queue.append(listener_name)
|
||||
|
||||
for listener_name, required_methods in and_listeners.items():
|
||||
if current in required_methods:
|
||||
pending_and_listeners.setdefault(listener_name, set()).add(current)
|
||||
|
||||
if required_methods == pending_and_listeners[listener_name]:
|
||||
if (
|
||||
listener_name not in levels
|
||||
or levels[listener_name] > current_level + 1
|
||||
):
|
||||
levels[listener_name] = current_level + 1
|
||||
if listener_name not in visited:
|
||||
queue.append(listener_name)
|
||||
|
||||
_process_router_paths(definition, current, current_level, levels, queue)
|
||||
|
||||
max_level = max(levels.values()) if levels else 0
|
||||
for method_name in definition.methods:
|
||||
if method_name not in levels:
|
||||
levels[method_name] = max_level + 1
|
||||
|
||||
return levels
|
||||
|
||||
|
||||
def _count_outgoing_edges(flow_or_definition: Any) -> dict[str, int]:
|
||||
definition = _definition_from(flow_or_definition)
|
||||
parent_children = _parent_children_from_definition(definition)
|
||||
return {
|
||||
method_name: len(parent_children.get(method_name, []))
|
||||
for method_name in definition.methods
|
||||
}
|
||||
|
||||
|
||||
def _build_ancestor_dict(flow_or_definition: Any) -> dict[str, set[str]]:
|
||||
definition = _definition_from(flow_or_definition)
|
||||
ancestors: dict[str, set[str]] = {node: set() for node in definition.methods}
|
||||
parent_children = _parent_children_from_definition(definition)
|
||||
worklist: deque[str] = deque()
|
||||
|
||||
for parent, children in parent_children.items():
|
||||
for child in children:
|
||||
if (
|
||||
child in ancestors
|
||||
and parent != child
|
||||
and parent not in ancestors[child]
|
||||
):
|
||||
ancestors[child].add(parent)
|
||||
worklist.append(child)
|
||||
|
||||
_dfs_ancestors(worklist, ancestors, parent_children)
|
||||
return ancestors
|
||||
|
||||
|
||||
def _dfs_ancestors(
|
||||
worklist: deque[str],
|
||||
ancestors: dict[str, set[str]],
|
||||
parent_children: dict[str, list[str]],
|
||||
) -> None:
|
||||
while worklist:
|
||||
parent = worklist.popleft()
|
||||
for child in parent_children.get(parent, []):
|
||||
if child not in ancestors:
|
||||
continue
|
||||
next_ancestors = set(ancestors[parent])
|
||||
if parent != child:
|
||||
next_ancestors.add(parent)
|
||||
next_ancestors.discard(child)
|
||||
if not next_ancestors.issubset(ancestors[child]):
|
||||
ancestors[child].update(next_ancestors)
|
||||
worklist.append(child)
|
||||
|
||||
|
||||
def _is_ancestor(
|
||||
node: str, ancestor_candidate: str, ancestors: dict[str, set[str]]
|
||||
) -> bool:
|
||||
return ancestor_candidate in ancestors.get(node, set())
|
||||
|
||||
|
||||
def _build_parent_children_dict(flow_or_definition: Any) -> dict[str, list[str]]:
|
||||
return _parent_children_from_definition(_definition_from(flow_or_definition))
|
||||
|
||||
|
||||
def _get_child_index(
|
||||
parent: str, child: str, parent_children: dict[str, list[str]]
|
||||
) -> int:
|
||||
children = parent_children.get(parent, [])
|
||||
children.sort()
|
||||
return children.index(child)
|
||||
|
||||
|
||||
def _process_router_paths(
|
||||
flow_or_definition: Any,
|
||||
current: str,
|
||||
current_level: int,
|
||||
levels: dict[str, int],
|
||||
queue: deque[str],
|
||||
) -> None:
|
||||
definition = _definition_from(flow_or_definition)
|
||||
method = definition.methods.get(current)
|
||||
if method is None or not method.router:
|
||||
return
|
||||
|
||||
for path in _router_paths(method):
|
||||
for listener_name, listener in definition.methods.items():
|
||||
if listener_name == current:
|
||||
continue
|
||||
if path in _direct_or_triggers(listener):
|
||||
if (
|
||||
listener_name not in levels
|
||||
or levels[listener_name] > current_level + 1
|
||||
):
|
||||
levels[listener_name] = current_level + 1
|
||||
queue.append(listener_name)
|
||||
@@ -1,131 +1,118 @@
|
||||
"""Flow structure builder for analyzing Flow execution."""
|
||||
"""Flow structure builder for definition-only Flow visualization."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
import inspect
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
|
||||
from crewai.flow.constants import AND_CONDITION, OR_CONDITION
|
||||
from crewai.flow.flow_wrappers import FlowCondition
|
||||
from crewai.flow.types import FlowMethodName
|
||||
from crewai.flow.utils import (
|
||||
is_flow_condition_dict,
|
||||
is_simple_flow_condition,
|
||||
from crewai.flow.flow_definition import (
|
||||
FlowDefinition,
|
||||
FlowDefinitionCondition,
|
||||
FlowMethodDefinition,
|
||||
)
|
||||
from crewai.flow.visualization.schema import extract_method_signature
|
||||
from crewai.flow.visualization.types import FlowStructure, NodeMetadata, StructureEdge
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
__all__ = ["build_flow_structure", "calculate_execution_paths"]
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from crewai.flow.flow import Flow
|
||||
|
||||
|
||||
def _definition_condition_items(
|
||||
condition: dict[str, Any],
|
||||
key: str,
|
||||
) -> list[FlowDefinitionCondition]:
|
||||
return cast(list[FlowDefinitionCondition], condition.get(key, []))
|
||||
|
||||
|
||||
def _definition_condition_parts(
|
||||
condition: dict[str, Any],
|
||||
) -> tuple[str, list[FlowDefinitionCondition]]:
|
||||
if "and" in condition:
|
||||
return AND_CONDITION, _definition_condition_items(condition, "and")
|
||||
return OR_CONDITION, _definition_condition_items(condition, "or")
|
||||
|
||||
|
||||
def _condition_type_from_definition(
|
||||
condition: FlowDefinitionCondition | None,
|
||||
) -> str | None:
|
||||
if isinstance(condition, dict):
|
||||
if "and" in condition:
|
||||
return AND_CONDITION
|
||||
if "or" in condition:
|
||||
return OR_CONDITION
|
||||
if isinstance(condition, str):
|
||||
return OR_CONDITION
|
||||
return None
|
||||
|
||||
|
||||
def _runtime_condition_from_definition(
|
||||
condition: FlowDefinitionCondition,
|
||||
) -> str | dict[str, Any]:
|
||||
if isinstance(condition, str):
|
||||
return condition
|
||||
condition_type, conditions = _definition_condition_parts(condition)
|
||||
return {
|
||||
"type": condition_type,
|
||||
"conditions": [_runtime_condition_from_definition(item) for item in conditions],
|
||||
}
|
||||
|
||||
|
||||
def _method_trigger_condition(
|
||||
method_definition: FlowMethodDefinition,
|
||||
) -> FlowDefinitionCondition | None:
|
||||
if method_definition.listen is not None:
|
||||
return method_definition.listen
|
||||
if isinstance(method_definition.start, str | dict):
|
||||
return method_definition.start
|
||||
return None
|
||||
|
||||
|
||||
def _method_router_paths(method_definition: FlowMethodDefinition) -> list[str]:
|
||||
if method_definition.human_feedback and method_definition.human_feedback.emit:
|
||||
return [str(path) for path in method_definition.human_feedback.emit]
|
||||
if method_definition.returns:
|
||||
return [str(path) for path in method_definition.returns]
|
||||
return []
|
||||
|
||||
|
||||
def _extract_direct_or_triggers(
|
||||
condition: str | dict[str, Any] | list[Any] | FlowCondition,
|
||||
condition: FlowDefinitionCondition,
|
||||
) -> list[str]:
|
||||
"""Extract direct OR-level trigger strings from a condition.
|
||||
|
||||
This function extracts strings that would directly trigger a listener,
|
||||
meaning they appear at the top level of an OR condition. Strings nested
|
||||
inside AND conditions are NOT considered direct triggers for router paths.
|
||||
|
||||
For example:
|
||||
- or_("a", "b") -> ["a", "b"] (both are direct triggers)
|
||||
- and_("a", "b") -> [] (neither are direct triggers, both required)
|
||||
- or_(and_("a", "b"), "c") -> ["c"] (only "c" is a direct trigger)
|
||||
|
||||
Args:
|
||||
condition: Can be a string, dict, or list.
|
||||
|
||||
Returns:
|
||||
List of direct OR-level trigger strings.
|
||||
"""
|
||||
if isinstance(condition, str):
|
||||
return [condition]
|
||||
if isinstance(condition, dict):
|
||||
cond_type = condition.get("type", OR_CONDITION)
|
||||
conditions_list = condition.get("conditions", [])
|
||||
|
||||
if cond_type == OR_CONDITION:
|
||||
strings = []
|
||||
for sub_cond in conditions_list:
|
||||
strings.extend(_extract_direct_or_triggers(sub_cond))
|
||||
return strings
|
||||
condition_type, conditions = _definition_condition_parts(condition)
|
||||
if condition_type == AND_CONDITION:
|
||||
return []
|
||||
if isinstance(condition, list):
|
||||
strings = []
|
||||
for item in condition:
|
||||
strings.extend(_extract_direct_or_triggers(item))
|
||||
return strings
|
||||
if callable(condition) and hasattr(condition, "__name__"):
|
||||
return [condition.__name__]
|
||||
return []
|
||||
strings: list[str] = []
|
||||
for sub_condition in conditions:
|
||||
strings.extend(_extract_direct_or_triggers(sub_condition))
|
||||
return strings
|
||||
|
||||
|
||||
def _extract_all_trigger_names(
|
||||
condition: str | dict[str, Any] | list[Any] | FlowCondition,
|
||||
condition: FlowDefinitionCondition,
|
||||
) -> list[str]:
|
||||
"""Extract ALL trigger names from a condition for display purposes.
|
||||
|
||||
Unlike _extract_direct_or_triggers, this extracts ALL strings and method
|
||||
names from the entire condition tree, including those nested in AND conditions.
|
||||
This is used for displaying trigger information in the UI.
|
||||
|
||||
For example:
|
||||
- or_("a", "b") -> ["a", "b"]
|
||||
- and_("a", "b") -> ["a", "b"]
|
||||
- or_(and_("a", method_6), method_4) -> ["a", "method_6", "method_4"]
|
||||
|
||||
Args:
|
||||
condition: Can be a string, dict, or list.
|
||||
|
||||
Returns:
|
||||
List of all trigger names found in the condition.
|
||||
"""
|
||||
if isinstance(condition, str):
|
||||
return [condition]
|
||||
if isinstance(condition, dict):
|
||||
conditions_list = condition.get("conditions", [])
|
||||
strings = []
|
||||
for sub_cond in conditions_list:
|
||||
strings.extend(_extract_all_trigger_names(sub_cond))
|
||||
return strings
|
||||
if isinstance(condition, list):
|
||||
strings = []
|
||||
for item in condition:
|
||||
strings.extend(_extract_all_trigger_names(item))
|
||||
return strings
|
||||
if callable(condition) and hasattr(condition, "__name__"):
|
||||
return [condition.__name__]
|
||||
return []
|
||||
_, conditions = _definition_condition_parts(condition)
|
||||
strings: list[str] = []
|
||||
for sub_condition in conditions:
|
||||
strings.extend(_extract_all_trigger_names(sub_condition))
|
||||
return strings
|
||||
|
||||
|
||||
def _create_edges_from_condition(
|
||||
condition: str | dict[str, Any] | list[Any] | FlowCondition,
|
||||
condition: FlowDefinitionCondition,
|
||||
target: str,
|
||||
nodes: dict[str, NodeMetadata],
|
||||
) -> list[StructureEdge]:
|
||||
"""Create edges from a condition tree, preserving AND/OR semantics.
|
||||
|
||||
This function recursively processes the condition tree and creates edges
|
||||
with the appropriate condition_type for each trigger.
|
||||
|
||||
For AND conditions, all triggers get edges with condition_type="AND".
|
||||
For OR conditions, triggers get edges with condition_type="OR".
|
||||
|
||||
Args:
|
||||
condition: The condition tree (string, dict, or list).
|
||||
target: The target node name.
|
||||
nodes: Dictionary of all nodes for validation.
|
||||
|
||||
Returns:
|
||||
List of StructureEdge objects representing the condition.
|
||||
"""
|
||||
edges: list[StructureEdge] = []
|
||||
|
||||
if isinstance(condition, str):
|
||||
@@ -138,21 +125,8 @@ def _create_edges_from_condition(
|
||||
is_router_path=False,
|
||||
)
|
||||
)
|
||||
elif callable(condition) and hasattr(condition, "__name__"):
|
||||
method_name = condition.__name__
|
||||
if method_name in nodes:
|
||||
edges.append(
|
||||
StructureEdge(
|
||||
source=method_name,
|
||||
target=target,
|
||||
condition_type=OR_CONDITION,
|
||||
is_router_path=False,
|
||||
)
|
||||
)
|
||||
elif isinstance(condition, dict):
|
||||
cond_type = condition.get("type", OR_CONDITION)
|
||||
conditions_list = condition.get("conditions", [])
|
||||
|
||||
cond_type, conditions = _definition_condition_parts(condition)
|
||||
if cond_type == AND_CONDITION:
|
||||
triggers = _extract_all_trigger_names(condition)
|
||||
edges.extend(
|
||||
@@ -166,271 +140,138 @@ def _create_edges_from_condition(
|
||||
if trigger in nodes
|
||||
)
|
||||
else:
|
||||
for sub_cond in conditions_list:
|
||||
edges.extend(_create_edges_from_condition(sub_cond, target, nodes))
|
||||
elif isinstance(condition, list):
|
||||
for item in condition:
|
||||
edges.extend(_create_edges_from_condition(item, target, nodes))
|
||||
for sub_condition in conditions:
|
||||
edges.extend(_create_edges_from_condition(sub_condition, target, nodes))
|
||||
|
||||
return edges
|
||||
|
||||
|
||||
def build_flow_structure(flow: Flow[Any]) -> FlowStructure:
|
||||
"""Build a structure representation of a Flow's execution.
|
||||
def _flow_definition_from(
|
||||
flow_or_definition: Flow[Any] | type[Flow[Any]] | FlowDefinition,
|
||||
) -> FlowDefinition:
|
||||
if isinstance(flow_or_definition, FlowDefinition):
|
||||
return flow_or_definition
|
||||
|
||||
Args:
|
||||
flow: Flow instance to analyze.
|
||||
flow_class = (
|
||||
flow_or_definition
|
||||
if isinstance(flow_or_definition, type)
|
||||
else type(flow_or_definition)
|
||||
)
|
||||
flow_definition = getattr(flow_class, "flow_definition", None)
|
||||
if callable(flow_definition):
|
||||
return cast(FlowDefinition, flow_definition())
|
||||
raise TypeError(
|
||||
"build_flow_structure requires a FlowDefinition or a Flow class/instance "
|
||||
"with flow_definition()."
|
||||
)
|
||||
|
||||
Returns:
|
||||
Dictionary with nodes, edges, start_methods, and router_methods.
|
||||
"""
|
||||
|
||||
def build_flow_structure(
|
||||
flow_or_definition: Flow[Any] | type[Flow[Any]] | FlowDefinition,
|
||||
) -> FlowStructure:
|
||||
"""Build a visualization structure projection from a FlowDefinition."""
|
||||
definition = _flow_definition_from(flow_or_definition)
|
||||
nodes: dict[str, NodeMetadata] = {}
|
||||
edges: list[StructureEdge] = []
|
||||
start_methods: list[str] = []
|
||||
router_methods: list[str] = []
|
||||
|
||||
for method_name, method in flow._methods.items():
|
||||
node_metadata: NodeMetadata = {"type": "listen"}
|
||||
for method_name, method_definition in definition.methods.items():
|
||||
node_metadata: NodeMetadata = {"type": "listen", "class_name": definition.name}
|
||||
|
||||
if hasattr(method, "__is_start_method__") and method.__is_start_method__:
|
||||
if method_definition.is_start:
|
||||
node_metadata["type"] = "start"
|
||||
start_methods.append(method_name)
|
||||
|
||||
if hasattr(method, "__is_router__") and method.__is_router__:
|
||||
if method_definition.router:
|
||||
node_metadata["is_router"] = True
|
||||
node_metadata["type"] = "router"
|
||||
router_methods.append(method_name)
|
||||
router_paths = _method_router_paths(method_definition)
|
||||
if router_paths:
|
||||
node_metadata["router_paths"] = router_paths
|
||||
|
||||
if method_name in flow._router_paths:
|
||||
node_metadata["router_paths"] = [
|
||||
str(p) for p in flow._router_paths[method_name]
|
||||
]
|
||||
|
||||
if hasattr(method, "__trigger_methods__") and method.__trigger_methods__:
|
||||
node_metadata["trigger_methods"] = [
|
||||
str(m) for m in method.__trigger_methods__
|
||||
]
|
||||
|
||||
if hasattr(method, "__condition_type__") and method.__condition_type__:
|
||||
node_metadata["trigger_condition_type"] = method.__condition_type__
|
||||
if "condition_type" not in node_metadata:
|
||||
node_metadata["condition_type"] = method.__condition_type__
|
||||
trigger_condition = _method_trigger_condition(method_definition)
|
||||
condition_type = _condition_type_from_definition(trigger_condition)
|
||||
if condition_type is not None and trigger_condition is not None:
|
||||
node_metadata["trigger_condition_type"] = condition_type
|
||||
node_metadata["condition_type"] = condition_type
|
||||
extracted = _extract_all_trigger_names(trigger_condition)
|
||||
if extracted:
|
||||
node_metadata["trigger_methods"] = extracted
|
||||
runtime_condition = _runtime_condition_from_definition(trigger_condition)
|
||||
if isinstance(runtime_condition, dict):
|
||||
node_metadata["trigger_condition"] = runtime_condition
|
||||
|
||||
if node_metadata.get("is_router") and "condition_type" not in node_metadata:
|
||||
node_metadata["condition_type"] = "IF"
|
||||
|
||||
if (
|
||||
hasattr(method, "__trigger_condition__")
|
||||
and method.__trigger_condition__ is not None
|
||||
):
|
||||
node_metadata["trigger_condition"] = method.__trigger_condition__
|
||||
|
||||
if "trigger_methods" not in node_metadata:
|
||||
extracted = _extract_all_trigger_names(method.__trigger_condition__)
|
||||
if extracted:
|
||||
node_metadata["trigger_methods"] = extracted
|
||||
|
||||
node_metadata["method_signature"] = extract_method_signature(
|
||||
method, method_name
|
||||
)
|
||||
|
||||
try:
|
||||
source_code = inspect.getsource(method)
|
||||
node_metadata["source_code"] = source_code
|
||||
|
||||
try:
|
||||
source_lines, start_line = inspect.getsourcelines(method)
|
||||
node_metadata["source_lines"] = source_lines
|
||||
node_metadata["source_start_line"] = start_line
|
||||
except (OSError, TypeError):
|
||||
pass
|
||||
|
||||
try:
|
||||
source_file = inspect.getsourcefile(method)
|
||||
if source_file:
|
||||
node_metadata["source_file"] = source_file
|
||||
except (OSError, TypeError):
|
||||
try:
|
||||
class_file = inspect.getsourcefile(flow.__class__)
|
||||
if class_file:
|
||||
node_metadata["source_file"] = class_file
|
||||
except (OSError, TypeError):
|
||||
pass
|
||||
except (OSError, TypeError):
|
||||
pass
|
||||
|
||||
try:
|
||||
class_obj = flow.__class__
|
||||
|
||||
if class_obj:
|
||||
class_name = class_obj.__name__
|
||||
|
||||
bases = class_obj.__bases__
|
||||
if bases:
|
||||
base_strs = []
|
||||
for base in bases:
|
||||
if hasattr(base, "__name__"):
|
||||
if hasattr(base, "__origin__"):
|
||||
base_strs.append(str(base))
|
||||
else:
|
||||
base_strs.append(base.__name__)
|
||||
else:
|
||||
base_strs.append(str(base))
|
||||
|
||||
try:
|
||||
source_lines = inspect.getsource(class_obj).split("\n")
|
||||
_, class_start_line = inspect.getsourcelines(class_obj)
|
||||
|
||||
for idx, line in enumerate(source_lines):
|
||||
stripped = line.strip()
|
||||
if stripped.startswith("class ") and class_name in stripped:
|
||||
class_signature = stripped.rstrip(":")
|
||||
node_metadata["class_signature"] = class_signature
|
||||
node_metadata["class_line_number"] = (
|
||||
class_start_line + idx
|
||||
)
|
||||
break
|
||||
except (OSError, TypeError):
|
||||
class_signature = f"class {class_name}({', '.join(base_strs)})"
|
||||
node_metadata["class_signature"] = class_signature
|
||||
else:
|
||||
class_signature = f"class {class_name}"
|
||||
node_metadata["class_signature"] = class_signature
|
||||
|
||||
node_metadata["class_name"] = class_name
|
||||
except (OSError, TypeError, AttributeError):
|
||||
pass
|
||||
|
||||
nodes[method_name] = node_metadata
|
||||
|
||||
for listener_name, condition_data in flow._listeners.items():
|
||||
if listener_name in router_methods:
|
||||
for method_name, method_definition in definition.methods.items():
|
||||
trigger_condition = _method_trigger_condition(method_definition)
|
||||
if trigger_condition is None:
|
||||
continue
|
||||
|
||||
if is_simple_flow_condition(condition_data):
|
||||
cond_type, methods = condition_data
|
||||
edges.extend(
|
||||
StructureEdge(
|
||||
source=str(trigger_method),
|
||||
target=str(listener_name),
|
||||
condition_type=cond_type,
|
||||
is_router_path=False,
|
||||
)
|
||||
for trigger_method in methods
|
||||
if str(trigger_method) in nodes
|
||||
)
|
||||
elif is_flow_condition_dict(condition_data):
|
||||
edges.extend(
|
||||
_create_edges_from_condition(condition_data, str(listener_name), nodes)
|
||||
)
|
||||
|
||||
for method_name, node_metadata in nodes.items(): # type: ignore[assignment]
|
||||
if node_metadata.get("is_router") and "trigger_methods" in node_metadata:
|
||||
trigger_methods = node_metadata["trigger_methods"]
|
||||
condition_type = node_metadata.get("trigger_condition_type", OR_CONDITION)
|
||||
|
||||
if "trigger_condition" in node_metadata:
|
||||
edges.extend(
|
||||
_create_edges_from_condition(
|
||||
node_metadata["trigger_condition"], # type: ignore[arg-type]
|
||||
method_name,
|
||||
nodes,
|
||||
)
|
||||
)
|
||||
else:
|
||||
edges.extend(
|
||||
StructureEdge(
|
||||
source=trigger_method,
|
||||
target=method_name,
|
||||
condition_type=condition_type,
|
||||
is_router_path=False,
|
||||
)
|
||||
for trigger_method in trigger_methods
|
||||
if trigger_method in nodes
|
||||
)
|
||||
edges.extend(
|
||||
_create_edges_from_condition(trigger_condition, method_name, nodes)
|
||||
)
|
||||
|
||||
all_string_triggers: set[str] = set()
|
||||
for condition_data in flow._listeners.values():
|
||||
if is_simple_flow_condition(condition_data):
|
||||
_, methods = condition_data
|
||||
for m in methods:
|
||||
if str(m) not in nodes: # It's a string trigger, not a method name
|
||||
all_string_triggers.add(str(m))
|
||||
elif is_flow_condition_dict(condition_data):
|
||||
for trigger in _extract_direct_or_triggers(condition_data):
|
||||
if trigger not in nodes:
|
||||
all_string_triggers.add(trigger)
|
||||
for method_definition in definition.methods.values():
|
||||
trigger_condition = _method_trigger_condition(method_definition)
|
||||
if trigger_condition is None:
|
||||
continue
|
||||
for trigger in _extract_direct_or_triggers(trigger_condition):
|
||||
if trigger not in nodes:
|
||||
all_string_triggers.add(trigger)
|
||||
|
||||
all_router_outputs: set[str] = set()
|
||||
for router_method_name in router_methods:
|
||||
if router_method_name not in flow._router_paths:
|
||||
flow._router_paths[FlowMethodName(router_method_name)] = []
|
||||
router_paths = _method_router_paths(definition.methods[router_method_name])
|
||||
if router_paths and router_method_name in nodes:
|
||||
nodes[router_method_name]["router_paths"] = router_paths
|
||||
all_router_outputs.update(router_paths)
|
||||
|
||||
current_paths = flow._router_paths.get(FlowMethodName(router_method_name), [])
|
||||
if current_paths and router_method_name in nodes:
|
||||
nodes[router_method_name]["router_paths"] = [str(p) for p in current_paths]
|
||||
all_router_outputs.update(str(p) for p in current_paths)
|
||||
|
||||
if not current_paths:
|
||||
if not router_paths:
|
||||
logger.warning(
|
||||
f"Could not determine return paths for router '{router_method_name}'. "
|
||||
f"Add a return type annotation like "
|
||||
f"'-> Literal[\"path1\", \"path2\"]' or '-> YourEnum' "
|
||||
f"to enable proper flow visualization."
|
||||
f"Router paths for '{router_method_name}' are dynamic or not "
|
||||
f"statically inferable; static visualization may omit route edges."
|
||||
)
|
||||
|
||||
orphaned_triggers = all_string_triggers - all_router_outputs
|
||||
if orphaned_triggers:
|
||||
logger.error(
|
||||
f"Found listeners waiting for triggers {orphaned_triggers} "
|
||||
f"but no router outputs these values explicitly. "
|
||||
f"If your router returns a non-static value, check that your router has proper return type annotations."
|
||||
logger.warning(
|
||||
f"Static visualization could not match listener triggers "
|
||||
f"{orphaned_triggers} to explicit router outputs. "
|
||||
f"Dynamic router values may still trigger these listeners at runtime."
|
||||
)
|
||||
|
||||
for router_method_name in router_methods:
|
||||
if router_method_name not in flow._router_paths:
|
||||
continue
|
||||
|
||||
router_paths = flow._router_paths[FlowMethodName(router_method_name)]
|
||||
router_paths = _method_router_paths(definition.methods[router_method_name])
|
||||
|
||||
for path in router_paths:
|
||||
for listener_name, condition_data in flow._listeners.items():
|
||||
for listener_name, method_definition in definition.methods.items():
|
||||
if listener_name == router_method_name:
|
||||
continue
|
||||
|
||||
trigger_strings_from_cond: list[str] = []
|
||||
|
||||
if is_simple_flow_condition(condition_data):
|
||||
_, methods = condition_data
|
||||
trigger_strings_from_cond = [str(m) for m in methods]
|
||||
elif is_flow_condition_dict(condition_data):
|
||||
trigger_strings_from_cond = _extract_direct_or_triggers(
|
||||
condition_data
|
||||
)
|
||||
trigger_condition = _method_trigger_condition(method_definition)
|
||||
if trigger_condition is None:
|
||||
continue
|
||||
trigger_strings_from_cond = _extract_direct_or_triggers(
|
||||
trigger_condition
|
||||
)
|
||||
|
||||
if str(path) in trigger_strings_from_cond:
|
||||
edges.append(
|
||||
StructureEdge(
|
||||
source=router_method_name,
|
||||
target=str(listener_name),
|
||||
target=listener_name,
|
||||
condition_type=None,
|
||||
is_router_path=True,
|
||||
router_path_label=str(path),
|
||||
)
|
||||
)
|
||||
|
||||
for start_method in flow._start_methods:
|
||||
if start_method not in nodes and start_method in flow._methods:
|
||||
method = flow._methods[start_method]
|
||||
nodes[str(start_method)] = NodeMetadata(type="start")
|
||||
|
||||
if hasattr(method, "__trigger_methods__") and method.__trigger_methods__:
|
||||
nodes[str(start_method)]["trigger_methods"] = [
|
||||
str(m) for m in method.__trigger_methods__
|
||||
]
|
||||
if hasattr(method, "__condition_type__") and method.__condition_type__:
|
||||
nodes[str(start_method)]["condition_type"] = method.__condition_type__
|
||||
|
||||
return FlowStructure(
|
||||
nodes=nodes,
|
||||
edges=edges,
|
||||
@@ -466,15 +307,6 @@ def calculate_execution_paths(structure: FlowStructure) -> int:
|
||||
return 0
|
||||
|
||||
def count_paths_from(node: str, visited: set[str]) -> int:
|
||||
"""Recursively count execution paths from a given node.
|
||||
|
||||
Args:
|
||||
node: Node name to start counting from.
|
||||
visited: Set of already visited nodes to prevent cycles.
|
||||
|
||||
Returns:
|
||||
Number of execution paths from this node to terminal nodes.
|
||||
"""
|
||||
if node in terminal_nodes:
|
||||
return 1
|
||||
|
||||
|
||||
@@ -1,104 +0,0 @@
|
||||
"""OpenAPI schema conversion utilities for Flow methods."""
|
||||
|
||||
import inspect
|
||||
from typing import Any, get_args, get_origin
|
||||
|
||||
|
||||
def type_to_openapi_schema(type_hint: Any) -> dict[str, Any]:
|
||||
"""Convert Python type hint to OpenAPI schema.
|
||||
|
||||
Args:
|
||||
type_hint: Python type hint to convert.
|
||||
|
||||
Returns:
|
||||
OpenAPI schema dictionary.
|
||||
"""
|
||||
if type_hint is inspect.Parameter.empty:
|
||||
return {}
|
||||
|
||||
if type_hint is None or type_hint is type(None):
|
||||
return {"type": "null"}
|
||||
|
||||
if hasattr(type_hint, "__module__") and hasattr(type_hint, "__name__"):
|
||||
if type_hint.__module__ == "typing" and type_hint.__name__ == "Any":
|
||||
return {}
|
||||
|
||||
type_str = str(type_hint)
|
||||
if type_str == "typing.Any" or type_str == "<class 'typing.Any'>":
|
||||
return {}
|
||||
|
||||
if isinstance(type_hint, str):
|
||||
return {"type": type_hint}
|
||||
|
||||
origin = get_origin(type_hint)
|
||||
args = get_args(type_hint)
|
||||
|
||||
if type_hint is str:
|
||||
return {"type": "string"}
|
||||
if type_hint is int:
|
||||
return {"type": "integer"}
|
||||
if type_hint is float:
|
||||
return {"type": "number"}
|
||||
if type_hint is bool:
|
||||
return {"type": "boolean"}
|
||||
if type_hint is dict or origin is dict:
|
||||
if args and len(args) > 1:
|
||||
return {
|
||||
"type": "object",
|
||||
"additionalProperties": type_to_openapi_schema(args[1]),
|
||||
}
|
||||
return {"type": "object"}
|
||||
if type_hint is list or origin is list:
|
||||
if args:
|
||||
return {"type": "array", "items": type_to_openapi_schema(args[0])}
|
||||
return {"type": "array"}
|
||||
if hasattr(type_hint, "__name__"):
|
||||
return {"type": "object", "className": type_hint.__name__}
|
||||
|
||||
return {}
|
||||
|
||||
|
||||
def extract_method_signature(method: Any, method_name: str) -> dict[str, Any]:
|
||||
"""Extract method signature as OpenAPI schema with documentation.
|
||||
|
||||
Args:
|
||||
method: Method to analyze.
|
||||
method_name: Method name.
|
||||
|
||||
Returns:
|
||||
Dictionary with operationId, parameters, returns, summary, and description.
|
||||
"""
|
||||
try:
|
||||
sig = inspect.signature(method)
|
||||
|
||||
parameters = {}
|
||||
for param_name, param in sig.parameters.items():
|
||||
if param_name == "self":
|
||||
continue
|
||||
parameters[param_name] = type_to_openapi_schema(param.annotation)
|
||||
|
||||
return_type = type_to_openapi_schema(sig.return_annotation)
|
||||
|
||||
docstring = inspect.getdoc(method)
|
||||
|
||||
result: dict[str, Any] = {
|
||||
"operationId": method_name,
|
||||
"parameters": parameters,
|
||||
"returns": return_type,
|
||||
}
|
||||
|
||||
if docstring:
|
||||
lines = docstring.strip().split("\n")
|
||||
summary = lines[0].strip()
|
||||
|
||||
if summary:
|
||||
result["summary"] = summary
|
||||
|
||||
if len(lines) > 1:
|
||||
description = "\n".join(line.strip() for line in lines[1:]).strip()
|
||||
if description:
|
||||
result["description"] = description
|
||||
|
||||
return result
|
||||
except Exception:
|
||||
return {"operationId": method_name, "parameters": {}, "returns": {}}
|
||||
@@ -3,6 +3,9 @@
|
||||
from typing import Any, TypedDict
|
||||
|
||||
|
||||
__all__ = ["FlowStructure", "NodeMetadata", "StructureEdge"]
|
||||
|
||||
|
||||
class NodeMetadata(TypedDict, total=False):
|
||||
"""Metadata for a single node in the flow structure."""
|
||||
|
||||
@@ -13,14 +16,7 @@ class NodeMetadata(TypedDict, total=False):
|
||||
trigger_condition_type: str | None
|
||||
trigger_methods: list[str]
|
||||
trigger_condition: dict[str, Any] | None
|
||||
method_signature: dict[str, Any]
|
||||
source_code: str
|
||||
source_lines: list[str]
|
||||
source_start_line: int
|
||||
source_file: str
|
||||
class_signature: str
|
||||
class_name: str
|
||||
class_line_number: int
|
||||
|
||||
|
||||
class StructureEdge(TypedDict, total=False):
|
||||
|
||||
@@ -288,6 +288,7 @@ SUPPORTED_NATIVE_PROVIDERS: Final[list[str]] = [
|
||||
"hosted_vllm",
|
||||
"cerebras",
|
||||
"dashscope",
|
||||
"snowflake",
|
||||
]
|
||||
|
||||
|
||||
@@ -376,6 +377,7 @@ class LLM(BaseLLM):
|
||||
"hosted_vllm": "hosted_vllm",
|
||||
"cerebras": "cerebras",
|
||||
"dashscope": "dashscope",
|
||||
"snowflake": "snowflake",
|
||||
}
|
||||
|
||||
canonical_provider = provider_mapping.get(prefix.lower())
|
||||
@@ -494,6 +496,9 @@ class LLM(BaseLLM):
|
||||
# OpenRouter uses org/model format but accepts anything
|
||||
return True
|
||||
|
||||
if provider == "snowflake":
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
@@ -592,6 +597,11 @@ class LLM(BaseLLM):
|
||||
|
||||
return BedrockCompletion
|
||||
|
||||
if provider == "snowflake":
|
||||
from crewai.llms.providers.snowflake.completion import SnowflakeCompletion
|
||||
|
||||
return SnowflakeCompletion
|
||||
|
||||
openai_compatible_providers = {
|
||||
"openrouter",
|
||||
"deepseek",
|
||||
|
||||
244
lib/crewai/src/crewai/llms/providers/snowflake/completion.py
Normal file
244
lib/crewai/src/crewai/llms/providers/snowflake/completion.py
Normal file
@@ -0,0 +1,244 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import model_validator
|
||||
|
||||
from crewai.llms.providers.openai.completion import OpenAICompletion
|
||||
from crewai.utilities.types import LLMMessage
|
||||
|
||||
|
||||
SNOWFLAKE_CORTEX_PATH = "/api/v2/cortex/v1"
|
||||
SNOWFLAKE_TOKEN_ENV_VARS = (
|
||||
"SNOWFLAKE_PAT",
|
||||
"SNOWFLAKE_TOKEN",
|
||||
"SNOWFLAKE_JWT",
|
||||
)
|
||||
|
||||
|
||||
def _normalize_snowflake_base_url(value: str) -> str:
|
||||
"""Return a Snowflake Cortex REST OpenAI-compatible base URL."""
|
||||
base_url = value.strip().rstrip("/")
|
||||
if not base_url:
|
||||
raise ValueError("Snowflake account URL cannot be empty")
|
||||
|
||||
if "://" not in base_url:
|
||||
base_url = f"https://{base_url}"
|
||||
|
||||
if base_url.endswith(SNOWFLAKE_CORTEX_PATH):
|
||||
return base_url
|
||||
|
||||
if "/api/v2/cortex" in base_url:
|
||||
raise ValueError(
|
||||
"Snowflake base URL must be the account URL or Cortex API root "
|
||||
f"ending in {SNOWFLAKE_CORTEX_PATH}; do not include endpoint paths."
|
||||
)
|
||||
|
||||
return f"{base_url}{SNOWFLAKE_CORTEX_PATH}"
|
||||
|
||||
|
||||
def _base_url_from_account_identifier(account_identifier: str) -> str:
|
||||
account = account_identifier.strip()
|
||||
if not account:
|
||||
raise ValueError("Snowflake account identifier cannot be empty")
|
||||
return _normalize_snowflake_base_url(f"{account}.snowflakecomputing.com")
|
||||
|
||||
|
||||
class SnowflakeCompletion(OpenAICompletion):
|
||||
"""Snowflake Cortex REST API native completion implementation.
|
||||
|
||||
Snowflake exposes an OpenAI-compatible Chat Completions endpoint at
|
||||
``/api/v2/cortex/v1/chat/completions``. This provider reuses CrewAI's
|
||||
native OpenAI transport while applying Snowflake-specific authentication,
|
||||
endpoint normalization, and Claude-family message constraints.
|
||||
"""
|
||||
|
||||
provider: str = "snowflake"
|
||||
api: Literal["completions"] = "completions"
|
||||
account_url: str | None = None
|
||||
account_identifier: str | None = None
|
||||
database: str | None = None
|
||||
schema_name: str | None = None
|
||||
warehouse: str | None = None
|
||||
role: str | None = None
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
def _normalize_snowflake_fields(cls, data: Any) -> Any:
|
||||
if not isinstance(data, dict):
|
||||
return data
|
||||
|
||||
data["provider"] = "snowflake"
|
||||
api = data.get("api")
|
||||
if api and api != "completions":
|
||||
raise ValueError(
|
||||
"Snowflake Cortex native provider supports only the Chat Completions API"
|
||||
)
|
||||
data["api"] = "completions"
|
||||
|
||||
data["api_key"] = cls._resolve_token(data.get("api_key"))
|
||||
resolved_base_url = cls._resolve_base_url(data)
|
||||
data["base_url"] = resolved_base_url
|
||||
data["account_url"] = resolved_base_url
|
||||
|
||||
return data
|
||||
|
||||
@staticmethod
|
||||
def _resolve_token(api_key: str | None) -> str:
|
||||
token = api_key
|
||||
if not token:
|
||||
for env_var in SNOWFLAKE_TOKEN_ENV_VARS:
|
||||
token = os.getenv(env_var)
|
||||
if token:
|
||||
break
|
||||
|
||||
if not token:
|
||||
raise ValueError(
|
||||
"Snowflake token is required. Set SNOWFLAKE_PAT, SNOWFLAKE_TOKEN, "
|
||||
"or SNOWFLAKE_JWT, or pass api_key."
|
||||
)
|
||||
|
||||
if token.startswith("pat/"):
|
||||
token = token.removeprefix("pat/")
|
||||
|
||||
return token
|
||||
|
||||
@classmethod
|
||||
def _resolve_base_url(cls, data: dict[str, Any]) -> str:
|
||||
explicit_base_url = data.get("base_url") or data.get("api_base")
|
||||
if explicit_base_url:
|
||||
return _normalize_snowflake_base_url(explicit_base_url)
|
||||
|
||||
account_url = data.get("account_url") or os.getenv("SNOWFLAKE_ACCOUNT_URL")
|
||||
if account_url:
|
||||
return _normalize_snowflake_base_url(account_url)
|
||||
|
||||
account_identifier = (
|
||||
data.get("account_identifier")
|
||||
or data.get("account")
|
||||
or data.get("snowflake_account")
|
||||
or os.getenv("SNOWFLAKE_ACCOUNT")
|
||||
or os.getenv("SNOWFLAKE_ACCOUNT_ID")
|
||||
or os.getenv("SNOWFLAKE_ACCOUNT_IDENTIFIER")
|
||||
)
|
||||
if account_identifier:
|
||||
return _base_url_from_account_identifier(account_identifier)
|
||||
|
||||
raise ValueError(
|
||||
"Snowflake account URL is required. Set SNOWFLAKE_ACCOUNT_URL or "
|
||||
"SNOWFLAKE_ACCOUNT, or pass account_url/base_url/account_identifier."
|
||||
)
|
||||
|
||||
def _format_messages(self, messages: str | list[LLMMessage]) -> list[LLMMessage]:
|
||||
formatted_messages = super()._format_messages(messages)
|
||||
if self._is_claude_model():
|
||||
formatted_messages = self._remove_incomplete_claude_tool_uses(
|
||||
formatted_messages
|
||||
)
|
||||
return self._ensure_claude_conversation_ends_with_user(formatted_messages)
|
||||
return formatted_messages
|
||||
|
||||
def _is_claude_model(self) -> bool:
|
||||
model = self.model.lower()
|
||||
return model.startswith(("claude-", "anthropic."))
|
||||
|
||||
@staticmethod
|
||||
def _remove_incomplete_claude_tool_uses(
|
||||
messages: list[LLMMessage],
|
||||
) -> list[LLMMessage]:
|
||||
"""Drop dangling Claude tool-use turns before sending to Snowflake.
|
||||
|
||||
Snowflake-hosted Claude models reject histories where an assistant tool
|
||||
use is not accompanied by matching tool results. CrewAI may retry or
|
||||
summarize after an interrupted tool cycle, leaving an assistant
|
||||
``tool_calls`` message in history without every corresponding
|
||||
``role='tool'`` result. OpenAI-family models tolerate that more often,
|
||||
but Claude through Snowflake returns:
|
||||
"Each 'toolUse' block must be accompanied with a matching 'toolResult' block."
|
||||
"""
|
||||
sanitized: list[LLMMessage] = []
|
||||
index = 0
|
||||
|
||||
while index < len(messages):
|
||||
message = messages[index]
|
||||
tool_calls = message.get("tool_calls") or []
|
||||
if message.get("role") != "assistant" or not tool_calls:
|
||||
sanitized.append(message)
|
||||
index += 1
|
||||
continue
|
||||
|
||||
expected_ids = {
|
||||
tool_call.get("id")
|
||||
for tool_call in tool_calls
|
||||
if isinstance(tool_call, dict) and tool_call.get("id")
|
||||
}
|
||||
if not expected_ids:
|
||||
sanitized.append(message)
|
||||
index += 1
|
||||
continue
|
||||
|
||||
tool_result_ids: set[str] = set()
|
||||
lookahead = index + 1
|
||||
while (
|
||||
lookahead < len(messages) and messages[lookahead].get("role") == "tool"
|
||||
):
|
||||
tool_call_id = messages[lookahead].get("tool_call_id")
|
||||
if isinstance(tool_call_id, str):
|
||||
tool_result_ids.add(tool_call_id)
|
||||
lookahead += 1
|
||||
|
||||
if expected_ids.issubset(tool_result_ids):
|
||||
sanitized.append(message)
|
||||
sanitized.extend(
|
||||
tool_message
|
||||
for tool_message in messages[index + 1 : lookahead]
|
||||
if tool_message.get("role") == "tool"
|
||||
and tool_message.get("tool_call_id") in expected_ids
|
||||
)
|
||||
|
||||
index = lookahead
|
||||
|
||||
return sanitized
|
||||
|
||||
@staticmethod
|
||||
def _ensure_claude_conversation_ends_with_user(
|
||||
messages: list[LLMMessage],
|
||||
) -> list[LLMMessage]:
|
||||
if not messages:
|
||||
return [{"role": "user", "content": "Hello"}]
|
||||
|
||||
if messages[-1].get("role") == "assistant" and not messages[-1].get(
|
||||
"tool_calls"
|
||||
):
|
||||
messages = messages[:-1]
|
||||
|
||||
if not messages:
|
||||
return [{"role": "user", "content": "Hello"}]
|
||||
|
||||
if messages[-1].get("role") == "user":
|
||||
return messages
|
||||
|
||||
return [
|
||||
*messages,
|
||||
{
|
||||
"role": "user",
|
||||
"content": "Please continue and provide your final answer.",
|
||||
},
|
||||
]
|
||||
|
||||
def _prepare_completion_params(
|
||||
self, messages: list[LLMMessage], tools: list[dict[str, Any]] | None = None
|
||||
) -> dict[str, Any]:
|
||||
params = super()._prepare_completion_params(messages=messages, tools=tools)
|
||||
if self._is_claude_model() and "max_tokens" in params:
|
||||
params["max_completion_tokens"] = params.pop("max_tokens")
|
||||
return params
|
||||
|
||||
def supports_function_calling(self) -> bool:
|
||||
model = self.model.lower()
|
||||
return model.startswith(("openai-", "claude-", "anthropic."))
|
||||
|
||||
def supports_multimodal(self) -> bool:
|
||||
model = self.model.lower()
|
||||
return model.startswith(("openai-", "claude-", "anthropic."))
|
||||
@@ -1067,6 +1067,62 @@ def test_agent_use_trained_data_honors_env_var(crew_training_handler, monkeypatc
|
||||
)
|
||||
|
||||
|
||||
@patch("crewai.agent.core.CrewTrainingHandler")
|
||||
def test_agent_use_trained_data_prefers_crew_trained_agents_file(
|
||||
crew_training_handler, monkeypatch
|
||||
):
|
||||
monkeypatch.setenv("CREWAI_TRAINED_AGENTS_FILE", "env_trained.pkl")
|
||||
agent = Agent(
|
||||
role="researcher",
|
||||
goal="test goal",
|
||||
backstory="test backstory",
|
||||
)
|
||||
task = Task(
|
||||
description="Research the topic",
|
||||
expected_output="A short report",
|
||||
agent=agent,
|
||||
)
|
||||
crew = Crew(agents=[agent], tasks=[task], trained_agents_file="crew_trained.pkl")
|
||||
agent.crew = crew
|
||||
crew_training_handler.return_value.load.return_value = {}
|
||||
|
||||
agent._use_trained_data(task_prompt="What is 1 + 1?")
|
||||
|
||||
crew_training_handler.assert_has_calls(
|
||||
[mock.call("crew_trained.pkl"), mock.call().load()]
|
||||
)
|
||||
|
||||
|
||||
@patch("crewai.agent.core.CrewTrainingHandler")
|
||||
def test_agent_use_trained_data_accepts_crew_trained_agents_file_path(
|
||||
crew_training_handler, tmp_path
|
||||
):
|
||||
agent = Agent(
|
||||
role="researcher",
|
||||
goal="test goal",
|
||||
backstory="test backstory",
|
||||
)
|
||||
task = Task(
|
||||
description="Research the topic",
|
||||
expected_output="A short report",
|
||||
agent=agent,
|
||||
)
|
||||
trained_agents_file = tmp_path / "crew_trained.pkl"
|
||||
crew = Crew(
|
||||
agents=[agent],
|
||||
tasks=[task],
|
||||
trained_agents_file=trained_agents_file,
|
||||
)
|
||||
agent.crew = crew
|
||||
crew_training_handler.return_value.load.return_value = {}
|
||||
|
||||
agent._use_trained_data(task_prompt="What is 1 + 1?")
|
||||
|
||||
crew_training_handler.assert_has_calls(
|
||||
[mock.call(str(trained_agents_file)), mock.call().load()]
|
||||
)
|
||||
|
||||
|
||||
def test_agent_use_trained_data_skips_load_when_file_missing(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv(
|
||||
"CREWAI_TRAINED_AGENTS_FILE", str(tmp_path / "does_not_exist.pkl")
|
||||
|
||||
321
lib/crewai/tests/llms/snowflake/test_snowflake.py
Normal file
321
lib/crewai/tests/llms/snowflake/test_snowflake.py
Normal file
@@ -0,0 +1,321 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from crewai.llm import LLM
|
||||
from crewai.llms.providers.snowflake.completion import (
|
||||
SNOWFLAKE_CORTEX_PATH,
|
||||
SnowflakeCompletion,
|
||||
_normalize_snowflake_base_url,
|
||||
)
|
||||
|
||||
|
||||
def _snowflake_env(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("SNOWFLAKE_PAT", "test-pat")
|
||||
monkeypatch.setenv("SNOWFLAKE_ACCOUNT_URL", "https://org-account.snowflakecomputing.com")
|
||||
monkeypatch.delenv("SNOWFLAKE_TOKEN", raising=False)
|
||||
monkeypatch.delenv("SNOWFLAKE_JWT", raising=False)
|
||||
monkeypatch.delenv("SNOWFLAKE_ACCOUNT", raising=False)
|
||||
monkeypatch.delenv("SNOWFLAKE_ACCOUNT_ID", raising=False)
|
||||
monkeypatch.delenv("SNOWFLAKE_ACCOUNT_IDENTIFIER", raising=False)
|
||||
|
||||
|
||||
class TestSnowflakeConfig:
|
||||
def test_normalizes_account_url_to_cortex_base_url(self):
|
||||
assert (
|
||||
_normalize_snowflake_base_url("https://org-account.snowflakecomputing.com")
|
||||
== f"https://org-account.snowflakecomputing.com{SNOWFLAKE_CORTEX_PATH}"
|
||||
)
|
||||
|
||||
def test_preserves_existing_cortex_base_url(self):
|
||||
base_url = f"https://org-account.snowflakecomputing.com{SNOWFLAKE_CORTEX_PATH}"
|
||||
assert _normalize_snowflake_base_url(base_url) == base_url
|
||||
|
||||
def test_rejects_endpoint_path_in_base_url(self):
|
||||
with pytest.raises(ValueError, match="do not include endpoint paths"):
|
||||
_normalize_snowflake_base_url(
|
||||
"https://org-account.snowflakecomputing.com"
|
||||
f"{SNOWFLAKE_CORTEX_PATH}/chat/completions"
|
||||
)
|
||||
|
||||
def test_empty_api_key_falls_back_to_env_token(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
_snowflake_env(monkeypatch)
|
||||
|
||||
llm = SnowflakeCompletion(model="openai-gpt-4.1", api_key="")
|
||||
|
||||
assert llm.api_key == "test-pat"
|
||||
|
||||
def test_uses_env_token_and_account_url(self, monkeypatch: pytest.MonkeyPatch):
|
||||
_snowflake_env(monkeypatch)
|
||||
|
||||
llm = SnowflakeCompletion(model="openai-gpt-4.1")
|
||||
|
||||
assert llm.api_key == "test-pat"
|
||||
assert llm.base_url == (
|
||||
f"https://org-account.snowflakecomputing.com{SNOWFLAKE_CORTEX_PATH}"
|
||||
)
|
||||
assert llm.account_url == llm.base_url
|
||||
|
||||
def test_strips_litellm_pat_prefix_for_compatibility(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
monkeypatch.setenv("SNOWFLAKE_PAT", "pat/test-pat")
|
||||
monkeypatch.setenv("SNOWFLAKE_ACCOUNT", "org-account")
|
||||
|
||||
llm = SnowflakeCompletion(model="openai-gpt-4.1")
|
||||
|
||||
assert llm.api_key == "test-pat"
|
||||
|
||||
def test_missing_token_raises_clear_error(self, monkeypatch: pytest.MonkeyPatch):
|
||||
monkeypatch.delenv("SNOWFLAKE_PAT", raising=False)
|
||||
monkeypatch.delenv("SNOWFLAKE_TOKEN", raising=False)
|
||||
monkeypatch.delenv("SNOWFLAKE_JWT", raising=False)
|
||||
monkeypatch.setenv("SNOWFLAKE_ACCOUNT_URL", "https://org-account.snowflakecomputing.com")
|
||||
|
||||
with pytest.raises(ValueError, match="Snowflake token is required"):
|
||||
SnowflakeCompletion(model="openai-gpt-4.1")
|
||||
|
||||
def test_missing_account_raises_clear_error(self, monkeypatch: pytest.MonkeyPatch):
|
||||
monkeypatch.setenv("SNOWFLAKE_PAT", "test-pat")
|
||||
monkeypatch.delenv("SNOWFLAKE_ACCOUNT_URL", raising=False)
|
||||
monkeypatch.delenv("SNOWFLAKE_ACCOUNT", raising=False)
|
||||
monkeypatch.delenv("SNOWFLAKE_ACCOUNT_ID", raising=False)
|
||||
monkeypatch.delenv("SNOWFLAKE_ACCOUNT_IDENTIFIER", raising=False)
|
||||
|
||||
with pytest.raises(ValueError, match="Snowflake account URL is required"):
|
||||
SnowflakeCompletion(model="openai-gpt-4.1")
|
||||
|
||||
def test_responses_api_is_rejected(self, monkeypatch: pytest.MonkeyPatch):
|
||||
_snowflake_env(monkeypatch)
|
||||
|
||||
with pytest.raises(ValueError, match="supports only the Chat Completions API"):
|
||||
SnowflakeCompletion(model="openai-gpt-4.1", api="responses")
|
||||
|
||||
|
||||
class TestSnowflakeFactory:
|
||||
def test_llm_creates_native_snowflake_provider(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
_snowflake_env(monkeypatch)
|
||||
|
||||
llm = LLM(model="snowflake/openai-gpt-4.1")
|
||||
|
||||
assert isinstance(llm, SnowflakeCompletion)
|
||||
assert llm.provider == "snowflake"
|
||||
assert llm.model == "openai-gpt-4.1"
|
||||
assert llm.is_litellm is False
|
||||
|
||||
def test_explicit_provider_creates_native_snowflake_provider(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
_snowflake_env(monkeypatch)
|
||||
|
||||
llm = LLM(model="claude-sonnet-4-5", provider="snowflake")
|
||||
|
||||
assert isinstance(llm, SnowflakeCompletion)
|
||||
assert llm.model == "claude-sonnet-4-5"
|
||||
|
||||
|
||||
class TestSnowflakeRequests:
|
||||
def test_prepare_completion_params_uses_snowflake_model_name(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
_snowflake_env(monkeypatch)
|
||||
llm = SnowflakeCompletion(
|
||||
model="openai-gpt-4.1",
|
||||
temperature=0.2,
|
||||
max_completion_tokens=128,
|
||||
)
|
||||
|
||||
params = llm._prepare_completion_params(
|
||||
[{"role": "user", "content": "Hello"}]
|
||||
)
|
||||
|
||||
assert params["model"] == "openai-gpt-4.1"
|
||||
assert params["temperature"] == 0.2
|
||||
assert params["max_completion_tokens"] == 128
|
||||
assert params["messages"] == [{"role": "user", "content": "Hello"}]
|
||||
|
||||
def test_claude_model_removes_trailing_assistant_prefill(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
_snowflake_env(monkeypatch)
|
||||
llm = SnowflakeCompletion(model="claude-sonnet-4-5")
|
||||
|
||||
messages = llm._format_messages(
|
||||
[
|
||||
{"role": "user", "content": "Write a summary."},
|
||||
{"role": "assistant", "content": "Here is"},
|
||||
]
|
||||
)
|
||||
|
||||
assert messages == [{"role": "user", "content": "Write a summary."}]
|
||||
|
||||
def test_claude_model_removes_dangling_tool_call_without_result(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
_snowflake_env(monkeypatch)
|
||||
llm = SnowflakeCompletion(model="claude-sonnet-4-5")
|
||||
|
||||
messages = llm._format_messages(
|
||||
[
|
||||
{"role": "user", "content": "Use the tool."},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": None,
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "call_1",
|
||||
"type": "function",
|
||||
"function": {"name": "lookup", "arguments": "{}"},
|
||||
}
|
||||
],
|
||||
},
|
||||
]
|
||||
)
|
||||
|
||||
assert messages == [{"role": "user", "content": "Use the tool."}]
|
||||
|
||||
def test_claude_model_preserves_complete_tool_call_result_pair(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
_snowflake_env(monkeypatch)
|
||||
llm = SnowflakeCompletion(model="claude-sonnet-4-5")
|
||||
|
||||
messages = llm._format_messages(
|
||||
[
|
||||
{"role": "user", "content": "Use the tool."},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": None,
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "call_1",
|
||||
"type": "function",
|
||||
"function": {"name": "lookup", "arguments": "{}"},
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
"role": "tool",
|
||||
"tool_call_id": "call_1",
|
||||
"content": "result",
|
||||
},
|
||||
]
|
||||
)
|
||||
|
||||
assert messages[-3]["role"] == "assistant"
|
||||
assert messages[-3]["tool_calls"][0]["id"] == "call_1"
|
||||
assert messages[-2] == {
|
||||
"role": "tool",
|
||||
"tool_call_id": "call_1",
|
||||
"content": "result",
|
||||
}
|
||||
assert messages[-1]["role"] == "user"
|
||||
|
||||
def test_claude_model_drops_unrelated_tool_results_from_preserved_pair(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
_snowflake_env(monkeypatch)
|
||||
llm = SnowflakeCompletion(model="claude-sonnet-4-5")
|
||||
|
||||
messages = llm._format_messages(
|
||||
[
|
||||
{"role": "user", "content": "Use the tool."},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": None,
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "call_1",
|
||||
"type": "function",
|
||||
"function": {"name": "lookup", "arguments": "{}"},
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
"role": "tool",
|
||||
"tool_call_id": "call_1",
|
||||
"content": "valid result",
|
||||
},
|
||||
{
|
||||
"role": "tool",
|
||||
"tool_call_id": "unrelated_call",
|
||||
"content": "unrelated result",
|
||||
},
|
||||
]
|
||||
)
|
||||
|
||||
assert messages[-3]["role"] == "assistant"
|
||||
assert messages[-2] == {
|
||||
"role": "tool",
|
||||
"tool_call_id": "call_1",
|
||||
"content": "valid result",
|
||||
}
|
||||
assert all(
|
||||
message.get("tool_call_id") != "unrelated_call" for message in messages
|
||||
)
|
||||
assert messages[-1]["role"] == "user"
|
||||
|
||||
def test_claude_model_maps_max_tokens_to_max_completion_tokens(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
_snowflake_env(monkeypatch)
|
||||
llm = SnowflakeCompletion(model="claude-sonnet-4-5", max_tokens=256)
|
||||
|
||||
params = llm._prepare_completion_params(
|
||||
[{"role": "user", "content": "Hello"}]
|
||||
)
|
||||
|
||||
assert "max_tokens" not in params
|
||||
assert params["max_completion_tokens"] == 256
|
||||
|
||||
def test_streaming_params_include_usage(self, monkeypatch: pytest.MonkeyPatch):
|
||||
_snowflake_env(monkeypatch)
|
||||
llm = SnowflakeCompletion(model="openai-gpt-4.1", stream=True)
|
||||
|
||||
params = llm._prepare_completion_params(
|
||||
[{"role": "user", "content": "Hello"}]
|
||||
)
|
||||
|
||||
assert params["stream"] is True
|
||||
assert params["stream_options"] == {"include_usage": True}
|
||||
|
||||
def test_non_streaming_call_uses_native_openai_client(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
_snowflake_env(monkeypatch)
|
||||
llm = SnowflakeCompletion(model="openai-gpt-4.1")
|
||||
fake_response = SimpleNamespace(
|
||||
usage=SimpleNamespace(
|
||||
prompt_tokens=3,
|
||||
completion_tokens=2,
|
||||
total_tokens=5,
|
||||
prompt_tokens_details=None,
|
||||
completion_tokens_details=None,
|
||||
),
|
||||
choices=[
|
||||
SimpleNamespace(
|
||||
message=SimpleNamespace(content="Snowflake response", tool_calls=None)
|
||||
)
|
||||
],
|
||||
)
|
||||
create = Mock(return_value=fake_response)
|
||||
fake_client = SimpleNamespace(
|
||||
chat=SimpleNamespace(completions=SimpleNamespace(create=create))
|
||||
)
|
||||
|
||||
with patch.object(llm, "_get_sync_client", return_value=fake_client):
|
||||
response = llm.call([{"role": "user", "content": "Hello"}])
|
||||
|
||||
assert response == "Snowflake response"
|
||||
create.assert_called_once()
|
||||
assert create.call_args.kwargs["model"] == "openai-gpt-4.1"
|
||||
assert create.call_args.kwargs["messages"] == [
|
||||
{"role": "user", "content": "Hello"}
|
||||
]
|
||||
@@ -569,13 +569,13 @@ class TestFlowResumeWithFeedback:
|
||||
|
||||
flow = TestFlow.from_pending("async-direct-test", persistence)
|
||||
|
||||
with patch("crewai.flow.flow.crewai_event_bus.emit"):
|
||||
with patch("crewai.flow.runtime.crewai_event_bus.emit"):
|
||||
result = await flow.resume_async("async feedback")
|
||||
|
||||
assert flow.last_human_feedback is not None
|
||||
assert flow.last_human_feedback.feedback == "async feedback"
|
||||
|
||||
@patch("crewai.flow.flow.crewai_event_bus.emit")
|
||||
@patch("crewai.flow.runtime.crewai_event_bus.emit")
|
||||
def test_resume_basic(self, mock_emit: MagicMock) -> None:
|
||||
"""Test basic resume functionality."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
@@ -615,7 +615,7 @@ class TestFlowResumeWithFeedback:
|
||||
|
||||
assert persistence.load_pending_feedback("resume-test-123") is None
|
||||
|
||||
@patch("crewai.flow.flow.crewai_event_bus.emit")
|
||||
@patch("crewai.flow.runtime.crewai_event_bus.emit")
|
||||
def test_resume_routing(self, mock_emit: MagicMock) -> None:
|
||||
"""Test resume with routing."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
@@ -697,7 +697,7 @@ class TestAsyncHumanFeedbackIntegration:
|
||||
assert hasattr(method, "__human_feedback_config__")
|
||||
assert method.__human_feedback_config__.provider is not None
|
||||
|
||||
@patch("crewai.flow.flow.crewai_event_bus.emit")
|
||||
@patch("crewai.flow.runtime.crewai_event_bus.emit")
|
||||
def test_async_provider_pauses_flow(self, mock_emit: MagicMock) -> None:
|
||||
"""Test that async provider pauses flow execution."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
@@ -743,7 +743,7 @@ class TestAsyncHumanFeedbackIntegration:
|
||||
persisted = persistence.load_pending_feedback(flow_id)
|
||||
assert persisted is not None
|
||||
|
||||
@patch("crewai.flow.flow.crewai_event_bus.emit")
|
||||
@patch("crewai.flow.runtime.crewai_event_bus.emit")
|
||||
def test_full_async_flow_cycle(self, mock_emit: MagicMock) -> None:
|
||||
"""Test complete async flow: start -> pause -> resume."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
@@ -804,7 +804,7 @@ class TestAsyncHumanFeedbackIntegration:
|
||||
class TestAutoPersistence:
|
||||
"""Tests for automatic persistence when no persistence is provided."""
|
||||
|
||||
@patch("crewai.flow.flow.crewai_event_bus.emit")
|
||||
@patch("crewai.flow.runtime.crewai_event_bus.emit")
|
||||
def test_auto_persistence_when_none_provided(self, mock_emit: MagicMock) -> None:
|
||||
"""Test that persistence is auto-created when HumanFeedbackPending is raised."""
|
||||
|
||||
@@ -925,7 +925,7 @@ class TestCollapseToOutcomeJsonParsing:
|
||||
class TestLLMObjectPreservedInContext:
|
||||
"""Tests that BaseLLM objects have their model string preserved in PendingFeedbackContext."""
|
||||
|
||||
@patch("crewai.flow.flow.crewai_event_bus.emit")
|
||||
@patch("crewai.flow.runtime.crewai_event_bus.emit")
|
||||
def test_basellm_object_model_string_survives_roundtrip(self, mock_emit: MagicMock) -> None:
|
||||
"""Test that when llm is a BaseLLM object, its model string is stored in context
|
||||
so that outcome collapsing works after async pause/resume.
|
||||
@@ -1125,7 +1125,7 @@ class TestAsyncHumanFeedbackEdgeCases:
|
||||
|
||||
flow = TestFlow.from_pending("default-test", persistence)
|
||||
|
||||
with patch("crewai.flow.flow.crewai_event_bus.emit"):
|
||||
with patch("crewai.flow.runtime.crewai_event_bus.emit"):
|
||||
result = flow.resume("")
|
||||
|
||||
assert flow.last_human_feedback.outcome == "approved"
|
||||
@@ -1159,7 +1159,7 @@ class TestAsyncHumanFeedbackEdgeCases:
|
||||
|
||||
flow = TestFlow.from_pending("no-feedback-test", persistence)
|
||||
|
||||
with patch("crewai.flow.flow.crewai_event_bus.emit"):
|
||||
with patch("crewai.flow.runtime.crewai_event_bus.emit"):
|
||||
result = flow.resume()
|
||||
|
||||
assert flow.last_human_feedback.outcome == "approved"
|
||||
@@ -1213,7 +1213,7 @@ class TestLiveLLMPreservationOnResume:
|
||||
assert hasattr(method, "_hf_llm")
|
||||
assert method._hf_llm == "gpt-4o-mini"
|
||||
|
||||
@patch("crewai.flow.flow.crewai_event_bus.emit")
|
||||
@patch("crewai.flow.runtime.crewai_event_bus.emit")
|
||||
def test_resume_async_uses_live_basellm_over_serialized_string(
|
||||
self, mock_emit: MagicMock
|
||||
) -> None:
|
||||
@@ -1286,7 +1286,7 @@ class TestLiveLLMPreservationOnResume:
|
||||
# And verify it's a BaseLLM instance, not a string
|
||||
assert isinstance(captured_llm[0], BaseLLM)
|
||||
|
||||
@patch("crewai.flow.flow.crewai_event_bus.emit")
|
||||
@patch("crewai.flow.runtime.crewai_event_bus.emit")
|
||||
def test_resume_async_falls_back_to_serialized_string_when_no_hf_llm(
|
||||
self, mock_emit: MagicMock
|
||||
) -> None:
|
||||
@@ -1344,7 +1344,7 @@ class TestLiveLLMPreservationOnResume:
|
||||
assert isinstance(captured_llm[0], BaseLLMClass)
|
||||
assert captured_llm[0].model == "gpt-4o-mini"
|
||||
|
||||
@patch("crewai.flow.flow.crewai_event_bus.emit")
|
||||
@patch("crewai.flow.runtime.crewai_event_bus.emit")
|
||||
def test_resume_async_uses_string_from_context_when_hf_llm_is_string(
|
||||
self, mock_emit: MagicMock
|
||||
) -> None:
|
||||
|
||||
@@ -3010,6 +3010,23 @@ def test__setup_for_training(researcher, writer):
|
||||
assert agent.allow_delegation is False
|
||||
|
||||
|
||||
def test_crew_trained_agents_file_is_preserved_on_copy(researcher):
|
||||
task = Task(
|
||||
description="Come up with a list of 5 interesting ideas to explore for an article",
|
||||
expected_output="5 bullet points with a paragraph for each idea.",
|
||||
agent=researcher,
|
||||
)
|
||||
crew = Crew(
|
||||
agents=[researcher],
|
||||
tasks=[task],
|
||||
trained_agents_file="custom_trained_agents.pkl",
|
||||
)
|
||||
|
||||
cloned_crew = crew.copy()
|
||||
|
||||
assert cloned_crew.trained_agents_file == "custom_trained_agents.pkl"
|
||||
|
||||
|
||||
@pytest.mark.vcr()
|
||||
def test_replay_feature(researcher, writer):
|
||||
list_ideas = Task(
|
||||
|
||||
@@ -161,6 +161,87 @@ def test_flow_with_or_condition():
|
||||
)
|
||||
|
||||
|
||||
def test_or_listener_fires_once_across_parallel_starts():
|
||||
"""Parallel ``@start`` paths feeding ``or_`` must not double-fire the listener."""
|
||||
fire_count = 0
|
||||
|
||||
class ParallelOrFlow(Flow):
|
||||
@start()
|
||||
async def fast_start(self):
|
||||
return "fast"
|
||||
|
||||
@start()
|
||||
async def slow_start(self):
|
||||
await asyncio.sleep(0.2)
|
||||
return "slow"
|
||||
|
||||
@listen(or_(fast_start, slow_start))
|
||||
def handler(self):
|
||||
nonlocal fire_count
|
||||
fire_count += 1
|
||||
|
||||
asyncio.run(ParallelOrFlow().kickoff_async())
|
||||
|
||||
assert fire_count == 1
|
||||
|
||||
|
||||
def test_or_listener_re_arms_across_router_loop():
|
||||
"""Regression for #5972: multi-source ``or_`` re-fires on each router emission."""
|
||||
fire_count = 0
|
||||
|
||||
class CyclicOrFlow(Flow):
|
||||
iteration = 0
|
||||
|
||||
@start()
|
||||
def kick(self):
|
||||
return "kick"
|
||||
|
||||
@router(kick)
|
||||
def initial_router(self):
|
||||
return "SignalA"
|
||||
|
||||
@listen(or_("SignalA", "SignalB"))
|
||||
def handler(self):
|
||||
nonlocal fire_count
|
||||
fire_count += 1
|
||||
|
||||
@router(handler)
|
||||
def loop_router(self):
|
||||
self.iteration += 1
|
||||
return "stop" if self.iteration >= 3 else "SignalB"
|
||||
|
||||
CyclicOrFlow().kickoff()
|
||||
|
||||
assert fire_count == 3
|
||||
|
||||
|
||||
def test_or_listener_does_not_double_fire_across_chained_routers():
|
||||
"""Chained routers within one dispatch wave must not re-fire the same ``or_`` listener."""
|
||||
fire_count = 0
|
||||
|
||||
class ChainedRouterOrFlow(Flow):
|
||||
@start()
|
||||
def kick(self):
|
||||
return "kick"
|
||||
|
||||
@router(kick)
|
||||
def router_a(self):
|
||||
return "SignalA"
|
||||
|
||||
@router("SignalA")
|
||||
def router_b(self):
|
||||
return "SignalB"
|
||||
|
||||
@listen(or_("SignalA", "SignalB"))
|
||||
def handler(self):
|
||||
nonlocal fire_count
|
||||
fire_count += 1
|
||||
|
||||
ChainedRouterOrFlow().kickoff()
|
||||
|
||||
assert fire_count == 1
|
||||
|
||||
|
||||
def test_flow_with_router():
|
||||
"""Test a flow that uses a router method to determine the next step."""
|
||||
execution_order = []
|
||||
|
||||
602
lib/crewai/tests/test_flow_definition_contract.py
Normal file
602
lib/crewai/tests/test_flow_definition_contract.py
Normal file
@@ -0,0 +1,602 @@
|
||||
"""Tests for the static Flow Definition contract."""
|
||||
|
||||
import ast
|
||||
from enum import Enum
|
||||
import importlib
|
||||
import inspect
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from crewai.flow import Flow, and_, human_feedback, listen, or_, persist, router, start
|
||||
import crewai.flow.flow_definition as flow_definition
|
||||
import crewai.flow.visualization.builder as visualization_builder
|
||||
|
||||
|
||||
def test_flow_public_exports_are_explicit():
|
||||
import crewai.flow.dsl as flow_dsl
|
||||
import crewai.flow.visualization as flow_visualization
|
||||
import crewai.flow.visualization.analysis as visualization_analysis
|
||||
|
||||
flow_package = importlib.import_module("crewai.flow")
|
||||
|
||||
assert "FlowDefinition" not in flow_package.__all__
|
||||
assert "FlowDefinitionDiagnostic" not in flow_package.__all__
|
||||
assert "build_flow_definition" not in flow_package.__all__
|
||||
assert "flow_structure" not in flow_package.__all__
|
||||
assert set(flow_dsl.__all__) == {"and_", "listen", "or_", "router", "start"}
|
||||
assert set(flow_definition.__all__) == {
|
||||
"FlowConfigDefinition",
|
||||
"FlowDefinition",
|
||||
"FlowDefinitionCondition",
|
||||
"FlowDefinitionDiagnostic",
|
||||
"FlowHumanFeedbackDefinition",
|
||||
"FlowMethodDefinition",
|
||||
"FlowPersistenceDefinition",
|
||||
"FlowStateDefinition",
|
||||
}
|
||||
assert "build_flow_structure" in flow_visualization.__all__
|
||||
assert "calculate_node_levels" not in flow_visualization.__all__
|
||||
assert visualization_analysis.__all__ == []
|
||||
|
||||
|
||||
def test_private_flow_helpers_do_not_have_docstrings():
|
||||
import crewai.flow.dsl as flow_dsl
|
||||
import crewai.flow.flow_wrappers as flow_wrappers
|
||||
import crewai.flow.human_feedback as human_feedback
|
||||
import crewai.flow.persistence.decorators as persistence_decorators
|
||||
import crewai.flow.visualization.analysis as visualization_analysis
|
||||
import crewai.flow.visualization.types as visualization_types
|
||||
|
||||
modules = [
|
||||
flow_dsl,
|
||||
flow_definition,
|
||||
flow_wrappers,
|
||||
human_feedback,
|
||||
persistence_decorators,
|
||||
visualization_analysis,
|
||||
visualization_builder,
|
||||
visualization_types,
|
||||
]
|
||||
violations: list[str] = []
|
||||
|
||||
for module in modules:
|
||||
source_path = Path(inspect.getsourcefile(module) or "")
|
||||
tree = ast.parse(source_path.read_text())
|
||||
stack: list[ast.AST] = []
|
||||
if getattr(module, "__all__", None) == [] and ast.get_docstring(tree):
|
||||
violations.append(f"{source_path}:1:<module>")
|
||||
|
||||
class PrivateDocstringVisitor(ast.NodeVisitor):
|
||||
def visit_ClassDef(self, node: ast.ClassDef) -> None:
|
||||
self._check_docstring(node)
|
||||
stack.append(node)
|
||||
self.generic_visit(node)
|
||||
stack.pop()
|
||||
|
||||
def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
|
||||
self._check_docstring(node)
|
||||
stack.append(node)
|
||||
self.generic_visit(node)
|
||||
stack.pop()
|
||||
|
||||
def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None:
|
||||
self._check_docstring(node)
|
||||
stack.append(node)
|
||||
self.generic_visit(node)
|
||||
stack.pop()
|
||||
|
||||
def _check_docstring(
|
||||
self,
|
||||
node: ast.ClassDef | ast.FunctionDef | ast.AsyncFunctionDef,
|
||||
) -> None:
|
||||
is_dunder = node.name.startswith("__") and node.name.endswith("__")
|
||||
is_private_name = node.name.startswith("_") and not is_dunder
|
||||
is_nested_function = any(
|
||||
isinstance(parent, (ast.FunctionDef, ast.AsyncFunctionDef))
|
||||
for parent in stack
|
||||
)
|
||||
if (is_private_name or is_nested_function) and ast.get_docstring(node):
|
||||
violations.append(f"{source_path}:{node.lineno}:{node.name}")
|
||||
|
||||
PrivateDocstringVisitor().visit(tree)
|
||||
|
||||
assert violations == []
|
||||
|
||||
|
||||
def test_flow_definition_contract_is_dsl_agnostic():
|
||||
source_path = Path(inspect.getsourcefile(flow_definition) or "")
|
||||
source = source_path.read_text()
|
||||
|
||||
assert "DSL" not in source
|
||||
assert "flow_wrappers" not in source
|
||||
assert "build_flow_definition" not in source
|
||||
assert "extract_flow_definition" not in source
|
||||
|
||||
|
||||
def test_flow_definition_maps_dsl_to_static_contract():
|
||||
class ContractState(BaseModel):
|
||||
topic: str = ""
|
||||
|
||||
class ContractFlow(Flow[ContractState]):
|
||||
"""A flow with every core DSL role."""
|
||||
|
||||
initial_state = ContractState
|
||||
stream = True
|
||||
max_method_calls = 7
|
||||
|
||||
@start()
|
||||
def begin(self):
|
||||
return "started"
|
||||
|
||||
@listen(begin)
|
||||
def process(self):
|
||||
return "processed"
|
||||
|
||||
@router(process)
|
||||
def decide(self):
|
||||
return "approved"
|
||||
|
||||
@listen(or_("approved", "revise"))
|
||||
@human_feedback(
|
||||
message="Review this output.",
|
||||
emit=["done", "revise"],
|
||||
llm="gpt-4o-mini",
|
||||
default_outcome="done",
|
||||
metadata={"team": "qa"},
|
||||
learn=True,
|
||||
learn_source="hitl",
|
||||
learn_strict=True,
|
||||
)
|
||||
def review(self):
|
||||
return "review"
|
||||
|
||||
@listen(and_(begin, process))
|
||||
def audit(self):
|
||||
return "audit"
|
||||
|
||||
definition = ContractFlow.flow_definition()
|
||||
|
||||
assert definition.schema_ == "crewai.flow/v1"
|
||||
assert definition.name == "ContractFlow"
|
||||
assert definition.description == "A flow with every core DSL role."
|
||||
assert definition.state is not None
|
||||
assert definition.state.type == "pydantic"
|
||||
assert definition.state.ref and "ContractState" in definition.state.ref
|
||||
assert definition.config.stream is True
|
||||
assert definition.config.max_method_calls == 7
|
||||
|
||||
assert definition.methods["begin"].start is True
|
||||
assert definition.methods["process"].listen == "begin"
|
||||
|
||||
decide = definition.methods["decide"]
|
||||
assert decide.listen == "process"
|
||||
assert decide.router is True
|
||||
assert decide.returns is None
|
||||
|
||||
review = definition.methods["review"]
|
||||
assert review.listen == {"or": ["approved", "revise"]}
|
||||
assert review.router is True
|
||||
assert review.returns is None
|
||||
assert review.human_feedback is not None
|
||||
assert review.human_feedback.emit == ["done", "revise"]
|
||||
assert review.human_feedback.default_outcome == "done"
|
||||
assert review.human_feedback.metadata == {"team": "qa"}
|
||||
assert review.human_feedback.learn is True
|
||||
assert review.human_feedback.learn_strict is True
|
||||
|
||||
assert definition.methods["audit"].listen == {"and": ["begin", "process"]}
|
||||
assert definition.diagnostics == []
|
||||
|
||||
|
||||
def test_flow_definition_classifies_start_router_from_human_feedback_emit():
|
||||
class StartRouterFlow(Flow):
|
||||
@start()
|
||||
@human_feedback(
|
||||
message="Review:",
|
||||
emit=["continue", "stop"],
|
||||
llm="gpt-4o-mini",
|
||||
)
|
||||
def entry_point(self):
|
||||
return "data"
|
||||
|
||||
@listen("continue")
|
||||
def proceed(self):
|
||||
return "proceeding"
|
||||
|
||||
@listen("stop")
|
||||
def halt(self):
|
||||
return "halted"
|
||||
|
||||
definition = StartRouterFlow.flow_definition()
|
||||
entry_point = definition.methods["entry_point"]
|
||||
|
||||
assert entry_point.is_start is True
|
||||
assert entry_point.router is True
|
||||
assert entry_point.human_feedback is not None
|
||||
assert entry_point.human_feedback.emit == ["continue", "stop"]
|
||||
assert entry_point.returns is None
|
||||
|
||||
|
||||
def test_flow_definition_round_trips_json_and_yaml():
|
||||
class RoundTripFlow(Flow):
|
||||
@start()
|
||||
def begin(self):
|
||||
return "started"
|
||||
|
||||
@router(begin)
|
||||
def decide(self):
|
||||
return "left"
|
||||
|
||||
@listen("left")
|
||||
def left(self):
|
||||
return "left"
|
||||
|
||||
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())
|
||||
|
||||
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"
|
||||
|
||||
|
||||
def test_flow_definition_detects_persist_metadata():
|
||||
@persist(verbose=True)
|
||||
class PersistedFlow(Flow[dict]):
|
||||
initial_state = {}
|
||||
|
||||
@start()
|
||||
def begin(self):
|
||||
return "started"
|
||||
|
||||
@persist(verbose=False)
|
||||
@listen(begin)
|
||||
def checkpoint(self):
|
||||
return "saved"
|
||||
|
||||
definition = PersistedFlow.flow_definition()
|
||||
|
||||
assert definition.persist is not None
|
||||
assert definition.persist.enabled is True
|
||||
assert definition.persist.verbose is True
|
||||
|
||||
assert definition.methods["begin"].persist is None
|
||||
|
||||
method_persist = definition.methods["checkpoint"].persist
|
||||
assert method_persist is not None
|
||||
assert method_persist.enabled is True
|
||||
assert method_persist.verbose is False
|
||||
|
||||
|
||||
def test_flow_definition_allows_dynamic_router_returns(caplog):
|
||||
caplog.set_level(logging.WARNING, logger="crewai.flow.flow_definition")
|
||||
|
||||
class DynamicRouterFlow(Flow):
|
||||
@start()
|
||||
def begin(self):
|
||||
return "started"
|
||||
|
||||
@router(begin)
|
||||
def decide(self):
|
||||
return self.state["dynamic_path"]
|
||||
|
||||
definition = DynamicRouterFlow.flow_definition()
|
||||
|
||||
assert definition.methods["decide"].returns is None
|
||||
assert definition.diagnostics == []
|
||||
assert caplog.records == []
|
||||
|
||||
|
||||
def test_flow_definition_infers_literal_router_returns():
|
||||
class LiteralRouterFlow(Flow):
|
||||
@start()
|
||||
def begin(self):
|
||||
return "started"
|
||||
|
||||
@router(begin)
|
||||
def decide(self) -> Literal["left", "right"]:
|
||||
return "left"
|
||||
|
||||
@listen("left")
|
||||
def left(self):
|
||||
return "left"
|
||||
|
||||
@listen("right")
|
||||
def right(self):
|
||||
return "right"
|
||||
|
||||
definition = LiteralRouterFlow.flow_definition()
|
||||
|
||||
assert definition.methods["decide"].returns == ["left", "right"]
|
||||
|
||||
|
||||
def test_flow_definition_infers_enum_router_returns():
|
||||
class Decision(str, Enum):
|
||||
APPROVE = "approve"
|
||||
REJECT = "reject"
|
||||
|
||||
class EnumRouterFlow(Flow):
|
||||
@start()
|
||||
def begin(self):
|
||||
return "started"
|
||||
|
||||
@router(begin)
|
||||
def decide(self) -> Decision:
|
||||
return Decision.APPROVE
|
||||
|
||||
@listen("approve")
|
||||
def approve(self):
|
||||
return "approve"
|
||||
|
||||
@listen("reject")
|
||||
def reject(self):
|
||||
return "reject"
|
||||
|
||||
definition = EnumRouterFlow.flow_definition()
|
||||
|
||||
assert definition.methods["decide"].returns == ["approve", "reject"]
|
||||
|
||||
|
||||
def test_flow_definition_infers_literal_union_router_returns():
|
||||
class LiteralUnionRouterFlow(Flow):
|
||||
@start()
|
||||
def begin(self):
|
||||
return "started"
|
||||
|
||||
@router(begin)
|
||||
def decide(self) -> Literal["left"] | Literal["right"]:
|
||||
return "left"
|
||||
|
||||
@listen("left")
|
||||
def left(self):
|
||||
return "left"
|
||||
|
||||
@listen("right")
|
||||
def right(self):
|
||||
return "right"
|
||||
|
||||
definition = LiteralUnionRouterFlow.flow_definition()
|
||||
|
||||
assert definition.methods["decide"].returns == ["left", "right"]
|
||||
|
||||
|
||||
def test_flow_definition_does_not_infer_unannotated_router_body_returns():
|
||||
class UnannotatedRouterFlow(Flow):
|
||||
@start()
|
||||
def begin(self):
|
||||
return "started"
|
||||
|
||||
@router(begin)
|
||||
def decide(self):
|
||||
return "left"
|
||||
|
||||
@listen("left")
|
||||
def left(self):
|
||||
return "left"
|
||||
|
||||
definition = UnannotatedRouterFlow.flow_definition()
|
||||
|
||||
assert definition.methods["decide"].returns is None
|
||||
|
||||
|
||||
def test_flow_definition_accepts_explicit_router_paths():
|
||||
class ExplicitRouterFlow(Flow):
|
||||
@start()
|
||||
def begin(self):
|
||||
return "started"
|
||||
|
||||
@router(begin, paths=["left", "right", "left"])
|
||||
def decide(self):
|
||||
return self.state["dynamic_path"]
|
||||
|
||||
@listen("left")
|
||||
def left(self):
|
||||
return "left"
|
||||
|
||||
@listen("right")
|
||||
def right(self):
|
||||
return "right"
|
||||
|
||||
definition = ExplicitRouterFlow.flow_definition()
|
||||
|
||||
assert definition.methods["decide"].returns == ["left", "right"]
|
||||
|
||||
|
||||
def test_flow_definition_preserves_diagnostics_loaded_from_contract():
|
||||
definition = flow_definition.FlowDefinition.from_dict(
|
||||
{
|
||||
"schema": "crewai.flow/v1",
|
||||
"name": "LoadedDiagnosticsFlow",
|
||||
"methods": {
|
||||
"decision": {
|
||||
"router": True,
|
||||
"returns": ["continue"],
|
||||
}
|
||||
},
|
||||
"diagnostics": [
|
||||
{
|
||||
"code": "serialized_warning",
|
||||
"message": "Preserved serialized diagnostic",
|
||||
"severity": "warning",
|
||||
"path": "methods.decision",
|
||||
},
|
||||
{
|
||||
"code": "router_without_trigger",
|
||||
"message": "router: true requires either start or listen",
|
||||
"severity": "error",
|
||||
"path": "methods.decision",
|
||||
},
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
codes = [diagnostic.code for diagnostic in definition.diagnostics]
|
||||
assert "serialized_warning" in codes
|
||||
assert codes.count("router_without_trigger") == 1
|
||||
|
||||
|
||||
def test_router_human_feedback_preserves_existing_router_metadata():
|
||||
class RouterHumanFeedbackFlow(Flow):
|
||||
@start()
|
||||
def begin(self):
|
||||
return "started"
|
||||
|
||||
@human_feedback(message="Review route:")
|
||||
@router(begin, paths=["approved", "rejected"])
|
||||
def decide(self):
|
||||
return "approved"
|
||||
|
||||
@listen("approved")
|
||||
def approved(self):
|
||||
return "approved"
|
||||
|
||||
definition = RouterHumanFeedbackFlow.flow_definition()
|
||||
method = definition.methods["decide"]
|
||||
|
||||
assert method.router is True
|
||||
assert method.listen == "begin"
|
||||
assert method.returns == ["approved", "rejected"]
|
||||
assert method.human_feedback is not None
|
||||
|
||||
|
||||
def test_dynamic_router_does_not_log_at_class_definition_time(caplog):
|
||||
caplog.set_level(logging.WARNING, logger="crewai.flow.flow_definition")
|
||||
|
||||
class LazyDynamicRouterFlow(Flow):
|
||||
@start()
|
||||
def begin(self):
|
||||
return "started"
|
||||
|
||||
@router(begin)
|
||||
def decide(self):
|
||||
return self.state["dynamic_path"]
|
||||
|
||||
# No diagnostics should be logged merely by defining the class -- the
|
||||
# FlowDefinition is built lazily.
|
||||
assert caplog.records == []
|
||||
|
||||
# Explicit access still should not log visualization-only diagnostics.
|
||||
definition = LazyDynamicRouterFlow.flow_definition()
|
||||
assert definition.diagnostics == []
|
||||
assert caplog.records == []
|
||||
|
||||
|
||||
def test_dynamic_router_string_listener_is_valid_contract():
|
||||
class DynamicRouterListenerFlow(Flow):
|
||||
@start()
|
||||
def begin(self):
|
||||
return "started"
|
||||
|
||||
@router(begin)
|
||||
def decide(self):
|
||||
return self.state["dynamic_path"]
|
||||
|
||||
@listen("dynamic_path")
|
||||
def handle(self):
|
||||
return "handled"
|
||||
|
||||
definition = DynamicRouterListenerFlow.flow_definition()
|
||||
|
||||
assert definition.diagnostics == []
|
||||
|
||||
|
||||
def test_static_string_listener_is_allowed_by_contract():
|
||||
definition = flow_definition.FlowDefinition.from_dict(
|
||||
{
|
||||
"schema": "crewai.flow/v1",
|
||||
"name": "TypoFlow",
|
||||
"methods": {
|
||||
"begin": {"start": True},
|
||||
"handle": {"listen": "begni"},
|
||||
},
|
||||
}
|
||||
)
|
||||
assert definition.diagnostics == []
|
||||
|
||||
|
||||
def test_start_false_not_classified_as_start_method():
|
||||
definition = flow_definition.FlowDefinition.from_dict(
|
||||
{
|
||||
"schema": "crewai.flow/v1",
|
||||
"name": "ExplicitNonStartFlow",
|
||||
"methods": {
|
||||
"begin": {"start": True},
|
||||
"handle": {"start": False, "listen": "begin"},
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
assert definition.methods["begin"].is_start is True
|
||||
assert definition.methods["handle"].is_start is False
|
||||
|
||||
class ExplicitNonStartFlow(Flow):
|
||||
@start()
|
||||
def begin(self):
|
||||
return "started"
|
||||
|
||||
@listen(begin)
|
||||
def handle(self):
|
||||
return "handled"
|
||||
|
||||
# Attach the loaded contract (with explicit ``start: false``) so the
|
||||
# projections read from it rather than rebuilding from the DSL.
|
||||
ExplicitNonStartFlow._flow_definition = definition
|
||||
|
||||
flow = ExplicitNonStartFlow()
|
||||
viz_structure = visualization_builder.build_flow_structure(flow)
|
||||
assert "handle" not in viz_structure["start_methods"]
|
||||
assert viz_structure["nodes"]["handle"]["type"] != "start"
|
||||
|
||||
|
||||
def test_flow_definition_cache_is_not_inherited_by_subclasses():
|
||||
class ParentFlow(Flow):
|
||||
@start()
|
||||
def begin(self):
|
||||
return "begin"
|
||||
|
||||
parent_definition = ParentFlow.flow_definition()
|
||||
|
||||
class ChildFlow(ParentFlow):
|
||||
@listen(ParentFlow.begin)
|
||||
def child_step(self):
|
||||
return "child"
|
||||
|
||||
child_definition = ChildFlow.flow_definition()
|
||||
|
||||
assert parent_definition.name == "ParentFlow"
|
||||
assert child_definition.name == "ChildFlow"
|
||||
assert child_definition is not parent_definition
|
||||
assert set(child_definition.methods) == {"begin", "child_step"}
|
||||
|
||||
|
||||
def test_flow_definition_logs_diagnostics_when_loaded_from_contract(caplog):
|
||||
caplog.set_level(logging.WARNING, logger="crewai.flow.flow_definition")
|
||||
|
||||
definition = flow_definition.FlowDefinition.from_dict(
|
||||
{
|
||||
"schema": "crewai.flow/v1",
|
||||
"name": "LoadedFlow",
|
||||
"methods": {
|
||||
"decision": {
|
||||
"router": True,
|
||||
"returns": ["continue"],
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
assert any(
|
||||
diagnostic.code == "router_without_trigger"
|
||||
for diagnostic in definition.diagnostics
|
||||
)
|
||||
assert any(
|
||||
record.levelno == logging.ERROR
|
||||
and "LoadedFlow" in record.message
|
||||
and "router_without_trigger" in record.message
|
||||
for record in caplog.records
|
||||
)
|
||||
@@ -1,818 +0,0 @@
|
||||
"""Tests for flow_serializer.py - Flow structure serialization for Studio UI."""
|
||||
|
||||
from typing import Literal
|
||||
|
||||
import pytest
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from crewai.flow.flow import Flow, and_, listen, or_, router, start
|
||||
from crewai.flow.flow_serializer import flow_structure
|
||||
from crewai.flow.human_feedback import human_feedback
|
||||
|
||||
|
||||
class TestSimpleLinearFlow:
|
||||
"""Test simple linear flow (start → listen → listen)."""
|
||||
|
||||
def test_linear_flow_structure(self):
|
||||
"""Test a simple sequential flow structure."""
|
||||
|
||||
class LinearFlow(Flow):
|
||||
"""A simple linear flow for testing."""
|
||||
|
||||
@start()
|
||||
def begin(self):
|
||||
return "started"
|
||||
|
||||
@listen(begin)
|
||||
def process(self):
|
||||
return "processed"
|
||||
|
||||
@listen(process)
|
||||
def finalize(self):
|
||||
return "done"
|
||||
|
||||
structure = flow_structure(LinearFlow)
|
||||
|
||||
assert structure["name"] == "LinearFlow"
|
||||
assert structure["description"] == "A simple linear flow for testing."
|
||||
assert len(structure["methods"]) == 3
|
||||
|
||||
method_map = {m["name"]: m for m in structure["methods"]}
|
||||
|
||||
assert method_map["begin"]["type"] == "start"
|
||||
assert method_map["process"]["type"] == "listen"
|
||||
assert method_map["finalize"]["type"] == "listen"
|
||||
|
||||
assert len(structure["edges"]) == 2
|
||||
|
||||
edge_pairs = [(e["from_method"], e["to_method"]) for e in structure["edges"]]
|
||||
assert ("begin", "process") in edge_pairs
|
||||
assert ("process", "finalize") in edge_pairs
|
||||
|
||||
for edge in structure["edges"]:
|
||||
assert edge["edge_type"] == "listen"
|
||||
assert edge["condition"] is None
|
||||
|
||||
|
||||
class TestRouterFlow:
|
||||
"""Test flow with router branching."""
|
||||
|
||||
def test_router_flow_structure(self):
|
||||
"""Test a flow with router that branches to different paths."""
|
||||
|
||||
class BranchingFlow(Flow):
|
||||
@start()
|
||||
def init(self):
|
||||
return "initialized"
|
||||
|
||||
@router(init)
|
||||
def decide(self) -> Literal["path_a", "path_b"]:
|
||||
return "path_a"
|
||||
|
||||
@listen("path_a")
|
||||
def handle_a(self):
|
||||
return "handled_a"
|
||||
|
||||
@listen("path_b")
|
||||
def handle_b(self):
|
||||
return "handled_b"
|
||||
|
||||
structure = flow_structure(BranchingFlow)
|
||||
|
||||
assert structure["name"] == "BranchingFlow"
|
||||
assert len(structure["methods"]) == 4
|
||||
|
||||
method_map = {m["name"]: m for m in structure["methods"]}
|
||||
|
||||
assert method_map["init"]["type"] == "start"
|
||||
assert method_map["decide"]["type"] == "router"
|
||||
assert method_map["handle_a"]["type"] == "listen"
|
||||
assert method_map["handle_b"]["type"] == "listen"
|
||||
|
||||
assert "path_a" in method_map["decide"]["router_paths"]
|
||||
assert "path_b" in method_map["decide"]["router_paths"]
|
||||
|
||||
# Should have: init -> decide (listen), decide -> handle_a (route), decide -> handle_b (route)
|
||||
listen_edges = [e for e in structure["edges"] if e["edge_type"] == "listen"]
|
||||
route_edges = [e for e in structure["edges"] if e["edge_type"] == "route"]
|
||||
|
||||
assert len(listen_edges) == 1
|
||||
assert listen_edges[0]["from_method"] == "init"
|
||||
assert listen_edges[0]["to_method"] == "decide"
|
||||
|
||||
assert len(route_edges) == 2
|
||||
route_targets = {e["to_method"] for e in route_edges}
|
||||
assert "handle_a" in route_targets
|
||||
assert "handle_b" in route_targets
|
||||
|
||||
route_conditions = {e["to_method"]: e["condition"] for e in route_edges}
|
||||
assert route_conditions["handle_a"] == "path_a"
|
||||
assert route_conditions["handle_b"] == "path_b"
|
||||
|
||||
|
||||
class TestAndOrConditions:
|
||||
"""Test flow with AND/OR conditions."""
|
||||
|
||||
def test_and_condition_flow(self):
|
||||
"""Test a flow where a method waits for multiple methods (AND)."""
|
||||
|
||||
class AndConditionFlow(Flow):
|
||||
@start()
|
||||
def step_a(self):
|
||||
return "a"
|
||||
|
||||
@start()
|
||||
def step_b(self):
|
||||
return "b"
|
||||
|
||||
@listen(and_(step_a, step_b))
|
||||
def converge(self):
|
||||
return "converged"
|
||||
|
||||
structure = flow_structure(AndConditionFlow)
|
||||
|
||||
assert len(structure["methods"]) == 3
|
||||
|
||||
method_map = {m["name"]: m for m in structure["methods"]}
|
||||
|
||||
assert method_map["step_a"]["type"] == "start"
|
||||
assert method_map["step_b"]["type"] == "start"
|
||||
assert method_map["converge"]["type"] == "listen"
|
||||
|
||||
assert method_map["converge"]["condition_type"] == "AND"
|
||||
|
||||
triggers = method_map["converge"]["trigger_methods"]
|
||||
assert "step_a" in triggers
|
||||
assert "step_b" in triggers
|
||||
|
||||
converge_edges = [e for e in structure["edges"] if e["to_method"] == "converge"]
|
||||
assert len(converge_edges) == 2
|
||||
|
||||
def test_or_condition_flow(self):
|
||||
"""Test a flow where a method is triggered by any of multiple methods (OR)."""
|
||||
|
||||
class OrConditionFlow(Flow):
|
||||
@start()
|
||||
def path_1(self):
|
||||
return "1"
|
||||
|
||||
@start()
|
||||
def path_2(self):
|
||||
return "2"
|
||||
|
||||
@listen(or_(path_1, path_2))
|
||||
def handle_any(self):
|
||||
return "handled"
|
||||
|
||||
structure = flow_structure(OrConditionFlow)
|
||||
|
||||
method_map = {m["name"]: m for m in structure["methods"]}
|
||||
|
||||
assert method_map["handle_any"]["condition_type"] == "OR"
|
||||
|
||||
triggers = method_map["handle_any"]["trigger_methods"]
|
||||
assert "path_1" in triggers
|
||||
assert "path_2" in triggers
|
||||
|
||||
|
||||
class TestHumanFeedbackMethods:
|
||||
"""Test flow with @human_feedback decorated methods."""
|
||||
|
||||
def test_human_feedback_detection(self):
|
||||
"""Test that human feedback methods are correctly identified."""
|
||||
|
||||
class HumanFeedbackFlow(Flow):
|
||||
@start()
|
||||
@human_feedback(
|
||||
message="Please review:",
|
||||
emit=["approved", "rejected"],
|
||||
llm="gpt-4o-mini",
|
||||
)
|
||||
def review_step(self):
|
||||
return "content to review"
|
||||
|
||||
@listen("approved")
|
||||
def handle_approved(self):
|
||||
return "approved"
|
||||
|
||||
@listen("rejected")
|
||||
def handle_rejected(self):
|
||||
return "rejected"
|
||||
|
||||
structure = flow_structure(HumanFeedbackFlow)
|
||||
|
||||
method_map = {m["name"]: m for m in structure["methods"]}
|
||||
|
||||
# review_step should have human feedback
|
||||
assert method_map["review_step"]["has_human_feedback"] is True
|
||||
# It's a start+router (due to emit)
|
||||
assert method_map["review_step"]["type"] == "start_router"
|
||||
assert "approved" in method_map["review_step"]["router_paths"]
|
||||
assert "rejected" in method_map["review_step"]["router_paths"]
|
||||
|
||||
# Other methods should not have human feedback
|
||||
assert method_map["handle_approved"]["has_human_feedback"] is False
|
||||
assert method_map["handle_rejected"]["has_human_feedback"] is False
|
||||
|
||||
def test_listen_plus_human_feedback_router_edges(self):
|
||||
"""Test that @listen + @human_feedback(emit=...) generates router edges.
|
||||
|
||||
This is the pattern used in the whitepaper generator:
|
||||
a listener method that also acts as a router via @human_feedback(emit=[...]).
|
||||
The serializer must generate edges from this method to listeners of its emit paths.
|
||||
"""
|
||||
|
||||
class ReviewFlow(Flow):
|
||||
@start()
|
||||
def generate(self):
|
||||
return "content"
|
||||
|
||||
@listen(generate)
|
||||
@human_feedback(
|
||||
message="Review this:",
|
||||
emit=["approved", "needs_changes", "cancelled"],
|
||||
llm="gpt-4o-mini",
|
||||
)
|
||||
def review(self):
|
||||
return "review result"
|
||||
|
||||
@listen("approved")
|
||||
def handle_approved(self):
|
||||
return "done"
|
||||
|
||||
@listen("needs_changes")
|
||||
def handle_changes(self):
|
||||
return "regenerating"
|
||||
|
||||
@listen("cancelled")
|
||||
def handle_cancelled(self):
|
||||
return "cancelled"
|
||||
|
||||
structure = flow_structure(ReviewFlow)
|
||||
|
||||
method_map = {m["name"]: m for m in structure["methods"]}
|
||||
edge_set = {(e["from_method"], e["to_method"], e.get("condition")) for e in structure["edges"]}
|
||||
|
||||
# review should be detected as a router with the emit paths
|
||||
assert method_map["review"]["type"] == "router"
|
||||
assert set(method_map["review"]["router_paths"]) == {"approved", "needs_changes", "cancelled"}
|
||||
assert method_map["review"]["has_human_feedback"] is True
|
||||
|
||||
assert ("generate", "review", None) in edge_set
|
||||
|
||||
assert ("review", "handle_approved", "approved") in edge_set
|
||||
assert ("review", "handle_changes", "needs_changes") in edge_set
|
||||
assert ("review", "handle_cancelled", "cancelled") in edge_set
|
||||
|
||||
|
||||
class TestCrewReferences:
|
||||
"""Test detection of Crew references in method bodies."""
|
||||
|
||||
def test_crew_detection_with_crew_call(self):
|
||||
"""Test that .crew() calls are detected."""
|
||||
|
||||
class FlowWithCrew(Flow):
|
||||
@start()
|
||||
def run_crew(self):
|
||||
return "result"
|
||||
|
||||
@listen(run_crew)
|
||||
def no_crew(self):
|
||||
return "done"
|
||||
|
||||
structure = flow_structure(FlowWithCrew)
|
||||
|
||||
method_map = {m["name"]: m for m in structure["methods"]}
|
||||
|
||||
# Note: Since the actual .crew() call is in a comment/string,
|
||||
# We're testing the mechanism exists.
|
||||
assert "has_crew" in method_map["run_crew"]
|
||||
assert "has_crew" in method_map["no_crew"]
|
||||
|
||||
def test_no_crew_when_absent(self):
|
||||
"""Test that methods without Crew refs return has_crew=False."""
|
||||
|
||||
class SimpleNonCrewFlow(Flow):
|
||||
@start()
|
||||
def calculate(self):
|
||||
return 1 + 1
|
||||
|
||||
@listen(calculate)
|
||||
def display(self):
|
||||
return "result"
|
||||
|
||||
structure = flow_structure(SimpleNonCrewFlow)
|
||||
|
||||
method_map = {m["name"]: m for m in structure["methods"]}
|
||||
|
||||
assert method_map["calculate"]["has_crew"] is False
|
||||
assert method_map["display"]["has_crew"] is False
|
||||
|
||||
|
||||
class TestTypedStateSchema:
|
||||
"""Test flow with typed Pydantic state."""
|
||||
|
||||
def test_pydantic_state_schema_extraction(self):
|
||||
"""Test extracting state schema from a Flow with Pydantic state."""
|
||||
|
||||
class MyState(BaseModel):
|
||||
counter: int = 0
|
||||
message: str = ""
|
||||
items: list[str] = Field(default_factory=list)
|
||||
|
||||
class TypedStateFlow(Flow[MyState]):
|
||||
initial_state = MyState
|
||||
|
||||
@start()
|
||||
def increment(self):
|
||||
self.state.counter += 1
|
||||
return self.state.counter
|
||||
|
||||
@listen(increment)
|
||||
def display(self):
|
||||
return f"Count: {self.state.counter}"
|
||||
|
||||
structure = flow_structure(TypedStateFlow)
|
||||
|
||||
assert structure["state_schema"] is not None
|
||||
fields = structure["state_schema"]["fields"]
|
||||
|
||||
field_names = {f["name"] for f in fields}
|
||||
assert "counter" in field_names
|
||||
assert "message" in field_names
|
||||
assert "items" in field_names
|
||||
|
||||
field_map = {f["name"]: f for f in fields}
|
||||
assert "int" in field_map["counter"]["type"]
|
||||
assert "str" in field_map["message"]["type"]
|
||||
|
||||
assert field_map["counter"]["default"] == 0
|
||||
assert field_map["message"]["default"] == ""
|
||||
|
||||
def test_dict_state_returns_none(self):
|
||||
"""Test that flows using dict state return None for state_schema."""
|
||||
|
||||
class DictStateFlow(Flow):
|
||||
@start()
|
||||
def begin(self):
|
||||
self.state["count"] = 1
|
||||
return "started"
|
||||
|
||||
structure = flow_structure(DictStateFlow)
|
||||
|
||||
assert structure["state_schema"] is None
|
||||
|
||||
|
||||
class TestEdgeCases:
|
||||
"""Test edge cases and special scenarios."""
|
||||
|
||||
def test_start_router_combo(self):
|
||||
"""Test a method that is both @start and a router (via human_feedback emit)."""
|
||||
|
||||
class StartRouterFlow(Flow):
|
||||
@start()
|
||||
@human_feedback(
|
||||
message="Review:",
|
||||
emit=["continue", "stop"],
|
||||
llm="gpt-4o-mini",
|
||||
)
|
||||
def entry_point(self):
|
||||
return "data"
|
||||
|
||||
@listen("continue")
|
||||
def proceed(self):
|
||||
return "proceeding"
|
||||
|
||||
@listen("stop")
|
||||
def halt(self):
|
||||
return "halted"
|
||||
|
||||
structure = flow_structure(StartRouterFlow)
|
||||
|
||||
method_map = {m["name"]: m for m in structure["methods"]}
|
||||
|
||||
assert method_map["entry_point"]["type"] == "start_router"
|
||||
assert method_map["entry_point"]["has_human_feedback"] is True
|
||||
assert "continue" in method_map["entry_point"]["router_paths"]
|
||||
assert "stop" in method_map["entry_point"]["router_paths"]
|
||||
|
||||
def test_multiple_start_methods(self):
|
||||
"""Test a flow with multiple start methods."""
|
||||
|
||||
class MultiStartFlow(Flow):
|
||||
@start()
|
||||
def start_a(self):
|
||||
return "a"
|
||||
|
||||
@start()
|
||||
def start_b(self):
|
||||
return "b"
|
||||
|
||||
@listen(and_(start_a, start_b))
|
||||
def combine(self):
|
||||
return "combined"
|
||||
|
||||
structure = flow_structure(MultiStartFlow)
|
||||
|
||||
start_methods = [m for m in structure["methods"] if m["type"] == "start"]
|
||||
assert len(start_methods) == 2
|
||||
|
||||
start_names = {m["name"] for m in start_methods}
|
||||
assert "start_a" in start_names
|
||||
assert "start_b" in start_names
|
||||
|
||||
def test_orphan_methods(self):
|
||||
"""Test that orphan methods (not connected to flow) are still captured."""
|
||||
|
||||
class FlowWithOrphan(Flow):
|
||||
@start()
|
||||
def begin(self):
|
||||
return "started"
|
||||
|
||||
@listen(begin)
|
||||
def connected(self):
|
||||
return "connected"
|
||||
|
||||
@listen("never_triggered")
|
||||
def orphan(self):
|
||||
return "orphan"
|
||||
|
||||
structure = flow_structure(FlowWithOrphan)
|
||||
|
||||
method_names = {m["name"] for m in structure["methods"]}
|
||||
assert "orphan" in method_names
|
||||
|
||||
method_map = {m["name"]: m for m in structure["methods"]}
|
||||
assert method_map["orphan"]["trigger_methods"] == ["never_triggered"]
|
||||
|
||||
def test_empty_flow(self):
|
||||
"""Test building structure for a flow with no methods."""
|
||||
|
||||
class EmptyFlow(Flow):
|
||||
pass
|
||||
|
||||
structure = flow_structure(EmptyFlow)
|
||||
|
||||
assert structure["name"] == "EmptyFlow"
|
||||
assert structure["methods"] == []
|
||||
assert structure["edges"] == []
|
||||
assert structure["state_schema"] is None
|
||||
|
||||
def test_flow_with_docstring(self):
|
||||
"""Test that flow docstring is captured."""
|
||||
|
||||
class DocumentedFlow(Flow):
|
||||
"""This is a well-documented flow.
|
||||
|
||||
It has multiple lines of documentation.
|
||||
"""
|
||||
|
||||
@start()
|
||||
def begin(self):
|
||||
return "started"
|
||||
|
||||
structure = flow_structure(DocumentedFlow)
|
||||
|
||||
assert structure["description"] is not None
|
||||
assert "well-documented flow" in structure["description"]
|
||||
|
||||
def test_flow_without_docstring(self):
|
||||
"""Test that missing docstring returns None."""
|
||||
|
||||
class UndocumentedFlow(Flow):
|
||||
@start()
|
||||
def begin(self):
|
||||
return "started"
|
||||
|
||||
structure = flow_structure(UndocumentedFlow)
|
||||
|
||||
assert structure["description"] is None
|
||||
|
||||
def test_nested_conditions(self):
|
||||
"""Test flow with nested AND/OR conditions."""
|
||||
|
||||
class NestedConditionFlow(Flow):
|
||||
@start()
|
||||
def a(self):
|
||||
return "a"
|
||||
|
||||
@start()
|
||||
def b(self):
|
||||
return "b"
|
||||
|
||||
@start()
|
||||
def c(self):
|
||||
return "c"
|
||||
|
||||
@listen(or_(and_(a, b), c))
|
||||
def complex_trigger(self):
|
||||
return "triggered"
|
||||
|
||||
structure = flow_structure(NestedConditionFlow)
|
||||
|
||||
method_map = {m["name"]: m for m in structure["methods"]}
|
||||
|
||||
triggers = method_map["complex_trigger"]["trigger_methods"]
|
||||
assert len(triggers) == 3
|
||||
assert "a" in triggers
|
||||
assert "b" in triggers
|
||||
assert "c" in triggers
|
||||
|
||||
|
||||
class TestErrorHandling:
|
||||
"""Test error handling and validation."""
|
||||
|
||||
def test_instance_raises_type_error(self):
|
||||
"""Test that passing an instance raises TypeError."""
|
||||
|
||||
class TestFlow(Flow):
|
||||
@start()
|
||||
def begin(self):
|
||||
return "started"
|
||||
|
||||
flow_instance = TestFlow()
|
||||
|
||||
with pytest.raises(TypeError) as exc_info:
|
||||
flow_structure(flow_instance)
|
||||
|
||||
assert "requires a Flow class, not an instance" in str(exc_info.value)
|
||||
|
||||
def test_non_class_raises_type_error(self):
|
||||
"""Test that passing non-class raises TypeError."""
|
||||
|
||||
with pytest.raises(TypeError):
|
||||
flow_structure("not a class")
|
||||
|
||||
with pytest.raises(TypeError):
|
||||
flow_structure(123)
|
||||
|
||||
|
||||
class TestEdgeGeneration:
|
||||
"""Test edge generation in various scenarios."""
|
||||
|
||||
def test_all_edges_generated_correctly(self):
|
||||
"""Verify all edges are correctly generated for a complex flow."""
|
||||
|
||||
class ComplexFlow(Flow):
|
||||
@start()
|
||||
def entry(self):
|
||||
return "started"
|
||||
|
||||
@listen(entry)
|
||||
def step_1(self):
|
||||
return "step_1"
|
||||
|
||||
@router(step_1)
|
||||
def branch(self) -> Literal["left", "right"]:
|
||||
return "left"
|
||||
|
||||
@listen("left")
|
||||
def left_path(self):
|
||||
return "left_done"
|
||||
|
||||
@listen("right")
|
||||
def right_path(self):
|
||||
return "right_done"
|
||||
|
||||
@listen(or_(left_path, right_path))
|
||||
def converge(self):
|
||||
return "done"
|
||||
|
||||
structure = flow_structure(ComplexFlow)
|
||||
|
||||
edges = structure["edges"]
|
||||
|
||||
listen_edges = [(e["from_method"], e["to_method"]) for e in edges if e["edge_type"] == "listen"]
|
||||
|
||||
assert ("entry", "step_1") in listen_edges
|
||||
assert ("step_1", "branch") in listen_edges
|
||||
assert ("left_path", "converge") in listen_edges
|
||||
assert ("right_path", "converge") in listen_edges
|
||||
|
||||
route_edges = [(e["from_method"], e["to_method"], e["condition"]) for e in edges if e["edge_type"] == "route"]
|
||||
|
||||
assert ("branch", "left_path", "left") in route_edges
|
||||
assert ("branch", "right_path", "right") in route_edges
|
||||
|
||||
def test_router_edge_conditions(self):
|
||||
"""Test that router edge conditions are properly set."""
|
||||
|
||||
class RouterConditionFlow(Flow):
|
||||
@start()
|
||||
def begin(self):
|
||||
return "start"
|
||||
|
||||
@router(begin)
|
||||
def route(self) -> Literal["option_1", "option_2", "option_3"]:
|
||||
return "option_1"
|
||||
|
||||
@listen("option_1")
|
||||
def handle_1(self):
|
||||
return "1"
|
||||
|
||||
@listen("option_2")
|
||||
def handle_2(self):
|
||||
return "2"
|
||||
|
||||
@listen("option_3")
|
||||
def handle_3(self):
|
||||
return "3"
|
||||
|
||||
structure = flow_structure(RouterConditionFlow)
|
||||
|
||||
route_edges = [e for e in structure["edges"] if e["edge_type"] == "route"]
|
||||
|
||||
assert len(route_edges) == 3
|
||||
|
||||
conditions = {e["to_method"]: e["condition"] for e in route_edges}
|
||||
assert conditions["handle_1"] == "option_1"
|
||||
assert conditions["handle_2"] == "option_2"
|
||||
assert conditions["handle_3"] == "option_3"
|
||||
|
||||
|
||||
class TestMethodTypeClassification:
|
||||
"""Test method type classification."""
|
||||
|
||||
def test_all_method_types(self):
|
||||
"""Test classification of all method types."""
|
||||
|
||||
class AllTypesFlow(Flow):
|
||||
@start()
|
||||
def start_only(self):
|
||||
return "start"
|
||||
|
||||
@listen(start_only)
|
||||
def listen_only(self):
|
||||
return "listen"
|
||||
|
||||
@router(listen_only)
|
||||
def router_only(self) -> Literal["path"]:
|
||||
return "path"
|
||||
|
||||
@listen("path")
|
||||
def after_router(self):
|
||||
return "after"
|
||||
|
||||
@start()
|
||||
@human_feedback(
|
||||
message="Review",
|
||||
emit=["yes", "no"],
|
||||
llm="gpt-4o-mini",
|
||||
)
|
||||
def start_and_router(self):
|
||||
return "data"
|
||||
|
||||
structure = flow_structure(AllTypesFlow)
|
||||
|
||||
method_map = {m["name"]: m for m in structure["methods"]}
|
||||
|
||||
assert method_map["start_only"]["type"] == "start"
|
||||
assert method_map["listen_only"]["type"] == "listen"
|
||||
assert method_map["router_only"]["type"] == "router"
|
||||
assert method_map["after_router"]["type"] == "listen"
|
||||
assert method_map["start_and_router"]["type"] == "start_router"
|
||||
|
||||
|
||||
class TestInputDetection:
|
||||
"""Test flow input detection."""
|
||||
|
||||
def test_inputs_list_exists(self):
|
||||
"""Test that inputs list is always present."""
|
||||
|
||||
class SimpleFlow(Flow):
|
||||
@start()
|
||||
def begin(self):
|
||||
return "started"
|
||||
|
||||
structure = flow_structure(SimpleFlow)
|
||||
|
||||
assert "inputs" in structure
|
||||
assert isinstance(structure["inputs"], list)
|
||||
|
||||
|
||||
class TestJsonSerializable:
|
||||
"""Test that output is JSON serializable."""
|
||||
|
||||
def test_structure_is_json_serializable(self):
|
||||
"""Test that the entire structure can be JSON serialized."""
|
||||
import json
|
||||
|
||||
class MyState(BaseModel):
|
||||
value: int = 0
|
||||
|
||||
class SerializableFlow(Flow[MyState]):
|
||||
"""Test flow for JSON serialization."""
|
||||
|
||||
initial_state = MyState
|
||||
|
||||
@start()
|
||||
@human_feedback(
|
||||
message="Review",
|
||||
emit=["ok", "not_ok"],
|
||||
llm="gpt-4o-mini",
|
||||
)
|
||||
def begin(self):
|
||||
return "data"
|
||||
|
||||
@listen("ok")
|
||||
def proceed(self):
|
||||
return "done"
|
||||
|
||||
structure = flow_structure(SerializableFlow)
|
||||
|
||||
json_str = json.dumps(structure)
|
||||
assert json_str is not None
|
||||
|
||||
parsed = json.loads(json_str)
|
||||
assert parsed["name"] == "SerializableFlow"
|
||||
assert len(parsed["methods"]) > 0
|
||||
|
||||
|
||||
class TestFlowInheritance:
|
||||
"""Test flow inheritance scenarios."""
|
||||
|
||||
def test_child_flow_inherits_parent_methods(self):
|
||||
"""Test that FlowB inheriting from FlowA includes methods from both.
|
||||
|
||||
Note: FlowMeta propagates methods but does NOT fully propagate the
|
||||
_listeners registry from parent classes. This means edges defined
|
||||
in the parent class (e.g., parent_start -> parent_process) may not
|
||||
appear in the child's structure. This is a known FlowMeta limitation.
|
||||
"""
|
||||
|
||||
class FlowA(Flow):
|
||||
"""Parent flow with start method."""
|
||||
|
||||
@start()
|
||||
def parent_start(self):
|
||||
return "parent started"
|
||||
|
||||
@listen(parent_start)
|
||||
def parent_process(self):
|
||||
return "parent processed"
|
||||
|
||||
class FlowB(FlowA):
|
||||
"""Child flow with additional methods."""
|
||||
|
||||
@listen(FlowA.parent_process)
|
||||
def child_continue(self):
|
||||
return "child continued"
|
||||
|
||||
@listen(child_continue)
|
||||
def child_finalize(self):
|
||||
return "child finalized"
|
||||
|
||||
structure = flow_structure(FlowB)
|
||||
|
||||
assert structure["name"] == "FlowB"
|
||||
|
||||
method_names = {m["name"] for m in structure["methods"]}
|
||||
assert "parent_start" in method_names
|
||||
assert "parent_process" in method_names
|
||||
assert "child_continue" in method_names
|
||||
assert "child_finalize" in method_names
|
||||
|
||||
method_map = {m["name"]: m for m in structure["methods"]}
|
||||
assert method_map["parent_start"]["type"] == "start"
|
||||
assert method_map["parent_process"]["type"] == "listen"
|
||||
assert method_map["child_continue"]["type"] == "listen"
|
||||
assert method_map["child_finalize"]["type"] == "listen"
|
||||
|
||||
edge_pairs = [(e["from_method"], e["to_method"]) for e in structure["edges"]]
|
||||
assert ("parent_process", "child_continue") in edge_pairs
|
||||
assert ("child_continue", "child_finalize") in edge_pairs
|
||||
|
||||
# KNOWN LIMITATION: Edges defined in parent class (parent_start -> parent_process)
|
||||
# are NOT propagated to child's _listeners registry by FlowMeta.
|
||||
# This is a FlowMeta limitation, not a serializer bug.
|
||||
|
||||
def test_child_flow_can_override_parent_method(self):
|
||||
"""Test that child can override parent methods."""
|
||||
|
||||
class BaseFlow(Flow):
|
||||
@start()
|
||||
def begin(self):
|
||||
return "base begin"
|
||||
|
||||
@listen(begin)
|
||||
def process(self):
|
||||
return "base process"
|
||||
|
||||
class ExtendedFlow(BaseFlow):
|
||||
@listen(BaseFlow.begin)
|
||||
def process(self):
|
||||
return "extended process"
|
||||
|
||||
@listen(process)
|
||||
def finalize(self):
|
||||
return "extended finalize"
|
||||
|
||||
structure = flow_structure(ExtendedFlow)
|
||||
|
||||
method_names = {m["name"] for m in structure["methods"]}
|
||||
assert "begin" in method_names
|
||||
assert "process" in method_names
|
||||
assert "finalize" in method_names
|
||||
|
||||
# Should have 3 methods total (not 4, since process is overridden)
|
||||
assert len(structure["methods"]) == 3
|
||||
@@ -8,6 +8,7 @@ from pathlib import Path
|
||||
import pytest
|
||||
|
||||
from crewai.flow.flow import Flow, and_, listen, or_, router, start
|
||||
from crewai.flow.flow_definition import FlowDefinition
|
||||
from crewai.flow.visualization import (
|
||||
build_flow_structure,
|
||||
visualize_flow_structure,
|
||||
@@ -76,6 +77,16 @@ class ComplexFlow(Flow):
|
||||
return "complete"
|
||||
|
||||
|
||||
def _attach_flow_definition(flow_class: type[Flow], methods: dict[str, object]) -> None:
|
||||
flow_class._flow_definition = FlowDefinition.from_dict(
|
||||
{
|
||||
"schema": "crewai.flow/v1",
|
||||
"name": flow_class.__name__,
|
||||
"methods": methods,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_build_flow_structure_simple():
|
||||
"""Test building structure for a simple sequential flow."""
|
||||
flow = SimpleFlow()
|
||||
@@ -98,6 +109,47 @@ def test_build_flow_structure_simple():
|
||||
assert edge["condition_type"] == "OR"
|
||||
|
||||
|
||||
def test_build_flow_structure_from_flow_class():
|
||||
"""Test building structure from a Flow class via its FlowDefinition."""
|
||||
structure = build_flow_structure(SimpleFlow)
|
||||
|
||||
assert set(structure["nodes"]) == {"begin", "process"}
|
||||
assert structure["start_methods"] == ["begin"]
|
||||
assert structure["nodes"]["begin"]["class_name"] == "SimpleFlow"
|
||||
|
||||
|
||||
def test_build_flow_structure_from_flow_definition():
|
||||
"""Test building visualization directly from a FlowDefinition."""
|
||||
definition = FlowDefinition.from_dict(
|
||||
{
|
||||
"schema": "crewai.flow/v1",
|
||||
"name": "DefinedFlow",
|
||||
"methods": {
|
||||
"begin": {"start": True},
|
||||
"decide": {
|
||||
"listen": "begin",
|
||||
"router": True,
|
||||
"returns": ["done"],
|
||||
},
|
||||
"finish": {"listen": "done"},
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
structure = build_flow_structure(definition)
|
||||
|
||||
assert set(structure["nodes"]) == {"begin", "decide", "finish"}
|
||||
assert structure["start_methods"] == ["begin"]
|
||||
assert structure["router_methods"] == ["decide"]
|
||||
assert structure["nodes"]["begin"]["class_name"] == "DefinedFlow"
|
||||
assert any(
|
||||
edge["source"] == "decide"
|
||||
and edge["target"] == "finish"
|
||||
and edge["router_path_label"] == "done"
|
||||
for edge in structure["edges"]
|
||||
)
|
||||
|
||||
|
||||
def test_build_flow_structure_with_router():
|
||||
"""Test building structure for a flow with router."""
|
||||
flow = RouterFlow()
|
||||
@@ -111,13 +163,10 @@ def test_build_flow_structure_with_router():
|
||||
|
||||
router_node = structure["nodes"]["decide"]
|
||||
assert router_node["type"] == "router"
|
||||
|
||||
if "router_paths" in router_node:
|
||||
assert len(router_node["router_paths"]) >= 1
|
||||
assert any("path" in path for path in router_node["router_paths"])
|
||||
assert "router_paths" not in router_node
|
||||
|
||||
router_edges = [edge for edge in structure["edges"] if edge["is_router_path"]]
|
||||
assert len(router_edges) >= 1
|
||||
assert router_edges == []
|
||||
|
||||
|
||||
def test_build_flow_structure_with_and_or_conditions():
|
||||
@@ -207,31 +256,25 @@ def test_visualize_flow_structure_json_data():
|
||||
assert "path_b" in js_content
|
||||
|
||||
|
||||
def test_node_metadata_includes_source_info():
|
||||
"""Test that nodes include source code and line number information."""
|
||||
def test_node_metadata_omits_source_info():
|
||||
"""Test that definition-only visualization omits Python source metadata."""
|
||||
flow = SimpleFlow()
|
||||
structure = build_flow_structure(flow)
|
||||
|
||||
for node_name, node_metadata in structure["nodes"].items():
|
||||
assert node_metadata["source_code"] is not None
|
||||
assert len(node_metadata["source_code"]) > 0
|
||||
assert node_metadata["source_start_line"] is not None
|
||||
assert node_metadata["source_start_line"] > 0
|
||||
assert node_metadata["source_file"] is not None
|
||||
assert node_metadata["source_file"].endswith(".py")
|
||||
for node_metadata in structure["nodes"].values():
|
||||
assert "source_code" not in node_metadata
|
||||
assert "source_lines" not in node_metadata
|
||||
assert "source_start_line" not in node_metadata
|
||||
assert "source_file" not in node_metadata
|
||||
|
||||
|
||||
def test_node_metadata_includes_method_signature():
|
||||
"""Test that nodes include method signature information."""
|
||||
def test_node_metadata_omits_method_signature():
|
||||
"""Test that definition-only visualization omits Python method signatures."""
|
||||
flow = SimpleFlow()
|
||||
structure = build_flow_structure(flow)
|
||||
|
||||
begin_node = structure["nodes"]["begin"]
|
||||
assert begin_node["method_signature"] is not None
|
||||
assert "operationId" in begin_node["method_signature"]
|
||||
assert begin_node["method_signature"]["operationId"] == "begin"
|
||||
assert "parameters" in begin_node["method_signature"]
|
||||
assert "returns" in begin_node["method_signature"]
|
||||
assert "method_signature" not in begin_node
|
||||
|
||||
|
||||
def test_router_node_has_correct_metadata():
|
||||
@@ -242,10 +285,7 @@ def test_router_node_has_correct_metadata():
|
||||
router_node = structure["nodes"]["decide"]
|
||||
assert router_node["type"] == "router"
|
||||
assert router_node["is_router"] is True
|
||||
assert router_node["router_paths"] is not None
|
||||
assert len(router_node["router_paths"]) == 2
|
||||
assert "path_a" in router_node["router_paths"]
|
||||
assert "path_b" in router_node["router_paths"]
|
||||
assert "router_paths" not in router_node
|
||||
|
||||
|
||||
def test_listen_node_has_trigger_methods():
|
||||
@@ -317,16 +357,15 @@ def test_topological_path_counting():
|
||||
assert len(structure["edges"]) > 0
|
||||
|
||||
|
||||
def test_class_signature_metadata():
|
||||
"""Test that nodes include class signature information."""
|
||||
def test_class_metadata_comes_from_definition():
|
||||
"""Test that nodes include only definition-derived class metadata."""
|
||||
flow = SimpleFlow()
|
||||
structure = build_flow_structure(flow)
|
||||
|
||||
for node_name, node_metadata in structure["nodes"].items():
|
||||
for node_metadata in structure["nodes"].values():
|
||||
assert node_metadata["class_name"] is not None
|
||||
assert node_metadata["class_name"] == "SimpleFlow"
|
||||
assert node_metadata["class_signature"] is not None
|
||||
assert "SimpleFlow" in node_metadata["class_signature"]
|
||||
assert "class_signature" not in node_metadata
|
||||
|
||||
|
||||
def test_visualization_plot_method():
|
||||
@@ -361,6 +400,15 @@ def test_router_paths_to_string_conditions():
|
||||
return "handled_b"
|
||||
|
||||
flow = RouterToStringFlow()
|
||||
_attach_flow_definition(
|
||||
RouterToStringFlow,
|
||||
{
|
||||
"init": {"start": True},
|
||||
"decide": {"listen": "init", "router": True, "returns": ["path_a", "path_b"]},
|
||||
"handle_either": {"listen": {"or": ["path_a", "path_b"]}},
|
||||
"handle_b_only": {"listen": "path_b"},
|
||||
},
|
||||
)
|
||||
structure = build_flow_structure(flow)
|
||||
|
||||
decide_node = structure["nodes"]["decide"]
|
||||
@@ -407,6 +455,16 @@ def test_router_paths_not_in_and_conditions():
|
||||
return "step_3_done"
|
||||
|
||||
flow = RouterAndConditionFlow()
|
||||
_attach_flow_definition(
|
||||
RouterAndConditionFlow,
|
||||
{
|
||||
"init": {"start": True},
|
||||
"decide": {"listen": "init", "router": True, "returns": ["path_a"]},
|
||||
"step_1": {"listen": "path_a"},
|
||||
"step_2_and": {"listen": {"and": ["path_a", "step_1"]}},
|
||||
"step_3_or": {"listen": {"or": [{"and": ["path_a", "step_1"]}, "path_a"]}},
|
||||
},
|
||||
)
|
||||
structure = build_flow_structure(flow)
|
||||
|
||||
router_edges = [edge for edge in structure["edges"] if edge["is_router_path"]]
|
||||
@@ -418,6 +476,66 @@ def test_router_paths_not_in_and_conditions():
|
||||
assert "step_2_and" not in targets
|
||||
|
||||
|
||||
def test_analysis_router_paths_only_match_direct_or_triggers():
|
||||
"""Test analysis helpers align router paths with builder direct-OR semantics."""
|
||||
from crewai.flow.visualization.analysis import (
|
||||
_build_parent_children_dict,
|
||||
_calculate_node_levels,
|
||||
_count_outgoing_edges,
|
||||
)
|
||||
|
||||
definition = FlowDefinition.from_dict(
|
||||
{
|
||||
"schema": "crewai.flow/v1",
|
||||
"name": "RouterAndAnalysisFlow",
|
||||
"methods": {
|
||||
"init": {"start": True},
|
||||
"decide": {
|
||||
"listen": "init",
|
||||
"router": True,
|
||||
"returns": ["path_a"],
|
||||
},
|
||||
"step_1": {"listen": "path_a"},
|
||||
"step_2_and": {"listen": {"and": ["path_a", "step_1"]}},
|
||||
"step_3_or": {
|
||||
"listen": {"or": [{"and": ["path_a", "step_1"]}, "path_a"]}
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
parent_children = _build_parent_children_dict(definition)
|
||||
levels = _calculate_node_levels(definition)
|
||||
outgoing_edges = _count_outgoing_edges(definition)
|
||||
|
||||
assert parent_children["decide"] == ["step_1", "step_3_or"]
|
||||
assert levels["step_2_and"] > levels["step_1"]
|
||||
assert outgoing_edges["decide"] == 2
|
||||
|
||||
|
||||
def test_analysis_ancestors_propagate_after_late_parent_merge():
|
||||
"""Test merged graph ancestors continue downstream after a later parent."""
|
||||
from crewai.flow.visualization.analysis import _build_ancestor_dict
|
||||
|
||||
definition = FlowDefinition.from_dict(
|
||||
{
|
||||
"schema": "crewai.flow/v1",
|
||||
"name": "MergedAncestorFlow",
|
||||
"methods": {
|
||||
"A": {"start": True},
|
||||
"B": {"listen": "A"},
|
||||
"D": {"listen": {"or": ["B", "C"]}},
|
||||
"E": {"listen": "D"},
|
||||
"C": {"listen": "A"},
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
ancestors = _build_ancestor_dict(definition)
|
||||
|
||||
assert ancestors["E"] == {"A", "B", "C", "D"}
|
||||
|
||||
|
||||
def test_chained_routers_no_self_loops():
|
||||
"""Test that chained routers don't create self-referencing edges.
|
||||
|
||||
@@ -454,6 +572,17 @@ def test_chained_routers_no_self_loops():
|
||||
return "need_auth"
|
||||
|
||||
flow = ChainedRouterFlow()
|
||||
_attach_flow_definition(
|
||||
ChainedRouterFlow,
|
||||
{
|
||||
"entrance": {"start": True},
|
||||
"session_in_cache": {"listen": "entrance", "router": True, "returns": ["exp"]},
|
||||
"check_exp": {"listen": "exp", "router": True, "returns": ["auth"]},
|
||||
"call_ai_auth": {"listen": "auth", "router": True, "returns": ["action"]},
|
||||
"forward_to_action": {"listen": "action"},
|
||||
"forward_to_authenticate": {"listen": "authenticate"},
|
||||
},
|
||||
)
|
||||
structure = build_flow_structure(flow)
|
||||
|
||||
for edge in structure["edges"]:
|
||||
@@ -523,6 +652,16 @@ def test_routers_with_shared_output_strings():
|
||||
return "skipped"
|
||||
|
||||
flow = SharedOutputRouterFlow()
|
||||
_attach_flow_definition(
|
||||
SharedOutputRouterFlow,
|
||||
{
|
||||
"start": {"start": True},
|
||||
"router_a": {"listen": "start", "router": True, "returns": ["auth"]},
|
||||
"router_b": {"listen": "auth", "router": True, "returns": ["done"]},
|
||||
"finalize": {"listen": "done"},
|
||||
"handle_skip": {"listen": "skip"},
|
||||
},
|
||||
)
|
||||
structure = build_flow_structure(flow)
|
||||
|
||||
for edge in structure["edges"]:
|
||||
@@ -580,18 +719,19 @@ def test_warning_for_router_without_paths(caplog):
|
||||
build_flow_structure(flow)
|
||||
|
||||
assert any(
|
||||
"Could not determine return paths for router 'dynamic_router'" in record.message
|
||||
"Router paths for 'dynamic_router' are dynamic" in record.message
|
||||
for record in caplog.records
|
||||
)
|
||||
|
||||
assert any(
|
||||
"Found listeners waiting for triggers" in record.message
|
||||
"Static visualization could not match listener triggers" in record.message
|
||||
for record in caplog.records
|
||||
)
|
||||
assert not any(record.levelno >= logging.ERROR for record in caplog.records)
|
||||
|
||||
|
||||
def test_warning_for_orphaned_listeners(caplog):
|
||||
"""Test that an error is logged when listeners wait for triggers no router outputs."""
|
||||
"""Test that a warning is logged when a trigger has no explicit router output."""
|
||||
import logging
|
||||
from typing import Literal
|
||||
|
||||
@@ -615,19 +755,33 @@ def test_warning_for_orphaned_listeners(caplog):
|
||||
return "orphan"
|
||||
|
||||
flow = OrphanedListenerFlow()
|
||||
_attach_flow_definition(
|
||||
OrphanedListenerFlow,
|
||||
{
|
||||
"begin": {"start": True},
|
||||
"my_router": {
|
||||
"listen": "begin",
|
||||
"router": True,
|
||||
"returns": ["option_a", "option_b"],
|
||||
},
|
||||
"handle_a": {"listen": "option_a"},
|
||||
"handle_orphan": {"listen": "option_c"},
|
||||
},
|
||||
)
|
||||
|
||||
with caplog.at_level(logging.ERROR):
|
||||
with caplog.at_level(logging.WARNING):
|
||||
build_flow_structure(flow)
|
||||
|
||||
assert any(
|
||||
"Found listeners waiting for triggers" in record.message
|
||||
"Static visualization could not match listener triggers" in record.message
|
||||
and "option_c" in record.message
|
||||
for record in caplog.records
|
||||
)
|
||||
assert not any(record.levelno >= logging.ERROR for record in caplog.records)
|
||||
|
||||
|
||||
def test_no_warning_for_properly_typed_router(caplog):
|
||||
"""Test that no warning is logged when router has proper type annotations."""
|
||||
def test_no_warning_for_explicit_contract_router_paths(caplog):
|
||||
"""Test no warning is logged when router paths are declared in the contract."""
|
||||
import logging
|
||||
from typing import Literal
|
||||
|
||||
@@ -651,11 +805,27 @@ def test_no_warning_for_properly_typed_router(caplog):
|
||||
return "b"
|
||||
|
||||
flow = ProperlyTypedRouterFlow()
|
||||
_attach_flow_definition(
|
||||
ProperlyTypedRouterFlow,
|
||||
{
|
||||
"begin": {"start": True},
|
||||
"typed_router": {
|
||||
"listen": "begin",
|
||||
"router": True,
|
||||
"returns": ["path_a", "path_b"],
|
||||
},
|
||||
"handle_a": {"listen": "path_a"},
|
||||
"handle_b": {"listen": "path_b"},
|
||||
},
|
||||
)
|
||||
|
||||
with caplog.at_level(logging.WARNING):
|
||||
build_flow_structure(flow)
|
||||
|
||||
# No warnings should be logged
|
||||
warning_messages = [r.message for r in caplog.records if r.levelno >= logging.WARNING]
|
||||
assert not any("Could not determine return paths" in msg for msg in warning_messages)
|
||||
assert not any("Found listeners waiting for triggers" in msg for msg in warning_messages)
|
||||
assert not any("Router paths for" in msg for msg in warning_messages)
|
||||
assert not any(
|
||||
"Static visualization could not match listener triggers" in msg
|
||||
for msg in warning_messages
|
||||
)
|
||||
|
||||
@@ -13,7 +13,7 @@ from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from crewai.flow import Flow, human_feedback, listen, start
|
||||
from crewai.flow import Flow, human_feedback, listen, persist, start
|
||||
from crewai.flow.human_feedback import (
|
||||
HumanFeedbackConfig,
|
||||
HumanFeedbackResult,
|
||||
@@ -91,6 +91,22 @@ class TestHumanFeedbackValidation:
|
||||
assert hasattr(test_method, "__human_feedback_config__")
|
||||
assert not hasattr(test_method, "__is_router__") or not test_method.__is_router__
|
||||
|
||||
def test_persist_preserves_hf_llm_attribute(self):
|
||||
"""Test @persist preserves the live LLM stashed by @human_feedback."""
|
||||
llm = object()
|
||||
|
||||
@persist()
|
||||
@human_feedback(
|
||||
message="Review this:",
|
||||
emit=["approve", "reject"],
|
||||
llm=llm,
|
||||
)
|
||||
def test_method(self):
|
||||
return "output"
|
||||
|
||||
assert hasattr(test_method, "_hf_llm")
|
||||
assert test_method._hf_llm is llm
|
||||
|
||||
|
||||
class TestHumanFeedbackConfig:
|
||||
"""Tests for HumanFeedbackConfig dataclass."""
|
||||
|
||||
@@ -10,7 +10,7 @@ requires-python = ">=3.10, <3.14"
|
||||
classifiers = ["Private :: Do Not Upload"]
|
||||
private = true
|
||||
dependencies = [
|
||||
"click~=8.1.7",
|
||||
"click>=8.1.7,<9",
|
||||
"tomlkit~=0.13.2",
|
||||
"openai>=1.83.0,<3",
|
||||
"python-dotenv>=1.2.2,<2",
|
||||
|
||||
@@ -918,7 +918,7 @@ def _update_all_versions(
|
||||
"[yellow]Warning:[/yellow] No __version__ attributes found to update"
|
||||
)
|
||||
|
||||
templates_dir = lib_dir / "crewai" / "src" / "crewai" / "cli" / "templates"
|
||||
templates_dir = lib_dir / "cli" / "src" / "crewai_cli" / "templates"
|
||||
if templates_dir.exists():
|
||||
if dry_run:
|
||||
for tpl in templates_dir.rglob("pyproject.toml"):
|
||||
|
||||
@@ -181,7 +181,7 @@ exclude-newer = "3 days"
|
||||
# transformers 4.57.6 has CVE-2026-1839; force 5.4+ (docling 2.84 allows huggingface-hub>=1).
|
||||
# cryptography 46.0.6 has CVE-2026-39892; force 46.0.7+.
|
||||
# pypdf <6.10.2 has GHSA-4pxv-j86v-mhcw, GHSA-7gw9-cf7v-778f, GHSA-x284-j5p8-9c5p; force 6.10.2+.
|
||||
# uv <0.11.6 has GHSA-pjjw-68hj-v9mw; force 0.11.6+.
|
||||
# uv <0.11.15 has GHSA-4gg8-gxpx-9rph (and earlier GHSA-pjjw-68hj-v9mw); force 0.11.15+.
|
||||
# python-multipart <0.0.27 has GHSA-pp6c-gr5w-3c5g (DoS via unbounded multipart headers).
|
||||
# gitpython <3.1.50 has GHSA-mv93-w799-cj2w (config_writer newline injection bypassing the 3.1.49 patch -> RCE via core.hooksPath).
|
||||
# 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+.
|
||||
@@ -203,7 +203,7 @@ override-dependencies = [
|
||||
"transformers>=5.4.0; python_version >= '3.10'",
|
||||
"cryptography>=46.0.7",
|
||||
"pypdf>=6.10.2,<7",
|
||||
"uv>=0.11.6,<1",
|
||||
"uv>=0.11.15,<1",
|
||||
"python-multipart>=0.0.27,<1",
|
||||
"gitpython>=3.1.50,<4",
|
||||
"langsmith>=0.8.0,<1",
|
||||
|
||||
65
uv.lock
generated
65
uv.lock
generated
@@ -13,12 +13,9 @@ resolution-markers = [
|
||||
]
|
||||
|
||||
[options]
|
||||
exclude-newer = "2026-05-19T15:27:50.647689Z"
|
||||
exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values.
|
||||
exclude-newer-span = "P3D"
|
||||
|
||||
[options.exclude-newer-package]
|
||||
starlette = "2026-05-22T16:00:00Z"
|
||||
|
||||
[manifest]
|
||||
members = [
|
||||
"crewai",
|
||||
@@ -46,7 +43,7 @@ overrides = [
|
||||
{ name = "starlette", specifier = ">=1.0.1" },
|
||||
{ name = "transformers", marker = "python_full_version >= '3.10'", specifier = ">=5.4.0" },
|
||||
{ name = "urllib3", specifier = ">=2.7.0" },
|
||||
{ name = "uv", specifier = ">=0.11.6,<1" },
|
||||
{ name = "uv", specifier = ">=0.11.15,<1" },
|
||||
]
|
||||
|
||||
[manifest.dependency-groups]
|
||||
@@ -1392,7 +1389,7 @@ requires-dist = [
|
||||
{ name = "boto3", marker = "extra == 'aws'", specifier = "~=1.42.79" },
|
||||
{ name = "boto3", marker = "extra == 'bedrock'", specifier = "~=1.42.79" },
|
||||
{ name = "chromadb", specifier = "~=1.1.0" },
|
||||
{ name = "click", specifier = "~=8.1.7" },
|
||||
{ name = "click", specifier = ">=8.1.7,<9" },
|
||||
{ name = "crewai-cli", editable = "lib/cli" },
|
||||
{ name = "crewai-core", editable = "lib/crewai-core" },
|
||||
{ name = "crewai-files", marker = "extra == 'file-processing'", editable = "lib/crewai-files" },
|
||||
@@ -1422,7 +1419,7 @@ requires-dist = [
|
||||
{ name = "portalocker", specifier = "~=2.7.0" },
|
||||
{ name = "pydantic", specifier = ">=2.11.9,<2.13" },
|
||||
{ name = "pydantic-settings", specifier = "~=2.10.1" },
|
||||
{ name = "pyjwt", specifier = ">=2.9.0,<3" },
|
||||
{ name = "pyjwt", specifier = ">=2.13.0,<3" },
|
||||
{ name = "python-dotenv", specifier = ">=1.2.2,<2" },
|
||||
{ name = "pyyaml", specifier = "~=6.0" },
|
||||
{ name = "qdrant-client", extras = ["fastembed"], marker = "extra == 'qdrant'", specifier = "~=1.14.3" },
|
||||
@@ -1462,14 +1459,14 @@ dependencies = [
|
||||
requires-dist = [
|
||||
{ name = "appdirs", specifier = "~=1.4.4" },
|
||||
{ name = "certifi" },
|
||||
{ name = "click", specifier = "~=8.1.7" },
|
||||
{ name = "click", specifier = ">=8.1.7,<9" },
|
||||
{ name = "crewai-core", editable = "lib/crewai-core" },
|
||||
{ name = "cryptography", specifier = ">=42.0" },
|
||||
{ name = "httpx", specifier = "~=0.28.1" },
|
||||
{ name = "packaging", specifier = ">=23.0" },
|
||||
{ name = "pydantic", specifier = ">=2.11.9,<2.13" },
|
||||
{ name = "pydantic-settings", specifier = "~=2.10.1" },
|
||||
{ name = "pyjwt", specifier = ">=2.9.0,<3" },
|
||||
{ name = "pyjwt", specifier = ">=2.13.0,<3" },
|
||||
{ name = "python-dotenv", specifier = ">=1.2.2,<2" },
|
||||
{ name = "rich", specifier = ">=13.7.1" },
|
||||
{ name = "textual", specifier = ">=7.5.0" },
|
||||
@@ -1507,7 +1504,7 @@ requires-dist = [
|
||||
{ name = "packaging", specifier = ">=23.0" },
|
||||
{ name = "portalocker", specifier = "~=2.7.0" },
|
||||
{ name = "pydantic", specifier = ">=2.11.9,<2.13" },
|
||||
{ name = "pyjwt", specifier = ">=2.9.0,<3" },
|
||||
{ name = "pyjwt", specifier = ">=2.13.0,<3" },
|
||||
{ name = "rich", specifier = ">=13.7.1" },
|
||||
{ name = "tomli", specifier = "~=2.0.2" },
|
||||
]
|
||||
@@ -1526,7 +1523,7 @@ dependencies = [
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "click", specifier = "~=8.1.7" },
|
||||
{ name = "click", specifier = ">=8.1.7,<9" },
|
||||
{ name = "openai", specifier = ">=1.83.0,<3" },
|
||||
{ name = "pygithub", specifier = "~=1.59.1" },
|
||||
{ name = "python-dotenv", specifier = ">=1.2.2,<2" },
|
||||
@@ -6905,14 +6902,14 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "pyjwt"
|
||||
version = "2.12.1"
|
||||
version = "2.13.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.11'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c2/27/a3b6e5bf6ff856d2509292e95c8f57f0df7017cf5394921fc4e4ef40308a/pyjwt-2.12.1.tar.gz", hash = "sha256:c74a7a2adf861c04d002db713dd85f84beb242228e671280bf709d765b03672b", size = 102564, upload-time = "2026-03-13T19:27:37.25Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515, upload-time = "2026-05-21T19:54:36.618Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/7a/8dd906bd22e79e47397a61742927f6747fe93242ef86645ee9092e610244/pyjwt-2.12.1-py3-none-any.whl", hash = "sha256:28ca37c070cad8ba8cd9790cd940535d40274d22f80ab87f3ac6a713e6e8454c", size = 29726, upload-time = "2026-03-13T19:27:35.677Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
@@ -9449,28 +9446,28 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "uv"
|
||||
version = "0.11.7"
|
||||
version = "0.11.17"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9b/7d/17750123a8c8e324627534fe1ae2e7a46689db8492f1a834ab4fd229a7d8/uv-0.11.7.tar.gz", hash = "sha256:46d971489b00bdb27e0aa715e4a5cd4ef2c28ea5b6ef78f2b67bf861eb44b405", size = 4083385, upload-time = "2026-04-15T21:42:55.474Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/2c/8e/ec34c19d0f254fcbcc5c1ce8c7f06e47e0f69a7e1a0269c1d59cb0b0f279/uv-0.11.17.tar.gz", hash = "sha256:1d1be74deec997db1dda05a7e67541c904d65cbfd72e455d3c0a2a1e4bf2cddf", size = 4203607, upload-time = "2026-05-28T20:39:47.707Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/5b/2bb2ab6fe6c78c2be10852482ef0cae5f3171460a6e5e24c32c9a0843163/uv-0.11.7-py3-none-linux_armv6l.whl", hash = "sha256:f422d39530516b1dfb28bb6e90c32bb7dacd50f6a383cd6e40c1a859419fbc8c", size = 23757265, upload-time = "2026-04-15T21:43:14.494Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/f5/36ff27b01e60a88712628c8a5a6003b8e418883c24e084e506095844a797/uv-0.11.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:8b2fe1ec6775dad10183e3fdce430a5b37b7857d49763c884f3a67eaa8ca6f8a", size = 23184529, upload-time = "2026-04-15T21:42:30.225Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/fa/f379be661316698f877e78f4c51e5044be0b6f390803387237ad92c4057f/uv-0.11.7-py3-none-macosx_11_0_arm64.whl", hash = "sha256:162fa961a9a081dcea6e889c79f738a5ae56507047e4672964972e33c301bea9", size = 21780167, upload-time = "2026-04-15T21:42:44.942Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/7f/fbed29775b0612f4f5679d3226268f1a347161abc1727b4080fb41d9f46f/uv-0.11.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:5985a15a92bd9a170fc1947abb1fbc3e9828c5a430ad85b5bed8356c20b67a71", size = 23609640, upload-time = "2026-04-15T21:42:22.57Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ad/de/989a69634a869a22322770120557c2d8cbba5b77ec7cfad326b4ec0f0547/uv-0.11.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:fab0bb43fbbc0ee5b5fee212078d2300c371b725faff7cf72eeaafa0bff0606b", size = 23322484, upload-time = "2026-04-15T21:43:26.52Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/08/c1af05ea602eb4eb75d86badb6b0594cc104c3ca83ccf06d9ed4dd2186ad/uv-0.11.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:23d457d6731ebdb83f1bffebe4894edab2ef43c1ec5488433c74300db4958924", size = 23326385, upload-time = "2026-04-15T21:42:41.32Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/99/e246962da06383e992ecab55000c62a50fb36efef855ea7264fad4816bf4/uv-0.11.7-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7d6a17507b8139b8803f445a03fd097f732ce8356b1b7b13cdb4dd8ef7f4b2e0", size = 24985751, upload-time = "2026-04-15T21:42:37.777Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/2d/b0b68083859579ce811996c1480765ec6a2442b44c451eaef53e6218fbae/uv-0.11.7-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dd48823ca4b505124389f49ae50626ba9f57212b9047738efc95126ed5f3844d", size = 25724160, upload-time = "2026-04-15T21:43:18.762Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/19/5970e89d9e458fd3c4966bbc586a685a1c0ab0a8bf334503f63fa20b925b/uv-0.11.7-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:eb91f52ee67e10d5290f2c2897e2171357f1a10966de38d83eefa93d96843b0c", size = 25028512, upload-time = "2026-04-15T21:43:02.721Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/83/eb/4e1557daf6693cb446ed28185664ad6682fd98c6dbac9e433cbc35df450a/uv-0.11.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4e4d5e31bea86e1b6e0f5a0f95e14e80018e6f6c0129256d2915a4b3d793644d", size = 24933975, upload-time = "2026-04-15T21:42:18.828Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/55/3b517ec8297f110d6981f525cccf26f86e30883fbb9c282769cffbcdcfca/uv-0.11.7-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:ceae53b202ea92bc954759bc7c7570cdcd5c3512fce15701198c19fd2dfb8605", size = 23706403, upload-time = "2026-04-15T21:43:10.664Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/30/7d93a0312d60e147722967036dc8ea37baab4802784bddc22464cb707deb/uv-0.11.7-py3-none-manylinux_2_31_riscv64.musllinux_1_1_riscv64.whl", hash = "sha256:f97e9f4e4d44fb5c4dfaa05e858ef3414a96416a2e4af270ecd88a3e5fb049a9", size = 24495797, upload-time = "2026-04-15T21:42:26.538Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/89/d49480bdab7725d36982793857e461d471bde8e1b7f438ffccee677a7bf8/uv-0.11.7-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:750ee5b96959b807cf442b73dd8b55111862d63f258f896787ea5f06b68aaca9", size = 24580471, upload-time = "2026-04-15T21:42:52.871Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/9f/c57dc03b48be17b564e304eb9ff982890c12dfb888b1ce370788733329ab/uv-0.11.7-py3-none-musllinux_1_1_i686.whl", hash = "sha256:f394331f0507e80ee732cb3df737589de53bed999dd02a6d24682f08c2f8ac4f", size = 24113637, upload-time = "2026-04-15T21:42:34.094Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/13/ba/b87e358b629a68258527e3490e73b7b148770f4d2257842dea3b7981d4e8/uv-0.11.7-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:0df59ab0c6a4b14a763e8445e1c303af9abeb53cdfa4428daf9ff9642c0a3cce", size = 25119850, upload-time = "2026-04-15T21:43:22.529Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/74/16d229e1d8574bcbafa6dc643ac20b70c3e581f42ac31a6f4fd53035ffe3/uv-0.11.7-py3-none-win32.whl", hash = "sha256:553e67cc766d013ce24353fecd4ea5533d2aedcfd35f9fac430e07b1d1f23ed4", size = 22918454, upload-time = "2026-04-15T21:42:58.702Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a6/1d/b73e473da616ac758b8918fb218febcc46ddf64cba9e03894dfa226b28bd/uv-0.11.7-py3-none-win_amd64.whl", hash = "sha256:5674dfb5944513f4b3735b05c2deba6b1b01151f46729d533d413a9a905f8c5d", size = 25447744, upload-time = "2026-04-15T21:42:48.813Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1b/bb/e6bfdea92ed270f3445a5a3c17599d041b3f2dbc5026c09e02830a03bbaf/uv-0.11.7-py3-none-win_arm64.whl", hash = "sha256:6158b7e39464f1aa1e040daa0186cae4749a78b5cd80ac769f32ca711b8976b1", size = 23941816, upload-time = "2026-04-15T21:43:06.732Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/2e/e6d42f9d39009eee976f1e5dfd31d3d1943e6e593ad7b191cf11e9744a36/uv-0.11.17-py3-none-linux_armv6l.whl", hash = "sha256:8426bfe315564d414cbc5ba5467595dc6348965e19acec742914f47da3ff269f", size = 23551216, upload-time = "2026-05-28T20:39:05.395Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/ee/d72bcc60f3585653a4b768425854d737d98d65c1765547d25c2999547ea9/uv-0.11.17-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6d1a033cc68cabb4141d6c1e3b66ffc6e970b98ba42e210f33270251e0bd8697", size = 22997377, upload-time = "2026-05-28T20:39:25.21Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/58/34/1bc69798d9ae998fbc42c61b02883f2ba00d04bdd858e589604d01846287/uv-0.11.17-py3-none-macosx_11_0_arm64.whl", hash = "sha256:58c07ffc272c847d29cd98ca5082fa4304a645f87c718ec900e3cca9026bd096", size = 21630197, upload-time = "2026-05-28T20:39:28.935Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/93/1be48ec6a8933d9a77d0ce5240ed63f68869f68517ccf5d62268ed03f3e8/uv-0.11.17-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:036d6e2940afe8b79637530b01b9241d8cfd174b07f1179a1ebbd42409c38ca3", size = 23414940, upload-time = "2026-05-28T20:39:55.015Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/31/b7488ff49d80090ea9d05d67a4d381a1b4479502e9853e654caa1c1c678e/uv-0.11.17-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:283186700c3e65a4644a73a917232da7d3e4a94d25ea0377a44f5b263fa49577", size = 23096330, upload-time = "2026-05-28T20:39:01.284Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/95/42b6137c5de06278d229c7eef2f314df2a738cd799795bbb44dace21bd6e/uv-0.11.17-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f2e44dfbfc7778d0d90edc6738f237c91e5e37e4e3cfe94c8a312cec56a41485", size = 23101906, upload-time = "2026-05-28T20:39:17.149Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/17/7c/0ca03b2d19965db6d5dfe0c8cf96a3d0b424503c8cbc3cd2ffdc5869a15d/uv-0.11.17-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1a817eeb3026f27a53d3f4b7855a5105f6787dd192140e201eda4d2b9a11b72e", size = 24444409, upload-time = "2026-05-28T20:39:59.218Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/fb/179f55a3b19d47c30ec1f41b9b964da74dfa7053ff310a70a9c4d8cb998d/uv-0.11.17-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bf8f5ad959583dcd2c4ae445c754a97c05700246ff89259f3fd285c9c20f4c00", size = 25540153, upload-time = "2026-05-28T20:39:09.535Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/29/592f42012765c43ae45c112110e214bca7b0cfc08c4c1b52e1dfa47dedd5/uv-0.11.17-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ce16892a45134d20165c1ceababe06f3e9ce6a58902db1eff812c8c93626823f", size = 24665906, upload-time = "2026-05-28T20:39:41.254Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/51/b75808766f895248553c6370968509cd4f726e6943e310a8f7a171036ad0/uv-0.11.17-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9da839e5a491c9a701d7d327a199cafc76ac27a03ac84fd2a8d4bf32c3af2448", size = 24863325, upload-time = "2026-05-28T20:39:51.006Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/6a/6f27ee69e97f480104bb8ec335f04c2a12add98edfcc4844a68e9538b6e2/uv-0.11.17-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:ec004b3c9bf9cb7756067ad1bd0bf64eb843e6fa2edbfbb3135ee152c14cea91", size = 23521674, upload-time = "2026-05-28T20:38:55.869Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/11/1344aca7c710f794750f74de0e552a54ab24193ecc01fa3b3ae22ff822a1/uv-0.11.17-py3-none-manylinux_2_31_riscv64.musllinux_1_1_riscv64.whl", hash = "sha256:659227cac719b618cc91e02be9e274ad5bd72d74fa278123e6373537e9f28216", size = 24224725, upload-time = "2026-05-28T20:39:32.945Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ad/44/7b11550c1453ea13b81e549c83523e6ab6ed3231d09b2fd6b9eb19acceaf/uv-0.11.17-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:e301d844eed9401f0f0351de12c55f1306ca05372acb0f28d35717c8ba663a22", size = 24301643, upload-time = "2026-05-28T20:39:45.183Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/36/8f683bc60547b8f93d0e752a8574d13fad776999cb978482b360c053ca22/uv-0.11.17-py3-none-musllinux_1_1_i686.whl", hash = "sha256:f0bf483c0d9fa14283992d56061b498b9d3d4adebd285af8744dc33f64dadfba", size = 23786049, upload-time = "2026-05-28T20:39:20.999Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/dc/7a495db39c2970de4fa375c337dbd617b16780911f88f0511f8fe7f6747c/uv-0.11.17-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:2ccd5487a4a192bc832ea04c867a26883757db8fdfe88bed85d8129c82f9e505", size = 25049786, upload-time = "2026-05-28T20:40:03.292Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/dd/74eff72d749eaf7e19f489878e21a368a7fef58d26ea0c63ec044ecd78b1/uv-0.11.17-py3-none-win32.whl", hash = "sha256:12b701fa32c5be3691759a73956e4462f30fa7b0dfa52ec66cb305bbb6ea4129", size = 22479213, upload-time = "2026-05-28T20:39:13.316Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/99/8af4a92b99a8a4823297c26df727fe957267e03e1196e3caa803c3f6ccb2/uv-0.11.17-py3-none-win_amd64.whl", hash = "sha256:44ec1fe3af839f87370dcf0400c0cab917cc1ce697d563e860fc7d9ed72655e7", size = 25083161, upload-time = "2026-05-28T20:40:07.931Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/76/a689077832d585d29d87f9cd0d65eca1af58abd29a4eab004d0a8a858b9c/uv-0.11.17-py3-none-win_arm64.whl", hash = "sha256:37c915bfcf86f99c1c5be7c9ed21e0d80624067ba47bc8916a3cb0530bc94d27", size = 23544936, upload-time = "2026-05-28T20:39:37.137Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
Reference in New Issue
Block a user