mirror of
https://github.com/crewAIInc/crewAI.git
synced 2026-06-16 05:38:12 +00:00
Compare commits
56 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e9d568dc69 | ||
|
|
fe2c236601 | ||
|
|
53c2284484 | ||
|
|
a5cc6f6d0e | ||
|
|
bb477f8a91 | ||
|
|
d80719df81 | ||
|
|
6ad821b157 | ||
|
|
2444895ca4 | ||
|
|
bf291a7a55 | ||
|
|
64438cba37 | ||
|
|
887adafd2c | ||
|
|
d3fc0d31f8 | ||
|
|
373dca3d04 | ||
|
|
21fa8e32d9 | ||
|
|
f18c03cd8f | ||
|
|
50b9c02272 | ||
|
|
c55334be5f | ||
|
|
05a2ba9ca4 | ||
|
|
fbafe1f0d3 | ||
|
|
5267c059f5 | ||
|
|
243c9edc1c | ||
|
|
68910b70c0 | ||
|
|
299782765c | ||
|
|
a1f44eb272 | ||
|
|
036b032ab6 | ||
|
|
f88ae54f96 | ||
|
|
b6e5d632c1 | ||
|
|
0d971e5bc5 | ||
|
|
b3f175b56f | ||
|
|
f523a7d029 | ||
|
|
f214ff4b7b | ||
|
|
a9e7c3a44f | ||
|
|
da8fe8c715 | ||
|
|
ce42994ae3 | ||
|
|
820c3905e3 | ||
|
|
703ffe67ee | ||
|
|
8919026326 | ||
|
|
988927006c | ||
|
|
48c1987fcf | ||
|
|
af62b7b583 | ||
|
|
1b14e162e9 | ||
|
|
e570534f15 | ||
|
|
913a3abead | ||
|
|
17cfbdf95f | ||
|
|
8cd51fc67e | ||
|
|
3723f0db76 | ||
|
|
cab3319af9 | ||
|
|
906cd9769d | ||
|
|
14ce97d787 | ||
|
|
f3a15a4f07 | ||
|
|
75dad212a2 | ||
|
|
aed69237d4 | ||
|
|
051fa0c1cb | ||
|
|
73d20fb0c3 | ||
|
|
d09e3f4544 | ||
|
|
1357491f0d |
2
.github/workflows/vulnerability-scan.yml
vendored
2
.github/workflows/vulnerability-scan.yml
vendored
@@ -64,6 +64,7 @@ jobs:
|
||||
--ignore-vuln PYSEC-2025-197 \
|
||||
--ignore-vuln PYSEC-2025-210 \
|
||||
--ignore-vuln PYSEC-2026-139 \
|
||||
--ignore-vuln GHSA-rrmf-rvhw-rf47 \
|
||||
--ignore-vuln PYSEC-2025-211 \
|
||||
--ignore-vuln PYSEC-2025-212 \
|
||||
--ignore-vuln PYSEC-2025-213 \
|
||||
@@ -81,6 +82,7 @@ jobs:
|
||||
# PYSEC-2025-183 - pyjwt 2.12.1: disputed weak-encryption claim; key length is application-chosen
|
||||
# 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
|
||||
# GHSA-rrmf-rvhw-rf47 - torch 2.11.0 (CVE-2025-3000, alias of PYSEC-2025-194): memory corruption in torch.jit.script, CVSS 1.9, local-only; affected <=2.12.0, no fix available. pip-audit reports it under the GHSA id so the PYSEC ignore above does not catch it.
|
||||
# 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)
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -31,3 +31,5 @@ chromadb-*.lock
|
||||
blogs/*
|
||||
secrets/*
|
||||
UNKNOWN.egg-info/
|
||||
demos/*
|
||||
.crewai/*
|
||||
|
||||
@@ -47,6 +47,7 @@ repos:
|
||||
--ignore-vuln PYSEC-2025-197
|
||||
--ignore-vuln PYSEC-2025-210
|
||||
--ignore-vuln PYSEC-2026-139
|
||||
--ignore-vuln GHSA-rrmf-rvhw-rf47
|
||||
--ignore-vuln PYSEC-2025-211
|
||||
--ignore-vuln PYSEC-2025-212
|
||||
--ignore-vuln PYSEC-2025-213
|
||||
|
||||
94
conftest.py
94
conftest.py
@@ -11,7 +11,99 @@ from typing import Any
|
||||
|
||||
from dotenv import load_dotenv
|
||||
import pytest
|
||||
from vcr.request import Request # type: ignore[import-untyped]
|
||||
|
||||
|
||||
def _patch_vcrpy_aiohttp_compat() -> None:
|
||||
"""Keep vcrpy's aiohttp stub working under aiohttp 3.14.0.
|
||||
|
||||
aiohttp 3.14.0 (pulled in to fix GHSA-jg22-mg44-37j8 and GHSA-hg6j-4rv6-33pg):
|
||||
* removed ``aiohttp.streams.AsyncStreamReaderMixin`` (folded into ``StreamReader``),
|
||||
which vcrpy's ``MockStream`` still subclasses -- vcr's patch machinery then raises
|
||||
``AttributeError`` at collection time; and
|
||||
* added a required ``stream_writer`` keyword-only arg to ``ClientResponse.__init__``,
|
||||
which vcrpy's ``MockClientResponse`` does not pass -- raising ``TypeError`` at
|
||||
cassette playback.
|
||||
|
||||
Restore the mixin, then rebuild ``MockClientResponse``'s ``super().__init__`` call from
|
||||
the live ``ClientResponse`` signature (defaulting every required keyword-only arg to
|
||||
``None``, mirroring vcrpy's original call) so it also survives future aiohttp additions.
|
||||
"""
|
||||
import asyncio
|
||||
import inspect
|
||||
|
||||
from aiohttp import streams
|
||||
from aiohttp.client_reqrep import ClientResponse
|
||||
|
||||
if not hasattr(streams, "AsyncStreamReaderMixin"):
|
||||
|
||||
class AsyncStreamReaderMixin:
|
||||
__slots__ = ()
|
||||
|
||||
def __aiter__(self) -> streams.AsyncStreamIterator[bytes]:
|
||||
return streams.AsyncStreamIterator(self.readline) # type: ignore[attr-defined]
|
||||
|
||||
def iter_chunked(self, n: int) -> streams.AsyncStreamIterator[bytes]:
|
||||
return streams.AsyncStreamIterator(lambda: self.read(n)) # type: ignore[attr-defined]
|
||||
|
||||
def iter_any(self) -> streams.AsyncStreamIterator[bytes]:
|
||||
return streams.AsyncStreamIterator(self.readany) # type: ignore[attr-defined]
|
||||
|
||||
def iter_chunks(self) -> streams.ChunkTupleAsyncStreamIterator:
|
||||
return streams.ChunkTupleAsyncStreamIterator(self) # type: ignore[arg-type]
|
||||
|
||||
streams.AsyncStreamReaderMixin = AsyncStreamReaderMixin # type: ignore[attr-defined]
|
||||
|
||||
# Importing the stub builds MockStream/MockClientResponse, so it must run after the
|
||||
# mixin is restored above.
|
||||
import vcr.stubs.aiohttp_stubs as aiohttp_stubs # type: ignore[import-untyped]
|
||||
|
||||
if getattr(aiohttp_stubs.MockClientResponse, "_crewai_aiohttp_patched", False):
|
||||
return
|
||||
|
||||
keyword_only = [
|
||||
name
|
||||
for name, param in inspect.signature(ClientResponse.__init__).parameters.items()
|
||||
if param.kind is inspect.Parameter.KEYWORD_ONLY
|
||||
]
|
||||
|
||||
class _NullStreamWriter:
|
||||
# aiohttp 3.14.0 reads stream_writer.output_size in the "request already
|
||||
# sent" branch (writer is None), so None is not enough -- supply a stub.
|
||||
output_size = 0
|
||||
|
||||
fallback_loop: list[asyncio.AbstractEventLoop] = []
|
||||
|
||||
def _resolve_loop() -> asyncio.AbstractEventLoop:
|
||||
# MockClientResponse is normally built inside aiohttp's running loop, so
|
||||
# prefer that. In a sync context there is no running loop; avoid
|
||||
# asyncio.get_event_loop(), which on 3.12+ emits a DeprecationWarning
|
||||
# (and can RuntimeError) when no current loop is set. Use one cached
|
||||
# loop instead -- the mock only stores it and calls loop.get_debug().
|
||||
try:
|
||||
return asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
if not fallback_loop:
|
||||
fallback_loop.append(asyncio.new_event_loop())
|
||||
return fallback_loop[0]
|
||||
|
||||
def _mock_client_response_init(
|
||||
self: Any, method: str, url: Any, request_info: Any = None
|
||||
) -> None:
|
||||
kwargs: dict[str, Any] = dict.fromkeys(keyword_only)
|
||||
kwargs["request_info"] = request_info
|
||||
if "loop" in kwargs:
|
||||
kwargs["loop"] = _resolve_loop()
|
||||
if "stream_writer" in kwargs:
|
||||
kwargs["stream_writer"] = _NullStreamWriter()
|
||||
ClientResponse.__init__(self, method, url, **kwargs)
|
||||
|
||||
aiohttp_stubs.MockClientResponse.__init__ = _mock_client_response_init
|
||||
aiohttp_stubs.MockClientResponse._crewai_aiohttp_patched = True
|
||||
|
||||
|
||||
_patch_vcrpy_aiohttp_compat()
|
||||
|
||||
from vcr.request import Request # type: ignore[import-untyped] # noqa: E402
|
||||
|
||||
|
||||
try:
|
||||
|
||||
@@ -4,6 +4,178 @@ description: "تحديثات المنتج والتحسينات وإصلاحات
|
||||
icon: "clock"
|
||||
mode: "wide"
|
||||
---
|
||||
<Update label="11 يونيو 2026">
|
||||
## v1.14.7
|
||||
|
||||
[عرض الإصدار على GitHub](https://github.com/crewAIInc/crewAI/releases/tag/1.14.7)
|
||||
|
||||
## ما الذي تغير
|
||||
|
||||
### الميزات
|
||||
- إضافة واجهات خلفية افتراضية قابلة للتوصيل للذاكرة، والمعرفة، وrag، وflow.
|
||||
- عرض السبب الحقيقي للإنهاء، ومعلمات العينة، وresponse.id في أحداث LLM.
|
||||
- تصنيف مشغلات DSL كزخارف واعية للمسار.
|
||||
- إضافة واجهة برمجة تطبيقات الدردشة لتدفقات المحادثة.
|
||||
- جعل واجهة القفل قابلة للتجاوز.
|
||||
- بناء FlowDefinition من بيانات التعريف الخاصة بـ Flow DSL.
|
||||
- إضافة مزود LLM من Snowflake Cortex الأصلي.
|
||||
- إضافة دعم لملفات الوكلاء المدربين من crew.
|
||||
|
||||
### إصلاحات الأخطاء
|
||||
- إصلاح نقطة التحقق لإعادة بناء BaseLLM مخصص كـ LLM ملموس عند الاستعادة.
|
||||
- تقييد الاستعادة على علامة لمنع اللقطات الحية من إعادة التشغيل كاستئناف.
|
||||
- تحديد حالة وقت التشغيل لكل تشغيل للحد من النمو وعزل التشغيل المتزامن.
|
||||
- إصلاح إعدادات التتبع على crewai-login.
|
||||
- احترام suppress_flow_events لأحداث تنفيذ الطريقة.
|
||||
- استعادة [project.scripts] في حزمة crewai لتثبيت أداة uv.
|
||||
- حل مشكلات CVE الخاصة بـ pip-audit لـ aiohttp وdocling وdocling-core.
|
||||
- إصلاح إدخال الملفات الذي لا يعمل بشكل موثوق.
|
||||
- إصلاح تاريخ نتائج أدوات Snowflake Claude غير المكتملة.
|
||||
|
||||
### الوثائق
|
||||
- تحديث سجل التغييرات والإصدار لـ v1.14.7.
|
||||
- تحديث وثائق جامع OpenTelemetry.
|
||||
- تحديث دليل NVIDIA Nemotron LLM.
|
||||
- إضافة دليل تكامل Databricks.
|
||||
- إضافة دليل تكامل Snowflake.
|
||||
|
||||
### الأداء
|
||||
- تحسين سرعة استيراد crewai من خلال تحميل مستندات docling بشكل كسول.
|
||||
|
||||
### إعادة الهيكلة
|
||||
- تبسيط تقييم شروط التدفق ليكون بلا حالة لكل حدث.
|
||||
- فصل منطق المحادثة عن وقت التشغيل وإضافة تعريف المحادثة.
|
||||
- تقسيم `flow.py` إلى DSL، وتعريف، ووقت تشغيل.
|
||||
|
||||
## المساهمون
|
||||
|
||||
@Luzk, @alex-clawd, @devin-ai-integration[bot], @greysonlalonde, @gvieira, @jessemiller, @lorenzejay, @lucasgomide, @mattatcha, @vinibrsl
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="10 يونيو 2026">
|
||||
## v1.14.7rc2
|
||||
|
||||
[عرض الإصدار على GitHub](https://github.com/crewAIInc/crewAI/releases/tag/1.14.7rc2)
|
||||
|
||||
## ما الذي تغير
|
||||
|
||||
### إصلاحات الأخطاء
|
||||
- استعادة البوابة على علامة لمنع اللقطات الحية من إعادة التشغيل كاستئناف
|
||||
|
||||
### الوثائق
|
||||
- تحديث سجل التغييرات والإصدار لـ v1.14.7rc1
|
||||
|
||||
## المساهمون
|
||||
|
||||
@greysonlalonde
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="10 يونيو 2026">
|
||||
## v1.14.7rc1
|
||||
|
||||
[عرض الإصدار على GitHub](https://github.com/crewAIInc/crewAI/releases/tag/1.14.7rc1)
|
||||
|
||||
## ما الذي تغير
|
||||
|
||||
### الميزات
|
||||
- إضافة `reset_runtime_state` لإطلاق حالة الحافلة المتراكمة
|
||||
- التعامل مع دعم كل من الموجهات المخصصة
|
||||
- فصل منطق المحادثة عن وقت التشغيل وإضافة `conversational_definition`
|
||||
|
||||
### إصلاحات الأخطاء
|
||||
- إصلاح نطاق حالة وقت التشغيل لكل تشغيل للحد من النمو وعزل التشغيلات المتزامنة
|
||||
- إصلاح إعدادات القياس عن بُعد على `crewai-login`
|
||||
- إصلاح احترام `suppress_flow_events` لفعاليات تنفيذ الأساليب
|
||||
|
||||
### الوثائق
|
||||
- تحديث صور OpenTelemetry
|
||||
- تحديث الوثائق لتعكس الحالة الجديدة لجمع بيانات OpenTelemetry
|
||||
- تحديث سجل التغييرات والإصدار لـ v1.14.7a4
|
||||
|
||||
### إعادة الهيكلة
|
||||
- تبسيط تقييم شرط التدفق ليكون بلا حالة لكل حدث
|
||||
- تحسين دورة توجيه المحادثة مع تقليل مسار واحد
|
||||
|
||||
## المساهمون
|
||||
|
||||
@greysonlalonde, @lorenzejay, @lucasgomide, @vinibrsl
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="9 يونيو 2026">
|
||||
## v1.14.7a4
|
||||
|
||||
[عرض الإصدار على GitHub](https://github.com/crewAIInc/crewAI/releases/tag/1.14.7a4)
|
||||
|
||||
## ما الذي تغير
|
||||
|
||||
### الميزات
|
||||
- نقل وقت التشغيل @listen/@router لقراءة من FlowDefinition
|
||||
- إضافة واجهات خلفية افتراضية قابلة للتوصيل للذاكرة، والمعرفة، وrag، وflow
|
||||
|
||||
### الوثائق
|
||||
- تحديث سجل التغييرات والإصدار لـ v1.14.7a3
|
||||
|
||||
## المساهمون
|
||||
|
||||
@greysonlalonde, @mattatcha, @vinibrsl
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="8 يونيو 2026">
|
||||
## v1.14.7a3
|
||||
|
||||
[عرض الإصدار على GitHub](https://github.com/crewAIInc/crewAI/releases/tag/1.14.7a3)
|
||||
|
||||
## ما الذي تغير
|
||||
|
||||
### إصلاحات الأخطاء
|
||||
- إصلاح تعرض `ask_for_human_input` في `AgentExecutor` التجريبي
|
||||
- حل مشكلات CVEs الخاصة بـ pip-audit لـ `aiohttp`، `docling`، `docling-core`، و `pip`
|
||||
|
||||
### إعادة هيكلة
|
||||
- نقل `@start` لقراءة من `FlowDefinition`
|
||||
|
||||
### الوثائق
|
||||
- تحديث سجل التغييرات والإصدار لـ v1.14.7a2
|
||||
|
||||
## المساهمون
|
||||
|
||||
@greysonlalonde، @lorenzejay، @vinibrsl
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="5 يونيو 2026">
|
||||
## v1.14.7a2
|
||||
|
||||
[عرض الإصدار على GitHub](https://github.com/crewAIInc/crewAI/releases/tag/1.14.7a2)
|
||||
|
||||
## ما الذي تغير
|
||||
|
||||
### الميزات
|
||||
- إضافة دعم تتبع تدفقات المحادثة.
|
||||
- تحديث وثائق تدفق المحادثة لاستخدام `handle_turn`.
|
||||
- عرض السبب الحقيقي لإنهاء المحادثة، ومعلمات العينة، و`response.id` في أحداث LLM.
|
||||
- تصنيف مشغلات DSL كزخارف واعية بالمسار.
|
||||
- تنفيذ واجهة برمجة التطبيقات للدردشة لتدفقات المحادثة.
|
||||
- جعل قفل الخلفية قابلاً للتجاوز في متجر القفل.
|
||||
- تقسيم أحادي تدفق DSL إلى وحدات زخرفية مركزة.
|
||||
- تسطيح استخدام ذاكرة التخزين المؤقت LiteLLM/أعداد الأسباب الفرعية في `_usage_to_dict`.
|
||||
- بناء `FlowDefinition` من بيانات التعريف الخاصة بتدفق DSL.
|
||||
|
||||
### الوثائق
|
||||
- إضافة دليل NVIDIA Nemotron LLM.
|
||||
- توثيق عمليات نشر المونوريبو.
|
||||
- تحديث سجل التغييرات والإصدار لـ v1.14.7a1.
|
||||
|
||||
## المساهمون
|
||||
|
||||
@alex-clawd, @gvieira, @lorenzejay, @lucasgomide, @mattatcha, @vinibrsl
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="3 يونيو 2026">
|
||||
## v1.14.7a1
|
||||
|
||||
|
||||
@@ -226,6 +226,48 @@ counter=2 message='Hello from first_method - updated by second_method'
|
||||
من خلال ضمان إعادة مخرجات الدالة الأخيرة وتوفير الوصول إلى الحالة، تجعل تدفقات CrewAI من السهل دمج نتائج سير عمل الذكاء الاصطناعي في التطبيقات أو الأنظمة الأكبر،
|
||||
مع الحفاظ على الوصول إلى الحالة طوال تنفيذ التدفق.
|
||||
|
||||
## مقاييس استخدام التدفق
|
||||
|
||||
بعد اكتمال تنفيذ التدفق، يمكنك الوصول إلى الخاصية `usage_metrics` لعرض إجمالي استخدام التوكنات عبر **كل استدعاء لنموذج اللغة** يتم خلال التشغيل — بما في ذلك الاستدعاءات من كل فريق (Crew) ينظمه التدفق، والاستدعاءات داخل أدوات الـ Agents، والاستدعاءات المباشرة لـ `LLM.call(...)` من دوال التدفق. هذا هو المكافئ على جانب الـ SDK للإجماليات المعروضة في واجهة CrewAI Enterprise.
|
||||
|
||||
```python Code
|
||||
from crewai import LLM
|
||||
from crewai.flow.flow import Flow, listen, start
|
||||
|
||||
class UsageMetricsFlow(Flow):
|
||||
@start()
|
||||
def run_first_crew(self):
|
||||
self.state.first_result = FirstCrew().crew().kickoff()
|
||||
|
||||
@listen(run_first_crew)
|
||||
def call_llm_directly(self):
|
||||
# استدعاء مباشر لنموذج اللغة — يُحسب أيضًا ضمن flow.usage_metrics
|
||||
llm = LLM(model="openai/gpt-4o-mini")
|
||||
self.state.summary = llm.call("لخّص النقاط الرئيسية.")
|
||||
|
||||
@listen(call_llm_directly)
|
||||
def run_second_crew(self):
|
||||
self.state.second_result = SecondCrew().crew().kickoff()
|
||||
|
||||
flow = UsageMetricsFlow()
|
||||
flow.kickoff()
|
||||
|
||||
print(flow.usage_metrics)
|
||||
# UsageMetrics(total_tokens=8579, prompt_tokens=6210, completion_tokens=2369,
|
||||
# cached_prompt_tokens=0, reasoning_tokens=0,
|
||||
# cache_creation_tokens=0, successful_requests=5)
|
||||
```
|
||||
|
||||
<Note>
|
||||
`flow.usage_metrics` **ليست** نفس `flow.kickoff().token_usage`. هذه الأخيرة
|
||||
ترجع فقط `CrewOutput.token_usage` لـ **آخر** دالة `@listen` أعادت
|
||||
`CrewOutput`، مما يعني أنها تعكس فقط الفريق الأخير وتتجاهل الفرق السابقة
|
||||
وكذلك أي استدعاءات مباشرة لـ `LLM.call(...)`. استخدم `flow.usage_metrics`
|
||||
كلما احتجت إلى الإجمالي **الكامل** للتوكنات لتنفيذ التدفق.
|
||||
</Note>
|
||||
|
||||
كل حقل في [`UsageMetrics`](https://github.com/crewAIInc/crewAI/blob/main/lib/crewai/src/crewai/types/usage_metrics.py) المُعاد هو مجموع جميع استدعاءات نموذج اللغة التي حدثت خلال استدعاء واحد لـ `flow.kickoff()`. تتم إعادة تعيين العدادات عند الاستدعاء التالي لـ `kickoff()` (وفي كل تكرار من `kickoff_for_each`)، لذلك لن تتكرر العدّات عبر التشغيلات المتتالية. يمكن قراءة هذه الخاصية بأمان في أي وقت بعد اكتمال `kickoff()`؛ قراءتها أثناء التنفيذ تُرجع المجموع الجزئي المتراكم حتى تلك اللحظة.
|
||||
|
||||
## إدارة حالة التدفق
|
||||
|
||||
إدارة الحالة بفعالية أمر بالغ الأهمية لبناء سير عمل ذكاء اصطناعي موثوق وقابل للصيانة. توفر تدفقات CrewAI آليات قوية لإدارة الحالة غير المهيكلة والمهيكلة،
|
||||
|
||||
@@ -24,15 +24,39 @@ mode: "wide"
|
||||
|
||||
1. في CrewAI AMP، انتقل إلى **Settings** > **OpenTelemetry Collectors**.
|
||||
2. انقر على **Add Collector**.
|
||||
3. اختر نوع التكامل — **OpenTelemetry Traces** أو **OpenTelemetry Logs**.
|
||||
4. هيّئ الاتصال:
|
||||
- **Endpoint** — نقطة نهاية OTLP لمجمّعك (مثل `https://otel-collector.example.com:4317`).
|
||||
- **Service Name** — اسم لتعريف هذه الخدمة في منصة المراقبة.
|
||||
- **Custom Headers** *(اختياري)* — أضف رؤوس المصادقة أو التوجيه كأزواج مفتاح-قيمة.
|
||||
- **Certificate** *(اختياري)* — قدم شهادة TLS إذا كان مجمّعك يتطلبها.
|
||||
5. انقر على **Save**.
|
||||
3. اختر تكاملاً:
|
||||
- **OpenTelemetry Traces** و**OpenTelemetry Logs** — صدّر إلى أي مجمّع أو واجهة خلفية متوافقة مع OTLP.
|
||||
- **Datadog** — أرسل التتبعات مباشرة إلى استقبال OTLP الخاص بـ Datadog، دون الحاجة إلى مجمّع منفصل أو Datadog Agent.
|
||||
4. هيّئ الاتصال. تعتمد الحقول على التكامل الذي اخترته:
|
||||
|
||||
<Frame></Frame>
|
||||
<Tabs>
|
||||
<Tab title="OpenTelemetry Traces / Logs">
|
||||
إن **OpenTelemetry Traces** و**OpenTelemetry Logs** تكاملان منفصلان يتشاركان نفس الحقول — اختر التكامل المطابق للإشارة التي تريد تصديرها.
|
||||
|
||||
- **Endpoint** — نقطة نهاية OTLP لمجمّعك (مثل `https://otel-collector.example.com:4317`).
|
||||
- **Service Name** — اسم لتعريف هذه الخدمة في منصة المراقبة.
|
||||
- **Custom Headers** *(اختياري)* — أضف رؤوس المصادقة أو التوجيه كأزواج مفتاح-قيمة.
|
||||
- **Certificate** *(اختياري)* — قدم شهادة TLS إذا كان مجمّعك يتطلبها.
|
||||
|
||||
<Frame></Frame>
|
||||
</Tab>
|
||||
<Tab title="Datadog">
|
||||
- **Datadog Site Domain** — مضيف OTLP لموقع Datadog الخاص بك فقط، دون بروتوكول أو مسار. يقوم CrewAI ببناء نقطة نهاية HTTPS OTLP الكاملة نيابةً عنك. استخدم المضيف المطابق لـ [موقع Datadog](https://docs.datadoghq.com/getting_started/site/) الخاص بك:
|
||||
- `otlp.datadoghq.com` (US1)
|
||||
- `otlp.us3.datadoghq.com` (US3)
|
||||
- `otlp.us5.datadoghq.com` (US5)
|
||||
- `otlp.datadoghq.eu` (EU1)
|
||||
- `otlp.ap1.datadoghq.com` (AP1)
|
||||
- **API Key** — مفتاح واجهة برمجة تطبيقات Datadog الخاص بك. راجع [كيفية إنشاء واحد](https://docs.datadoghq.com/account_management/api-app-keys/#api-keys).
|
||||
|
||||
يصدّر تكامل Datadog **التتبعات**.
|
||||
|
||||
<Frame></Frame>
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
5. *(اختياري)* انقر على **Test Connection** للتحقق من قدرة CrewAI على الوصول إلى نقطة النهاية باستخدام بيانات الاعتماد التي قدمتها.
|
||||
6. انقر على **Save**.
|
||||
|
||||
<Tip>
|
||||
يمكنك إضافة مجمّعات متعددة — على سبيل المثال، واحد للتتبعات وآخر للسجلات، أو الإرسال إلى واجهات خلفية مختلفة لأغراض مختلفة.
|
||||
|
||||
@@ -164,6 +164,12 @@ crewai deploy remove <deployment_id>
|
||||

|
||||
</Frame>
|
||||
|
||||
<Tip>
|
||||
إذا كان Crew أو Flow داخل مجلد فرعي في monorepo، فوسّع **Advanced**
|
||||
وعيّن دليل عمل قبل النشر. راجع
|
||||
[النشر من Monorepo](/ar/enterprise/guides/monorepo-deployments).
|
||||
</Tip>
|
||||
|
||||
</Step>
|
||||
|
||||
<Step title="تعيين متغيرات البيئة">
|
||||
|
||||
220
docs/ar/enterprise/guides/monorepo-deployments.mdx
Normal file
220
docs/ar/enterprise/guides/monorepo-deployments.mdx
Normal file
@@ -0,0 +1,220 @@
|
||||
---
|
||||
title: "النشر من Monorepo"
|
||||
description: "انشر Crew أو Flow من مجلد فرعي داخل مستودع أكبر"
|
||||
icon: "folder-tree"
|
||||
mode: "wide"
|
||||
---
|
||||
|
||||
<Note>
|
||||
استخدم دليل عمل عندما يكون Crew أو Flow داخل مستودع أكبر. يتحقق CrewAI AMP
|
||||
من الأتمتة ويبنيها ويشغلها من ذلك المجلد الفرعي بدلاً من جذر المستودع.
|
||||
</Note>
|
||||
|
||||
## متى تستخدم ذلك
|
||||
|
||||
يكون النشر من monorepo مفيداً عندما يحتوي مستودع واحد على عدة أتمتات أو حزم
|
||||
مشتركة أو كود تطبيقات آخر:
|
||||
|
||||
```text
|
||||
company-ai/
|
||||
|-- uv.lock
|
||||
|-- packages/
|
||||
| `-- shared_tools/
|
||||
`-- crews/
|
||||
|-- support_agent/
|
||||
| |-- pyproject.toml
|
||||
| `-- src/
|
||||
| `-- support_agent/
|
||||
| |-- main.py
|
||||
| `-- crew.py
|
||||
`-- research_flow/
|
||||
|-- pyproject.toml
|
||||
`-- src/
|
||||
`-- research_flow/
|
||||
`-- main.py
|
||||
```
|
||||
|
||||
لنشر `support_agent`، اضبط دليل العمل على:
|
||||
|
||||
```text
|
||||
crews/support_agent
|
||||
```
|
||||
|
||||
لا يزال AMP يجلب المستودع كاملاً أو يرفعه، لكنه يتعامل مع المجلد المحدد كجذر
|
||||
مشروع الأتمتة.
|
||||
|
||||
## ما الذي يتحكم به دليل العمل
|
||||
|
||||
عند تعيين دليل عمل، يستخدم AMP ذلك المجلد من أجل:
|
||||
|
||||
- التحقق من المشروع، بما في ذلك `pyproject.toml` و`src/` ونقطة دخول Crew أو Flow
|
||||
- تثبيت الاعتماديات باستخدام `uv`
|
||||
- دليل العمل للعملية قيد التشغيل
|
||||
- متغير البيئة `CREW_ROOT_DIR`
|
||||
|
||||
ترك الحقل فارغاً يحافظ على السلوك الحالي ويستخدم جذر المستودع.
|
||||
|
||||
## المصادر المدعومة
|
||||
|
||||
يمكنك تعيين دليل عمل عند إنشاء نشر من:
|
||||
|
||||
- مستودع GitHub متصل
|
||||
- مستودع Git مكوّن في AMP
|
||||
- رفع ملف ZIP
|
||||
|
||||
<Info>
|
||||
اضبط أدلة العمل من واجهة AMP على الويب. لا يطلب تدفق CLI
|
||||
`crewai deploy create` هذا الحقل.
|
||||
</Info>
|
||||
|
||||
يمكنك أيضاً إضافة دليل العمل أو تغييره في نشر موجود من صفحة **Settings** الخاصة
|
||||
بالنشر. يسري التغيير في النشر التالي.
|
||||
|
||||
<Warning>
|
||||
لا يمكن استخدام أدلة العمل وauto-deploy معاً. إذا كان للنشر دليل عمل، يتم
|
||||
تعطيل auto-deploy لذلك النشر. أوقف auto-deploy قبل تعيين دليل عمل.
|
||||
</Warning>
|
||||
|
||||
## إعداد نشر جديد
|
||||
|
||||
<Steps>
|
||||
<Step title="افتح Deploy from Code">
|
||||
في CrewAI AMP، أنشئ نشراً جديداً واختر المصدر: GitHub أو Git Repository أو
|
||||
رفع ZIP.
|
||||
</Step>
|
||||
|
||||
<Step title="اختر المستودع أو الفرع أو ملف ZIP">
|
||||
اختر المستودع والفرع اللذين يحتويان على monorepo، أو ارفع ملف ZIP يحتوي
|
||||
جذره على محتويات monorepo.
|
||||
</Step>
|
||||
|
||||
<Step title="افتح الإعدادات المتقدمة">
|
||||
وسّع قسم **Advanced** في نموذج النشر.
|
||||
</Step>
|
||||
|
||||
<Step title="أدخل دليل العمل">
|
||||
أدخل المسار من جذر المستودع إلى مشروع Crew أو Flow:
|
||||
|
||||
```text
|
||||
crews/support_agent
|
||||
```
|
||||
|
||||
لا تضف شرطة مائلة في البداية.
|
||||
</Step>
|
||||
|
||||
<Step title="انشر">
|
||||
أضف أي متغيرات بيئة مطلوبة، ثم ابدأ النشر.
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
## إعداد نشر موجود
|
||||
|
||||
<Steps>
|
||||
<Step title="افتح إعدادات النشر">
|
||||
انتقل إلى الأتمتة في AMP وافتح **Settings**.
|
||||
</Step>
|
||||
|
||||
<Step title="أوقف auto-deploy إذا لزم الأمر">
|
||||
إذا كان auto-deploy مفعلاً، أوقفه أولاً. لا يكون حقل دليل العمل متاحاً
|
||||
أثناء تشغيل auto-deploy.
|
||||
</Step>
|
||||
|
||||
<Step title="عيّن دليل العمل">
|
||||
في **Basic settings**، أدخل مسار المجلد الفرعي، مثل:
|
||||
|
||||
```text
|
||||
crews/support_agent
|
||||
```
|
||||
</Step>
|
||||
|
||||
<Step title="أعد النشر">
|
||||
احفظ الإعداد وأعد نشر الأتمتة. سيتم استخدام دليل العمل الجديد في النشر
|
||||
التالي.
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
## قواعد المسار
|
||||
|
||||
يجب أن يكون دليل العمل مساراً نسبياً داخل جذر المستودع أو ZIP.
|
||||
|
||||
| القاعدة | المثال |
|
||||
|---------|--------|
|
||||
| استخدم مساراً نسبياً | `crews/support_agent` |
|
||||
| لا تبدأ بـ `/` | `/crews/support_agent` غير صالح |
|
||||
| لا تستخدم مقاطع المسار `.` أو `..` | `crews/../support_agent` غير صالح |
|
||||
| استخدم الأحرف والأرقام والشرطات والشرطات السفلية والنقاط والشرطات المائلة فقط | `crews/support agent` غير صالح |
|
||||
| اجعل المسار 255 حرفاً أو أقل | يتم رفض المسارات الأطول |
|
||||
|
||||
يزيل AMP المسافات البيضاء في البداية والنهاية، ويضغط الشرطات المائلة المتكررة،
|
||||
ويزيل الشرطة المائلة النهائية. تستخدم القيمة الفارغة جذر المستودع.
|
||||
|
||||
## ملفات القفل وUV Workspaces
|
||||
|
||||
يجب أن يحتوي المجلد المحدد على `pyproject.toml` ودليل `src/` الخاصين بالأتمتة.
|
||||
يمكن أن يوجد ملف `uv.lock` أو `poetry.lock` إما في المجلد المحدد أو في جذر
|
||||
المستودع.
|
||||
|
||||
يدعم هذا التخطيطين الشائعين في monorepo:
|
||||
|
||||
<Tabs>
|
||||
<Tab title="ملف قفل المشروع">
|
||||
```text
|
||||
company-ai/
|
||||
`-- crews/
|
||||
`-- support_agent/
|
||||
|-- pyproject.toml
|
||||
|-- uv.lock
|
||||
`-- src/
|
||||
`-- support_agent/
|
||||
`-- main.py
|
||||
```
|
||||
</Tab>
|
||||
|
||||
<Tab title="ملف قفل workspace">
|
||||
```text
|
||||
company-ai/
|
||||
|-- uv.lock
|
||||
|-- packages/
|
||||
| `-- shared_tools/
|
||||
`-- crews/
|
||||
`-- support_agent/
|
||||
|-- pyproject.toml
|
||||
`-- src/
|
||||
`-- support_agent/
|
||||
`-- main.py
|
||||
```
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
<Tip>
|
||||
إذا كانت الأتمتة تستورد حزماً مشتركة من مكان آخر في monorepo، فصرّح بهذه
|
||||
الحزم في `pyproject.toml` باستخدام إعدادات UV workspace أو path أو source.
|
||||
يشغل AMP الأتمتة من المجلد المحدد، لذلك يجب تثبيت الكود المشترك كاعتمادية
|
||||
بدلاً من الاعتماد على وجود جذر المستودع في Python path.
|
||||
</Tip>
|
||||
|
||||
## استكشاف الأخطاء وإصلاحها
|
||||
|
||||
### لم يتم العثور على دليل العمل
|
||||
|
||||
تحقق من أن المسار نسبي إلى جذر المستودع أو ZIP. بالنسبة لرفع ZIP، يجب أن
|
||||
تتضمن محتويات ZIP مسار دليل العمل تماماً كما أدخلته.
|
||||
|
||||
### pyproject.toml مفقود
|
||||
|
||||
يجب أن يشير دليل العمل إلى مجلد مشروع Crew أو Flow، وليس فقط إلى مجلد أب
|
||||
يحتوي على عدة مشاريع.
|
||||
|
||||
### uv.lock أو poetry.lock مفقود
|
||||
|
||||
اعمل commit لملف قفل إما في مجلد المشروع المحدد أو في جذر المستودع. بالنسبة
|
||||
إلى UV workspaces، يتم دعم إبقاء `uv.lock` في جذر workspace.
|
||||
|
||||
### Auto-Deploy غير متاح
|
||||
|
||||
يتم تعطيل auto-deploy أثناء تعيين دليل عمل. استخدم إعادة النشر اليدوية أو شغّل
|
||||
إعادة النشر من CI/CD باستخدام AMP API.
|
||||
|
||||
<Card title="النشر على AMP" icon="rocket" href="/ar/enterprise/guides/deploy-to-amp">
|
||||
تابع دليل النشر بعد اختيار دليل عمل monorepo.
|
||||
</Card>
|
||||
@@ -161,6 +161,18 @@ crew = Crew(
|
||||
)
|
||||
```
|
||||
|
||||
<Note>
|
||||
يُحتفظ بـ `agent.i18n` للتوافق مع الإصدارات السابقة فقط، وقد تم إهماله. لتخصيص المطالبات أثناء التشغيل، مرّر `prompt_file` إلى `Crew`. وللوصول البرمجي المباشر إلى شرائح المطالبات، استخدم أداة i18n مباشرة:
|
||||
</Note>
|
||||
|
||||
```python
|
||||
from crewai.utilities.i18n import get_i18n
|
||||
|
||||
i18n = get_i18n("custom_prompts.json")
|
||||
format_slice = i18n.slice("format")
|
||||
tool_prompt = i18n.tools("ask_question")
|
||||
```
|
||||
|
||||
#### الخيار 3: تعطيل مطالبات النظام لنماذج o1
|
||||
```python
|
||||
agent = Agent(
|
||||
@@ -208,6 +220,8 @@ agent = Agent(
|
||||
|
||||
يدمج CrewAI بعد ذلك تخصيصاتك مع الإعدادات الافتراضية، فلا تحتاج لإعادة تعريف كل مطالبة. إليك الطريقة:
|
||||
|
||||
بالنسبة للكود الذي يحتاج إلى قراءة شرائح المطالبات مباشرة، استخدم `crewai.utilities.i18n.get_i18n()` مع ملف المطالبات نفسه بدلًا من قراءة `agent.i18n`.
|
||||
|
||||
### مثال: تخصيص أساسي للمطالبات
|
||||
|
||||
أنشئ ملف `custom_prompts.json` بالمطالبات التي تريد تعديلها. تأكد من إدراج جميع المطالبات عالية المستوى التي يجب أن يحتويها، وليس فقط تغييراتك:
|
||||
|
||||
473
docs/ar/guides/flows/conversational-flows.mdx
Normal file
473
docs/ar/guides/flows/conversational-flows.mdx
Normal file
@@ -0,0 +1,473 @@
|
||||
---
|
||||
title: تدفقات المحادثة
|
||||
description: أنشئ تطبيقات دردشة متعددة الجولات مع kickoff لكل جولة وسجل الرسائل وتوجيه النية والتتبع وجسور WebSocket.
|
||||
icon: comments
|
||||
mode: "wide"
|
||||
---
|
||||
|
||||
## نظرة عامة
|
||||
|
||||
تعامل التطبيقات المحادثية مع كل سطر من المستخدم كـ **تشغيل flow جديد** بنفس **معرّف الجلسة**. توفر CrewAI مساعدات لسجل الرسائل وتصنيف النية الاختياري وتأجيل التتبع وجسور الواجهة، إضافة إلى REPL محلي `flow.chat()` للتدفقات المحادثية.
|
||||
|
||||
| المفهوم | التنفيذ |
|
||||
|---------|---------|
|
||||
| معرّف الجلسة | `handle_turn(..., session_id=...)` → `kickoff(inputs={"id": ...})` → `state.id` |
|
||||
| سطر المستخدم | `handle_turn(message)` يضيف الرسالة إلى `state.messages` قبل تشغيل الرسم |
|
||||
| اكتمال الجولة | `FlowFinished` لهذا **التشغيل** فقط؛ تستمر المحادثة في `handle_turn` التالي |
|
||||
| تتبع الجلسة | `ConversationConfig(defer_trace_finalization=True)` + `finalize_session_traces()` |
|
||||
|
||||
## واجهات الجولات
|
||||
|
||||
استخدم **`flow.handle_turn(message, session_id=...)`** لكل رسالة مستخدم من REST أو WebSocket أو الاختبارات أو الواجهات المخصصة. استخدم **`flow.chat()`** عندما تريد حلقة دردشة محلية في الطرفية لـ `Flow` محادثي.
|
||||
|
||||
لا يقبل `Flow.kickoff()` الوسيطين `user_message=` أو `session_id=`. في التدفقات المحادثية، يخزن `handle_turn()` الرسالة المعلقة ويستدعي داخلياً `kickoff(inputs={"id": session_id})`.
|
||||
|
||||
| API | الاستخدام |
|
||||
|-----|-----------|
|
||||
| `handle_turn(message, session_id=...)` | غلاف مريح لجولة واحدة في `Flow` محادثي |
|
||||
| `chat()` | REPL محلي في الطرفية لـ `Flow` محادثي |
|
||||
| `kickoff(inputs={...})` | تشغيل متقدم للـ flow بدون معالجة جولة محادثية |
|
||||
| `ask()` | مطالبة حاجزة **داخل** خطوة واحدة |
|
||||
| `@human_feedback` | الموافقة/الرفض على **مخرجات خطوة** — وليس السطر التالي |
|
||||
| `ChatSession.handle_turn(...)` | طبقة نقل فوق `handle_turn` |
|
||||
|
||||
## بداية سريعة
|
||||
|
||||
```python
|
||||
from uuid import uuid4
|
||||
|
||||
from crewai import Flow
|
||||
from crewai.flow import listen
|
||||
from crewai.experimental.conversational import (
|
||||
ConversationConfig,
|
||||
ConversationState,
|
||||
)
|
||||
|
||||
|
||||
@ConversationConfig(defer_trace_finalization=True)
|
||||
class SupportFlow(Flow[ConversationState]):
|
||||
conversational = True
|
||||
|
||||
def route_turn(self, context):
|
||||
message = (self.state.current_user_message or "").lower()
|
||||
if "طلب" in message or "order" in message:
|
||||
return "order"
|
||||
if "وداع" in message or "goodbye" in message:
|
||||
return "goodbye"
|
||||
return "help"
|
||||
|
||||
@listen("order")
|
||||
def handle_order(self):
|
||||
reply = "طلبك في الطريق."
|
||||
self.append_assistant_message(reply)
|
||||
return reply
|
||||
|
||||
@listen("help")
|
||||
def handle_help(self):
|
||||
reply = "كيف يمكنني المساعدة؟"
|
||||
self.append_assistant_message(reply)
|
||||
return reply
|
||||
|
||||
@listen("goodbye")
|
||||
def handle_goodbye(self):
|
||||
reply = "وداعاً!"
|
||||
self.append_assistant_message(reply)
|
||||
return reply
|
||||
|
||||
|
||||
session_id = str(uuid4())
|
||||
flow = SupportFlow()
|
||||
|
||||
try:
|
||||
flow.handle_turn("أين طلبي؟", session_id=session_id)
|
||||
flow.handle_turn("وماذا عن الإرجاع؟", session_id=session_id)
|
||||
finally:
|
||||
flow.finalize_session_traces()
|
||||
```
|
||||
|
||||
## دورة حياة الجولة
|
||||
|
||||
كل `handle_turn` يشغّل:
|
||||
|
||||
1. **`_configure_conversational_kickoff`** — دمج `session_id` / `user_message` في `inputs` وتطبيق `ConversationalConfig`.
|
||||
2. **استعادة الحالة** — عند وجود `inputs["id"]` و`@persist`.
|
||||
3. **`FlowStarted`** — في أول جولة للجلسة المؤجلة فقط.
|
||||
4. **`prepare_conversational_turn`** — إضافة رسالة المستخدم و`last_user_message` وتصنيف اختياري.
|
||||
5. **تنفيذ الرسم** — `@start` → `@router` → معالجات `@listen`.
|
||||
6. **نهاية التشغيل** — يُتخطى `flow_finished` والتتبع لكل جولة عند التأجيل؛ `Agent.kickoff()` / crews لا تغلق دفعة الأب.
|
||||
|
||||
استدعِ **`append_assistant_message(reply)`** في المعالجات. سطر المستخدم محفوظ عبر `handle_turn` — لا تُضفه مرة أخرى.
|
||||
|
||||
## `ConversationalConfig` (افتراضيات على مستوى الصنف)
|
||||
|
||||
عيّن على صنف `Flow` كـ `conversational_config: ClassVar[ConversationalConfig | None]`.
|
||||
|
||||
| الحقل | الافتراضي | الغرض |
|
||||
|-------|-----------|--------|
|
||||
| `default_intents` | `None` | تسميات outcome للتصنيف التلقائي قبل kickoff |
|
||||
| `intent_llm` | `None` | نموذج التصنيف (مطلوب عند وجود intents) |
|
||||
| `interactive_prompt` | `"You: "` | مطالبة `kickoff(interactive=True)` |
|
||||
| `interactive_timeout` | `None` | مهلة لكل سطر في الوضع التفاعلي |
|
||||
| `exit_commands` | `exit`, `quit` | كلمات إنهاء الوضع التفاعلي |
|
||||
| `defer_trace_finalization` | `True` | إبقاء دفعة trace واحدة مفتوحة بين الجولات |
|
||||
|
||||
يمكن التجاوز لكل kickoff عبر `intents=` و`intent_llm=`.
|
||||
|
||||
## `ChatState` (شكل الحالة الموصى به للحفظ)
|
||||
|
||||
```python
|
||||
from crewai.flow import ChatState
|
||||
|
||||
|
||||
class MyChatState(ChatState):
|
||||
# موروث: id, messages, last_user_message, last_intent, session_ready
|
||||
research_turn_count: int = 0
|
||||
custom_flag: bool = False
|
||||
```
|
||||
|
||||
| الحقل | الدور |
|
||||
|-------|------|
|
||||
| `id` | UUID الجلسة (مثل `session_id` / `inputs["id"]`) |
|
||||
| `messages` | قائمة `{role, content}` لسجل LLM |
|
||||
| `last_user_message` | آخر سطر مستخدم في هذه الجولة |
|
||||
| `last_intent` | تسمية المسار بعد التصنيف (إن وُجد) |
|
||||
| `session_ready` | علم bootstrap لمرة واحدة |
|
||||
|
||||
`ConversationalInputs` هو `TypedDict` لـ `kickoff(inputs={...})`: `id`, `user_message`, `last_intent`.
|
||||
|
||||
## API المحادثة على `Flow`
|
||||
|
||||
### معاملات `kickoff` / `kickoff_async`
|
||||
|
||||
| المعامل | الغرض |
|
||||
|---------|--------|
|
||||
| `user_message` | نص هذه الجولة (أو `{"role": "user", "content": "..."}`) |
|
||||
| `session_id` | UUID المحادثة → `inputs["id"]` / `state.id` |
|
||||
| `intents` | تسميات outcome لـ `classify_intent` قبل kickoff |
|
||||
| `intent_llm` | LLM للتصنيف (مطلوب مع `intents`) |
|
||||
| `interactive` | حلقة CLI عبر `ask()` (للعروض المحلية فقط) |
|
||||
| `interactive_prompt` | مطالبة الوضع التفاعلي |
|
||||
| `interactive_timeout` | مهلة `ask()` لكل سطر |
|
||||
| `exit_commands` | كلمات إنهاء الوضع التفاعلي |
|
||||
| `inputs` | حقول حالة إضافية |
|
||||
| `restore_from_state_id` | استنساخ من flow محفوظ آخر |
|
||||
|
||||
### سمات المثيل
|
||||
|
||||
| السمة | الغرض |
|
||||
|-------|--------|
|
||||
| `conversational_config` | افتراضيات `ConversationalConfig` على مستوى الصنف |
|
||||
| `defer_trace_finalization` | علم المثيل؛ يُضبط تلقائياً من config عند kickoff |
|
||||
| `suppress_flow_events` | يخفي لوحات console؛ **التتبع يُسجّل** |
|
||||
| `stream` | بث؛ مع `ChatSession.handle_turn(..., stream=True)` |
|
||||
|
||||
### طرق وخصائص
|
||||
|
||||
| الاسم | الوصف |
|
||||
|------|--------|
|
||||
| `append_message(role, content, **extra)` | إضافة إلى `state.messages` |
|
||||
| `conversation_messages` | سجل للقراءة فقط لاستدعاءات LLM |
|
||||
| `classify_intent(text, outcomes, *, llm, context=None)` | تعيين outcome |
|
||||
| `receive_user_message(text, *, outcomes=None, llm=None)` | إضافة رسالة مستخدم؛ `last_intent` اختياري |
|
||||
| `finalize_session_traces()` | إصدار `flow_finished` المؤجل وإنهاء دفعة trace |
|
||||
| `_should_defer_trace_finalization()` | هل يُؤجل إنهاء trace لكل جولة |
|
||||
| `input_history` | سجل تدقيق مطالبات وردود `ask()` |
|
||||
|
||||
### مساعدات الوحدة (`crewai.flow.conversation`)
|
||||
|
||||
| الدالة | الوصف |
|
||||
|--------|--------|
|
||||
| `normalize_kickoff_inputs(...)` | دمج kwargs المحادثة في `inputs` |
|
||||
| `get_conversation_messages(flow)` | قراءة الرسائل من الحالة أو المخزن |
|
||||
| `append_message(flow, ...)` | مثل طريقة المثيل |
|
||||
| `prepare_conversational_turn(flow, ...)` | تهيئة الجولة (عادةً kickoff يستدعيها) |
|
||||
| `receive_user_message(flow, ...)` | مثل طريقة المثيل |
|
||||
| `set_state_field(flow, name, value)` | تعيين حقل dict أو Pydantic |
|
||||
| `get_conversational_config(flow)` | قراءة `conversational_config` |
|
||||
| `input_history_to_messages(entries)` | تحويل `input_history` لصيغة رسائل LLM |
|
||||
|
||||
## أنماط توجيه النية
|
||||
|
||||
### أ. تصنيف مسبق عبر `ConversationalConfig` (الأبسط)
|
||||
|
||||
عيّن `default_intents` و`intent_llm`. كل kickoff يصنّف قبل `@router`؛ اقرأ `self.state.last_intent` في `route()`.
|
||||
|
||||
### ب. تصنيف داخل `@router` (مطالبات أغنى)
|
||||
|
||||
عيّن `default_intents=None` ليضيف kickoff الرسالة فقط. في `route()` استدعِ `classify_intent`:
|
||||
|
||||
```python
|
||||
@router(bootstrap)
|
||||
def route(self):
|
||||
intent = self.classify_intent(
|
||||
self._routing_prompt(self.state.last_user_message),
|
||||
("GREETING", "ORDER", "RESEARCH", "GOODBYE"),
|
||||
llm=self.conversational_config.intent_llm or "gpt-4o-mini",
|
||||
)
|
||||
self.state.last_intent = intent
|
||||
return intent
|
||||
```
|
||||
|
||||
للبحث على الويب أو أدوات متعددة الخطوات استخدم **`@listen("RESEARCH")`** مع `Agent.kickoff()` وأدوات — وليس `LLM.call()` فقط.
|
||||
|
||||
## عندما ينتهي الـ flow ويستمر المستخدم
|
||||
|
||||
`FlowFinished` يعني أن **تنفيذ الرسم هذا** اكتمل. تستمر المحادثة بـ `kickoff` آخر ونفس `session_id`. `@persist` يستعيد `messages` والأعلام والسياق.
|
||||
|
||||
**نمط الحفظ:** يُفضّل `@persist` على **خطوة نهائية واحدة** (مثل `finalize`) وليس على صنف `Flow` بالكامل. الحفظ على مستوى الصنف بعد كل method قد يفقد تحديثات المعالجات في نفس الجولة.
|
||||
|
||||
لا تستخدم `@human_feedback` لأسطر المتابعة في الدردشة إلا عند الحاجة لموافقة بشرية على مخرجات خطوة محددة.
|
||||
|
||||
## `Flow` المحادثاتي (تجريبي)
|
||||
|
||||
<Warning>
|
||||
**ميزة تجريبية.** سطح `Flow` المحادثاتي (`conversational = True`،
|
||||
`handle_turn`، `ConversationConfig`، `RouterConfig`،
|
||||
`ConversationState`، الرسم البياني المدمج والمساعدات) يقع تحت
|
||||
`crewai.experimental` وقد يتغير شكله قبل التخرج. ثبّت إصدار CrewAI إذا
|
||||
كنت تعتمد على سلوك محدد، وراقب changelog للتحديثات الكاسرة. الملاحظات
|
||||
والمشاكل مرحب بها.
|
||||
</Warning>
|
||||
|
||||
فعّل الرسم المحادثاتي بتعيين `conversational = True` على صنف فرعي من `Flow`. عندئذٍ يُظهر `Flow` الأساسي رسم `@start` / `@router` / `converse_turn` / `end_conversation` مدمجاً، ويدير `state.messages`، ويُشغّل LLM التوجيه، ويبقي دفعة trace مفتوحة عبر الجولات. أنت تكتب **المسارات المخصصة** فقط؛ والإطار يتولى الباقي.
|
||||
|
||||
استخدمه عندما تريد دردشة متعددة الجولات مع موجّه قائم على LLM ومعالجات لكل مسار دون توصيل دورة الحياة يدوياً. استخدم `Flow[ChatState]` (النمط الأدنى مستوى في الأعلى) عندما تحتاج تحكماً كاملاً.
|
||||
|
||||
### مثال سريع
|
||||
|
||||
```python
|
||||
from crewai import LLM, Flow
|
||||
from crewai.flow import listen
|
||||
from crewai.experimental.conversational import (
|
||||
ConversationConfig,
|
||||
ConversationState,
|
||||
RouterConfig,
|
||||
)
|
||||
|
||||
|
||||
ROUTER_LLM = LLM(model="gpt-4o-mini")
|
||||
|
||||
|
||||
@ConversationConfig(
|
||||
system_prompt="A multi-agent assistant for ordinary chat and tool-backed tasks.",
|
||||
llm=ROUTER_LLM,
|
||||
router=RouterConfig(), # المسارات + الأوصاف تُكتشف تلقائياً من معالجات @listen
|
||||
)
|
||||
class SupportFlow(Flow[ConversationState]):
|
||||
conversational = True
|
||||
|
||||
@listen("INTERNET_SEARCH")
|
||||
def handle_internet_search(self) -> str:
|
||||
"""Fresh web research, current news, real-time lookups."""
|
||||
...
|
||||
self.append_assistant_message(reply)
|
||||
return reply
|
||||
|
||||
@listen("CREWAI_DOCS")
|
||||
def handle_crewai_docs(self) -> str:
|
||||
"""Look up the CrewAI documentation for framework/API questions."""
|
||||
...
|
||||
self.append_assistant_message(reply)
|
||||
return reply
|
||||
|
||||
|
||||
flow = SupportFlow()
|
||||
try:
|
||||
flow.handle_turn("ماذا يمكنك أن تفعل؟") # يوجَّه إلى converse (مدمج)
|
||||
flow.handle_turn("ابحث في الويب عن أخبار الذكاء الاصطناعي.") # يوجَّه إلى INTERNET_SEARCH
|
||||
flow.handle_turn("لخص النتيجة الأولى.") # يعود إلى converse
|
||||
finally:
|
||||
flow.finalize_session_traces()
|
||||
```
|
||||
|
||||
للدردشة المحلية في الطرفية، استخدم `chat()`:
|
||||
|
||||
```python
|
||||
def kickoff() -> None:
|
||||
SupportFlow().chat()
|
||||
```
|
||||
|
||||
يلف `chat()` استدعاءات `handle_turn()` داخل REPL، ويخرج عند `exit` / `quit`، ويتجاهل الأسطر الفارغة افتراضياً، ويستدعي `finalize_session_traces()` عند انتهاء الجلسة.
|
||||
|
||||
### `ConversationConfig`
|
||||
|
||||
مزخرف صنف يُلحق افتراضيات الدردشة على مستوى الصنف.
|
||||
|
||||
| الحقل | الافتراضي | الغرض |
|
||||
|-------|-----------|-------|
|
||||
| `system_prompt` | `slices.conversational_system_prompt` من i18n | رسالة system يستخدمها `converse_turn` المدمج. مرر `""` للتعطيل التام. |
|
||||
| `llm` | `None` | LLM المحادثة (يستخدمه `converse_turn` وكاحتياطي للموجّه). |
|
||||
| `router` | `None` | `RouterConfig` للتوجيه عبر LLM. بدونه، يسقط الـ flow دائماً إلى `converse`. |
|
||||
| `answer_from_history_prompt` | افتراضي الإطار | رسالة system للمسار الاختياري `answer_from_history`. |
|
||||
| `answer_from_history_llm` | `None` | يُفعّل الاختصار `answer_from_history` عند تعيينه. |
|
||||
| `intent_llm` | `None` | LLM لمسار التصنيف المسبق القديم `intents=`/`default_intents`. |
|
||||
| `default_intents` | `None` | تسميات النتائج للتصنيف المسبق القديم. |
|
||||
| `visible_agent_outputs` | `None` | `"all"` أو قائمة بأسماء الـ agents الذين تُرفع مخرجاتهم من `append_agent_result()` إلى رسائل عامة. |
|
||||
| `defer_trace_finalization` | `True` | يبقي دفعة trace واحدة مفتوحة عبر استدعاءات `handle_turn()`. |
|
||||
|
||||
### `RouterConfig` وفهرس المسارات المُولَّد تلقائياً
|
||||
|
||||
```python
|
||||
RouterConfig(
|
||||
prompt="تأطير اختياري للنطاق (سياسة، صوت، شخصية).",
|
||||
response_format=MyRoute, # اختياري؛ يُولَّد تلقائياً عند الإغفال
|
||||
llm=ROUTER_LLM, # يسقط إلى ConversationConfig.llm
|
||||
routes=["INTERNET_SEARCH", "CREWAI_DOCS"], # اختياري؛ يُستنتج من المستمعين
|
||||
route_descriptions={
|
||||
"INTERNET_SEARCH": "تجاوز الـ docstring لهذا المسار فقط.",
|
||||
},
|
||||
default_intent="converse", # يُستخدم عند فشل LLM أو غيابه
|
||||
fallback_intent="converse", # يُستخدم عندما يعيد LLM مساراً غير صالح
|
||||
intent_field="intent",
|
||||
)
|
||||
```
|
||||
|
||||
تُبنى رسالة الموجّه إلى LLM تلقائياً. لكل مسار يختار الإطار وصفاً بهذا الترتيب من الأولوية:
|
||||
|
||||
1. `RouterConfig.route_descriptions[label]` — تجاوز صريح.
|
||||
2. `Flow.builtin_route_descriptions[label]` — نص جاهز من الإطار لـ `converse` و`end` و`answer_from_history` (مصاغ لـ LLM التوجيه).
|
||||
3. أول سطر غير فارغ من docstring معالج `@listen(label)`.
|
||||
4. فارغ (المسار يظهر في الفهرس بلا وصف).
|
||||
|
||||
عملياً، **إضافة مسار جديد = `@listen("X")` + docstring من سطر واحد**:
|
||||
|
||||
```python
|
||||
@listen("INTERNET_SEARCH")
|
||||
def handle_internet_search(self) -> str:
|
||||
"""Fresh web research, current news, real-time lookups."""
|
||||
...
|
||||
```
|
||||
|
||||
…وسيرى LLM التوجيه:
|
||||
|
||||
```
|
||||
Routes:
|
||||
- CREWAI_DOCS: Look up the CrewAI documentation for framework/API questions.
|
||||
- INTERNET_SEARCH: Fresh web research, current news, real-time lookups.
|
||||
- converse: Ordinary chat, follow-ups, summaries, clarifications…
|
||||
- end: User signals the conversation is finished (goodbye, exit, done).
|
||||
```
|
||||
|
||||
`RouterConfig.prompt` مخصص لـ **تأطير النطاق** (شخصية المساعد، قواعد العمل، النبرة). فهرس المسارات يُبنى تلقائياً — لا تُدرج المسارات في `prompt`؛ سيختل التزامن لحظة إضافة معالج جديد.
|
||||
|
||||
### المسارات المدمجة
|
||||
|
||||
| المسار | المعالج | الغرض |
|
||||
|--------|---------|-------|
|
||||
| `converse` | `converse_turn` | معالج الدردشة الافتراضي. يستدعي `ConversationConfig.llm` بـ system prompt + التاريخ القانوني للرسائل. |
|
||||
| `end` | `end_conversation` | يضبط `state.ended = True` ويُصدر رد إنهاء. |
|
||||
| `answer_from_history` | `answer_from_history_turn` | اختياري. يُوجَّه إليه عندما يكون `ConversationConfig.answer_from_history_llm` مُعيَّناً ويمكن الإجابة على الرسالة من التاريخ فقط. |
|
||||
|
||||
يمكنك تجاوز أي من هذه بتعريف معالج بنفس الاسم في الصنف الفرعي.
|
||||
|
||||
### دلالات `handle_turn()`
|
||||
|
||||
`flow.handle_turn(message)` يُشغّل جولة واحدة:
|
||||
|
||||
1. يعيد ضبط تعقّب التنفيذ لكل جولة (`_completed_methods`, `_method_outputs`) ليُعاد تشغيل الرسم — بدون ذلك، استدعاءات `kickoff` المتكررة على نفس النسخة ستُحدث دائرة قصر من الجولة الثانية لأن `Flow.kickoff_async` يعتبر `inputs={"id": ...}` استعادة من نقطة تفتيش.
|
||||
2. يُلحق رسالة المستخدم بـ `state.messages` ويضبط `current_user_message` / `last_user_message`. يُحافَظ على `last_intent` **من الجولة السابقة** كي يستخدمها LLM التوجيه كإشارة.
|
||||
3. يُشغّل `conversation_start` → `route_conversation` → معالج `@listen` المختار.
|
||||
4. يخزّن الموجّه قراره في `state.last_intent` (يكون مرئياً لسياق التوجيه في الجولة التالية).
|
||||
5. إذا أعاد معالجك سلسلة نصية ولم يستدعِ `append_assistant_message`، فإن `handle_turn` يُلحقها نيابةً عنك.
|
||||
|
||||
استدعِ `handle_turn()` لرسائل الدردشة. استدعاء `kickoff(inputs={"id": ...})` مباشرةً يشغل الرسم بدون غلاف الجولة المحادثية.
|
||||
|
||||
### `chat()` للـ REPL المحلي
|
||||
|
||||
`flow.chat()` هو غلاف الطرفية الجاهز فوق `handle_turn()`:
|
||||
|
||||
```python
|
||||
flow = SupportFlow()
|
||||
flow.chat()
|
||||
```
|
||||
|
||||
يتولى الحلقة المحلية الشائعة:
|
||||
|
||||
1. يطلب رسالة من المستخدم.
|
||||
2. يتوقف عند `exit` / `quit` أو `EOFError` أو `KeyboardInterrupt`.
|
||||
3. يستدعي `handle_turn(message, session_id=...)`.
|
||||
4. يطبع نتيجة المساعد.
|
||||
5. ينهي traces الجلسة المؤجلة داخل كتلة `finally`.
|
||||
|
||||
خصص سلوك الطرفية عبر I/O قابل للحقن:
|
||||
|
||||
```python
|
||||
flow.chat(
|
||||
session_id="demo-session",
|
||||
prompt="You: ",
|
||||
assistant_prefix="Assistant: ",
|
||||
exit_commands=("exit", "quit", "bye"),
|
||||
)
|
||||
```
|
||||
|
||||
لتطبيقات الويب والـ workers الخلفية والاختبارات ووسائط النقل المخصصة، استمر في استخدام `handle_turn()` مباشرةً.
|
||||
|
||||
### سلوك موجّه مخصص
|
||||
|
||||
لتشغيل آثار جانبية (إعداد ناقل أحداث، قياس عن بُعد) في كل قرار توجيه، تجاوز `route_turn`:
|
||||
|
||||
```python
|
||||
class SupportFlow(Flow[ConversationState]):
|
||||
conversational = True
|
||||
|
||||
def route_turn(self, context: dict[str, Any]) -> str | None:
|
||||
self.event_bus = MyBus(self)
|
||||
return super().route_turn(context)
|
||||
```
|
||||
|
||||
لتجاوز موجّه LLM واختيار مسار برمجياً، أعد سلسلة نصية من `route_turn`؛ إعادة `None` تسقط إلى `_route_with_config(...)`.
|
||||
|
||||
### `append_assistant_message` و`append_agent_result`
|
||||
|
||||
داخل معالج `@listen(label)`، اختر:
|
||||
|
||||
- `self.append_assistant_message(text)` — يضيف جولة مساعد مرئية للمستخدم إلى `state.messages`. سيراها `converse_turn` في الجولة التالية.
|
||||
- `self.append_agent_result(agent_name, result, visibility="private")` — يسجّل حدثاً منظماً في `state.events` وموضوعاً في `state.agent_threads[agent_name]`. الرؤية العامة تستدعي `append_assistant_message` أيضاً. استخدم النتائج الخاصة للعمل الجانبي الذي يجب ألا يلوث التاريخ القانوني.
|
||||
|
||||
يمكن لـ `ConversationConfig.visible_agent_outputs` رفع النتائج الخاصة لـ agents محددين إلى عامة عالمياً (`"all"` أو قائمة بالأسماء).
|
||||
|
||||
## التتبع عبر الجولات
|
||||
|
||||
مع `defer_trace_finalization=True` (افتراضي في `ConversationalConfig`):
|
||||
|
||||
- **دفعة trace واحدة** لجلسة الدردشة.
|
||||
- **`flow_started`** في الجولة الأولى فقط؛ **`flow_finished`** مرة في `finalize_session_traces()`.
|
||||
- **`kickoff` لكل جولة** لا يطبع "Trace batch finalized".
|
||||
- **العمل المتداخل** (`Agent.kickoff()`, crews, Exa) يُلحق بدفعة **الأب**؛ flow داخلي من `AgentExecutor` لا يغلق دفعة الجلسة مبكراً.
|
||||
|
||||
```python
|
||||
flow.chat(session_id=session_id)
|
||||
```
|
||||
|
||||
`flow.chat()` يستدعي `finalize_session_traces()` نيابةً عنك. عندما تملك الحلقة عبر `handle_turn()` أو `kickoff(...)`، استدعِ `finalize_session_traces()` عند انتهاء الجلسة.
|
||||
|
||||
`suppress_flow_events=True` يخفي لوحات Rich فقط؛ أحداث trace والـ methods تُصدر.
|
||||
|
||||
### دورة حياة trace لـ `Flow` المحادثاتي
|
||||
|
||||
يستخدم [`Flow` المحادثاتي](#flow-المحادثاتي-تجريبي) التجريبي نفس دورة حياة tracing: `defer_trace_finalization` افتراضياً `True`، فيبقي كل `handle_turn()` أثر الجلسة مفتوحاً. أنهِ دوماً عند نهاية الجلسة — لُف حلقتك بـ `try/finally` واستدعِ `flow.finalize_session_traces()` عند الخروج. بدون ذلك، تبقى الدفعة مفتوحة وقد لا تُصدَّر آخر محادثة أبداً.
|
||||
|
||||
## البث
|
||||
|
||||
اضبط `stream = True` على صنف `Flow`. عندئذٍ يُصدر `kickoff(...)` أحداث `assistant_delta` (وما يرتبط بها) عبر ناقل الأحداث القياسي.
|
||||
|
||||
## الاستيراد
|
||||
|
||||
```python
|
||||
from crewai.flow import (
|
||||
ChatState,
|
||||
ConversationalConfig,
|
||||
ConversationalInputs,
|
||||
Flow,
|
||||
listen,
|
||||
persist,
|
||||
router,
|
||||
start,
|
||||
)
|
||||
```
|
||||
|
||||
## مراجع
|
||||
|
||||
- [إتقان إدارة حالة Flow](/ar/guides/flows/mastering-flow-state)
|
||||
- [أنشئ أول Flow](/ar/guides/flows/first-flow)
|
||||
- Demo: `lib/crewai/runner_conversational_flow_simple.py` — REPL بسيط مع `RESEARCH` ووكيل Exa
|
||||
@@ -272,6 +272,7 @@ crewai flow plot
|
||||
3. استكشف دوال `and_` و`or_` لتنفيذ متوازٍ أكثر تعقيدًا
|
||||
4. اربط Flow بواجهات API خارجية وقواعد بيانات وواجهات مستخدم
|
||||
5. ادمج عدة Crews متخصصة في Flow واحد
|
||||
6. أنشئ تطبيقات دردشة متعددة الجولات مع [تدفقات المحادثة](/ar/guides/flows/conversational-flows) (`kickoff` لكل رسالة، `ChatSession`، تأجيل التتبع)
|
||||
|
||||
<Check>
|
||||
تهانينا! لقد بنيت بنجاح أول CrewAI Flow يجمع بين الكود العادي واستدعاءات LLM المباشرة ومعالجة Crew لإنشاء دليل شامل. هذه المهارات الأساسية تمكّنك من إنشاء تطبيقات AI متطورة بشكل متزايد.
|
||||
|
||||
@@ -20,6 +20,8 @@ mode: "wide"
|
||||
5. **توسيع تطبيقاتك** - دعم سير العمل المعقدة بتنظيم بيانات مناسب
|
||||
6. **تمكين التطبيقات الحوارية** - تخزين والوصول إلى سجل المحادثات للتفاعلات الواعية بالسياق
|
||||
|
||||
للدردشة متعددة الجولات (`kickoff` لكل سطر مستخدم، `ChatState`، توجيه النية، تأجيل التتبع، و`ChatSession`)، راجع [تدفقات المحادثة](/ar/guides/flows/conversational-flows).
|
||||
|
||||
## أساسيات إدارة الحالة
|
||||
|
||||
### نهجان لإدارة الحالة
|
||||
|
||||
2253
docs/docs.json
2253
docs/docs.json
File diff suppressed because it is too large
Load Diff
@@ -4,6 +4,178 @@ description: "Product updates, improvements, and bug fixes for CrewAI"
|
||||
icon: "clock"
|
||||
mode: "wide"
|
||||
---
|
||||
<Update label="Jun 11, 2026">
|
||||
## v1.14.7
|
||||
|
||||
[View release on GitHub](https://github.com/crewAIInc/crewAI/releases/tag/1.14.7)
|
||||
|
||||
## What's Changed
|
||||
|
||||
### Features
|
||||
- Add pluggable default backends for memory, knowledge, rag, and flow.
|
||||
- Surface real finish_reason, sampling params, and response.id on LLM events.
|
||||
- Type DSL triggers as route-aware decorators.
|
||||
- Add chat API for conversational flows.
|
||||
- Make locking backend overridable.
|
||||
- Build FlowDefinition from Flow DSL metadata.
|
||||
- Add native Snowflake Cortex LLM provider.
|
||||
- Add crew trained agents file support.
|
||||
|
||||
### Bug Fixes
|
||||
- Fix checkpoint to rebuild custom BaseLLM as concrete LLM on restore.
|
||||
- Gate restore on a flag to prevent live snapshots from replaying as resume.
|
||||
- Scope runtime state per run to bound growth and isolate concurrent runs.
|
||||
- Fix telemetry setup on crewai-login.
|
||||
- Respect suppress_flow_events for method-execution events.
|
||||
- Restore [project.scripts] in crewai package for uv tool install.
|
||||
- Resolve pip-audit CVEs for aiohttp, docling, and docling-core.
|
||||
- Fix file input not working reliably.
|
||||
- Fix Snowflake Claude incomplete tool result histories.
|
||||
|
||||
### Documentation
|
||||
- Update changelog and version for v1.14.7.
|
||||
- Update OpenTelemetry collector documentation.
|
||||
- Update NVIDIA Nemotron LLM guide.
|
||||
- Add Databricks integration guide.
|
||||
- Add Snowflake integration guide.
|
||||
|
||||
### Performance
|
||||
- Improve crewai import speed by lazy-loading docling imports.
|
||||
|
||||
### Refactoring
|
||||
- Simplify flow condition evaluation to be stateless per event.
|
||||
- Decouple convo logic from runtime and add a conversational_definition.
|
||||
- Split `flow.py` into DSL, definition, and runtime.
|
||||
|
||||
## Contributors
|
||||
|
||||
@Luzk, @alex-clawd, @devin-ai-integration[bot], @greysonlalonde, @gvieira, @jessemiller, @lorenzejay, @lucasgomide, @mattatcha, @vinibrsl
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="Jun 10, 2026">
|
||||
## v1.14.7rc2
|
||||
|
||||
[View release on GitHub](https://github.com/crewAIInc/crewAI/releases/tag/1.14.7rc2)
|
||||
|
||||
## What's Changed
|
||||
|
||||
### Bug Fixes
|
||||
- Gate restore on a flag to prevent live snapshots from replaying as resume
|
||||
|
||||
### Documentation
|
||||
- Update changelog and version for v1.14.7rc1
|
||||
|
||||
## Contributors
|
||||
|
||||
@greysonlalonde
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="Jun 10, 2026">
|
||||
## v1.14.7rc1
|
||||
|
||||
[View release on GitHub](https://github.com/crewAIInc/crewAI/releases/tag/1.14.7rc1)
|
||||
|
||||
## What's Changed
|
||||
|
||||
### Features
|
||||
- Add `reset_runtime_state` to release accumulated bus state
|
||||
- Handle supporting both custom prompts
|
||||
- Decouple conversation logic from runtime and add a `conversational_definition`
|
||||
|
||||
### Bug Fixes
|
||||
- Fix scope of runtime state per run to bound growth and isolate concurrent runs
|
||||
- Fix telemetry setup on `crewai-login`
|
||||
- Fix respect for `suppress_flow_events` for method-execution events
|
||||
|
||||
### Documentation
|
||||
- Update OpenTelemetry images
|
||||
- Update documentation to reflect new state of OpenTelemetry collector
|
||||
- Update changelog and version for v1.14.7a4
|
||||
|
||||
### Refactoring
|
||||
- Simplify flow condition evaluation to be stateless per event
|
||||
- Improve conversation routing cycle with one less route
|
||||
|
||||
## Contributors
|
||||
|
||||
@greysonlalonde, @lorenzejay, @lucasgomide, @vinibrsl
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="Jun 09, 2026">
|
||||
## v1.14.7a4
|
||||
|
||||
[View release on GitHub](https://github.com/crewAIInc/crewAI/releases/tag/1.14.7a4)
|
||||
|
||||
## What's Changed
|
||||
|
||||
### Features
|
||||
- Migrate @listen/@router runtime to read from FlowDefinition
|
||||
- Add pluggable default backends for memory, knowledge, rag, and flow
|
||||
|
||||
### Documentation
|
||||
- Update changelog and version for v1.14.7a3
|
||||
|
||||
## Contributors
|
||||
|
||||
@greysonlalonde, @mattatcha, @vinibrsl
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="Jun 08, 2026">
|
||||
## v1.14.7a3
|
||||
|
||||
[View release on GitHub](https://github.com/crewAIInc/crewAI/releases/tag/1.14.7a3)
|
||||
|
||||
## What's Changed
|
||||
|
||||
### Bug Fixes
|
||||
- Fix exposure of `ask_for_human_input` on experimental `AgentExecutor`
|
||||
- Resolve pip-audit CVEs for `aiohttp`, `docling`, `docling-core`, and `pip`
|
||||
|
||||
### Refactoring
|
||||
- Migrate `@start` to read from `FlowDefinition`
|
||||
|
||||
### Documentation
|
||||
- Update changelog and version for v1.14.7a2
|
||||
|
||||
## Contributors
|
||||
|
||||
@greysonlalonde, @lorenzejay, @vinibrsl
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="Jun 05, 2026">
|
||||
## v1.14.7a2
|
||||
|
||||
[View release on GitHub](https://github.com/crewAIInc/crewAI/releases/tag/1.14.7a2)
|
||||
|
||||
## What's Changed
|
||||
|
||||
### Features
|
||||
- Add conversational flow traces support.
|
||||
- Update conversational flow documentation to utilize `handle_turn`.
|
||||
- Surface real `finish_reason`, sampling parameters, and `response.id` in LLM events.
|
||||
- Type DSL triggers as route-aware decorators.
|
||||
- Implement chat API for conversational flows.
|
||||
- Make locking backend overridable in lock store.
|
||||
- Split flow DSL monolith into focused decorator modules.
|
||||
- Flatten LiteLLM cache/reasoning usage sub-counts in `_usage_to_dict`.
|
||||
- Build `FlowDefinition` from Flow DSL metadata.
|
||||
|
||||
### Documentation
|
||||
- Add NVIDIA Nemotron LLM guide.
|
||||
- Document monorepo deployments.
|
||||
- Update changelog and version for v1.14.7a1.
|
||||
|
||||
## Contributors
|
||||
|
||||
@alex-clawd, @gvieira, @lorenzejay, @lucasgomide, @mattatcha, @vinibrsl
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="Jun 03, 2026">
|
||||
## v1.14.7a1
|
||||
|
||||
|
||||
@@ -226,6 +226,49 @@ After the Flow has run, you can access the final state to see the updates made b
|
||||
By ensuring that the final method's output is returned and providing access to the state, CrewAI Flows make it easy to integrate the results of your AI workflows into larger applications or systems,
|
||||
while also maintaining and accessing the state throughout the Flow's execution.
|
||||
|
||||
## Flow Usage Metrics
|
||||
|
||||
After a Flow execution completes, you can access the `usage_metrics` property to view aggregated token usage across **every LLM call** made during the run — including calls from every Crew the Flow orchestrated, calls inside Agent tools, and bare `LLM.call(...)` invocations from Flow methods. This is the SDK-side equivalent of the totals shown in the CrewAI Enterprise UI.
|
||||
|
||||
```python Code
|
||||
from crewai import LLM
|
||||
from crewai.flow.flow import Flow, listen, start
|
||||
|
||||
class UsageMetricsFlow(Flow):
|
||||
@start()
|
||||
def run_first_crew(self):
|
||||
self.state.first_result = FirstCrew().crew().kickoff()
|
||||
|
||||
@listen(run_first_crew)
|
||||
def call_llm_directly(self):
|
||||
# Bare LLM call — still counted by flow.usage_metrics
|
||||
llm = LLM(model="openai/gpt-4o-mini")
|
||||
self.state.summary = llm.call("Summarize the key takeaways.")
|
||||
|
||||
@listen(call_llm_directly)
|
||||
def run_second_crew(self):
|
||||
self.state.second_result = SecondCrew().crew().kickoff()
|
||||
|
||||
flow = UsageMetricsFlow()
|
||||
flow.kickoff()
|
||||
|
||||
print(flow.usage_metrics)
|
||||
# UsageMetrics(total_tokens=8579, prompt_tokens=6210, completion_tokens=2369,
|
||||
# cached_prompt_tokens=0, reasoning_tokens=0,
|
||||
# cache_creation_tokens=0, successful_requests=5)
|
||||
```
|
||||
|
||||
<Note>
|
||||
`flow.usage_metrics` is **not** the same as `flow.kickoff().token_usage`. The
|
||||
latter returns the `CrewOutput.token_usage` of the **last** `@listen` method
|
||||
that returned a `CrewOutput`, which means it only reflects the final Crew and
|
||||
ignores prior Crews and bare `LLM.call(...)` invocations entirely. Use
|
||||
`flow.usage_metrics` whenever you need the **full** token rollup for the Flow
|
||||
execution.
|
||||
</Note>
|
||||
|
||||
Each entry in the returned [`UsageMetrics`](https://github.com/crewAIInc/crewAI/blob/main/lib/crewai/src/crewai/types/usage_metrics.py) is the sum across all LLM calls made within a single `flow.kickoff()` invocation. Counters reset on the next `kickoff()` call (or on each iteration of `kickoff_for_each`), so successive runs don't double-count. The property is safe to read at any point after `kickoff()` completes; reading it during execution returns the partial total accumulated so far.
|
||||
|
||||
## Flow State Management
|
||||
|
||||
Managing state effectively is crucial for building reliable and maintainable AI workflows. CrewAI Flows provides robust mechanisms for both unstructured and structured state management,
|
||||
|
||||
@@ -952,6 +952,61 @@ In this section, you'll find detailed examples that help you select, configure,
|
||||
```
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="NVIDIA Nemotron">
|
||||
NVIDIA Nemotron models are designed for demanding agentic workloads, including complex reasoning, long-context analysis, tool use, multilingual tasks, and high-stakes RAG.
|
||||
|
||||
The `NVIDIA-Nemotron-3-Ultra-550B-A55B-NVFP4` model is a frontier-scale open-weight model from NVIDIA with 550B total parameters and 55B active parameters. It uses a LatentMoE architecture that combines Mamba-2, MoE, Attention, and Multi-Token Prediction (MTP), and supports context lengths up to 1M tokens.
|
||||
|
||||
<Info>
|
||||
`NVIDIA-Nemotron-3-Ultra-550B-A55B-NVFP4` is a very large model. NVIDIA lists minimum serving requirements of 4x GB200, 4x B200, 4x GB300, 4x B300, or 8x H100 GPUs. For most CrewAI users, the recommended path is to use NVIDIA NIM or another OpenAI-compatible hosted endpoint rather than running it locally.
|
||||
</Info>
|
||||
|
||||
**Hosted NVIDIA NIM usage:**
|
||||
```toml Code
|
||||
NVIDIA_API_KEY=<your-api-key>
|
||||
```
|
||||
|
||||
```python Code
|
||||
from crewai import LLM
|
||||
|
||||
llm = LLM(
|
||||
model="nvidia_nim/nvidia/nvidia-nemotron-3-ultra-550b-a55b",
|
||||
temperature=0.2,
|
||||
max_tokens=4096,
|
||||
)
|
||||
```
|
||||
|
||||
**Self-hosted OpenAI-compatible endpoint:**
|
||||
```python Code
|
||||
from crewai import LLM
|
||||
|
||||
llm = LLM(
|
||||
model="openai/nvidia-nemotron-3-ultra-550b-a55b-nvfp4",
|
||||
base_url="https://your-nemotron-endpoint.example.com/v1",
|
||||
api_key="your-api-key",
|
||||
temperature=0.2,
|
||||
max_tokens=4096,
|
||||
)
|
||||
```
|
||||
|
||||
**Model details:**
|
||||
|
||||
| Model | Context Window | Best For |
|
||||
|-------|----------------|----------|
|
||||
| `nvidia/NVIDIA-Nemotron-3-Ultra-550B-A55B-NVFP4` | Up to 1M tokens | Frontier reasoning, complex agentic workflows, long-context analysis, tool use, multilingual reasoning, and high-stakes RAG |
|
||||
|
||||
**Supported languages:** English, French, Spanish, Italian, German, Japanese, Korean, Hindi, Brazilian Portuguese, and Chinese.
|
||||
|
||||
**Reasoning mode:** Nemotron 3 Ultra supports configurable reasoning via its chat template using `enable_thinking=True` or `enable_thinking=False`. If you are using a hosted endpoint, check your provider's documentation for how that flag is exposed.
|
||||
|
||||
For model details, license, and deployment guidance, see the [NVIDIA Nemotron 3 Ultra model card](https://huggingface.co/nvidia/NVIDIA-Nemotron-3-Ultra-550B-A55B-NVFP4).
|
||||
|
||||
**Note:** Hosted NVIDIA NIM usage uses LiteLLM. Add it as a dependency to your project:
|
||||
```bash
|
||||
uv add 'crewai[litellm]'
|
||||
```
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Local NVIDIA NIM Deployed using WSL2">
|
||||
|
||||
NVIDIA NIM enables you to run powerful LLMs locally on your Windows machine using WSL2 (Windows Subsystem for Linux).
|
||||
|
||||
@@ -101,7 +101,7 @@ crew = Crew(
|
||||
)
|
||||
```
|
||||
|
||||
When `memory=True`, the crew creates a default `Memory()` and passes the crew's `embedder` configuration through automatically. All agents in the crew share the crew's memory unless an agent has its own.
|
||||
When `memory=True`, the crew creates a default `Memory()` and passes the crew's `embedder` configuration through automatically. All agents in the crew share the crew's memory unless an agent has its own. Without a custom `embedder`, memory uses OpenAI `text-embedding-3-large` embeddings.
|
||||
|
||||
After each task, the crew automatically extracts discrete facts from the task output and stores them. Before each task, the agent recalls relevant context from memory and injects it into the task prompt.
|
||||
|
||||
@@ -515,7 +515,11 @@ memory = Memory(
|
||||
|
||||
## Embedder Configuration
|
||||
|
||||
Memory needs an embedding model to convert text into vectors for semantic search. You can configure this in three ways.
|
||||
Memory needs an embedding model to convert text into vectors for semantic search. By default, `Memory()` uses OpenAI `text-embedding-3-large` embeddings, which produce 3072-dimensional vectors. Set `OPENAI_API_KEY` for the default path, or configure a custom embedder in one of three ways.
|
||||
|
||||
<Warning>
|
||||
Existing local memory stores created with 1536-dimensional embeddings, such as `text-embedding-3-small` or `text-embedding-ada-002`, may not be compatible with the `text-embedding-3-large` default. This applies to both the OpenAI and Azure OpenAI providers — Azure's default embedding model also changed from `text-embedding-ada-002` to `text-embedding-3-large`. If local testing fails with an embedding dimension mismatch, reset memory with `crewai reset-memories -m`, delete the local memory storage directory, or explicitly configure the older embedder model until you migrate.
|
||||
</Warning>
|
||||
|
||||
### Passing to Memory Directly
|
||||
|
||||
@@ -523,7 +527,7 @@ Memory needs an embedding model to convert text into vectors for semantic search
|
||||
from crewai import Memory
|
||||
|
||||
# As a config dict
|
||||
memory = Memory(embedder={"provider": "openai", "config": {"model_name": "text-embedding-3-small"}})
|
||||
memory = Memory(embedder={"provider": "openai", "config": {"model_name": "text-embedding-3-large"}})
|
||||
|
||||
# As a pre-built callable
|
||||
from crewai.rag.embeddings.factory import build_embedder
|
||||
@@ -542,7 +546,7 @@ crew = Crew(
|
||||
agents=[...],
|
||||
tasks=[...],
|
||||
memory=True,
|
||||
embedder={"provider": "openai", "config": {"model_name": "text-embedding-3-small"}},
|
||||
embedder={"provider": "openai", "config": {"model_name": "text-embedding-3-large"}},
|
||||
)
|
||||
```
|
||||
|
||||
@@ -554,7 +558,7 @@ crew = Crew(
|
||||
memory = Memory(embedder={
|
||||
"provider": "openai",
|
||||
"config": {
|
||||
"model_name": "text-embedding-3-small",
|
||||
"model_name": "text-embedding-3-large",
|
||||
# "api_key": "sk-...", # or set OPENAI_API_KEY env var
|
||||
},
|
||||
})
|
||||
@@ -701,9 +705,9 @@ memory = Memory(embedder=my_embedder)
|
||||
|
||||
| Provider | Key | Typical Model | Notes |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| OpenAI | `openai` | `text-embedding-3-small` | Default. Set `OPENAI_API_KEY`. |
|
||||
| OpenAI | `openai` | `text-embedding-3-large` | Default. Set `OPENAI_API_KEY`. |
|
||||
| Ollama | `ollama` | `mxbai-embed-large` | Local, no API key needed. |
|
||||
| Azure OpenAI | `azure` | `text-embedding-ada-002` | Requires `deployment_id`. |
|
||||
| Azure OpenAI | `azure` | `text-embedding-3-large` | Default model. Requires `deployment_id`. |
|
||||
| Google AI | `google-generativeai` | `gemini-embedding-001` | Set `GOOGLE_API_KEY`. |
|
||||
| Google Vertex | `google-vertex` | `gemini-embedding-001` | Requires `project_id`. |
|
||||
| Cohere | `cohere` | `embed-english-v3.0` | Strong multilingual support. |
|
||||
@@ -836,6 +840,9 @@ class MemoryMonitor(BaseEventListener):
|
||||
**Background save errors in logs?**
|
||||
- Memory saves run in a background thread. Errors are emitted as `MemorySaveFailedEvent` but don't crash the agent. Check logs for the root cause (usually LLM or embedder connection issues).
|
||||
|
||||
**Embedding dimension mismatch?**
|
||||
- Existing local memory stores may have been created with a different embedding model. The default OpenAI memory embedder is now `text-embedding-3-large` (3072 dimensions), while older stores commonly used 1536-dimensional embeddings. For local testing, run `crewai reset-memories -m`, delete the local memory storage directory, or configure the previous embedder model explicitly.
|
||||
|
||||
**Concurrent write conflicts?**
|
||||
- LanceDB operations are serialized with a shared lock and retried automatically on conflict. This handles multiple `Memory` instances pointing at the same database (e.g. agent memory + crew memory). No action needed.
|
||||
|
||||
@@ -862,7 +869,7 @@ All configuration is passed as keyword arguments to `Memory(...)`. Every paramet
|
||||
| :--- | :--- | :--- |
|
||||
| `llm` | `"gpt-4o-mini"` | LLM for analysis (model name or `BaseLLM` instance). |
|
||||
| `storage` | `"lancedb"` | Storage backend (`"lancedb"`, a path string, or a `StorageBackend` instance). |
|
||||
| `embedder` | `None` (OpenAI default) | Embedder (config dict, callable, or `None` for default OpenAI). |
|
||||
| `embedder` | `None` (OpenAI `text-embedding-3-large`) | Embedder (config dict, callable, or `None` for default OpenAI). |
|
||||
| `recency_weight` | `0.3` | Weight for recency in composite score. |
|
||||
| `semantic_weight` | `0.5` | Weight for semantic similarity in composite score. |
|
||||
| `importance_weight` | `0.2` | Weight for importance in composite score. |
|
||||
|
||||
@@ -24,15 +24,39 @@ Telemetry data follows the [OpenTelemetry GenAI semantic conventions](https://op
|
||||
|
||||
1. In CrewAI AMP, go to **Settings** > **OpenTelemetry Collectors**.
|
||||
2. Click **Add Collector**.
|
||||
3. Select an integration type — **OpenTelemetry Traces** or **OpenTelemetry Logs**.
|
||||
4. Configure the connection:
|
||||
- **Endpoint** — Your collector's OTLP endpoint (e.g., `https://otel-collector.example.com:4317`).
|
||||
- **Service Name** — A name to identify this service in your observability platform.
|
||||
- **Custom Headers** *(optional)* — Add authentication or routing headers as key-value pairs.
|
||||
- **Certificate** *(optional)* — Provide a TLS certificate if your collector requires one.
|
||||
5. Click **Save**.
|
||||
3. Select an integration:
|
||||
- **OpenTelemetry Traces** and **OpenTelemetry Logs** — export to any OTLP-compatible collector or backend.
|
||||
- **Datadog** — send traces straight to Datadog's OTLP intake, no separate collector or Datadog Agent required.
|
||||
4. Configure the connection. The fields depend on the integration you selected:
|
||||
|
||||
<Frame></Frame>
|
||||
<Tabs>
|
||||
<Tab title="OpenTelemetry Traces / Logs">
|
||||
**OpenTelemetry Traces** and **OpenTelemetry Logs** are separate integrations that share the same fields — pick the one matching the signal you want to export.
|
||||
|
||||
- **Endpoint** — Your collector's OTLP endpoint (e.g., `https://otel-collector.example.com:4317`).
|
||||
- **Service Name** — A name to identify this service in your observability platform.
|
||||
- **Custom Headers** *(optional)* — Add authentication or routing headers as key-value pairs.
|
||||
- **Certificate** *(optional)* — Provide a TLS certificate if your collector requires one.
|
||||
|
||||
<Frame></Frame>
|
||||
</Tab>
|
||||
<Tab title="Datadog">
|
||||
- **Datadog Site Domain** — Your Datadog site's OTLP host only, with no protocol or path. CrewAI builds the full HTTPS OTLP endpoint for you. Use the host that matches your [Datadog site](https://docs.datadoghq.com/getting_started/site/):
|
||||
- `otlp.datadoghq.com` (US1)
|
||||
- `otlp.us3.datadoghq.com` (US3)
|
||||
- `otlp.us5.datadoghq.com` (US5)
|
||||
- `otlp.datadoghq.eu` (EU1)
|
||||
- `otlp.ap1.datadoghq.com` (AP1)
|
||||
- **API Key** — Your Datadog API key. See [how to create one](https://docs.datadoghq.com/account_management/api-app-keys/#api-keys).
|
||||
|
||||
The Datadog integration exports **traces**.
|
||||
|
||||
<Frame></Frame>
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
5. *(optional)* Click **Test Connection** to verify CrewAI can reach the endpoint with the credentials you provided.
|
||||
6. Click **Save**.
|
||||
|
||||
<Tip>
|
||||
You can add multiple collectors — for example, one for traces and another for logs, or send to different backends for different purposes.
|
||||
|
||||
@@ -164,6 +164,12 @@ You need to push your crew to a GitHub repository. If you haven't created a crew
|
||||

|
||||
</Frame>
|
||||
|
||||
<Tip>
|
||||
If your Crew or Flow is inside a monorepo subfolder, expand **Advanced**
|
||||
and set a working directory before deploying. See
|
||||
[Monorepo Deployments](/en/enterprise/guides/monorepo-deployments).
|
||||
</Tip>
|
||||
|
||||
</Step>
|
||||
|
||||
<Step title="Set Environment Variables">
|
||||
|
||||
225
docs/en/enterprise/guides/monorepo-deployments.mdx
Normal file
225
docs/en/enterprise/guides/monorepo-deployments.mdx
Normal file
@@ -0,0 +1,225 @@
|
||||
---
|
||||
title: "Monorepo Deployments"
|
||||
description: "Deploy a Crew or Flow from a subfolder in a larger repository"
|
||||
icon: "folder-tree"
|
||||
mode: "wide"
|
||||
---
|
||||
|
||||
<Note>
|
||||
Use a working directory when your Crew or Flow lives inside a larger
|
||||
repository. CrewAI AMP validates, builds, tests, and runs the automation from
|
||||
that subfolder instead of the repository root.
|
||||
</Note>
|
||||
|
||||
## When to Use This
|
||||
|
||||
Monorepo deployments are useful when one repository contains multiple
|
||||
automations, shared packages, or other application code:
|
||||
|
||||
```text
|
||||
company-ai/
|
||||
|-- uv.lock
|
||||
|-- packages/
|
||||
| `-- shared_tools/
|
||||
`-- crews/
|
||||
|-- support_agent/
|
||||
| |-- pyproject.toml
|
||||
| `-- src/
|
||||
| `-- support_agent/
|
||||
| |-- main.py
|
||||
| `-- crew.py
|
||||
`-- research_flow/
|
||||
|-- pyproject.toml
|
||||
`-- src/
|
||||
`-- research_flow/
|
||||
`-- main.py
|
||||
```
|
||||
|
||||
To deploy `support_agent`, set the working directory to:
|
||||
|
||||
```text
|
||||
crews/support_agent
|
||||
```
|
||||
|
||||
AMP still pulls or uploads the whole repository, but it treats the selected
|
||||
folder as the automation project root.
|
||||
|
||||
## What the Working Directory Controls
|
||||
|
||||
When a working directory is set, AMP uses that folder for:
|
||||
|
||||
- Project validation, including `pyproject.toml`, `src/`, and the Crew or Flow entry point
|
||||
- Dependency installation with `uv`
|
||||
- The running process working directory
|
||||
- The `CREW_ROOT_DIR` environment variable
|
||||
|
||||
Leaving the field empty keeps the existing behavior and uses the repository
|
||||
root.
|
||||
|
||||
## Supported Sources
|
||||
|
||||
You can set a working directory when creating a deployment from:
|
||||
|
||||
- A connected GitHub repository
|
||||
- A Git repository configured in AMP
|
||||
- A ZIP upload
|
||||
|
||||
<Info>
|
||||
Configure working directories in the AMP web interface. The
|
||||
`crewai deploy create` CLI flow does not prompt for this field.
|
||||
</Info>
|
||||
|
||||
You can also add or change the working directory on an existing deployment from
|
||||
the deployment's **Settings** page. The change takes effect on the next deploy.
|
||||
|
||||
<Warning>
|
||||
Working directories and auto-deploy cannot be used together. If a deployment
|
||||
has a working directory, auto-deploy is disabled for that deployment. Turn
|
||||
auto-deploy off before setting a working directory.
|
||||
</Warning>
|
||||
|
||||
## Configure a New Deployment
|
||||
|
||||
<Steps>
|
||||
<Step title="Open Deploy from Code">
|
||||
In CrewAI AMP, create a new deployment and choose your source: GitHub, Git
|
||||
Repository, or ZIP upload.
|
||||
</Step>
|
||||
|
||||
<Step title="Select the repository, branch, or ZIP file">
|
||||
Choose the repository and branch that contain your monorepo, or upload a ZIP
|
||||
file whose root contains the monorepo contents.
|
||||
</Step>
|
||||
|
||||
<Step title="Open Advanced settings">
|
||||
Expand the **Advanced** section in the deploy form.
|
||||
</Step>
|
||||
|
||||
<Step title="Enter the working directory">
|
||||
Enter the path from the repository root to the Crew or Flow project:
|
||||
|
||||
```text
|
||||
crews/support_agent
|
||||
```
|
||||
|
||||
Do not include a leading slash.
|
||||
</Step>
|
||||
|
||||
<Step title="Deploy">
|
||||
Add any required environment variables, then start the deployment.
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
## Configure an Existing Deployment
|
||||
|
||||
<Steps>
|
||||
<Step title="Open the deployment settings">
|
||||
Go to your automation in AMP and open **Settings**.
|
||||
</Step>
|
||||
|
||||
<Step title="Turn off auto-deploy if needed">
|
||||
If auto-deploy is enabled, disable it first. The working directory field is
|
||||
unavailable while auto-deploy is on.
|
||||
</Step>
|
||||
|
||||
<Step title="Set the working directory">
|
||||
In **Basic settings**, enter the subfolder path, such as:
|
||||
|
||||
```text
|
||||
crews/support_agent
|
||||
```
|
||||
</Step>
|
||||
|
||||
<Step title="Redeploy">
|
||||
Save the setting and redeploy the automation. The new working directory is
|
||||
used on the next deploy.
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
## Path Rules
|
||||
|
||||
The working directory must be a relative path inside the repository or ZIP root.
|
||||
|
||||
| Rule | Example |
|
||||
|------|---------|
|
||||
| Use a relative path | `crews/support_agent` |
|
||||
| Do not start with `/` | `/crews/support_agent` is invalid |
|
||||
| Do not use `.` or `..` path segments | `crews/../support_agent` is invalid |
|
||||
| Use only letters, numbers, dashes, underscores, dots, and forward slashes | `crews/support agent` is invalid |
|
||||
| Keep the path at 255 characters or fewer | Longer paths are rejected |
|
||||
|
||||
AMP trims leading and trailing whitespace, collapses repeated slashes, and
|
||||
removes trailing slashes. A blank value uses the repository root.
|
||||
|
||||
## Lock Files and UV Workspaces
|
||||
|
||||
The selected folder must contain the automation's `pyproject.toml` and `src/`
|
||||
directory. A `uv.lock` or `poetry.lock` file can live either in the selected
|
||||
folder or at the repository root.
|
||||
|
||||
This supports both common monorepo layouts:
|
||||
|
||||
<Tabs>
|
||||
<Tab title="Project lock file">
|
||||
```text
|
||||
company-ai/
|
||||
`-- crews/
|
||||
`-- support_agent/
|
||||
|-- pyproject.toml
|
||||
|-- uv.lock
|
||||
`-- src/
|
||||
`-- support_agent/
|
||||
`-- main.py
|
||||
```
|
||||
</Tab>
|
||||
|
||||
<Tab title="Workspace lock file">
|
||||
```text
|
||||
company-ai/
|
||||
|-- uv.lock
|
||||
|-- packages/
|
||||
| `-- shared_tools/
|
||||
`-- crews/
|
||||
`-- support_agent/
|
||||
|-- pyproject.toml
|
||||
`-- src/
|
||||
`-- support_agent/
|
||||
`-- main.py
|
||||
```
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
<Tip>
|
||||
If your automation imports shared packages from elsewhere in the monorepo,
|
||||
declare those packages in `pyproject.toml` using UV workspace, path, or source
|
||||
configuration. AMP runs the automation from the selected folder, so shared
|
||||
code should be installed as a dependency instead of relying on the repository
|
||||
root being on the Python path.
|
||||
</Tip>
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Working Directory Not Found
|
||||
|
||||
Check that the path is relative to the repository or ZIP root. For ZIP uploads,
|
||||
the ZIP contents must include the working directory path exactly as entered.
|
||||
|
||||
### Missing pyproject.toml
|
||||
|
||||
The working directory should point to the Crew or Flow project folder, not just
|
||||
to a parent folder that contains several projects.
|
||||
|
||||
### Missing uv.lock or poetry.lock
|
||||
|
||||
Commit a lock file either in the selected project folder or in the repository
|
||||
root. For UV workspaces, keeping `uv.lock` at the workspace root is supported.
|
||||
|
||||
### Auto-Deploy Is Unavailable
|
||||
|
||||
Auto-deploy is disabled while a working directory is set. Use manual redeploys
|
||||
or trigger redeployments from CI/CD with the AMP API instead.
|
||||
|
||||
<Card title="Deploy to AMP" icon="rocket" href="/en/enterprise/guides/deploy-to-amp">
|
||||
Continue with the deployment guide after choosing your monorepo working
|
||||
directory.
|
||||
</Card>
|
||||
@@ -161,6 +161,18 @@ crew = Crew(
|
||||
)
|
||||
```
|
||||
|
||||
<Note>
|
||||
`agent.i18n` is maintained only for backward compatibility and is deprecated. For runtime prompt customization, pass `prompt_file` to `Crew`. For programmatic access to prompt slices, use the i18n utility directly:
|
||||
</Note>
|
||||
|
||||
```python
|
||||
from crewai.utilities.i18n import get_i18n
|
||||
|
||||
i18n = get_i18n("custom_prompts.json")
|
||||
format_slice = i18n.slice("format")
|
||||
tool_prompt = i18n.tools("ask_question")
|
||||
```
|
||||
|
||||
#### Option 3: Disable System Prompts for o1 Models
|
||||
```python
|
||||
agent = Agent(
|
||||
@@ -208,6 +220,8 @@ One straightforward approach is to create a JSON file for the prompts you want t
|
||||
|
||||
CrewAI then merges your customizations with the defaults, so you don't have to redefine every prompt. Here's how:
|
||||
|
||||
For code that needs to read prompt slices directly, use `crewai.utilities.i18n.get_i18n()` with the same prompt file instead of reading `agent.i18n`.
|
||||
|
||||
### Example: Basic Prompt Customization
|
||||
|
||||
Create a `custom_prompts.json` file with the prompts you want to modify. Ensure you list all top-level prompts it should contain, not just your changes:
|
||||
|
||||
@@ -172,7 +172,7 @@ Flows are ideal when:
|
||||
|
||||
```python
|
||||
# Example: Customer Support Flow with structured processing
|
||||
from crewai.flow.flow import Flow, listen, router, start
|
||||
from crewai.flow.flow import Flow, listen, or_, router, start
|
||||
from pydantic import BaseModel
|
||||
from typing import List, Dict
|
||||
|
||||
@@ -238,7 +238,7 @@ class CustomerSupportFlow(Flow[SupportTicketState]):
|
||||
|
||||
# Additional category handlers...
|
||||
|
||||
@listen("billing", "account_access", "technical_issue", "feature_request", "other")
|
||||
@listen(or_("billing", "account_access", "technical_issue", "feature_request", "other"))
|
||||
def resolve_ticket(self, resolution_info):
|
||||
# Final resolution step
|
||||
self.state.resolution = f"Issue resolved: {resolution_info}"
|
||||
|
||||
501
docs/en/guides/flows/conversational-flows.mdx
Normal file
501
docs/en/guides/flows/conversational-flows.mdx
Normal file
@@ -0,0 +1,501 @@
|
||||
---
|
||||
title: Conversational Flows
|
||||
description: Build multi-turn chat apps with handle_turn per turn, message history, intent routing, tracing, and WebSocket bridges.
|
||||
icon: comments
|
||||
mode: "wide"
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Conversational apps treat each user line as a **new flow run** with the **same session id**. CrewAI adds helpers for message history, optional intent routing, deferred tracing, UI bridges, and a local `flow.chat()` REPL for conversational flows.
|
||||
|
||||
| Concept | Implementation |
|
||||
|---------|----------------|
|
||||
| Session id | `handle_turn(..., session_id=...)` → `kickoff(inputs={"id": ...})` → `state.id` |
|
||||
| User line | `handle_turn(message)` appends to `state.messages` before the graph runs |
|
||||
| Turn complete | `FlowFinished` for **this run** only; chat continues on the next `handle_turn` |
|
||||
| Full-session trace | `ConversationConfig(defer_trace_finalization=True)` + `finalize_session_traces()` |
|
||||
|
||||
## Turn APIs
|
||||
|
||||
Use **`flow.handle_turn(message, session_id=...)`** for every user message from REST, WebSocket, tests, and custom UIs. Use **`flow.chat()`** when you want a local terminal chat loop for a conversational `Flow`.
|
||||
|
||||
`Flow.kickoff()` does **not** accept `user_message=` or `session_id=` keyword arguments. For conversational flows, `handle_turn()` stores the pending message and calls `kickoff(inputs={"id": session_id})` internally after resetting per-turn execution state.
|
||||
|
||||
| API | Use for |
|
||||
|-----|---------|
|
||||
| `handle_turn(message, session_id=...)` | Ergonomic one-turn wrapper for conversational `Flow` |
|
||||
| `chat()` | Local terminal REPL for conversational `Flow` |
|
||||
| `kickoff(inputs={...})` | Advanced flow execution without conversational turn handling |
|
||||
| `ask()` | Blocking prompt **inside** one step (wizard, clarification) |
|
||||
| `@human_feedback` | Approve/reject **a step output** — not the next chat line |
|
||||
| `ChatSession.handle_turn(...)` | Transport layer over `handle_turn` (SSE / WebSocket) |
|
||||
|
||||
## Quick start
|
||||
|
||||
```python
|
||||
from uuid import uuid4
|
||||
|
||||
from crewai import Flow
|
||||
from crewai.flow import listen
|
||||
from crewai.experimental.conversational import (
|
||||
ConversationConfig,
|
||||
ConversationState,
|
||||
)
|
||||
|
||||
|
||||
@ConversationConfig(defer_trace_finalization=True)
|
||||
class SupportFlow(Flow[ConversationState]):
|
||||
conversational = True
|
||||
|
||||
def route_turn(self, context):
|
||||
message = (self.state.current_user_message or "").lower()
|
||||
if "order" in message:
|
||||
return "order"
|
||||
if "bye" in message or "goodbye" in message:
|
||||
return "goodbye"
|
||||
return "help"
|
||||
|
||||
@listen("order")
|
||||
def handle_order(self):
|
||||
reply = "Your order is on the way."
|
||||
self.append_assistant_message(reply)
|
||||
return reply
|
||||
|
||||
@listen("help")
|
||||
def handle_help(self):
|
||||
reply = "How can I help?"
|
||||
self.append_assistant_message(reply)
|
||||
return reply
|
||||
|
||||
@listen("goodbye")
|
||||
def handle_goodbye(self):
|
||||
reply = "Goodbye!"
|
||||
self.append_assistant_message(reply)
|
||||
return reply
|
||||
|
||||
|
||||
session_id = str(uuid4())
|
||||
flow = SupportFlow()
|
||||
|
||||
try:
|
||||
flow.handle_turn("Where is my order?", session_id=session_id)
|
||||
flow.handle_turn("What about returns?", session_id=session_id)
|
||||
finally:
|
||||
flow.finalize_session_traces() # one trace link for the whole chat
|
||||
```
|
||||
|
||||
## Turn lifecycle
|
||||
|
||||
Each `handle_turn` runs this pipeline:
|
||||
|
||||
1. **Turn setup** — stores the pending user message, resolves the session id, resets per-turn execution tracking, and calls `kickoff(inputs={"id": session_id})`.
|
||||
2. **State restore** — if `inputs["id"]` exists and `@persist` is configured, loads the latest snapshot.
|
||||
3. **`FlowStarted`** — emitted on the first deferred session turn only.
|
||||
4. **Pending turn hydration** — appends the user message to `state.messages`, sets `current_user_message` / `last_user_message`, and optionally classifies when `intents` / `default_intents` + `intent_llm` are set.
|
||||
5. **Graph execution** — `conversation_start` → `route_conversation` → the selected `@listen` handler.
|
||||
6. **End of run** — per-turn `flow_finished` and trace finalization are **skipped** when deferral is enabled; nested `Agent.kickoff()` / crews do not close the parent batch either.
|
||||
|
||||
Handlers should call **`append_assistant_message(reply)`** so the next turn’s `conversation_messages` includes assistant text. The user line is already stored by `handle_turn` — do not append it again in handlers.
|
||||
|
||||
## `ConversationConfig` (class-level defaults)
|
||||
|
||||
Decorate your conversational `Flow` subclass with `ConversationConfig`.
|
||||
|
||||
| Field | Default | Purpose |
|
||||
|-------|---------|---------|
|
||||
| `system_prompt` | Framework default | System message used by the built-in `converse_turn`. |
|
||||
| `llm` | `None` | Conversation LLM used by `converse_turn` and as router fallback. |
|
||||
| `router` | `None` | `RouterConfig` for LLM-driven routing. |
|
||||
| `intent_llm` | `None` | LLM for `intents=` / `default_intents` pre-classification. |
|
||||
| `default_intents` | `None` | Outcome labels for pre-classification. |
|
||||
| `defer_trace_finalization` | `True` | Keep one trace batch open across `handle_turn()` calls. |
|
||||
|
||||
Override pre-classification per turn with `handle_turn(..., intents=..., intent_llm=...)`.
|
||||
|
||||
## Lower-level `ChatState` helpers
|
||||
|
||||
`ChatState`, `ConversationalConfig`, and `crewai.flow.conversation` helpers are still importable for advanced orchestration, tests, or custom wrappers. They do not add `user_message=` or `session_id=` keyword arguments to `Flow.kickoff()`.
|
||||
|
||||
```python
|
||||
from crewai.flow import ChatState
|
||||
|
||||
|
||||
class MyChatState(ChatState):
|
||||
# Inherited: id, messages, last_user_message, last_intent, session_ready
|
||||
research_turn_count: int = 0
|
||||
custom_flag: bool = False
|
||||
```
|
||||
|
||||
| Field | Role |
|
||||
|-------|------|
|
||||
| `id` | Session UUID (same as `inputs["id"]`) |
|
||||
| `messages` | `list` of `{role, content}` for LLM history |
|
||||
| `last_user_message` | Latest user line for this turn |
|
||||
| `last_intent` | Route label after classification (if used) |
|
||||
| `session_ready` | One-time bootstrap flag (permissions, caches, etc.) |
|
||||
|
||||
`ConversationalInputs` is a `TypedDict` for conventional `kickoff(inputs={...})` keys: `id`, `user_message`, `last_intent`.
|
||||
|
||||
## `Flow` conversational API
|
||||
|
||||
### `handle_turn` parameters
|
||||
|
||||
| Parameter | Purpose |
|
||||
|-----------|---------|
|
||||
| `message` | This turn’s text |
|
||||
| `session_id` | Conversation UUID → `inputs["id"]` / `state.id` |
|
||||
| `intents` | Outcome labels for pre-kickoff `classify_intent` |
|
||||
| `intent_llm` | LLM for classification (required with `intents`) |
|
||||
| `**kickoff_kwargs` | Forwarded to `kickoff()` for options like `input_files`, `from_checkpoint`, and `restore_from_state_id` |
|
||||
|
||||
### `kickoff` parameters
|
||||
|
||||
`Flow.kickoff()` accepts `inputs`, `input_files`, `from_checkpoint`, and `restore_from_state_id`. Pass `inputs={"id": session_id}` when you need raw flow execution, but use `handle_turn()` when the call represents a chat message.
|
||||
|
||||
### Instance attributes
|
||||
|
||||
| Attribute | Purpose |
|
||||
|-----------|---------|
|
||||
| `conversational` | Set to `True` to enable the conversational graph and `handle_turn()` |
|
||||
| `defer_trace_finalization` | Instance flag; set automatically from config on `handle_turn()` |
|
||||
| `suppress_flow_events` | Hides console flow panels; **tracing still records** method/flow events |
|
||||
| `stream` | Enable streaming; use with `ChatSession.handle_turn(..., stream=True)` |
|
||||
|
||||
### Methods and properties
|
||||
|
||||
| Name | Description |
|
||||
|------|-------------|
|
||||
| `append_assistant_message(content)` | Append a user-visible assistant reply to `state.messages` |
|
||||
| `append_message(role, content, **extra)` | Lower-level append to `state.messages` |
|
||||
| `conversation_messages` | Read-only history for LLM calls |
|
||||
| `classify_intent(text, outcomes, *, llm, context=None)` | Map text to one outcome (same collapse logic as `@human_feedback`) |
|
||||
| `receive_user_message(text, *, outcomes=None, llm=None)` | Append user message; optionally set `last_intent` |
|
||||
| `finalize_session_traces()` | Emit deferred `flow_finished` and finalize the session trace batch |
|
||||
| `_should_defer_trace_finalization()` | Whether this flow defers per-turn trace finalization |
|
||||
| `input_history` | Audit trail of `ask()` prompts and responses |
|
||||
|
||||
### Module helpers (`crewai.flow.conversation`)
|
||||
|
||||
Importable for tests or custom orchestration:
|
||||
|
||||
| Function | Description |
|
||||
|----------|-------------|
|
||||
| `normalize_kickoff_inputs(inputs, user_message=..., session_id=...)` | Merge conversational kwargs into `inputs` |
|
||||
| `get_conversation_messages(flow)` | Read messages from state or internal buffer |
|
||||
| `append_message(flow, role, content, **extra)` | Same as instance method |
|
||||
| `prepare_conversational_turn(flow, user_message=..., intents=..., intent_llm=..., config=...)` | Lower-level turn hydration for custom wrappers |
|
||||
| `receive_user_message(flow, text, ...)` | Same as instance method |
|
||||
| `set_state_field(flow, name, value)` | Set a field on dict or Pydantic state |
|
||||
| `get_conversational_config(flow)` | Read class `conversational_config` |
|
||||
| `input_history_to_messages(entries)` | Convert `input_history` to LLM message format |
|
||||
|
||||
## Intent routing patterns
|
||||
|
||||
### A. Pre-classify via `ConversationConfig` (simplest)
|
||||
|
||||
Set `default_intents` and `intent_llm`. Each `handle_turn()` runs classification before routing; read `self.state.last_intent` in `route_turn()`.
|
||||
|
||||
### B. Classify inside `route_turn` (richer prompts)
|
||||
|
||||
Set `default_intents=None` so `handle_turn()` only appends the user message. In `route_turn()`, call `classify_intent` with a custom prompt or descriptions:
|
||||
|
||||
```python
|
||||
def route_turn(self, context):
|
||||
intent = self.classify_intent(
|
||||
self._routing_prompt(self.state.current_user_message),
|
||||
("GREETING", "ORDER", "RESEARCH", "GOODBYE"),
|
||||
llm="gpt-4o-mini",
|
||||
)
|
||||
self.state.last_intent = intent
|
||||
return intent
|
||||
```
|
||||
|
||||
Use **`@listen("RESEARCH")`** (or similar) for steps that run `Agent.kickoff()` with tools — not bare `LLM.call()` — when you need web research or multi-step tool use.
|
||||
|
||||
## When the flow finishes but the user keeps chatting
|
||||
|
||||
`FlowFinished` means **this graph run** completed. The conversation continues with another `handle_turn()` and the same `session_id`. `@persist` restores `messages`, flags, and context.
|
||||
|
||||
**Persist pattern:** prefer `@persist` on a **single terminal step** (for example `finalize`) rather than on the whole `Flow` class. Class-level persist saves after every method; `load_state` uses the latest row, which may be a mid-run snapshot (for example right after `bootstrap`) and miss handler updates from the same turn.
|
||||
|
||||
Do **not** use `@human_feedback` for follow-up chat lines unless a human must approve a specific step output before it is shown.
|
||||
|
||||
## Conversational `Flow` (experimental)
|
||||
|
||||
<Warning>
|
||||
**This is an experimental feature.** The conversational `Flow` surface
|
||||
(`conversational = True`, `handle_turn`, `ConversationConfig`,
|
||||
`RouterConfig`, `ConversationState`, the built-in graph + helpers) lives
|
||||
under `crewai.experimental` and may change shape before it graduates.
|
||||
Pin your CrewAI version if you depend on specific behavior, and watch the
|
||||
changelog for breaking updates. Open issues / feedback welcome.
|
||||
</Warning>
|
||||
|
||||
Opt into the conversational chat graph by setting `conversational = True` on a `Flow` subclass. The base `Flow` then ships a built-in `@start` / `@router` / `converse_turn` / `end_conversation` graph, manages `state.messages`, can drive a router LLM, and keeps the trace batch open across turns. You write the **custom routes**; the framework owns the rest.
|
||||
|
||||
Use this when you want a multi-turn chat with a router and per-route handlers without wiring the lifecycle yourself. Use `Flow[ChatState]` (the lower-level pattern above) when you need full control.
|
||||
|
||||
### Quick example
|
||||
|
||||
```python
|
||||
from crewai import Flow
|
||||
from crewai.flow import listen
|
||||
from crewai.experimental.conversational import (
|
||||
ConversationConfig,
|
||||
ConversationState,
|
||||
)
|
||||
|
||||
|
||||
@ConversationConfig(defer_trace_finalization=True)
|
||||
class SupportFlow(Flow[ConversationState]):
|
||||
conversational = True
|
||||
|
||||
def route_turn(self, context: dict) -> str | None:
|
||||
message = (self.state.current_user_message or "").lower()
|
||||
if "search" in message or "news" in message:
|
||||
return "INTERNET_SEARCH"
|
||||
if "docs" in message or "crewai" in message:
|
||||
return "CREWAI_DOCS"
|
||||
return "converse"
|
||||
|
||||
@listen("INTERNET_SEARCH")
|
||||
def handle_internet_search(self) -> str:
|
||||
"""Fresh web research, current news, real-time lookups."""
|
||||
reply = "I would run the web research route here."
|
||||
self.append_assistant_message(reply)
|
||||
return reply
|
||||
|
||||
@listen("CREWAI_DOCS")
|
||||
def handle_crewai_docs(self) -> str:
|
||||
"""Look up the CrewAI documentation for framework/API questions."""
|
||||
reply = "I would look up the CrewAI docs here."
|
||||
self.append_assistant_message(reply)
|
||||
return reply
|
||||
|
||||
|
||||
flow = SupportFlow()
|
||||
try:
|
||||
flow.handle_turn("What can you do?") # routes to converse
|
||||
flow.handle_turn("Search the web for AI news.") # routes to INTERNET_SEARCH
|
||||
flow.handle_turn("Check the CrewAI docs.") # routes to CREWAI_DOCS
|
||||
finally:
|
||||
flow.finalize_session_traces()
|
||||
```
|
||||
|
||||
For a local terminal chat, use `chat()`:
|
||||
|
||||
```python
|
||||
def kickoff() -> None:
|
||||
SupportFlow().chat()
|
||||
```
|
||||
|
||||
`chat()` wraps `handle_turn()` in a REPL, exits on `exit` / `quit`, skips blank lines by default, and calls `finalize_session_traces()` when the session ends.
|
||||
|
||||
### `ConversationConfig`
|
||||
|
||||
Class decorator that attaches per-class chat defaults.
|
||||
|
||||
| Field | Default | Purpose |
|
||||
|-------|---------|---------|
|
||||
| `system_prompt` | `slices.conversational_system_prompt` from i18n | System message used by the built-in `converse_turn`. Pass `""` to opt out entirely. |
|
||||
| `llm` | `None` | Conversation LLM (used by `converse_turn` and as router fallback). |
|
||||
| `router` | `None` | `RouterConfig` for LLM-driven routing. Without it, the flow always falls through to `converse`. |
|
||||
| `answer_from_history_prompt` | Framework default | System message for the optional `answer_from_history` route. |
|
||||
| `answer_from_history_llm` | `None` | Enables the `answer_from_history` short-circuit when set. |
|
||||
| `intent_llm` | `None` | LLM for legacy `intents=`/`default_intents` pre-classification. |
|
||||
| `default_intents` | `None` | Outcome labels for legacy pre-classification. |
|
||||
| `visible_agent_outputs` | `None` | `"all"`, or a list of agent names whose `append_agent_result()` calls should be promoted to public assistant messages. |
|
||||
| `defer_trace_finalization` | `True` | Keep one trace batch open across `handle_turn()` calls. |
|
||||
|
||||
### `RouterConfig` and the auto-built route catalog
|
||||
|
||||
```python
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from crewai import LLM
|
||||
from crewai.experimental.conversational import RouterConfig
|
||||
|
||||
|
||||
class MyRoute(BaseModel):
|
||||
intent: Literal["INTERNET_SEARCH", "CREWAI_DOCS", "converse"]
|
||||
|
||||
|
||||
ROUTER_LLM = LLM(model="gpt-4o-mini")
|
||||
|
||||
router_config = RouterConfig(
|
||||
prompt="Optional domain framing (policy, voice, persona).",
|
||||
response_format=MyRoute, # optional; auto-generated otherwise
|
||||
llm=ROUTER_LLM, # falls back to ConversationConfig.llm
|
||||
routes=["INTERNET_SEARCH", "CREWAI_DOCS"], # optional; inferred from listeners
|
||||
route_descriptions={
|
||||
"INTERNET_SEARCH": "Override the docstring for this one route.",
|
||||
},
|
||||
default_intent="converse", # used when LLM call fails or no LLM available
|
||||
fallback_intent="converse", # used when LLM returns an invalid route
|
||||
intent_field="intent",
|
||||
)
|
||||
```
|
||||
|
||||
The router prompt that gets sent to the LLM is built automatically. For each route the framework picks a description with this precedence:
|
||||
|
||||
1. `RouterConfig.route_descriptions[label]` — explicit override.
|
||||
2. `Flow.builtin_route_descriptions[label]` — framework-canned text for `converse`, `end`, `answer_from_history` (phrased for the router LLM).
|
||||
3. First non-empty line of the `@listen(label)` handler's docstring.
|
||||
4. Empty (the route is listed without a description).
|
||||
|
||||
So in practice, **adding a new route is `@listen("X")` + a one-line docstring**:
|
||||
|
||||
```python
|
||||
from crewai.flow import listen
|
||||
|
||||
|
||||
@listen("INTERNET_SEARCH")
|
||||
def handle_internet_search(self) -> str:
|
||||
"""Fresh web research, current news, real-time lookups."""
|
||||
...
|
||||
```
|
||||
|
||||
…and the router LLM sees:
|
||||
|
||||
```
|
||||
Routes:
|
||||
- CREWAI_DOCS: Look up the CrewAI documentation for framework/API questions.
|
||||
- INTERNET_SEARCH: Fresh web research, current news, real-time lookups.
|
||||
- converse: Ordinary chat, follow-ups, summaries, clarifications…
|
||||
- end: User signals the conversation is finished (goodbye, exit, done).
|
||||
```
|
||||
|
||||
`RouterConfig.prompt` is for **domain framing** (assistant persona, business rules, voice). The route catalog is auto-built — don't list routes in `prompt`; they'll drift the moment you add a handler.
|
||||
|
||||
### Built-in routes
|
||||
|
||||
| Route | Handler | Purpose |
|
||||
|-------|---------|---------|
|
||||
| `converse` | `converse_turn` | Default chat handler. Calls `ConversationConfig.llm` with the system prompt + canonical message history. |
|
||||
| `end` | `end_conversation` | Sets `state.ended = True` and emits a terminator reply. |
|
||||
| `answer_from_history` | `answer_from_history_turn` | Optional. Routes here when `ConversationConfig.answer_from_history_llm` is set and the message can be answered from existing history. |
|
||||
|
||||
You can override any of these by defining a same-named handler in your subclass.
|
||||
|
||||
### `handle_turn()` semantics
|
||||
|
||||
`flow.handle_turn(message)` runs one turn:
|
||||
|
||||
1. Resets per-execution tracking (`_completed_methods`, `_method_outputs`) so the graph re-runs — without this, repeated `kickoff` calls on the same flow instance would short-circuit on turn 2+ because `Flow.kickoff_async` treats `inputs={"id": ...}` as a checkpoint restore.
|
||||
2. Appends the user message to `state.messages`, sets `current_user_message` / `last_user_message`. `last_intent` is **preserved from the prior turn** so the router LLM can use it as a signal.
|
||||
3. Runs `conversation_start` → `route_conversation` → the chosen `@listen` handler.
|
||||
4. The router stores its decision in `state.last_intent` (visible to the next turn's router context).
|
||||
5. If your handler returned a string and didn't already call `append_assistant_message`, `handle_turn` appends it for you.
|
||||
|
||||
Call `handle_turn()` for chat messages. Calling `kickoff(inputs={"id": ...})` directly runs the flow graph without applying the conversational turn wrapper.
|
||||
|
||||
### `chat()` for local REPLs
|
||||
|
||||
`flow.chat()` is the batteries-included terminal wrapper around `handle_turn()`:
|
||||
|
||||
```python
|
||||
flow = SupportFlow()
|
||||
flow.chat()
|
||||
```
|
||||
|
||||
It handles the common local loop:
|
||||
|
||||
1. Prompts for a user message.
|
||||
2. Stops on `exit` / `quit`, `EOFError`, or `KeyboardInterrupt`.
|
||||
3. Calls `handle_turn(message, session_id=...)`.
|
||||
4. Prints the assistant result.
|
||||
5. Finalizes deferred session traces in a `finally` block.
|
||||
|
||||
Customize the terminal behavior with injectable I/O:
|
||||
|
||||
```python
|
||||
flow.chat(
|
||||
session_id="demo-session",
|
||||
prompt="You: ",
|
||||
assistant_prefix="Assistant: ",
|
||||
exit_commands=("exit", "quit", "bye"),
|
||||
)
|
||||
```
|
||||
|
||||
For web apps, background workers, tests, and custom transports, keep using `handle_turn()` directly.
|
||||
|
||||
### Custom router behavior
|
||||
|
||||
To run side effects (event bus setup, telemetry) on every routing decision, override `route_turn`:
|
||||
|
||||
```python
|
||||
from typing import Any
|
||||
|
||||
from crewai import Flow
|
||||
from crewai.experimental.conversational import ConversationState
|
||||
|
||||
|
||||
class SupportFlow(Flow[ConversationState]):
|
||||
conversational = True
|
||||
|
||||
def route_turn(self, context: dict[str, Any]) -> str | None:
|
||||
self.event_bus = MyBus(self)
|
||||
return super().route_turn(context)
|
||||
```
|
||||
|
||||
To bypass the LLM router entirely and pick a route programmatically, return a string from `route_turn`; returning `None` falls back to `_route_with_config(...)`.
|
||||
|
||||
### `append_assistant_message` and `append_agent_result`
|
||||
|
||||
Inside a `@listen(label)` handler, choose:
|
||||
|
||||
- `self.append_assistant_message(text)` — adds a user-visible assistant turn to `state.messages`. The next turn's `converse_turn` sees it.
|
||||
- `self.append_agent_result(agent_name, result, visibility="private")` — records a structured event in `state.events` and a thread in `state.agent_threads[agent_name]`. Public visibility also calls `append_assistant_message` for you. Use private results for scratch work that shouldn't pollute the canonical history.
|
||||
|
||||
`ConversationConfig.visible_agent_outputs` can promote specific agents' private results to public globally (`"all"`, or a list of agent names).
|
||||
|
||||
## Tracing across turns
|
||||
|
||||
With `defer_trace_finalization=True` (default in `ConversationConfig`):
|
||||
|
||||
- **One trace batch** for the whole chat session.
|
||||
- **`flow_started`** on the first turn only; **`flow_finished`** once in `finalize_session_traces()`.
|
||||
- **Per-turn** `kickoff` does not print “Trace batch finalized”.
|
||||
- **Nested work** (`Agent.kickoff()`, crews, Exa tools) appends to the **parent** batch; inner `AgentExecutor` flows do not close the session batch early.
|
||||
|
||||
```python
|
||||
flow.chat(session_id=session_id)
|
||||
```
|
||||
|
||||
`flow.chat()` calls `finalize_session_traces()` for you. When you own the loop
|
||||
with `handle_turn()`, call `finalize_session_traces()` when
|
||||
the session ends.
|
||||
|
||||
`suppress_flow_events=True` only hides Rich console panels; trace and method events still emit for observability.
|
||||
|
||||
### Conversational `Flow` trace lifecycle
|
||||
|
||||
The experimental [conversational `Flow`](#conversational-flow-experimental) uses the same tracing lifecycle: `defer_trace_finalization` defaults to `True`, so each `handle_turn()` keeps the session trace open. Always finalize at the end of the session — wrap your REPL/loop in `try/finally` and call `flow.finalize_session_traces()` on exit. Without it, the trace batch stays open and the final conversation may never export.
|
||||
|
||||
## Streaming
|
||||
|
||||
Set `stream = True` on the `Flow` class. `kickoff(...)` will then emit `assistant_delta` (and related) events through the standard event bus.
|
||||
|
||||
## Imports
|
||||
|
||||
```python
|
||||
from crewai.flow import (
|
||||
ChatState,
|
||||
ConversationalConfig,
|
||||
ConversationalInputs,
|
||||
Flow,
|
||||
listen,
|
||||
persist,
|
||||
router,
|
||||
start,
|
||||
)
|
||||
```
|
||||
|
||||
## See also
|
||||
|
||||
- [Mastering Flow State Management](/en/guides/flows/mastering-flow-state) — persistence, Pydantic state, `@persist`
|
||||
- [Build Your First Flow](/en/guides/flows/first-flow) — flow basics
|
||||
- Demo: `lib/crewai/runner_conversational_flow_simple.py` — minimal REPL with `RESEARCH` + Exa agent
|
||||
@@ -617,6 +617,7 @@ Now that you've built your first flow, you can:
|
||||
3. Explore the `and_` and `or_` functions for more complex parallel execution
|
||||
4. Connect your flow to external APIs, databases, or user interfaces
|
||||
5. Combine multiple specialized crews in a single flow
|
||||
6. Build multi-turn chat apps with [Conversational Flows](/en/guides/flows/conversational-flows) (`kickoff` per message, `ChatSession`, deferred tracing)
|
||||
|
||||
<Check>
|
||||
Congratulations! You've successfully built your first CrewAI Flow that combines regular code, direct LLM calls, and crew-based processing to create a comprehensive guide. These foundational skills enable you to create increasingly sophisticated AI applications that can tackle complex, multi-stage problems through a combination of procedural control and collaborative intelligence.
|
||||
|
||||
@@ -22,6 +22,8 @@ Effective state management enables you to:
|
||||
5. **Scale your applications** - Support complex workflows with proper data organization
|
||||
6. **Enable conversational applications** - Store and access conversation history for context-aware AI interactions
|
||||
|
||||
For multi-turn chat (`kickoff` per user line, `ChatState`, intent routing, deferred tracing, and `ChatSession`), see [Conversational Flows](/en/guides/flows/conversational-flows).
|
||||
|
||||
Let's explore how to leverage these capabilities effectively.
|
||||
|
||||
## State Management Fundamentals
|
||||
|
||||
@@ -141,7 +141,7 @@ crew = Crew(
|
||||
process=Process.sequential, # or Process.hierarchical
|
||||
memory=True,
|
||||
cache=True,
|
||||
embedder={"provider": "openai", "config": {"model": "text-embedding-3-small"}},
|
||||
embedder={"provider": "openai", "config": {"model": "text-embedding-3-large"}},
|
||||
)
|
||||
```
|
||||
|
||||
@@ -173,7 +173,7 @@ write = Task(
|
||||
|
||||
### Memory & embedder config {#memory-embedder-config}
|
||||
|
||||
If `memory=True` and you're not using the default OpenAI embeddings, you must pass an `embedder`:
|
||||
If `memory=True` and you're not using the default OpenAI `text-embedding-3-large` embeddings, you must pass an `embedder`:
|
||||
|
||||
```python
|
||||
crew = Crew(
|
||||
@@ -187,4 +187,4 @@ crew = Crew(
|
||||
)
|
||||
```
|
||||
|
||||
Set the relevant provider credentials (`OPENAI_API_KEY`, `OLLAMA_HOST`, etc.) in your `.env` file. Memory storage paths are project-local by default — delete the project's memory directory if you change embedders, since dimensions don't mix.
|
||||
Set the relevant provider credentials (`OPENAI_API_KEY`, `OLLAMA_HOST`, etc.) in your `.env` file. Memory storage paths are project-local by default. Existing local memory stores created with 1536-dimensional embeddings may not be compatible with the default OpenAI `text-embedding-3-large` embedder, which uses 3072 dimensions. If you hit a dimension mismatch, delete the project's memory directory, run `crewai reset-memories -m`, or explicitly configure the older embedder model until you migrate.
|
||||
|
||||
BIN
docs/images/crewai-otel-collector-datadog.png
Normal file
BIN
docs/images/crewai-otel-collector-datadog.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 455 KiB |
BIN
docs/images/crewai-otel-collector-opentelemetry.png
Normal file
BIN
docs/images/crewai-otel-collector-opentelemetry.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 420 KiB |
@@ -4,6 +4,178 @@ description: "CrewAI의 제품 업데이트, 개선 사항 및 버그 수정"
|
||||
icon: "clock"
|
||||
mode: "wide"
|
||||
---
|
||||
<Update label="2026년 6월 11일">
|
||||
## v1.14.7
|
||||
|
||||
[GitHub 릴리스 보기](https://github.com/crewAIInc/crewAI/releases/tag/1.14.7)
|
||||
|
||||
## 변경 사항
|
||||
|
||||
### 기능
|
||||
- 메모리, 지식, RAG 및 흐름에 대한 플러그 가능한 기본 백엔드를 추가했습니다.
|
||||
- LLM 이벤트에서 실제 finish_reason, 샘플링 매개변수 및 response.id를 표시합니다.
|
||||
- 경로 인식 장식자로서의 타입 DSL 트리거를 설정합니다.
|
||||
- 대화 흐름을 위한 채팅 API를 추가했습니다.
|
||||
- 잠금 백엔드를 재정의 가능하도록 만듭니다.
|
||||
- Flow DSL 메타데이터에서 FlowDefinition을 빌드합니다.
|
||||
- 네이티브 Snowflake Cortex LLM 공급자를 추가했습니다.
|
||||
- 훈련된 에이전트 파일 지원을 추가했습니다.
|
||||
|
||||
### 버그 수정
|
||||
- 복원 시 사용자 정의 BaseLLM을 구체적인 LLM으로 재구성하도록 체크포인트를 수정했습니다.
|
||||
- 라이브 스냅샷이 재개로 재생되지 않도록 플래그를 사용하여 복원을 제한합니다.
|
||||
- 실행마다 런타임 상태의 범위를 설정하여 성장을 제한하고 동시 실행을 격리합니다.
|
||||
- crewai-login에서 텔레메트리 설정을 수정했습니다.
|
||||
- 메서드 실행 이벤트에 대해 suppress_flow_events를 존중합니다.
|
||||
- uv 도구 설치를 위해 crewai 패키지에서 [project.scripts]를 복원합니다.
|
||||
- aiohttp, docling 및 docling-core에 대한 pip-audit CVE를 해결합니다.
|
||||
- 파일 입력이 신뢰할 수 없게 작동하는 문제를 수정했습니다.
|
||||
- Snowflake Claude의 불완전한 도구 결과 기록을 수정했습니다.
|
||||
|
||||
### 문서
|
||||
- v1.14.7에 대한 변경 로그 및 버전을 업데이트했습니다.
|
||||
- OpenTelemetry 수집기 문서를 업데이트했습니다.
|
||||
- NVIDIA Nemotron LLM 가이드를 업데이트했습니다.
|
||||
- Databricks 통합 가이드를 추가했습니다.
|
||||
- Snowflake 통합 가이드를 추가했습니다.
|
||||
|
||||
### 성능
|
||||
- docling 가져오기를 지연 로딩하여 crewai 가져오기 속도를 개선했습니다.
|
||||
|
||||
### 리팩토링
|
||||
- 흐름 조건 평가를 이벤트별로 상태 비저장으로 단순화했습니다.
|
||||
- 대화 논리를 런타임에서 분리하고 conversational_definition을 추가했습니다.
|
||||
- `flow.py`를 DSL, 정의 및 런타임으로 분리했습니다.
|
||||
|
||||
## 기여자
|
||||
|
||||
@Luzk, @alex-clawd, @devin-ai-integration[bot], @greysonlalonde, @gvieira, @jessemiller, @lorenzejay, @lucasgomide, @mattatcha, @vinibrsl
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="2026년 6월 10일">
|
||||
## v1.14.7rc2
|
||||
|
||||
[GitHub 릴리스 보기](https://github.com/crewAIInc/crewAI/releases/tag/1.14.7rc2)
|
||||
|
||||
## 변경 사항
|
||||
|
||||
### 버그 수정
|
||||
- 라이브 스냅샷이 재개로 재생되는 것을 방지하기 위한 플래그에서 게이트 복원
|
||||
|
||||
### 문서
|
||||
- v1.14.7rc1에 대한 변경 로그 및 버전 업데이트
|
||||
|
||||
## 기여자
|
||||
|
||||
@greysonlalonde
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="2026년 6월 10일">
|
||||
## v1.14.7rc1
|
||||
|
||||
[GitHub 릴리스 보기](https://github.com/crewAIInc/crewAI/releases/tag/1.14.7rc1)
|
||||
|
||||
## 변경 사항
|
||||
|
||||
### 기능
|
||||
- 누적된 버스 상태를 해제하기 위해 `reset_runtime_state` 추가
|
||||
- 사용자 정의 프롬프트를 모두 지원하도록 처리
|
||||
- 대화 논리를 런타임과 분리하고 `conversational_definition` 추가
|
||||
|
||||
### 버그 수정
|
||||
- 실행당 런타임 상태의 범위를 수정하여 성장 제한 및 동시 실행 격리
|
||||
- `crewai-login`에서 원격 측정 설정 수정
|
||||
- 메서드 실행 이벤트에 대한 `suppress_flow_events` 존중 수정
|
||||
|
||||
### 문서
|
||||
- OpenTelemetry 이미지 업데이트
|
||||
- OpenTelemetry 수집기의 새로운 상태를 반영하도록 문서 업데이트
|
||||
- v1.14.7a4에 대한 변경 로그 및 버전 업데이트
|
||||
|
||||
### 리팩토링
|
||||
- 이벤트당 상태 비저장 방식으로 흐름 조건 평가 단순화
|
||||
- 경로를 하나 줄여 대화 라우팅 사이클 개선
|
||||
|
||||
## 기여자
|
||||
|
||||
@greysonlalonde, @lorenzejay, @lucasgomide, @vinibrsl
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="2026년 6월 9일">
|
||||
## v1.14.7a4
|
||||
|
||||
[GitHub 릴리스 보기](https://github.com/crewAIInc/crewAI/releases/tag/1.14.7a4)
|
||||
|
||||
## 변경 사항
|
||||
|
||||
### 기능
|
||||
- @listen/@router 런타임을 FlowDefinition에서 읽도록 마이그레이션
|
||||
- 메모리, 지식, rag 및 flow에 대한 플러그형 기본 백엔드 추가
|
||||
|
||||
### 문서
|
||||
- v1.14.7a3에 대한 변경 로그 및 버전 업데이트
|
||||
|
||||
## 기여자
|
||||
|
||||
@greysonlalonde, @mattatcha, @vinibrsl
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="2026년 6월 8일">
|
||||
## v1.14.7a3
|
||||
|
||||
[GitHub 릴리스 보기](https://github.com/crewAIInc/crewAI/releases/tag/1.14.7a3)
|
||||
|
||||
## 변경 사항
|
||||
|
||||
### 버그 수정
|
||||
- 실험적인 `AgentExecutor`에서 `ask_for_human_input` 노출 문제 수정
|
||||
- `aiohttp`, `docling`, `docling-core`, 및 `pip`에 대한 pip-audit CVE 해결
|
||||
|
||||
### 리팩토링
|
||||
- `@start`를 `FlowDefinition`에서 읽도록 마이그레이션
|
||||
|
||||
### 문서화
|
||||
- v1.14.7a2에 대한 변경 로그 및 버전 업데이트
|
||||
|
||||
## 기여자
|
||||
|
||||
@greysonlalonde, @lorenzejay, @vinibrsl
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="2026년 6월 5일">
|
||||
## v1.14.7a2
|
||||
|
||||
[GitHub 릴리스 보기](https://github.com/crewAIInc/crewAI/releases/tag/1.14.7a2)
|
||||
|
||||
## 변경 사항
|
||||
|
||||
### 기능
|
||||
- 대화 흐름 추적 지원 추가.
|
||||
- `handle_turn`을 활용하도록 대화 흐름 문서 업데이트.
|
||||
- LLM 이벤트에서 실제 `finish_reason`, 샘플링 매개변수 및 `response.id` 표시.
|
||||
- 라우트 인식 데코레이터로서 DSL 트리거 유형 지정.
|
||||
- 대화 흐름을 위한 채팅 API 구현.
|
||||
- 잠금 저장소에서 백엔드 잠금 오버라이드 가능하게 설정.
|
||||
- 흐름 DSL 모놀리스를 집중된 데코레이터 모듈로 분할.
|
||||
- `_usage_to_dict`에서 LiteLLM 캐시/추론 사용 하위 카운트 평탄화.
|
||||
- 흐름 DSL 메타데이터에서 `FlowDefinition` 구축.
|
||||
|
||||
### 문서
|
||||
- NVIDIA Nemotron LLM 가이드 추가.
|
||||
- 모노레포 배포 문서화.
|
||||
- v1.14.7a1에 대한 변경 로그 및 버전 업데이트.
|
||||
|
||||
## 기여자
|
||||
|
||||
@alex-clawd, @gvieira, @lorenzejay, @lucasgomide, @mattatcha, @vinibrsl
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="2026년 6월 3일">
|
||||
## v1.14.7a1
|
||||
|
||||
|
||||
@@ -221,6 +221,48 @@ Flow가 실행된 후, 이러한 메소드들에 의해 수행된 업데이트
|
||||
최종 메소드의 출력이 반환되고 상태에 접근할 수 있도록 함으로써, CrewAI Flow는 AI 워크플로우의 결과를 더 큰 애플리케이션이나 시스템에 쉽게 통합할 수 있게 하며,
|
||||
Flow 실행 과정 전반에 걸쳐 상태를 유지하고 접근하면서도 이를 용이하게 만듭니다.
|
||||
|
||||
## 플로우 사용 메트릭
|
||||
|
||||
Flow 실행이 완료된 후, `usage_metrics` 속성에 접근하여 실행 동안 발생한 **모든 LLM 호출**의 토큰 사용량 집계를 확인할 수 있습니다. 여기에는 Flow가 오케스트레이션한 모든 Crew의 호출, Agent의 도구 내부에서 발생한 호출, 그리고 Flow 메서드에서 직접 호출한 `LLM.call(...)`이 모두 포함됩니다. 이는 CrewAI Enterprise UI에 표시되는 총량과 동등한 SDK 측 값입니다.
|
||||
|
||||
```python Code
|
||||
from crewai import LLM
|
||||
from crewai.flow.flow import Flow, listen, start
|
||||
|
||||
class UsageMetricsFlow(Flow):
|
||||
@start()
|
||||
def run_first_crew(self):
|
||||
self.state.first_result = FirstCrew().crew().kickoff()
|
||||
|
||||
@listen(run_first_crew)
|
||||
def call_llm_directly(self):
|
||||
# 직접 LLM 호출 — flow.usage_metrics에서도 집계됩니다
|
||||
llm = LLM(model="openai/gpt-4o-mini")
|
||||
self.state.summary = llm.call("핵심 내용을 요약해 주세요.")
|
||||
|
||||
@listen(call_llm_directly)
|
||||
def run_second_crew(self):
|
||||
self.state.second_result = SecondCrew().crew().kickoff()
|
||||
|
||||
flow = UsageMetricsFlow()
|
||||
flow.kickoff()
|
||||
|
||||
print(flow.usage_metrics)
|
||||
# UsageMetrics(total_tokens=8579, prompt_tokens=6210, completion_tokens=2369,
|
||||
# cached_prompt_tokens=0, reasoning_tokens=0,
|
||||
# cache_creation_tokens=0, successful_requests=5)
|
||||
```
|
||||
|
||||
<Note>
|
||||
`flow.usage_metrics`는 `flow.kickoff().token_usage`와 **동일하지 않습니다**.
|
||||
후자는 `CrewOutput`을 반환한 **마지막** `@listen` 메서드의
|
||||
`CrewOutput.token_usage`만 반환하므로, 이전에 실행된 Crew들과 Flow 메서드에서
|
||||
직접 호출한 `LLM.call(...)`은 전혀 포함되지 않습니다. Flow 실행에 대한
|
||||
**전체** 토큰 집계가 필요할 때는 항상 `flow.usage_metrics`를 사용하십시오.
|
||||
</Note>
|
||||
|
||||
반환되는 [`UsageMetrics`](https://github.com/crewAIInc/crewAI/blob/main/lib/crewai/src/crewai/types/usage_metrics.py)의 각 항목은 단일 `flow.kickoff()` 실행 동안 발생한 모든 LLM 호출의 합계입니다. 다음 `kickoff()` 호출(및 `kickoff_for_each`의 각 반복)에서 카운터가 초기화되므로 연속 실행이 이중으로 집계되지 않습니다. 이 속성은 `kickoff()` 완료 후 언제든지 안전하게 읽을 수 있으며, 실행 중에 읽으면 그 시점까지 누적된 부분 합계를 반환합니다.
|
||||
|
||||
## 플로우 상태 관리
|
||||
|
||||
상태를 효과적으로 관리하는 것은 신뢰할 수 있고 유지 보수가 용이한 AI 워크플로를 구축하는 데 매우 중요합니다. CrewAI 플로우는 비정형 및 정형 상태 관리를 위한 강력한 메커니즘을 제공하여, 개발자가 자신의 애플리케이션에 가장 적합한 접근 방식을 선택할 수 있도록 합니다.
|
||||
|
||||
@@ -24,15 +24,39 @@ CrewAI AMP는 배포에서 OpenTelemetry **트레이스**와 **로그**를 자
|
||||
|
||||
1. CrewAI AMP에서 **Settings** > **OpenTelemetry Collectors**로 이동합니다.
|
||||
2. **Add Collector**를 클릭합니다.
|
||||
3. 통합 유형을 선택합니다 — **OpenTelemetry Traces** 또는 **OpenTelemetry Logs**.
|
||||
4. 연결을 구성합니다:
|
||||
- **Endpoint** — 수집기의 OTLP 엔드포인트 (예: `https://otel-collector.example.com:4317`).
|
||||
- **Service Name** — 관측 가능성 플랫폼에서 이 서비스를 식별하기 위한 이름.
|
||||
- **Custom Headers** *(선택 사항)* — 인증 또는 라우팅 헤더를 키-값 쌍으로 추가합니다.
|
||||
- **Certificate** *(선택 사항)* — 수집기에서 TLS 인증서가 필요한 경우 제공합니다.
|
||||
5. **Save**를 클릭합니다.
|
||||
3. 통합을 선택합니다:
|
||||
- **OpenTelemetry Traces** 및 **OpenTelemetry Logs** — OTLP 호환 수집기 또는 백엔드로 내보냅니다.
|
||||
- **Datadog** — 별도의 수집기나 Datadog Agent 없이 트레이스를 Datadog의 OTLP 인테이크로 직접 전송합니다.
|
||||
4. 연결을 구성합니다. 필드는 선택한 통합에 따라 달라집니다:
|
||||
|
||||
<Frame></Frame>
|
||||
<Tabs>
|
||||
<Tab title="OpenTelemetry Traces / Logs">
|
||||
**OpenTelemetry Traces**와 **OpenTelemetry Logs**는 동일한 필드를 공유하는 별개의 통합입니다 — 내보내려는 신호에 맞는 것을 선택하세요.
|
||||
|
||||
- **Endpoint** — 수집기의 OTLP 엔드포인트 (예: `https://otel-collector.example.com:4317`).
|
||||
- **Service Name** — 관측 가능성 플랫폼에서 이 서비스를 식별하기 위한 이름.
|
||||
- **Custom Headers** *(선택 사항)* — 인증 또는 라우팅 헤더를 키-값 쌍으로 추가합니다.
|
||||
- **Certificate** *(선택 사항)* — 수집기에서 TLS 인증서가 필요한 경우 제공합니다.
|
||||
|
||||
<Frame></Frame>
|
||||
</Tab>
|
||||
<Tab title="Datadog">
|
||||
- **Datadog Site Domain** — Datadog 사이트의 OTLP 호스트만 입력합니다 (프로토콜이나 경로 제외). CrewAI가 전체 HTTPS OTLP 엔드포인트를 자동으로 구성합니다. [Datadog 사이트](https://docs.datadoghq.com/getting_started/site/)에 맞는 호스트를 사용하세요:
|
||||
- `otlp.datadoghq.com` (US1)
|
||||
- `otlp.us3.datadoghq.com` (US3)
|
||||
- `otlp.us5.datadoghq.com` (US5)
|
||||
- `otlp.datadoghq.eu` (EU1)
|
||||
- `otlp.ap1.datadoghq.com` (AP1)
|
||||
- **API Key** — Datadog API 키입니다. [키 생성 방법](https://docs.datadoghq.com/account_management/api-app-keys/#api-keys)을 참고하세요.
|
||||
|
||||
Datadog 통합은 **트레이스**를 내보냅니다.
|
||||
|
||||
<Frame></Frame>
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
5. *(선택 사항)* **Test Connection**을 클릭하여 제공한 자격 증명으로 CrewAI가 엔드포인트에 연결할 수 있는지 확인합니다.
|
||||
6. **Save**를 클릭합니다.
|
||||
|
||||
<Tip>
|
||||
여러 수집기를 추가할 수 있습니다 — 예를 들어, 트레이스용 하나와 로그용 하나를 추가하거나, 다른 목적을 위해 다른 백엔드로 전송할 수 있습니다.
|
||||
|
||||
@@ -163,6 +163,12 @@ Crew를 GitHub 저장소에 푸시해야 합니다. 아직 Crew를 만들지 않
|
||||

|
||||
</Frame>
|
||||
|
||||
<Tip>
|
||||
Crew 또는 Flow가 모노레포 하위 폴더 안에 있다면 배포 전에
|
||||
**Advanced**를 펼치고 작업 디렉터리를 설정하세요.
|
||||
[모노레포 배포](/ko/enterprise/guides/monorepo-deployments)를 참조하세요.
|
||||
</Tip>
|
||||
|
||||
</Step>
|
||||
|
||||
<Step title="환경 변수 설정하기">
|
||||
@@ -440,4 +446,4 @@ type = "flow"
|
||||
|
||||
<Card title="도움이 필요하신가요?" icon="headset" href="mailto:support@crewai.com">
|
||||
배포 문제 또는 AMP 플랫폼에 대한 문의 사항이 있으시면 지원팀에 연락해 주세요.
|
||||
</Card>
|
||||
</Card>
|
||||
|
||||
222
docs/ko/enterprise/guides/monorepo-deployments.mdx
Normal file
222
docs/ko/enterprise/guides/monorepo-deployments.mdx
Normal file
@@ -0,0 +1,222 @@
|
||||
---
|
||||
title: "모노레포 배포"
|
||||
description: "더 큰 저장소의 하위 폴더에서 Crew 또는 Flow 배포하기"
|
||||
icon: "folder-tree"
|
||||
mode: "wide"
|
||||
---
|
||||
|
||||
<Note>
|
||||
Crew 또는 Flow가 더 큰 저장소 안에 있을 때 작업 디렉터리를 사용하세요.
|
||||
CrewAI AMP는 저장소 루트 대신 해당 하위 폴더에서 자동화를 검증, 빌드,
|
||||
실행합니다.
|
||||
</Note>
|
||||
|
||||
## 사용 시점
|
||||
|
||||
모노레포 배포는 하나의 저장소에 여러 자동화, 공유 패키지 또는 다른 애플리케이션
|
||||
코드가 함께 있을 때 유용합니다:
|
||||
|
||||
```text
|
||||
company-ai/
|
||||
|-- uv.lock
|
||||
|-- packages/
|
||||
| `-- shared_tools/
|
||||
`-- crews/
|
||||
|-- support_agent/
|
||||
| |-- pyproject.toml
|
||||
| `-- src/
|
||||
| `-- support_agent/
|
||||
| |-- main.py
|
||||
| `-- crew.py
|
||||
`-- research_flow/
|
||||
|-- pyproject.toml
|
||||
`-- src/
|
||||
`-- research_flow/
|
||||
`-- main.py
|
||||
```
|
||||
|
||||
`support_agent`를 배포하려면 작업 디렉터리를 다음과 같이 설정합니다:
|
||||
|
||||
```text
|
||||
crews/support_agent
|
||||
```
|
||||
|
||||
AMP는 여전히 전체 저장소를 가져오거나 업로드하지만, 선택한 폴더를 자동화
|
||||
프로젝트 루트로 처리합니다.
|
||||
|
||||
## 작업 디렉터리가 제어하는 항목
|
||||
|
||||
작업 디렉터리가 설정되면 AMP는 해당 폴더를 다음 용도로 사용합니다:
|
||||
|
||||
- `pyproject.toml`, `src/`, Crew 또는 Flow 진입점을 포함한 프로젝트 검증
|
||||
- `uv`를 사용한 종속성 설치
|
||||
- 실행 중인 프로세스의 작업 디렉터리
|
||||
- `CREW_ROOT_DIR` 환경 변수
|
||||
|
||||
필드를 비워 두면 기존 동작이 유지되며 저장소 루트를 사용합니다.
|
||||
|
||||
## 지원되는 소스
|
||||
|
||||
다음 소스에서 배포를 만들 때 작업 디렉터리를 설정할 수 있습니다:
|
||||
|
||||
- 연결된 GitHub 저장소
|
||||
- AMP에 구성된 Git 저장소
|
||||
- ZIP 업로드
|
||||
|
||||
<Info>
|
||||
작업 디렉터리는 AMP 웹 인터페이스에서 구성하세요.
|
||||
`crewai deploy create` CLI 흐름은 이 필드를 묻지 않습니다.
|
||||
</Info>
|
||||
|
||||
기존 배포의 **Settings** 페이지에서도 작업 디렉터리를 추가하거나 변경할 수
|
||||
있습니다. 변경 사항은 다음 배포부터 적용됩니다.
|
||||
|
||||
<Warning>
|
||||
작업 디렉터리와 auto-deploy는 함께 사용할 수 없습니다. 배포에 작업
|
||||
디렉터리가 설정되어 있으면 해당 배포의 auto-deploy가 비활성화됩니다.
|
||||
작업 디렉터리를 설정하기 전에 auto-deploy를 끄세요.
|
||||
</Warning>
|
||||
|
||||
## 새 배포 구성
|
||||
|
||||
<Steps>
|
||||
<Step title="Deploy from Code 열기">
|
||||
CrewAI AMP에서 새 배포를 만들고 소스를 선택합니다: GitHub, Git
|
||||
Repository 또는 ZIP 업로드.
|
||||
</Step>
|
||||
|
||||
<Step title="저장소, 브랜치 또는 ZIP 파일 선택">
|
||||
모노레포가 들어 있는 저장소와 브랜치를 선택하거나, 루트에 모노레포 내용이
|
||||
포함된 ZIP 파일을 업로드합니다.
|
||||
</Step>
|
||||
|
||||
<Step title="고급 설정 열기">
|
||||
배포 양식에서 **Advanced** 섹션을 펼칩니다.
|
||||
</Step>
|
||||
|
||||
<Step title="작업 디렉터리 입력">
|
||||
저장소 루트에서 Crew 또는 Flow 프로젝트까지의 경로를 입력합니다:
|
||||
|
||||
```text
|
||||
crews/support_agent
|
||||
```
|
||||
|
||||
앞에 슬래시를 붙이지 마세요.
|
||||
</Step>
|
||||
|
||||
<Step title="배포">
|
||||
필요한 환경 변수를 추가한 다음 배포를 시작합니다.
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
## 기존 배포 구성
|
||||
|
||||
<Steps>
|
||||
<Step title="배포 설정 열기">
|
||||
AMP에서 자동화로 이동한 뒤 **Settings**를 엽니다.
|
||||
</Step>
|
||||
|
||||
<Step title="필요한 경우 auto-deploy 끄기">
|
||||
auto-deploy가 활성화되어 있으면 먼저 끄세요. auto-deploy가 켜져 있는
|
||||
동안에는 작업 디렉터리 필드를 사용할 수 없습니다.
|
||||
</Step>
|
||||
|
||||
<Step title="작업 디렉터리 설정">
|
||||
**Basic settings**에서 다음과 같은 하위 폴더 경로를 입력합니다:
|
||||
|
||||
```text
|
||||
crews/support_agent
|
||||
```
|
||||
</Step>
|
||||
|
||||
<Step title="다시 배포">
|
||||
설정을 저장하고 자동화를 다시 배포합니다. 새 작업 디렉터리는 다음 배포부터
|
||||
사용됩니다.
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
## 경로 규칙
|
||||
|
||||
작업 디렉터리는 저장소 또는 ZIP 루트 안의 상대 경로여야 합니다.
|
||||
|
||||
| 규칙 | 예시 |
|
||||
|------|------|
|
||||
| 상대 경로를 사용합니다 | `crews/support_agent` |
|
||||
| `/`로 시작하지 않습니다 | `/crews/support_agent`는 유효하지 않습니다 |
|
||||
| `.` 또는 `..` 경로 세그먼트를 사용하지 않습니다 | `crews/../support_agent`는 유효하지 않습니다 |
|
||||
| 문자, 숫자, 하이픈, 밑줄, 점, 슬래시만 사용합니다 | `crews/support agent`는 유효하지 않습니다 |
|
||||
| 경로는 255자 이하로 유지합니다 | 더 긴 경로는 거부됩니다 |
|
||||
|
||||
AMP는 앞뒤 공백을 제거하고, 반복된 슬래시를 하나로 줄이며, 끝의 슬래시를
|
||||
제거합니다. 빈 값은 저장소 루트를 사용합니다.
|
||||
|
||||
## Lock 파일과 UV 워크스페이스
|
||||
|
||||
선택한 폴더에는 자동화의 `pyproject.toml`과 `src/` 디렉터리가 있어야
|
||||
합니다. `uv.lock` 또는 `poetry.lock` 파일은 선택한 폴더나 저장소 루트에
|
||||
둘 수 있습니다.
|
||||
|
||||
이 방식은 일반적인 두 가지 모노레포 레이아웃을 모두 지원합니다:
|
||||
|
||||
<Tabs>
|
||||
<Tab title="프로젝트 lock 파일">
|
||||
```text
|
||||
company-ai/
|
||||
`-- crews/
|
||||
`-- support_agent/
|
||||
|-- pyproject.toml
|
||||
|-- uv.lock
|
||||
`-- src/
|
||||
`-- support_agent/
|
||||
`-- main.py
|
||||
```
|
||||
</Tab>
|
||||
|
||||
<Tab title="워크스페이스 lock 파일">
|
||||
```text
|
||||
company-ai/
|
||||
|-- uv.lock
|
||||
|-- packages/
|
||||
| `-- shared_tools/
|
||||
`-- crews/
|
||||
`-- support_agent/
|
||||
|-- pyproject.toml
|
||||
`-- src/
|
||||
`-- support_agent/
|
||||
`-- main.py
|
||||
```
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
<Tip>
|
||||
자동화가 모노레포의 다른 위치에 있는 공유 패키지를 가져온다면, UV
|
||||
workspace, path 또는 source 설정을 사용해 해당 패키지를 `pyproject.toml`에
|
||||
선언하세요. AMP는 선택한 폴더에서 자동화를 실행하므로, 저장소 루트가
|
||||
Python path에 있다고 가정하기보다 공유 코드를 종속성으로 설치해야 합니다.
|
||||
</Tip>
|
||||
|
||||
## 문제 해결
|
||||
|
||||
### 작업 디렉터리를 찾을 수 없음
|
||||
|
||||
경로가 저장소 또는 ZIP 루트를 기준으로 한 상대 경로인지 확인하세요. ZIP
|
||||
업로드의 경우 ZIP 내용에 입력한 작업 디렉터리 경로가 정확히 포함되어야 합니다.
|
||||
|
||||
### pyproject.toml 누락
|
||||
|
||||
작업 디렉터리는 여러 프로젝트를 담은 상위 폴더가 아니라 Crew 또는 Flow 프로젝트
|
||||
폴더를 가리켜야 합니다.
|
||||
|
||||
### uv.lock 또는 poetry.lock 누락
|
||||
|
||||
선택한 프로젝트 폴더 또는 저장소 루트에 lock 파일을 커밋하세요. UV
|
||||
워크스페이스의 경우 `uv.lock`을 워크스페이스 루트에 두는 방식이 지원됩니다.
|
||||
|
||||
### Auto-Deploy를 사용할 수 없음
|
||||
|
||||
작업 디렉터리가 설정되어 있으면 auto-deploy가 비활성화됩니다. 수동 재배포를
|
||||
사용하거나 AMP API로 CI/CD에서 재배포를 트리거하세요.
|
||||
|
||||
<Card title="AMP에 배포하기" icon="rocket" href="/ko/enterprise/guides/deploy-to-amp">
|
||||
모노레포 작업 디렉터리를 선택한 뒤 배포 가이드를 계속 진행하세요.
|
||||
</Card>
|
||||
@@ -161,6 +161,18 @@ crew = Crew(
|
||||
)
|
||||
```
|
||||
|
||||
<Note>
|
||||
`agent.i18n`은 이전 버전과의 호환성을 위해서만 유지되며 사용이 중단될 예정입니다. 런타임 프롬프트 커스터마이징에는 `Crew`에 `prompt_file`을 전달하세요. 프롬프트 슬라이스를 코드에서 직접 읽어야 한다면 i18n 유틸리티를 직접 사용하세요:
|
||||
</Note>
|
||||
|
||||
```python
|
||||
from crewai.utilities.i18n import get_i18n
|
||||
|
||||
i18n = get_i18n("custom_prompts.json")
|
||||
format_slice = i18n.slice("format")
|
||||
tool_prompt = i18n.tools("ask_question")
|
||||
```
|
||||
|
||||
#### 옵션 3: o1 모델에 대한 시스템 프롬프트 비활성화
|
||||
```python
|
||||
agent = Agent(
|
||||
@@ -208,6 +220,8 @@ agent = Agent(
|
||||
|
||||
그러면 CrewAI가 기본값과 사용자가 지정한 내용을 병합하므로, 모든 프롬프트를 다시 정의할 필요가 없습니다. 방법은 다음과 같습니다:
|
||||
|
||||
프롬프트 슬라이스를 코드에서 직접 읽어야 하는 경우에는 `agent.i18n`을 읽는 대신 동일한 프롬프트 파일로 `crewai.utilities.i18n.get_i18n()`을 사용하세요.
|
||||
|
||||
### 예시: 기본 프롬프트 커스터마이징
|
||||
|
||||
수정하고 싶은 프롬프트를 포함하는 `custom_prompts.json` 파일을 생성하세요. 변경 사항만이 아니라 포함해야 하는 모든 최상위 프롬프트를 반드시 나열해야 합니다:
|
||||
@@ -314,4 +328,4 @@ CrewAI에서의 저수준 prompt 커스터마이제이션은 매우 맞춤화되
|
||||
|
||||
<Check>
|
||||
이제 CrewAI에서 고급 prompt 커스터마이징을 위한 기초를 갖추었습니다. 모델별 구조나 도메인별 제약에 맞춰 적용하든, 이러한 저수준 접근 방식은 agent 상호작용을 매우 전문적으로 조정할 수 있게 해줍니다.
|
||||
</Check>
|
||||
</Check>
|
||||
|
||||
474
docs/ko/guides/flows/conversational-flows.mdx
Normal file
474
docs/ko/guides/flows/conversational-flows.mdx
Normal file
@@ -0,0 +1,474 @@
|
||||
---
|
||||
title: 대화형 Flow
|
||||
description: 턴마다 kickoff, 메시지 기록, 의도 라우팅, 트레이싱, WebSocket 브리지로 멀티턴 채팅 앱을 만듭니다.
|
||||
icon: comments
|
||||
mode: "wide"
|
||||
---
|
||||
|
||||
## 개요
|
||||
|
||||
대화형 앱은 각 사용자 입력을 **동일한 세션 id**로 **새 flow 실행**으로 처리합니다. CrewAI는 메시지 기록, 선택적 의도 분류, 지연 트레이싱, UI 브리지, 그리고 대화형 flow용 로컬 `flow.chat()` REPL을 제공합니다.
|
||||
|
||||
| 개념 | 구현 |
|
||||
|------|------|
|
||||
| 세션 id | `handle_turn(..., session_id=...)` → `kickoff(inputs={"id": ...})` → `state.id` |
|
||||
| 사용자 입력 | `handle_turn(message)`가 그래프 실행 전 `state.messages`에 추가 |
|
||||
| 턴 완료 | `FlowFinished`는 **이번 실행**만 의미; 다음 `handle_turn`로 대화 계속 |
|
||||
| 세션 전체 트레이스 | `ConversationConfig(defer_trace_finalization=True)` + `finalize_session_traces()` |
|
||||
|
||||
## 턴 API
|
||||
|
||||
REST, WebSocket, 테스트, 커스텀 UI에서 오는 모든 사용자 메시지에는 **`flow.handle_turn(message, session_id=...)`**를 사용하세요. 대화형 `Flow`를 로컬 터미널 채팅 루프로 실행하고 싶을 때는 **`flow.chat()`**을 사용하세요.
|
||||
|
||||
`Flow.kickoff()`는 `user_message=` 또는 `session_id=` 키워드 인자를 받지 않습니다. 대화형 flow에서는 `handle_turn()`이 보류 중인 메시지를 저장하고 내부적으로 `kickoff(inputs={"id": session_id})`를 호출합니다.
|
||||
|
||||
| API | 용도 |
|
||||
|-----|------|
|
||||
| `handle_turn(message, session_id=...)` | 대화형 `Flow`용 한 턴 편의 래퍼 |
|
||||
| `chat()` | 대화형 `Flow`용 로컬 터미널 REPL |
|
||||
| `kickoff(inputs={...})` | 대화형 턴 처리 없이 flow를 직접 실행 |
|
||||
| `ask()` | 한 스텝 **내부** 블로킹 프롬프트 (마법사, 확인) |
|
||||
| `@human_feedback` | **스텝 출력** 승인/거부 — 다음 채팅 줄이 아님 |
|
||||
| `ChatSession.handle_turn(...)` | `handle_turn` 위의 전송 계층 (SSE / WebSocket) |
|
||||
|
||||
## 빠른 시작
|
||||
|
||||
```python
|
||||
from uuid import uuid4
|
||||
|
||||
from crewai import Flow
|
||||
from crewai.flow import listen
|
||||
from crewai.experimental.conversational import (
|
||||
ConversationConfig,
|
||||
ConversationState,
|
||||
)
|
||||
|
||||
|
||||
@ConversationConfig(defer_trace_finalization=True)
|
||||
class SupportFlow(Flow[ConversationState]):
|
||||
conversational = True
|
||||
|
||||
def route_turn(self, context):
|
||||
message = self.state.current_user_message or ""
|
||||
if "주문" in message or "order" in message.lower():
|
||||
return "order"
|
||||
if "안녕" in message or "goodbye" in message.lower():
|
||||
return "goodbye"
|
||||
return "help"
|
||||
|
||||
@listen("order")
|
||||
def handle_order(self):
|
||||
reply = "주문이 배송 중입니다."
|
||||
self.append_assistant_message(reply)
|
||||
return reply
|
||||
|
||||
@listen("help")
|
||||
def handle_help(self):
|
||||
reply = "무엇을 도와드릴까요?"
|
||||
self.append_assistant_message(reply)
|
||||
return reply
|
||||
|
||||
@listen("goodbye")
|
||||
def handle_goodbye(self):
|
||||
reply = "안녕히 가세요!"
|
||||
self.append_assistant_message(reply)
|
||||
return reply
|
||||
|
||||
|
||||
session_id = str(uuid4())
|
||||
flow = SupportFlow()
|
||||
|
||||
try:
|
||||
flow.handle_turn("주문 어디까지 왔나요?", session_id=session_id)
|
||||
flow.handle_turn("반품은 어떻게 하나요?", session_id=session_id)
|
||||
finally:
|
||||
flow.finalize_session_traces() # 전체 대화에 대한 단일 trace 링크
|
||||
```
|
||||
|
||||
## 턴 생명주기
|
||||
|
||||
각 `handle_turn`은 다음 파이프라인을 실행합니다:
|
||||
|
||||
1. **`_configure_conversational_kickoff`** — `session_id` / `user_message`를 `inputs`에 병합, `ConversationalConfig` 적용, 설정 시 지연 트레이싱 활성화.
|
||||
2. **상태 복원** — `inputs["id"]`가 있고 `@persist`가 설정되면 최신 스냅샷 로드.
|
||||
3. **`FlowStarted`** — 지연 세션의 첫 턴에서만 발생.
|
||||
4. **`prepare_conversational_turn`** — 사용자 메시지를 `state.messages`에 추가, `last_user_message` 설정, `last_intent` 초기화, `intents` / `default_intents` + `intent_llm` 설정 시 분류.
|
||||
5. **그래프 실행** — `@start` → `@router` → `@listen` 핸들러.
|
||||
6. **실행 종료** — 지연 활성화 시 턴별 `flow_finished` 및 trace 종료 **건너뜀**; 중첩 `Agent.kickoff()` / crew도 부모 batch를 닫지 않음.
|
||||
|
||||
핸들러는 **`append_assistant_message(reply)`**를 호출해 다음 턴의 `conversation_messages`에 어시스턴트 응답이 포함되게 하세요. 사용자 입력은 `handle_turn`이 이미 저장합니다 — 핸들러에서 다시 추가하지 마세요.
|
||||
|
||||
## `ConversationalConfig` (클래스 수준 기본값)
|
||||
|
||||
`Flow` 서브클래스에 `conversational_config: ClassVar[ConversationalConfig | None]`로 설정합니다.
|
||||
|
||||
| 필드 | 기본값 | 목적 |
|
||||
|------|--------|------|
|
||||
| `default_intents` | `None` | kickoff 전 자동 분류용 outcome 라벨 |
|
||||
| `intent_llm` | `None` | 분류용 모델 (intent 사용 시 필수) |
|
||||
| `interactive_prompt` | `"You: "` | `kickoff(interactive=True)` 프롬프트 |
|
||||
| `interactive_timeout` | `None` | 대화형 모드 줄 단위 타임아웃 |
|
||||
| `exit_commands` | `exit`, `quit` | 대화형 모드 종료 단어 |
|
||||
| `defer_trace_finalization` | `True` | 턴 간 하나의 trace batch 유지 |
|
||||
|
||||
`intents=` 및 `intent_llm=` 키워드로 kickoff마다 재정의할 수 있습니다.
|
||||
|
||||
## `ChatState` (권장 persist 형태)
|
||||
|
||||
```python
|
||||
from crewai.flow import ChatState
|
||||
|
||||
|
||||
class MyChatState(ChatState):
|
||||
# 상속: id, messages, last_user_message, last_intent, session_ready
|
||||
research_turn_count: int = 0
|
||||
custom_flag: bool = False
|
||||
```
|
||||
|
||||
| 필드 | 역할 |
|
||||
|------|------|
|
||||
| `id` | 세션 UUID (`session_id` / `inputs["id"]`와 동일) |
|
||||
| `messages` | LLM 기록용 `{role, content}` 리스트 |
|
||||
| `last_user_message` | 이번 턴의 최신 사용자 입력 |
|
||||
| `last_intent` | 분류 후 라우트 라벨 (사용 시) |
|
||||
| `session_ready` | 일회성 bootstrap 플래그 |
|
||||
|
||||
`ConversationalInputs`는 `kickoff(inputs={...})`용 `TypedDict`: `id`, `user_message`, `last_intent`.
|
||||
|
||||
## `Flow` 대화 API
|
||||
|
||||
### `kickoff` / `kickoff_async` 파라미터
|
||||
|
||||
| 파라미터 | 목적 |
|
||||
|----------|------|
|
||||
| `user_message` | 이번 턴 텍스트 (또는 `{"role": "user", "content": "..."}`) |
|
||||
| `session_id` | 대화 UUID → `inputs["id"]` / `state.id` |
|
||||
| `intents` | kickoff 전 `classify_intent`용 outcome 라벨 |
|
||||
| `intent_llm` | 분류 LLM (`intents`와 함께 필수) |
|
||||
| `interactive` | `ask()` CLI 루프 (로컬 데모 전용) |
|
||||
| `interactive_prompt` | 대화형 모드 프롬프트 |
|
||||
| `interactive_timeout` | 줄 단위 `ask()` 타임아웃 |
|
||||
| `exit_commands` | 대화형 모드 종료 단어 |
|
||||
| `inputs` | 추가 상태 필드 |
|
||||
| `restore_from_state_id` | 다른 persist flow에서 fork 복원 |
|
||||
|
||||
### 인스턴스 속성
|
||||
|
||||
| 속성 | 목적 |
|
||||
|------|------|
|
||||
| `conversational_config` | 클래스 수준 `ConversationalConfig` |
|
||||
| `defer_trace_finalization` | 인스턴스 플래그; kickoff 시 config에서 자동 설정 |
|
||||
| `suppress_flow_events` | 콘솔 flow 패널 숨김; **트레이싱은 계속 기록** |
|
||||
| `stream` | 스트리밍; `ChatSession.handle_turn(..., stream=True)`와 함께 |
|
||||
|
||||
### 메서드 및 프로퍼티
|
||||
|
||||
| 이름 | 설명 |
|
||||
|------|------|
|
||||
| `append_message(role, content, **extra)` | `state.messages`에 추가 |
|
||||
| `conversation_messages` | LLM 호출용 읽기 전용 기록 |
|
||||
| `classify_intent(text, outcomes, *, llm, context=None)` | outcome 매핑 (`@human_feedback`와 동일 collapse) |
|
||||
| `receive_user_message(text, *, outcomes=None, llm=None)` | 사용자 메시지 추가; 선택적 `last_intent` |
|
||||
| `finalize_session_traces()` | 지연 `flow_finished` 발생 및 세션 trace batch 종료 |
|
||||
| `_should_defer_trace_finalization()` | 턴별 trace 종료 지연 여부 |
|
||||
| `input_history` | `ask()` 프롬프트/응답 감사 기록 |
|
||||
|
||||
### 모듈 헬퍼 (`crewai.flow.conversation`)
|
||||
|
||||
테스트 또는 커스텀 오케스트레이션용:
|
||||
|
||||
| 함수 | 설명 |
|
||||
|------|------|
|
||||
| `normalize_kickoff_inputs(...)` | 대화 kwargs를 `inputs`에 병합 |
|
||||
| `get_conversation_messages(flow)` | 상태 또는 내부 버퍼에서 메시지 읽기 |
|
||||
| `append_message(flow, ...)` | 인스턴스 메서드와 동일 |
|
||||
| `prepare_conversational_turn(flow, ...)` | 턴 수화 (보통 kickoff가 호출) |
|
||||
| `receive_user_message(flow, ...)` | 인스턴스 메서드와 동일 |
|
||||
| `set_state_field(flow, name, value)` | dict 또는 Pydantic 상태 필드 설정 |
|
||||
| `get_conversational_config(flow)` | 클래스 `conversational_config` 읽기 |
|
||||
| `input_history_to_messages(entries)` | `input_history`를 LLM 메시지 형식으로 |
|
||||
|
||||
## 의도 라우팅 패턴
|
||||
|
||||
### A. `ConversationalConfig`로 사전 분류 (가장 단순)
|
||||
|
||||
`default_intents`와 `intent_llm` 설정. 각 kickoff가 `@router` 전에 분류; `route()`에서 `self.state.last_intent` 읽기.
|
||||
|
||||
### B. `@router` 내부에서 분류 (풍부한 프롬프트)
|
||||
|
||||
`default_intents=None`으로 kickoff는 메시지만 추가. `route()`에서 커스텀 프롬프트로 `classify_intent` 호출:
|
||||
|
||||
```python
|
||||
@router(bootstrap)
|
||||
def route(self):
|
||||
intent = self.classify_intent(
|
||||
self._routing_prompt(self.state.last_user_message),
|
||||
("GREETING", "ORDER", "RESEARCH", "GOODBYE"),
|
||||
llm=self.conversational_config.intent_llm or "gpt-4o-mini",
|
||||
)
|
||||
self.state.last_intent = intent
|
||||
return intent
|
||||
```
|
||||
|
||||
웹 리서치나 다단계 tool이 필요하면 **`@listen("RESEARCH")`** 등에서 `Agent.kickoff()`와 tool 사용 — 단순 `LLM.call()` 대신.
|
||||
|
||||
## flow가 끝났지만 사용자는 계속 대화할 때
|
||||
|
||||
`FlowFinished`는 **이번 그래프 실행**이 완료됨을 의미합니다. 같은 `session_id`로 또 다른 `kickoff`로 대화가 이어집니다. `@persist`가 `messages`, 플래그, 컨텍스트를 복원합니다.
|
||||
|
||||
**Persist 패턴:** 전체 `Flow` 클래스보다 **단일 종료 스텝**(예: `finalize`)에 `@persist`를 두는 것이 좋습니다. 클래스 수준 persist는 매 메서드 후 저장하며, `load_state`는 최신 행을 사용해 같은 턴의 핸들러 업데이트를 놓칠 수 있습니다.
|
||||
|
||||
후속 채팅 줄에 `@human_feedback`를 쓰지 마세요. 특정 스텝 출력을 사람이 승인해야 할 때만 사용하세요.
|
||||
|
||||
## 대화형 `Flow` (실험적)
|
||||
|
||||
<Warning>
|
||||
**실험적 기능입니다.** 대화형 `Flow`의 API 표면(`conversational = True`,
|
||||
`handle_turn`, `ConversationConfig`, `RouterConfig`, `ConversationState`,
|
||||
내장 그래프와 헬퍼)은 `crewai.experimental` 하위에 있으며 정식 출시
|
||||
전까지 변경될 수 있습니다. 특정 동작에 의존한다면 CrewAI 버전을 고정하고
|
||||
변경 사항이 있는지 changelog를 확인하세요. 피드백과 이슈 환영합니다.
|
||||
</Warning>
|
||||
|
||||
`Flow` 서브클래스에 `conversational = True`를 지정하면 대화형 챗 그래프가 활성화됩니다. 베이스 `Flow`가 `@start` / `@router` / `converse_turn` / `end_conversation` 그래프를 노출하고, `state.messages`를 관리하며, router LLM을 구동하고, 턴 간 trace 배치를 열린 상태로 유지합니다. 여러분은 **커스텀 라우트**만 작성하면 되고, 나머지는 프레임워크가 담당합니다.
|
||||
|
||||
LLM 기반 라우터와 라우트별 핸들러로 멀티턴 챗을 만들고 싶지만 라이프사이클을 직접 배선하고 싶지 않을 때 사용하세요. 완전한 제어가 필요하면 위의 `Flow[ChatState]`로 내려가세요.
|
||||
|
||||
### 빠른 예제
|
||||
|
||||
```python
|
||||
from crewai import LLM, Flow
|
||||
from crewai.flow import listen
|
||||
from crewai.experimental.conversational import (
|
||||
ConversationConfig,
|
||||
ConversationState,
|
||||
RouterConfig,
|
||||
)
|
||||
|
||||
|
||||
ROUTER_LLM = LLM(model="gpt-4o-mini")
|
||||
|
||||
|
||||
@ConversationConfig(
|
||||
system_prompt="A multi-agent assistant for ordinary chat and tool-backed tasks.",
|
||||
llm=ROUTER_LLM,
|
||||
router=RouterConfig(), # 라우트 + 설명은 @listen 핸들러에서 자동 발견
|
||||
)
|
||||
class SupportFlow(Flow[ConversationState]):
|
||||
conversational = True
|
||||
|
||||
@listen("INTERNET_SEARCH")
|
||||
def handle_internet_search(self) -> str:
|
||||
"""Fresh web research, current news, real-time lookups."""
|
||||
...
|
||||
self.append_assistant_message(reply)
|
||||
return reply
|
||||
|
||||
@listen("CREWAI_DOCS")
|
||||
def handle_crewai_docs(self) -> str:
|
||||
"""Look up the CrewAI documentation for framework/API questions."""
|
||||
...
|
||||
self.append_assistant_message(reply)
|
||||
return reply
|
||||
|
||||
|
||||
flow = SupportFlow()
|
||||
try:
|
||||
flow.handle_turn("뭘 할 수 있어?") # converse(빌트인)로 라우팅
|
||||
flow.handle_turn("AI 뉴스를 웹에서 찾아줘.") # INTERNET_SEARCH로 라우팅
|
||||
flow.handle_turn("첫 번째 결과를 요약해줘.") # 다시 converse로 라우팅
|
||||
finally:
|
||||
flow.finalize_session_traces()
|
||||
```
|
||||
|
||||
로컬 터미널 채팅에는 `chat()`을 사용하세요:
|
||||
|
||||
```python
|
||||
def kickoff() -> None:
|
||||
SupportFlow().chat()
|
||||
```
|
||||
|
||||
`chat()`은 `handle_turn()`을 REPL로 감싸고, `exit` / `quit`에서 종료하며, 기본적으로 빈 줄을 건너뛰고, 세션이 끝날 때 `finalize_session_traces()`를 호출합니다.
|
||||
|
||||
### `ConversationConfig`
|
||||
|
||||
클래스 단위의 챗 기본값을 부착하는 클래스 데코레이터입니다.
|
||||
|
||||
| 필드 | 기본값 | 목적 |
|
||||
|------|--------|------|
|
||||
| `system_prompt` | i18n `slices.conversational_system_prompt` | 빌트인 `converse_turn`이 사용하는 system 메시지. 빈 문자열(`""`)을 전달하면 system 메시지를 끕니다. |
|
||||
| `llm` | `None` | 대화용 LLM (빌트인 `converse_turn`이 사용하고 router 폴백도 됨). |
|
||||
| `router` | `None` | LLM 기반 라우팅을 위한 `RouterConfig`. 없으면 항상 `converse`로 떨어집니다. |
|
||||
| `answer_from_history_prompt` | 프레임워크 기본값 | 선택적인 `answer_from_history` 라우트용 system 메시지. |
|
||||
| `answer_from_history_llm` | `None` | 설정되면 `answer_from_history` 단축 경로가 활성화됩니다. |
|
||||
| `intent_llm` | `None` | 레거시 `intents=`/`default_intents` 사전 분류용 LLM. |
|
||||
| `default_intents` | `None` | 레거시 사전 분류용 outcome 레이블. |
|
||||
| `visible_agent_outputs` | `None` | `"all"` 또는 `append_agent_result()` 결과를 사용자에게 공개로 승격할 에이전트 이름 목록. |
|
||||
| `defer_trace_finalization` | `True` | `handle_turn()` 호출들 사이에서 하나의 trace 배치를 열어 둡니다. |
|
||||
|
||||
### `RouterConfig`와 자동 생성되는 라우트 카탈로그
|
||||
|
||||
```python
|
||||
RouterConfig(
|
||||
prompt="선택적인 도메인 프레이밍 (정책, 톤, 페르소나).",
|
||||
response_format=MyRoute, # 선택; 없으면 자동 생성
|
||||
llm=ROUTER_LLM, # ConversationConfig.llm으로 폴백
|
||||
routes=["INTERNET_SEARCH", "CREWAI_DOCS"], # 선택; 리스너에서 추론
|
||||
route_descriptions={
|
||||
"INTERNET_SEARCH": "이 라우트만 docstring 대신 사용할 설명.",
|
||||
},
|
||||
default_intent="converse", # LLM 호출 실패 또는 LLM 없음일 때 사용
|
||||
fallback_intent="converse", # LLM이 잘못된 라우트를 반환할 때 사용
|
||||
intent_field="intent",
|
||||
)
|
||||
```
|
||||
|
||||
router에 전달되는 프롬프트는 자동으로 만들어집니다. 각 라우트의 설명은 다음 우선순위로 결정됩니다:
|
||||
|
||||
1. `RouterConfig.route_descriptions[label]` — 명시적 오버라이드.
|
||||
2. `Flow.builtin_route_descriptions[label]` — `converse`, `end`, `answer_from_history`용 프레임워크 캐닝 텍스트 (router LLM용으로 다듬어진 문구).
|
||||
3. `@listen(label)` 핸들러 docstring의 첫 줄(비어있지 않은 줄).
|
||||
4. 빈 문자열 (라우트만 카탈로그에 등장하고 설명은 없음).
|
||||
|
||||
실제 사용에서 **새 라우트를 추가하는 방법은 `@listen("X")` + 한 줄짜리 docstring**입니다:
|
||||
|
||||
```python
|
||||
@listen("INTERNET_SEARCH")
|
||||
def handle_internet_search(self) -> str:
|
||||
"""Fresh web research, current news, real-time lookups."""
|
||||
...
|
||||
```
|
||||
|
||||
…그러면 router LLM은 다음을 봅니다:
|
||||
|
||||
```
|
||||
Routes:
|
||||
- CREWAI_DOCS: Look up the CrewAI documentation for framework/API questions.
|
||||
- INTERNET_SEARCH: Fresh web research, current news, real-time lookups.
|
||||
- converse: Ordinary chat, follow-ups, summaries, clarifications…
|
||||
- end: User signals the conversation is finished (goodbye, exit, done).
|
||||
```
|
||||
|
||||
`RouterConfig.prompt`는 **도메인 프레이밍** (어시스턴트 페르소나, 비즈니스 규칙, 톤)을 위한 자리입니다. 라우트 카탈로그는 자동 생성되니 `prompt` 안에 라우트 목록을 넣지 마세요. 핸들러를 추가하는 순간 동기화가 깨집니다.
|
||||
|
||||
### 빌트인 라우트
|
||||
|
||||
| 라우트 | 핸들러 | 목적 |
|
||||
|--------|--------|------|
|
||||
| `converse` | `converse_turn` | 기본 챗 핸들러. system prompt + 정식 메시지 히스토리와 함께 `ConversationConfig.llm`을 호출합니다. |
|
||||
| `end` | `end_conversation` | `state.ended = True`로 설정하고 종료 응답을 보냅니다. |
|
||||
| `answer_from_history` | `answer_from_history_turn` | 선택적. `ConversationConfig.answer_from_history_llm`이 설정되어 있고 메시지를 히스토리만으로 답할 수 있을 때 라우팅됩니다. |
|
||||
|
||||
서브클래스에 같은 이름의 핸들러를 정의하면 어떤 것이든 오버라이드할 수 있습니다.
|
||||
|
||||
### `handle_turn()` 시맨틱
|
||||
|
||||
`flow.handle_turn(message)`는 한 턴을 실행합니다:
|
||||
|
||||
1. 그래프가 다시 실행되도록 턴 단위 실행 추적(`_completed_methods`, `_method_outputs`)을 초기화합니다 — 이게 없으면 동일 인스턴스에서 반복 `kickoff` 호출 시 `Flow.kickoff_async`가 `inputs={"id": ...}`를 체크포인트 복원으로 간주해 2번째 턴부터 단락 회로가 발생합니다.
|
||||
2. 사용자 메시지를 `state.messages`에 추가하고 `current_user_message` / `last_user_message`를 설정합니다. `last_intent`는 **이전 턴 값이 유지**되어 router LLM이 신호로 활용할 수 있습니다.
|
||||
3. `conversation_start` → `route_conversation` → 선택된 `@listen` 핸들러 순으로 실행됩니다.
|
||||
4. router는 결정을 `state.last_intent`에 저장합니다 (다음 턴의 router 컨텍스트에서 보입니다).
|
||||
5. 핸들러가 문자열을 반환했지만 `append_assistant_message`를 직접 호출하지 않았다면, `handle_turn`이 대신 추가해 줍니다.
|
||||
|
||||
채팅 메시지에는 `handle_turn()`을 호출하세요. `kickoff(inputs={"id": ...})`를 직접 호출하면 대화형 턴 래퍼 없이 flow 그래프가 실행됩니다.
|
||||
|
||||
### 로컬 REPL용 `chat()`
|
||||
|
||||
`flow.chat()`은 `handle_turn()` 위에 얹은 바로 쓸 수 있는 터미널 래퍼입니다:
|
||||
|
||||
```python
|
||||
flow = SupportFlow()
|
||||
flow.chat()
|
||||
```
|
||||
|
||||
일반적인 로컬 루프를 처리합니다:
|
||||
|
||||
1. 사용자 메시지를 입력받습니다.
|
||||
2. `exit` / `quit`, `EOFError`, `KeyboardInterrupt`에서 멈춥니다.
|
||||
3. `handle_turn(message, session_id=...)`를 호출합니다.
|
||||
4. 어시스턴트 결과를 출력합니다.
|
||||
5. `finally` 블록에서 지연된 세션 trace를 finalize합니다.
|
||||
|
||||
주입 가능한 I/O로 터미널 동작을 커스터마이즈할 수 있습니다:
|
||||
|
||||
```python
|
||||
flow.chat(
|
||||
session_id="demo-session",
|
||||
prompt="You: ",
|
||||
assistant_prefix="Assistant: ",
|
||||
exit_commands=("exit", "quit", "bye"),
|
||||
)
|
||||
```
|
||||
|
||||
웹 앱, 백그라운드 worker, 테스트, 커스텀 transport에서는 계속 `handle_turn()`을 직접 사용하세요.
|
||||
|
||||
### 커스텀 router 동작
|
||||
|
||||
매 라우팅 결정마다 사이드 이펙트(이벤트 버스 셋업, 텔레메트리)를 실행하려면 `route_turn`을 오버라이드하세요:
|
||||
|
||||
```python
|
||||
class SupportFlow(Flow[ConversationState]):
|
||||
conversational = True
|
||||
|
||||
def route_turn(self, context: dict[str, Any]) -> str | None:
|
||||
self.event_bus = MyBus(self)
|
||||
return super().route_turn(context)
|
||||
```
|
||||
|
||||
LLM router를 우회해 프로그램적으로 라우트를 선택하려면 `route_turn`에서 문자열을 반환하세요. `None`을 반환하면 `_route_with_config(...)`로 떨어집니다.
|
||||
|
||||
### `append_assistant_message`와 `append_agent_result`
|
||||
|
||||
`@listen(label)` 핸들러 안에서 두 가지 중 선택하세요:
|
||||
|
||||
- `self.append_assistant_message(text)` — 사용자에게 보이는 어시스턴트 턴을 `state.messages`에 추가합니다. 다음 턴의 `converse_turn`이 이 내용을 보게 됩니다.
|
||||
- `self.append_agent_result(agent_name, result, visibility="private")` — 구조화된 이벤트를 `state.events`에, 스레드를 `state.agent_threads[agent_name]`에 기록합니다. public 가시성은 자동으로 `append_assistant_message`도 호출합니다. 정식 히스토리를 더럽히지 말아야 할 임시 작업에는 private을 쓰세요.
|
||||
|
||||
`ConversationConfig.visible_agent_outputs`로 특정 에이전트의 private 결과를 전역적으로 public으로 승격할 수 있습니다 (`"all"` 또는 이름 리스트).
|
||||
|
||||
## 턴 간 트레이싱
|
||||
|
||||
`defer_trace_finalization=True` (`ConversationalConfig` 기본값):
|
||||
|
||||
- 채팅 세션 전체에 **하나의 trace batch**.
|
||||
- 첫 턴에만 **`flow_started`**; `finalize_session_traces()`에서 **`flow_finished`** 한 번.
|
||||
- 턴별 `kickoff`는 “Trace batch finalized”를 출력하지 않음.
|
||||
- **중첩 작업** (`Agent.kickoff()`, crew, Exa tool)은 **부모** batch에 추가; 내부 `AgentExecutor` flow가 세션 batch를 조기 종료하지 않음.
|
||||
|
||||
```python
|
||||
flow.chat(session_id=session_id)
|
||||
```
|
||||
|
||||
`flow.chat()`이 `finalize_session_traces()`를 대신 호출합니다. `handle_turn()`이나 `kickoff(...)`로 직접 루프를 소유하는 경우, 세션이 끝날 때 `finalize_session_traces()`를 호출하세요.
|
||||
|
||||
`suppress_flow_events=True`는 Rich 콘솔 패널만 숨깁니다. trace 및 method 이벤트는 계속 발생합니다.
|
||||
|
||||
### 대화형 `Flow` trace 수명 주기
|
||||
|
||||
실험적 [대화형 `Flow`](#대화형-flow-실험적)는 동일한 tracing 수명 주기를 따릅니다. `defer_trace_finalization` 기본값이 `True`이므로 각 `handle_turn()`이 세션 trace를 열어 둡니다. 세션 끝에서 항상 finalize하세요 — REPL/루프를 `try/finally`로 감싸고 종료 시 `flow.finalize_session_traces()`를 호출하세요. 호출하지 않으면 batch가 열린 채 남아 마지막 대화가 export되지 않을 수 있습니다.
|
||||
|
||||
## 스트리밍
|
||||
|
||||
`Flow` 클래스에 `stream = True`. `kickoff(...)`가 표준 이벤트 버스를 통해 `assistant_delta` 등 이벤트를 발생시킵니다.
|
||||
|
||||
## import
|
||||
|
||||
```python
|
||||
from crewai.flow import (
|
||||
ChatState,
|
||||
ConversationalConfig,
|
||||
ConversationalInputs,
|
||||
Flow,
|
||||
listen,
|
||||
persist,
|
||||
router,
|
||||
start,
|
||||
)
|
||||
```
|
||||
|
||||
## 참고
|
||||
|
||||
- [Flow 상태 관리 마스터하기](/ko/guides/flows/mastering-flow-state)
|
||||
- [첫 Flow 만들기](/ko/guides/flows/first-flow)
|
||||
- 데모: `lib/crewai/runner_conversational_flow_simple.py`
|
||||
@@ -607,6 +607,7 @@ result = ContentCrew().crew().kickoff(inputs={
|
||||
3. 더 복잡한 병렬 실행을 위해 `and_` 및 `or_` 함수를 탐색해 보세요.
|
||||
4. flow를 외부 API, 데이터베이스 또는 사용자 인터페이스에 연결해 보세요.
|
||||
5. 여러 전문화된 crew를 하나의 flow에서 결합해 보세요.
|
||||
6. [대화형 Flow](/ko/guides/flows/conversational-flows)로 멀티턴 채팅 앱 구축 (`kickoff` per message, `ChatSession`, 지연 트레이싱)
|
||||
|
||||
<Check>
|
||||
축하합니다! 정규 코드, 직접적인 LLM 호출, crew 기반 처리를 결합하여 포괄적인 가이드를 생성하는 첫 번째 CrewAI Flow를 성공적으로 구축하셨습니다. 이러한 기초적인 역량을 바탕으로 절차적 제어와 협업적 인텔리전스를 결합하여 복잡하고 다단계의 문제를 해결할 수 있는 점점 더 정교한 AI 애플리케이션을 만들 수 있습니다.
|
||||
|
||||
@@ -22,6 +22,8 @@ State 관리는 모든 고급 AI 워크플로우의 중추입니다. CrewAI Flow
|
||||
5. **애플리케이션 확장** - 적절한 데이터 조직을 통해 복잡한 워크플로를 지원할 수 있습니다.
|
||||
6. **대화형 애플리케이션 활성화** - 컨텍스트 기반 AI 상호작용을 위해 대화 내역을 저장하고 접근할 수 있습니다.
|
||||
|
||||
멀티턴 채팅(`kickoff` per user line, `ChatState`, 의도 라우팅, 지연 트레이싱, `ChatSession`)은 [대화형 Flow](/ko/guides/flows/conversational-flows)를 참고하세요.
|
||||
|
||||
이러한 기능을 효과적으로 활용하는 방법을 살펴보겠습니다.
|
||||
|
||||
## 상태 관리 기본 사항
|
||||
|
||||
@@ -4,6 +4,178 @@ description: "Atualizações de produto, melhorias e correções do CrewAI"
|
||||
icon: "clock"
|
||||
mode: "wide"
|
||||
---
|
||||
<Update label="11 jun 2026">
|
||||
## v1.14.7
|
||||
|
||||
[Ver release no GitHub](https://github.com/crewAIInc/crewAI/releases/tag/1.14.7)
|
||||
|
||||
## O que Mudou
|
||||
|
||||
### Recursos
|
||||
- Adicionar backends padrão plugáveis para memória, conhecimento, rag e fluxo.
|
||||
- Exibir o verdadeiro finish_reason, parâmetros de amostragem e response.id em eventos LLM.
|
||||
- Tipar os gatilhos DSL como decoradores cientes de rotas.
|
||||
- Adicionar API de chat para fluxos de conversa.
|
||||
- Tornar o backend de bloqueio substituível.
|
||||
- Construir FlowDefinition a partir de metadados Flow DSL.
|
||||
- Adicionar provedor nativo Snowflake Cortex LLM.
|
||||
- Adicionar suporte a arquivos de agentes treinados pela equipe.
|
||||
|
||||
### Correções de Bugs
|
||||
- Corrigir checkpoint para reconstruir BaseLLM personalizado como LLM concreto na restauração.
|
||||
- Controlar a restauração com uma flag para evitar que snapshots ao vivo sejam reproduzidos como retomar.
|
||||
- Escopar o estado de execução por execução para limitar o crescimento e isolar execuções concorrentes.
|
||||
- Corrigir configuração de telemetria no crewai-login.
|
||||
- Respeitar suppress_flow_events para eventos de execução de método.
|
||||
- Restaurar [project.scripts] no pacote crewai para instalação da ferramenta uv.
|
||||
- Resolver CVEs de pip-audit para aiohttp, docling e docling-core.
|
||||
- Corrigir entrada de arquivo que não estava funcionando de forma confiável.
|
||||
- Corrigir histórias de resultados de ferramentas incompletas do Snowflake Claude.
|
||||
|
||||
### Documentação
|
||||
- Atualizar changelog e versão para v1.14.7.
|
||||
- Atualizar documentação do coletor OpenTelemetry.
|
||||
- Atualizar guia do LLM NVIDIA Nemotron.
|
||||
- Adicionar guia de integração do Databricks.
|
||||
- Adicionar guia de integração do Snowflake.
|
||||
|
||||
### Desempenho
|
||||
- Melhorar a velocidade de importação do crewai através do carregamento preguiçoso de imports do docling.
|
||||
|
||||
### Refatoração
|
||||
- Simplificar a avaliação de condições de fluxo para ser sem estado por evento.
|
||||
- Desacoplar a lógica de conversa da execução e adicionar uma conversational_definition.
|
||||
- Dividir `flow.py` em DSL, definição e execução.
|
||||
|
||||
## Contribuidores
|
||||
|
||||
@Luzk, @alex-clawd, @devin-ai-integration[bot], @greysonlalonde, @gvieira, @jessemiller, @lorenzejay, @lucasgomide, @mattatcha, @vinibrsl
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="10 jun 2026">
|
||||
## v1.14.7rc2
|
||||
|
||||
[Ver release no GitHub](https://github.com/crewAIInc/crewAI/releases/tag/1.14.7rc2)
|
||||
|
||||
## O que Mudou
|
||||
|
||||
### Correções de Bugs
|
||||
- Restauração de portão em uma flag para evitar que snapshots ao vivo sejam reproduzidos como retomar
|
||||
|
||||
### Documentação
|
||||
- Atualizar changelog e versão para v1.14.7rc1
|
||||
|
||||
## Contributors
|
||||
|
||||
@greysonlalonde
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="10 jun 2026">
|
||||
## v1.14.7rc1
|
||||
|
||||
[Ver release no GitHub](https://github.com/crewAIInc/crewAI/releases/tag/1.14.7rc1)
|
||||
|
||||
## O que Mudou
|
||||
|
||||
### Recursos
|
||||
- Adicionar `reset_runtime_state` para liberar o estado acumulado do barramento
|
||||
- Lidar com suporte a ambos os prompts personalizados
|
||||
- Desacoplar a lógica de conversa do tempo de execução e adicionar uma `conversational_definition`
|
||||
|
||||
### Correções de Bugs
|
||||
- Corrigir o escopo do estado de tempo de execução por execução para limitar o crescimento e isolar execuções concorrentes
|
||||
- Corrigir a configuração de telemetria em `crewai-login`
|
||||
- Corrigir o respeito a `suppress_flow_events` para eventos de execução de método
|
||||
|
||||
### Documentação
|
||||
- Atualizar imagens do OpenTelemetry
|
||||
- Atualizar a documentação para refletir o novo estado do coletor OpenTelemetry
|
||||
- Atualizar o changelog e a versão para v1.14.7a4
|
||||
|
||||
### Refatoração
|
||||
- Simplificar a avaliação da condição de fluxo para ser sem estado por evento
|
||||
- Melhorar o ciclo de roteamento de conversas com uma rota a menos
|
||||
|
||||
## Contribuidores
|
||||
|
||||
@greysonlalonde, @lorenzejay, @lucasgomide, @vinibrsl
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="09 jun 2026">
|
||||
## v1.14.7a4
|
||||
|
||||
[Ver release no GitHub](https://github.com/crewAIInc/crewAI/releases/tag/1.14.7a4)
|
||||
|
||||
## O Que Mudou
|
||||
|
||||
### Funcionalidades
|
||||
- Migrar a execução @listen/@router para ler a partir de FlowDefinition
|
||||
- Adicionar backends padrão plugáveis para memória, conhecimento, rag e flow
|
||||
|
||||
### Documentação
|
||||
- Atualizar changelog e versão para v1.14.7a3
|
||||
|
||||
## Contributors
|
||||
|
||||
@greysonlalonde, @mattatcha, @vinibrsl
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="08 jun 2026">
|
||||
## v1.14.7a3
|
||||
|
||||
[Ver release no GitHub](https://github.com/crewAIInc/crewAI/releases/tag/1.14.7a3)
|
||||
|
||||
## O que Mudou
|
||||
|
||||
### Correções de Bugs
|
||||
- Corrigir a exposição de `ask_for_human_input` no `AgentExecutor` experimental
|
||||
- Resolver CVEs do pip-audit para `aiohttp`, `docling`, `docling-core` e `pip`
|
||||
|
||||
### Refatoração
|
||||
- Migrar `@start` para ler de `FlowDefinition`
|
||||
|
||||
### Documentação
|
||||
- Atualizar o changelog e a versão para v1.14.7a2
|
||||
|
||||
## Contribuidores
|
||||
|
||||
@greysonlalonde, @lorenzejay, @vinibrsl
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="05 jun 2026">
|
||||
## v1.14.7a2
|
||||
|
||||
[Ver release no GitHub](https://github.com/crewAIInc/crewAI/releases/tag/1.14.7a2)
|
||||
|
||||
## O que Mudou
|
||||
|
||||
### Recursos
|
||||
- Adicionar suporte a rastreamentos de fluxo de conversa.
|
||||
- Atualizar a documentação do fluxo de conversa para utilizar `handle_turn`.
|
||||
- Exibir o real `finish_reason`, parâmetros de amostragem e `response.id` em eventos LLM.
|
||||
- Tipar os gatilhos DSL como decoradores cientes de rota.
|
||||
- Implementar API de chat para fluxos de conversa.
|
||||
- Tornar o backend de bloqueio substituível no armazenamento de bloqueios.
|
||||
- Dividir o monólito DSL de fluxo em módulos de decoradores focados.
|
||||
- Achatar os subcontagens de uso de cache/razão do LiteLLM em `_usage_to_dict`.
|
||||
- Construir `FlowDefinition` a partir dos metadados do Flow DSL.
|
||||
|
||||
### Documentação
|
||||
- Adicionar guia do LLM NVIDIA Nemotron.
|
||||
- Documentar implantações de monorepo.
|
||||
- Atualizar changelog e versão para v1.14.7a1.
|
||||
|
||||
## Contribuidores
|
||||
|
||||
@alex-clawd, @gvieira, @lorenzejay, @lucasgomide, @mattatcha, @vinibrsl
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="03 jun 2026">
|
||||
## v1.14.7a1
|
||||
|
||||
|
||||
@@ -219,6 +219,49 @@ Após o término da execução, é possível acessar o estado final e observar a
|
||||
Ao garantir que a saída do método final seja retornada e oferecer acesso ao estado, o CrewAI Flows facilita a integração dos resultados dos seus workflows de IA em aplicações maiores,
|
||||
além de permitir o gerenciamento e o acesso ao estado durante toda a execução do Flow.
|
||||
|
||||
## Métricas de Uso do Flow
|
||||
|
||||
Após a execução de um Flow, você pode acessar a propriedade `usage_metrics` para visualizar o consumo agregado de tokens em **todas as chamadas de LLM** realizadas durante a execução — incluindo chamadas das Crews orquestradas pelo Flow, chamadas dentro de tools de Agents, e invocações diretas de `LLM.call(...)` feitas a partir de métodos do Flow. Esse é o equivalente, do lado do SDK, ao total exibido na interface do CrewAI Enterprise.
|
||||
|
||||
```python Code
|
||||
from crewai import LLM
|
||||
from crewai.flow.flow import Flow, listen, start
|
||||
|
||||
class UsageMetricsFlow(Flow):
|
||||
@start()
|
||||
def run_first_crew(self):
|
||||
self.state.first_result = FirstCrew().crew().kickoff()
|
||||
|
||||
@listen(run_first_crew)
|
||||
def call_llm_directly(self):
|
||||
# Chamada direta de LLM — também contabilizada por flow.usage_metrics
|
||||
llm = LLM(model="openai/gpt-4o-mini")
|
||||
self.state.summary = llm.call("Resuma os principais pontos.")
|
||||
|
||||
@listen(call_llm_directly)
|
||||
def run_second_crew(self):
|
||||
self.state.second_result = SecondCrew().crew().kickoff()
|
||||
|
||||
flow = UsageMetricsFlow()
|
||||
flow.kickoff()
|
||||
|
||||
print(flow.usage_metrics)
|
||||
# UsageMetrics(total_tokens=8579, prompt_tokens=6210, completion_tokens=2369,
|
||||
# cached_prompt_tokens=0, reasoning_tokens=0,
|
||||
# cache_creation_tokens=0, successful_requests=5)
|
||||
```
|
||||
|
||||
<Note>
|
||||
`flow.usage_metrics` **não** é o mesmo que `flow.kickoff().token_usage`. Este
|
||||
último retorna apenas o `CrewOutput.token_usage` do **último** método
|
||||
`@listen` que retornou um `CrewOutput`, ou seja, reflete somente a Crew
|
||||
final e ignora completamente as Crews anteriores e quaisquer chamadas
|
||||
diretas de `LLM.call(...)`. Use `flow.usage_metrics` sempre que precisar do
|
||||
rollup **completo** de tokens da execução do Flow.
|
||||
</Note>
|
||||
|
||||
Cada campo do [`UsageMetrics`](https://github.com/crewAIInc/crewAI/blob/main/lib/crewai/src/crewai/types/usage_metrics.py) retornado representa a soma de todas as chamadas de LLM feitas em uma única invocação de `flow.kickoff()`. Os contadores são resetados a cada novo `kickoff()` (e em cada iteração de `kickoff_for_each`), de modo que execuções sucessivas não duplicam o total. A propriedade é segura para ser lida em qualquer momento após o `kickoff()`; lê-la durante a execução retorna o total parcial acumulado até aquele instante.
|
||||
|
||||
## Gerenciamento de Estado em Flows
|
||||
|
||||
Gerenciar o estado de forma eficaz é fundamental para construir fluxos de trabalho de IA confiáveis e de fácil manutenção. O CrewAI Flows oferece mecanismos robustos para o gerenciamento de estado tanto não estruturado quanto estruturado,
|
||||
|
||||
@@ -24,15 +24,39 @@ Os dados de telemetria seguem as [convenções semânticas GenAI do OpenTelemetr
|
||||
|
||||
1. No CrewAI AMP, vá para **Settings** > **OpenTelemetry Collectors**.
|
||||
2. Clique em **Add Collector**.
|
||||
3. Selecione um tipo de integração — **OpenTelemetry Traces** ou **OpenTelemetry Logs**.
|
||||
4. Configure a conexão:
|
||||
- **Endpoint** — O endpoint OTLP do seu coletor (por exemplo, `https://otel-collector.example.com:4317`).
|
||||
- **Service Name** — Um nome para identificar este serviço na sua plataforma de observabilidade.
|
||||
- **Custom Headers** *(opcional)* — Adicione headers de autenticação ou roteamento como pares chave-valor.
|
||||
- **Certificate** *(opcional)* — Forneça um certificado TLS se o seu coletor exigir um.
|
||||
5. Clique em **Save**.
|
||||
3. Selecione uma integração:
|
||||
- **OpenTelemetry Traces** e **OpenTelemetry Logs** — exporte para qualquer coletor ou backend compatível com OTLP.
|
||||
- **Datadog** — envie traces diretamente para a ingestão OTLP do Datadog, sem precisar de um coletor separado ou do Datadog Agent.
|
||||
4. Configure a conexão. Os campos dependem da integração selecionada:
|
||||
|
||||
<Frame></Frame>
|
||||
<Tabs>
|
||||
<Tab title="OpenTelemetry Traces / Logs">
|
||||
**OpenTelemetry Traces** e **OpenTelemetry Logs** são integrações separadas que compartilham os mesmos campos — escolha a que corresponde ao sinal que você quer exportar.
|
||||
|
||||
- **Endpoint** — O endpoint OTLP do seu coletor (por exemplo, `https://otel-collector.example.com:4317`).
|
||||
- **Service Name** — Um nome para identificar este serviço na sua plataforma de observabilidade.
|
||||
- **Custom Headers** *(opcional)* — Adicione headers de autenticação ou roteamento como pares chave-valor.
|
||||
- **Certificate** *(opcional)* — Forneça um certificado TLS se o seu coletor exigir um.
|
||||
|
||||
<Frame></Frame>
|
||||
</Tab>
|
||||
<Tab title="Datadog">
|
||||
- **Datadog Site Domain** — Apenas o host OTLP do seu site Datadog, sem protocolo ou caminho. O CrewAI monta o endpoint HTTPS OTLP completo para você. Use o host correspondente ao seu [site Datadog](https://docs.datadoghq.com/getting_started/site/):
|
||||
- `otlp.datadoghq.com` (US1)
|
||||
- `otlp.us3.datadoghq.com` (US3)
|
||||
- `otlp.us5.datadoghq.com` (US5)
|
||||
- `otlp.datadoghq.eu` (EU1)
|
||||
- `otlp.ap1.datadoghq.com` (AP1)
|
||||
- **API Key** — Sua chave de API do Datadog. Veja [como criar uma](https://docs.datadoghq.com/account_management/api-app-keys/#api-keys).
|
||||
|
||||
A integração com o Datadog exporta **traces**.
|
||||
|
||||
<Frame></Frame>
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
5. *(opcional)* Clique em **Test Connection** para verificar se o CrewAI consegue acessar o endpoint com as credenciais fornecidas.
|
||||
6. Clique em **Save**.
|
||||
|
||||
<Tip>
|
||||
Você pode adicionar múltiplos coletores — por exemplo, um para traces e outro para logs, ou enviar para diferentes backends para diferentes propósitos.
|
||||
|
||||
@@ -163,6 +163,12 @@ Você precisa enviar seu crew para um repositório do GitHub. Caso ainda não te
|
||||

|
||||
</Frame>
|
||||
|
||||
<Tip>
|
||||
Se seu Crew ou Flow estiver dentro de uma subpasta de monorepo, expanda
|
||||
**Advanced** e defina um diretório de trabalho antes de implantar. Consulte
|
||||
[Implantações em Monorepo](/pt-BR/enterprise/guides/monorepo-deployments).
|
||||
</Tip>
|
||||
|
||||
</Step>
|
||||
|
||||
<Step title="Definir as Variáveis de Ambiente">
|
||||
@@ -441,4 +447,4 @@ type = "flow"
|
||||
<Card title="Precisa de Ajuda?" icon="headset" href="mailto:support@crewai.com">
|
||||
Entre em contato com nossa equipe de suporte para ajuda com questões de
|
||||
implantação ou dúvidas sobre a plataforma AMP.
|
||||
</Card>
|
||||
</Card>
|
||||
|
||||
230
docs/pt-BR/enterprise/guides/monorepo-deployments.mdx
Normal file
230
docs/pt-BR/enterprise/guides/monorepo-deployments.mdx
Normal file
@@ -0,0 +1,230 @@
|
||||
---
|
||||
title: "Implantações em Monorepo"
|
||||
description: "Implante um Crew ou Flow a partir de uma subpasta em um repositório maior"
|
||||
icon: "folder-tree"
|
||||
mode: "wide"
|
||||
---
|
||||
|
||||
<Note>
|
||||
Use um diretório de trabalho quando seu Crew ou Flow estiver dentro de um
|
||||
repositório maior. O CrewAI AMP valida, faz o build e executa a automação a
|
||||
partir dessa subpasta em vez da raiz do repositório.
|
||||
</Note>
|
||||
|
||||
## Quando Usar
|
||||
|
||||
Implantações em monorepo são úteis quando um repositório contém múltiplas
|
||||
automações, pacotes compartilhados ou outro código de aplicação:
|
||||
|
||||
```text
|
||||
company-ai/
|
||||
|-- uv.lock
|
||||
|-- packages/
|
||||
| `-- shared_tools/
|
||||
`-- crews/
|
||||
|-- support_agent/
|
||||
| |-- pyproject.toml
|
||||
| `-- src/
|
||||
| `-- support_agent/
|
||||
| |-- main.py
|
||||
| `-- crew.py
|
||||
`-- research_flow/
|
||||
|-- pyproject.toml
|
||||
`-- src/
|
||||
`-- research_flow/
|
||||
`-- main.py
|
||||
```
|
||||
|
||||
Para implantar `support_agent`, defina o diretório de trabalho como:
|
||||
|
||||
```text
|
||||
crews/support_agent
|
||||
```
|
||||
|
||||
O AMP ainda baixa ou recebe o repositório inteiro, mas trata a pasta
|
||||
selecionada como a raiz do projeto da automação.
|
||||
|
||||
## O Que o Diretório de Trabalho Controla
|
||||
|
||||
Quando um diretório de trabalho é definido, o AMP usa essa pasta para:
|
||||
|
||||
- Validação do projeto, incluindo `pyproject.toml`, `src/` e o ponto de entrada do Crew ou Flow
|
||||
- Instalação de dependências com `uv`
|
||||
- O diretório de trabalho do processo em execução
|
||||
- A variável de ambiente `CREW_ROOT_DIR`
|
||||
|
||||
Deixar o campo vazio mantém o comportamento existente e usa a raiz do
|
||||
repositório.
|
||||
|
||||
## Fontes Suportadas
|
||||
|
||||
Você pode definir um diretório de trabalho ao criar uma implantação a partir de:
|
||||
|
||||
- Um repositório GitHub conectado
|
||||
- Um repositório Git configurado no AMP
|
||||
- Um upload de ZIP
|
||||
|
||||
<Info>
|
||||
Configure diretórios de trabalho na interface web do AMP. O fluxo
|
||||
`crewai deploy create` da CLI não solicita esse campo.
|
||||
</Info>
|
||||
|
||||
Você também pode adicionar ou alterar o diretório de trabalho de uma implantação
|
||||
existente pela página **Settings** da implantação. A alteração passa a valer no
|
||||
próximo deploy.
|
||||
|
||||
<Warning>
|
||||
Diretórios de trabalho e auto-deploy não podem ser usados juntos. Se uma
|
||||
implantação tiver um diretório de trabalho, o auto-deploy fica desabilitado
|
||||
para ela. Desative o auto-deploy antes de definir um diretório de trabalho.
|
||||
</Warning>
|
||||
|
||||
## Configurar uma Nova Implantação
|
||||
|
||||
<Steps>
|
||||
<Step title="Abra Deploy from Code">
|
||||
No CrewAI AMP, crie uma nova implantação e escolha sua fonte: GitHub, Git
|
||||
Repository ou upload de ZIP.
|
||||
</Step>
|
||||
|
||||
<Step title="Selecione o repositório, branch ou arquivo ZIP">
|
||||
Escolha o repositório e a branch que contêm seu monorepo, ou envie um ZIP
|
||||
cuja raiz contenha os arquivos do monorepo.
|
||||
</Step>
|
||||
|
||||
<Step title="Abra as configurações avançadas">
|
||||
Expanda a seção **Advanced** no formulário de deploy.
|
||||
</Step>
|
||||
|
||||
<Step title="Informe o diretório de trabalho">
|
||||
Informe o caminho da raiz do repositório até o projeto Crew ou Flow:
|
||||
|
||||
```text
|
||||
crews/support_agent
|
||||
```
|
||||
|
||||
Não inclua uma barra inicial.
|
||||
</Step>
|
||||
|
||||
<Step title="Implante">
|
||||
Adicione as variáveis de ambiente necessárias e inicie a implantação.
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
## Configurar uma Implantação Existente
|
||||
|
||||
<Steps>
|
||||
<Step title="Abra as configurações da implantação">
|
||||
Acesse sua automação no AMP e abra **Settings**.
|
||||
</Step>
|
||||
|
||||
<Step title="Desative o auto-deploy, se necessário">
|
||||
Se o auto-deploy estiver habilitado, desative-o primeiro. O campo de
|
||||
diretório de trabalho fica indisponível enquanto o auto-deploy está ativo.
|
||||
</Step>
|
||||
|
||||
<Step title="Defina o diretório de trabalho">
|
||||
Em **Basic settings**, informe o caminho da subpasta, como:
|
||||
|
||||
```text
|
||||
crews/support_agent
|
||||
```
|
||||
</Step>
|
||||
|
||||
<Step title="Reimplante">
|
||||
Salve a configuração e reimplante a automação. O novo diretório de trabalho
|
||||
será usado no próximo deploy.
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
## Regras de Caminho
|
||||
|
||||
O diretório de trabalho deve ser um caminho relativo dentro da raiz do
|
||||
repositório ou do ZIP.
|
||||
|
||||
| Regra | Exemplo |
|
||||
|-------|---------|
|
||||
| Use um caminho relativo | `crews/support_agent` |
|
||||
| Não comece com `/` | `/crews/support_agent` é inválido |
|
||||
| Não use segmentos de caminho `.` ou `..` | `crews/../support_agent` é inválido |
|
||||
| Use apenas letras, números, hifens, underscores, pontos e barras | `crews/support agent` é inválido |
|
||||
| Mantenha o caminho com 255 caracteres ou menos | Caminhos maiores são rejeitados |
|
||||
|
||||
O AMP remove espaços em branco no início e no fim, reduz barras repetidas e
|
||||
remove barras finais. Um valor em branco usa a raiz do repositório.
|
||||
|
||||
## Arquivos Lock e Workspaces UV
|
||||
|
||||
A pasta selecionada deve conter o `pyproject.toml` e o diretório `src/` da
|
||||
automação. Um arquivo `uv.lock` ou `poetry.lock` pode ficar na pasta selecionada
|
||||
ou na raiz do repositório.
|
||||
|
||||
Isso oferece suporte aos dois layouts comuns de monorepo:
|
||||
|
||||
<Tabs>
|
||||
<Tab title="Arquivo lock do projeto">
|
||||
```text
|
||||
company-ai/
|
||||
`-- crews/
|
||||
`-- support_agent/
|
||||
|-- pyproject.toml
|
||||
|-- uv.lock
|
||||
`-- src/
|
||||
`-- support_agent/
|
||||
`-- main.py
|
||||
```
|
||||
</Tab>
|
||||
|
||||
<Tab title="Arquivo lock do workspace">
|
||||
```text
|
||||
company-ai/
|
||||
|-- uv.lock
|
||||
|-- packages/
|
||||
| `-- shared_tools/
|
||||
`-- crews/
|
||||
`-- support_agent/
|
||||
|-- pyproject.toml
|
||||
`-- src/
|
||||
`-- support_agent/
|
||||
`-- main.py
|
||||
```
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
<Tip>
|
||||
Se sua automação importar pacotes compartilhados de outro lugar do monorepo,
|
||||
declare esses pacotes no `pyproject.toml` usando configuração de workspace,
|
||||
caminho ou source do UV. O AMP executa a automação a partir da pasta
|
||||
selecionada, então o código compartilhado deve ser instalado como dependência
|
||||
em vez de depender da raiz do repositório no Python path.
|
||||
</Tip>
|
||||
|
||||
## Solução de Problemas
|
||||
|
||||
### Diretório de Trabalho Não Encontrado
|
||||
|
||||
Verifique se o caminho é relativo à raiz do repositório ou do ZIP. Para uploads
|
||||
de ZIP, o conteúdo do ZIP deve incluir exatamente o caminho informado como
|
||||
diretório de trabalho.
|
||||
|
||||
### pyproject.toml Ausente
|
||||
|
||||
O diretório de trabalho deve apontar para a pasta do projeto Crew ou Flow, não
|
||||
apenas para uma pasta pai que contém vários projetos.
|
||||
|
||||
### uv.lock ou poetry.lock Ausente
|
||||
|
||||
Faça commit de um arquivo lock na pasta do projeto selecionada ou na raiz do
|
||||
repositório. Para workspaces UV, manter `uv.lock` na raiz do workspace é
|
||||
suportado.
|
||||
|
||||
### Auto-Deploy Indisponível
|
||||
|
||||
O auto-deploy fica desabilitado enquanto um diretório de trabalho está definido.
|
||||
Use reimplantações manuais ou acione reimplantações a partir de CI/CD com a API
|
||||
do AMP.
|
||||
|
||||
<Card title="Deploy para AMP" icon="rocket" href="/pt-BR/enterprise/guides/deploy-to-amp">
|
||||
Continue com o guia de implantação depois de escolher o diretório de trabalho
|
||||
do monorepo.
|
||||
</Card>
|
||||
@@ -161,6 +161,18 @@ crew = Crew(
|
||||
)
|
||||
```
|
||||
|
||||
<Note>
|
||||
`agent.i18n` é mantido apenas para compatibilidade retroativa e está obsoleto. Para customização de prompts em tempo de execução, passe `prompt_file` para `Crew`. Para acesso programático aos slices de prompt, use diretamente o utilitário de i18n:
|
||||
</Note>
|
||||
|
||||
```python
|
||||
from crewai.utilities.i18n import get_i18n
|
||||
|
||||
i18n = get_i18n("custom_prompts.json")
|
||||
format_slice = i18n.slice("format")
|
||||
tool_prompt = i18n.tools("ask_question")
|
||||
```
|
||||
|
||||
#### Opção 3: Desativar Prompts de Sistema para Modelos o1
|
||||
```python
|
||||
agent = Agent(
|
||||
@@ -208,6 +220,8 @@ Uma abordagem direta é criar um arquivo JSON para os prompts que deseja sobresc
|
||||
|
||||
O CrewAI então mescla suas customizações com os padrões, assim você não precisa redefinir todos os prompts. Veja como:
|
||||
|
||||
Para código que precisa ler slices de prompt diretamente, use `crewai.utilities.i18n.get_i18n()` com o mesmo arquivo de prompts em vez de ler `agent.i18n`.
|
||||
|
||||
### Exemplo: Customização Básica de Prompt
|
||||
|
||||
Crie um arquivo `custom_prompts.json` com os prompts que deseja modificar. Certifique-se de listar todos os prompts de nível superior que ele deve conter, não apenas suas alterações:
|
||||
|
||||
475
docs/pt-BR/guides/flows/conversational-flows.mdx
Normal file
475
docs/pt-BR/guides/flows/conversational-flows.mdx
Normal file
@@ -0,0 +1,475 @@
|
||||
---
|
||||
title: Flows Conversacionais
|
||||
description: Crie apps de chat multi-turno com kickoff por turno, histórico de mensagens, roteamento de intenção, tracing e pontes WebSocket.
|
||||
icon: comments
|
||||
mode: "wide"
|
||||
---
|
||||
|
||||
## Visão geral
|
||||
|
||||
Apps conversacionais tratam cada linha do usuário como uma **nova execução do flow** com o **mesmo id de sessão**. A CrewAI oferece helpers para histórico de mensagens, classificação opcional de intenção, tracing adiado, pontes para UI e um REPL local `flow.chat()` para flows conversacionais.
|
||||
|
||||
| Conceito | Implementação |
|
||||
|---------|----------------|
|
||||
| Id de sessão | `handle_turn(..., session_id=...)` → `kickoff(inputs={"id": ...})` → `state.id` |
|
||||
| Linha do usuário | `handle_turn(message)` acrescenta em `state.messages` antes do grafo rodar |
|
||||
| Fim do turno | `FlowFinished` só para **esta execução**; o chat segue no próximo `handle_turn` |
|
||||
| Trace da sessão | `ConversationConfig(defer_trace_finalization=True)` + `finalize_session_traces()` |
|
||||
|
||||
## APIs de turno
|
||||
|
||||
Use **`flow.handle_turn(message, session_id=...)`** para cada mensagem de usuário em REST, WebSocket, testes e UIs customizadas. Use **`flow.chat()`** quando quiser um loop de chat local no terminal para um `Flow` conversacional.
|
||||
|
||||
`Flow.kickoff()` não aceita os argumentos nomeados `user_message=` ou `session_id=`. Para flows conversacionais, `handle_turn()` guarda a mensagem pendente e chama `kickoff(inputs={"id": session_id})` internamente.
|
||||
|
||||
| API | Uso |
|
||||
|-----|-----|
|
||||
| `handle_turn(message, session_id=...)` | Wrapper ergonômico de um turno para `Flow` conversacional |
|
||||
| `chat()` | REPL local no terminal para `Flow` conversacional |
|
||||
| `kickoff(inputs={...})` | Execução avançada do flow sem tratamento de turno conversacional |
|
||||
| `ask()` | Prompt bloqueante **dentro** de um passo (wizard, esclarecimento) |
|
||||
| `@human_feedback` | Aprovar/rejeitar **saída de um passo** — não a próxima linha do chat |
|
||||
| `ChatSession.handle_turn(...)` | Camada de transporte sobre `handle_turn` (SSE / WebSocket) |
|
||||
|
||||
## Início rápido
|
||||
|
||||
```python
|
||||
from uuid import uuid4
|
||||
|
||||
from crewai import Flow
|
||||
from crewai.flow import listen
|
||||
from crewai.experimental.conversational import (
|
||||
ConversationConfig,
|
||||
ConversationState,
|
||||
)
|
||||
|
||||
|
||||
@ConversationConfig(defer_trace_finalization=True)
|
||||
class SupportFlow(Flow[ConversationState]):
|
||||
conversational = True
|
||||
|
||||
def route_turn(self, context):
|
||||
message = (self.state.current_user_message or "").lower()
|
||||
if "pedido" in message or "order" in message:
|
||||
return "order"
|
||||
if "tchau" in message or "goodbye" in message:
|
||||
return "goodbye"
|
||||
return "help"
|
||||
|
||||
@listen("order")
|
||||
def handle_order(self):
|
||||
reply = "Seu pedido está a caminho."
|
||||
self.append_assistant_message(reply)
|
||||
return reply
|
||||
|
||||
@listen("help")
|
||||
def handle_help(self):
|
||||
reply = "Como posso ajudar?"
|
||||
self.append_assistant_message(reply)
|
||||
return reply
|
||||
|
||||
@listen("goodbye")
|
||||
def handle_goodbye(self):
|
||||
reply = "Até logo!"
|
||||
self.append_assistant_message(reply)
|
||||
return reply
|
||||
|
||||
|
||||
session_id = str(uuid4())
|
||||
flow = SupportFlow()
|
||||
|
||||
try:
|
||||
flow.handle_turn("Onde está meu pedido?", session_id=session_id)
|
||||
flow.handle_turn("E as devoluções?", session_id=session_id)
|
||||
finally:
|
||||
flow.finalize_session_traces() # um link de trace para o chat inteiro
|
||||
```
|
||||
|
||||
## Ciclo de vida do turno
|
||||
|
||||
Cada `handle_turn` executa este pipeline:
|
||||
|
||||
1. **`_configure_conversational_kickoff`** — mescla `session_id` / `user_message` em `inputs`, aplica `ConversationalConfig`, habilita tracing adiado quando configurado.
|
||||
2. **Restauração de estado** — se `inputs["id"]` existe e `@persist` está configurado, carrega o snapshot mais recente.
|
||||
3. **`FlowStarted`** — emitido apenas no primeiro turno da sessão adiada.
|
||||
4. **`prepare_conversational_turn`** — acrescenta a mensagem do usuário em `state.messages`, define `last_user_message`, limpa `last_intent`, classifica opcionalmente quando `intents` / `default_intents` + `intent_llm` estão definidos.
|
||||
5. **Execução do grafo** — `@start` → `@router` → handlers `@listen`.
|
||||
6. **Fim da execução** — `flow_finished` por turno e finalização de trace são **ignorados** com adiamento; `Agent.kickoff()` / crews aninhados também não fecham o batch pai.
|
||||
|
||||
Os handlers devem chamar **`append_assistant_message(reply)`** para que o próximo turno inclua a resposta do assistente. A linha do usuário já é salva por `handle_turn` — não acrescente de novo nos handlers.
|
||||
|
||||
## `ConversationalConfig` (padrões em nível de classe)
|
||||
|
||||
Defina na subclasse de `Flow` como `conversational_config: ClassVar[ConversationalConfig | None]`.
|
||||
|
||||
| Campo | Padrão | Propósito |
|
||||
|-------|---------|-----------|
|
||||
| `default_intents` | `None` | Rótulos de outcome para classificação automática antes do kickoff |
|
||||
| `intent_llm` | `None` | Modelo para classificação (obrigatório quando há intents) |
|
||||
| `interactive_prompt` | `"You: "` | Prompt para `kickoff(interactive=True)` |
|
||||
| `interactive_timeout` | `None` | Timeout por linha no modo interativo |
|
||||
| `exit_commands` | `exit`, `quit` | Palavras que encerram o modo interativo |
|
||||
| `defer_trace_finalization` | `True` | Manter um batch de trace aberto entre turnos |
|
||||
|
||||
Sobrescreva por kickoff com `intents=` e `intent_llm=`.
|
||||
|
||||
## `ChatState` (formato persistido recomendado)
|
||||
|
||||
```python
|
||||
from crewai.flow import ChatState
|
||||
|
||||
|
||||
class MyChatState(ChatState):
|
||||
# Herdados: id, messages, last_user_message, last_intent, session_ready
|
||||
research_turn_count: int = 0
|
||||
custom_flag: bool = False
|
||||
```
|
||||
|
||||
| Campo | Função |
|
||||
|-------|--------|
|
||||
| `id` | UUID da sessão (igual a `session_id` / `inputs["id"]`) |
|
||||
| `messages` | `list` de `{role, content}` para histórico de LLM |
|
||||
| `last_user_message` | Última linha do usuário neste turno |
|
||||
| `last_intent` | Rótulo de rota após classificação (se usado) |
|
||||
| `session_ready` | Flag de bootstrap único (permissões, caches, etc.) |
|
||||
|
||||
`ConversationalInputs` é um `TypedDict` para `kickoff(inputs={...})`: `id`, `user_message`, `last_intent`.
|
||||
|
||||
## API conversacional em `Flow`
|
||||
|
||||
### Parâmetros de `kickoff` / `kickoff_async`
|
||||
|
||||
| Parâmetro | Propósito |
|
||||
|-----------|-----------|
|
||||
| `user_message` | Texto deste turno (ou `{"role": "user", "content": "..."}`) |
|
||||
| `session_id` | UUID da conversa → `inputs["id"]` / `state.id` |
|
||||
| `intents` | Rótulos de outcome para `classify_intent` antes do kickoff |
|
||||
| `intent_llm` | LLM para classificação (obrigatório com `intents`) |
|
||||
| `interactive` | Loop CLI via `ask()` (só demos locais) |
|
||||
| `interactive_prompt` | Prompt no modo interativo |
|
||||
| `interactive_timeout` | Timeout de `ask()` por linha |
|
||||
| `exit_commands` | Palavras que encerram o modo interativo |
|
||||
| `inputs` | Campos extras de estado (mesclados com chaves conversacionais) |
|
||||
| `restore_from_state_id` | Hidratação fork de outro flow persistido |
|
||||
|
||||
### Atributos de instância
|
||||
|
||||
| Atributo | Propósito |
|
||||
|-----------|-----------|
|
||||
| `conversational_config` | Padrões `ConversationalConfig` em nível de classe |
|
||||
| `defer_trace_finalization` | Flag de instância; definida automaticamente a partir do config no kickoff |
|
||||
| `suppress_flow_events` | Oculta painéis Rich no console; **tracing ainda registra** eventos |
|
||||
| `stream` | Habilita streaming; use com `ChatSession.handle_turn(..., stream=True)` |
|
||||
|
||||
### Métodos e propriedades
|
||||
|
||||
| Nome | Descrição |
|
||||
|------|-------------|
|
||||
| `append_message(role, content, **extra)` | Acrescenta em `state.messages` (roles: `user`, `assistant`, `system`, `tool`) |
|
||||
| `conversation_messages` | Histórico somente leitura para chamadas LLM |
|
||||
| `classify_intent(text, outcomes, *, llm, context=None)` | Mapeia texto a um outcome (mesma lógica de `@human_feedback`) |
|
||||
| `receive_user_message(text, *, outcomes=None, llm=None)` | Acrescenta mensagem do usuário; opcionalmente define `last_intent` |
|
||||
| `finalize_session_traces()` | Emite `flow_finished` adiado e finaliza o batch de trace da sessão |
|
||||
| `_should_defer_trace_finalization()` | Se este flow adia finalização de trace por turno |
|
||||
| `input_history` | Trilha de auditoria de prompts e respostas de `ask()` |
|
||||
|
||||
### Helpers do módulo (`crewai.flow.conversation`)
|
||||
|
||||
Importáveis para testes ou orquestração customizada:
|
||||
|
||||
| Função | Descrição |
|
||||
|----------|-------------|
|
||||
| `normalize_kickoff_inputs(inputs, user_message=..., session_id=...)` | Mescla kwargs conversacionais em `inputs` |
|
||||
| `get_conversation_messages(flow)` | Lê mensagens do estado ou buffer interno |
|
||||
| `append_message(flow, role, content, **extra)` | Igual ao método de instância |
|
||||
| `prepare_conversational_turn(flow, ...)` | Hidratação do turno (geralmente chamado pelo kickoff) |
|
||||
| `receive_user_message(flow, text, ...)` | Igual ao método de instância |
|
||||
| `set_state_field(flow, name, value)` | Define campo em estado dict ou Pydantic |
|
||||
| `get_conversational_config(flow)` | Lê `conversational_config` da classe |
|
||||
| `input_history_to_messages(entries)` | Converte `input_history` para formato de mensagens LLM |
|
||||
|
||||
## Padrões de roteamento de intenção
|
||||
|
||||
### A. Pré-classificar via `ConversationalConfig` (mais simples)
|
||||
|
||||
Defina `default_intents` e `intent_llm`. Cada kickoff classifica antes do `@router`; leia `self.state.last_intent` em `route()`.
|
||||
|
||||
### B. Classificar dentro do `@router` (prompts mais ricos)
|
||||
|
||||
Defina `default_intents=None` para o kickoff só acrescentar a mensagem. Em `route()`, chame `classify_intent` com prompt ou descrições customizadas:
|
||||
|
||||
```python
|
||||
@router(bootstrap)
|
||||
def route(self):
|
||||
intent = self.classify_intent(
|
||||
self._routing_prompt(self.state.last_user_message),
|
||||
("GREETING", "ORDER", "RESEARCH", "GOODBYE"),
|
||||
llm=self.conversational_config.intent_llm or "gpt-4o-mini",
|
||||
)
|
||||
self.state.last_intent = intent
|
||||
return intent
|
||||
```
|
||||
|
||||
Use **`@listen("RESEARCH")`** (ou similar) para passos com `Agent.kickoff()` e ferramentas — não `LLM.call()` puro — quando precisar de pesquisa web ou uso multi-etapa de tools.
|
||||
|
||||
## Quando o flow termina mas o usuário continua conversando
|
||||
|
||||
`FlowFinished` significa que **esta execução do grafo** terminou. A conversa segue com outro `kickoff` e o mesmo `session_id`. `@persist` restaura `messages`, flags e contexto.
|
||||
|
||||
**Padrão de persistência:** prefira `@persist` em um **único passo terminal** (por exemplo `finalize`) em vez de na classe `Flow` inteira. Persist em nível de classe salva após cada método; `load_state` usa a linha mais recente, que pode ser snapshot no meio da execução e perder atualizações dos handlers no mesmo turno.
|
||||
|
||||
Não use `@human_feedback` para linhas de chat de follow-up, a menos que um humano precise aprovar uma saída específica antes de exibi-la.
|
||||
|
||||
## `Flow` conversacional (experimental)
|
||||
|
||||
<Warning>
|
||||
**Funcionalidade experimental.** A superfície do `Flow` conversacional
|
||||
(`conversational = True`, `handle_turn`, `ConversationConfig`,
|
||||
`RouterConfig`, `ConversationState`, o grafo embutido + helpers) vive em
|
||||
`crewai.experimental` e pode mudar de formato antes de graduar. Fixe a
|
||||
versão do CrewAI se depende de comportamento específico e acompanhe o
|
||||
changelog para mudanças quebradoras. Feedback / issues bem-vindos.
|
||||
</Warning>
|
||||
|
||||
Habilite o grafo conversacional definindo `conversational = True` em uma subclasse de `Flow`. O `Flow` base passa a expor um grafo embutido `@start` / `@router` / `converse_turn` / `end_conversation`, gerencia `state.messages`, dirige o LLM de roteamento e mantém o batch de trace aberto entre os turnos. Você escreve as **rotas customizadas**; o framework cuida do resto.
|
||||
|
||||
Use isto quando quiser um chat multi-turno com router LLM e handlers por rota sem cablar o ciclo de vida na mão. Use `Flow[ChatState]` (o padrão de mais baixo nível acima) quando precisar de controle total.
|
||||
|
||||
### Exemplo rápido
|
||||
|
||||
```python
|
||||
from crewai import LLM, Flow
|
||||
from crewai.flow import listen
|
||||
from crewai.experimental.conversational import (
|
||||
ConversationConfig,
|
||||
ConversationState,
|
||||
RouterConfig,
|
||||
)
|
||||
|
||||
|
||||
ROUTER_LLM = LLM(model="gpt-4o-mini")
|
||||
|
||||
|
||||
@ConversationConfig(
|
||||
system_prompt="A multi-agent assistant for ordinary chat and tool-backed tasks.",
|
||||
llm=ROUTER_LLM,
|
||||
router=RouterConfig(), # rotas + descrições auto-descobertas pelos handlers @listen
|
||||
)
|
||||
class SupportFlow(Flow[ConversationState]):
|
||||
conversational = True
|
||||
|
||||
@listen("INTERNET_SEARCH")
|
||||
def handle_internet_search(self) -> str:
|
||||
"""Fresh web research, current news, real-time lookups."""
|
||||
...
|
||||
self.append_assistant_message(reply)
|
||||
return reply
|
||||
|
||||
@listen("CREWAI_DOCS")
|
||||
def handle_crewai_docs(self) -> str:
|
||||
"""Look up the CrewAI documentation for framework/API questions."""
|
||||
...
|
||||
self.append_assistant_message(reply)
|
||||
return reply
|
||||
|
||||
|
||||
flow = SupportFlow()
|
||||
try:
|
||||
flow.handle_turn("O que você pode fazer?") # roteia para converse (built-in)
|
||||
flow.handle_turn("Pesquise na web por notícias de IA.") # roteia para INTERNET_SEARCH
|
||||
flow.handle_turn("Resuma o primeiro resultado.") # volta para converse
|
||||
finally:
|
||||
flow.finalize_session_traces()
|
||||
```
|
||||
|
||||
Para um chat local no terminal, use `chat()`:
|
||||
|
||||
```python
|
||||
def kickoff() -> None:
|
||||
SupportFlow().chat()
|
||||
```
|
||||
|
||||
`chat()` envolve `handle_turn()` em um REPL, sai com `exit` / `quit`, ignora linhas em branco por padrão e chama `finalize_session_traces()` quando a sessão termina.
|
||||
|
||||
### `ConversationConfig`
|
||||
|
||||
Decorador de classe que anexa os defaults de chat por classe.
|
||||
|
||||
| Campo | Padrão | Propósito |
|
||||
|-------|--------|-----------|
|
||||
| `system_prompt` | `slices.conversational_system_prompt` (i18n) | System message usado pelo `converse_turn` embutido. Passe `""` para desativar totalmente. |
|
||||
| `llm` | `None` | LLM de conversa (usado pelo `converse_turn` e como fallback do router). |
|
||||
| `router` | `None` | `RouterConfig` para roteamento por LLM. Sem ele, o flow sempre cai em `converse`. |
|
||||
| `answer_from_history_prompt` | padrão do framework | System message para a rota opcional `answer_from_history`. |
|
||||
| `answer_from_history_llm` | `None` | Habilita o atalho `answer_from_history` quando definido. |
|
||||
| `intent_llm` | `None` | LLM para o caminho legado `intents=`/`default_intents`. |
|
||||
| `default_intents` | `None` | Labels de outcome para pré-classificação legada. |
|
||||
| `visible_agent_outputs` | `None` | `"all"` ou lista de nomes de agentes cujos `append_agent_result()` devem virar mensagens públicas. |
|
||||
| `defer_trace_finalization` | `True` | Mantém um único batch de trace aberto entre chamadas de `handle_turn()`. |
|
||||
|
||||
### `RouterConfig` e o catálogo de rotas auto-gerado
|
||||
|
||||
```python
|
||||
RouterConfig(
|
||||
prompt="Enquadramento de domínio opcional (política, voz, persona).",
|
||||
response_format=MyRoute, # opcional; auto-gerado caso contrário
|
||||
llm=ROUTER_LLM, # usa ConversationConfig.llm como fallback
|
||||
routes=["INTERNET_SEARCH", "CREWAI_DOCS"], # opcional; inferido dos listeners
|
||||
route_descriptions={
|
||||
"INTERNET_SEARCH": "Sobrescreve a docstring só desta rota.",
|
||||
},
|
||||
default_intent="converse", # usado quando a chamada ao LLM falha ou não há LLM
|
||||
fallback_intent="converse", # usado quando o LLM retorna rota inválida
|
||||
intent_field="intent",
|
||||
)
|
||||
```
|
||||
|
||||
O prompt do router é montado automaticamente. Para cada rota o framework escolhe a descrição nesta precedência:
|
||||
|
||||
1. `RouterConfig.route_descriptions[label]` — override explícito.
|
||||
2. `Flow.builtin_route_descriptions[label]` — texto canônico do framework para `converse`, `end`, `answer_from_history` (otimizado para o LLM de routing).
|
||||
3. Primeira linha não vazia da docstring do handler `@listen(label)`.
|
||||
4. Vazio (a rota aparece no catálogo sem descrição).
|
||||
|
||||
Na prática, **adicionar uma rota é `@listen("X")` + uma docstring de uma linha**:
|
||||
|
||||
```python
|
||||
@listen("INTERNET_SEARCH")
|
||||
def handle_internet_search(self) -> str:
|
||||
"""Fresh web research, current news, real-time lookups."""
|
||||
...
|
||||
```
|
||||
|
||||
…e o LLM de routing vê:
|
||||
|
||||
```
|
||||
Routes:
|
||||
- CREWAI_DOCS: Look up the CrewAI documentation for framework/API questions.
|
||||
- INTERNET_SEARCH: Fresh web research, current news, real-time lookups.
|
||||
- converse: Ordinary chat, follow-ups, summaries, clarifications…
|
||||
- end: User signals the conversation is finished (goodbye, exit, done).
|
||||
```
|
||||
|
||||
`RouterConfig.prompt` é para **enquadramento de domínio** (persona do assistente, regras de negócio, voz). O catálogo de rotas é auto-gerado — não liste rotas em `prompt`; elas vão sair de sincronia assim que você adicionar um handler.
|
||||
|
||||
### Rotas embutidas
|
||||
|
||||
| Rota | Handler | Propósito |
|
||||
|------|---------|-----------|
|
||||
| `converse` | `converse_turn` | Handler de chat padrão. Chama `ConversationConfig.llm` com o system prompt + histórico canônico. |
|
||||
| `end` | `end_conversation` | Define `state.ended = True` e emite uma resposta de encerramento. |
|
||||
| `answer_from_history` | `answer_from_history_turn` | Opcional. Cai aqui quando `ConversationConfig.answer_from_history_llm` está definido e a mensagem pode ser respondida só pelo histórico. |
|
||||
|
||||
Você pode sobrescrever qualquer uma definindo um handler com o mesmo nome na subclasse.
|
||||
|
||||
### Semântica de `handle_turn()`
|
||||
|
||||
`flow.handle_turn(message)` roda um turno:
|
||||
|
||||
1. Reseta o tracking por execução (`_completed_methods`, `_method_outputs`) para o grafo re-rodar — sem isso, chamadas repetidas de `kickoff` na mesma instância dariam curto-circuito no turno 2+ porque `Flow.kickoff_async` trata `inputs={"id": ...}` como restauração de checkpoint.
|
||||
2. Anexa a mensagem do usuário em `state.messages`, define `current_user_message` / `last_user_message`. `last_intent` é **preservado do turno anterior** para que o LLM de routing possa usá-lo como sinal.
|
||||
3. Roda `conversation_start` → `route_conversation` → o handler `@listen` escolhido.
|
||||
4. O router grava sua decisão em `state.last_intent` (visível para o contexto de routing do próximo turno).
|
||||
5. Se seu handler retornou uma string e ainda não chamou `append_assistant_message`, `handle_turn` anexa para você.
|
||||
|
||||
Chame `handle_turn()` para mensagens de chat. Chamar `kickoff(inputs={"id": ...})` diretamente executa o grafo sem aplicar o wrapper de turno conversacional.
|
||||
|
||||
### `chat()` para REPLs locais
|
||||
|
||||
`flow.chat()` é o wrapper de terminal pronto para uso em cima de `handle_turn()`:
|
||||
|
||||
```python
|
||||
flow = SupportFlow()
|
||||
flow.chat()
|
||||
```
|
||||
|
||||
Ele cobre o loop local comum:
|
||||
|
||||
1. Solicita uma mensagem do usuário.
|
||||
2. Para com `exit` / `quit`, `EOFError` ou `KeyboardInterrupt`.
|
||||
3. Chama `handle_turn(message, session_id=...)`.
|
||||
4. Imprime o resultado do assistente.
|
||||
5. Finaliza traces de sessão adiados em um bloco `finally`.
|
||||
|
||||
Customize o comportamento do terminal com I/O injetável:
|
||||
|
||||
```python
|
||||
flow.chat(
|
||||
session_id="demo-session",
|
||||
prompt="You: ",
|
||||
assistant_prefix="Assistant: ",
|
||||
exit_commands=("exit", "quit", "bye"),
|
||||
)
|
||||
```
|
||||
|
||||
Para apps web, workers em background, testes e transportes customizados, continue usando `handle_turn()` diretamente.
|
||||
|
||||
### Comportamento customizado do router
|
||||
|
||||
Para rodar efeitos colaterais (setup de event bus, telemetria) em toda decisão de routing, sobrescreva `route_turn`:
|
||||
|
||||
```python
|
||||
class SupportFlow(Flow[ConversationState]):
|
||||
conversational = True
|
||||
|
||||
def route_turn(self, context: dict[str, Any]) -> str | None:
|
||||
self.event_bus = MyBus(self)
|
||||
return super().route_turn(context)
|
||||
```
|
||||
|
||||
Para ignorar o router LLM e escolher uma rota programaticamente, retorne uma string de `route_turn`; retornar `None` cai no `_route_with_config(...)`.
|
||||
|
||||
### `append_assistant_message` e `append_agent_result`
|
||||
|
||||
Dentro de um handler `@listen(label)`, escolha:
|
||||
|
||||
- `self.append_assistant_message(text)` — adiciona um turno de assistente visível ao usuário em `state.messages`. O `converse_turn` do próximo turno vai vê-lo.
|
||||
- `self.append_agent_result(agent_name, result, visibility="private")` — registra um evento estruturado em `state.events` e uma thread em `state.agent_threads[agent_name]`. Visibilidade pública também chama `append_assistant_message` automaticamente. Use resultados privados para trabalho de bastidor que não deve poluir o histórico canônico.
|
||||
|
||||
`ConversationConfig.visible_agent_outputs` pode promover globalmente os resultados privados de agentes específicos para públicos (`"all"` ou lista de nomes).
|
||||
|
||||
## Tracing entre turnos
|
||||
|
||||
Com `defer_trace_finalization=True` (padrão em `ConversationalConfig`):
|
||||
|
||||
- **Um batch de trace** para toda a sessão de chat.
|
||||
- **`flow_started`** só no primeiro turno; **`flow_finished`** uma vez em `finalize_session_traces()`.
|
||||
- **`kickoff` por turno** não exibe “Trace batch finalized”.
|
||||
- **Trabalho aninhado** (`Agent.kickoff()`, crews, tools Exa) acrescenta ao batch **pai**; flows internos de `AgentExecutor` não fecham o batch da sessão cedo.
|
||||
|
||||
```python
|
||||
flow.chat(session_id=session_id)
|
||||
```
|
||||
|
||||
`flow.chat()` chama `finalize_session_traces()` para você. Quando você controla o loop com `handle_turn()` ou `kickoff(...)`, chame `finalize_session_traces()` quando a sessão terminar.
|
||||
|
||||
`suppress_flow_events=True` só oculta painéis do console; eventos de trace e método ainda são emitidos.
|
||||
|
||||
### Ciclo de vida de trace do `Flow` conversacional
|
||||
|
||||
O [`Flow` conversacional](#flow-conversacional-experimental) experimental usa o mesmo ciclo de vida de tracing: `defer_trace_finalization` é `True` por padrão, então cada `handle_turn()` mantém o trace da sessão aberto. Sempre finalize ao fim da sessão — envolva seu loop em `try/finally` e chame `flow.finalize_session_traces()` na saída. Sem isso, o batch fica aberto e a última conversa pode nunca ser exportada.
|
||||
|
||||
## Streaming
|
||||
|
||||
Defina `stream = True` na classe `Flow`. `kickoff(...)` então emitirá `assistant_delta` (e eventos relacionados) pelo event bus padrão.
|
||||
|
||||
## Imports
|
||||
|
||||
```python
|
||||
from crewai.flow import (
|
||||
ChatState,
|
||||
ConversationalConfig,
|
||||
ConversationalInputs,
|
||||
Flow,
|
||||
listen,
|
||||
persist,
|
||||
router,
|
||||
start,
|
||||
)
|
||||
```
|
||||
|
||||
## Veja também
|
||||
|
||||
- [Dominando o Gerenciamento de Estado em Flows](/pt-BR/guides/flows/mastering-flow-state) — persistência, estado Pydantic, `@persist`
|
||||
- [Construa Seu Primeiro Flow](/pt-BR/guides/flows/first-flow) — fundamentos de flow
|
||||
- Demo: `lib/crewai/runner_conversational_flow_simple.py` — REPL mínimo com `RESEARCH` + agente Exa
|
||||
@@ -614,6 +614,7 @@ Agora que você construiu seu primeiro flow, pode:
|
||||
3. Explorar as funções `and_` e `or_` para execuções paralelas e mais complexas
|
||||
4. Conectar seu flow a APIs externas, bancos de dados ou interfaces de usuário
|
||||
5. Combinar múltiplos crews especializados em um único flow
|
||||
6. Criar apps de chat multi-turn com [Flows conversacionais](/pt-BR/guides/flows/conversational-flows) (`kickoff` por mensagem, `ChatSession`, tracing adiado)
|
||||
|
||||
<Check>
|
||||
Parabéns! Você construiu seu primeiro CrewAI Flow que combina código regular, chamadas diretas a LLM e processamento baseado em crews para criar um guia abrangente. Essas habilidades fundamentais permitem criar aplicações de IA cada vez mais sofisticadas, capazes de resolver problemas complexos de múltiplas etapas por meio de controle procedural e inteligência colaborativa.
|
||||
|
||||
@@ -22,6 +22,8 @@ Um gerenciamento de estado efetivo possibilita que você:
|
||||
5. **Escalone suas aplicações** – Ofereça suporte a workflows complexos com organização apropriada dos dados
|
||||
6. **Habilite aplicações conversacionais** – Armazene e acesse o histórico da conversa para interações de IA com contexto
|
||||
|
||||
Para chat multi-turn (`kickoff` por linha do usuário, `ChatState`, roteamento por intenção, tracing adiado e `ChatSession`), veja [Flows conversacionais](/pt-BR/guides/flows/conversational-flows).
|
||||
|
||||
Vamos explorar como aproveitar essas capacidades de forma eficiente.
|
||||
|
||||
## Fundamentos do Gerenciamento de Estado
|
||||
|
||||
@@ -8,7 +8,7 @@ authors = [
|
||||
]
|
||||
requires-python = ">=3.10, <3.14"
|
||||
dependencies = [
|
||||
"crewai-core==1.14.7a1",
|
||||
"crewai-core==1.14.7",
|
||||
"click>=8.1.7,<9",
|
||||
"pydantic>=2.11.9,<2.13",
|
||||
"pydantic-settings~=2.10.1",
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "1.14.7a1"
|
||||
__version__ = "1.14.7"
|
||||
|
||||
@@ -3,41 +3,94 @@ from __future__ import annotations
|
||||
from importlib.metadata import version as get_version
|
||||
import os
|
||||
import subprocess
|
||||
from typing import Any
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import click
|
||||
from crewai_core.token_manager import TokenManager
|
||||
|
||||
from crewai_cli.add_crew_to_flow import add_crew_to_flow
|
||||
from crewai_cli.authentication.main import AuthenticationCommand
|
||||
from crewai_cli.config import Settings
|
||||
from crewai_cli.create_crew import create_crew
|
||||
from crewai_cli.create_flow import create_flow
|
||||
from crewai_cli.crew_chat import run_chat
|
||||
from crewai_cli.deploy.main import DeployCommand
|
||||
from crewai_cli.enterprise.main import EnterpriseConfigureCommand
|
||||
from crewai_cli.evaluate_crew import evaluate_crew
|
||||
from crewai_cli.experimental.skills.main import SkillCommand
|
||||
from crewai_cli.install_crew import install_crew
|
||||
from crewai_cli.kickoff_flow import kickoff_flow
|
||||
from crewai_cli.organization.main import OrganizationCommand
|
||||
from crewai_cli.plot_flow import plot_flow
|
||||
from crewai_cli.remote_template.main import TemplateCommand
|
||||
from crewai_cli.replay_from_task import replay_task_command
|
||||
from crewai_cli.reset_memories_command import reset_memories_command
|
||||
from crewai_cli.run_crew import run_crew
|
||||
from crewai_cli.settings.main import SettingsCommand
|
||||
from crewai_cli.task_outputs import load_task_outputs
|
||||
from crewai_cli.tools.main import ToolCommand
|
||||
from crewai_cli.train_crew import train_crew
|
||||
from crewai_cli.triggers.main import TriggersCommand
|
||||
from crewai_cli.update_crew import update_crew
|
||||
from crewai_cli.user_data import (
|
||||
_load_user_data,
|
||||
is_tracing_enabled,
|
||||
update_user_data,
|
||||
)
|
||||
from crewai_cli.utils import build_env_with_all_tool_credentials, read_toml
|
||||
from crewai_cli.utils import (
|
||||
build_env_with_all_tool_credentials,
|
||||
enable_prompt_line_editing,
|
||||
read_toml,
|
||||
)
|
||||
|
||||
|
||||
def train_crew(*args: Any, **kwargs: Any) -> Any:
|
||||
from crewai_cli.train_crew import train_crew as _train_crew
|
||||
|
||||
return _train_crew(*args, **kwargs)
|
||||
|
||||
|
||||
def evaluate_crew(*args: Any, **kwargs: Any) -> Any:
|
||||
from crewai_cli.evaluate_crew import evaluate_crew as _evaluate_crew
|
||||
|
||||
return _evaluate_crew(*args, **kwargs)
|
||||
|
||||
|
||||
def replay_task_command(*args: Any, **kwargs: Any) -> Any:
|
||||
from crewai_cli.replay_from_task import replay_task_command as _replay_task_command
|
||||
|
||||
return _replay_task_command(*args, **kwargs)
|
||||
|
||||
|
||||
def run_flow_definition(*args: Any, **kwargs: Any) -> Any:
|
||||
from crewai_cli.run_flow_definition import (
|
||||
run_flow_definition as _run_flow_definition,
|
||||
)
|
||||
|
||||
return _run_flow_definition(*args, **kwargs)
|
||||
|
||||
|
||||
def run_crew(*args: Any, **kwargs: Any) -> Any:
|
||||
from crewai_cli.run_crew import run_crew as _run_crew
|
||||
|
||||
return _run_crew(*args, **kwargs)
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
# mypy sees the real classes; at runtime the shims below defer the
|
||||
# heavy imports until a command actually instantiates them.
|
||||
from crewai_cli.authentication.main import AuthenticationCommand
|
||||
from crewai_cli.deploy.main import DeployCommand
|
||||
from crewai_cli.organization.main import OrganizationCommand
|
||||
from crewai_cli.remote_template.main import TemplateCommand
|
||||
else:
|
||||
|
||||
class AuthenticationCommand:
|
||||
def __new__(cls, *args: Any, **kwargs: Any) -> Any:
|
||||
from crewai_cli.authentication.main import (
|
||||
AuthenticationCommand as _AuthenticationCommand,
|
||||
)
|
||||
|
||||
return _AuthenticationCommand(*args, **kwargs)
|
||||
|
||||
class DeployCommand:
|
||||
def __new__(cls, *args: Any, **kwargs: Any) -> Any:
|
||||
from crewai_cli.deploy.main import DeployCommand as _DeployCommand
|
||||
|
||||
return _DeployCommand(*args, **kwargs)
|
||||
|
||||
class TemplateCommand:
|
||||
def __new__(cls, *args: Any, **kwargs: Any) -> Any:
|
||||
from crewai_cli.remote_template.main import (
|
||||
TemplateCommand as _TemplateCommand,
|
||||
)
|
||||
|
||||
return _TemplateCommand(*args, **kwargs)
|
||||
|
||||
class OrganizationCommand:
|
||||
def __new__(cls, *args: Any, **kwargs: Any) -> Any:
|
||||
from crewai_cli.organization.main import (
|
||||
OrganizationCommand as _OrganizationCommand,
|
||||
)
|
||||
|
||||
return _OrganizationCommand(*args, **kwargs)
|
||||
|
||||
|
||||
def _get_cli_version() -> str:
|
||||
@@ -90,17 +143,57 @@ def uv(uv_args: tuple[str, ...]) -> None:
|
||||
|
||||
|
||||
@crewai.command()
|
||||
@click.argument("type", type=click.Choice(["crew", "flow"]))
|
||||
@click.argument("name")
|
||||
@click.argument(
|
||||
"type", required=False, default=None, type=click.Choice(["crew", "flow"])
|
||||
)
|
||||
@click.argument("name", required=False, default=None)
|
||||
@click.option("--provider", type=str, help="The provider to use for the crew")
|
||||
@click.option("--skip_provider", is_flag=True, help="Skip provider validation")
|
||||
@click.option(
|
||||
"--classic",
|
||||
is_flag=True,
|
||||
help="Use classic Python/YAML project structure instead of JSON",
|
||||
)
|
||||
def create(
|
||||
type: str, name: str, provider: str | None, skip_provider: bool = False
|
||||
type: str | None,
|
||||
name: str | None,
|
||||
provider: str | None,
|
||||
skip_provider: bool = False,
|
||||
classic: bool = False,
|
||||
) -> None:
|
||||
"""Create a new crew, or flow."""
|
||||
if not type:
|
||||
from crewai_cli.tui_picker import pick
|
||||
|
||||
options = [
|
||||
("crew", "A team of AI agents working together"),
|
||||
(
|
||||
"flow",
|
||||
"A deterministic workflow with full control over agents and crews",
|
||||
),
|
||||
]
|
||||
type = pick("What would you like to create?", options)
|
||||
if type is None:
|
||||
raise SystemExit(0)
|
||||
click.echo()
|
||||
if not name:
|
||||
enable_prompt_line_editing()
|
||||
name = click.prompt(
|
||||
click.style(f" Name of your {type}", fg="cyan", bold=True),
|
||||
prompt_suffix=click.style(" › ", fg="bright_white"), # noqa: RUF001
|
||||
)
|
||||
if type == "crew":
|
||||
create_crew(name, provider, skip_provider)
|
||||
if classic:
|
||||
from crewai_cli.create_crew import create_crew
|
||||
|
||||
create_crew(name, provider, skip_provider)
|
||||
else:
|
||||
from crewai_cli.create_json_crew import create_json_crew
|
||||
|
||||
create_json_crew(name, provider, skip_provider)
|
||||
elif type == "flow":
|
||||
from crewai_cli.create_flow import create_flow
|
||||
|
||||
create_flow(name)
|
||||
else:
|
||||
click.secho("Error: Invalid type. Must be 'crew' or 'flow'.", fg="red")
|
||||
@@ -185,6 +278,8 @@ def replay(task_id: str, trained_agents_file: str | None) -> None:
|
||||
def log_tasks_outputs() -> None:
|
||||
"""Retrieve your latest crew.kickoff() task outputs."""
|
||||
try:
|
||||
from crewai_cli.task_outputs import load_task_outputs
|
||||
|
||||
tasks = load_task_outputs()
|
||||
|
||||
if not tasks:
|
||||
@@ -273,6 +368,8 @@ def reset_memories(
|
||||
"Please specify at least one memory type to reset using the appropriate flags."
|
||||
)
|
||||
return
|
||||
from crewai_cli.reset_memories_command import reset_memories_command
|
||||
|
||||
reset_memories_command(memory, knowledge, agent_knowledge, kickoff_outputs, all)
|
||||
except Exception as e:
|
||||
click.echo(f"An error occurred while resetting memories: {e}", err=True)
|
||||
@@ -295,7 +392,7 @@ def reset_memories(
|
||||
"--embedder-model",
|
||||
type=str,
|
||||
default=None,
|
||||
help="Embedder model name (e.g. text-embedding-3-small, gemini-embedding-001).",
|
||||
help="Embedder model name (e.g. text-embedding-3-large, gemini-embedding-001).",
|
||||
)
|
||||
@click.option(
|
||||
"--embedder-config",
|
||||
@@ -350,7 +447,7 @@ def memory(
|
||||
"-m",
|
||||
"--model",
|
||||
type=str,
|
||||
default="gpt-4o-mini",
|
||||
default="gpt-5.4-mini",
|
||||
help="LLM Model to run the tests on the Crew. For now only accepting only OpenAI models.",
|
||||
)
|
||||
@click.option(
|
||||
@@ -381,6 +478,8 @@ def test(n_iterations: int, model: str, trained_agents_file: str | None) -> None
|
||||
@click.pass_context
|
||||
def install(context: click.Context) -> None:
|
||||
"""Install the Crew."""
|
||||
from crewai_cli.install_crew import install_crew
|
||||
|
||||
install_crew(context.args)
|
||||
|
||||
|
||||
@@ -398,14 +497,46 @@ def install(context: click.Context) -> None:
|
||||
"CREWAI_TRAINED_AGENTS_FILE."
|
||||
),
|
||||
)
|
||||
def run(trained_agents_file: str | None) -> None:
|
||||
"""Run the Crew."""
|
||||
@click.option(
|
||||
"--definition",
|
||||
type=str,
|
||||
default=None,
|
||||
help=(
|
||||
"Experimental: path to a Flow Definition YAML/JSON file, "
|
||||
"or an inline YAML/JSON string."
|
||||
),
|
||||
)
|
||||
@click.option(
|
||||
"--inputs",
|
||||
type=str,
|
||||
default=None,
|
||||
help='Experimental: JSON object passed to flow.kickoff(), e.g. \'{"topic":"AI"}\'.',
|
||||
)
|
||||
def run(
|
||||
trained_agents_file: str | None,
|
||||
definition: str | None,
|
||||
inputs: str | None,
|
||||
) -> None:
|
||||
"""Run the Crew or Flow."""
|
||||
if inputs is not None and definition is None:
|
||||
raise click.UsageError("--inputs requires --definition")
|
||||
|
||||
if definition is not None:
|
||||
click.secho(
|
||||
"Warning: `crewai run --definition` is experimental and may change without notice.",
|
||||
fg="yellow",
|
||||
)
|
||||
run_flow_definition(definition=definition, inputs=inputs)
|
||||
return
|
||||
|
||||
run_crew(trained_agents_file=trained_agents_file)
|
||||
|
||||
|
||||
@crewai.command()
|
||||
def update() -> None:
|
||||
"""Update the pyproject.toml of the Crew project to use uv."""
|
||||
from crewai_cli.update_crew import update_crew
|
||||
|
||||
update_crew()
|
||||
|
||||
|
||||
@@ -515,6 +646,8 @@ def tool() -> None:
|
||||
@tool.command(name="create")
|
||||
@click.argument("handle")
|
||||
def tool_create(handle: str) -> None:
|
||||
from crewai_cli.tools.main import ToolCommand
|
||||
|
||||
tool_cmd = ToolCommand()
|
||||
tool_cmd.create(handle)
|
||||
|
||||
@@ -522,6 +655,8 @@ def tool_create(handle: str) -> None:
|
||||
@tool.command(name="install")
|
||||
@click.argument("handle")
|
||||
def tool_install(handle: str) -> None:
|
||||
from crewai_cli.tools.main import ToolCommand
|
||||
|
||||
tool_cmd = ToolCommand()
|
||||
tool_cmd.login()
|
||||
tool_cmd.install(handle)
|
||||
@@ -538,6 +673,8 @@ def tool_install(handle: str) -> None:
|
||||
@click.option("--public", "is_public", flag_value=True, default=False)
|
||||
@click.option("--private", "is_public", flag_value=False)
|
||||
def tool_publish(is_public: bool, force: bool) -> None:
|
||||
from crewai_cli.tools.main import ToolCommand
|
||||
|
||||
tool_cmd = ToolCommand()
|
||||
tool_cmd.login()
|
||||
tool_cmd.publish(is_public, force)
|
||||
@@ -570,6 +707,8 @@ def skill() -> None:
|
||||
help="Create skill in current dir instead of ./skills/",
|
||||
)
|
||||
def skill_create(name: str, in_project: bool) -> None:
|
||||
from crewai_cli.experimental.skills.main import SkillCommand
|
||||
|
||||
skill_cmd = SkillCommand()
|
||||
skill_cmd.create(name, in_project=in_project)
|
||||
|
||||
@@ -577,6 +716,8 @@ def skill_create(name: str, in_project: bool) -> None:
|
||||
@skill.command(name="install")
|
||||
@click.argument("ref")
|
||||
def skill_install(ref: str) -> None:
|
||||
from crewai_cli.experimental.skills.main import SkillCommand
|
||||
|
||||
skill_cmd = SkillCommand()
|
||||
skill_cmd.install(ref)
|
||||
|
||||
@@ -593,6 +734,8 @@ def skill_install(ref: str) -> None:
|
||||
@click.option("--private", "is_public", flag_value=False)
|
||||
@click.option("--org", default=None, help="Organisation slug (overrides settings).")
|
||||
def skill_publish(is_public: bool, org: str | None, force: bool) -> None:
|
||||
from crewai_cli.experimental.skills.main import SkillCommand
|
||||
|
||||
skill_cmd = SkillCommand()
|
||||
skill_cmd.publish(is_public, org=org, force=force)
|
||||
|
||||
@@ -600,6 +743,8 @@ def skill_publish(is_public: bool, org: str | None, force: bool) -> None:
|
||||
@skill.command(name="list")
|
||||
def skill_list() -> None:
|
||||
"""List locally installed skills."""
|
||||
from crewai_cli.experimental.skills.main import SkillCommand
|
||||
|
||||
skill_cmd = SkillCommand()
|
||||
skill_cmd.list_cached()
|
||||
|
||||
@@ -639,6 +784,8 @@ def flow() -> None:
|
||||
@flow.command(name="kickoff")
|
||||
def flow_run() -> None:
|
||||
"""Kickoff the Flow."""
|
||||
from crewai_cli.kickoff_flow import kickoff_flow
|
||||
|
||||
click.echo("Running the Flow")
|
||||
kickoff_flow()
|
||||
|
||||
@@ -646,6 +793,8 @@ def flow_run() -> None:
|
||||
@flow.command(name="plot")
|
||||
def flow_plot() -> None:
|
||||
"""Plot the Flow."""
|
||||
from crewai_cli.plot_flow import plot_flow
|
||||
|
||||
click.echo("Plotting the Flow")
|
||||
plot_flow()
|
||||
|
||||
@@ -654,6 +803,8 @@ def flow_plot() -> None:
|
||||
@click.argument("crew_name")
|
||||
def flow_add_crew(crew_name: str) -> None:
|
||||
"""Add a crew to an existing flow."""
|
||||
from crewai_cli.add_crew_to_flow import add_crew_to_flow
|
||||
|
||||
click.echo(f"Adding crew {crew_name} to the flow")
|
||||
add_crew_to_flow(crew_name)
|
||||
|
||||
@@ -666,6 +817,8 @@ def triggers() -> None:
|
||||
@triggers.command(name="list")
|
||||
def triggers_list() -> None:
|
||||
"""List all available triggers from integrations."""
|
||||
from crewai_cli.triggers.main import TriggersCommand
|
||||
|
||||
triggers_cmd = TriggersCommand()
|
||||
triggers_cmd.list_triggers()
|
||||
|
||||
@@ -674,6 +827,8 @@ def triggers_list() -> None:
|
||||
@click.argument("trigger_path")
|
||||
def triggers_run(trigger_path: str) -> None:
|
||||
"""Execute crew with trigger payload. Format: app_slug/trigger_slug"""
|
||||
from crewai_cli.triggers.main import TriggersCommand
|
||||
|
||||
triggers_cmd = TriggersCommand()
|
||||
triggers_cmd.execute_with_trigger(trigger_path)
|
||||
|
||||
@@ -686,6 +841,8 @@ def chat() -> None:
|
||||
click.secho(
|
||||
"\nStarting a conversation with the Crew\nType 'exit' or Ctrl+C to quit.\n",
|
||||
)
|
||||
from crewai_cli.crew_chat import run_chat
|
||||
|
||||
run_chat()
|
||||
|
||||
|
||||
@@ -725,6 +882,8 @@ def enterprise() -> None:
|
||||
@click.argument("enterprise_url")
|
||||
def enterprise_configure(enterprise_url: str) -> None:
|
||||
"""Configure CrewAI AMP OAuth2 settings from the provided Enterprise URL."""
|
||||
from crewai_cli.enterprise.main import EnterpriseConfigureCommand
|
||||
|
||||
enterprise_command = EnterpriseConfigureCommand()
|
||||
enterprise_command.configure(enterprise_url)
|
||||
|
||||
@@ -737,6 +896,8 @@ def config() -> None:
|
||||
@config.command("list")
|
||||
def config_list() -> None:
|
||||
"""List all CLI configuration parameters."""
|
||||
from crewai_cli.settings.main import SettingsCommand
|
||||
|
||||
config_command = SettingsCommand()
|
||||
config_command.list()
|
||||
|
||||
@@ -746,6 +907,8 @@ def config_list() -> None:
|
||||
@click.argument("value")
|
||||
def config_set(key: str, value: str) -> None:
|
||||
"""Set a CLI configuration parameter."""
|
||||
from crewai_cli.settings.main import SettingsCommand
|
||||
|
||||
config_command = SettingsCommand()
|
||||
config_command.set(key, value)
|
||||
|
||||
@@ -753,6 +916,8 @@ def config_set(key: str, value: str) -> None:
|
||||
@config.command("reset")
|
||||
def config_reset() -> None:
|
||||
"""Reset all CLI configuration parameters to default values."""
|
||||
from crewai_cli.settings.main import SettingsCommand
|
||||
|
||||
config_command = SettingsCommand()
|
||||
config_command.reset_all_settings()
|
||||
|
||||
|
||||
@@ -13,6 +13,10 @@ from crewai_cli.plus_api import PlusAPI
|
||||
console = Console()
|
||||
|
||||
|
||||
class AuthenticationRequiredError(SystemExit):
|
||||
"""Raised when a Plus API command needs the user to log in first."""
|
||||
|
||||
|
||||
class BaseCommand:
|
||||
def __init__(self) -> None:
|
||||
self._telemetry = Telemetry()
|
||||
@@ -31,7 +35,7 @@ class PlusAPIMixin:
|
||||
style="bold red",
|
||||
)
|
||||
console.print("Run 'crewai login' to sign up/login.", style="bold green")
|
||||
raise SystemExit from None
|
||||
raise AuthenticationRequiredError from None
|
||||
|
||||
def _validate_response(self, response: httpx.Response) -> None:
|
||||
"""Handle and display error messages from API responses.
|
||||
|
||||
1126
lib/cli/src/crewai_cli/create_json_crew.py
Normal file
1126
lib/cli/src/crewai_cli/create_json_crew.py
Normal file
File diff suppressed because it is too large
Load Diff
2098
lib/cli/src/crewai_cli/crew_run_tui.py
Normal file
2098
lib/cli/src/crewai_cli/crew_run_tui.py
Normal file
File diff suppressed because it is too large
Load Diff
409
lib/cli/src/crewai_cli/deploy/archive.py
Normal file
409
lib/cli/src/crewai_cli/deploy/archive.py
Normal file
@@ -0,0 +1,409 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
import tempfile
|
||||
from typing import Any
|
||||
import zipfile
|
||||
|
||||
from crewai_cli import git
|
||||
from crewai_cli.deploy.validate import normalize_package_name
|
||||
from crewai_cli.utils import parse_toml
|
||||
|
||||
|
||||
_EXCLUDED_DIRS = {
|
||||
".crewai",
|
||||
".git",
|
||||
".mypy_cache",
|
||||
".pytest_cache",
|
||||
".ruff_cache",
|
||||
".tox",
|
||||
".venv",
|
||||
"__pycache__",
|
||||
"build",
|
||||
"dist",
|
||||
"env",
|
||||
"venv",
|
||||
}
|
||||
_EXCLUDED_FILES = {
|
||||
".DS_Store",
|
||||
".env",
|
||||
}
|
||||
_ALLOWED_ENV_EXAMPLES = {
|
||||
".env.example",
|
||||
".env.sample",
|
||||
}
|
||||
_EXCLUDED_SUFFIXES = {
|
||||
".pyc",
|
||||
".pyo",
|
||||
}
|
||||
_SCRIPT_KEY_PATTERN = re.compile(r"^\s*(?P<key>[A-Za-z0-9_.-]+|\"[^\"]+\"|'[^']+')\s*=")
|
||||
_SECTION_PATTERN = re.compile(r"^\s*\[[^\]]+\]\s*(?:#.*)?$")
|
||||
|
||||
|
||||
def create_project_zip(
|
||||
project_name: str,
|
||||
*,
|
||||
project_dir: Path | None = None,
|
||||
repository: git.Repository | None = None,
|
||||
) -> Path:
|
||||
"""Create a deployable ZIP archive for a CrewAI project."""
|
||||
root = (project_dir or Path.cwd()).resolve()
|
||||
files = _project_files(root, repository)
|
||||
if not files:
|
||||
raise ValueError("No deployable project files were found.")
|
||||
|
||||
staged_root = _stage_project(root, files)
|
||||
archive_handle = tempfile.NamedTemporaryFile(
|
||||
prefix=f"{project_name}-",
|
||||
suffix=".zip",
|
||||
delete=False,
|
||||
)
|
||||
archive_path = Path(archive_handle.name)
|
||||
archive_handle.close()
|
||||
|
||||
try:
|
||||
with zipfile.ZipFile(archive_path, "w", zipfile.ZIP_DEFLATED) as zip_file:
|
||||
for relative_path in _walk_files(staged_root):
|
||||
absolute_path = staged_root / relative_path
|
||||
zip_file.write(absolute_path, relative_path.as_posix())
|
||||
finally:
|
||||
shutil.rmtree(staged_root, ignore_errors=True)
|
||||
|
||||
return archive_path
|
||||
|
||||
|
||||
def _project_files(root: Path, repository: git.Repository | None = None) -> list[Path]:
|
||||
"""Return project-relative files to include in the archive."""
|
||||
if repository is not None:
|
||||
return _repository_project_files(root, repository)
|
||||
|
||||
try:
|
||||
repository = git.Repository(path=str(root), fetch=False)
|
||||
except ValueError:
|
||||
repository = None
|
||||
|
||||
if repository is not None:
|
||||
return _repository_project_files(root, repository)
|
||||
|
||||
return [
|
||||
path
|
||||
for path in _walk_files(root)
|
||||
if not _is_excluded(path) and _is_regular_file(root / path)
|
||||
]
|
||||
|
||||
|
||||
def _repository_project_files(root: Path, repository: git.Repository) -> list[Path]:
|
||||
"""Return deployable files from Git while applying local safety excludes."""
|
||||
files = [Path(path) for path in repository.deployable_files()]
|
||||
return [
|
||||
path
|
||||
for path in files
|
||||
if not _is_excluded(path) and _is_regular_file(root / path)
|
||||
]
|
||||
|
||||
|
||||
def _walk_files(root: Path) -> list[Path]:
|
||||
"""List regular files below root as project-relative paths."""
|
||||
return [
|
||||
path.relative_to(root) for path in root.rglob("*") if _is_regular_file(path)
|
||||
]
|
||||
|
||||
|
||||
def _is_regular_file(path: Path) -> bool:
|
||||
"""Return True for regular files, excluding symlinks to files."""
|
||||
return path.is_file() and not path.is_symlink()
|
||||
|
||||
|
||||
def _is_excluded(path: Path) -> bool:
|
||||
"""Return True when a file should be omitted from deployment ZIPs."""
|
||||
parts = set(path.parts)
|
||||
if parts.intersection(_EXCLUDED_DIRS):
|
||||
return True
|
||||
|
||||
name = path.name
|
||||
if name in _EXCLUDED_FILES:
|
||||
return True
|
||||
if name.startswith(".env.") and name not in _ALLOWED_ENV_EXAMPLES:
|
||||
return True
|
||||
return path.suffix in _EXCLUDED_SUFFIXES
|
||||
|
||||
|
||||
def _stage_project(root: Path, files: list[Path]) -> Path:
|
||||
"""Copy archive files into a temporary staging directory."""
|
||||
staging_root = Path(tempfile.mkdtemp(prefix="crewai-deploy-"))
|
||||
|
||||
try:
|
||||
for relative_path in files:
|
||||
source = root / relative_path
|
||||
if not _is_regular_file(source):
|
||||
continue
|
||||
|
||||
destination = staging_root / relative_path
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(source, destination)
|
||||
|
||||
if _is_json_crew_project(staging_root):
|
||||
_add_json_crew_deploy_wrapper(staging_root)
|
||||
except Exception:
|
||||
shutil.rmtree(staging_root, ignore_errors=True)
|
||||
raise
|
||||
return staging_root
|
||||
|
||||
|
||||
def _is_json_crew_project(root: Path) -> bool:
|
||||
"""Return True for JSON crew projects that need a Python deploy wrapper."""
|
||||
if not ((root / "crew.jsonc").is_file() or (root / "crew.json").is_file()):
|
||||
return False
|
||||
|
||||
project = _read_pyproject(root)
|
||||
tool_config = project.get("tool") or {}
|
||||
crewai_config = tool_config.get("crewai") if isinstance(tool_config, dict) else None
|
||||
declared_type = (
|
||||
crewai_config.get("type") if isinstance(crewai_config, dict) else None
|
||||
)
|
||||
if declared_type == "flow":
|
||||
return False
|
||||
|
||||
package_name = _package_name(root)
|
||||
if package_name is None:
|
||||
raise ValueError(
|
||||
"Could not derive a valid Python package name from [project].name."
|
||||
)
|
||||
|
||||
return not (root / "src" / package_name / "crew.py").is_file()
|
||||
|
||||
|
||||
def _read_pyproject(root: Path) -> dict[str, Any]:
|
||||
"""Read pyproject.toml, returning an empty mapping on missing or invalid data."""
|
||||
pyproject_path = root / "pyproject.toml"
|
||||
if not pyproject_path.is_file():
|
||||
return {}
|
||||
try:
|
||||
pyproject = parse_toml(pyproject_path.read_text())
|
||||
except Exception:
|
||||
return {}
|
||||
return pyproject if isinstance(pyproject, dict) else {}
|
||||
|
||||
|
||||
def _package_name(root: Path) -> str | None:
|
||||
"""Return the normalized Python package name for the project."""
|
||||
project = _read_pyproject(root).get("project")
|
||||
if not isinstance(project, dict):
|
||||
return None
|
||||
|
||||
name = project.get("name")
|
||||
if not isinstance(name, str) or not name.strip():
|
||||
return None
|
||||
|
||||
package_name = normalize_package_name(name)
|
||||
return package_name or None
|
||||
|
||||
|
||||
def _class_name(package_name: str) -> str:
|
||||
"""Return the generated wrapper class name for a package."""
|
||||
parts = [part for part in re.split(r"[^a-zA-Z0-9]+", package_name) if part]
|
||||
class_name = "".join(part[:1].upper() + part[1:] for part in parts)
|
||||
if not class_name:
|
||||
return "JsonCrew"
|
||||
if class_name[0].isdigit():
|
||||
return f"Crew{class_name}"
|
||||
return class_name
|
||||
|
||||
|
||||
def _add_json_crew_deploy_wrapper(root: Path) -> None:
|
||||
"""Add Python wrapper files required to deploy a JSON crew project."""
|
||||
package_name = _package_name(root)
|
||||
if package_name is None:
|
||||
raise ValueError(
|
||||
"Could not derive a valid Python package name from [project].name."
|
||||
)
|
||||
|
||||
package_dir = root / "src" / package_name
|
||||
config_dir = package_dir / "config"
|
||||
config_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
class_name = _class_name(package_name)
|
||||
crew_filename = "crew.jsonc" if (root / "crew.jsonc").is_file() else "crew.json"
|
||||
|
||||
(package_dir / "__init__.py").write_text("", encoding="utf-8")
|
||||
(config_dir / "agents.yaml").write_text("{}\n", encoding="utf-8")
|
||||
(config_dir / "tasks.yaml").write_text("{}\n", encoding="utf-8")
|
||||
(package_dir / "crew.py").write_text(
|
||||
_json_crew_py(class_name, crew_filename),
|
||||
encoding="utf-8",
|
||||
)
|
||||
(package_dir / "main.py").write_text(
|
||||
_json_main_py(package_name, class_name),
|
||||
encoding="utf-8",
|
||||
)
|
||||
_ensure_project_scripts(root, package_name)
|
||||
|
||||
|
||||
def _json_crew_py(class_name: str, crew_filename: str) -> str:
|
||||
"""Render the generated crew.py module for a JSON crew."""
|
||||
return f'''from pathlib import Path
|
||||
|
||||
from crewai import Crew
|
||||
from crewai.project import CrewBase, crew
|
||||
from crewai.project.crew_loader import load_crew
|
||||
|
||||
|
||||
def _crew_path() -> Path:
|
||||
return Path(__file__).resolve().parents[2] / "{crew_filename}"
|
||||
|
||||
|
||||
@CrewBase
|
||||
class {class_name}:
|
||||
"""Compatibility wrapper for a JSON-defined CrewAI project."""
|
||||
|
||||
@crew
|
||||
def crew(self) -> Crew:
|
||||
crew_instance, default_inputs = load_crew(_crew_path())
|
||||
self.default_inputs = default_inputs
|
||||
return crew_instance
|
||||
'''
|
||||
|
||||
|
||||
def _json_main_py(package_name: str, class_name: str) -> str:
|
||||
"""Render the generated main.py entrypoints for a JSON crew."""
|
||||
return f"""#!/usr/bin/env python
|
||||
import json
|
||||
import sys
|
||||
|
||||
from {package_name}.crew import {class_name}
|
||||
|
||||
|
||||
def _load():
|
||||
wrapper = {class_name}()
|
||||
crew = wrapper.crew()
|
||||
return crew, getattr(wrapper, "default_inputs", {{}})
|
||||
|
||||
|
||||
def run():
|
||||
crew, inputs = _load()
|
||||
return crew.kickoff(inputs=inputs)
|
||||
|
||||
|
||||
def train():
|
||||
crew, inputs = _load()
|
||||
return crew.train(
|
||||
n_iterations=int(sys.argv[1]),
|
||||
filename=sys.argv[2],
|
||||
inputs=inputs,
|
||||
)
|
||||
|
||||
|
||||
def replay():
|
||||
crew, _ = _load()
|
||||
return crew.replay(task_id=sys.argv[1])
|
||||
|
||||
|
||||
def test():
|
||||
crew, inputs = _load()
|
||||
return crew.test(
|
||||
n_iterations=int(sys.argv[1]),
|
||||
eval_llm=sys.argv[2],
|
||||
inputs=inputs,
|
||||
)
|
||||
|
||||
|
||||
def run_with_trigger():
|
||||
if len(sys.argv) < 2:
|
||||
raise ValueError("No trigger payload provided.")
|
||||
|
||||
crew, inputs = _load()
|
||||
trigger_payload = json.loads(sys.argv[1])
|
||||
return crew.kickoff(
|
||||
inputs={{**inputs, "crewai_trigger_payload": trigger_payload}}
|
||||
)
|
||||
"""
|
||||
|
||||
|
||||
def _ensure_project_scripts(root: Path, package_name: str) -> None:
|
||||
"""Ensure generated wrappers have project script entrypoints."""
|
||||
pyproject_path = root / "pyproject.toml"
|
||||
if not pyproject_path.is_file():
|
||||
return
|
||||
|
||||
content = pyproject_path.read_text(encoding="utf-8")
|
||||
entries = _project_script_entries(package_name)
|
||||
pyproject_path.write_text(
|
||||
_update_project_scripts(content, entries),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def _project_script_entries(package_name: str) -> dict[str, str]:
|
||||
"""Return script entrypoints required by the generated JSON wrapper."""
|
||||
return {
|
||||
package_name: f"{package_name}.main:run",
|
||||
"run_crew": f"{package_name}.main:run",
|
||||
"train": f"{package_name}.main:train",
|
||||
"replay": f"{package_name}.main:replay",
|
||||
"test": f"{package_name}.main:test",
|
||||
"run_with_trigger": f"{package_name}.main:run_with_trigger",
|
||||
}
|
||||
|
||||
|
||||
def _update_project_scripts(content: str, entries: dict[str, str]) -> str:
|
||||
"""Add or replace generated script entries in pyproject.toml content."""
|
||||
lines = content.rstrip().splitlines()
|
||||
header_index = _project_scripts_header_index(lines)
|
||||
if header_index is None:
|
||||
return content.rstrip() + _project_scripts_block(entries)
|
||||
|
||||
end_index = _section_end_index(lines, header_index + 1)
|
||||
seen: set[str] = set()
|
||||
for index in range(header_index + 1, end_index):
|
||||
key = _script_key(lines[index])
|
||||
if key in entries:
|
||||
lines[index] = _script_line(key, entries[key])
|
||||
seen.add(key)
|
||||
|
||||
missing_lines = [
|
||||
_script_line(key, value) for key, value in entries.items() if key not in seen
|
||||
]
|
||||
lines[end_index:end_index] = missing_lines
|
||||
return "\n".join(lines).rstrip() + "\n"
|
||||
|
||||
|
||||
def _project_scripts_header_index(lines: list[str]) -> int | None:
|
||||
"""Return the line index of the project scripts table, if present."""
|
||||
for index, line in enumerate(lines):
|
||||
if line.strip() == "[project.scripts]":
|
||||
return index
|
||||
return None
|
||||
|
||||
|
||||
def _section_end_index(lines: list[str], start_index: int) -> int:
|
||||
"""Return the exclusive end index for a TOML table section."""
|
||||
for index in range(start_index, len(lines)):
|
||||
if _SECTION_PATTERN.match(lines[index]):
|
||||
return index
|
||||
return len(lines)
|
||||
|
||||
|
||||
def _script_key(line: str) -> str | None:
|
||||
"""Return the script key for a pyproject script line."""
|
||||
match = _SCRIPT_KEY_PATTERN.match(line)
|
||||
if not match:
|
||||
return None
|
||||
|
||||
key = match.group("key")
|
||||
if key.startswith(("'", '"')) and key.endswith(("'", '"')):
|
||||
return key[1:-1]
|
||||
return key
|
||||
|
||||
|
||||
def _script_line(key: str, value: str) -> str:
|
||||
"""Render a project script TOML entry."""
|
||||
return f'{key} = "{value}"'
|
||||
|
||||
|
||||
def _project_scripts_block(entries: dict[str, str]) -> str:
|
||||
"""Render a project scripts TOML table."""
|
||||
lines = ["", "", "[project.scripts]"]
|
||||
lines.extend(_script_line(key, value) for key, value in entries.items())
|
||||
return "\n".join(lines) + "\n"
|
||||
@@ -1,3 +1,5 @@
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
from typing import Any
|
||||
|
||||
from crewai_core.plus_api import CreateCrewPayload
|
||||
@@ -5,14 +7,19 @@ from rich.console import Console
|
||||
|
||||
from crewai_cli import git
|
||||
from crewai_cli.command import BaseCommand, PlusAPIMixin
|
||||
from crewai_cli.deploy.validate import validate_project
|
||||
from crewai_cli.deploy.archive import create_project_zip
|
||||
from crewai_cli.deploy.validate import DeployValidator, Severity, render_report
|
||||
from crewai_cli.utils import fetch_and_json_env_file, get_project_name
|
||||
|
||||
|
||||
console = Console()
|
||||
_MISSING_LOCKFILE_ERROR_CODES = {"missing_lockfile"}
|
||||
|
||||
|
||||
def _run_predeploy_validation(skip_validate: bool) -> bool:
|
||||
def _run_predeploy_validation(
|
||||
skip_validate: bool,
|
||||
ignored_error_codes: set[str] | None = None,
|
||||
) -> bool:
|
||||
"""Run pre-deploy validation unless skipped.
|
||||
|
||||
Returns True if deployment should proceed, False if it should abort.
|
||||
@@ -24,8 +31,22 @@ def _run_predeploy_validation(skip_validate: bool) -> bool:
|
||||
return True
|
||||
|
||||
console.print("Running pre-deploy validation...", style="bold blue")
|
||||
validator = validate_project()
|
||||
if not validator.ok:
|
||||
validator = DeployValidator()
|
||||
validator.run()
|
||||
|
||||
ignored_error_codes = ignored_error_codes or set()
|
||||
visible_results = [
|
||||
result
|
||||
for result in validator.results
|
||||
if result.severity is not Severity.ERROR
|
||||
or result.code not in ignored_error_codes
|
||||
]
|
||||
render_report(visible_results)
|
||||
|
||||
blocking_errors = [
|
||||
result for result in validator.errors if result.code not in ignored_error_codes
|
||||
]
|
||||
if blocking_errors:
|
||||
console.print(
|
||||
"\n[bold red]Pre-deploy validation failed. "
|
||||
"Fix the issues above or re-run with --skip-validate.[/bold red]"
|
||||
@@ -34,6 +55,79 @@ def _run_predeploy_validation(skip_validate: bool) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
def _display_git_repository_help() -> None:
|
||||
"""Explain how to prepare a new project for deployment."""
|
||||
console.print(
|
||||
"Initialized a local Git repository and created an initial commit.",
|
||||
style="green",
|
||||
)
|
||||
|
||||
|
||||
def _display_git_remote_help() -> None:
|
||||
"""Explain that ZIP deployment will be used without an origin remote."""
|
||||
console.print(
|
||||
"No origin remote found. Deploying from a ZIP upload instead.",
|
||||
style="yellow",
|
||||
)
|
||||
|
||||
|
||||
def _env_summary(env_vars: dict[str, str]) -> str:
|
||||
"""Return a compact description of environment variables for prompts."""
|
||||
if not env_vars:
|
||||
return "0 env vars"
|
||||
keys = ", ".join(sorted(env_vars))
|
||||
return f"{len(env_vars)} env vars: {keys}"
|
||||
|
||||
|
||||
def _needs_lockfile_for_deploy(project_root: Path | None = None) -> bool:
|
||||
"""Return True when deploy should create the project's first lockfile."""
|
||||
root = project_root or Path.cwd()
|
||||
if not (root / "pyproject.toml").is_file():
|
||||
return False
|
||||
return not (root / "uv.lock").is_file() and not (root / "poetry.lock").is_file()
|
||||
|
||||
|
||||
def _ensure_lockfile_for_deploy() -> None:
|
||||
"""Create a uv lockfile before deploy when a project has not been run yet."""
|
||||
if not _needs_lockfile_for_deploy():
|
||||
return
|
||||
|
||||
from crewai_cli.install_crew import install_crew
|
||||
|
||||
console.print(
|
||||
"No lockfile found. Installing dependencies before deployment...",
|
||||
style="bold blue",
|
||||
)
|
||||
try:
|
||||
install_crew([], raise_on_error=True)
|
||||
except subprocess.CalledProcessError as e:
|
||||
raise SystemExit(e.returncode) from e
|
||||
except Exception as e:
|
||||
raise SystemExit(1) from e
|
||||
|
||||
|
||||
def _prepare_project_for_deploy(skip_validate: bool) -> bool:
|
||||
"""Validate deploy inputs before creating a missing lockfile."""
|
||||
if skip_validate:
|
||||
_run_predeploy_validation(skip_validate)
|
||||
_ensure_lockfile_for_deploy()
|
||||
return True
|
||||
|
||||
needs_lockfile = _needs_lockfile_for_deploy()
|
||||
ignored_error_codes = _MISSING_LOCKFILE_ERROR_CODES if needs_lockfile else None
|
||||
if not _run_predeploy_validation(
|
||||
skip_validate,
|
||||
ignored_error_codes=ignored_error_codes,
|
||||
):
|
||||
return False
|
||||
|
||||
if not needs_lockfile:
|
||||
return True
|
||||
|
||||
_ensure_lockfile_for_deploy()
|
||||
return _run_predeploy_validation(skip_validate)
|
||||
|
||||
|
||||
class DeployCommand(BaseCommand, PlusAPIMixin):
|
||||
"""
|
||||
A class to handle deployment-related operations for CrewAI projects.
|
||||
@@ -92,14 +186,30 @@ class DeployCommand(BaseCommand, PlusAPIMixin):
|
||||
uuid (Optional[str]): The UUID of the crew to deploy.
|
||||
skip_validate (bool): Skip pre-deploy validation checks.
|
||||
"""
|
||||
if not _run_predeploy_validation(skip_validate):
|
||||
if not _prepare_project_for_deploy(skip_validate):
|
||||
return
|
||||
self._telemetry.start_deployment_span(uuid)
|
||||
console.print("Starting deployment...", style="bold blue")
|
||||
if uuid:
|
||||
repository = self._prepare_git_repository()
|
||||
remote_repo_url = repository.origin_url() if repository else None
|
||||
|
||||
if remote_repo_url and uuid:
|
||||
response = self.plus_api_client.deploy_by_uuid(uuid)
|
||||
elif self.project_name:
|
||||
elif remote_repo_url and self.project_name:
|
||||
response = self.plus_api_client.deploy_by_name(self.project_name)
|
||||
elif uuid:
|
||||
_display_git_remote_help()
|
||||
env_vars = fetch_and_json_env_file()
|
||||
response = self._update_crew_from_zip(uuid, repository, env_vars)
|
||||
elif self.project_name:
|
||||
_display_git_remote_help()
|
||||
deployment_uuid = self._deployment_uuid_by_name()
|
||||
env_vars = fetch_and_json_env_file()
|
||||
response = self._update_crew_from_zip(
|
||||
deployment_uuid,
|
||||
repository,
|
||||
env_vars,
|
||||
)
|
||||
else:
|
||||
self._standard_no_param_error_message()
|
||||
return
|
||||
@@ -107,6 +217,19 @@ class DeployCommand(BaseCommand, PlusAPIMixin):
|
||||
self._validate_response(response)
|
||||
self._display_deployment_info(response.json())
|
||||
|
||||
def _deployment_uuid_by_name(self) -> str:
|
||||
"""Resolve the current project's deployment UUID by project name."""
|
||||
if not self.project_name:
|
||||
raise ValueError("project_name is required to find a deployment")
|
||||
|
||||
response = self.plus_api_client.crew_status_by_name(self.project_name)
|
||||
self._validate_response(response)
|
||||
json_response = response.json()
|
||||
uuid = json_response.get("uuid")
|
||||
if not uuid:
|
||||
raise ValueError("Deployment status response did not include a uuid")
|
||||
return str(uuid)
|
||||
|
||||
def create_crew(self, confirm: bool = False, skip_validate: bool = False) -> None:
|
||||
"""
|
||||
Create a new crew deployment.
|
||||
@@ -115,32 +238,143 @@ class DeployCommand(BaseCommand, PlusAPIMixin):
|
||||
confirm (bool): Whether to skip the interactive confirmation prompt.
|
||||
skip_validate (bool): Skip pre-deploy validation checks.
|
||||
"""
|
||||
if not _run_predeploy_validation(skip_validate):
|
||||
if not _prepare_project_for_deploy(skip_validate):
|
||||
return
|
||||
self._telemetry.create_crew_deployment_span()
|
||||
console.print("Creating deployment...", style="bold blue")
|
||||
env_vars = fetch_and_json_env_file()
|
||||
repository = self._prepare_git_repository()
|
||||
remote_repo_url = repository.origin_url() if repository else None
|
||||
|
||||
try:
|
||||
remote_repo_url = git.Repository().origin_url()
|
||||
except ValueError:
|
||||
remote_repo_url = None
|
||||
|
||||
if remote_repo_url is None:
|
||||
console.print("No remote repository URL found.", style="bold red")
|
||||
console.print(
|
||||
"Please ensure your project has a valid remote repository.",
|
||||
style="yellow",
|
||||
)
|
||||
return
|
||||
|
||||
self._confirm_input(env_vars, remote_repo_url, confirm)
|
||||
payload = self._create_payload(env_vars, remote_repo_url)
|
||||
response = self.plus_api_client.create_crew(payload)
|
||||
if remote_repo_url:
|
||||
self._confirm_input(env_vars, remote_repo_url, confirm)
|
||||
payload = self._create_payload(env_vars, remote_repo_url)
|
||||
response = self.plus_api_client.create_crew(payload)
|
||||
else:
|
||||
_display_git_remote_help()
|
||||
response = self._create_crew_from_zip(env_vars, repository, confirm)
|
||||
|
||||
self._validate_response(response)
|
||||
self._display_creation_success(response.json())
|
||||
|
||||
def _prepare_git_repository(self) -> git.Repository | None:
|
||||
"""Prepare Git for deploy while preserving remote deploy when possible."""
|
||||
try:
|
||||
repository = git.Repository(fetch=False)
|
||||
except ValueError as exc:
|
||||
if "not a Git repository" not in str(exc):
|
||||
console.print(
|
||||
f"{exc} Continuing with ZIP deployment.",
|
||||
style="yellow",
|
||||
)
|
||||
return None
|
||||
|
||||
try:
|
||||
repository = git.Repository.initialize()
|
||||
except Exception as init_error:
|
||||
console.print(
|
||||
"Git auto-setup did not complete. Continuing with ZIP deployment.",
|
||||
style="yellow",
|
||||
)
|
||||
console.print(str(init_error), style="dim")
|
||||
try:
|
||||
return git.Repository(fetch=False)
|
||||
except Exception as repository_error:
|
||||
console.print(str(repository_error), style="dim")
|
||||
return None
|
||||
|
||||
_display_git_repository_help()
|
||||
return repository
|
||||
|
||||
remote_repo_url = repository.origin_url()
|
||||
if remote_repo_url:
|
||||
try:
|
||||
repository.fetch()
|
||||
except ValueError as fetch_error:
|
||||
console.print(
|
||||
"Could not fetch from origin. Continuing with remote deployment.",
|
||||
style="yellow",
|
||||
)
|
||||
console.print(str(fetch_error), style="dim")
|
||||
|
||||
try:
|
||||
if repository.create_initial_commit_if_needed():
|
||||
console.print(
|
||||
"Created an initial Git commit for this project.",
|
||||
style="green",
|
||||
)
|
||||
except Exception as commit_error:
|
||||
console.print(
|
||||
"Could not create an initial Git commit. "
|
||||
"Continuing with remote deployment.",
|
||||
style="yellow",
|
||||
)
|
||||
console.print(str(commit_error), style="dim")
|
||||
|
||||
return repository
|
||||
|
||||
try:
|
||||
if repository.create_initial_commit_if_needed():
|
||||
console.print(
|
||||
"Created an initial Git commit for this project.",
|
||||
style="green",
|
||||
)
|
||||
except Exception as commit_error:
|
||||
console.print(
|
||||
"Could not create an initial Git commit. "
|
||||
"Continuing with ZIP deployment using Git file listing.",
|
||||
style="yellow",
|
||||
)
|
||||
console.print(str(commit_error), style="dim")
|
||||
return repository
|
||||
|
||||
return repository
|
||||
|
||||
def _create_crew_from_zip(
|
||||
self,
|
||||
env_vars: dict[str, str],
|
||||
repository: git.Repository | None,
|
||||
confirm: bool,
|
||||
) -> Any:
|
||||
"""Create a deployment by uploading a project ZIP archive."""
|
||||
if not self.project_name:
|
||||
raise ValueError("project_name is required to create a ZIP deployment")
|
||||
|
||||
console.print("Preparing project ZIP...", style="bold blue")
|
||||
zip_file_path = create_project_zip(self.project_name, repository=repository)
|
||||
try:
|
||||
self._confirm_zip_input(env_vars, confirm)
|
||||
console.print("Uploading project ZIP...", style="bold blue")
|
||||
return self.plus_api_client.create_crew_from_zip(
|
||||
zip_file_path,
|
||||
name=self.project_name,
|
||||
env=env_vars,
|
||||
)
|
||||
finally:
|
||||
zip_file_path.unlink(missing_ok=True)
|
||||
|
||||
def _update_crew_from_zip(
|
||||
self,
|
||||
uuid: str,
|
||||
repository: git.Repository | None,
|
||||
env_vars: dict[str, str],
|
||||
) -> Any:
|
||||
"""Update an existing deployment by uploading a project ZIP archive."""
|
||||
if not self.project_name:
|
||||
raise ValueError("project_name is required to update a ZIP deployment")
|
||||
|
||||
console.print("Preparing project ZIP...", style="bold blue")
|
||||
zip_file_path = create_project_zip(self.project_name, repository=repository)
|
||||
try:
|
||||
console.print("Uploading project ZIP...", style="bold blue")
|
||||
return self.plus_api_client.update_crew_from_zip(
|
||||
uuid,
|
||||
zip_file_path,
|
||||
env=env_vars,
|
||||
)
|
||||
finally:
|
||||
zip_file_path.unlink(missing_ok=True)
|
||||
|
||||
def _confirm_input(
|
||||
self, env_vars: dict[str, str], remote_repo_url: str, confirm: bool
|
||||
) -> None:
|
||||
@@ -153,11 +387,16 @@ class DeployCommand(BaseCommand, PlusAPIMixin):
|
||||
confirm (bool): Whether to confirm input.
|
||||
"""
|
||||
if not confirm:
|
||||
input(f"Press Enter to continue with the following Env vars: {env_vars}")
|
||||
input(f"Press Enter to continue with {_env_summary(env_vars)}")
|
||||
input(
|
||||
f"Press Enter to continue with the following remote repository: {remote_repo_url}\n"
|
||||
)
|
||||
|
||||
def _confirm_zip_input(self, env_vars: dict[str, str], confirm: bool) -> None:
|
||||
"""Prompt before ZIP upload unless confirmation was already supplied."""
|
||||
if not confirm:
|
||||
input(f"Press Enter to continue with {_env_summary(env_vars)}")
|
||||
|
||||
def _create_payload(
|
||||
self,
|
||||
env_vars: dict[str, str],
|
||||
|
||||
@@ -38,6 +38,12 @@ import subprocess
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
from crewai.project.json_loader import (
|
||||
JSONProjectValidationError,
|
||||
find_crew_json_file,
|
||||
find_json_project_file,
|
||||
validate_crew_project,
|
||||
)
|
||||
from rich.console import Console
|
||||
|
||||
from crewai_cli.utils import parse_toml
|
||||
@@ -151,9 +157,33 @@ class DeployValidator:
|
||||
def ok(self) -> bool:
|
||||
return not self.errors
|
||||
|
||||
@property
|
||||
def _is_json_crew(self) -> bool:
|
||||
"""True for JSON crew projects, deferring to the declared type.
|
||||
|
||||
A flow project that also contains a crew.json(c) file validates as
|
||||
the flow it declares in pyproject.toml, not as a JSON crew.
|
||||
"""
|
||||
if find_crew_json_file(self.project_root) is None:
|
||||
return False
|
||||
pyproject_path = self.project_root / "pyproject.toml"
|
||||
if not pyproject_path.exists():
|
||||
return True
|
||||
try:
|
||||
data = parse_toml(pyproject_path.read_text())
|
||||
except Exception:
|
||||
return True
|
||||
declared_type: str | None = (
|
||||
(data.get("tool") or {}).get("crewai", {}).get("type")
|
||||
)
|
||||
return declared_type != "flow"
|
||||
|
||||
def run(self) -> list[ValidationResult]:
|
||||
"""Run all checks. Later checks are skipped when earlier ones make
|
||||
them impossible (e.g. no pyproject.toml → no lockfile check)."""
|
||||
if self._is_json_crew:
|
||||
return self._run_json_checks()
|
||||
|
||||
if not self._check_pyproject():
|
||||
return self.results
|
||||
|
||||
@@ -176,6 +206,110 @@ class DeployValidator:
|
||||
|
||||
return self.results
|
||||
|
||||
def _run_json_checks(self) -> list[ValidationResult]:
|
||||
"""Validation suite for JSON-defined crew projects."""
|
||||
crew_path = find_crew_json_file(self.project_root)
|
||||
if crew_path is None:
|
||||
return self.results
|
||||
|
||||
try:
|
||||
project = validate_crew_project(crew_path, self.project_root / "agents")
|
||||
except JSONProjectValidationError as e:
|
||||
self._add(
|
||||
Severity.ERROR,
|
||||
"invalid_crew_json",
|
||||
f"{crew_path.name} has invalid JSON crew configuration",
|
||||
detail="\n".join(e.errors),
|
||||
hint="Fix the JSON crew, agent, and task references before deploying.",
|
||||
)
|
||||
return self.results
|
||||
except Exception as e:
|
||||
self._add(
|
||||
Severity.ERROR,
|
||||
"invalid_crew_json",
|
||||
f"Cannot parse {crew_path.name}",
|
||||
detail=str(e),
|
||||
)
|
||||
return self.results
|
||||
|
||||
agents_dir = self.project_root / "agents"
|
||||
|
||||
self._check_pyproject()
|
||||
self._check_lockfile()
|
||||
self._check_env_vars_json(crew_path, agents_dir, project.agent_names)
|
||||
self._check_version_vs_lockfile()
|
||||
|
||||
return self.results
|
||||
|
||||
def _check_env_vars_json(
|
||||
self, crew_path: Path, agents_dir: Path, agent_names: list[str]
|
||||
) -> None:
|
||||
"""Check for env var references in JSON crew files."""
|
||||
referenced: set[str] = set()
|
||||
pattern = re.compile(r"\$\{?([A-Z][A-Z0-9_]+)\}?")
|
||||
|
||||
try:
|
||||
referenced.update(pattern.findall(crew_path.read_text(errors="ignore")))
|
||||
except OSError as exc:
|
||||
logger.debug("Skipping unreadable crew file %s: %s", crew_path, exc)
|
||||
|
||||
for name in agent_names:
|
||||
agent_path = find_json_project_file(agents_dir, name)
|
||||
if agent_path is None:
|
||||
continue
|
||||
try:
|
||||
referenced.update(
|
||||
pattern.findall(agent_path.read_text(errors="ignore"))
|
||||
)
|
||||
except OSError as exc:
|
||||
logger.debug("Skipping unreadable agent file %s: %s", agent_path, exc)
|
||||
|
||||
for py_path in self.project_root.rglob("*.py"):
|
||||
if ".venv" in py_path.parts:
|
||||
continue
|
||||
try:
|
||||
text = py_path.read_text(encoding="utf-8", errors="ignore")
|
||||
except OSError:
|
||||
continue
|
||||
env_pattern = re.compile(
|
||||
r"""(?x)
|
||||
(?:os\.environ\s*(?:\[\s*|\.get\s*\(\s*)
|
||||
|os\.getenv\s*\(\s*
|
||||
|getenv\s*\(\s*)
|
||||
['"]([A-Z][A-Z0-9_]*)['"]
|
||||
"""
|
||||
)
|
||||
referenced.update(env_pattern.findall(text))
|
||||
|
||||
env_file = self.project_root / ".env"
|
||||
env_keys: set[str] = set()
|
||||
if env_file.exists():
|
||||
for line in env_file.read_text(errors="ignore").splitlines():
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
env_keys.add(line.split("=", 1)[0].strip())
|
||||
|
||||
missing_known = sorted(
|
||||
var
|
||||
for var in referenced
|
||||
if var in _KNOWN_API_KEY_HINTS
|
||||
and var not in env_keys
|
||||
and var not in os.environ
|
||||
)
|
||||
if missing_known:
|
||||
self._add(
|
||||
Severity.WARNING,
|
||||
"env_vars_not_in_dotenv",
|
||||
f"{len(missing_known)} referenced API key(s) not in .env",
|
||||
detail=(
|
||||
"These env vars are referenced in your project but not set "
|
||||
f"locally: {', '.join(missing_known)}. Deploys will fail "
|
||||
"unless they are added to the deployment's Environment "
|
||||
"Variables in the CrewAI dashboard."
|
||||
),
|
||||
)
|
||||
|
||||
def _check_pyproject(self) -> bool:
|
||||
pyproject_path = self.project_root / "pyproject.toml"
|
||||
if not pyproject_path.exists():
|
||||
|
||||
@@ -1,9 +1,31 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import cached_property
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
|
||||
|
||||
_INITIAL_COMMIT_EXCLUDE_PATTERNS = [
|
||||
".crewai/",
|
||||
".env",
|
||||
".env.*",
|
||||
"!.env.example",
|
||||
"!.env.sample",
|
||||
".mypy_cache/",
|
||||
".pytest_cache/",
|
||||
".ruff_cache/",
|
||||
".tox/",
|
||||
".venv/",
|
||||
"__pycache__/",
|
||||
"build/",
|
||||
"dist/",
|
||||
"env/",
|
||||
"venv/",
|
||||
]
|
||||
|
||||
|
||||
class Repository:
|
||||
def __init__(self, path: str = ".") -> None:
|
||||
def __init__(self, path: str = ".", fetch: bool = True) -> None:
|
||||
self.path = path
|
||||
|
||||
if not self.is_git_installed():
|
||||
@@ -12,7 +34,8 @@ class Repository:
|
||||
if not self.is_git_repo:
|
||||
raise ValueError(f"{self.path} is not a Git repository.")
|
||||
|
||||
self.fetch()
|
||||
if fetch:
|
||||
self.fetch()
|
||||
|
||||
@staticmethod
|
||||
def is_git_installed() -> bool:
|
||||
@@ -30,7 +53,33 @@ class Repository:
|
||||
|
||||
def fetch(self) -> None:
|
||||
"""Fetch latest updates from the remote."""
|
||||
subprocess.run(["git", "fetch"], cwd=self.path, check=True) # noqa: S607
|
||||
command = ["git", "fetch"]
|
||||
result = subprocess.run( # noqa: S603
|
||||
command,
|
||||
cwd=self.path,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return
|
||||
if "No remote repository specified" in result.stderr:
|
||||
return
|
||||
details = result.stderr.strip() or result.stdout.strip() or "no output"
|
||||
raise ValueError(
|
||||
f"Git fetch failed with exit code {result.returncode} "
|
||||
f"for command {command!r}: {details}"
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def initialize(cls, path: str = ".") -> Repository:
|
||||
"""Initialize a Git repository and create an initial commit if needed."""
|
||||
if not cls.is_git_installed():
|
||||
raise ValueError("Git is not installed or not found in your PATH.")
|
||||
|
||||
subprocess.run(["git", "init"], cwd=path, check=True) # noqa: S607
|
||||
repository = cls(path=path, fetch=False)
|
||||
repository.create_initial_commit_if_needed()
|
||||
return repository
|
||||
|
||||
def status(self) -> str:
|
||||
"""Get the git status in porcelain format."""
|
||||
@@ -48,6 +97,7 @@ class Repository:
|
||||
["git", "rev-parse", "--is-inside-work-tree"], # noqa: S607
|
||||
cwd=self.path,
|
||||
encoding="utf-8",
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
return True
|
||||
except subprocess.CalledProcessError:
|
||||
@@ -70,6 +120,74 @@ class Repository:
|
||||
return False
|
||||
return True
|
||||
|
||||
def has_commits(self) -> bool:
|
||||
"""Return True if the repository has at least one commit."""
|
||||
try:
|
||||
subprocess.run(
|
||||
["git", "rev-parse", "--verify", "HEAD"], # noqa: S607
|
||||
cwd=self.path,
|
||||
capture_output=True,
|
||||
check=True,
|
||||
text=True,
|
||||
)
|
||||
return True
|
||||
except subprocess.CalledProcessError:
|
||||
return False
|
||||
|
||||
def create_initial_commit_if_needed(self) -> bool:
|
||||
"""Create a local initial commit when the repository has no commits."""
|
||||
if self.has_commits():
|
||||
return False
|
||||
|
||||
self._ensure_initial_commit_excludes()
|
||||
subprocess.run(["git", "add", "."], cwd=self.path, check=True) # noqa: S607
|
||||
command = [
|
||||
"git",
|
||||
"-c",
|
||||
"user.name=CrewAI",
|
||||
"-c",
|
||||
"user.email=deploy@crewai.com",
|
||||
"commit",
|
||||
"--allow-empty",
|
||||
"-m",
|
||||
"Initial crew",
|
||||
]
|
||||
subprocess.run( # noqa: S603
|
||||
command,
|
||||
cwd=self.path,
|
||||
check=True,
|
||||
)
|
||||
return True
|
||||
|
||||
def _ensure_initial_commit_excludes(self) -> None:
|
||||
"""Add local-only ignore patterns before auto-staging an initial commit."""
|
||||
exclude_file = Path(self.path) / ".git" / "info" / "exclude"
|
||||
exclude_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
existing = exclude_file.read_text() if exclude_file.exists() else ""
|
||||
existing_lines = set(existing.splitlines())
|
||||
missing_patterns = [
|
||||
pattern
|
||||
for pattern in _INITIAL_COMMIT_EXCLUDE_PATTERNS
|
||||
if pattern not in existing_lines
|
||||
]
|
||||
if not missing_patterns:
|
||||
return
|
||||
|
||||
prefix = "" if existing.endswith("\n") or not existing else "\n"
|
||||
patterns = "\n".join(missing_patterns)
|
||||
exclude_file.write_text(
|
||||
f"{existing}{prefix}# CrewAI deploy auto-commit excludes\n{patterns}\n"
|
||||
)
|
||||
|
||||
def deployable_files(self) -> list[str]:
|
||||
"""Return files tracked by Git or untracked and not ignored."""
|
||||
output = subprocess.check_output(
|
||||
["git", "ls-files", "--cached", "--others", "--exclude-standard"], # noqa: S607
|
||||
cwd=self.path,
|
||||
encoding="utf-8",
|
||||
)
|
||||
return [line for line in output.splitlines() if line]
|
||||
|
||||
def origin_url(self) -> str | None:
|
||||
"""Get the Git repository's remote URL."""
|
||||
try:
|
||||
|
||||
@@ -1,20 +1,77 @@
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
|
||||
import click
|
||||
|
||||
from crewai_cli.utils import build_env_with_all_tool_credentials
|
||||
from crewai_cli.deploy.validate import normalize_package_name
|
||||
from crewai_cli.utils import build_env_with_all_tool_credentials, parse_toml
|
||||
|
||||
|
||||
def _find_json_crew_file(project_root: Path | None = None) -> Path | None:
|
||||
"""Return the JSON crew definition path when present."""
|
||||
root = project_root or Path.cwd()
|
||||
for filename in ("crew.jsonc", "crew.json"):
|
||||
crew_path = root / filename
|
||||
if crew_path.is_file():
|
||||
return crew_path
|
||||
return None
|
||||
|
||||
|
||||
def _is_json_crew_project(project_root: Path | None = None) -> bool:
|
||||
"""Return True for JSON crew projects that do not need package install."""
|
||||
root = project_root or Path.cwd()
|
||||
if _find_json_crew_file(root) is None:
|
||||
return False
|
||||
|
||||
pyproject_path = root / "pyproject.toml"
|
||||
if not pyproject_path.is_file():
|
||||
return True
|
||||
|
||||
try:
|
||||
pyproject = parse_toml(pyproject_path.read_text())
|
||||
except Exception:
|
||||
return True
|
||||
if not isinstance(pyproject, dict):
|
||||
return True
|
||||
|
||||
tool_config = pyproject.get("tool") or {}
|
||||
crewai_config = tool_config.get("crewai") if isinstance(tool_config, dict) else None
|
||||
declared_type = (
|
||||
crewai_config.get("type") if isinstance(crewai_config, dict) else None
|
||||
)
|
||||
project_config = pyproject.get("project") or {}
|
||||
project_name = (
|
||||
project_config.get("name") if isinstance(project_config, dict) else None
|
||||
)
|
||||
if isinstance(project_name, str):
|
||||
package_name = normalize_package_name(project_name)
|
||||
if package_name and (root / "src" / package_name / "crew.py").is_file():
|
||||
return False
|
||||
|
||||
return declared_type != "flow"
|
||||
|
||||
|
||||
# Be mindful about changing this.
|
||||
# on some environments we don't use this command but instead uv sync directly
|
||||
# so if you expect this to support more things you will need to replicate it there
|
||||
# ask @joaomdmoura if you are unsure
|
||||
def install_crew(proxy_options: list[str]) -> None:
|
||||
def install_crew(
|
||||
proxy_options: list[str],
|
||||
*,
|
||||
raise_on_error: bool = False,
|
||||
install_project: bool | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Install the crew by running the UV command to lock and install.
|
||||
"""
|
||||
try:
|
||||
command = ["uv", "sync", *proxy_options]
|
||||
if install_project is None:
|
||||
install_project = not _is_json_crew_project()
|
||||
|
||||
command = ["uv", "sync"]
|
||||
if not install_project and "--no-install-project" not in proxy_options:
|
||||
command.append("--no-install-project")
|
||||
command.extend(proxy_options)
|
||||
|
||||
# Inject tool repository credentials so uv can authenticate
|
||||
# against private package indexes (e.g. crewai tool repository).
|
||||
@@ -22,11 +79,21 @@ def install_crew(proxy_options: list[str]) -> None:
|
||||
# project depends on tools from a private index.
|
||||
env = build_env_with_all_tool_credentials()
|
||||
|
||||
subprocess.run(command, check=True, capture_output=False, text=True, env=env) # noqa: S603
|
||||
subprocess.run( # noqa: S603
|
||||
command,
|
||||
check=True,
|
||||
capture_output=False,
|
||||
text=True,
|
||||
env=env,
|
||||
)
|
||||
|
||||
except subprocess.CalledProcessError as e:
|
||||
click.echo(f"An error occurred while running the crew: {e}", err=True)
|
||||
click.echo(e.output, err=True)
|
||||
if raise_on_error:
|
||||
raise
|
||||
|
||||
except Exception as e:
|
||||
click.echo(f"An unexpected error occurred: {e}", err=True)
|
||||
if raise_on_error:
|
||||
raise
|
||||
|
||||
@@ -1,12 +1,95 @@
|
||||
"""Re-export of ``crewai_core.plus_api.PlusAPI``.
|
||||
|
||||
Kept as a stable import path for the CLI; new code should import from
|
||||
``crewai_core.plus_api`` directly.
|
||||
"""
|
||||
"""CrewAI CLI API client extensions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from crewai_core.plus_api import PlusAPI as PlusAPI
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal, cast
|
||||
from urllib.parse import urljoin
|
||||
|
||||
from crewai_core.plus_api import PlusAPI as _CorePlusAPI
|
||||
import httpx
|
||||
|
||||
|
||||
HttpMethod = Literal["GET", "POST", "PATCH", "DELETE"]
|
||||
|
||||
|
||||
class PlusAPI(_CorePlusAPI):
|
||||
"""CLI API client.
|
||||
|
||||
The ZIP deployment methods live here as well as in newer crewai-core
|
||||
versions so editable CLI installs still work when an older crewai-core is
|
||||
present in the runtime environment.
|
||||
"""
|
||||
|
||||
def _make_multipart_request(
|
||||
self,
|
||||
method: HttpMethod,
|
||||
endpoint: str,
|
||||
*,
|
||||
zip_file_path: str | Path,
|
||||
data: dict[str, str] | None = None,
|
||||
timeout: float | None = None,
|
||||
verify: bool = True,
|
||||
) -> httpx.Response:
|
||||
"""Send an authenticated multipart request containing a project ZIP."""
|
||||
url = urljoin(self.base_url, endpoint)
|
||||
headers = dict(cast(dict[str, str], self.headers))
|
||||
headers.pop("Content-Type", None)
|
||||
path = Path(zip_file_path)
|
||||
request_kwargs: dict[str, Any] = {"headers": headers}
|
||||
if data is not None:
|
||||
request_kwargs["data"] = data
|
||||
if timeout is not None:
|
||||
request_kwargs["timeout"] = timeout
|
||||
|
||||
with (
|
||||
path.open("rb") as file_handle,
|
||||
httpx.Client(trust_env=False, verify=verify) as client,
|
||||
):
|
||||
files = {
|
||||
"zip_file": (path.name, file_handle, "application/zip"),
|
||||
}
|
||||
return client.request(method, url, files=files, **request_kwargs)
|
||||
|
||||
def create_crew_from_zip(
|
||||
self,
|
||||
zip_file_path: str | Path,
|
||||
*,
|
||||
name: str | None = None,
|
||||
env: dict[str, str] | None = None,
|
||||
) -> httpx.Response:
|
||||
"""Create a crew deployment from a local project ZIP archive."""
|
||||
data: dict[str, str] = {}
|
||||
if name:
|
||||
data["name"] = name
|
||||
if env:
|
||||
data.update({f"env[{key}]": value for key, value in env.items()})
|
||||
return self._make_multipart_request(
|
||||
"POST",
|
||||
f"{self.CREWS_RESOURCE}/zip",
|
||||
zip_file_path=zip_file_path,
|
||||
data=data or None,
|
||||
timeout=300,
|
||||
)
|
||||
|
||||
def update_crew_from_zip(
|
||||
self,
|
||||
uuid: str,
|
||||
zip_file_path: str | Path,
|
||||
*,
|
||||
env: dict[str, str] | None = None,
|
||||
) -> httpx.Response:
|
||||
"""Update an existing crew deployment from a local project ZIP archive."""
|
||||
data: dict[str, str] = {}
|
||||
if env:
|
||||
data.update({f"env[{key}]": value for key, value in env.items()})
|
||||
return self._make_multipart_request(
|
||||
"POST",
|
||||
f"{self.CREWS_RESOURCE}/{uuid}/zip_update",
|
||||
zip_file_path=zip_file_path,
|
||||
data=data or None,
|
||||
timeout=300,
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["PlusAPI"]
|
||||
|
||||
@@ -1,25 +1,475 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import AbstractContextManager, nullcontext
|
||||
from enum import Enum
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import click
|
||||
from crewai.project.json_loader import find_crew_json_file
|
||||
from crewai_core.constants import CREWAI_TRAINED_AGENTS_FILE_ENV
|
||||
from packaging import version
|
||||
|
||||
from crewai_cli.utils import build_env_with_all_tool_credentials, read_toml
|
||||
from crewai_cli.utils import (
|
||||
build_env_with_all_tool_credentials,
|
||||
enable_prompt_line_editing,
|
||||
read_toml,
|
||||
)
|
||||
from crewai_cli.version import get_crewai_version
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from crewai_cli.crew_run_tui import CrewRunApp
|
||||
|
||||
|
||||
class CrewType(Enum):
|
||||
STANDARD = "standard"
|
||||
FLOW = "flow"
|
||||
|
||||
|
||||
def run_crew(trained_agents_file: str | None = None) -> None:
|
||||
"""Run the crew or flow by running a command in the UV environment.
|
||||
# Must accept the same names as the kickoff interpolation pattern in
|
||||
# crewai.utilities.string_utils (_VARIABLE_PATTERN), including hyphens —
|
||||
# otherwise placeholders are interpolated at runtime but never prompted for.
|
||||
_INPUT_PLACEHOLDER_RE = re.compile(r"(?<!{){([A-Za-z_][A-Za-z0-9_\-]*)}(?!})")
|
||||
_CREWAI_CLI_RUNNER_PACKAGE_DIR_ENV = "CREWAI_CLI_RUNNER_PACKAGE_DIR"
|
||||
_CREWAI_RUNNER_SOURCE_DIR_ENV = "CREWAI_RUNNER_SOURCE_DIR"
|
||||
_JSON_CREW_RUNNER_CODE = """
|
||||
import importlib.util
|
||||
import os
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
Starting from version 0.103.0, this command can be used to run both
|
||||
standard crews and flows. For flows, it detects the type from pyproject.toml
|
||||
and automatically runs the appropriate command.
|
||||
source_dir = os.environ.get("CREWAI_RUNNER_SOURCE_DIR")
|
||||
if source_dir:
|
||||
sys.path.insert(0, source_dir)
|
||||
|
||||
package_dir = Path(os.environ["CREWAI_CLI_RUNNER_PACKAGE_DIR"])
|
||||
package_spec = importlib.util.spec_from_file_location(
|
||||
"crewai_cli",
|
||||
package_dir / "__init__.py",
|
||||
submodule_search_locations=[str(package_dir)],
|
||||
)
|
||||
if package_spec is None or package_spec.loader is None:
|
||||
raise ImportError(f"Cannot load CrewAI CLI package from {package_dir}")
|
||||
|
||||
package = importlib.util.module_from_spec(package_spec)
|
||||
sys.modules["crewai_cli"] = package
|
||||
package_spec.loader.exec_module(package)
|
||||
|
||||
module_path = package_dir / "run_crew.py"
|
||||
module_spec = importlib.util.spec_from_file_location("crewai_cli.run_crew", module_path)
|
||||
if module_spec is None or module_spec.loader is None:
|
||||
raise ImportError(f"Cannot load CrewAI CLI runner from {module_path}")
|
||||
|
||||
module = importlib.util.module_from_spec(module_spec)
|
||||
sys.modules["crewai_cli.run_crew"] = module
|
||||
module_spec.loader.exec_module(module)
|
||||
|
||||
from crewai_core.constants import CREWAI_TRAINED_AGENTS_FILE_ENV
|
||||
|
||||
module._run_json_crew(
|
||||
trained_agents_file=os.getenv(CREWAI_TRAINED_AGENTS_FILE_ENV)
|
||||
)
|
||||
""".strip()
|
||||
|
||||
|
||||
def _has_json_crew() -> bool:
|
||||
"""Check if this is a JSON-defined crew project.
|
||||
|
||||
The project type declared in pyproject.toml wins: a flow project that
|
||||
happens to contain a crew.json(c) file still runs as a flow. A missing
|
||||
or unreadable pyproject means a bare JSON crew project.
|
||||
"""
|
||||
if find_crew_json_file() is None:
|
||||
return False
|
||||
try:
|
||||
pyproject_data = read_toml()
|
||||
except Exception:
|
||||
return True
|
||||
declared_type: str | None = (
|
||||
pyproject_data.get("tool", {}).get("crewai", {}).get("type")
|
||||
)
|
||||
return declared_type != "flow"
|
||||
|
||||
|
||||
def _extract_input_placeholders(text: str | None) -> set[str]:
|
||||
if not text:
|
||||
return set()
|
||||
return set(_INPUT_PLACEHOLDER_RE.findall(text))
|
||||
|
||||
|
||||
def _missing_input_names(crew: Any, inputs: dict[str, Any]) -> list[str]:
|
||||
"""Return input placeholders used by a crew but not provided as defaults."""
|
||||
placeholders: set[str] = set()
|
||||
|
||||
for agent in getattr(crew, "agents", []) or []:
|
||||
placeholders.update(_extract_input_placeholders(getattr(agent, "role", None)))
|
||||
placeholders.update(_extract_input_placeholders(getattr(agent, "goal", None)))
|
||||
placeholders.update(
|
||||
_extract_input_placeholders(getattr(agent, "backstory", None))
|
||||
)
|
||||
|
||||
for task in getattr(crew, "tasks", []) or []:
|
||||
placeholders.update(
|
||||
_extract_input_placeholders(getattr(task, "description", None))
|
||||
)
|
||||
placeholders.update(
|
||||
_extract_input_placeholders(getattr(task, "expected_output", None))
|
||||
)
|
||||
placeholders.update(
|
||||
_extract_input_placeholders(getattr(task, "output_file", None))
|
||||
)
|
||||
|
||||
return sorted(name for name in placeholders if name not in inputs)
|
||||
|
||||
|
||||
def _prompt_for_missing_inputs(
|
||||
crew: Any, default_inputs: dict[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
"""Ask for runtime values for placeholders that lack default inputs."""
|
||||
inputs = dict(default_inputs or {})
|
||||
missing = _missing_input_names(crew, inputs)
|
||||
if not missing:
|
||||
return inputs
|
||||
|
||||
enable_prompt_line_editing()
|
||||
|
||||
click.echo()
|
||||
click.secho(" Runtime inputs", fg="cyan", bold=True)
|
||||
click.secho(
|
||||
" Values for {placeholder} references in your agents and tasks.",
|
||||
dim=True,
|
||||
)
|
||||
|
||||
for name in missing:
|
||||
inputs[name] = click.prompt(
|
||||
click.style(f" {name}", fg="cyan"),
|
||||
prompt_suffix=click.style(" > ", fg="bright_white"),
|
||||
)
|
||||
|
||||
return inputs
|
||||
|
||||
|
||||
def _json_loading_status(message: str) -> AbstractContextManager[Any]:
|
||||
from rich.console import Console
|
||||
from rich.text import Text
|
||||
|
||||
console = Console()
|
||||
if not console.is_terminal:
|
||||
return nullcontext()
|
||||
return console.status(
|
||||
Text(f" {message}", style="bold #1F7982"),
|
||||
spinner="dots",
|
||||
)
|
||||
|
||||
|
||||
def _load_json_crew(crew_path: Path) -> tuple[Any, dict[str, Any]]:
|
||||
from crewai.project.crew_loader import load_crew
|
||||
|
||||
return load_crew(crew_path)
|
||||
|
||||
|
||||
def _load_json_crew_for_tui(
|
||||
crew_path: Path,
|
||||
) -> tuple[type[Any], Any, dict[str, Any], list[str], list[str]]:
|
||||
with _json_loading_status("Preparing crew..."):
|
||||
from crewai_cli.crew_run_tui import CrewRunApp
|
||||
|
||||
crew, default_inputs = _load_json_crew(crew_path)
|
||||
_prepare_json_crew_for_tui(crew)
|
||||
task_names = [
|
||||
getattr(task, "name", "") or getattr(task, "description", "")[:40] or "Task"
|
||||
for task in crew.tasks
|
||||
]
|
||||
agent_names = [
|
||||
getattr(agent, "role", "") or getattr(agent, "name", "") or "Agent"
|
||||
for agent in crew.agents
|
||||
]
|
||||
|
||||
return CrewRunApp, crew, default_inputs, task_names, agent_names
|
||||
|
||||
|
||||
def _prepare_json_crew_for_tui(crew: Any) -> None:
|
||||
"""Apply the same quiet/streaming setup used by the TUI JSON loader."""
|
||||
crew.verbose = False
|
||||
for agent in crew.agents:
|
||||
agent.verbose = False
|
||||
if hasattr(agent, "llm") and hasattr(agent.llm, "stream"):
|
||||
agent.llm.stream = True
|
||||
|
||||
|
||||
def _run_json_crew(trained_agents_file: str | None = None) -> Any:
|
||||
"""Load and run a JSON-defined crew."""
|
||||
from dotenv import load_dotenv
|
||||
|
||||
env_file = Path.cwd() / ".env"
|
||||
if env_file.exists():
|
||||
load_dotenv(env_file, override=True)
|
||||
|
||||
# JSON crews run in-process, so export the trained-agents file directly
|
||||
# instead of forwarding it to a subprocess like classic crews do.
|
||||
if trained_agents_file:
|
||||
os.environ[CREWAI_TRAINED_AGENTS_FILE_ENV] = trained_agents_file
|
||||
|
||||
crew_path = find_crew_json_file()
|
||||
if crew_path is None:
|
||||
raise FileNotFoundError("No crew.jsonc or crew.json found")
|
||||
|
||||
crew_run_app_cls, crew, default_inputs, task_names, agent_names = (
|
||||
_load_json_crew_for_tui(crew_path)
|
||||
)
|
||||
runtime_inputs = _prompt_for_missing_inputs(crew, default_inputs)
|
||||
|
||||
app = crew_run_app_cls(
|
||||
crew_name=crew.name or "Crew",
|
||||
total_tasks=len(crew.tasks),
|
||||
agent_names=agent_names,
|
||||
task_names=task_names,
|
||||
)
|
||||
app._crew = crew
|
||||
app._default_inputs = runtime_inputs
|
||||
|
||||
app.run()
|
||||
|
||||
_print_post_tui_summary(app)
|
||||
|
||||
if app._status == "failed":
|
||||
# Mirror the classic subprocess path: a failed crew must produce a
|
||||
# non-zero exit code so scripts and CI don't treat it as success.
|
||||
raise SystemExit(1)
|
||||
|
||||
if app._status not in ("completed", "failed"):
|
||||
# User quit mid-run. kickoff runs in a thread worker that cannot be
|
||||
# force-cancelled, so end the process to stop in-flight LLM and tool
|
||||
# work instead of letting it burn tokens in the background.
|
||||
click.secho("\n Run cancelled.", fg="yellow")
|
||||
sys.stdout.flush()
|
||||
os._exit(130)
|
||||
|
||||
if getattr(app, "_want_deploy", False):
|
||||
_chain_deploy()
|
||||
|
||||
return app._crew_result
|
||||
|
||||
|
||||
def _has_lockfile(project_root: Path | None = None) -> bool:
|
||||
"""Return True when the project already has a dependency lockfile."""
|
||||
return _has_uv_lockfile(project_root) or _has_poetry_lockfile(project_root)
|
||||
|
||||
|
||||
def _has_uv_lockfile(project_root: Path | None = None) -> bool:
|
||||
"""Return True when the project has a uv lockfile."""
|
||||
root = project_root or Path.cwd()
|
||||
return (root / "uv.lock").is_file()
|
||||
|
||||
|
||||
def _has_poetry_lockfile(project_root: Path | None = None) -> bool:
|
||||
"""Return True when the project has a Poetry lockfile."""
|
||||
root = project_root or Path.cwd()
|
||||
return (root / "poetry.lock").is_file()
|
||||
|
||||
|
||||
def _uses_poetry_lockfile(project_root: Path | None = None) -> bool:
|
||||
"""Return True when Poetry is the only available lock source."""
|
||||
return _has_poetry_lockfile(project_root) and not _has_uv_lockfile(project_root)
|
||||
|
||||
|
||||
def _has_project_venv(project_root: Path | None = None) -> bool:
|
||||
"""Return True when the project already has a local uv environment."""
|
||||
root = project_root or Path.cwd()
|
||||
return (root / ".venv").is_dir()
|
||||
|
||||
|
||||
def _install_json_crew_dependencies_if_needed() -> None:
|
||||
"""Prepare JSON crew dependencies without mutating existing lockfiles."""
|
||||
project_root = Path.cwd()
|
||||
if not (project_root / "pyproject.toml").is_file():
|
||||
return
|
||||
|
||||
has_uv_lockfile = _has_uv_lockfile(project_root)
|
||||
has_lockfile = has_uv_lockfile or _has_poetry_lockfile(project_root)
|
||||
if has_lockfile and _has_project_venv(project_root):
|
||||
return
|
||||
if _uses_poetry_lockfile(project_root):
|
||||
return
|
||||
|
||||
from crewai_cli.install_crew import install_crew
|
||||
|
||||
try:
|
||||
if has_uv_lockfile:
|
||||
click.echo("Syncing dependencies from lockfile...")
|
||||
install_crew(["--frozen"], raise_on_error=True)
|
||||
else:
|
||||
click.echo("Installing dependencies...")
|
||||
install_crew([], raise_on_error=True)
|
||||
except subprocess.CalledProcessError as e:
|
||||
raise SystemExit(e.returncode) from e
|
||||
except Exception as e:
|
||||
raise SystemExit(1) from e
|
||||
|
||||
|
||||
def _find_local_crewai_source_dir() -> Path | None:
|
||||
"""Return the repo's CrewAI source dir when running from a source checkout."""
|
||||
for parent in Path(__file__).resolve().parents:
|
||||
candidate = parent / "lib" / "crewai" / "src"
|
||||
if (candidate / "crewai" / "project" / "json_loader.py").is_file():
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
def _json_crew_run_command(project_root: Path | None = None) -> list[str]:
|
||||
"""Return the project-environment command for running JSON crews."""
|
||||
if _uses_poetry_lockfile(project_root):
|
||||
return ["poetry", "run", "python", "-c", _JSON_CREW_RUNNER_CODE]
|
||||
return ["uv", "run", "--no-sync", "python", "-c", _JSON_CREW_RUNNER_CODE]
|
||||
|
||||
|
||||
def _run_json_crew_in_project_env(trained_agents_file: str | None = None) -> Any:
|
||||
"""Run JSON crews from the project's uv-managed environment."""
|
||||
if not (Path.cwd() / "pyproject.toml").is_file():
|
||||
return _run_json_crew(trained_agents_file=trained_agents_file)
|
||||
|
||||
_install_json_crew_dependencies_if_needed()
|
||||
|
||||
command = _json_crew_run_command()
|
||||
env = build_env_with_all_tool_credentials()
|
||||
env[_CREWAI_CLI_RUNNER_PACKAGE_DIR_ENV] = str(Path(__file__).resolve().parent)
|
||||
if local_crewai_source_dir := _find_local_crewai_source_dir():
|
||||
env[_CREWAI_RUNNER_SOURCE_DIR_ENV] = str(local_crewai_source_dir)
|
||||
if trained_agents_file:
|
||||
env[CREWAI_TRAINED_AGENTS_FILE_ENV] = trained_agents_file
|
||||
|
||||
try:
|
||||
subprocess.run( # noqa: S603
|
||||
command,
|
||||
capture_output=False,
|
||||
text=True,
|
||||
check=True,
|
||||
env=env,
|
||||
)
|
||||
except subprocess.CalledProcessError as e:
|
||||
raise SystemExit(e.returncode) from e
|
||||
except Exception as e:
|
||||
click.echo(f"An unexpected error occurred while running the JSON crew: {e}")
|
||||
raise SystemExit(1) from e
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _chain_deploy() -> None:
|
||||
from rich.console import Console
|
||||
|
||||
console = Console()
|
||||
|
||||
def print_system_exit_failure(exc: SystemExit) -> None:
|
||||
if isinstance(exc.code, int):
|
||||
detail = f" with exit code {exc.code}"
|
||||
elif exc.code:
|
||||
detail = f": {exc.code}"
|
||||
else:
|
||||
detail = ""
|
||||
console.print(f"\nDeploy failed{detail}\n", style="bold red")
|
||||
|
||||
try:
|
||||
from crewai_cli.command import AuthenticationRequiredError
|
||||
from crewai_cli.deploy.main import DeployCommand
|
||||
|
||||
console.print("\nStarting deployment…\n", style="bold #FF5A50")
|
||||
DeployCommand().create_crew(confirm=True, skip_validate=True)
|
||||
except AuthenticationRequiredError:
|
||||
from crewai_cli.authentication.main import AuthenticationCommand
|
||||
|
||||
console.print()
|
||||
AuthenticationCommand().login()
|
||||
try:
|
||||
DeployCommand().create_crew(confirm=True, skip_validate=True)
|
||||
except AuthenticationRequiredError:
|
||||
console.print(
|
||||
"\nDeploy failed: authentication is still required.\n",
|
||||
style="bold red",
|
||||
)
|
||||
except SystemExit as e:
|
||||
print_system_exit_failure(e)
|
||||
except Exception as e:
|
||||
console.print(f"\nDeploy failed: {e}\n", style="bold red")
|
||||
except SystemExit as e:
|
||||
print_system_exit_failure(e)
|
||||
except Exception as e:
|
||||
console.print(f"\nDeploy failed: {e}\n", style="bold red")
|
||||
|
||||
|
||||
def _print_post_tui_summary(app: CrewRunApp) -> None:
|
||||
"""Print a summary to the terminal after the Textual TUI exits."""
|
||||
import time
|
||||
|
||||
from rich.console import Console
|
||||
from rich.markdown import Markdown
|
||||
from rich.padding import Padding
|
||||
from rich.panel import Panel
|
||||
from rich.text import Text
|
||||
|
||||
console = Console()
|
||||
elapsed = time.time() - app._start_time
|
||||
|
||||
out_tokens = app._output_tokens + app._live_out_tokens
|
||||
token_parts = []
|
||||
if app._input_tokens:
|
||||
token_parts.append(f"↑{app._input_tokens:,}")
|
||||
if out_tokens:
|
||||
token_parts.append(f"↓{out_tokens:,}")
|
||||
token_str = " ".join(token_parts)
|
||||
if token_str:
|
||||
token_str += " tokens"
|
||||
|
||||
crewai_red = "#FF5A50"
|
||||
crewai_teal = "#1F7982"
|
||||
|
||||
if app._status == "completed":
|
||||
summary = Text()
|
||||
summary.append(
|
||||
f" ✔ Completed {app._total_tasks} tasks",
|
||||
style=f"bold {crewai_teal}",
|
||||
)
|
||||
summary.append(f" in {elapsed:.1f}s", style="dim")
|
||||
if token_str:
|
||||
summary.append(f" {token_str}", style="dim")
|
||||
console.print(
|
||||
Panel(
|
||||
summary,
|
||||
title=f" {app._crew_name} ",
|
||||
title_align="left",
|
||||
border_style=crewai_teal,
|
||||
padding=(0, 1),
|
||||
)
|
||||
)
|
||||
if app._final_output:
|
||||
console.print()
|
||||
console.print(Text(" Final Result", style=f"bold {crewai_teal}"))
|
||||
console.print()
|
||||
console.print(Padding(Markdown(app._final_output), (0, 2)))
|
||||
elif app._status == "failed":
|
||||
content = Text()
|
||||
content.append(" ✘ Failed", style=f"bold {crewai_red}")
|
||||
content.append(f" after {elapsed:.1f}s\n", style="dim")
|
||||
if app._error:
|
||||
content.append(f"\n {app._error}\n", style=crewai_red)
|
||||
console.print(
|
||||
Panel(
|
||||
content,
|
||||
title=f" {app._crew_name} ",
|
||||
title_align="left",
|
||||
border_style=crewai_red,
|
||||
padding=(0, 1),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def run_crew(trained_agents_file: str | None = None) -> None:
|
||||
"""Run the crew or flow.
|
||||
|
||||
Args:
|
||||
trained_agents_file: Optional path to a trained-agents pickle produced
|
||||
@@ -27,6 +477,11 @@ def run_crew(trained_agents_file: str | None = None) -> None:
|
||||
``CREWAI_TRAINED_AGENTS_FILE`` so agents load suggestions from this
|
||||
file instead of the default ``trained_agents_data.pkl``.
|
||||
"""
|
||||
# JSON crew projects take precedence
|
||||
if _has_json_crew():
|
||||
_run_json_crew_in_project_env(trained_agents_file=trained_agents_file)
|
||||
return
|
||||
|
||||
crewai_version = get_crewai_version()
|
||||
min_required_version = "0.71.0"
|
||||
pyproject_data = read_toml()
|
||||
|
||||
113
lib/cli/src/crewai_cli/run_flow_definition.py
Normal file
113
lib/cli/src/crewai_cli/run_flow_definition.py
Normal file
@@ -0,0 +1,113 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import click
|
||||
|
||||
|
||||
def run_flow_definition(definition: str, inputs: str | None = None) -> None:
|
||||
"""Run a flow from a Flow Definition YAML/JSON string or file path."""
|
||||
try:
|
||||
from crewai.flow.flow import Flow
|
||||
from crewai.flow.flow_definition import FlowDefinition
|
||||
except ImportError as exc:
|
||||
click.echo(
|
||||
"Running flows from definitions requires the full crewai package.",
|
||||
err=True,
|
||||
)
|
||||
raise SystemExit(1) from exc
|
||||
|
||||
parsed_inputs = _parse_inputs(inputs)
|
||||
definition_source = _read_definition_source(definition)
|
||||
|
||||
try:
|
||||
flow_definition = _parse_flow_definition(FlowDefinition, definition_source)
|
||||
flow = Flow.from_definition(flow_definition)
|
||||
result = flow.kickoff(inputs=parsed_inputs)
|
||||
except Exception as exc:
|
||||
click.echo(
|
||||
f"An error occurred while running the flow definition: {exc}", err=True
|
||||
)
|
||||
raise SystemExit(1) from exc
|
||||
|
||||
click.echo(_format_result(result))
|
||||
|
||||
|
||||
def _parse_inputs(inputs: str | None) -> dict[str, Any] | None:
|
||||
if inputs is None:
|
||||
return None
|
||||
|
||||
try:
|
||||
parsed = json.loads(inputs)
|
||||
except json.JSONDecodeError as exc:
|
||||
click.echo(f"Invalid --inputs JSON: {exc}", err=True)
|
||||
raise SystemExit(1) from exc
|
||||
|
||||
if not isinstance(parsed, dict):
|
||||
click.echo("Invalid --inputs JSON: expected an object.", err=True)
|
||||
raise SystemExit(1)
|
||||
|
||||
return parsed
|
||||
|
||||
|
||||
def _read_definition_source(definition: str) -> str:
|
||||
path = Path(definition).expanduser()
|
||||
try:
|
||||
is_file = path.is_file()
|
||||
except OSError as exc:
|
||||
if _looks_like_inline_definition(definition):
|
||||
return definition
|
||||
click.echo(f"Invalid --definition path: {definition} ({exc})", err=True)
|
||||
raise SystemExit(1) from exc
|
||||
|
||||
if is_file:
|
||||
try:
|
||||
return path.read_text(encoding="utf-8")
|
||||
except (OSError, UnicodeError) as exc:
|
||||
click.echo(
|
||||
f"Unable to read --definition path {path}: {exc}",
|
||||
err=True,
|
||||
)
|
||||
raise SystemExit(1) from exc
|
||||
|
||||
try:
|
||||
if path.exists():
|
||||
click.echo(
|
||||
f"Invalid --definition path: {definition} is not a file.", err=True
|
||||
)
|
||||
raise SystemExit(1)
|
||||
except OSError as exc:
|
||||
click.echo(f"Invalid --definition path: {definition} ({exc})", err=True)
|
||||
raise SystemExit(1) from exc
|
||||
|
||||
return definition
|
||||
|
||||
|
||||
def _looks_like_inline_definition(definition: str) -> bool:
|
||||
stripped = definition.lstrip()
|
||||
return "\n" in definition or stripped.startswith(("{", "---")) or ":" in stripped
|
||||
|
||||
|
||||
def _parse_flow_definition(flow_definition_cls: type[Any], source: str) -> Any:
|
||||
if _looks_like_json(source):
|
||||
return flow_definition_cls.from_json(source)
|
||||
|
||||
return flow_definition_cls.from_yaml(source)
|
||||
|
||||
|
||||
def _looks_like_json(source: str) -> bool:
|
||||
stripped = source.lstrip()
|
||||
return stripped.startswith("{")
|
||||
|
||||
|
||||
def _format_result(result: Any) -> str:
|
||||
raw_result = getattr(result, "raw", result)
|
||||
if isinstance(raw_result, str):
|
||||
return raw_result
|
||||
|
||||
try:
|
||||
return json.dumps(raw_result, default=str)
|
||||
except TypeError:
|
||||
return str(raw_result)
|
||||
@@ -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.7a1"
|
||||
"crewai[tools]==1.14.7"
|
||||
]
|
||||
|
||||
[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.7a1"
|
||||
"crewai[tools]==1.14.7"
|
||||
]
|
||||
|
||||
[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.7a1"
|
||||
"crewai[tools]==1.14.7"
|
||||
]
|
||||
|
||||
[tool.crewai]
|
||||
|
||||
419
lib/cli/src/crewai_cli/tui_picker.py
Normal file
419
lib/cli/src/crewai_cli/tui_picker.py
Normal file
@@ -0,0 +1,419 @@
|
||||
"""Arrow-key interactive pickers for CLI prompts."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import suppress
|
||||
import sys
|
||||
from typing import overload
|
||||
|
||||
import click
|
||||
|
||||
|
||||
# CrewAI brand: primary=#FF5A50 (coral), teal=#1F7982
|
||||
_CORAL = "\033[38;2;255;90;80m" # #FF5A50
|
||||
_TEAL = "\033[38;2;31;121;130m" # #1F7982
|
||||
_BOLD = "\033[1m"
|
||||
_DIM = "\033[2m"
|
||||
_RESET = "\033[0m"
|
||||
_HIDE_CURSOR = "\033[?25l"
|
||||
_SHOW_CURSOR = "\033[?25h"
|
||||
|
||||
|
||||
def _is_interactive() -> bool:
|
||||
try:
|
||||
return sys.stdin.isatty() and sys.stdout.isatty()
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _read_key() -> str:
|
||||
if sys.platform == "win32":
|
||||
import msvcrt
|
||||
|
||||
ch = msvcrt.getwch()
|
||||
if ch in ("\x00", "\xe0"):
|
||||
ch2 = msvcrt.getwch()
|
||||
return {"H": "up", "P": "down"}.get(ch2, "")
|
||||
if ch == "\r":
|
||||
return "enter"
|
||||
if ch == " ":
|
||||
return "space"
|
||||
if ch == "\x03":
|
||||
raise KeyboardInterrupt
|
||||
return ch
|
||||
|
||||
import termios
|
||||
import tty
|
||||
|
||||
fd = sys.stdin.fileno()
|
||||
old = termios.tcgetattr(fd)
|
||||
try:
|
||||
tty.setcbreak(fd)
|
||||
ch = sys.stdin.read(1)
|
||||
if ch == "\x1b":
|
||||
seq = sys.stdin.read(2)
|
||||
if seq == "[A":
|
||||
return "up"
|
||||
if seq == "[B":
|
||||
return "down"
|
||||
return "esc"
|
||||
if ch in ("\r", "\n"):
|
||||
return "enter"
|
||||
if ch == " ":
|
||||
return "space"
|
||||
if ch == "\x03":
|
||||
raise KeyboardInterrupt
|
||||
return ch
|
||||
finally:
|
||||
termios.tcsetattr(fd, termios.TCSADRAIN, old)
|
||||
|
||||
|
||||
def _clear_lines(n: int) -> None:
|
||||
sys.stdout.write(f"\033[{n}A")
|
||||
for _ in range(n):
|
||||
sys.stdout.write("\033[2K\n")
|
||||
sys.stdout.write(f"\033[{n}A")
|
||||
sys.stdout.flush()
|
||||
|
||||
|
||||
def _draw_single(labels: list[str], cursor: int, *, clear: bool = False) -> None:
|
||||
total = len(labels)
|
||||
if clear:
|
||||
sys.stdout.write(f"\033[{total}A")
|
||||
for i, label in enumerate(labels):
|
||||
if i == cursor:
|
||||
sys.stdout.write(f"\033[2K {_CORAL}→{_RESET} {_BOLD}{label}{_RESET}\n")
|
||||
else:
|
||||
sys.stdout.write(f"\033[2K {label}\n")
|
||||
sys.stdout.flush()
|
||||
|
||||
|
||||
def _draw_multi(
|
||||
labels: list[str],
|
||||
cursor: int,
|
||||
selected: set[int],
|
||||
*,
|
||||
action_indices: set[int] | None = None,
|
||||
separator_indices: set[int] | None = None,
|
||||
clear: bool = False,
|
||||
) -> None:
|
||||
action_indices = action_indices or set()
|
||||
separator_indices = separator_indices or set()
|
||||
hint_text = "↑↓ navigate, space toggle, enter confirm"
|
||||
if action_indices:
|
||||
hint_text = "↑↓ navigate, space toggle, enter confirm, ▸ rows expand/collapse"
|
||||
hint = f" {_DIM}{hint_text}{_RESET}"
|
||||
total = len(labels) + 1
|
||||
if clear:
|
||||
sys.stdout.write(f"\033[{total}A")
|
||||
sys.stdout.write(f"\033[2K{hint}\n")
|
||||
for i, label in enumerate(labels):
|
||||
if i in separator_indices:
|
||||
sys.stdout.write(f"\033[2K {_TEAL}{label}{_RESET}\n")
|
||||
continue
|
||||
if i in action_indices:
|
||||
check = " "
|
||||
elif i in selected:
|
||||
check = f"{_CORAL}[x]{_RESET}"
|
||||
else:
|
||||
check = "[ ]"
|
||||
arrow = f"{_CORAL}→{_RESET} " if i == cursor else " "
|
||||
bold = f"{_BOLD}{label}{_RESET}" if i == cursor else label
|
||||
sys.stdout.write(f"\033[2K {arrow}{check} {bold}\n")
|
||||
sys.stdout.flush()
|
||||
|
||||
|
||||
def _arrow_select_one(labels: list[str]) -> int:
|
||||
cursor = 0
|
||||
total = len(labels)
|
||||
sys.stdout.write(_HIDE_CURSOR)
|
||||
sys.stdout.flush()
|
||||
try:
|
||||
_draw_single(labels, cursor)
|
||||
while True:
|
||||
key = _read_key()
|
||||
if key == "up" and cursor > 0:
|
||||
cursor -= 1
|
||||
_draw_single(labels, cursor, clear=True)
|
||||
elif key == "down" and cursor < total - 1:
|
||||
cursor += 1
|
||||
_draw_single(labels, cursor, clear=True)
|
||||
elif key == "enter":
|
||||
_clear_lines(total)
|
||||
return cursor
|
||||
elif key in ("esc", "q"):
|
||||
_clear_lines(total)
|
||||
return -1
|
||||
finally:
|
||||
sys.stdout.write(_SHOW_CURSOR)
|
||||
sys.stdout.flush()
|
||||
|
||||
|
||||
def _arrow_select_multi(
|
||||
labels: list[str],
|
||||
*,
|
||||
action_indices: set[int] | None = None,
|
||||
separator_indices: set[int] | None = None,
|
||||
preselected: set[int] | None = None,
|
||||
initial_cursor: int | None = None,
|
||||
) -> tuple[list[int], int | None]:
|
||||
total = len(labels)
|
||||
selected: set[int] = set(preselected or ())
|
||||
action_indices = action_indices or set()
|
||||
separator_indices = separator_indices or set()
|
||||
if initial_cursor is not None and 0 <= initial_cursor < total:
|
||||
cursor = initial_cursor
|
||||
else:
|
||||
cursor = _first_selectable_index(total, separator_indices)
|
||||
sys.stdout.write(_HIDE_CURSOR)
|
||||
sys.stdout.flush()
|
||||
try:
|
||||
_draw_multi(
|
||||
labels,
|
||||
cursor,
|
||||
selected,
|
||||
action_indices=action_indices,
|
||||
separator_indices=separator_indices,
|
||||
)
|
||||
while True:
|
||||
key = _read_key()
|
||||
if key == "up":
|
||||
cursor = _next_selectable_index(cursor, -1, total, separator_indices)
|
||||
_draw_multi(
|
||||
labels,
|
||||
cursor,
|
||||
selected,
|
||||
action_indices=action_indices,
|
||||
separator_indices=separator_indices,
|
||||
clear=True,
|
||||
)
|
||||
elif key == "down":
|
||||
cursor = _next_selectable_index(cursor, 1, total, separator_indices)
|
||||
_draw_multi(
|
||||
labels,
|
||||
cursor,
|
||||
selected,
|
||||
action_indices=action_indices,
|
||||
separator_indices=separator_indices,
|
||||
clear=True,
|
||||
)
|
||||
elif key == "space":
|
||||
if cursor in action_indices:
|
||||
_clear_lines(total + 1)
|
||||
return sorted(selected), cursor
|
||||
selected ^= {cursor}
|
||||
_draw_multi(
|
||||
labels,
|
||||
cursor,
|
||||
selected,
|
||||
action_indices=action_indices,
|
||||
separator_indices=separator_indices,
|
||||
clear=True,
|
||||
)
|
||||
elif key == "enter":
|
||||
_clear_lines(total + 1)
|
||||
if cursor in action_indices:
|
||||
return sorted(selected), cursor
|
||||
return sorted(selected), None
|
||||
elif key in ("esc", "q"):
|
||||
_clear_lines(total + 1)
|
||||
return sorted(selected), None
|
||||
finally:
|
||||
sys.stdout.write(_SHOW_CURSOR)
|
||||
sys.stdout.flush()
|
||||
|
||||
|
||||
def _numbered_select(labels: list[str]) -> int:
|
||||
for idx, label in enumerate(labels, 1):
|
||||
click.echo(f" {idx}. {label}")
|
||||
click.echo()
|
||||
while True:
|
||||
choice = click.prompt(" Select", type=str, default="1")
|
||||
if choice.lower() == "q":
|
||||
return -1
|
||||
try:
|
||||
num = int(choice)
|
||||
if 1 <= num <= len(labels):
|
||||
return num - 1
|
||||
except ValueError:
|
||||
# Non-numeric input falls through to the shared error message.
|
||||
pass
|
||||
click.secho(f" Invalid choice. Enter 1-{len(labels)}.", fg="red")
|
||||
|
||||
|
||||
def _numbered_select_multi(
|
||||
labels: list[str],
|
||||
*,
|
||||
action_indices: set[int] | None = None,
|
||||
separator_indices: set[int] | None = None,
|
||||
preselected: set[int] | None = None,
|
||||
) -> tuple[list[int], int | None]:
|
||||
action_indices = action_indices or set()
|
||||
separator_indices = separator_indices or set()
|
||||
numbered_indices: list[int] = []
|
||||
for idx, label in enumerate(labels):
|
||||
if idx in separator_indices:
|
||||
click.secho(f" {label}", fg="cyan")
|
||||
continue
|
||||
numbered_indices.append(idx)
|
||||
click.echo(f" {len(numbered_indices)}. {label}")
|
||||
click.echo()
|
||||
raw = click.prompt(
|
||||
" Select (comma-separated numbers, or empty to skip)",
|
||||
default="",
|
||||
show_default=False,
|
||||
)
|
||||
if not raw.strip():
|
||||
return sorted(preselected or ()), None
|
||||
indices: list[int] = list(preselected or ())
|
||||
for part in raw.split(","):
|
||||
with suppress(ValueError):
|
||||
num = int(part.strip())
|
||||
if 1 <= num <= len(numbered_indices):
|
||||
idx = numbered_indices[num - 1]
|
||||
if idx in action_indices:
|
||||
return sorted(set(indices)), idx
|
||||
indices.append(idx)
|
||||
return sorted(set(indices)), None
|
||||
|
||||
|
||||
def _first_selectable_index(total: int, separator_indices: set[int]) -> int:
|
||||
for idx in range(total):
|
||||
if idx not in separator_indices:
|
||||
return idx
|
||||
return 0
|
||||
|
||||
|
||||
def _next_selectable_index(
|
||||
cursor: int,
|
||||
direction: int,
|
||||
total: int,
|
||||
separator_indices: set[int],
|
||||
) -> int:
|
||||
next_cursor = cursor + direction
|
||||
while 0 <= next_cursor < total:
|
||||
if next_cursor not in separator_indices:
|
||||
return next_cursor
|
||||
next_cursor += direction
|
||||
return cursor
|
||||
|
||||
|
||||
# ── Public API ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
def pick(title: str, options: list[tuple[str, str]]) -> str | None:
|
||||
"""Arrow-key single-select picker.
|
||||
|
||||
Args:
|
||||
title: Header text.
|
||||
options: List of ``(value, description)`` tuples.
|
||||
|
||||
Returns:
|
||||
The *value* of the selected option, or ``None`` if cancelled.
|
||||
"""
|
||||
labels = [f"{value:<12s} {desc}" for value, desc in options]
|
||||
|
||||
click.echo()
|
||||
click.secho(f" {title}", fg="cyan", bold=True)
|
||||
click.echo()
|
||||
|
||||
if _is_interactive():
|
||||
try:
|
||||
idx = _arrow_select_one(labels)
|
||||
except Exception:
|
||||
idx = _numbered_select(labels)
|
||||
else:
|
||||
idx = _numbered_select(labels)
|
||||
|
||||
if idx < 0:
|
||||
return None
|
||||
|
||||
value, _desc = options[idx]
|
||||
click.secho(f" ✔ {value}", fg="green")
|
||||
return value
|
||||
|
||||
|
||||
def pick_one(title: str, labels: list[str]) -> int:
|
||||
"""Arrow-key single-select from plain labels.
|
||||
|
||||
Returns:
|
||||
Selected index, or ``-1`` if cancelled.
|
||||
"""
|
||||
click.echo()
|
||||
click.secho(f" {title}", fg="cyan")
|
||||
|
||||
if _is_interactive():
|
||||
try:
|
||||
return _arrow_select_one(labels)
|
||||
except Exception:
|
||||
return _numbered_select(labels)
|
||||
return _numbered_select(labels)
|
||||
|
||||
|
||||
@overload
|
||||
def pick_many(
|
||||
title: str,
|
||||
labels: list[str],
|
||||
*,
|
||||
separator_indices: set[int] | None = None,
|
||||
preselected: set[int] | None = None,
|
||||
initial_cursor: int | None = None,
|
||||
) -> list[int]: ...
|
||||
|
||||
|
||||
@overload
|
||||
def pick_many(
|
||||
title: str,
|
||||
labels: list[str],
|
||||
*,
|
||||
action_indices: set[int],
|
||||
separator_indices: set[int] | None = None,
|
||||
preselected: set[int] | None = None,
|
||||
initial_cursor: int | None = None,
|
||||
) -> tuple[list[int], int | None]: ...
|
||||
|
||||
|
||||
def pick_many(
|
||||
title: str,
|
||||
labels: list[str],
|
||||
*,
|
||||
action_indices: set[int] | None = None,
|
||||
separator_indices: set[int] | None = None,
|
||||
preselected: set[int] | None = None,
|
||||
initial_cursor: int | None = None,
|
||||
) -> list[int] | tuple[list[int], int | None]:
|
||||
"""Arrow-key multi-select with checkboxes.
|
||||
|
||||
Returns:
|
||||
Sorted list of selected indices, or ``(indices, action_index)`` when
|
||||
``action_indices`` is provided.
|
||||
"""
|
||||
click.echo()
|
||||
click.secho(f" {title}", fg="cyan")
|
||||
|
||||
if _is_interactive():
|
||||
try:
|
||||
selected, action = _arrow_select_multi(
|
||||
labels,
|
||||
action_indices=action_indices,
|
||||
separator_indices=separator_indices,
|
||||
preselected=preselected,
|
||||
initial_cursor=initial_cursor,
|
||||
)
|
||||
except Exception:
|
||||
selected, action = _numbered_select_multi(
|
||||
labels,
|
||||
action_indices=action_indices,
|
||||
separator_indices=separator_indices,
|
||||
preselected=preselected,
|
||||
)
|
||||
else:
|
||||
selected, action = _numbered_select_multi(
|
||||
labels,
|
||||
action_indices=action_indices,
|
||||
separator_indices=separator_indices,
|
||||
preselected=preselected,
|
||||
)
|
||||
if action_indices is None:
|
||||
return selected
|
||||
return selected, action
|
||||
@@ -24,6 +24,7 @@ __all__ = [
|
||||
"build_env_with_all_tool_credentials",
|
||||
"build_env_with_tool_repository_credentials",
|
||||
"copy_template",
|
||||
"enable_prompt_line_editing",
|
||||
"fetch_and_json_env_file",
|
||||
"get_project_description",
|
||||
"get_project_name",
|
||||
@@ -40,6 +41,19 @@ __all__ = [
|
||||
console = Console()
|
||||
|
||||
|
||||
def enable_prompt_line_editing() -> None:
|
||||
"""Enable cursor movement/history editing for Click text prompts when available."""
|
||||
try:
|
||||
import readline
|
||||
except ImportError:
|
||||
return
|
||||
|
||||
try:
|
||||
readline.parse_and_bind("set editing-mode emacs")
|
||||
except Exception: # pragma: no cover - readline backends vary by platform
|
||||
return
|
||||
|
||||
|
||||
def copy_template(
|
||||
src: Path, dst: Path, name: str, class_name: str, folder_name: str
|
||||
) -> None:
|
||||
|
||||
270
lib/cli/tests/deploy/test_archive.py
Normal file
270
lib/cli/tests/deploy/test_archive.py
Normal file
@@ -0,0 +1,270 @@
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import zipfile
|
||||
|
||||
import pytest
|
||||
|
||||
from crewai_cli.deploy.archive import create_project_zip
|
||||
|
||||
|
||||
def test_create_project_zip_excludes_local_artifacts(tmp_path: Path):
|
||||
(tmp_path / "pyproject.toml").write_text("[project]\nname = 'demo'\n")
|
||||
(tmp_path / "uv.lock").write_text("# lock\n")
|
||||
(tmp_path / "src").mkdir()
|
||||
(tmp_path / "src" / "main.py").write_text("print('hello')\n")
|
||||
(tmp_path / ".env").write_text("OPENAI_API_KEY=secret\n")
|
||||
(tmp_path / ".env.example").write_text("OPENAI_API_KEY=\n")
|
||||
(tmp_path / "__pycache__").mkdir()
|
||||
(tmp_path / "__pycache__" / "main.pyc").write_bytes(b"compiled")
|
||||
(tmp_path / ".git").mkdir()
|
||||
(tmp_path / ".git" / "config").write_text("[core]\n")
|
||||
|
||||
archive_path = create_project_zip("demo", project_dir=tmp_path)
|
||||
try:
|
||||
with zipfile.ZipFile(archive_path) as archive:
|
||||
names = set(archive.namelist())
|
||||
finally:
|
||||
archive_path.unlink(missing_ok=True)
|
||||
|
||||
assert names == {
|
||||
"pyproject.toml",
|
||||
"uv.lock",
|
||||
"src/main.py",
|
||||
".env.example",
|
||||
}
|
||||
|
||||
|
||||
def test_create_project_zip_uses_repository_file_list(tmp_path: Path):
|
||||
(tmp_path / "pyproject.toml").write_text("[project]\nname = 'demo'\n")
|
||||
(tmp_path / "uv.lock").write_text("# lock\n")
|
||||
(tmp_path / "ignored.txt").write_text("ignored\n")
|
||||
|
||||
class RepositoryStub:
|
||||
def deployable_files(self) -> list[str]:
|
||||
return ["pyproject.toml", "uv.lock"]
|
||||
|
||||
archive_path = create_project_zip(
|
||||
"demo",
|
||||
project_dir=tmp_path,
|
||||
repository=RepositoryStub(), # type: ignore[arg-type]
|
||||
)
|
||||
try:
|
||||
with zipfile.ZipFile(archive_path) as archive:
|
||||
names = set(archive.namelist())
|
||||
finally:
|
||||
archive_path.unlink(missing_ok=True)
|
||||
|
||||
assert names == {"pyproject.toml", "uv.lock"}
|
||||
|
||||
|
||||
def test_create_project_zip_without_repository_uses_git_ignore_rules(
|
||||
tmp_path: Path,
|
||||
):
|
||||
(tmp_path / "pyproject.toml").write_text("[project]\nname = 'demo'\n")
|
||||
(tmp_path / ".gitignore").write_text("node_modules/\nsecret.txt\n")
|
||||
(tmp_path / "src").mkdir()
|
||||
(tmp_path / "src" / "main.py").write_text("print('hello')\n")
|
||||
(tmp_path / "node_modules").mkdir()
|
||||
(tmp_path / "node_modules" / "package.json").write_text("{}\n")
|
||||
(tmp_path / "secret.txt").write_text("secret\n")
|
||||
|
||||
try:
|
||||
subprocess.run(
|
||||
["git", "init"],
|
||||
cwd=tmp_path,
|
||||
capture_output=True,
|
||||
check=True,
|
||||
text=True,
|
||||
)
|
||||
except (FileNotFoundError, subprocess.CalledProcessError) as exc:
|
||||
pytest.skip(f"git is not available in this environment: {exc}")
|
||||
|
||||
archive_path = create_project_zip("demo", project_dir=tmp_path)
|
||||
try:
|
||||
with zipfile.ZipFile(archive_path) as archive:
|
||||
names = set(archive.namelist())
|
||||
finally:
|
||||
archive_path.unlink(missing_ok=True)
|
||||
|
||||
assert names == {
|
||||
".gitignore",
|
||||
"pyproject.toml",
|
||||
"src/main.py",
|
||||
}
|
||||
|
||||
|
||||
def test_create_project_zip_does_not_fallback_when_repository_listing_fails(
|
||||
tmp_path: Path,
|
||||
):
|
||||
(tmp_path / "pyproject.toml").write_text("[project]\nname = 'demo'\n")
|
||||
|
||||
class RepositoryStub:
|
||||
def deployable_files(self) -> list[str]:
|
||||
raise RuntimeError("git listing failed")
|
||||
|
||||
with pytest.raises(RuntimeError, match="git listing failed"):
|
||||
create_project_zip(
|
||||
"demo",
|
||||
project_dir=tmp_path,
|
||||
repository=RepositoryStub(), # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
|
||||
def test_create_project_zip_excludes_symlinked_files(tmp_path: Path):
|
||||
(tmp_path / "pyproject.toml").write_text("[project]\nname = 'demo'\n")
|
||||
outside_file = tmp_path.parent / f"{tmp_path.name}-secret.txt"
|
||||
outside_file.write_text("secret\n")
|
||||
archive_path: Path | None = None
|
||||
try:
|
||||
try:
|
||||
(tmp_path / "external-secret.txt").symlink_to(outside_file)
|
||||
except OSError as exc:
|
||||
pytest.skip(f"symlinks are not supported in this environment: {exc}")
|
||||
|
||||
archive_path = create_project_zip("demo", project_dir=tmp_path)
|
||||
with zipfile.ZipFile(archive_path) as archive:
|
||||
names = set(archive.namelist())
|
||||
finally:
|
||||
if archive_path is not None:
|
||||
archive_path.unlink(missing_ok=True)
|
||||
outside_file.unlink(missing_ok=True)
|
||||
|
||||
assert names == {"pyproject.toml"}
|
||||
|
||||
|
||||
def test_create_project_zip_adds_json_project_wrapper(tmp_path: Path):
|
||||
(tmp_path / "pyproject.toml").write_text(
|
||||
"""
|
||||
[project]
|
||||
name = "json_crew"
|
||||
version = "0.1.0"
|
||||
dependencies = ["crewai[tools]>=1.15"]
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.crewai]
|
||||
type = "crew"
|
||||
""".strip()
|
||||
+ "\n"
|
||||
)
|
||||
(tmp_path / "agents").mkdir()
|
||||
(tmp_path / "agents" / "researcher.jsonc").write_text("{}\n")
|
||||
(tmp_path / "crew.jsonc").write_text("{}\n")
|
||||
|
||||
archive_path = create_project_zip("json_crew", project_dir=tmp_path)
|
||||
try:
|
||||
with zipfile.ZipFile(archive_path) as archive:
|
||||
names = set(archive.namelist())
|
||||
crew_py = archive.read("src/json_crew/crew.py").decode()
|
||||
main_py = archive.read("src/json_crew/main.py").decode()
|
||||
pyproject = archive.read("pyproject.toml").decode()
|
||||
finally:
|
||||
archive_path.unlink(missing_ok=True)
|
||||
|
||||
assert "uv.lock" not in names
|
||||
assert "crew.jsonc" in names
|
||||
assert "agents/researcher.jsonc" in names
|
||||
assert "src/json_crew/__init__.py" in names
|
||||
assert "src/json_crew/crew.py" in names
|
||||
assert "src/json_crew/main.py" in names
|
||||
assert "src/json_crew/config/agents.yaml" in names
|
||||
assert "src/json_crew/config/tasks.yaml" in names
|
||||
assert "load_crew(_crew_path())" in crew_py
|
||||
assert "JsonCrew" in crew_py
|
||||
assert "from json_crew.crew import JsonCrew" in main_py
|
||||
assert "run_crew = \"json_crew.main:run\"" in pyproject
|
||||
|
||||
|
||||
def test_create_project_zip_updates_existing_json_project_scripts(tmp_path: Path):
|
||||
(tmp_path / "pyproject.toml").write_text(
|
||||
"""
|
||||
[project]
|
||||
name = "json_crew"
|
||||
version = "0.1.0"
|
||||
|
||||
[project.scripts]
|
||||
json_crew = "old.module:run"
|
||||
run_crew = "old.module:run"
|
||||
custom = "custom.module:main"
|
||||
|
||||
[tool.crewai]
|
||||
type = "crew"
|
||||
""".strip()
|
||||
+ "\n"
|
||||
)
|
||||
(tmp_path / "crew.jsonc").write_text("{}\n")
|
||||
|
||||
archive_path = create_project_zip("json_crew", project_dir=tmp_path)
|
||||
try:
|
||||
with zipfile.ZipFile(archive_path) as archive:
|
||||
pyproject = archive.read("pyproject.toml").decode()
|
||||
finally:
|
||||
archive_path.unlink(missing_ok=True)
|
||||
|
||||
assert 'json_crew = "json_crew.main:run"' in pyproject
|
||||
assert 'run_crew = "json_crew.main:run"' in pyproject
|
||||
assert 'train = "json_crew.main:train"' in pyproject
|
||||
assert 'replay = "json_crew.main:replay"' in pyproject
|
||||
assert 'test = "json_crew.main:test"' in pyproject
|
||||
assert 'run_with_trigger = "json_crew.main:run_with_trigger"' in pyproject
|
||||
assert 'custom = "custom.module:main"' in pyproject
|
||||
assert "old.module:run" not in pyproject
|
||||
assert "[tool.crewai]" in pyproject
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"tool_config",
|
||||
[
|
||||
'tool = "invalid"\n',
|
||||
'[tool]\ncrewai = "invalid"\n',
|
||||
],
|
||||
)
|
||||
def test_create_project_zip_adds_json_wrapper_for_malformed_tool_config(
|
||||
tmp_path: Path, tool_config: str
|
||||
):
|
||||
(tmp_path / "pyproject.toml").write_text(
|
||||
f"""
|
||||
[project]
|
||||
name = "json_crew"
|
||||
version = "0.1.0"
|
||||
|
||||
{tool_config}
|
||||
""".strip()
|
||||
+ "\n"
|
||||
)
|
||||
(tmp_path / "crew.jsonc").write_text("{}\n")
|
||||
|
||||
archive_path = create_project_zip("json_crew", project_dir=tmp_path)
|
||||
try:
|
||||
with zipfile.ZipFile(archive_path) as archive:
|
||||
names = set(archive.namelist())
|
||||
pyproject = archive.read("pyproject.toml").decode()
|
||||
finally:
|
||||
archive_path.unlink(missing_ok=True)
|
||||
|
||||
assert "src/json_crew/crew.py" in names
|
||||
assert "src/json_crew/main.py" in names
|
||||
assert "run_crew = \"json_crew.main:run\"" in pyproject
|
||||
|
||||
|
||||
def test_create_project_zip_rejects_empty_normalized_package_name(tmp_path: Path):
|
||||
(tmp_path / "pyproject.toml").write_text(
|
||||
"""
|
||||
[project]
|
||||
name = "!!!"
|
||||
version = "0.1.0"
|
||||
|
||||
[tool.crewai]
|
||||
type = "crew"
|
||||
""".strip()
|
||||
+ "\n"
|
||||
)
|
||||
(tmp_path / "crew.jsonc").write_text("{}\n")
|
||||
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match=r"Could not derive a valid Python package name",
|
||||
):
|
||||
create_project_zip("invalid", project_dir=tmp_path)
|
||||
@@ -1,16 +1,172 @@
|
||||
import sys
|
||||
import unittest
|
||||
from io import StringIO
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
from unittest.mock import MagicMock, Mock, patch
|
||||
|
||||
import pytest
|
||||
import json
|
||||
|
||||
import crewai_cli.deploy.main as deploy_main
|
||||
import httpx
|
||||
from crewai_cli.deploy.main import DeployCommand
|
||||
from crewai_cli.deploy.validate import Severity, ValidationResult
|
||||
from crewai_cli.utils import parse_toml
|
||||
|
||||
|
||||
def test_ensure_lockfile_for_deploy_runs_install_when_lock_missing(
|
||||
monkeypatch, tmp_path: Path
|
||||
):
|
||||
monkeypatch.chdir(tmp_path)
|
||||
(tmp_path / "pyproject.toml").write_text("[project]\nname = 'demo'\n")
|
||||
calls = []
|
||||
|
||||
def fake_install_crew(proxy_options, *, raise_on_error=False):
|
||||
calls.append((proxy_options, raise_on_error))
|
||||
|
||||
monkeypatch.setattr("crewai_cli.install_crew.install_crew", fake_install_crew)
|
||||
|
||||
deploy_main._ensure_lockfile_for_deploy()
|
||||
|
||||
assert calls == [([], True)]
|
||||
|
||||
|
||||
def test_ensure_lockfile_for_deploy_skips_when_lock_exists(monkeypatch, tmp_path: Path):
|
||||
monkeypatch.chdir(tmp_path)
|
||||
(tmp_path / "pyproject.toml").write_text("[project]\nname = 'demo'\n")
|
||||
(tmp_path / "uv.lock").write_text("# lock\n")
|
||||
calls = []
|
||||
|
||||
def fake_install_crew(proxy_options, *, raise_on_error=False):
|
||||
calls.append((proxy_options, raise_on_error))
|
||||
|
||||
monkeypatch.setattr("crewai_cli.install_crew.install_crew", fake_install_crew)
|
||||
|
||||
deploy_main._ensure_lockfile_for_deploy()
|
||||
|
||||
assert calls == []
|
||||
|
||||
|
||||
def test_ensure_lockfile_for_deploy_skips_without_pyproject(
|
||||
monkeypatch, tmp_path: Path
|
||||
):
|
||||
monkeypatch.chdir(tmp_path)
|
||||
calls = []
|
||||
|
||||
def fake_install_crew(proxy_options, *, raise_on_error=False):
|
||||
calls.append((proxy_options, raise_on_error))
|
||||
|
||||
monkeypatch.setattr("crewai_cli.install_crew.install_crew", fake_install_crew)
|
||||
|
||||
deploy_main._ensure_lockfile_for_deploy()
|
||||
|
||||
assert calls == []
|
||||
|
||||
|
||||
def test_ensure_lockfile_for_deploy_failure_exits_nonzero(
|
||||
monkeypatch, tmp_path: Path
|
||||
):
|
||||
monkeypatch.chdir(tmp_path)
|
||||
(tmp_path / "pyproject.toml").write_text("[project]\nname = 'demo'\n")
|
||||
|
||||
def fake_install_crew(proxy_options, *, raise_on_error=False):
|
||||
raise subprocess.CalledProcessError(42, ["uv", "sync"])
|
||||
|
||||
monkeypatch.setattr("crewai_cli.install_crew.install_crew", fake_install_crew)
|
||||
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
deploy_main._ensure_lockfile_for_deploy()
|
||||
|
||||
assert exc_info.value.code == 42
|
||||
|
||||
|
||||
class _FakeDeployValidator:
|
||||
def __init__(self, results: list[ValidationResult]):
|
||||
self.results = results
|
||||
|
||||
@property
|
||||
def errors(self) -> list[ValidationResult]:
|
||||
return [
|
||||
result
|
||||
for result in self.results
|
||||
if result.severity is Severity.ERROR
|
||||
]
|
||||
|
||||
def run(self) -> list[ValidationResult]:
|
||||
return self.results
|
||||
|
||||
|
||||
def test_prepare_project_for_deploy_blocks_install_when_validation_fails(
|
||||
monkeypatch, tmp_path: Path
|
||||
):
|
||||
monkeypatch.chdir(tmp_path)
|
||||
(tmp_path / "pyproject.toml").write_text("[project]\nname = 'demo'\n")
|
||||
install_calls = []
|
||||
rendered_results = []
|
||||
missing_lockfile = ValidationResult(
|
||||
Severity.ERROR,
|
||||
"missing_lockfile",
|
||||
"Expected to find a lockfile",
|
||||
)
|
||||
invalid_config = ValidationResult(
|
||||
Severity.ERROR,
|
||||
"invalid_crew_json",
|
||||
"crew.jsonc has invalid configuration",
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
deploy_main,
|
||||
"DeployValidator",
|
||||
lambda: _FakeDeployValidator([missing_lockfile, invalid_config]),
|
||||
)
|
||||
monkeypatch.setattr(deploy_main, "render_report", rendered_results.append)
|
||||
|
||||
def fake_install_crew(proxy_options, *, raise_on_error=False):
|
||||
install_calls.append((proxy_options, raise_on_error))
|
||||
|
||||
monkeypatch.setattr("crewai_cli.install_crew.install_crew", fake_install_crew)
|
||||
|
||||
assert deploy_main._prepare_project_for_deploy(skip_validate=False) is False
|
||||
|
||||
assert install_calls == []
|
||||
assert [[result.code for result in results] for results in rendered_results] == [
|
||||
["invalid_crew_json"]
|
||||
]
|
||||
|
||||
|
||||
def test_prepare_project_for_deploy_creates_missing_lock_after_validation(
|
||||
monkeypatch, tmp_path: Path
|
||||
):
|
||||
monkeypatch.chdir(tmp_path)
|
||||
(tmp_path / "pyproject.toml").write_text("[project]\nname = 'demo'\n")
|
||||
install_calls = []
|
||||
missing_lockfile = ValidationResult(
|
||||
Severity.ERROR,
|
||||
"missing_lockfile",
|
||||
"Expected to find a lockfile",
|
||||
)
|
||||
validators = [
|
||||
_FakeDeployValidator([missing_lockfile]),
|
||||
_FakeDeployValidator([]),
|
||||
]
|
||||
|
||||
def fake_validator():
|
||||
return validators.pop(0)
|
||||
|
||||
def fake_install_crew(proxy_options, *, raise_on_error=False):
|
||||
install_calls.append((proxy_options, raise_on_error))
|
||||
(tmp_path / "uv.lock").write_text("# lock\n")
|
||||
|
||||
monkeypatch.setattr(deploy_main, "DeployValidator", fake_validator)
|
||||
monkeypatch.setattr(deploy_main, "render_report", lambda results: None)
|
||||
monkeypatch.setattr("crewai_cli.install_crew.install_crew", fake_install_crew)
|
||||
|
||||
assert deploy_main._prepare_project_for_deploy(skip_validate=False) is True
|
||||
|
||||
assert install_calls == [([], True)]
|
||||
assert validators == []
|
||||
|
||||
|
||||
class TestDeployCommand(unittest.TestCase):
|
||||
@patch("crewai_cli.command.get_auth_token")
|
||||
@patch("crewai_cli.deploy.main.get_project_name")
|
||||
@@ -28,19 +184,25 @@ class TestDeployCommand(unittest.TestCase):
|
||||
self.mock_get_auth_token.return_value = "test_token"
|
||||
self.mock_get_project_name.return_value = "test_project"
|
||||
|
||||
self.deploy_command = DeployCommand()
|
||||
self.deploy_command = deploy_main.DeployCommand()
|
||||
self.mock_client = self.deploy_command.plus_api_client
|
||||
|
||||
def test_init_success(self):
|
||||
self.assertEqual(self.deploy_command.project_name, "test_project")
|
||||
self.mock_plus_api.assert_called_once_with(api_key="test_token")
|
||||
|
||||
@patch("builtins.input")
|
||||
def test_confirm_zip_input_only_confirms_env_vars(self, mock_input):
|
||||
self.deploy_command._confirm_zip_input({"MODEL": "openai/gpt-5"}, False)
|
||||
|
||||
mock_input.assert_called_once_with("Press Enter to continue with 1 env vars: MODEL")
|
||||
|
||||
@patch("crewai_cli.command.get_auth_token")
|
||||
def test_init_failure(self, mock_get_auth_token):
|
||||
mock_get_auth_token.side_effect = Exception("Auth failed")
|
||||
|
||||
with self.assertRaises(SystemExit):
|
||||
DeployCommand()
|
||||
deploy_main.DeployCommand()
|
||||
|
||||
def test_validate_response_successful_response(self):
|
||||
mock_response = Mock(spec=httpx.Response)
|
||||
@@ -123,8 +285,15 @@ class TestDeployCommand(unittest.TestCase):
|
||||
)
|
||||
self.assertIn("2023-01-01 - INFO: Test log", fake_out.getvalue())
|
||||
|
||||
@patch("crewai_cli.deploy.main.git.Repository")
|
||||
@patch("crewai_cli.deploy.main.DeployCommand._display_deployment_info")
|
||||
def test_deploy_with_uuid(self, mock_display):
|
||||
def test_deploy_with_uuid(self, mock_display, mock_repository):
|
||||
mock_repository.return_value.origin_url.return_value = (
|
||||
"https://github.com/test/repo.git"
|
||||
)
|
||||
mock_repository.return_value.create_initial_commit_if_needed.return_value = (
|
||||
False
|
||||
)
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {"uuid": "test-uuid"}
|
||||
@@ -135,8 +304,15 @@ class TestDeployCommand(unittest.TestCase):
|
||||
self.mock_client.deploy_by_uuid.assert_called_once_with("test-uuid")
|
||||
mock_display.assert_called_once_with({"uuid": "test-uuid"})
|
||||
|
||||
@patch("crewai_cli.deploy.main.git.Repository")
|
||||
@patch("crewai_cli.deploy.main.DeployCommand._display_deployment_info")
|
||||
def test_deploy_with_project_name(self, mock_display):
|
||||
def test_deploy_with_project_name(self, mock_display, mock_repository):
|
||||
mock_repository.return_value.origin_url.return_value = (
|
||||
"https://github.com/test/repo.git"
|
||||
)
|
||||
mock_repository.return_value.create_initial_commit_if_needed.return_value = (
|
||||
False
|
||||
)
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {"uuid": "test-uuid"}
|
||||
@@ -147,16 +323,142 @@ class TestDeployCommand(unittest.TestCase):
|
||||
self.mock_client.deploy_by_name.assert_called_once_with("test_project")
|
||||
mock_display.assert_called_once_with({"uuid": "test-uuid"})
|
||||
|
||||
@patch("crewai_cli.deploy.main.create_project_zip")
|
||||
@patch("crewai_cli.deploy.main.git.Repository")
|
||||
@patch("crewai_cli.deploy.main.DeployCommand._display_deployment_info")
|
||||
def test_deploy_with_remote_keeps_remote_path_when_fetch_fails(
|
||||
self, mock_display, mock_repository, mock_create_project_zip
|
||||
):
|
||||
repository = mock_repository.return_value
|
||||
repository.origin_url.return_value = "https://github.com/test/repo.git"
|
||||
repository.fetch.side_effect = ValueError("fetch failed")
|
||||
repository.create_initial_commit_if_needed.return_value = False
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.is_success = True
|
||||
mock_response.json.return_value = {"uuid": "test-uuid"}
|
||||
self.mock_client.deploy_by_name.return_value = mock_response
|
||||
|
||||
with patch("sys.stdout", new=StringIO()) as fake_out:
|
||||
self.deploy_command.deploy(skip_validate=True)
|
||||
output = fake_out.getvalue()
|
||||
|
||||
mock_repository.assert_called_once_with(fetch=False)
|
||||
repository.fetch.assert_called_once_with()
|
||||
self.assertIn("Continuing with remote deployment", output)
|
||||
self.mock_client.deploy_by_name.assert_called_once_with("test_project")
|
||||
self.mock_client.update_crew_from_zip.assert_not_called()
|
||||
mock_create_project_zip.assert_not_called()
|
||||
mock_display.assert_called_once_with({"uuid": "test-uuid"})
|
||||
|
||||
@patch("crewai_cli.deploy.main.create_project_zip")
|
||||
@patch("crewai_cli.deploy.main.git.Repository")
|
||||
@patch("crewai_cli.deploy.main.DeployCommand._display_deployment_info")
|
||||
def test_deploy_with_remote_keeps_remote_path_when_initial_commit_fails(
|
||||
self, mock_display, mock_repository, mock_create_project_zip
|
||||
):
|
||||
repository = mock_repository.return_value
|
||||
repository.origin_url.return_value = "https://github.com/test/repo.git"
|
||||
repository.create_initial_commit_if_needed.side_effect = RuntimeError(
|
||||
"commit failed"
|
||||
)
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.is_success = True
|
||||
mock_response.json.return_value = {"uuid": "test-uuid"}
|
||||
self.mock_client.deploy_by_name.return_value = mock_response
|
||||
|
||||
with patch("sys.stdout", new=StringIO()) as fake_out:
|
||||
self.deploy_command.deploy(skip_validate=True)
|
||||
output = fake_out.getvalue()
|
||||
|
||||
mock_repository.assert_called_once_with(fetch=False)
|
||||
repository.fetch.assert_called_once_with()
|
||||
self.assertIn("Continuing with remote deployment", output)
|
||||
self.mock_client.deploy_by_name.assert_called_once_with("test_project")
|
||||
self.mock_client.update_crew_from_zip.assert_not_called()
|
||||
mock_create_project_zip.assert_not_called()
|
||||
mock_display.assert_called_once_with({"uuid": "test-uuid"})
|
||||
|
||||
@patch("crewai_cli.deploy.main.create_project_zip")
|
||||
@patch("crewai_cli.deploy.main.fetch_and_json_env_file")
|
||||
@patch("crewai_cli.deploy.main.git.Repository.origin_url")
|
||||
@patch("builtins.input")
|
||||
def test_create_crew(self, mock_input, mock_git_origin_url, mock_fetch_env):
|
||||
@patch("crewai_cli.deploy.main.git.Repository")
|
||||
@patch("crewai_cli.deploy.main.DeployCommand._display_deployment_info")
|
||||
def test_deploy_with_uuid_without_remote_updates_from_zip(
|
||||
self, mock_display, mock_repository, mock_fetch_env, mock_create_project_zip
|
||||
):
|
||||
mock_fetch_env.return_value = {"ENV_VAR": "value"}
|
||||
mock_git_origin_url.return_value = "https://github.com/test/repo.git"
|
||||
mock_repository.return_value.origin_url.return_value = None
|
||||
mock_repository.return_value.create_initial_commit_if_needed.return_value = (
|
||||
False
|
||||
)
|
||||
mock_create_project_zip.return_value = Path("/tmp/test_project.zip")
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {"uuid": "test-uuid"}
|
||||
self.mock_client.update_crew_from_zip.return_value = mock_response
|
||||
|
||||
self.deploy_command.deploy(uuid="test-uuid", skip_validate=True)
|
||||
|
||||
self.mock_client.update_crew_from_zip.assert_called_once_with(
|
||||
"test-uuid",
|
||||
Path("/tmp/test_project.zip"),
|
||||
env={"ENV_VAR": "value"},
|
||||
)
|
||||
self.mock_client.deploy_by_uuid.assert_not_called()
|
||||
mock_display.assert_called_once_with({"uuid": "test-uuid"})
|
||||
|
||||
@patch("crewai_cli.deploy.main.create_project_zip")
|
||||
@patch("crewai_cli.deploy.main.fetch_and_json_env_file")
|
||||
@patch("crewai_cli.deploy.main.git.Repository")
|
||||
@patch("crewai_cli.deploy.main.DeployCommand._display_deployment_info")
|
||||
def test_deploy_with_project_name_without_remote_updates_from_zip(
|
||||
self, mock_display, mock_repository, mock_fetch_env, mock_create_project_zip
|
||||
):
|
||||
mock_fetch_env.return_value = {"ENV_VAR": "value"}
|
||||
mock_repository.return_value.origin_url.return_value = None
|
||||
mock_repository.return_value.create_initial_commit_if_needed.return_value = (
|
||||
False
|
||||
)
|
||||
mock_create_project_zip.return_value = Path("/tmp/test_project.zip")
|
||||
status_response = MagicMock()
|
||||
status_response.status_code = 200
|
||||
status_response.is_success = True
|
||||
status_response.json.return_value = {"uuid": "test-uuid"}
|
||||
update_response = MagicMock()
|
||||
update_response.status_code = 200
|
||||
update_response.json.return_value = {"uuid": "test-uuid"}
|
||||
self.mock_client.crew_status_by_name.return_value = status_response
|
||||
self.mock_client.update_crew_from_zip.return_value = update_response
|
||||
|
||||
self.deploy_command.deploy(skip_validate=True)
|
||||
|
||||
self.mock_client.crew_status_by_name.assert_called_once_with("test_project")
|
||||
self.mock_client.update_crew_from_zip.assert_called_once_with(
|
||||
"test-uuid",
|
||||
Path("/tmp/test_project.zip"),
|
||||
env={"ENV_VAR": "value"},
|
||||
)
|
||||
self.mock_client.deploy_by_name.assert_not_called()
|
||||
mock_display.assert_called_once_with({"uuid": "test-uuid"})
|
||||
|
||||
@patch("crewai_cli.deploy.main.fetch_and_json_env_file")
|
||||
@patch("crewai_cli.deploy.main.git.Repository")
|
||||
@patch("builtins.input")
|
||||
@pytest.mark.timeout(180)
|
||||
def test_create_crew(self, mock_input, mock_repository, mock_fetch_env):
|
||||
mock_fetch_env.return_value = {"ENV_VAR": "value"}
|
||||
mock_repository.return_value.origin_url.return_value = (
|
||||
"https://github.com/test/repo.git"
|
||||
)
|
||||
mock_repository.return_value.create_initial_commit_if_needed.return_value = (
|
||||
False
|
||||
)
|
||||
mock_input.return_value = ""
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 201
|
||||
mock_response.is_success = True
|
||||
mock_response.json.return_value = {"uuid": "new-uuid", "status": "created"}
|
||||
self.mock_client.create_crew.return_value = mock_response
|
||||
|
||||
@@ -165,6 +467,129 @@ class TestDeployCommand(unittest.TestCase):
|
||||
self.assertIn("Deployment created successfully!", fake_out.getvalue())
|
||||
self.assertIn("new-uuid", fake_out.getvalue())
|
||||
|
||||
@patch("crewai_cli.deploy.main.create_project_zip")
|
||||
@patch("crewai_cli.deploy.main.fetch_and_json_env_file")
|
||||
@patch("crewai_cli.deploy.main.git.Repository")
|
||||
def test_create_crew_without_git_repo_initializes_and_uses_zip(
|
||||
self, mock_repository, mock_fetch_env, mock_create_project_zip
|
||||
):
|
||||
mock_fetch_env.return_value = {"ENV_VAR": "value"}
|
||||
mock_repository.side_effect = ValueError("not a Git repository")
|
||||
initialized_repository = MagicMock()
|
||||
initialized_repository.origin_url.return_value = None
|
||||
mock_repository.initialize.return_value = initialized_repository
|
||||
mock_create_project_zip.return_value = Path("/tmp/test_project.zip")
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 201
|
||||
mock_response.is_success = True
|
||||
mock_response.json.return_value = {"uuid": "zip-uuid", "status": "created"}
|
||||
self.mock_client.create_crew_from_zip.return_value = mock_response
|
||||
|
||||
with patch("sys.stdout", new=StringIO()) as fake_out:
|
||||
self.deploy_command.create_crew(confirm=True, skip_validate=True)
|
||||
output = fake_out.getvalue()
|
||||
|
||||
self.assertIn("Initialized a local Git repository", output)
|
||||
self.assertIn("Deploying from a ZIP upload", output)
|
||||
mock_repository.initialize.assert_called_once_with()
|
||||
mock_create_project_zip.assert_called_once_with(
|
||||
"test_project", repository=initialized_repository
|
||||
)
|
||||
self.mock_client.create_crew_from_zip.assert_called_once_with(
|
||||
Path("/tmp/test_project.zip"),
|
||||
name="test_project",
|
||||
env={"ENV_VAR": "value"},
|
||||
)
|
||||
self.mock_client.create_crew.assert_not_called()
|
||||
|
||||
@patch("crewai_cli.deploy.main.git.Repository")
|
||||
def test_prepare_git_repository_returns_repo_when_init_commit_fails(
|
||||
self, mock_repository
|
||||
):
|
||||
recovered_repository = MagicMock()
|
||||
mock_repository.side_effect = [
|
||||
ValueError("not a Git repository"),
|
||||
recovered_repository,
|
||||
]
|
||||
mock_repository.initialize.side_effect = RuntimeError("commit failed")
|
||||
|
||||
with patch("sys.stdout", new=StringIO()) as fake_out:
|
||||
repository = self.deploy_command._prepare_git_repository()
|
||||
|
||||
self.assertIs(repository, recovered_repository)
|
||||
self.assertIn("Git auto-setup did not complete", fake_out.getvalue())
|
||||
mock_repository.initialize.assert_called_once_with()
|
||||
self.assertEqual(mock_repository.call_count, 2)
|
||||
|
||||
@patch("crewai_cli.deploy.main.create_project_zip")
|
||||
@patch("crewai_cli.deploy.main.fetch_and_json_env_file")
|
||||
@patch("crewai_cli.deploy.main.git.Repository")
|
||||
def test_create_crew_without_remote_uses_zip(
|
||||
self, mock_repository, mock_fetch_env, mock_create_project_zip
|
||||
):
|
||||
mock_fetch_env.return_value = {"ENV_VAR": "value"}
|
||||
mock_repository.return_value.origin_url.return_value = None
|
||||
mock_repository.return_value.create_initial_commit_if_needed.return_value = (
|
||||
False
|
||||
)
|
||||
mock_create_project_zip.return_value = Path("/tmp/test_project.zip")
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 201
|
||||
mock_response.is_success = True
|
||||
mock_response.json.return_value = {"uuid": "zip-uuid", "status": "created"}
|
||||
self.mock_client.create_crew_from_zip.return_value = mock_response
|
||||
|
||||
with patch("sys.stdout", new=StringIO()) as fake_out:
|
||||
self.deploy_command.create_crew(confirm=True, skip_validate=True)
|
||||
output = fake_out.getvalue()
|
||||
|
||||
self.assertIn("No origin remote found.", output)
|
||||
self.assertIn("Deploying from a ZIP upload", output)
|
||||
mock_create_project_zip.assert_called_once_with(
|
||||
"test_project", repository=mock_repository.return_value
|
||||
)
|
||||
self.mock_client.create_crew_from_zip.assert_called_once_with(
|
||||
Path("/tmp/test_project.zip"),
|
||||
name="test_project",
|
||||
env={"ENV_VAR": "value"},
|
||||
)
|
||||
self.mock_client.create_crew.assert_not_called()
|
||||
|
||||
@patch("crewai_cli.deploy.main.create_project_zip")
|
||||
@patch("crewai_cli.deploy.main.fetch_and_json_env_file")
|
||||
@patch("crewai_cli.deploy.main.git.Repository")
|
||||
def test_create_crew_without_remote_uses_git_file_list_when_commit_fails(
|
||||
self, mock_repository, mock_fetch_env, mock_create_project_zip
|
||||
):
|
||||
mock_fetch_env.return_value = {"ENV_VAR": "value"}
|
||||
repository = mock_repository.return_value
|
||||
repository.origin_url.return_value = None
|
||||
repository.create_initial_commit_if_needed.side_effect = RuntimeError(
|
||||
"commit failed"
|
||||
)
|
||||
mock_create_project_zip.return_value = Path("/tmp/test_project.zip")
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 201
|
||||
mock_response.is_success = True
|
||||
mock_response.json.return_value = {"uuid": "zip-uuid", "status": "created"}
|
||||
self.mock_client.create_crew_from_zip.return_value = mock_response
|
||||
|
||||
with patch("sys.stdout", new=StringIO()) as fake_out:
|
||||
self.deploy_command.create_crew(confirm=True, skip_validate=True)
|
||||
output = fake_out.getvalue()
|
||||
|
||||
self.assertIn("Continuing with ZIP deployment using Git", output)
|
||||
self.assertIn("file listing", output)
|
||||
mock_create_project_zip.assert_called_once_with(
|
||||
"test_project", repository=repository
|
||||
)
|
||||
self.mock_client.create_crew_from_zip.assert_called_once_with(
|
||||
Path("/tmp/test_project.zip"),
|
||||
name="test_project",
|
||||
env={"ENV_VAR": "value"},
|
||||
)
|
||||
self.mock_client.create_crew.assert_not_called()
|
||||
|
||||
def test_list_crews(self):
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
|
||||
@@ -110,6 +110,45 @@ def _run_without_import_check(root: Path) -> DeployValidator:
|
||||
return v
|
||||
|
||||
|
||||
def _scaffold_json_crew(root: Path, *, task_agent: str = "researcher") -> None:
|
||||
(root / "pyproject.toml").write_text(_make_pyproject(name="json_crew"))
|
||||
(root / "uv.lock").write_text("# dummy uv lockfile\n")
|
||||
agents_dir = root / "agents"
|
||||
agents_dir.mkdir()
|
||||
(agents_dir / "researcher.jsonc").write_text(
|
||||
dedent(
|
||||
"""
|
||||
{
|
||||
"role": "Researcher",
|
||||
"goal": "Research things",
|
||||
"backstory": "Experienced researcher",
|
||||
"llm": "openai/gpt-4o-mini"
|
||||
}
|
||||
"""
|
||||
).strip()
|
||||
+ "\n"
|
||||
)
|
||||
(root / "crew.jsonc").write_text(
|
||||
dedent(
|
||||
f"""
|
||||
{{
|
||||
"name": "json_crew",
|
||||
"agents": ["researcher"],
|
||||
"tasks": [
|
||||
{{
|
||||
"name": "research",
|
||||
"description": "Research https://example.com/a//b",
|
||||
"expected_output": "Findings",
|
||||
"agent": "{task_agent}"
|
||||
}}
|
||||
]
|
||||
}}
|
||||
"""
|
||||
).strip()
|
||||
+ "\n"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"project_name, expected",
|
||||
[
|
||||
@@ -129,6 +168,38 @@ def test_valid_standard_crew_project_passes(tmp_path: Path) -> None:
|
||||
assert v.ok, f"expected clean run, got {v.results}"
|
||||
|
||||
|
||||
def test_valid_json_crew_project_passes(tmp_path: Path) -> None:
|
||||
_scaffold_json_crew(tmp_path)
|
||||
v = DeployValidator(project_root=tmp_path)
|
||||
v.run()
|
||||
assert "invalid_crew_json" not in _codes(v)
|
||||
|
||||
|
||||
def test_json_task_agent_mismatch_is_error(tmp_path: Path) -> None:
|
||||
_scaffold_json_crew(tmp_path, task_agent="missing_agent")
|
||||
v = DeployValidator(project_root=tmp_path)
|
||||
v.run()
|
||||
finding = next(r for r in v.results if r.code == "invalid_crew_json")
|
||||
assert finding.severity is Severity.ERROR
|
||||
assert "missing_agent" in finding.detail
|
||||
|
||||
|
||||
def test_json_runtime_fields_are_deploy_errors(tmp_path: Path) -> None:
|
||||
_scaffold_json_crew(tmp_path)
|
||||
crew_path = tmp_path / "crew.jsonc"
|
||||
crew_path.write_text(
|
||||
crew_path.read_text().replace(
|
||||
'"name": "json_crew",',
|
||||
'"name": "json_crew",\n "id": "00000000-0000-4000-8000-000000000000",',
|
||||
)
|
||||
)
|
||||
v = DeployValidator(project_root=tmp_path)
|
||||
v.run()
|
||||
finding = next(r for r in v.results if r.code == "invalid_crew_json")
|
||||
assert finding.severity is Severity.ERROR
|
||||
assert "runtime-only" in finding.detail
|
||||
|
||||
|
||||
def test_missing_pyproject_errors(tmp_path: Path) -> None:
|
||||
v = _run_without_import_check(tmp_path)
|
||||
assert "missing_pyproject" in _codes(v)
|
||||
@@ -410,7 +481,7 @@ def test_modern_crewai_pin_does_not_warn(tmp_path: Path) -> None:
|
||||
|
||||
def test_create_crew_aborts_on_validation_error(tmp_path: Path) -> None:
|
||||
"""`crewai deploy create` must not contact the API when validation fails."""
|
||||
from unittest.mock import MagicMock, patch as mock_patch
|
||||
from unittest.mock import patch as mock_patch
|
||||
|
||||
from crewai_cli.deploy.main import DeployCommand
|
||||
|
||||
@@ -419,11 +490,38 @@ def test_create_crew_aborts_on_validation_error(tmp_path: Path) -> None:
|
||||
mock_patch("crewai_cli.deploy.main.get_project_name", return_value="p"),
|
||||
mock_patch("crewai_cli.command.PlusAPI") as mock_api,
|
||||
mock_patch(
|
||||
"crewai_cli.deploy.main.validate_project"
|
||||
) as mock_validate,
|
||||
"crewai_cli.deploy.main._prepare_project_for_deploy",
|
||||
return_value=False,
|
||||
),
|
||||
):
|
||||
mock_validate.return_value = MagicMock(ok=False)
|
||||
cmd = DeployCommand()
|
||||
cmd.create_crew()
|
||||
assert not cmd.plus_api_client.create_crew.called
|
||||
del mock_api # silence unused-var lint
|
||||
del mock_api # silence unused-var lint
|
||||
|
||||
|
||||
def test_is_json_crew_defers_to_declared_flow_type(tmp_path):
|
||||
"""A flow project with a stray crew.jsonc must validate as a flow."""
|
||||
(tmp_path / "crew.jsonc").write_text("{}")
|
||||
(tmp_path / "pyproject.toml").write_text(
|
||||
'[project]\nname = "demo"\nversion = "0.1.0"\n\n'
|
||||
'[tool.crewai]\ntype = "flow"\n'
|
||||
)
|
||||
|
||||
assert DeployValidator(project_root=tmp_path)._is_json_crew is False
|
||||
|
||||
|
||||
def test_is_json_crew_true_for_declared_crew_type(tmp_path):
|
||||
(tmp_path / "crew.jsonc").write_text("{}")
|
||||
(tmp_path / "pyproject.toml").write_text(
|
||||
'[project]\nname = "demo"\nversion = "0.1.0"\n\n'
|
||||
'[tool.crewai]\ntype = "crew"\n'
|
||||
)
|
||||
|
||||
assert DeployValidator(project_root=tmp_path)._is_json_crew is True
|
||||
|
||||
|
||||
def test_is_json_crew_true_without_pyproject(tmp_path):
|
||||
(tmp_path / "crew.jsonc").write_text("{}")
|
||||
|
||||
assert DeployValidator(project_root=tmp_path)._is_json_crew is True
|
||||
|
||||
@@ -13,6 +13,7 @@ from crewai_cli.cli import (
|
||||
flow_add_crew,
|
||||
login,
|
||||
reset_memories,
|
||||
run,
|
||||
test,
|
||||
train,
|
||||
version,
|
||||
@@ -93,9 +94,9 @@ def test_version_command_with_tools(runner):
|
||||
def test_test_default_iterations(evaluate_crew, runner):
|
||||
result = runner.invoke(test)
|
||||
|
||||
evaluate_crew.assert_called_once_with(3, "gpt-4o-mini", trained_agents_file=None)
|
||||
evaluate_crew.assert_called_once_with(3, "gpt-5.4-mini", trained_agents_file=None)
|
||||
assert result.exit_code == 0
|
||||
assert "Testing the crew for 3 iterations with model gpt-4o-mini" in result.output
|
||||
assert "Testing the crew for 3 iterations with model gpt-5.4-mini" in result.output
|
||||
|
||||
|
||||
@mock.patch("crewai_cli.cli.evaluate_crew")
|
||||
@@ -119,6 +120,43 @@ def test_test_invalid_string_iterations(evaluate_crew, runner):
|
||||
)
|
||||
|
||||
|
||||
@mock.patch("crewai_cli.cli.run_crew")
|
||||
def test_run_uses_project_runner_by_default(run_crew, runner):
|
||||
result = runner.invoke(run)
|
||||
|
||||
assert result.exit_code == 0
|
||||
run_crew.assert_called_once_with(trained_agents_file=None)
|
||||
assert "experimental" not in result.output.lower()
|
||||
|
||||
|
||||
@mock.patch("crewai_cli.cli.run_flow_definition")
|
||||
def test_run_with_definition_uses_definition_runner(run_flow_definition, runner):
|
||||
result = runner.invoke(
|
||||
run,
|
||||
["--definition", "flow.yaml", "--inputs", '{"topic":"AI"}'],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert (
|
||||
"Warning: `crewai run --definition` is experimental and may change without notice."
|
||||
in result.output
|
||||
)
|
||||
run_flow_definition.assert_called_once_with(
|
||||
definition="flow.yaml", inputs='{"topic":"AI"}'
|
||||
)
|
||||
|
||||
|
||||
@mock.patch("crewai_cli.cli.run_crew")
|
||||
@mock.patch("crewai_cli.cli.run_flow_definition")
|
||||
def test_run_rejects_inputs_without_definition(run_flow_definition, run_crew, runner):
|
||||
result = runner.invoke(run, ["--inputs", '{"topic":"AI"}'])
|
||||
|
||||
assert result.exit_code == 2
|
||||
assert "Error: --inputs requires --definition" in result.output
|
||||
run_flow_definition.assert_not_called()
|
||||
run_crew.assert_not_called()
|
||||
|
||||
|
||||
@mock.patch("crewai_cli.cli.AuthenticationCommand")
|
||||
def test_login(command, runner):
|
||||
mock_auth = command.return_value
|
||||
|
||||
@@ -6,6 +6,8 @@ from unittest import mock
|
||||
|
||||
import pytest
|
||||
from click.testing import CliRunner
|
||||
import crewai_cli.create_json_crew as json_crew
|
||||
import crewai_cli.tui_picker as tui_picker
|
||||
from crewai_cli.create_crew import create_crew, create_folder_structure
|
||||
|
||||
|
||||
@@ -345,3 +347,468 @@ def test_env_vars_are_uppercased_in_env_file(
|
||||
env_file_path = crew_path / ".env"
|
||||
content = env_file_path.read_text()
|
||||
assert "MODEL=" in content
|
||||
|
||||
|
||||
def test_json_wizard_defaults_to_sequential_and_memory_enabled(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
json_crew,
|
||||
"_wizard_agent",
|
||||
lambda **_: {
|
||||
"name": "researcher",
|
||||
"role": "Researcher",
|
||||
"goal": "Research",
|
||||
"backstory": "Researcher",
|
||||
"llm": "openai/gpt-5.5",
|
||||
"tools": [],
|
||||
"planning": False,
|
||||
"allow_delegation": False,
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
json_crew,
|
||||
"_wizard_task",
|
||||
lambda **_: {
|
||||
"name": "research_task",
|
||||
"description": "Research",
|
||||
"expected_output": "Findings",
|
||||
"agent": "researcher",
|
||||
"context": [],
|
||||
},
|
||||
)
|
||||
|
||||
def confirm(label: str, default: bool = False) -> bool:
|
||||
if label == "Enable crew memory?":
|
||||
return default
|
||||
return False
|
||||
|
||||
monkeypatch.setattr(json_crew, "_confirm", confirm)
|
||||
monkeypatch.setattr(json_crew.click, "prompt", lambda *_, **__: "")
|
||||
monkeypatch.setattr(
|
||||
json_crew,
|
||||
"pick_one",
|
||||
lambda *_args, **_kwargs: pytest.fail("process should not be prompted"),
|
||||
)
|
||||
|
||||
_agents, _tasks, settings = json_crew._wizard_agents_and_tasks(
|
||||
skip_provider=True,
|
||||
default_llm="openai/gpt-5.5",
|
||||
)
|
||||
|
||||
assert settings == {"process": "sequential", "memory": True, "inputs": {}}
|
||||
|
||||
|
||||
def test_json_wizard_shows_interpolation_hint(capsys):
|
||||
json_crew._show_interpolation_hint("tasks")
|
||||
|
||||
output = capsys.readouterr().out
|
||||
assert "{placeholder}" in output
|
||||
assert "dynamic values" in output
|
||||
assert "{topic}" not in output
|
||||
assert "Description >" not in output
|
||||
assert '"description"' not in output
|
||||
|
||||
|
||||
def test_json_wizard_text_prompt_uses_full_prompt_for_readline(monkeypatch):
|
||||
prompts: list[str] = []
|
||||
|
||||
monkeypatch.setattr(
|
||||
json_crew, "_readline_safe_prompt", lambda prompt: f"safe:{prompt}"
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"builtins.input", lambda prompt: prompts.append(prompt) or "Draft content"
|
||||
)
|
||||
|
||||
assert json_crew._prompt_text("Goal", spacing_before=False) == "Draft content"
|
||||
assert len(prompts) == 1
|
||||
assert prompts[0].startswith("safe:")
|
||||
assert "Goal" in prompts[0]
|
||||
assert " > " in prompts[0]
|
||||
|
||||
|
||||
def test_json_wizard_tool_picker_prioritizes_common_tools(monkeypatch):
|
||||
picker_calls: list[tuple[str, list[str], dict[str, object]]] = []
|
||||
|
||||
def pick_many(title: str, labels: list[str], **kwargs):
|
||||
picker_calls.append((title, labels, kwargs))
|
||||
return [1, 3], None
|
||||
|
||||
monkeypatch.setattr(json_crew, "pick_many", pick_many)
|
||||
|
||||
tools = json_crew._select_tools()
|
||||
|
||||
assert tools == ["SerperDevTool", "DirectoryReadTool"]
|
||||
assert len(picker_calls) == 1
|
||||
labels = picker_calls[0][1]
|
||||
assert 0 in picker_calls[0][2]["separator_indices"]
|
||||
assert labels[0] == "── Common tools ──"
|
||||
assert labels[1].strip().endswith("SerperDevTool")
|
||||
assert labels[2].strip().endswith("ScrapeWebsiteTool")
|
||||
assert labels[3].strip().endswith("DirectoryReadTool")
|
||||
assert labels[4].strip().endswith("FileReadTool")
|
||||
assert labels[5].strip().endswith("FileWriterTool")
|
||||
assert labels[1].index("Google search") < labels[1].index("SerperDevTool")
|
||||
assert "More tools" not in labels
|
||||
|
||||
|
||||
def test_json_wizard_tool_picker_collapses_categories_by_default(monkeypatch):
|
||||
picker_calls: list[tuple[str, list[str], dict[str, object]]] = []
|
||||
|
||||
def pick_many(title: str, labels: list[str], **kwargs):
|
||||
picker_calls.append((title, labels, kwargs))
|
||||
return [], None
|
||||
|
||||
monkeypatch.setattr(json_crew, "pick_many", pick_many)
|
||||
|
||||
json_crew._select_tools()
|
||||
|
||||
labels = picker_calls[0][1]
|
||||
action_indices = picker_calls[0][2]["action_indices"]
|
||||
# Categories show as collapsed action rows, not separators with tools
|
||||
assert any(label.startswith("▸ Search & Research") for label in labels)
|
||||
assert any(label.startswith("▸ Web Scraping") for label in labels)
|
||||
assert not any(label.strip().endswith("BraveSearchTool") for label in labels)
|
||||
assert len(action_indices) >= 4
|
||||
# Only the common tools section is visible beyond the category rows
|
||||
assert len(labels) == 1 + 5 + len(action_indices)
|
||||
|
||||
|
||||
def test_json_wizard_tool_picker_expands_one_category_at_a_time(monkeypatch):
|
||||
picker_calls: list[tuple[str, list[str], dict[str, object]]] = []
|
||||
|
||||
def find_category_row(labels: list[str], category: str) -> int:
|
||||
return next(
|
||||
idx for idx, label in enumerate(labels) if category in label
|
||||
)
|
||||
|
||||
def pick_many(title: str, labels: list[str], **kwargs):
|
||||
picker_calls.append((title, labels, kwargs))
|
||||
call_num = len(picker_calls)
|
||||
if call_num == 1:
|
||||
return [], find_category_row(labels, "Search & Research")
|
||||
if call_num == 2:
|
||||
# Search & Research is expanded; select BraveSearchTool and
|
||||
# expand Web Scraping instead
|
||||
brave = next(
|
||||
idx
|
||||
for idx, label in enumerate(labels)
|
||||
if label.strip().endswith("BraveSearchTool")
|
||||
)
|
||||
return [brave], find_category_row(labels, "Web Scraping")
|
||||
return [], None
|
||||
|
||||
monkeypatch.setattr(json_crew, "pick_many", pick_many)
|
||||
|
||||
tools = json_crew._select_tools()
|
||||
|
||||
assert tools == ["BraveSearchTool"]
|
||||
assert len(picker_calls) == 3
|
||||
# Second render: Search & Research expanded, others collapsed
|
||||
labels2 = picker_calls[1][1]
|
||||
assert any(label.startswith("▾ Search & Research") for label in labels2)
|
||||
assert any(label.strip().endswith("BraveSearchTool") for label in labels2)
|
||||
assert any(label.startswith("▸ Web Scraping") for label in labels2)
|
||||
# Third render: Web Scraping expanded, Search & Research collapsed again
|
||||
labels3 = picker_calls[2][1]
|
||||
assert any(label.startswith("▸ Search & Research") for label in labels3)
|
||||
assert any(label.startswith("▾ Web Scraping") for label in labels3)
|
||||
assert not any(label.strip().endswith("BraveSearchTool") for label in labels3)
|
||||
# The collapsed Search & Research row reports its selection count
|
||||
assert any(
|
||||
"Search & Research" in label and "1 selected" in label for label in labels3
|
||||
)
|
||||
# Cursor returns to the toggled category row
|
||||
assert picker_calls[2][2]["initial_cursor"] == next(
|
||||
idx for idx, label in enumerate(labels3) if "Web Scraping" in label
|
||||
)
|
||||
|
||||
|
||||
def test_json_wizard_tool_picker_preserves_selection_across_renders(monkeypatch):
|
||||
picker_calls: list[tuple[str, list[str], dict[str, object]]] = []
|
||||
|
||||
def pick_many(title: str, labels: list[str], **kwargs):
|
||||
picker_calls.append((title, labels, kwargs))
|
||||
call_num = len(picker_calls)
|
||||
if call_num == 1:
|
||||
# Select a common tool, then expand a category
|
||||
category_row = next(
|
||||
idx for idx, label in enumerate(labels) if "Web Scraping" in label
|
||||
)
|
||||
return [1], category_row
|
||||
# Confirm without touching anything else
|
||||
return sorted(kwargs["preselected"]), None
|
||||
|
||||
monkeypatch.setattr(json_crew, "pick_many", pick_many)
|
||||
|
||||
tools = json_crew._select_tools()
|
||||
|
||||
# The common-tool selection survived the expand re-render via preselected
|
||||
assert tools == ["SerperDevTool"]
|
||||
assert 1 in picker_calls[1][2]["preselected"]
|
||||
|
||||
|
||||
def test_json_wizard_tool_picker_lists_builtin_tools_across_categories(monkeypatch):
|
||||
picker_calls: list[tuple[str, list[str], dict[str, object]]] = []
|
||||
expanded_labels: list[str] = []
|
||||
|
||||
def pick_many(title: str, labels: list[str], **kwargs):
|
||||
picker_calls.append((title, labels, kwargs))
|
||||
expanded_labels.extend(labels)
|
||||
action_indices = sorted(kwargs["action_indices"])
|
||||
call_num = len(picker_calls)
|
||||
if call_num <= len(action_indices):
|
||||
# Expand the n-th category (indices shift between renders, so
|
||||
# recompute from this render's action rows)
|
||||
return [], action_indices[call_num - 1]
|
||||
return [], None
|
||||
|
||||
monkeypatch.setattr(json_crew, "pick_many", pick_many)
|
||||
|
||||
json_crew._select_tools()
|
||||
|
||||
tool_names = {
|
||||
label.rsplit(maxsplit=1)[-1]
|
||||
for label in expanded_labels
|
||||
if not label.startswith(("▸", "▾", "──"))
|
||||
}
|
||||
|
||||
assert {
|
||||
"DirectorySearchTool",
|
||||
"MDXSearchTool",
|
||||
"XMLSearchTool",
|
||||
"YoutubeVideoSearchTool",
|
||||
"S3ReaderTool",
|
||||
"E2BExecTool",
|
||||
"TavilyResearchTool",
|
||||
"SerplyNewsSearchTool",
|
||||
"BrowserbaseLoadTool",
|
||||
"PatronusEvalTool",
|
||||
}.issubset(tool_names)
|
||||
assert {
|
||||
"MCPServerAdapter",
|
||||
"MongoDBVectorSearchConfig",
|
||||
"ScrapegraphScrapeToolSchema",
|
||||
"SnowflakeConfig",
|
||||
}.isdisjoint(tool_names)
|
||||
|
||||
|
||||
def test_multi_picker_skips_separator_on_initial_cursor(monkeypatch):
|
||||
cursors: list[int] = []
|
||||
|
||||
monkeypatch.setattr(tui_picker, "_read_key", lambda: "enter")
|
||||
monkeypatch.setattr(
|
||||
tui_picker,
|
||||
"_draw_multi",
|
||||
lambda _labels, cursor, *_args, **_kwargs: cursors.append(cursor),
|
||||
)
|
||||
monkeypatch.setattr(tui_picker, "_clear_lines", lambda *_args, **_kwargs: None)
|
||||
|
||||
assert tui_picker._arrow_select_multi(
|
||||
["── Common tools ──", "Google search via Serper API SerperDevTool"],
|
||||
separator_indices={0},
|
||||
) == ([], None)
|
||||
assert cursors == [1]
|
||||
|
||||
|
||||
def test_json_wizard_agent_attribute_prompts_are_compact(monkeypatch):
|
||||
prompt_calls: list[tuple[str, bool]] = []
|
||||
prompt_values = {
|
||||
"Role": "Senior Dev Rel",
|
||||
"Goal": "Draft content",
|
||||
"Backstory": "Knows developer communities",
|
||||
}
|
||||
|
||||
def prompt_text(
|
||||
label: str,
|
||||
default: str = "",
|
||||
*,
|
||||
spacing_before: bool = True,
|
||||
) -> str:
|
||||
prompt_calls.append((label, spacing_before))
|
||||
return prompt_values[label]
|
||||
|
||||
monkeypatch.setattr(json_crew, "_prompt_text", prompt_text)
|
||||
monkeypatch.setattr(json_crew, "_select_model", lambda: "openai/gpt-5.5")
|
||||
monkeypatch.setattr(json_crew, "pick_many", lambda *_args, **_kwargs: ([], None))
|
||||
monkeypatch.setattr(json_crew, "_confirm", lambda *_args, **_kwargs: False)
|
||||
|
||||
agent = json_crew._wizard_agent(agent_num=1, existing_names=[])
|
||||
|
||||
assert agent is not None
|
||||
assert prompt_calls == [
|
||||
("Role", False),
|
||||
("Goal", False),
|
||||
("Backstory", False),
|
||||
]
|
||||
|
||||
|
||||
def test_json_wizard_task_attribute_prompts_are_compact(monkeypatch):
|
||||
prompt_calls: list[tuple[str, bool]] = []
|
||||
prompt_values = {
|
||||
"Description": "Research latest release",
|
||||
"Expected output": "Release summary",
|
||||
}
|
||||
|
||||
def prompt_text(
|
||||
label: str,
|
||||
default: str = "",
|
||||
*,
|
||||
spacing_before: bool = True,
|
||||
) -> str:
|
||||
prompt_calls.append((label, spacing_before))
|
||||
return prompt_values[label]
|
||||
|
||||
monkeypatch.setattr(json_crew, "_prompt_text", prompt_text)
|
||||
|
||||
task = json_crew._wizard_task(
|
||||
task_num=1,
|
||||
agent_names=["senior_dev_rel"],
|
||||
prior_task_names=[],
|
||||
)
|
||||
|
||||
assert task is not None
|
||||
assert prompt_calls == [
|
||||
("Description", False),
|
||||
("Expected output", False),
|
||||
]
|
||||
|
||||
|
||||
def test_json_create_provider_preselects_default_model(tmp_path, monkeypatch):
|
||||
monkeypatch.chdir(tmp_path)
|
||||
with mock.patch(
|
||||
"crewai_cli.create_json_crew._wizard_agents_and_tasks"
|
||||
) as mock_wizard:
|
||||
mock_wizard.return_value = (
|
||||
[
|
||||
{
|
||||
"name": "researcher",
|
||||
"role": "Researcher",
|
||||
"goal": "Research",
|
||||
"backstory": "Researcher",
|
||||
"llm": "openai/gpt-5.5",
|
||||
"tools": [],
|
||||
"planning": False,
|
||||
"allow_delegation": False,
|
||||
}
|
||||
],
|
||||
[
|
||||
{
|
||||
"name": "research_task",
|
||||
"description": "Research",
|
||||
"expected_output": "Findings",
|
||||
"agent": "researcher",
|
||||
"context": [],
|
||||
}
|
||||
],
|
||||
{"process": "sequential", "memory": False, "inputs": {}},
|
||||
)
|
||||
|
||||
json_crew.create_json_crew("JSON Crew", provider="openai", skip_provider=True)
|
||||
|
||||
mock_wizard.assert_called_once_with(
|
||||
skip_provider=True,
|
||||
default_llm="openai/gpt-5.5",
|
||||
)
|
||||
assert (tmp_path / "json_crew" / "crew.jsonc").exists()
|
||||
assert not (tmp_path / "json_crew" / "tests").exists()
|
||||
assert not (tmp_path / "json_crew" / "config.jsonc").exists()
|
||||
|
||||
crew_template = (tmp_path / "json_crew" / "crew.jsonc").read_text()
|
||||
assert (
|
||||
'"guardrail": "Every factual claim needs context support."'
|
||||
in crew_template
|
||||
)
|
||||
assert '"guardrails": [' in crew_template
|
||||
assert '"guardrail_max_retries": 2' in crew_template
|
||||
assert "Docs: https://docs.crewai.com/concepts/tasks" in crew_template
|
||||
assert '"output_pydantic": null' in crew_template
|
||||
assert '"type": "ConditionalTask"' in crew_template
|
||||
assert '"condition": { "python": "my_project.conditions.should_run" }' in (
|
||||
crew_template
|
||||
)
|
||||
assert '"output_json": { "python": "my_project.models.ReportOutput" }' in (
|
||||
crew_template
|
||||
)
|
||||
assert (
|
||||
'"converter_cls": { "python": "my_project.converters.CustomConverter" }'
|
||||
in crew_template
|
||||
)
|
||||
assert '"markdown": false' in crew_template
|
||||
assert '"input_files": { "brief": "data/brief.txt" }' in crew_template
|
||||
assert "Docs: https://docs.crewai.com/concepts/crews" in crew_template
|
||||
assert "manager_agent can reference an agents/<name>.jsonc file" in crew_template
|
||||
assert '"manager_agent": "researcher"' in crew_template
|
||||
assert (
|
||||
'"before_kickoff_callbacks": [{"python": '
|
||||
'"my_project.callbacks.before_kickoff"}]'
|
||||
) in crew_template
|
||||
assert (
|
||||
'"after_kickoff_callbacks": [{"python": '
|
||||
'"my_project.callbacks.after_kickoff"}]'
|
||||
) in crew_template
|
||||
assert '"output_log_file": "crew.log"' in crew_template
|
||||
assert "Crew-level LLM fields also accept object form" in crew_template
|
||||
assert '"chat_llm": {"model": "llama3", "provider": "ollama"' in (
|
||||
crew_template
|
||||
)
|
||||
assert "Use {placeholder} in agent or task text" in crew_template
|
||||
assert "`crewai run` prompts for any placeholders" in crew_template
|
||||
assert "Use {placeholder} inputs here" in crew_template
|
||||
|
||||
agent_template = (
|
||||
tmp_path / "json_crew" / "agents" / "researcher.jsonc"
|
||||
).read_text()
|
||||
assert "You can use {placeholder} inputs in role, goal, or backstory" in (
|
||||
agent_template
|
||||
)
|
||||
assert '"role": "Senior {industry} Researcher"' in agent_template
|
||||
assert '"type": {"python": "my_project.agents.CustomAgent"}' in agent_template
|
||||
assert "Optional agent-level guardrail" in agent_template
|
||||
assert "Python refs must point to module-level functions/classes" in agent_template
|
||||
assert (
|
||||
'"step_callback": {"python": "my_project.callbacks.on_agent_step"}'
|
||||
in agent_template
|
||||
)
|
||||
assert '"guardrail_max_retries": 2' in agent_template
|
||||
assert "Docs: https://docs.crewai.com/concepts/agents" in agent_template
|
||||
assert '"reasoning": true' in agent_template
|
||||
assert "For custom endpoints or deployment-based providers" in agent_template
|
||||
assert '"deployment_name": "my-deployment", "provider": "azure"' in (
|
||||
agent_template
|
||||
)
|
||||
assert '"planning_config": {' in agent_template
|
||||
assert '"llm": {"model": "deepseek-chat", "provider": "deepseek"}' in (
|
||||
agent_template
|
||||
)
|
||||
assert '"knowledge_sources": []' in agent_template
|
||||
|
||||
|
||||
def test_json_provider_default_model_helper():
|
||||
assert json_crew._default_model_for_provider("openai") == "openai/gpt-5.5"
|
||||
assert json_crew._default_model_for_provider("anthropic/claude-custom") == (
|
||||
"anthropic/claude-custom"
|
||||
)
|
||||
assert json_crew._default_model_for_provider("unknown") is None
|
||||
|
||||
|
||||
def test_json_wizard_task_reprompts_on_cancelled_agent_pick(monkeypatch):
|
||||
"""Esc on the agent picker must reprompt, not silently assign agent 0."""
|
||||
prompts = iter(["Do the research", "A report"])
|
||||
monkeypatch.setattr(json_crew, "_prompt_text", lambda *a, **k: next(prompts))
|
||||
|
||||
pick_calls: list[str] = []
|
||||
picks = iter([-1, 1])
|
||||
|
||||
def fake_pick_one(title: str, labels: list[str]) -> int:
|
||||
pick_calls.append(title)
|
||||
return next(picks)
|
||||
|
||||
monkeypatch.setattr(json_crew, "pick_one", fake_pick_one)
|
||||
|
||||
task = json_crew._wizard_task(
|
||||
task_num=1,
|
||||
agent_names=["first_agent", "second_agent"],
|
||||
prior_task_names=[],
|
||||
)
|
||||
|
||||
assert len(pick_calls) == 2
|
||||
assert task["agent"] == "second_agent"
|
||||
|
||||
823
lib/cli/tests/test_crew_run_tui.py
Normal file
823
lib/cli/tests/test_crew_run_tui.py
Normal file
@@ -0,0 +1,823 @@
|
||||
from datetime import datetime
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from crewai.events.event_bus import crewai_event_bus
|
||||
from crewai.events.types.observation_events import (
|
||||
GoalAchievedEarlyEvent,
|
||||
PlanRefinementEvent,
|
||||
PlanReplanTriggeredEvent,
|
||||
PlanStepCompletedEvent,
|
||||
PlanStepStartedEvent,
|
||||
StepObservationCompletedEvent,
|
||||
StepObservationFailedEvent,
|
||||
StepObservationStartedEvent,
|
||||
)
|
||||
from crewai.events.types.tool_usage_events import (
|
||||
ToolUsageErrorEvent,
|
||||
ToolUsageFinishedEvent,
|
||||
ToolUsageStartedEvent,
|
||||
)
|
||||
from crewai_cli.command import AuthenticationRequiredError
|
||||
from crewai_cli import run_crew
|
||||
from crewai_cli.crew_run_tui import CrewRunApp
|
||||
|
||||
|
||||
def _app_with_plan() -> CrewRunApp:
|
||||
app = CrewRunApp()
|
||||
app._plan = {
|
||||
"plan": "Demo plan",
|
||||
"steps": [
|
||||
{"step_number": 1, "description": "First"},
|
||||
{"step_number": 2, "description": "Second"},
|
||||
{"step_number": 3, "description": "Third"},
|
||||
],
|
||||
}
|
||||
app._plan_step_status = {1: "pending", 2: "pending", 3: "pending"}
|
||||
return app
|
||||
|
||||
|
||||
def _log_entry(name: str) -> dict:
|
||||
now = time.time()
|
||||
return {
|
||||
"tool_name": name,
|
||||
"status": "success",
|
||||
"args": None,
|
||||
"result": f"{name} result",
|
||||
"error": None,
|
||||
"start_time": now,
|
||||
"duration": 1.0,
|
||||
"task_idx": 1,
|
||||
}
|
||||
|
||||
|
||||
def _emit_event(event: object) -> None:
|
||||
future = crewai_event_bus.emit(None, event)
|
||||
if future:
|
||||
future.result(timeout=5)
|
||||
|
||||
|
||||
def test_chain_deploy_skips_validation_after_auth_retry(monkeypatch) -> None:
|
||||
create_calls: list[dict[str, object]] = []
|
||||
login_calls: list[bool] = []
|
||||
|
||||
class FakeDeployCommand:
|
||||
attempts = 0
|
||||
|
||||
def create_crew(self, **kwargs) -> None:
|
||||
create_calls.append(kwargs)
|
||||
FakeDeployCommand.attempts += 1
|
||||
if FakeDeployCommand.attempts == 1:
|
||||
raise AuthenticationRequiredError
|
||||
|
||||
class FakeAuthenticationCommand:
|
||||
def login(self) -> None:
|
||||
login_calls.append(True)
|
||||
|
||||
monkeypatch.setattr("crewai_cli.deploy.main.DeployCommand", FakeDeployCommand)
|
||||
monkeypatch.setattr(
|
||||
"crewai_cli.authentication.main.AuthenticationCommand",
|
||||
FakeAuthenticationCommand,
|
||||
)
|
||||
|
||||
run_crew._chain_deploy()
|
||||
|
||||
assert create_calls == [
|
||||
{"confirm": True, "skip_validate": True},
|
||||
{"confirm": True, "skip_validate": True},
|
||||
]
|
||||
assert login_calls == [True]
|
||||
|
||||
|
||||
def test_chain_deploy_does_not_login_for_deploy_exit(monkeypatch, capsys) -> None:
|
||||
create_calls: list[dict[str, object]] = []
|
||||
login_calls: list[bool] = []
|
||||
|
||||
class FakeDeployCommand:
|
||||
def create_crew(self, **kwargs) -> None:
|
||||
create_calls.append(kwargs)
|
||||
raise SystemExit(42)
|
||||
|
||||
class FakeAuthenticationCommand:
|
||||
def login(self) -> None:
|
||||
login_calls.append(True)
|
||||
|
||||
monkeypatch.setattr("crewai_cli.deploy.main.DeployCommand", FakeDeployCommand)
|
||||
monkeypatch.setattr(
|
||||
"crewai_cli.authentication.main.AuthenticationCommand",
|
||||
FakeAuthenticationCommand,
|
||||
)
|
||||
|
||||
run_crew._chain_deploy()
|
||||
|
||||
assert create_calls == [{"confirm": True, "skip_validate": True}]
|
||||
assert login_calls == []
|
||||
assert "Deploy failed with exit code 42" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_plan_step_status_updates_only_the_explicit_step() -> None:
|
||||
app = _app_with_plan()
|
||||
|
||||
app._set_plan_step_status(2, "done")
|
||||
|
||||
assert app._plan_step_status == {
|
||||
1: "pending",
|
||||
2: "done",
|
||||
3: "pending",
|
||||
}
|
||||
|
||||
|
||||
def test_step_observation_events_update_the_explicit_step() -> None:
|
||||
app = _app_with_plan()
|
||||
app._subscribe()
|
||||
try:
|
||||
future = crewai_event_bus.emit(
|
||||
None,
|
||||
StepObservationStartedEvent(
|
||||
agent_role="Agent",
|
||||
step_number=2,
|
||||
step_description="Second",
|
||||
),
|
||||
)
|
||||
if future:
|
||||
future.result(timeout=5)
|
||||
|
||||
assert app._plan_step_status == {
|
||||
1: "pending",
|
||||
2: "active",
|
||||
3: "pending",
|
||||
}
|
||||
|
||||
future = crewai_event_bus.emit(
|
||||
None,
|
||||
StepObservationCompletedEvent(
|
||||
agent_role="Agent",
|
||||
step_number=2,
|
||||
step_description="Second",
|
||||
step_completed_successfully=True,
|
||||
),
|
||||
)
|
||||
if future:
|
||||
future.result(timeout=5)
|
||||
finally:
|
||||
app._unsubscribe()
|
||||
|
||||
assert app._plan_step_status == {
|
||||
1: "pending",
|
||||
2: "done",
|
||||
3: "pending",
|
||||
}
|
||||
|
||||
|
||||
def test_plan_step_lifecycle_events_update_the_explicit_step() -> None:
|
||||
app = _app_with_plan()
|
||||
app._subscribe()
|
||||
try:
|
||||
_emit_event(
|
||||
PlanStepStartedEvent(
|
||||
agent_role="Agent",
|
||||
step_number=2,
|
||||
step_description="Second",
|
||||
)
|
||||
)
|
||||
|
||||
assert app._plan_step_status == {
|
||||
1: "pending",
|
||||
2: "active",
|
||||
3: "pending",
|
||||
}
|
||||
|
||||
_emit_event(
|
||||
PlanStepCompletedEvent(
|
||||
agent_role="Agent",
|
||||
step_number=2,
|
||||
step_description="Second",
|
||||
success=True,
|
||||
result="done",
|
||||
)
|
||||
)
|
||||
finally:
|
||||
app._unsubscribe()
|
||||
|
||||
assert app._plan_step_status == {
|
||||
1: "pending",
|
||||
2: "done",
|
||||
3: "pending",
|
||||
}
|
||||
|
||||
|
||||
def test_failed_plan_step_lifecycle_event_marks_exact_step_failed() -> None:
|
||||
app = _app_with_plan()
|
||||
app._subscribe()
|
||||
try:
|
||||
_emit_event(
|
||||
PlanStepCompletedEvent(
|
||||
agent_role="Agent",
|
||||
step_number=2,
|
||||
step_description="Second",
|
||||
success=False,
|
||||
error="Step failed",
|
||||
)
|
||||
)
|
||||
finally:
|
||||
app._unsubscribe()
|
||||
|
||||
assert app._plan_step_status == {
|
||||
1: "pending",
|
||||
2: "failed",
|
||||
3: "pending",
|
||||
}
|
||||
|
||||
|
||||
def test_tool_usage_events_do_not_advance_plan_steps() -> None:
|
||||
app = _app_with_plan()
|
||||
app._subscribe()
|
||||
try:
|
||||
future = crewai_event_bus.emit(
|
||||
None,
|
||||
ToolUsageStartedEvent(tool_name="search", tool_args={"query": "CrewAI"}),
|
||||
)
|
||||
if future:
|
||||
future.result(timeout=5)
|
||||
|
||||
now = datetime.now()
|
||||
future = crewai_event_bus.emit(
|
||||
None,
|
||||
ToolUsageFinishedEvent(
|
||||
tool_name="search",
|
||||
tool_args={"query": "CrewAI"},
|
||||
started_at=now,
|
||||
finished_at=now,
|
||||
output="result",
|
||||
),
|
||||
)
|
||||
if future:
|
||||
future.result(timeout=5)
|
||||
finally:
|
||||
app._unsubscribe()
|
||||
|
||||
assert app._plan_step_status == {
|
||||
1: "pending",
|
||||
2: "pending",
|
||||
3: "pending",
|
||||
}
|
||||
|
||||
|
||||
def test_next_tool_does_not_mark_unfinished_tool_successful() -> None:
|
||||
app = _app_with_plan()
|
||||
app._subscribe()
|
||||
try:
|
||||
_emit_event(
|
||||
ToolUsageStartedEvent(tool_name="search", tool_args={"query": "CrewAI"}),
|
||||
)
|
||||
_emit_event(
|
||||
ToolUsageStartedEvent(tool_name="scrape", tool_args={"url": "https://x"}),
|
||||
)
|
||||
finally:
|
||||
app._unsubscribe()
|
||||
|
||||
assert app._log_entries[0]["status"] == "timeout"
|
||||
assert app._log_entries[0]["result"] is None
|
||||
assert app._log_entries[0]["error"] == (
|
||||
"No result received before the next tool started"
|
||||
)
|
||||
assert app._log_entries[1]["status"] == "running"
|
||||
assert app._plan_step_status == {
|
||||
1: "pending",
|
||||
2: "pending",
|
||||
3: "pending",
|
||||
}
|
||||
|
||||
|
||||
def test_internal_reasoning_function_call_is_hidden_from_activity_log() -> None:
|
||||
app = _app_with_plan()
|
||||
app._subscribe()
|
||||
try:
|
||||
future = crewai_event_bus.emit(
|
||||
None,
|
||||
ToolUsageStartedEvent(
|
||||
tool_name="create_reasoning_plan",
|
||||
tool_args={"plan": "Plan", "steps": [], "ready": True},
|
||||
),
|
||||
)
|
||||
if future:
|
||||
future.result(timeout=5)
|
||||
|
||||
now = datetime.now()
|
||||
future = crewai_event_bus.emit(
|
||||
None,
|
||||
ToolUsageFinishedEvent(
|
||||
tool_name="create_reasoning_plan",
|
||||
tool_args={"plan": "Plan", "steps": [], "ready": True},
|
||||
started_at=now,
|
||||
finished_at=now,
|
||||
output='{"plan":"Plan","steps":[],"ready":true}',
|
||||
),
|
||||
)
|
||||
if future:
|
||||
future.result(timeout=5)
|
||||
|
||||
future = crewai_event_bus.emit(
|
||||
None,
|
||||
ToolUsageErrorEvent(
|
||||
tool_name="create_reasoning_plan",
|
||||
tool_args={"plan": "Plan", "steps": [], "ready": True},
|
||||
error="internal planning fallback",
|
||||
),
|
||||
)
|
||||
if future:
|
||||
future.result(timeout=5)
|
||||
finally:
|
||||
app._unsubscribe()
|
||||
|
||||
assert app._log_entries == []
|
||||
assert app._current_task_steps == []
|
||||
|
||||
|
||||
def test_tool_failure_does_not_override_successful_plan_step_completion() -> None:
|
||||
app = _app_with_plan()
|
||||
app._subscribe()
|
||||
try:
|
||||
_emit_event(
|
||||
PlanStepStartedEvent(
|
||||
agent_role="Agent",
|
||||
step_number=1,
|
||||
step_description="First",
|
||||
)
|
||||
)
|
||||
_emit_event(
|
||||
ToolUsageStartedEvent(
|
||||
tool_name="search_the_internet_with_serper",
|
||||
tool_args={"search_query": "CrewAI release"},
|
||||
plan_step_number=1,
|
||||
plan_step_description="First",
|
||||
)
|
||||
)
|
||||
_emit_event(
|
||||
ToolUsageErrorEvent(
|
||||
tool_name="search_the_internet_with_serper",
|
||||
tool_args={"search_query": "CrewAI release"},
|
||||
plan_step_number=1,
|
||||
plan_step_description="First",
|
||||
error="No results",
|
||||
)
|
||||
)
|
||||
_emit_event(
|
||||
PlanStepCompletedEvent(
|
||||
agent_role="Agent",
|
||||
step_number=1,
|
||||
step_description="First",
|
||||
success=True,
|
||||
result="Recovered with another source",
|
||||
)
|
||||
)
|
||||
finally:
|
||||
app._unsubscribe()
|
||||
|
||||
assert app._plan_step_status == {
|
||||
1: "done",
|
||||
2: "pending",
|
||||
3: "pending",
|
||||
}
|
||||
|
||||
|
||||
def test_tool_event_step_metadata_is_stored_in_activity_log() -> None:
|
||||
app = _app_with_plan()
|
||||
app._subscribe()
|
||||
try:
|
||||
_emit_event(
|
||||
ToolUsageStartedEvent(
|
||||
tool_name="search_the_internet_with_serper",
|
||||
tool_args={"search_query": "CrewAI release"},
|
||||
plan_step_number=2,
|
||||
plan_step_description="Second",
|
||||
)
|
||||
)
|
||||
now = datetime.now()
|
||||
_emit_event(
|
||||
ToolUsageFinishedEvent(
|
||||
tool_name="search_the_internet_with_serper",
|
||||
tool_args={"search_query": "CrewAI release"},
|
||||
plan_step_number=2,
|
||||
plan_step_description="Second",
|
||||
started_at=now,
|
||||
finished_at=now,
|
||||
output="Found official source",
|
||||
)
|
||||
)
|
||||
finally:
|
||||
app._unsubscribe()
|
||||
|
||||
assert app._log_entries[0]["plan_step_number"] == 2
|
||||
assert app._plan_step_status == {
|
||||
1: "pending",
|
||||
2: "pending",
|
||||
3: "pending",
|
||||
}
|
||||
|
||||
|
||||
def test_starting_next_tool_does_not_infer_plan_step_progress() -> None:
|
||||
app = _app_with_plan()
|
||||
app._subscribe()
|
||||
try:
|
||||
_emit_event(
|
||||
ToolUsageStartedEvent(
|
||||
tool_name="search_the_internet_with_serper",
|
||||
tool_args={"search_query": "CrewAI release"},
|
||||
)
|
||||
)
|
||||
_emit_event(
|
||||
ToolUsageErrorEvent(
|
||||
tool_name="search_the_internet_with_serper",
|
||||
tool_args={"search_query": "CrewAI release"},
|
||||
error="No results",
|
||||
)
|
||||
)
|
||||
_emit_event(
|
||||
ToolUsageStartedEvent(
|
||||
tool_name="read_website_content",
|
||||
tool_args={"url": "https://example.com"},
|
||||
)
|
||||
)
|
||||
finally:
|
||||
app._unsubscribe()
|
||||
|
||||
assert app._log_entries[0]["status"] == "error"
|
||||
assert app._log_entries[1]["status"] == "running"
|
||||
assert app._plan_step_status == {
|
||||
1: "pending",
|
||||
2: "pending",
|
||||
3: "pending",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_crew_done_does_not_mark_unfinished_tool_successful() -> None:
|
||||
app = _app_with_plan()
|
||||
|
||||
async with app.run_test(size=(100, 40)) as pilot:
|
||||
app._plan_step_status = {1: "failed", 2: "done", 3: "pending"}
|
||||
app._log_entries = [
|
||||
{
|
||||
"tool_name": "search",
|
||||
"status": "running",
|
||||
"args": '{"query": "CrewAI"}',
|
||||
"result": None,
|
||||
"error": None,
|
||||
"start_time": time.time() - 2,
|
||||
"duration": None,
|
||||
"task_idx": 1,
|
||||
}
|
||||
]
|
||||
|
||||
app._on_crew_done("final output")
|
||||
await pilot.pause()
|
||||
|
||||
assert app._log_entries[0]["status"] == "timeout"
|
||||
assert app._log_entries[0]["result"] is None
|
||||
assert app._log_entries[0]["error"] == "No result received before crew completed"
|
||||
assert app._plan_step_status == {1: "failed", 2: "done", 3: "done"}
|
||||
|
||||
|
||||
def test_streamed_step_observation_updates_named_step_only() -> None:
|
||||
app = _app_with_plan()
|
||||
|
||||
updated = app._try_parse_step_observation(
|
||||
'{"step_completed_successfully":true,'
|
||||
'"key_information_learned":"Step 2 succeeded with the official source."}'
|
||||
)
|
||||
|
||||
assert updated is True
|
||||
assert app._plan_step_status == {
|
||||
1: "pending",
|
||||
2: "done",
|
||||
3: "pending",
|
||||
}
|
||||
|
||||
|
||||
def test_failed_streamed_step_observation_marks_named_step_failed() -> None:
|
||||
app = _app_with_plan()
|
||||
|
||||
updated = app._try_parse_step_observation(
|
||||
'{"step_completed_successfully":false,'
|
||||
'"key_information_learned":"Step 2 failed because the tool failed."}'
|
||||
)
|
||||
|
||||
assert updated is True
|
||||
assert app._plan_step_status == {
|
||||
1: "pending",
|
||||
2: "failed",
|
||||
3: "pending",
|
||||
}
|
||||
|
||||
|
||||
def test_streamed_goal_achieved_observation_collapses_remaining_steps_done() -> None:
|
||||
app = _app_with_plan()
|
||||
|
||||
updated = app._try_parse_step_observation(
|
||||
'{"step_number":2,'
|
||||
'"step_completed_successfully":true,'
|
||||
'"key_information_learned":"Goal is already satisfied.",'
|
||||
'"goal_already_achieved":true}'
|
||||
)
|
||||
|
||||
assert updated is True
|
||||
assert app._plan_step_status == {
|
||||
1: "done",
|
||||
2: "done",
|
||||
3: "done",
|
||||
}
|
||||
|
||||
|
||||
def test_task_completion_collapses_pending_plan_steps_but_preserves_failed() -> None:
|
||||
app = _app_with_plan()
|
||||
app._plan_step_status = {1: "failed", 2: "done", 3: "pending"}
|
||||
|
||||
app._collapse_plan_on_task_done()
|
||||
|
||||
assert app._plan_step_status == {1: "failed", 2: "done", 3: "done"}
|
||||
|
||||
|
||||
def test_observation_failure_collapses_to_done_because_executor_continues() -> None:
|
||||
app = _app_with_plan()
|
||||
app._plan_step_status = {1: "done", 2: "active", 3: "pending"}
|
||||
app._subscribe()
|
||||
try:
|
||||
future = crewai_event_bus.emit(
|
||||
None,
|
||||
StepObservationFailedEvent(
|
||||
agent_role="Agent",
|
||||
step_number=2,
|
||||
step_description="Second",
|
||||
error="observer timeout",
|
||||
),
|
||||
)
|
||||
if future:
|
||||
future.result(timeout=5)
|
||||
finally:
|
||||
app._unsubscribe()
|
||||
|
||||
assert app._plan_step_status == {
|
||||
1: "done",
|
||||
2: "done",
|
||||
3: "pending",
|
||||
}
|
||||
|
||||
|
||||
def test_goal_achieved_event_collapses_remaining_steps_done() -> None:
|
||||
app = _app_with_plan()
|
||||
app._plan_step_status = {1: "done", 2: "active", 3: "pending"}
|
||||
app._subscribe()
|
||||
try:
|
||||
future = crewai_event_bus.emit(
|
||||
None,
|
||||
GoalAchievedEarlyEvent(
|
||||
agent_role="Agent",
|
||||
step_number=2,
|
||||
steps_completed=2,
|
||||
steps_remaining=1,
|
||||
),
|
||||
)
|
||||
if future:
|
||||
future.result(timeout=5)
|
||||
finally:
|
||||
app._unsubscribe()
|
||||
|
||||
assert app._plan_step_status == {
|
||||
1: "done",
|
||||
2: "done",
|
||||
3: "done",
|
||||
}
|
||||
|
||||
|
||||
def test_replan_event_keeps_old_plan_until_next_streamed_plan_replaces_it() -> None:
|
||||
app = _app_with_plan()
|
||||
app._subscribe()
|
||||
try:
|
||||
future = crewai_event_bus.emit(
|
||||
None,
|
||||
PlanReplanTriggeredEvent(
|
||||
agent_role="Agent",
|
||||
step_number=2,
|
||||
replan_reason="Need updated sources",
|
||||
replan_count=1,
|
||||
completed_steps_preserved=1,
|
||||
),
|
||||
)
|
||||
if future:
|
||||
future.result(timeout=5)
|
||||
finally:
|
||||
app._unsubscribe()
|
||||
|
||||
assert app._plan is not None
|
||||
assert app._plan_step_status == {1: "pending", 2: "pending", 3: "pending"}
|
||||
assert app._awaiting_replan is True
|
||||
|
||||
app._try_parse_plan(
|
||||
'{"plan":"Updated plan","steps":['
|
||||
'{"step_number":1,"description":"Updated first"},'
|
||||
'{"step_number":2,"description":"Updated second"}]}'
|
||||
)
|
||||
|
||||
assert app._plan == {
|
||||
"plan": "Updated plan",
|
||||
"steps": [
|
||||
{"step_number": 1, "description": "Updated first"},
|
||||
{"step_number": 2, "description": "Updated second"},
|
||||
],
|
||||
}
|
||||
assert app._plan_step_status == {1: "pending", 2: "pending"}
|
||||
assert app._awaiting_replan is False
|
||||
|
||||
|
||||
def test_plan_refinement_updates_descriptions_without_new_statuses() -> None:
|
||||
app = _app_with_plan()
|
||||
app._plan_step_status = {1: "done", 2: "active", 3: "pending"}
|
||||
app._subscribe()
|
||||
try:
|
||||
future = crewai_event_bus.emit(
|
||||
None,
|
||||
PlanRefinementEvent(
|
||||
agent_role="Agent",
|
||||
step_number=2,
|
||||
refined_step_count=1,
|
||||
refinements=["Step 3: Write the final answer from verified facts"],
|
||||
),
|
||||
)
|
||||
if future:
|
||||
future.result(timeout=5)
|
||||
finally:
|
||||
app._unsubscribe()
|
||||
|
||||
assert app._plan_step_status == {
|
||||
1: "done",
|
||||
2: "done",
|
||||
3: "pending",
|
||||
}
|
||||
assert app._plan["steps"][2]["description"] == (
|
||||
"Write the final answer from verified facts"
|
||||
)
|
||||
|
||||
|
||||
def test_step_observation_json_is_hidden_from_streaming_text() -> None:
|
||||
app = _app_with_plan()
|
||||
|
||||
assert (
|
||||
app._strip_step_observation_json(
|
||||
'Visible before {"step_completed_successfully":true,'
|
||||
'"key_information_learned":"Step 2 succeeded."} visible after'
|
||||
)
|
||||
== "Visible before visible after"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_completed_run_keeps_activity_log_keyboard_navigation_active() -> None:
|
||||
app = CrewRunApp()
|
||||
|
||||
async with app.run_test(size=(100, 40)) as pilot:
|
||||
app._log_entries = [_log_entry("search"), _log_entry("scrape")]
|
||||
|
||||
app._on_crew_done("final output")
|
||||
await pilot.pause()
|
||||
|
||||
assert app.focused is app.query_one("#log-panel")
|
||||
|
||||
await pilot.press("down", "enter")
|
||||
await pilot.pause()
|
||||
|
||||
assert app._log_cursor == 1
|
||||
assert app._log_expanded == {1}
|
||||
|
||||
await pilot.press("up")
|
||||
await pilot.pause()
|
||||
|
||||
assert app._log_cursor == 0
|
||||
|
||||
|
||||
class _FakeTask:
|
||||
fingerprint = None
|
||||
|
||||
def __init__(self, task_id: str, name: str) -> None:
|
||||
self.id = task_id
|
||||
self.name = name
|
||||
self.description = name
|
||||
|
||||
|
||||
def test_async_task_completion_marks_the_right_sidebar_row() -> None:
|
||||
"""Overlapping tasks: completing task 1 while task 2 runs must not
|
||||
mark task 2 done, and starting task 2 must not mark task 1 done."""
|
||||
from crewai.events.types.task_events import TaskCompletedEvent, TaskStartedEvent
|
||||
from crewai.tasks.task_output import TaskOutput
|
||||
|
||||
app = CrewRunApp(total_tasks=2, task_names=["first", "second"])
|
||||
app._subscribe()
|
||||
try:
|
||||
task1 = _FakeTask("id-1", "first")
|
||||
task2 = _FakeTask("id-2", "second")
|
||||
|
||||
for task in (task1, task2):
|
||||
future = crewai_event_bus.emit(
|
||||
None, TaskStartedEvent(context=None, task=task)
|
||||
)
|
||||
if future:
|
||||
future.result(timeout=5)
|
||||
|
||||
# Both started: neither prematurely done
|
||||
assert app._task_statuses == {1: "active", 2: "active"}
|
||||
|
||||
future = crewai_event_bus.emit(
|
||||
None,
|
||||
TaskCompletedEvent(
|
||||
output=TaskOutput(description="first", raw="done", agent="a"),
|
||||
task=task1,
|
||||
),
|
||||
)
|
||||
if future:
|
||||
future.result(timeout=5)
|
||||
|
||||
assert app._task_statuses == {1: "done", 2: "active"}
|
||||
finally:
|
||||
app._unsubscribe()
|
||||
|
||||
|
||||
def test_pop_task_state_falls_back_to_current_task() -> None:
|
||||
app = CrewRunApp(total_tasks=2, task_names=["first", "second"])
|
||||
app._current_task_idx = 2
|
||||
app._current_task_desc = "second"
|
||||
|
||||
class _Evt:
|
||||
task = None
|
||||
task_name = "unknown"
|
||||
|
||||
state = app._pop_task_state(_Evt())
|
||||
assert state["idx"] == 2
|
||||
assert state["desc"] == "second"
|
||||
|
||||
|
||||
def test_overlapping_task_logs_keep_their_own_state() -> None:
|
||||
"""Task 1 completing after task 2 started must log its own description,
|
||||
agent, and output — and must not steal or reset task 2's stream state."""
|
||||
from crewai.events.types.task_events import TaskCompletedEvent, TaskStartedEvent
|
||||
from crewai.tasks.task_output import TaskOutput
|
||||
|
||||
app = CrewRunApp(total_tasks=2, task_names=["first", "second"])
|
||||
app._subscribe()
|
||||
try:
|
||||
task1 = _FakeTask("id-1", "first")
|
||||
task2 = _FakeTask("id-2", "second")
|
||||
|
||||
for task in (task1, task2):
|
||||
future = crewai_event_bus.emit(
|
||||
None, TaskStartedEvent(context=None, task=task)
|
||||
)
|
||||
if future:
|
||||
future.result(timeout=5)
|
||||
|
||||
# Task 2 is current and has streamed state in flight
|
||||
app._task_full_output = "task two streaming output"
|
||||
app._current_task_steps = [{"type": "llm", "summary": "thinking"}]
|
||||
|
||||
future = crewai_event_bus.emit(
|
||||
None,
|
||||
TaskCompletedEvent(
|
||||
output=TaskOutput(
|
||||
description="first", raw="task one result", agent="a1"
|
||||
),
|
||||
task=task1,
|
||||
),
|
||||
)
|
||||
if future:
|
||||
future.result(timeout=5)
|
||||
|
||||
# Task 1's entry carries its own identity and output
|
||||
entry1 = app._task_logs[-1]
|
||||
assert entry1["idx"] == 1
|
||||
assert entry1["desc"] == "first"
|
||||
assert entry1["output"] == "task one result"
|
||||
assert entry1["steps"] == []
|
||||
|
||||
# Task 2's in-flight stream state was not consumed or reset
|
||||
assert app._task_full_output == "task two streaming output"
|
||||
assert app._current_task_steps == [{"type": "llm", "summary": "thinking"}]
|
||||
|
||||
future = crewai_event_bus.emit(
|
||||
None,
|
||||
TaskCompletedEvent(
|
||||
output=TaskOutput(
|
||||
description="second", raw="task two result", agent="a2"
|
||||
),
|
||||
task=task2,
|
||||
),
|
||||
)
|
||||
if future:
|
||||
future.result(timeout=5)
|
||||
|
||||
entry2 = app._task_logs[-1]
|
||||
assert entry2["idx"] == 2
|
||||
assert entry2["desc"] == "second"
|
||||
assert entry2["output"] == "task two streaming output"
|
||||
assert any(step.get("summary") == "thinking" for step in entry2["steps"])
|
||||
finally:
|
||||
app._unsubscribe()
|
||||
@@ -31,6 +31,18 @@ def test_is_git_not_installed(fp):
|
||||
Repository(path=".")
|
||||
|
||||
|
||||
def test_fetch_failure_raises_value_error(fp):
|
||||
fp.register(["git", "--version"], stdout="git version 2.30.0\n")
|
||||
fp.register(["git", "rev-parse", "--is-inside-work-tree"], stdout="true\n")
|
||||
fp.register(["git", "fetch"], returncode=128, stderr="remote unavailable\n")
|
||||
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match=r"Git fetch failed with exit code 128 for command \['git', 'fetch'\]: remote unavailable",
|
||||
):
|
||||
Repository(path=".")
|
||||
|
||||
|
||||
def test_status(fp, repository):
|
||||
fp.register(
|
||||
["git", "status", "--branch", "--porcelain"],
|
||||
@@ -99,3 +111,45 @@ def test_origin_url(fp, repository):
|
||||
stdout="https://github.com/user/repo.git\n",
|
||||
)
|
||||
assert repository.origin_url() == "https://github.com/user/repo.git"
|
||||
|
||||
|
||||
def test_initialize_creates_initial_commit(fp, tmp_path):
|
||||
fp.register(["git", "--version"], stdout="git version 2.30.0\n")
|
||||
fp.register(["git", "init"], stdout="")
|
||||
fp.register(["git", "--version"], stdout="git version 2.30.0\n")
|
||||
fp.register(["git", "rev-parse", "--is-inside-work-tree"], stdout="true\n")
|
||||
fp.register(["git", "rev-parse", "--verify", "HEAD"], returncode=1)
|
||||
fp.register(["git", "add", "."], stdout="")
|
||||
fp.register(
|
||||
[
|
||||
"git",
|
||||
"-c",
|
||||
"user.name=CrewAI",
|
||||
"-c",
|
||||
"user.email=deploy@crewai.com",
|
||||
"commit",
|
||||
"--allow-empty",
|
||||
"-m",
|
||||
"Initial crew",
|
||||
],
|
||||
stdout="",
|
||||
)
|
||||
|
||||
repo = Repository.initialize(path=str(tmp_path))
|
||||
|
||||
assert repo.path == str(tmp_path)
|
||||
exclude_file = tmp_path / ".git" / "info" / "exclude"
|
||||
exclude_text = exclude_file.read_text()
|
||||
assert ".env" in exclude_text
|
||||
assert "!.env.example" in exclude_text
|
||||
assert "!.env.sample" in exclude_text
|
||||
assert "__pycache__/" in exclude_text
|
||||
|
||||
|
||||
def test_deployable_files_uses_git_excludes(fp, repository):
|
||||
fp.register(
|
||||
["git", "ls-files", "--cached", "--others", "--exclude-standard"],
|
||||
stdout="pyproject.toml\nsrc/main.py\n",
|
||||
)
|
||||
|
||||
assert repository.deployable_files() == ["pyproject.toml", "src/main.py"]
|
||||
|
||||
102
lib/cli/tests/test_install_crew.py
Normal file
102
lib/cli/tests/test_install_crew.py
Normal file
@@ -0,0 +1,102 @@
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
|
||||
import crewai_cli.install_crew as install_crew_module
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _tool_credentials(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
install_crew_module,
|
||||
"build_env_with_all_tool_credentials",
|
||||
lambda: {"CREWAI_TEST": "1"},
|
||||
)
|
||||
|
||||
|
||||
def test_install_crew_json_project_skips_project_install(
|
||||
fp, monkeypatch, tmp_path: Path
|
||||
):
|
||||
monkeypatch.chdir(tmp_path)
|
||||
(tmp_path / "pyproject.toml").write_text(
|
||||
"""
|
||||
[project]
|
||||
name = "json_crew"
|
||||
|
||||
[tool.crewai]
|
||||
type = "crew"
|
||||
""".strip()
|
||||
)
|
||||
(tmp_path / "crew.jsonc").write_text("{}\n")
|
||||
fp.register(["uv", "sync", "--no-install-project"], stdout="")
|
||||
|
||||
install_crew_module.install_crew([])
|
||||
|
||||
|
||||
def test_install_crew_json_project_with_python_package_installs_project(
|
||||
fp, monkeypatch, tmp_path: Path
|
||||
):
|
||||
monkeypatch.chdir(tmp_path)
|
||||
(tmp_path / "pyproject.toml").write_text(
|
||||
"""
|
||||
[project]
|
||||
name = "hybrid-crew"
|
||||
|
||||
[tool.crewai]
|
||||
type = "crew"
|
||||
""".strip()
|
||||
)
|
||||
(tmp_path / "crew.jsonc").write_text("{}\n")
|
||||
package_dir = tmp_path / "src" / "hybrid_crew"
|
||||
package_dir.mkdir(parents=True)
|
||||
(package_dir / "crew.py").write_text("class HybridCrew: ...\n")
|
||||
fp.register(["uv", "sync"], stdout="")
|
||||
|
||||
install_crew_module.install_crew([])
|
||||
|
||||
|
||||
def test_install_crew_flow_project_installs_project(fp, monkeypatch, tmp_path: Path):
|
||||
monkeypatch.chdir(tmp_path)
|
||||
(tmp_path / "pyproject.toml").write_text(
|
||||
"""
|
||||
[project]
|
||||
name = "flow_project"
|
||||
|
||||
[tool.crewai]
|
||||
type = "flow"
|
||||
""".strip()
|
||||
)
|
||||
(tmp_path / "crew.jsonc").write_text("{}\n")
|
||||
fp.register(["uv", "sync"], stdout="")
|
||||
|
||||
install_crew_module.install_crew([])
|
||||
|
||||
|
||||
def test_install_crew_classic_project_installs_project(
|
||||
fp, monkeypatch, tmp_path: Path
|
||||
):
|
||||
monkeypatch.chdir(tmp_path)
|
||||
(tmp_path / "pyproject.toml").write_text("[project]\nname = 'classic'\n")
|
||||
fp.register(["uv", "sync"], stdout="")
|
||||
|
||||
install_crew_module.install_crew([])
|
||||
|
||||
|
||||
def test_install_crew_install_project_false_adds_no_install_project(fp):
|
||||
fp.register(["uv", "sync", "--no-install-project", "--frozen"], stdout="")
|
||||
|
||||
install_crew_module.install_crew(["--frozen"], install_project=False)
|
||||
|
||||
|
||||
def test_install_crew_reraises_sync_failure_when_requested(fp):
|
||||
fp.register(["uv", "sync"], returncode=1, stderr="sync failed\n")
|
||||
|
||||
with pytest.raises(subprocess.CalledProcessError):
|
||||
install_crew_module.install_crew([], raise_on_error=True)
|
||||
|
||||
|
||||
def test_install_crew_swallows_sync_failure_by_default(fp):
|
||||
fp.register(["uv", "sync"], returncode=1, stderr="sync failed\n")
|
||||
|
||||
install_crew_module.install_crew([])
|
||||
@@ -292,6 +292,36 @@ class TestPlusAPI(unittest.TestCase):
|
||||
"POST", "/crewai_plus/api/v1/crews", json=payload
|
||||
)
|
||||
|
||||
@patch("crewai_cli.plus_api.PlusAPI._make_multipart_request")
|
||||
def test_create_crew_from_zip(self, mock_make_multipart_request):
|
||||
self.api.create_crew_from_zip(
|
||||
"/tmp/test.zip",
|
||||
name="test_crew",
|
||||
env={"ENV_VAR": "value"},
|
||||
)
|
||||
mock_make_multipart_request.assert_called_once_with(
|
||||
"POST",
|
||||
"/crewai_plus/api/v1/crews/zip",
|
||||
zip_file_path="/tmp/test.zip",
|
||||
data={"name": "test_crew", "env[ENV_VAR]": "value"},
|
||||
timeout=300,
|
||||
)
|
||||
|
||||
@patch("crewai_cli.plus_api.PlusAPI._make_multipart_request")
|
||||
def test_update_crew_from_zip(self, mock_make_multipart_request):
|
||||
self.api.update_crew_from_zip(
|
||||
"test_uuid",
|
||||
"/tmp/test.zip",
|
||||
env={"ENV_VAR": "value"},
|
||||
)
|
||||
mock_make_multipart_request.assert_called_once_with(
|
||||
"POST",
|
||||
"/crewai_plus/api/v1/crews/test_uuid/zip_update",
|
||||
zip_file_path="/tmp/test.zip",
|
||||
data={"env[ENV_VAR]": "value"},
|
||||
timeout=300,
|
||||
)
|
||||
|
||||
@patch("crewai_core.plus_api.Settings")
|
||||
@patch.dict(os.environ, {"CREWAI_PLUS_URL": ""})
|
||||
def test_custom_base_url(self, mock_settings_class):
|
||||
|
||||
470
lib/cli/tests/test_run_crew.py
Normal file
470
lib/cli/tests/test_run_crew.py
Normal file
@@ -0,0 +1,470 @@
|
||||
"""Tests for crewai_cli.run_crew JSON crew handling."""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
from crewai_core.constants import CREWAI_TRAINED_AGENTS_FILE_ENV
|
||||
|
||||
import crewai_cli.run_crew as run_crew_module
|
||||
|
||||
|
||||
def test_run_crew_forwards_trained_agents_file_to_json_crews(monkeypatch):
|
||||
"""crewai run -f must reach JSON crews, not only classic subprocess crews."""
|
||||
monkeypatch.setattr(run_crew_module, "_has_json_crew", lambda: True)
|
||||
called: dict = {}
|
||||
|
||||
def fake_run_json_crew_in_project_env(trained_agents_file=None):
|
||||
called["trained_agents_file"] = trained_agents_file
|
||||
|
||||
monkeypatch.setattr(
|
||||
run_crew_module,
|
||||
"_run_json_crew_in_project_env",
|
||||
fake_run_json_crew_in_project_env,
|
||||
)
|
||||
|
||||
run_crew_module.run_crew(trained_agents_file="some.pkl")
|
||||
|
||||
assert called == {"trained_agents_file": "some.pkl"}
|
||||
|
||||
|
||||
def test_json_run_uses_project_env_when_pyproject_exists(monkeypatch, tmp_path: Path):
|
||||
"""JSON crew runs should execute inside the project uv environment."""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
(tmp_path / "pyproject.toml").write_text("[project]\nname = 'demo'\n")
|
||||
install_calls = []
|
||||
subprocess_calls = []
|
||||
|
||||
monkeypatch.setattr(
|
||||
run_crew_module,
|
||||
"_install_json_crew_dependencies_if_needed",
|
||||
lambda: install_calls.append(True),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
run_crew_module,
|
||||
"build_env_with_all_tool_credentials",
|
||||
lambda: {"EXISTING": "value"},
|
||||
)
|
||||
|
||||
def fake_subprocess_run(command, **kwargs):
|
||||
subprocess_calls.append((command, kwargs))
|
||||
|
||||
monkeypatch.setattr(run_crew_module.subprocess, "run", fake_subprocess_run)
|
||||
|
||||
run_crew_module._run_json_crew_in_project_env(
|
||||
trained_agents_file="trained.pkl"
|
||||
)
|
||||
|
||||
expected_env = {
|
||||
"EXISTING": "value",
|
||||
run_crew_module._CREWAI_CLI_RUNNER_PACKAGE_DIR_ENV: str(
|
||||
Path(run_crew_module.__file__).resolve().parent
|
||||
),
|
||||
CREWAI_TRAINED_AGENTS_FILE_ENV: "trained.pkl",
|
||||
}
|
||||
if local_crewai_source_dir := run_crew_module._find_local_crewai_source_dir():
|
||||
expected_env[run_crew_module._CREWAI_RUNNER_SOURCE_DIR_ENV] = str(
|
||||
local_crewai_source_dir
|
||||
)
|
||||
|
||||
assert install_calls == [True]
|
||||
assert subprocess_calls == [
|
||||
(
|
||||
[
|
||||
"uv",
|
||||
"run",
|
||||
"--no-sync",
|
||||
"python",
|
||||
"-c",
|
||||
run_crew_module._JSON_CREW_RUNNER_CODE,
|
||||
],
|
||||
{
|
||||
"capture_output": False,
|
||||
"text": True,
|
||||
"check": True,
|
||||
"env": expected_env,
|
||||
},
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def test_json_run_uses_poetry_run_for_poetry_lock_without_uv_lock(
|
||||
monkeypatch, tmp_path: Path
|
||||
):
|
||||
monkeypatch.chdir(tmp_path)
|
||||
(tmp_path / "pyproject.toml").write_text("[project]\nname = 'demo'\n")
|
||||
(tmp_path / "poetry.lock").write_text("# lock\n")
|
||||
monkeypatch.setattr(
|
||||
run_crew_module,
|
||||
"_install_json_crew_dependencies_if_needed",
|
||||
lambda: None,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
run_crew_module,
|
||||
"build_env_with_all_tool_credentials",
|
||||
lambda: {},
|
||||
)
|
||||
subprocess_calls = []
|
||||
|
||||
def fake_subprocess_run(command, **kwargs):
|
||||
subprocess_calls.append((command, kwargs))
|
||||
|
||||
monkeypatch.setattr(run_crew_module.subprocess, "run", fake_subprocess_run)
|
||||
|
||||
run_crew_module._run_json_crew_in_project_env()
|
||||
|
||||
expected_env = {
|
||||
run_crew_module._CREWAI_CLI_RUNNER_PACKAGE_DIR_ENV: str(
|
||||
Path(run_crew_module.__file__).resolve().parent
|
||||
),
|
||||
}
|
||||
if local_crewai_source_dir := run_crew_module._find_local_crewai_source_dir():
|
||||
expected_env[run_crew_module._CREWAI_RUNNER_SOURCE_DIR_ENV] = str(
|
||||
local_crewai_source_dir
|
||||
)
|
||||
|
||||
assert subprocess_calls == [
|
||||
(
|
||||
[
|
||||
"poetry",
|
||||
"run",
|
||||
"python",
|
||||
"-c",
|
||||
run_crew_module._JSON_CREW_RUNNER_CODE,
|
||||
],
|
||||
{
|
||||
"capture_output": False,
|
||||
"text": True,
|
||||
"check": True,
|
||||
"env": expected_env,
|
||||
},
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def test_json_runner_code_loads_current_cli_package_over_project_env(tmp_path: Path):
|
||||
old_parent = tmp_path / "old"
|
||||
old_pkg = old_parent / "crewai_cli"
|
||||
old_pkg.mkdir(parents=True)
|
||||
(old_pkg / "__init__.py").write_text("")
|
||||
(old_pkg / "run_crew.py").write_text("raise ImportError('old package used')\n")
|
||||
old_crewai_project = old_parent / "crewai" / "project"
|
||||
old_crewai_project.mkdir(parents=True)
|
||||
(old_parent / "crewai" / "__init__.py").write_text("")
|
||||
(old_crewai_project / "__init__.py").write_text("")
|
||||
(old_crewai_project / "json_loader.py").write_text(
|
||||
"raise ImportError('old crewai used')\n"
|
||||
)
|
||||
|
||||
current_pkg = tmp_path / "current" / "crewai_cli"
|
||||
current_pkg.mkdir(parents=True)
|
||||
marker = tmp_path / "marker.txt"
|
||||
(current_pkg / "__init__.py").write_text("")
|
||||
(current_pkg / "run_crew.py").write_text(
|
||||
"from pathlib import Path\n"
|
||||
"from crewai.project.json_loader import SOURCE\n"
|
||||
"def _run_json_crew(trained_agents_file=None):\n"
|
||||
f" Path({str(marker)!r}).write_text(SOURCE + ':' + (trained_agents_file or ''))\n"
|
||||
)
|
||||
current_crewai_project = tmp_path / "current_crewai_src" / "crewai" / "project"
|
||||
current_crewai_project.mkdir(parents=True)
|
||||
(tmp_path / "current_crewai_src" / "crewai" / "__init__.py").write_text("")
|
||||
(current_crewai_project / "__init__.py").write_text("")
|
||||
(current_crewai_project / "json_loader.py").write_text("SOURCE = 'current'\n")
|
||||
|
||||
env = os.environ.copy()
|
||||
env["PYTHONPATH"] = str(old_parent)
|
||||
env[run_crew_module._CREWAI_CLI_RUNNER_PACKAGE_DIR_ENV] = str(current_pkg)
|
||||
env[run_crew_module._CREWAI_RUNNER_SOURCE_DIR_ENV] = str(
|
||||
tmp_path / "current_crewai_src"
|
||||
)
|
||||
env[CREWAI_TRAINED_AGENTS_FILE_ENV] = "trained.pkl"
|
||||
|
||||
subprocess.run(
|
||||
[sys.executable, "-c", run_crew_module._JSON_CREW_RUNNER_CODE],
|
||||
check=True,
|
||||
env=env,
|
||||
cwd=tmp_path,
|
||||
)
|
||||
|
||||
assert marker.read_text() == "current:trained.pkl"
|
||||
|
||||
|
||||
def test_json_run_without_pyproject_runs_in_process(monkeypatch, tmp_path: Path):
|
||||
monkeypatch.chdir(tmp_path)
|
||||
called: dict = {}
|
||||
|
||||
def fake_run_json_crew(trained_agents_file=None):
|
||||
called["trained_agents_file"] = trained_agents_file
|
||||
return "result"
|
||||
|
||||
monkeypatch.setattr(run_crew_module, "_run_json_crew", fake_run_json_crew)
|
||||
|
||||
assert (
|
||||
run_crew_module._run_json_crew_in_project_env(
|
||||
trained_agents_file="trained.pkl"
|
||||
)
|
||||
== "result"
|
||||
)
|
||||
assert called == {"trained_agents_file": "trained.pkl"}
|
||||
|
||||
|
||||
def test_json_project_env_run_failure_exits_nonzero(monkeypatch, tmp_path: Path):
|
||||
monkeypatch.chdir(tmp_path)
|
||||
(tmp_path / "pyproject.toml").write_text("[project]\nname = 'demo'\n")
|
||||
|
||||
monkeypatch.setattr(
|
||||
run_crew_module, "_install_json_crew_dependencies_if_needed", lambda: None
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
run_crew_module, "build_env_with_all_tool_credentials", lambda: {}
|
||||
)
|
||||
|
||||
def fake_subprocess_run(command, **kwargs):
|
||||
raise subprocess.CalledProcessError(7, command)
|
||||
|
||||
monkeypatch.setattr(run_crew_module.subprocess, "run", fake_subprocess_run)
|
||||
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
run_crew_module._run_json_crew_in_project_env()
|
||||
|
||||
assert exc_info.value.code == 7
|
||||
|
||||
|
||||
def test_json_run_installs_dependencies_when_pyproject_has_no_lockfile(
|
||||
monkeypatch, tmp_path: Path
|
||||
):
|
||||
"""JSON crew runs should lock/sync project dependencies only when needed."""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
(tmp_path / "pyproject.toml").write_text("[project]\nname = 'demo'\n")
|
||||
calls = []
|
||||
|
||||
def fake_install_crew(
|
||||
proxy_options, *, raise_on_error=False, install_project=None
|
||||
):
|
||||
calls.append((proxy_options, raise_on_error, install_project))
|
||||
|
||||
monkeypatch.setattr("crewai_cli.install_crew.install_crew", fake_install_crew)
|
||||
|
||||
run_crew_module._install_json_crew_dependencies_if_needed()
|
||||
|
||||
assert calls == [([], True, None)]
|
||||
|
||||
|
||||
def test_json_run_syncs_frozen_when_uv_lock_exists_without_venv(
|
||||
monkeypatch, tmp_path: Path
|
||||
):
|
||||
monkeypatch.chdir(tmp_path)
|
||||
(tmp_path / "pyproject.toml").write_text("[project]\nname = 'demo'\n")
|
||||
(tmp_path / "uv.lock").write_text("# lock\n")
|
||||
calls = []
|
||||
|
||||
def fake_install_crew(
|
||||
proxy_options, *, raise_on_error=False, install_project=None
|
||||
):
|
||||
calls.append((proxy_options, raise_on_error, install_project))
|
||||
|
||||
monkeypatch.setattr("crewai_cli.install_crew.install_crew", fake_install_crew)
|
||||
|
||||
run_crew_module._install_json_crew_dependencies_if_needed()
|
||||
|
||||
assert calls == [(["--frozen"], True, None)]
|
||||
|
||||
|
||||
def test_json_run_skips_uv_sync_when_only_poetry_lock_exists_without_venv(
|
||||
monkeypatch, tmp_path: Path
|
||||
):
|
||||
monkeypatch.chdir(tmp_path)
|
||||
(tmp_path / "pyproject.toml").write_text("[project]\nname = 'demo'\n")
|
||||
(tmp_path / "poetry.lock").write_text("# lock\n")
|
||||
calls = []
|
||||
|
||||
def fake_install_crew(
|
||||
proxy_options, *, raise_on_error=False, install_project=None
|
||||
):
|
||||
calls.append((proxy_options, raise_on_error, install_project))
|
||||
|
||||
monkeypatch.setattr("crewai_cli.install_crew.install_crew", fake_install_crew)
|
||||
|
||||
run_crew_module._install_json_crew_dependencies_if_needed()
|
||||
|
||||
assert calls == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize("lockfile", ["uv.lock", "poetry.lock"])
|
||||
def test_json_run_skips_dependency_install_when_lockfile_and_venv_exist(
|
||||
monkeypatch, tmp_path: Path, lockfile: str
|
||||
):
|
||||
monkeypatch.chdir(tmp_path)
|
||||
(tmp_path / "pyproject.toml").write_text("[project]\nname = 'demo'\n")
|
||||
(tmp_path / lockfile).write_text("# lock\n")
|
||||
(tmp_path / ".venv").mkdir()
|
||||
calls = []
|
||||
|
||||
def fake_install_crew(
|
||||
proxy_options, *, raise_on_error=False, install_project=None
|
||||
):
|
||||
calls.append((proxy_options, raise_on_error, install_project))
|
||||
|
||||
monkeypatch.setattr("crewai_cli.install_crew.install_crew", fake_install_crew)
|
||||
|
||||
run_crew_module._install_json_crew_dependencies_if_needed()
|
||||
|
||||
assert calls == []
|
||||
|
||||
|
||||
def test_json_run_skips_dependency_install_without_pyproject(
|
||||
monkeypatch, tmp_path: Path
|
||||
):
|
||||
monkeypatch.chdir(tmp_path)
|
||||
calls = []
|
||||
|
||||
def fake_install_crew(
|
||||
proxy_options, *, raise_on_error=False, install_project=None
|
||||
):
|
||||
calls.append((proxy_options, raise_on_error))
|
||||
|
||||
monkeypatch.setattr("crewai_cli.install_crew.install_crew", fake_install_crew)
|
||||
|
||||
run_crew_module._install_json_crew_dependencies_if_needed()
|
||||
|
||||
assert calls == []
|
||||
|
||||
|
||||
def test_json_run_install_failure_exits_nonzero(monkeypatch, tmp_path: Path):
|
||||
monkeypatch.chdir(tmp_path)
|
||||
(tmp_path / "pyproject.toml").write_text("[project]\nname = 'demo'\n")
|
||||
|
||||
def fake_install_crew(
|
||||
proxy_options, *, raise_on_error=False, install_project=None
|
||||
):
|
||||
raise subprocess.CalledProcessError(42, ["uv", "sync"])
|
||||
|
||||
monkeypatch.setattr("crewai_cli.install_crew.install_crew", fake_install_crew)
|
||||
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
run_crew_module._install_json_crew_dependencies_if_needed()
|
||||
|
||||
assert exc_info.value.code == 42
|
||||
|
||||
|
||||
def test_run_json_crew_exports_trained_agents_env(monkeypatch, tmp_path: Path):
|
||||
"""JSON crews run in-process, so the pickle path must land in the env var."""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
monkeypatch.delenv(CREWAI_TRAINED_AGENTS_FILE_ENV, raising=False)
|
||||
|
||||
try:
|
||||
# No crew.json(c) in tmp_path: the loader fails *after* the env var
|
||||
# export, which is the part under test.
|
||||
with pytest.raises(FileNotFoundError):
|
||||
run_crew_module._run_json_crew(trained_agents_file="some.pkl")
|
||||
assert os.environ[CREWAI_TRAINED_AGENTS_FILE_ENV] == "some.pkl"
|
||||
finally:
|
||||
os.environ.pop(CREWAI_TRAINED_AGENTS_FILE_ENV, None)
|
||||
|
||||
|
||||
def test_run_json_crew_leaves_env_untouched_without_flag(monkeypatch, tmp_path: Path):
|
||||
monkeypatch.chdir(tmp_path)
|
||||
monkeypatch.delenv(CREWAI_TRAINED_AGENTS_FILE_ENV, raising=False)
|
||||
|
||||
with pytest.raises(FileNotFoundError):
|
||||
run_crew_module._run_json_crew()
|
||||
|
||||
assert CREWAI_TRAINED_AGENTS_FILE_ENV not in os.environ
|
||||
|
||||
|
||||
def test_missing_input_names_accepts_hyphenated_placeholders():
|
||||
"""The prompt regex must accept the same names kickoff interpolation does."""
|
||||
from types import SimpleNamespace
|
||||
|
||||
crew = SimpleNamespace(
|
||||
agents=[
|
||||
SimpleNamespace(
|
||||
role="Researcher", goal="Cover {my-topic}", backstory=""
|
||||
)
|
||||
],
|
||||
tasks=[
|
||||
SimpleNamespace(
|
||||
description="Write about {my-topic} for {target-audience}",
|
||||
expected_output="Post",
|
||||
output_file=None,
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
assert run_crew_module._missing_input_names(crew, {}) == [
|
||||
"my-topic",
|
||||
"target-audience",
|
||||
]
|
||||
|
||||
|
||||
def _patch_tui_run(monkeypatch, status: str):
|
||||
"""Stub the TUI pieces of _run_json_crew so only exit handling runs."""
|
||||
|
||||
class FakeApp:
|
||||
def __init__(self, **kwargs):
|
||||
self._status = status
|
||||
self._crew_result = "result" if status == "completed" else None
|
||||
self._want_deploy = False
|
||||
|
||||
def run(self):
|
||||
pass
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
crew = SimpleNamespace(name="Demo", tasks=[], agents=[])
|
||||
monkeypatch.setattr(
|
||||
run_crew_module, "find_crew_json_file", lambda: Path("crew.jsonc")
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
run_crew_module,
|
||||
"_load_json_crew_for_tui",
|
||||
lambda _path: (FakeApp, crew, {}, [], []),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
run_crew_module, "_prompt_for_missing_inputs", lambda _crew, inputs: inputs
|
||||
)
|
||||
monkeypatch.setattr(run_crew_module, "_print_post_tui_summary", lambda _app: None)
|
||||
|
||||
|
||||
def test_run_json_crew_failed_status_exits_nonzero(monkeypatch, tmp_path: Path):
|
||||
monkeypatch.chdir(tmp_path)
|
||||
_patch_tui_run(monkeypatch, status="failed")
|
||||
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
run_crew_module._run_json_crew()
|
||||
|
||||
assert exc_info.value.code == 1
|
||||
|
||||
|
||||
def test_run_json_crew_completed_status_returns_result(monkeypatch, tmp_path: Path):
|
||||
monkeypatch.chdir(tmp_path)
|
||||
_patch_tui_run(monkeypatch, status="completed")
|
||||
|
||||
assert run_crew_module._run_json_crew() == "result"
|
||||
|
||||
|
||||
def test_has_json_crew_defers_to_declared_flow_type(monkeypatch, tmp_path: Path):
|
||||
"""A flow project containing a stray crew.jsonc must still run as a flow."""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
(tmp_path / "crew.jsonc").write_text("{}")
|
||||
(tmp_path / "pyproject.toml").write_text('[tool.crewai]\ntype = "flow"\n')
|
||||
|
||||
assert run_crew_module._has_json_crew() is False
|
||||
|
||||
|
||||
def test_has_json_crew_true_for_declared_crew_type(monkeypatch, tmp_path: Path):
|
||||
monkeypatch.chdir(tmp_path)
|
||||
(tmp_path / "crew.jsonc").write_text("{}")
|
||||
(tmp_path / "pyproject.toml").write_text('[tool.crewai]\ntype = "crew"\n')
|
||||
|
||||
assert run_crew_module._has_json_crew() is True
|
||||
|
||||
|
||||
def test_has_json_crew_true_without_pyproject(monkeypatch, tmp_path: Path):
|
||||
monkeypatch.chdir(tmp_path)
|
||||
(tmp_path / "crew.jsonc").write_text("{}")
|
||||
|
||||
assert run_crew_module._has_json_crew() is True
|
||||
156
lib/cli/tests/test_run_flow_definition.py
Normal file
156
lib/cli/tests/test_run_flow_definition.py
Normal file
@@ -0,0 +1,156 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
import types
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
from crewai_cli.run_flow_definition import run_flow_definition
|
||||
|
||||
|
||||
class _FakeFlow:
|
||||
def __init__(self, definition):
|
||||
self.definition = definition
|
||||
|
||||
def kickoff(self, inputs=None):
|
||||
return {
|
||||
"flow": self.definition["name"],
|
||||
"inputs": inputs or {},
|
||||
}
|
||||
|
||||
|
||||
class _FakeFlowFactory:
|
||||
@classmethod
|
||||
def from_definition(cls, definition):
|
||||
return _FakeFlow(definition)
|
||||
|
||||
|
||||
class _FakeFlowDefinition:
|
||||
@classmethod
|
||||
def from_yaml(cls, source):
|
||||
return yaml.safe_load(source)
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, source):
|
||||
return json.loads(source)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_flow_runtime(monkeypatch):
|
||||
crewai_module = types.ModuleType("crewai")
|
||||
flow_package = types.ModuleType("crewai.flow")
|
||||
flow_module = types.ModuleType("crewai.flow.flow")
|
||||
flow_definition_module = types.ModuleType("crewai.flow.flow_definition")
|
||||
|
||||
flow_module.Flow = _FakeFlowFactory
|
||||
flow_definition_module.FlowDefinition = _FakeFlowDefinition
|
||||
|
||||
monkeypatch.setitem(sys.modules, "crewai", crewai_module)
|
||||
monkeypatch.setitem(sys.modules, "crewai.flow", flow_package)
|
||||
monkeypatch.setitem(sys.modules, "crewai.flow.flow", flow_module)
|
||||
monkeypatch.setitem(
|
||||
sys.modules, "crewai.flow.flow_definition", flow_definition_module
|
||||
)
|
||||
|
||||
|
||||
def _captured_json(capsys):
|
||||
return json.loads(capsys.readouterr().out)
|
||||
|
||||
|
||||
def test_run_flow_definition_reads_definition_file(
|
||||
tmp_path, capsys, fake_flow_runtime
|
||||
):
|
||||
definition_path = tmp_path / "flow.yaml"
|
||||
definition_path.write_text("schema: crewai.flow/v1\nname: TestFlow\n")
|
||||
|
||||
run_flow_definition(str(definition_path), '{"topic":"AI"}')
|
||||
|
||||
assert _captured_json(capsys) == {
|
||||
"flow": "TestFlow",
|
||||
"inputs": {"topic": "AI"},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("definition_source", "expected_flow_name"),
|
||||
[
|
||||
pytest.param(
|
||||
"schema: crewai.flow/v1\nname: InlineFlow\n",
|
||||
"InlineFlow",
|
||||
id="inline-yaml",
|
||||
),
|
||||
pytest.param(
|
||||
'{"schema":"crewai.flow/v1","name":"InlineJsonFlow"}',
|
||||
"InlineJsonFlow",
|
||||
id="inline-json",
|
||||
),
|
||||
pytest.param(
|
||||
'{"schema":"crewai.flow/v1","name":"' + ("JsonFlow" * 500) + '"}',
|
||||
"JsonFlow" * 500,
|
||||
id="large-inline-json",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_run_flow_definition_accepts_inline_definitions(
|
||||
definition_source, expected_flow_name, capsys, fake_flow_runtime
|
||||
):
|
||||
run_flow_definition(definition_source)
|
||||
|
||||
assert _captured_json(capsys) == {"flow": expected_flow_name, "inputs": {}}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("filename", "definition_source", "expected_flow_name"),
|
||||
[
|
||||
pytest.param(
|
||||
"flow.yaml",
|
||||
"schema: crewai.flow/v1\nname: YamlFileFlow\n",
|
||||
"YamlFileFlow",
|
||||
id="yaml-file",
|
||||
),
|
||||
pytest.param(
|
||||
"flow.json",
|
||||
'{"schema":"crewai.flow/v1","name":"JsonFlow"}',
|
||||
"JsonFlow",
|
||||
id="json-file",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_run_flow_definition_accepts_definition_files(
|
||||
filename, definition_source, expected_flow_name, tmp_path, capsys, fake_flow_runtime
|
||||
):
|
||||
definition_path = tmp_path / filename
|
||||
definition_path.write_text(definition_source)
|
||||
|
||||
run_flow_definition(str(definition_path))
|
||||
|
||||
assert _captured_json(capsys) == {"flow": expected_flow_name, "inputs": {}}
|
||||
|
||||
|
||||
def test_run_flow_definition_rejects_non_object_inputs(fake_flow_runtime, capsys):
|
||||
with pytest.raises(SystemExit):
|
||||
run_flow_definition("name: TestFlow", '["not", "an", "object"]')
|
||||
|
||||
assert "Invalid --inputs JSON: expected an object." in capsys.readouterr().err
|
||||
|
||||
|
||||
def test_run_flow_definition_reports_unreadable_file(
|
||||
monkeypatch, tmp_path, capsys, fake_flow_runtime
|
||||
):
|
||||
definition_path = tmp_path / "flow.yaml"
|
||||
definition_path.write_text("schema: crewai.flow/v1\nname: TestFlow\n")
|
||||
|
||||
def raise_permission_error(self, *args, **kwargs):
|
||||
raise PermissionError("no access")
|
||||
|
||||
monkeypatch.setattr("pathlib.Path.read_text", raise_permission_error)
|
||||
|
||||
with pytest.raises(SystemExit):
|
||||
run_flow_definition(str(definition_path))
|
||||
|
||||
err = capsys.readouterr().err
|
||||
assert "Unable to read --definition path" in err
|
||||
assert str(definition_path) in err
|
||||
assert "no access" in err
|
||||
@@ -157,14 +157,16 @@ def test_install_api_error(mock_get, capsys, tool_command):
|
||||
mock_get.assert_called_once_with("error-tool")
|
||||
|
||||
|
||||
@patch("crewai_cli.tools.main.git.Repository.fetch")
|
||||
@patch("crewai_cli.tools.main.git.Repository.is_synced", return_value=False)
|
||||
def test_publish_when_not_in_sync(mock_is_synced, mock_fetch, capsys, tool_command):
|
||||
@patch("crewai_cli.tools.main.git.Repository")
|
||||
def test_publish_when_not_in_sync(mock_repository, capsys, tool_command):
|
||||
mock_repository.return_value.is_synced.return_value = False
|
||||
|
||||
with raises(SystemExit):
|
||||
tool_command.publish(is_public=True)
|
||||
|
||||
output = capsys.readouterr().out
|
||||
assert "Local changes need to be resolved before publishing" in output
|
||||
mock_repository.return_value.is_synced.assert_called_once_with()
|
||||
|
||||
|
||||
@patch("crewai_cli.tools.main.get_project_name", return_value="sample-tool")
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "1.14.7a1"
|
||||
__version__ = "1.14.7"
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
"""Centralised lock factory.
|
||||
|
||||
If ``REDIS_URL`` is set and the ``redis`` package is installed, locks are
|
||||
distributed via ``portalocker.RedisLock``. Otherwise, falls back to the
|
||||
standard file-based ``portalocker.Lock`` in the system temp dir.
|
||||
By default, if ``REDIS_URL`` is set and the ``redis`` package is installed,
|
||||
locks are distributed via ``portalocker.RedisLock``. Otherwise, falls back to
|
||||
the standard file-based ``portalocker.Lock`` in the system temp dir.
|
||||
|
||||
The backend can be replaced via :func:`set_lock_backend` to plug in a custom
|
||||
locking strategy (e.g. a different distributed lock service, or an in-process
|
||||
lock for tests).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterator
|
||||
from contextlib import contextmanager
|
||||
from collections.abc import Callable, Iterator
|
||||
from contextlib import AbstractContextManager, contextmanager
|
||||
from functools import lru_cache
|
||||
from hashlib import md5
|
||||
import logging
|
||||
@@ -30,6 +34,25 @@ _REDIS_URL: str | None = os.environ.get("REDIS_URL")
|
||||
|
||||
_DEFAULT_TIMEOUT: Final[int] = 120
|
||||
|
||||
# A backend is called as ``backend(name, timeout=...)`` and returns a context
|
||||
# manager that holds the lock while the ``with`` block runs.
|
||||
LockBackend = Callable[..., AbstractContextManager[None]]
|
||||
|
||||
# ``None`` means use the built-in Redis/file selection.
|
||||
_backend: LockBackend | None = None
|
||||
|
||||
|
||||
def set_lock_backend(backend: LockBackend | None) -> None:
|
||||
"""Replace the process-wide locking backend used by :func:`lock`.
|
||||
|
||||
Intended for one-time setup at startup. Pass ``None`` to restore the
|
||||
built-in Redis/file default. In-flight :func:`lock` calls keep the backend
|
||||
they started with, but swapping backends while other threads acquire locks
|
||||
is otherwise unsynchronised.
|
||||
"""
|
||||
global _backend
|
||||
_backend = backend
|
||||
|
||||
|
||||
def _redis_available() -> bool:
|
||||
"""Return True if redis is installed and REDIS_URL is set."""
|
||||
@@ -58,10 +81,19 @@ def lock(name: str, *, timeout: float = _DEFAULT_TIMEOUT) -> Iterator[None]:
|
||||
"""Acquire a named lock, yielding while it is held.
|
||||
|
||||
Args:
|
||||
name: A human-readable lock name (e.g. ``"chromadb_init"``).
|
||||
Automatically namespaced to avoid collisions.
|
||||
name: A human-readable lock name (e.g. ``"chromadb_init"``). The
|
||||
built-in default namespaces it to avoid collisions; a custom
|
||||
backend receives it verbatim.
|
||||
timeout: Maximum seconds to wait for the lock before raising.
|
||||
"""
|
||||
# Snapshot the global once: a concurrent set_lock_backend() must not turn
|
||||
# the check-then-call into calling ``None``.
|
||||
backend = _backend
|
||||
if backend is not None:
|
||||
with backend(name, timeout=timeout):
|
||||
yield
|
||||
return
|
||||
|
||||
channel = f"crewai:{md5(name.encode(), usedforsecurity=False).hexdigest()}"
|
||||
|
||||
if _redis_available():
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any, Final, Literal, TypedDict, cast
|
||||
from urllib.parse import urljoin
|
||||
|
||||
@@ -190,6 +191,36 @@ class PlusAPI:
|
||||
with httpx.Client(trust_env=False, verify=verify) as client:
|
||||
return client.request(method, url, **request_kwargs)
|
||||
|
||||
def _make_multipart_request(
|
||||
self,
|
||||
method: HttpMethod,
|
||||
endpoint: str,
|
||||
*,
|
||||
zip_file_path: str | Path,
|
||||
data: dict[str, str] | None = None,
|
||||
timeout: float | None = None,
|
||||
verify: bool = True,
|
||||
) -> httpx.Response:
|
||||
"""Send an authenticated multipart request containing a project ZIP."""
|
||||
url = urljoin(self.base_url, endpoint)
|
||||
headers = dict(cast(dict[str, str], self.headers))
|
||||
headers.pop("Content-Type", None)
|
||||
path = Path(zip_file_path)
|
||||
request_kwargs: dict[str, Any] = {"headers": headers}
|
||||
if data is not None:
|
||||
request_kwargs["data"] = data
|
||||
if timeout is not None:
|
||||
request_kwargs["timeout"] = timeout
|
||||
|
||||
with (
|
||||
path.open("rb") as file_handle,
|
||||
httpx.Client(trust_env=False, verify=verify) as client,
|
||||
):
|
||||
files = {
|
||||
"zip_file": (path.name, file_handle, "application/zip"),
|
||||
}
|
||||
return client.request(method, url, files=files, **request_kwargs)
|
||||
|
||||
def login_to_tool_repository(
|
||||
self, user_identifier: str | None = None
|
||||
) -> httpx.Response:
|
||||
@@ -312,6 +343,46 @@ class PlusAPI:
|
||||
def create_crew(self, payload: CreateCrewPayload) -> httpx.Response:
|
||||
return self._make_request("POST", self.CREWS_RESOURCE, json=payload)
|
||||
|
||||
def create_crew_from_zip(
|
||||
self,
|
||||
zip_file_path: str | Path,
|
||||
*,
|
||||
name: str | None = None,
|
||||
env: dict[str, str] | None = None,
|
||||
) -> httpx.Response:
|
||||
"""Create a crew deployment from a local project ZIP archive."""
|
||||
data: dict[str, str] = {}
|
||||
if name:
|
||||
data["name"] = name
|
||||
if env:
|
||||
data.update({f"env[{key}]": value for key, value in env.items()})
|
||||
return self._make_multipart_request(
|
||||
"POST",
|
||||
f"{self.CREWS_RESOURCE}/zip",
|
||||
zip_file_path=zip_file_path,
|
||||
data=data or None,
|
||||
timeout=300,
|
||||
)
|
||||
|
||||
def update_crew_from_zip(
|
||||
self,
|
||||
uuid: str,
|
||||
zip_file_path: str | Path,
|
||||
*,
|
||||
env: dict[str, str] | None = None,
|
||||
) -> httpx.Response:
|
||||
"""Update an existing crew deployment from a local project ZIP archive."""
|
||||
data: dict[str, str] = {}
|
||||
if env:
|
||||
data.update({f"env[{key}]": value for key, value in env.items()})
|
||||
return self._make_multipart_request(
|
||||
"POST",
|
||||
f"{self.CREWS_RESOURCE}/{uuid}/zip_update",
|
||||
zip_file_path=zip_file_path,
|
||||
data=data or None,
|
||||
timeout=300,
|
||||
)
|
||||
|
||||
def get_organizations(self) -> httpx.Response:
|
||||
return self._make_request("GET", self.ORGANIZATIONS_RESOURCE)
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ import contextlib
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
from typing import Any, Final
|
||||
from typing import Any, ClassVar, Final
|
||||
|
||||
from opentelemetry import trace
|
||||
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
|
||||
@@ -27,7 +27,7 @@ from opentelemetry.sdk.trace.export import (
|
||||
BatchSpanProcessor,
|
||||
SpanExportResult,
|
||||
)
|
||||
from opentelemetry.trace import Span, Status, StatusCode
|
||||
from opentelemetry.trace import ProxyTracerProvider, Span, Status, StatusCode
|
||||
from typing_extensions import Self
|
||||
|
||||
|
||||
@@ -72,8 +72,8 @@ class Telemetry:
|
||||
and event-bus signal handlers (see ``crewai.telemetry.telemetry``).
|
||||
"""
|
||||
|
||||
_instance = None
|
||||
_lock = threading.Lock()
|
||||
_instance: ClassVar[Self | None] = None
|
||||
_lock: ClassVar[threading.Lock] = threading.Lock()
|
||||
|
||||
def __new__(cls) -> Self:
|
||||
if cls._instance is None:
|
||||
@@ -149,6 +149,10 @@ class Telemetry:
|
||||
if self.ready and not self.trace_set:
|
||||
try:
|
||||
with suppress_warnings():
|
||||
existing_provider = trace.get_tracer_provider()
|
||||
if not isinstance(existing_provider, ProxyTracerProvider):
|
||||
self.trace_set = True
|
||||
return
|
||||
trace.set_tracer_provider(self.provider)
|
||||
self.trace_set = True
|
||||
except Exception as e:
|
||||
|
||||
@@ -13,6 +13,7 @@ from crewai_core import (
|
||||
user_data,
|
||||
version,
|
||||
)
|
||||
from opentelemetry.sdk.trace import TracerProvider
|
||||
import pytest
|
||||
|
||||
|
||||
@@ -94,3 +95,36 @@ def test_user_data_decline_blocks(
|
||||
def test_unused_var_warning_silenced() -> None:
|
||||
# Touch os to keep the import (used by env-var fixtures above)
|
||||
assert os.environ is not None
|
||||
|
||||
|
||||
def test_core_telemetry_skips_duplicate_tracer_provider(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
from crewai_core.telemetry import Telemetry
|
||||
|
||||
Telemetry._instance = None
|
||||
monkeypatch.delenv("OTEL_SDK_DISABLED", raising=False)
|
||||
monkeypatch.delenv("CREWAI_DISABLE_TELEMETRY", raising=False)
|
||||
monkeypatch.delenv("CREWAI_DISABLE_TRACKING", raising=False)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"crewai_core.telemetry.trace.get_tracer_provider",
|
||||
lambda: TracerProvider(),
|
||||
)
|
||||
|
||||
called = False
|
||||
|
||||
def fail_if_called(provider: object) -> None:
|
||||
nonlocal called
|
||||
called = True
|
||||
|
||||
monkeypatch.setattr(
|
||||
"crewai_core.telemetry.trace.set_tracer_provider",
|
||||
fail_if_called,
|
||||
)
|
||||
|
||||
telemetry = Telemetry()
|
||||
telemetry.set_tracer()
|
||||
|
||||
assert called is False
|
||||
assert telemetry.trace_set is True
|
||||
|
||||
@@ -152,4 +152,4 @@ __all__ = [
|
||||
"wrap_file_source",
|
||||
]
|
||||
|
||||
__version__ = "1.14.7a1"
|
||||
__version__ = "1.14.7"
|
||||
|
||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncIterator, Iterator
|
||||
import inspect
|
||||
import json
|
||||
import mimetypes
|
||||
from pathlib import Path
|
||||
from typing import Annotated, Any, BinaryIO, Protocol, cast, runtime_checkable
|
||||
@@ -23,6 +24,9 @@ from typing_extensions import TypeIs
|
||||
from crewai_files.core.constants import DEFAULT_MAX_FILE_SIZE_BYTES, MAGIC_BUFFER_SIZE
|
||||
|
||||
|
||||
OCTET_STREAM = "application/octet-stream"
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class AsyncReadable(Protocol):
|
||||
"""Protocol for async readable streams."""
|
||||
@@ -56,13 +60,51 @@ class _AsyncReadableValidator:
|
||||
ValidatedAsyncReadable = Annotated[AsyncReadable, _AsyncReadableValidator()]
|
||||
|
||||
|
||||
def _fallback_content_type(filename: str | None) -> str:
|
||||
"""Get content type from filename extension or return default."""
|
||||
def _detect_content_type_from_bytes(data: bytes) -> str | None:
|
||||
if data.startswith(b"\x89PNG\r\n\x1a\n"):
|
||||
return "image/png"
|
||||
if data.startswith(b"\xff\xd8\xff"):
|
||||
return "image/jpeg"
|
||||
if data.startswith(b"%PDF-"):
|
||||
return "application/pdf"
|
||||
|
||||
try:
|
||||
decoded = data.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
return None
|
||||
|
||||
stripped = decoded.lstrip()
|
||||
if stripped.startswith(("{", "[")):
|
||||
try:
|
||||
json.loads(decoded)
|
||||
return "application/json"
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
if "\x00" not in decoded:
|
||||
return "text/plain"
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _fallback_content_type(filename: str | None, data: bytes | None = None) -> str:
|
||||
"""Get content type from filename extension, then content sniffing.
|
||||
|
||||
The extension lookup runs first so specific types like ``text/csv`` or
|
||||
``application/xml`` are not degraded to generic sniffed types such as
|
||||
``text/plain``; byte sniffing only covers extensionless/unknown names.
|
||||
"""
|
||||
if filename:
|
||||
mime_type, _ = mimetypes.guess_type(filename)
|
||||
if mime_type:
|
||||
return mime_type
|
||||
return "application/octet-stream"
|
||||
|
||||
if data:
|
||||
content_type = _detect_content_type_from_bytes(data)
|
||||
if content_type:
|
||||
return content_type
|
||||
|
||||
return OCTET_STREAM
|
||||
|
||||
|
||||
def generate_filename(content_type: str) -> str:
|
||||
@@ -97,9 +139,19 @@ def detect_content_type(data: bytes, filename: str | None = None) -> str:
|
||||
import magic
|
||||
|
||||
result: str = magic.from_buffer(data[:MAGIC_BUFFER_SIZE], mime=True)
|
||||
return result
|
||||
if result != OCTET_STREAM:
|
||||
return result
|
||||
return _fallback_content_type(filename, data)
|
||||
except ImportError:
|
||||
return _fallback_content_type(filename)
|
||||
return _fallback_content_type(filename, data)
|
||||
|
||||
|
||||
def _read_magic_header(path: Path) -> bytes | None:
|
||||
try:
|
||||
with path.open("rb") as file:
|
||||
return file.read(MAGIC_BUFFER_SIZE)
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
def detect_content_type_from_path(path: Path, filename: str | None = None) -> str:
|
||||
@@ -115,13 +167,16 @@ def detect_content_type_from_path(path: Path, filename: str | None = None) -> st
|
||||
Returns:
|
||||
The detected MIME type.
|
||||
"""
|
||||
fallback_filename = filename or path.name
|
||||
try:
|
||||
import magic
|
||||
|
||||
result: str = magic.from_file(str(path), mime=True)
|
||||
return result
|
||||
if result != OCTET_STREAM:
|
||||
return result
|
||||
return _fallback_content_type(fallback_filename, _read_magic_header(path))
|
||||
except ImportError:
|
||||
return _fallback_content_type(filename or path.name)
|
||||
return _fallback_content_type(fallback_filename, _read_magic_header(path))
|
||||
|
||||
|
||||
class _BinaryIOValidator:
|
||||
|
||||
@@ -129,6 +129,20 @@ class FileResolver:
|
||||
"""
|
||||
return constraints is not None and constraints.supports_url_references
|
||||
|
||||
@classmethod
|
||||
def _should_resolve_as_url_reference(
|
||||
cls,
|
||||
file: FileInput,
|
||||
provider: ProviderType,
|
||||
constraints: ProviderConstraints | None,
|
||||
) -> bool:
|
||||
"""Check if the provider can accept the current URL source directly."""
|
||||
if not cls._is_url_source(file) or not cls._supports_url(constraints):
|
||||
return False
|
||||
|
||||
provider_lower = provider.lower()
|
||||
return "bedrock" not in provider_lower and "aws" not in provider_lower
|
||||
|
||||
@staticmethod
|
||||
def _resolve_as_url(file: FileInput) -> UrlReference:
|
||||
"""Resolve a URL source as UrlReference.
|
||||
@@ -159,7 +173,7 @@ class FileResolver:
|
||||
"""
|
||||
constraints = get_constraints_for_provider(provider)
|
||||
|
||||
if self._is_url_source(file) and self._supports_url(constraints):
|
||||
if self._should_resolve_as_url_reference(file, provider, constraints):
|
||||
return self._resolve_as_url(file)
|
||||
|
||||
context = self._build_file_context(file)
|
||||
@@ -424,7 +438,7 @@ class FileResolver:
|
||||
"""
|
||||
constraints = get_constraints_for_provider(provider)
|
||||
|
||||
if self._is_url_source(file) and self._supports_url(constraints):
|
||||
if self._should_resolve_as_url_reference(file, provider, constraints):
|
||||
return self._resolve_as_url(file)
|
||||
|
||||
context = self._build_file_context(file)
|
||||
|
||||
@@ -10,7 +10,7 @@ requires-python = ">=3.10, <3.14"
|
||||
dependencies = [
|
||||
"pytube~=15.0.0",
|
||||
"requests>=2.33.0,<3",
|
||||
"crewai==1.14.7a1",
|
||||
"crewai==1.14.7",
|
||||
"tiktoken>=0.8.0,<0.13",
|
||||
"beautifulsoup4~=4.13.4",
|
||||
"python-docx~=1.2.0",
|
||||
@@ -63,7 +63,7 @@ spider-client = [
|
||||
"spider-client>=0.1.25",
|
||||
]
|
||||
scrapegraph-py = [
|
||||
"scrapegraph-py>=1.9.0",
|
||||
"scrapegraph-py>=1.9.0,<2",
|
||||
]
|
||||
linkup-sdk = [
|
||||
"linkup-sdk>=0.2.2",
|
||||
|
||||
@@ -330,4 +330,4 @@ __all__ = [
|
||||
"ZapierActionTools",
|
||||
]
|
||||
|
||||
__version__ = "1.14.7a1"
|
||||
__version__ = "1.14.7"
|
||||
|
||||
@@ -22,6 +22,31 @@ logger = logging.getLogger(__name__)
|
||||
_UNSAFE_PATHS_ENV = "CREWAI_TOOLS_ALLOW_UNSAFE_PATHS"
|
||||
|
||||
|
||||
def format_path_for_display(path: str, base_dir: str | None = None) -> str:
|
||||
"""Return a path label that does not expose absolute directory prefixes."""
|
||||
if base_dir is None:
|
||||
base_dir = os.getcwd()
|
||||
|
||||
try:
|
||||
resolved_base = os.path.realpath(base_dir)
|
||||
resolved_path = os.path.realpath(
|
||||
os.path.join(resolved_base, path) if not os.path.isabs(path) else path
|
||||
)
|
||||
if os.path.commonpath([resolved_base, resolved_path]) == resolved_base:
|
||||
return os.path.relpath(resolved_path, resolved_base)
|
||||
except (OSError, ValueError) as exc:
|
||||
logger.debug("Falling back to basename for display path formatting: %s", exc)
|
||||
|
||||
return os.path.basename(os.path.realpath(path)) or "[redacted path]"
|
||||
|
||||
|
||||
def format_error_for_display(error: Exception) -> str:
|
||||
"""Return exception details without OS-added absolute path context."""
|
||||
if isinstance(error, OSError):
|
||||
return error.strerror or error.__class__.__name__
|
||||
return str(error)
|
||||
|
||||
|
||||
def _is_escape_hatch_enabled() -> bool:
|
||||
"""Check if the unsafe paths escape hatch is enabled."""
|
||||
return os.environ.get(_UNSAFE_PATHS_ENV, "").lower() in ("true", "1", "yes")
|
||||
@@ -66,8 +91,8 @@ def validate_file_path(path: str, base_dir: str | None = None) -> str:
|
||||
prefix = resolved_base if resolved_base.endswith(os.sep) else resolved_base + os.sep
|
||||
if not resolved_path.startswith(prefix) and resolved_path != resolved_base:
|
||||
raise ValueError(
|
||||
f"Path '{path}' resolves to '{resolved_path}' which is outside "
|
||||
f"the allowed directory '{resolved_base}'. "
|
||||
f"Path '{format_path_for_display(resolved_path, resolved_base)}' is "
|
||||
f"outside the allowed directory. "
|
||||
f"Set {_UNSAFE_PATHS_ENV}=true to bypass this check."
|
||||
)
|
||||
|
||||
|
||||
@@ -3,7 +3,11 @@ from typing import Any
|
||||
from crewai.tools import BaseTool
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from crewai_tools.security.safe_path import validate_file_path
|
||||
from crewai_tools.security.safe_path import (
|
||||
format_error_for_display,
|
||||
format_path_for_display,
|
||||
validate_file_path,
|
||||
)
|
||||
|
||||
|
||||
class FileReadToolSchema(BaseModel):
|
||||
@@ -58,8 +62,9 @@ class FileReadTool(BaseTool):
|
||||
**kwargs: Additional keyword arguments passed to BaseTool.
|
||||
"""
|
||||
if file_path is not None:
|
||||
display_path = format_path_for_display(file_path)
|
||||
kwargs["description"] = (
|
||||
f"A tool that reads file content. The default file is {file_path}, but you can provide a different 'file_path' parameter to read another file. You can also specify 'start_line' and 'line_count' to read specific parts of the file."
|
||||
f"A tool that reads file content. The default file is {display_path}, but you can provide a different 'file_path' parameter to read another file. You can also specify 'start_line' and 'line_count' to read specific parts of the file."
|
||||
)
|
||||
|
||||
super().__init__(**kwargs)
|
||||
@@ -78,7 +83,12 @@ class FileReadTool(BaseTool):
|
||||
if file_path is None:
|
||||
return "Error: No file path provided. Please provide a file path either in the constructor or as an argument."
|
||||
|
||||
file_path = validate_file_path(file_path)
|
||||
try:
|
||||
file_path = validate_file_path(file_path)
|
||||
except ValueError as e:
|
||||
return f"Error: Invalid file path: {e!s}"
|
||||
|
||||
display_path = format_path_for_display(file_path)
|
||||
try:
|
||||
with open(file_path, "r") as file:
|
||||
if start_line == 1 and line_count is None:
|
||||
@@ -98,8 +108,11 @@ class FileReadTool(BaseTool):
|
||||
|
||||
return "".join(selected_lines)
|
||||
except FileNotFoundError:
|
||||
return f"Error: File not found at path: {file_path}"
|
||||
return f"Error: File not found at path: {display_path}"
|
||||
except PermissionError:
|
||||
return f"Error: Permission denied when trying to read file: {file_path}"
|
||||
return f"Error: Permission denied when trying to read file: {display_path}"
|
||||
except Exception as e:
|
||||
return f"Error: Failed to read file {file_path}. {e!s}"
|
||||
return (
|
||||
f"Error: Failed to read file {display_path}. "
|
||||
f"{format_error_for_display(e)}"
|
||||
)
|
||||
|
||||
@@ -5,6 +5,11 @@ from typing import Any
|
||||
from crewai.tools import BaseTool
|
||||
from pydantic import BaseModel
|
||||
|
||||
from crewai_tools.security.safe_path import (
|
||||
format_error_for_display,
|
||||
format_path_for_display,
|
||||
)
|
||||
|
||||
|
||||
def strtobool(val: str | bool) -> bool:
|
||||
if isinstance(val, bool):
|
||||
@@ -44,6 +49,9 @@ class FileWriterTool(BaseTool):
|
||||
# itself, since that is not a valid file target.
|
||||
real_directory = Path(directory).resolve()
|
||||
real_filepath = Path(filepath).resolve()
|
||||
display_filepath = format_path_for_display(
|
||||
str(real_filepath), str(real_directory)
|
||||
)
|
||||
if (
|
||||
not real_filepath.is_relative_to(real_directory)
|
||||
or real_filepath == real_directory
|
||||
@@ -56,15 +64,18 @@ class FileWriterTool(BaseTool):
|
||||
kwargs["overwrite"] = strtobool(kwargs["overwrite"])
|
||||
|
||||
if os.path.exists(real_filepath) and not kwargs["overwrite"]:
|
||||
return f"File {real_filepath} already exists and overwrite option was not passed."
|
||||
return f"File {display_filepath} already exists and overwrite option was not passed."
|
||||
|
||||
mode = "w" if kwargs["overwrite"] else "x"
|
||||
with open(real_filepath, mode) as file:
|
||||
file.write(kwargs["content"])
|
||||
return f"Content successfully written to {real_filepath}"
|
||||
return f"Content successfully written to {display_filepath}"
|
||||
except FileExistsError:
|
||||
return f"File {real_filepath} already exists and overwrite option was not passed."
|
||||
return f"File {display_filepath} already exists and overwrite option was not passed."
|
||||
except KeyError as e:
|
||||
return f"An error occurred while accessing key: {e!s}"
|
||||
except Exception as e:
|
||||
return f"An error occurred while writing to the file: {e!s}"
|
||||
return (
|
||||
"An error occurred while writing to the file: "
|
||||
f"{format_error_for_display(e)}"
|
||||
)
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import os
|
||||
from unittest.mock import mock_open, patch
|
||||
|
||||
from crewai_tools import FileReadTool
|
||||
@@ -6,21 +5,16 @@ from crewai_tools import FileReadTool
|
||||
|
||||
def test_file_read_tool_constructor():
|
||||
"""Test FileReadTool initialization with file_path."""
|
||||
test_file = "/tmp/test_file.txt"
|
||||
test_content = "Hello, World!"
|
||||
with open(test_file, "w") as f:
|
||||
f.write(test_content)
|
||||
test_file = "test_file.txt"
|
||||
|
||||
tool = FileReadTool(file_path=test_file)
|
||||
assert tool.file_path == test_file
|
||||
assert "test_file.txt" in tool.description
|
||||
|
||||
os.remove(test_file)
|
||||
|
||||
|
||||
def test_file_read_tool_run():
|
||||
"""Test FileReadTool _run method with file_path at runtime."""
|
||||
test_file = "/tmp/test_file.txt"
|
||||
test_file = "test_file.txt"
|
||||
test_content = "Hello, World!"
|
||||
|
||||
# Use mock_open to mock file operations
|
||||
@@ -36,18 +30,18 @@ def test_file_read_tool_error_handling():
|
||||
result = tool._run()
|
||||
assert "Error: No file path provided" in result
|
||||
|
||||
result = tool._run(file_path="/nonexistent/file.txt")
|
||||
result = tool._run(file_path="nonexistent/file.txt")
|
||||
assert "Error: File not found at path:" in result
|
||||
|
||||
with patch("builtins.open", side_effect=PermissionError()):
|
||||
result = tool._run(file_path="/tmp/no_permission.txt")
|
||||
result = tool._run(file_path="no_permission.txt")
|
||||
assert "Error: Permission denied" in result
|
||||
|
||||
|
||||
def test_file_read_tool_constructor_and_run():
|
||||
"""Test FileReadTool using both constructor and runtime file paths."""
|
||||
test_file1 = "/tmp/test1.txt"
|
||||
test_file2 = "/tmp/test2.txt"
|
||||
test_file1 = "test1.txt"
|
||||
test_file2 = "test2.txt"
|
||||
content1 = "File 1 content"
|
||||
content2 = "File 2 content"
|
||||
|
||||
@@ -64,7 +58,7 @@ def test_file_read_tool_constructor_and_run():
|
||||
|
||||
def test_file_read_tool_chunk_reading():
|
||||
"""Test FileReadTool reading specific chunks of a file."""
|
||||
test_file = "/tmp/multiline_test.txt"
|
||||
test_file = "multiline_test.txt"
|
||||
lines = [
|
||||
"Line 1\n",
|
||||
"Line 2\n",
|
||||
@@ -104,7 +98,7 @@ def test_file_read_tool_chunk_reading():
|
||||
|
||||
def test_file_read_tool_chunk_error_handling():
|
||||
"""Test error handling for chunk reading."""
|
||||
test_file = "/tmp/short_test.txt"
|
||||
test_file = "short_test.txt"
|
||||
lines = ["Line 1\n", "Line 2\n", "Line 3\n"]
|
||||
file_content = "".join(lines)
|
||||
|
||||
@@ -122,7 +116,7 @@ def test_file_read_tool_chunk_error_handling():
|
||||
|
||||
def test_file_read_tool_zero_or_negative_start_line():
|
||||
"""Test that start_line values of 0 or negative read from the start of the file."""
|
||||
test_file = "/tmp/negative_test.txt"
|
||||
test_file = "negative_test.txt"
|
||||
lines = ["Line 1\n", "Line 2\n", "Line 3\n", "Line 4\n", "Line 5\n"]
|
||||
file_content = "".join(lines)
|
||||
|
||||
@@ -150,3 +144,45 @@ def test_file_read_tool_zero_or_negative_start_line():
|
||||
result = tool._run(file_path=test_file, start_line=-10, line_count=2)
|
||||
expected = "".join(lines[0:2]) # Should read first 2 lines
|
||||
assert result == expected
|
||||
|
||||
|
||||
def test_file_read_tool_error_messages_do_not_disclose_absolute_paths(
|
||||
tmp_path, monkeypatch
|
||||
):
|
||||
"""FileReadTool should redact absolute prefixes from user-visible errors."""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
tool = FileReadTool()
|
||||
target = tmp_path / "secret.txt"
|
||||
|
||||
result = tool._run(file_path=str(target))
|
||||
assert "secret.txt" in result
|
||||
assert str(tmp_path) not in result
|
||||
|
||||
target.touch()
|
||||
with patch("builtins.open", side_effect=PermissionError()):
|
||||
result = tool._run(file_path=str(target))
|
||||
assert "secret.txt" in result
|
||||
assert str(tmp_path) not in result
|
||||
|
||||
with patch(
|
||||
"builtins.open",
|
||||
side_effect=OSError(5, "Input/output error", str(target)),
|
||||
):
|
||||
result = tool._run(file_path=str(target))
|
||||
assert "secret.txt" in result
|
||||
assert str(tmp_path) not in result
|
||||
|
||||
|
||||
def test_file_read_tool_invalid_path_error_does_not_disclose_workspace(
|
||||
tmp_path, monkeypatch
|
||||
):
|
||||
"""Validation errors should not echo the resolved workspace path."""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
outside = tmp_path.parent / "outside.txt"
|
||||
|
||||
result = FileReadTool()._run(file_path=str(outside))
|
||||
|
||||
assert "Invalid file path" in result
|
||||
assert "outside.txt" in result
|
||||
assert str(tmp_path) not in result
|
||||
assert str(tmp_path.parent) not in result
|
||||
|
||||
@@ -47,6 +47,8 @@ def test_basic_file_write(tool, temp_env):
|
||||
assert os.path.exists(path)
|
||||
assert read_file(path) == temp_env["test_content"]
|
||||
assert "successfully written" in result
|
||||
assert temp_env["test_file"] in result
|
||||
assert temp_env["temp_dir"] not in result
|
||||
|
||||
|
||||
def test_directory_creation(tool, temp_env):
|
||||
@@ -62,6 +64,8 @@ def test_directory_creation(tool, temp_env):
|
||||
assert os.path.exists(new_dir)
|
||||
assert os.path.exists(path)
|
||||
assert "successfully written" in result
|
||||
assert temp_env["test_file"] in result
|
||||
assert new_dir not in result
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
@@ -134,6 +138,8 @@ def test_file_exists_error_handling(tool, temp_env, overwrite):
|
||||
)
|
||||
|
||||
assert "already exists and overwrite option was not passed" in result
|
||||
assert temp_env["test_file"] in result
|
||||
assert temp_env["temp_dir"] not in result
|
||||
assert read_file(path) == "Pre-existing content"
|
||||
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import os
|
||||
import pytest
|
||||
|
||||
from crewai_tools.security.safe_path import (
|
||||
format_path_for_display,
|
||||
validate_directory_path,
|
||||
validate_file_path,
|
||||
validate_url,
|
||||
@@ -66,6 +67,37 @@ class TestValidateFilePath:
|
||||
result = validate_file_path("/etc/passwd", str(tmp_path))
|
||||
assert result == os.path.realpath("/etc/passwd")
|
||||
|
||||
def test_rejection_message_redacts_absolute_prefixes(self, tmp_path):
|
||||
outside = tmp_path.parent / "outside.txt"
|
||||
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
validate_file_path(str(outside), str(tmp_path))
|
||||
|
||||
message = str(exc_info.value)
|
||||
assert "outside.txt" in message
|
||||
assert str(tmp_path) not in message
|
||||
assert str(tmp_path.parent) not in message
|
||||
|
||||
|
||||
class TestFormatPathForDisplay:
|
||||
"""Tests for user-visible path labels."""
|
||||
|
||||
def test_returns_relative_path_inside_base(self, tmp_path):
|
||||
nested_file = tmp_path / "nested" / "file.txt"
|
||||
nested_file.parent.mkdir()
|
||||
nested_file.touch()
|
||||
|
||||
result = format_path_for_display(str(nested_file), str(tmp_path))
|
||||
|
||||
assert result == os.path.join("nested", "file.txt")
|
||||
|
||||
def test_redacts_absolute_prefix_outside_base(self, tmp_path):
|
||||
outside_file = tmp_path.parent / "outside.txt"
|
||||
|
||||
result = format_path_for_display(str(outside_file), str(tmp_path))
|
||||
|
||||
assert result == "outside.txt"
|
||||
|
||||
|
||||
class TestValidateDirectoryPath:
|
||||
"""Tests for validate_directory_path."""
|
||||
|
||||
@@ -8,8 +8,8 @@ authors = [
|
||||
]
|
||||
requires-python = ">=3.10, <3.14"
|
||||
dependencies = [
|
||||
"crewai-core==1.14.7a1",
|
||||
"crewai-cli==1.14.7a1",
|
||||
"crewai-core==1.14.7",
|
||||
"crewai-cli==1.14.7",
|
||||
# Core Dependencies
|
||||
"pydantic>=2.11.9,<2.13",
|
||||
"openai>=2.30.0,<3",
|
||||
@@ -33,11 +33,12 @@ dependencies = [
|
||||
"appdirs~=1.4.4",
|
||||
"jsonref~=1.1.0",
|
||||
"json-repair~=0.25.2",
|
||||
"cel-python>=0.5.0,<0.6",
|
||||
"tomli-w~=1.1.0",
|
||||
"tomli~=2.0.2",
|
||||
"json5~=0.10.0",
|
||||
"portalocker~=2.7.0",
|
||||
"pydantic-settings~=2.10.1",
|
||||
"pydantic-settings>=2.10.1,<3",
|
||||
"httpx~=0.28.1",
|
||||
"mcp~=1.26.0",
|
||||
"aiosqlite~=0.21.0",
|
||||
@@ -54,7 +55,7 @@ Repository = "https://github.com/crewAIInc/crewAI"
|
||||
|
||||
[project.optional-dependencies]
|
||||
tools = [
|
||||
"crewai-tools==1.14.7a1",
|
||||
"crewai-tools==1.14.7",
|
||||
]
|
||||
embeddings = [
|
||||
"tiktoken>=0.8.0,<0.13"
|
||||
@@ -67,7 +68,11 @@ openpyxl = [
|
||||
]
|
||||
mem0 = ["mem0ai>=2.0.0,<3"]
|
||||
docling = [
|
||||
"docling~=2.84.0",
|
||||
"docling~=2.97.0",
|
||||
# docling 2.97 split into docling-slim; the chunker package (HierarchicalChunker)
|
||||
# now eagerly imports code-chunking submodules that need tree-sitter/semchunk,
|
||||
# which only the docling-core[chunking] extra provides.
|
||||
"docling-core[chunking]>=2.74.1",
|
||||
]
|
||||
qdrant = [
|
||||
"qdrant-client[fastembed]~=1.14.3",
|
||||
|
||||
@@ -48,7 +48,7 @@ def _suppress_pydantic_deprecation_warnings() -> None:
|
||||
|
||||
_suppress_pydantic_deprecation_warnings()
|
||||
|
||||
__version__ = "1.14.7a1"
|
||||
__version__ = "1.14.7"
|
||||
|
||||
_LAZY_IMPORTS: dict[str, tuple[str, str]] = {
|
||||
"Memory": ("crewai.memory.unified_memory", "Memory"),
|
||||
|
||||
@@ -758,6 +758,31 @@ class Agent(BaseAgent):
|
||||
self._check_execution_error(e, task)
|
||||
return await self.aexecute_task(task, context, tools)
|
||||
|
||||
def message(self, content: str, **kwargs: Any) -> str:
|
||||
"""Send a single message and get a response.
|
||||
|
||||
Creates a temporary Task + Crew, executes, and returns the raw output.
|
||||
"""
|
||||
from crewai.crew import Crew
|
||||
from crewai.task import Task
|
||||
from crewai.types.streaming import CrewStreamingOutput
|
||||
|
||||
task = Task(
|
||||
description=content,
|
||||
expected_output="Respond to the user's message appropriately.",
|
||||
agent=self,
|
||||
)
|
||||
crew = Crew(
|
||||
agents=[self],
|
||||
tasks=[task],
|
||||
verbose=self.verbose,
|
||||
memory=self.memory or False,
|
||||
)
|
||||
result = crew.kickoff()
|
||||
if isinstance(result, CrewStreamingOutput):
|
||||
return result.result.raw
|
||||
return result.raw
|
||||
|
||||
def execute_task(
|
||||
self,
|
||||
task: Task,
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Literal
|
||||
from typing import Annotated, Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel, BeforeValidator, Field
|
||||
|
||||
from crewai.agents.agent_builder.base_agent import _validate_llm_ref
|
||||
from crewai.llms.base_llm import BaseLLM
|
||||
|
||||
|
||||
@@ -69,7 +70,7 @@ class PlanningConfig(BaseModel):
|
||||
max_attempts=3,
|
||||
max_steps=10,
|
||||
plan_prompt="Create a focused plan for: {description}",
|
||||
llm="gpt-4o-mini",
|
||||
llm="gpt-5.4-mini",
|
||||
),
|
||||
)
|
||||
```
|
||||
@@ -139,7 +140,10 @@ class PlanningConfig(BaseModel):
|
||||
"whether to continue or replan. None means no per-step timeout."
|
||||
),
|
||||
)
|
||||
llm: str | BaseLLM | None = Field(
|
||||
llm: Annotated[
|
||||
str | BaseLLM | None,
|
||||
BeforeValidator(_validate_llm_ref),
|
||||
] = Field(
|
||||
default=None,
|
||||
description="LLM to use for planning. Uses agent's LLM if None.",
|
||||
)
|
||||
|
||||
@@ -81,7 +81,7 @@ class OpenAIAgentAdapter(BaseAgentAdapter):
|
||||
Raises:
|
||||
ImportError: If OpenAI agent dependencies are not installed.
|
||||
"""
|
||||
self.llm = kwargs.pop("model", "gpt-4o-mini")
|
||||
self.llm = kwargs.pop("model", "gpt-5.4-mini")
|
||||
super().__init__(**kwargs)
|
||||
self._tool_adapter = OpenAIAgentToolAdapter(tools=kwargs.get("tools"))
|
||||
self._converter_adapter = OpenAIConverterAdapter(agent_adapter=self)
|
||||
|
||||
@@ -46,6 +46,7 @@ from crewai.state.checkpoint_config import CheckpointConfig, _coerce_checkpoint
|
||||
from crewai.tools.base_tool import BaseTool, Tool
|
||||
from crewai.types.callback import SerializableCallable
|
||||
from crewai.utilities.config import process_config
|
||||
from crewai.utilities.i18n import I18N, get_i18n
|
||||
from crewai.utilities.logger import Logger
|
||||
from crewai.utilities.rpm_controller import RPMController
|
||||
from crewai.utilities.string_utils import interpolate_only
|
||||
@@ -81,16 +82,42 @@ _LLM_TYPE_REGISTRY: dict[str, str] = {
|
||||
def _validate_llm_ref(value: Any) -> Any:
|
||||
if isinstance(value, dict):
|
||||
import importlib
|
||||
import inspect
|
||||
|
||||
llm_type = value.get("llm_type")
|
||||
if not llm_type or llm_type not in _LLM_TYPE_REGISTRY:
|
||||
if not llm_type:
|
||||
model = (
|
||||
value.get("model")
|
||||
or value.get("model_name")
|
||||
or value.get("deployment_name")
|
||||
)
|
||||
if not model:
|
||||
raise ValueError(
|
||||
"LLM config objects must include 'model', 'model_name', "
|
||||
"or 'deployment_name', or a serialized 'llm_type'. "
|
||||
f"Got keys: {list(value)}"
|
||||
)
|
||||
from crewai.llm import LLM
|
||||
|
||||
llm_kwargs = {**value, "model": model}
|
||||
llm_kwargs.pop("model_name", None)
|
||||
llm_kwargs.pop("deployment_name", None)
|
||||
return LLM(**llm_kwargs)
|
||||
|
||||
if llm_type not in _LLM_TYPE_REGISTRY:
|
||||
raise ValueError(
|
||||
f"Unknown or missing llm_type: {llm_type!r}. "
|
||||
f"Unknown llm_type: {llm_type!r}. "
|
||||
f"Expected one of {list(_LLM_TYPE_REGISTRY)}"
|
||||
)
|
||||
dotted = _LLM_TYPE_REGISTRY[llm_type]
|
||||
mod_path, cls_name = dotted.rsplit(".", 1)
|
||||
cls = getattr(importlib.import_module(mod_path), cls_name)
|
||||
if inspect.isabstract(cls):
|
||||
from crewai.llm import LLM
|
||||
|
||||
return LLM(
|
||||
**{k: v for k, v in value.items() if v is not None and k != "llm_type"}
|
||||
)
|
||||
return cls(**value)
|
||||
return value
|
||||
|
||||
@@ -186,6 +213,7 @@ class BaseAgent(BaseModel, ABC, metaclass=AgentMeta):
|
||||
tools (list[Any] | None): Tools at the agent's disposal.
|
||||
max_iter (int): Maximum iterations for an agent to execute a task.
|
||||
agent_executor: An instance of the CrewAgentExecutor class.
|
||||
i18n (I18N): Internationalization settings.
|
||||
llm (Any): Language model that will run the agent.
|
||||
crew (Any): Crew to which the agent belongs.
|
||||
|
||||
@@ -265,6 +293,14 @@ class BaseAgent(BaseModel, ABC, metaclass=AgentMeta):
|
||||
_serialize_executor_ref, return_type=dict | None, when_used="json"
|
||||
),
|
||||
] = Field(default=None, description="An instance of the CrewAgentExecutor class.")
|
||||
i18n: I18N = Field(
|
||||
default_factory=get_i18n,
|
||||
description="Internationalization settings.",
|
||||
deprecated=(
|
||||
"Agent.i18n is deprecated and will be removed in a future release. "
|
||||
"Use crewai.utilities.i18n.get_i18n() or Crew(prompt_file=...) instead."
|
||||
),
|
||||
)
|
||||
|
||||
llm: Annotated[
|
||||
str | BaseLLM | None,
|
||||
@@ -601,7 +637,10 @@ class BaseAgent(BaseModel, ABC, metaclass=AgentMeta):
|
||||
if self.memory is True:
|
||||
from crewai.memory.unified_memory import Memory
|
||||
|
||||
self.memory = Memory()
|
||||
memory_kwargs: dict[str, Any] = {}
|
||||
if self.llm is not None:
|
||||
memory_kwargs["llm"] = self.llm
|
||||
self.memory = Memory(**memory_kwargs)
|
||||
elif self.memory is False:
|
||||
self.memory = None
|
||||
return self
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user