From 7676b0937c82abc9b53c8a4a3de99af17e28e829 Mon Sep 17 00:00:00 2001 From: Greyson LaLonde Date: Wed, 3 Jun 2026 09:45:59 -0700 Subject: [PATCH 01/18] fix(deps): bump authlib to >=1.6.12 to patch PYSEC-2026-188 --- pyproject.toml | 4 ++-- uv.lock | 23 ++++++++++++++++++----- 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index bed9f45eb..461bfd7ae 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -186,7 +186,7 @@ exclude-newer = "3 days" # gitpython <3.1.50 has GHSA-mv93-w799-cj2w (config_writer newline injection bypassing the 3.1.49 patch -> RCE via core.hooksPath). # urllib3 <2.7.0 has GHSA-qccp-gfcp-xxvc (ProxyManager cross-origin redirect leaks Authorization/Cookie) and GHSA-mf9v-mfxr-j63j (streaming decompression-bomb bypass); force 2.7.0+. # langsmith <0.8.0 has GHSA-3644-q5cj-c5c7 (public prompt manifest deserialization, SSRF/secret disclosure); force 0.8.0+. -# authlib <1.6.11 has GHSA-jj8c-mmj3-mmgv (CSRF bypass in cache-based state storage). +# authlib <1.6.12 has GHSA-jj8c-mmj3-mmgv (CSRF bypass in cache-based state storage) and PYSEC-2026-188. # pip <26.1.1 has GHSA-58qw-9mgm-455v (archive handling); OSV considers 26.1.1 unaffected. # paramiko <5.0.0 has GHSA-r374-rxx8-8654 (SHA-1 in rsakey.py); OSV considers 5.0.0 unaffected. Transitive via composio-core. # starlette <1.0.1 has PYSEC-2026-161 (missing Host header validation poisons request.url.path, bypassing path-based auth). Transitive via fastapi. @@ -207,7 +207,7 @@ override-dependencies = [ "python-multipart>=0.0.27,<1", "gitpython>=3.1.50,<4", "langsmith>=0.8.0,<1", - "authlib>=1.6.11", + "authlib>=1.6.12", "pip>=26.1.1", "paramiko>=5.0.0", "starlette>=1.0.1", diff --git a/uv.lock b/uv.lock index f433b1fcc..a72027c5a 100644 --- a/uv.lock +++ b/uv.lock @@ -13,7 +13,7 @@ resolution-markers = [ ] [options] -exclude-newer = "2026-05-30T15:40:20.821639605Z" +exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. exclude-newer-span = "P3D" [manifest] @@ -26,7 +26,7 @@ members = [ "crewai-tools", ] overrides = [ - { name = "authlib", specifier = ">=1.6.11" }, + { name = "authlib", specifier = ">=1.6.12" }, { name = "cryptography", specifier = ">=46.0.7" }, { name = "gitpython", specifier = ">=3.1.50,<4" }, { name = "langchain-core", specifier = ">=1.3.3,<2" }, @@ -444,14 +444,15 @@ wheels = [ [[package]] name = "authlib" -version = "1.6.11" +version = "1.7.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cryptography" }, + { name = "joserfc" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/28/10/b325d58ffe86815b399334a101e63bc6fa4e1953921cb23703b48a0a0220/authlib-1.6.11.tar.gz", hash = "sha256:64db35b9b01aeccb4715a6c9a6613a06f2bd7be2ab9d2eb89edd1dfc7580a38f", size = 165359, upload-time = "2026-04-16T07:22:50.279Z" } +sdist = { url = "https://files.pythonhosted.org/packages/36/98/7d93f30d029643c0275dbc0bd6d5a6f670661ee6c9a94d93af7ab4887600/authlib-1.7.2.tar.gz", hash = "sha256:2cea25fefcd4e7173bdf1372c0afc265c8034b23a8cd5dcb6a9164b826c64231", size = 176511, upload-time = "2026-05-06T08:10:23.116Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/57/2f/55fca558f925a51db046e5b929deb317ddb05afed74b22d89f4eca578980/authlib-1.6.11-py2.py3-none-any.whl", hash = "sha256:c8687a9a26451c51a34a06fa17bb97cb15bba46a6a626755e2d7f50da8bff3e3", size = 244469, upload-time = "2026-04-16T07:22:48.413Z" }, + { url = "https://files.pythonhosted.org/packages/fb/95/adcb68e20c34162e9135f370d6e31737719c2b6f94bc953fe7ed1f10fe21/authlib-1.7.2-py2.py3-none-any.whl", hash = "sha256:3e1faedc9d87e7d56a164eca3ccb6ace0d61b94abe83e92242f8dc8bba9b4a9f", size = 259548, upload-time = "2026-05-06T08:10:21.436Z" }, ] [[package]] @@ -3570,6 +3571,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7b/91/984aca2ec129e2757d1e4e3c81c3fcda9d0f85b74670a094cc443d9ee949/joblib-1.5.3-py3-none-any.whl", hash = "sha256:5fc3c5039fc5ca8c0276333a188bbd59d6b7ab37fe6632daa76bc7f9ec18e713", size = 309071, upload-time = "2025-12-15T08:41:44.973Z" }, ] +[[package]] +name = "joserfc" +version = "1.6.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5d/ac/d4fd5b30f82900eac60d765f179f0ba005825ac462cc8ced6e13ec685ab3/joserfc-1.6.8.tar.gz", hash = "sha256:878620c553a6ebdd76ccdc356782fee3f735f21a356d079a546b42a4670ace5f", size = 232930, upload-time = "2026-05-27T03:22:37.819Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/8c/5cdce2cf3ce8155849baf9a5e2ce77e89dc87ec3bdb38259e5d85fbc45bd/joserfc-1.6.8-py3-none-any.whl", hash = "sha256:22fb31a69094a5e6f44632002a9df2c30c941fc6c8ce1b037e92c03de954cf9f", size = 70927, upload-time = "2026-05-27T03:22:35.796Z" }, +] + [[package]] name = "json-repair" version = "0.25.3" From 68cdd44520f8a359281380c365064fba572aa03d Mon Sep 17 00:00:00 2001 From: Greyson LaLonde Date: Wed, 3 Jun 2026 09:50:39 -0700 Subject: [PATCH 02/18] fix(cli): restore [project.scripts] in crewai package for uv tool install --- lib/crewai/pyproject.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/lib/crewai/pyproject.toml b/lib/crewai/pyproject.toml index d61b30266..45afa06c5 100644 --- a/lib/crewai/pyproject.toml +++ b/lib/crewai/pyproject.toml @@ -138,6 +138,9 @@ torchvision = [ crewai-files = { workspace = true } +[project.scripts] +crewai = "crewai_cli.cli:crewai" + [build-system] requires = ["hatchling"] build-backend = "hatchling.build" From be3cf62b63c17314ccd1718671426437f1b147bf Mon Sep 17 00:00:00 2001 From: Lorenze Jay <63378463+lorenzejay@users.noreply.github.com> Date: Wed, 3 Jun 2026 10:30:33 -0700 Subject: [PATCH 03/18] feat: bump versions to 1.14.7a1 (#6031) --- lib/cli/pyproject.toml | 2 +- lib/cli/src/crewai_cli/__init__.py | 2 +- lib/cli/src/crewai_cli/templates/crew/pyproject.toml | 2 +- lib/cli/src/crewai_cli/templates/flow/pyproject.toml | 2 +- lib/cli/src/crewai_cli/templates/tool/pyproject.toml | 2 +- lib/crewai-core/src/crewai_core/__init__.py | 2 +- lib/crewai-files/src/crewai_files/__init__.py | 2 +- lib/crewai-tools/pyproject.toml | 2 +- lib/crewai-tools/src/crewai_tools/__init__.py | 2 +- lib/crewai/pyproject.toml | 6 +++--- lib/crewai/src/crewai/__init__.py | 2 +- lib/devtools/src/crewai_devtools/__init__.py | 2 +- 12 files changed, 14 insertions(+), 14 deletions(-) diff --git a/lib/cli/pyproject.toml b/lib/cli/pyproject.toml index 806e300ad..0df51e128 100644 --- a/lib/cli/pyproject.toml +++ b/lib/cli/pyproject.toml @@ -8,7 +8,7 @@ authors = [ ] requires-python = ">=3.10, <3.14" dependencies = [ - "crewai-core==1.14.6", + "crewai-core==1.14.7a1", "click>=8.1.7,<9", "pydantic>=2.11.9,<2.13", "pydantic-settings~=2.10.1", diff --git a/lib/cli/src/crewai_cli/__init__.py b/lib/cli/src/crewai_cli/__init__.py index ee738f982..378e531da 100644 --- a/lib/cli/src/crewai_cli/__init__.py +++ b/lib/cli/src/crewai_cli/__init__.py @@ -1 +1 @@ -__version__ = "1.14.6" +__version__ = "1.14.7a1" diff --git a/lib/cli/src/crewai_cli/templates/crew/pyproject.toml b/lib/cli/src/crewai_cli/templates/crew/pyproject.toml index d77df1461..eab69283b 100644 --- a/lib/cli/src/crewai_cli/templates/crew/pyproject.toml +++ b/lib/cli/src/crewai_cli/templates/crew/pyproject.toml @@ -5,7 +5,7 @@ description = "{{name}} using crewAI" authors = [{ name = "Your Name", email = "you@example.com" }] requires-python = ">=3.10,<3.14" dependencies = [ - "crewai[tools]==1.14.6" + "crewai[tools]==1.14.7a1" ] [project.scripts] diff --git a/lib/cli/src/crewai_cli/templates/flow/pyproject.toml b/lib/cli/src/crewai_cli/templates/flow/pyproject.toml index 1c127ccf1..88a0691d8 100644 --- a/lib/cli/src/crewai_cli/templates/flow/pyproject.toml +++ b/lib/cli/src/crewai_cli/templates/flow/pyproject.toml @@ -5,7 +5,7 @@ description = "{{name}} using crewAI" authors = [{ name = "Your Name", email = "you@example.com" }] requires-python = ">=3.10,<3.14" dependencies = [ - "crewai[tools]==1.14.6" + "crewai[tools]==1.14.7a1" ] [project.scripts] diff --git a/lib/cli/src/crewai_cli/templates/tool/pyproject.toml b/lib/cli/src/crewai_cli/templates/tool/pyproject.toml index 05b82ad43..4af8825ef 100644 --- a/lib/cli/src/crewai_cli/templates/tool/pyproject.toml +++ b/lib/cli/src/crewai_cli/templates/tool/pyproject.toml @@ -5,7 +5,7 @@ description = "Power up your crews with {{folder_name}}" readme = "README.md" requires-python = ">=3.10,<3.14" dependencies = [ - "crewai[tools]==1.14.6" + "crewai[tools]==1.14.7a1" ] [tool.crewai] diff --git a/lib/crewai-core/src/crewai_core/__init__.py b/lib/crewai-core/src/crewai_core/__init__.py index ee738f982..378e531da 100644 --- a/lib/crewai-core/src/crewai_core/__init__.py +++ b/lib/crewai-core/src/crewai_core/__init__.py @@ -1 +1 @@ -__version__ = "1.14.6" +__version__ = "1.14.7a1" diff --git a/lib/crewai-files/src/crewai_files/__init__.py b/lib/crewai-files/src/crewai_files/__init__.py index 96f55b568..d9048a33a 100644 --- a/lib/crewai-files/src/crewai_files/__init__.py +++ b/lib/crewai-files/src/crewai_files/__init__.py @@ -152,4 +152,4 @@ __all__ = [ "wrap_file_source", ] -__version__ = "1.14.6" +__version__ = "1.14.7a1" diff --git a/lib/crewai-tools/pyproject.toml b/lib/crewai-tools/pyproject.toml index 6a79c49e4..543c0ff7d 100644 --- a/lib/crewai-tools/pyproject.toml +++ b/lib/crewai-tools/pyproject.toml @@ -10,7 +10,7 @@ requires-python = ">=3.10, <3.14" dependencies = [ "pytube~=15.0.0", "requests>=2.33.0,<3", - "crewai==1.14.6", + "crewai==1.14.7a1", "tiktoken>=0.8.0,<0.13", "beautifulsoup4~=4.13.4", "python-docx~=1.2.0", diff --git a/lib/crewai-tools/src/crewai_tools/__init__.py b/lib/crewai-tools/src/crewai_tools/__init__.py index da788f8e1..4a7dc7103 100644 --- a/lib/crewai-tools/src/crewai_tools/__init__.py +++ b/lib/crewai-tools/src/crewai_tools/__init__.py @@ -330,4 +330,4 @@ __all__ = [ "ZapierActionTools", ] -__version__ = "1.14.6" +__version__ = "1.14.7a1" diff --git a/lib/crewai/pyproject.toml b/lib/crewai/pyproject.toml index 45afa06c5..8c7282e29 100644 --- a/lib/crewai/pyproject.toml +++ b/lib/crewai/pyproject.toml @@ -8,8 +8,8 @@ authors = [ ] requires-python = ">=3.10, <3.14" dependencies = [ - "crewai-core==1.14.6", - "crewai-cli==1.14.6", + "crewai-core==1.14.7a1", + "crewai-cli==1.14.7a1", # Core Dependencies "pydantic>=2.11.9,<2.13", "openai>=2.30.0,<3", @@ -54,7 +54,7 @@ Repository = "https://github.com/crewAIInc/crewAI" [project.optional-dependencies] tools = [ - "crewai-tools==1.14.6", + "crewai-tools==1.14.7a1", ] embeddings = [ "tiktoken>=0.8.0,<0.13" diff --git a/lib/crewai/src/crewai/__init__.py b/lib/crewai/src/crewai/__init__.py index 5a65854e3..241b250de 100644 --- a/lib/crewai/src/crewai/__init__.py +++ b/lib/crewai/src/crewai/__init__.py @@ -48,7 +48,7 @@ def _suppress_pydantic_deprecation_warnings() -> None: _suppress_pydantic_deprecation_warnings() -__version__ = "1.14.6" +__version__ = "1.14.7a1" _LAZY_IMPORTS: dict[str, tuple[str, str]] = { "Memory": ("crewai.memory.unified_memory", "Memory"), diff --git a/lib/devtools/src/crewai_devtools/__init__.py b/lib/devtools/src/crewai_devtools/__init__.py index 9a799dbe8..e60677184 100644 --- a/lib/devtools/src/crewai_devtools/__init__.py +++ b/lib/devtools/src/crewai_devtools/__init__.py @@ -1,3 +1,3 @@ """CrewAI development tools.""" -__version__ = "1.14.6" +__version__ = "1.14.7a1" From ea88904d353d406bdac768df098b4931b820292b Mon Sep 17 00:00:00 2001 From: Lorenze Jay <63378463+lorenzejay@users.noreply.github.com> Date: Wed, 3 Jun 2026 10:40:43 -0700 Subject: [PATCH 04/18] docs: update changelog and version for v1.14.7a1 (#6032) --- docs/ar/changelog.mdx | 32 ++++++++++++++++++++++++++++++++ docs/en/changelog.mdx | 32 ++++++++++++++++++++++++++++++++ docs/ko/changelog.mdx | 32 ++++++++++++++++++++++++++++++++ docs/pt-BR/changelog.mdx | 32 ++++++++++++++++++++++++++++++++ 4 files changed, 128 insertions(+) diff --git a/docs/ar/changelog.mdx b/docs/ar/changelog.mdx index 1e279bf8a..67a17eba4 100644 --- a/docs/ar/changelog.mdx +++ b/docs/ar/changelog.mdx @@ -4,6 +4,38 @@ description: "تحديثات المنتج والتحسينات وإصلاحات icon: "clock" mode: "wide" --- + + ## v1.14.7a1 + + [عرض الإصدار على GitHub](https://github.com/crewAIInc/crewAI/releases/tag/1.14.7a1) + + ## ما الذي تغير + + ### الميزات + - إضافة دعم ملفات الوكلاء المدربين + - إضافة مزود LLM الأصلي لـ Snowflake Cortex + - إضافة دليل تكامل Databricks + - إضافة دليل تكامل Snowflake + + ### إصلاحات الأخطاء + - إصلاح CLI عن طريق استعادة `[project.scripts]` في حزمة crewai لتثبيت أداة UV + - حل مشكلات موثوقية إدخال الملفات + - إصلاح تاريخ نتائج الأدوات غير المكتملة في Snowflake Claude + - التعامل مع استدعاءات الأدوات الممثلة كسلاسل لـ Snowflake Claude + - إعادة تفعيل مستمعي `or_` متعدد المصادر عبر دورات مدفوعة بالموجه + + ### الأداء + - تحسين سرعة استيراد crewai عن طريق تحميل استيرادات docling بشكل كسول + + ### إعادة هيكلة + - تقسيم `flow.py` إلى DSL، تعريف، وتشغيل + + ## المساهمون + + @Luzk, @alex-clawd, @devin-ai-integration[bot], @greysonlalonde, @jessemiller, @lorenzejay, @vinibrsl + + + ## v1.14.6 diff --git a/docs/en/changelog.mdx b/docs/en/changelog.mdx index 7ff868fc7..3077979fe 100644 --- a/docs/en/changelog.mdx +++ b/docs/en/changelog.mdx @@ -4,6 +4,38 @@ description: "Product updates, improvements, and bug fixes for CrewAI" icon: "clock" mode: "wide" --- + + ## v1.14.7a1 + + [View release on GitHub](https://github.com/crewAIInc/crewAI/releases/tag/1.14.7a1) + + ## What's Changed + + ### Features + - Add crew trained agents file support + - Add native Snowflake Cortex LLM provider + - Add Databricks integration guide + - Add Snowflake integration guide + + ### Bug Fixes + - Fix CLI by restoring `[project.scripts]` in crewai package for UV tool install + - Resolve file input reliability issues + - Fix incomplete tool result histories in Snowflake Claude + - Handle stringified tool calls for Snowflake Claude + - Re-arm multi-source `or_` listeners across router-driven cycles + + ### Performance + - Improve crewai import speed by lazy-loading docling imports + + ### Refactoring + - Split `flow.py` into DSL, definition, and runtime + + ## Contributors + + @Luzk, @alex-clawd, @devin-ai-integration[bot], @greysonlalonde, @jessemiller, @lorenzejay, @vinibrsl + + + ## v1.14.6 diff --git a/docs/ko/changelog.mdx b/docs/ko/changelog.mdx index 88176e2f1..74408caf3 100644 --- a/docs/ko/changelog.mdx +++ b/docs/ko/changelog.mdx @@ -4,6 +4,38 @@ description: "CrewAI의 제품 업데이트, 개선 사항 및 버그 수정" icon: "clock" mode: "wide" --- + + ## v1.14.7a1 + + [GitHub 릴리스 보기](https://github.com/crewAIInc/crewAI/releases/tag/1.14.7a1) + + ## 변경 사항 + + ### 기능 + - 승무원 교육을 받은 에이전트 파일 지원 추가 + - 네이티브 Snowflake Cortex LLM 공급자 추가 + - Databricks 통합 가이드 추가 + - Snowflake 통합 가이드 추가 + + ### 버그 수정 + - UV 도구 설치를 위한 crewai 패키지에서 `[project.scripts]` 복원하여 CLI 수정 + - 파일 입력 신뢰성 문제 해결 + - Snowflake Claude에서 불완전한 도구 결과 기록 수정 + - Snowflake Claude를 위한 문자열화된 도구 호출 처리 + - 라우터 주도 사이클 전반에 걸쳐 다중 소스 `or_` 리스너 재장착 + + ### 성능 + - docling 가져오기를 지연 로딩하여 crewai 가져오기 속도 개선 + + ### 리팩토링 + - `flow.py`를 DSL, 정의 및 런타임으로 분할 + + ## 기여자 + + @Luzk, @alex-clawd, @devin-ai-integration[bot], @greysonlalonde, @jessemiller, @lorenzejay, @vinibrsl + + + ## v1.14.6 diff --git a/docs/pt-BR/changelog.mdx b/docs/pt-BR/changelog.mdx index bebfd517a..349529138 100644 --- a/docs/pt-BR/changelog.mdx +++ b/docs/pt-BR/changelog.mdx @@ -4,6 +4,38 @@ description: "Atualizações de produto, melhorias e correções do CrewAI" icon: "clock" mode: "wide" --- + + ## v1.14.7a1 + + [Ver release no GitHub](https://github.com/crewAIInc/crewAI/releases/tag/1.14.7a1) + + ## O Que Mudou + + ### Funcionalidades + - Adicionar suporte a arquivos de agentes treinados da equipe + - Adicionar provedor nativo Snowflake Cortex LLM + - Adicionar guia de integração com Databricks + - Adicionar guia de integração com Snowflake + + ### Correções de Bugs + - Corrigir CLI restaurando `[project.scripts]` no pacote crewai para instalação da ferramenta UV + - Resolver problemas de confiabilidade na entrada de arquivos + - Corrigir históricos de resultados de ferramentas incompletos no Snowflake Claude + - Lidar com chamadas de ferramentas em formato de string para Snowflake Claude + - Re-armar ouvintes `or_` de múltiplas fontes em ciclos controlados por roteadores + + ### Desempenho + - Melhorar a velocidade de importação do crewai através do carregamento preguiçoso de importações do docling + + ### Refatoração + - Dividir `flow.py` em DSL, definição e tempo de execução + + ## Contributors + + @Luzk, @alex-clawd, @devin-ai-integration[bot], @greysonlalonde, @jessemiller, @lorenzejay, @vinibrsl + + + ## v1.14.6 From 1357491f0d801abf75850f541c2e5d0525b1ff99 Mon Sep 17 00:00:00 2001 From: Lorenze Jay <63378463+lorenzejay@users.noreply.github.com> Date: Wed, 3 Jun 2026 11:53:16 -0700 Subject: [PATCH 05/18] Lorenze/feat/conversational flows (#5896) * feat: add conversational flows documentation and chat session support - Introduced a new guide for building multi-turn chat applications using , detailing session management and message handling. - Added class to facilitate chat interactions, including streaming support and event handling. - Implemented for class-level defaults and improved input normalization for conversational turns. - Enhanced event listeners to manage flow events and tracing more effectively, including support for nested crew executions. - Added tests for conversational flow helpers and kickoff parameters to ensure functionality and reliability. * linted * feat: enhance flow event tracing and session management - Updated TraceCollectionListener to handle nested flows without re-claiming parent session batches. - Ensured that method execution events are always emitted for tracing, regardless of flow event suppression. - Improved finalization logic for flow trace batches to respect session deferral flags. - Added tests to verify that method execution events are emitted correctly when flow events are suppressed and that deferred session finalization is respected in nested flows. * updated docs * feat: introduce experimental conversational flow framework - Added a new module for conversational flow, including classes for managing conversation state, messages, and events. - Implemented and for structured intent handling and routing. - Enhanced the class to support turn-oriented conversational applications with built-in routing and message handling. - Updated to include new classes in the public API. - Added tests to validate the functionality of the new conversational flow features. * handled docs * feat(flow): enhance conversational flow handling and tracing - Introduced support for deferred multi-turn tracing to maintain continuous event sequences. - Updated method to delegate to restored checkpoint flows, improving session management. - Added tests to validate the new tracing behavior and ensure correct event handling in conversational flows. * fix multimodal test * better conversational * adjusted prompt * drop unused * fix test * refactor: rename to and update related documentation This commit refactors the class to for clarity and consistency across the codebase. The documentation has been updated to reflect this change, ensuring that references to the new class are accurate. Additionally, the alias for legacy imports is maintained for backward compatibility. The changes enhance the overall structure and readability of the conversational flow implementation. * fix test * adding experimetnal indicators * fix test and reloaded cassettes * cleanup ConversationalFlow class * addressing double finalization and fixed tests * improve on emphemeral tracing and adddressing comments --- docs/ar/guides/flows/conversational-flows.mdx | 451 ++++++ docs/ar/guides/flows/first-flow.mdx | 1 + docs/ar/guides/flows/mastering-flow-state.mdx | 2 + docs/docs.json | 56 + docs/en/guides/flows/conversational-flows.mdx | 454 ++++++ docs/en/guides/flows/first-flow.mdx | 1 + docs/en/guides/flows/mastering-flow-state.mdx | 2 + docs/ko/guides/flows/conversational-flows.mdx | 453 ++++++ docs/ko/guides/flows/first-flow.mdx | 1 + docs/ko/guides/flows/mastering-flow-state.mdx | 2 + .../guides/flows/conversational-flows.mdx | 454 ++++++ docs/pt-BR/guides/flows/first-flow.mdx | 1 + .../guides/flows/mastering-flow-state.mdx | 2 + .../src/crewai/events/event_listener.py | 22 +- .../tracing/first_time_trace_handler.py | 2 + .../listeners/tracing/trace_batch_manager.py | 123 +- .../listeners/tracing/trace_listener.py | 108 +- .../src/crewai/experimental/__init__.py | 103 +- .../src/crewai/experimental/conversational.py | 184 +++ .../experimental/conversational_mixin.py | 814 +++++++++++ lib/crewai/src/crewai/flow/__init__.py | 8 + lib/crewai/src/crewai/flow/conversation.py | 246 ++++ lib/crewai/src/crewai/flow/flow_context.py | 4 + lib/crewai/src/crewai/flow/flow_serializer.py | 10 + lib/crewai/src/crewai/flow/flow_wrappers.py | 1 + lib/crewai/src/crewai/flow/runtime.py | 333 ++++- lib/crewai/src/crewai/translations/en.json | 2 + ...t_trace_listener_collects_crew_events.yaml | 357 ++--- ...p.test_trace_listener_ephemeral_batch.yaml | 235 ++- ...race_listener_with_authenticated_user.yaml | 115 +- lib/crewai/tests/test_flow_conversation.py | 1289 +++++++++++++++++ lib/crewai/tests/tracing/test_tracing.py | 117 ++ 32 files changed, 5359 insertions(+), 594 deletions(-) create mode 100644 docs/ar/guides/flows/conversational-flows.mdx create mode 100644 docs/en/guides/flows/conversational-flows.mdx create mode 100644 docs/ko/guides/flows/conversational-flows.mdx create mode 100644 docs/pt-BR/guides/flows/conversational-flows.mdx create mode 100644 lib/crewai/src/crewai/experimental/conversational.py create mode 100644 lib/crewai/src/crewai/experimental/conversational_mixin.py create mode 100644 lib/crewai/src/crewai/flow/conversation.py create mode 100644 lib/crewai/tests/test_flow_conversation.py diff --git a/docs/ar/guides/flows/conversational-flows.mdx b/docs/ar/guides/flows/conversational-flows.mdx new file mode 100644 index 000000000..bfb45c90b --- /dev/null +++ b/docs/ar/guides/flows/conversational-flows.mdx @@ -0,0 +1,451 @@ +--- +title: تدفقات المحادثة +description: أنشئ تطبيقات دردشة متعددة الجولات مع kickoff لكل جولة وسجل الرسائل وتوجيه النية والتتبع وجسور WebSocket. +icon: comments +mode: "wide" +--- + +## نظرة عامة + +تعامل التطبيقات المحادثية مع كل سطر من المستخدم كـ **تشغيل flow جديد** بنفس **معرّف الجلسة**. توفر CrewAI مساعدات لسجل الرسائل وتصنيف النية الاختياري وتأجيل التتبع وجسور الواجهة — دون API منفصل `chat()` على `Flow`. + +| المفهوم | التنفيذ | +|---------|---------| +| معرّف الجلسة | `kickoff(session_id=...)` → `inputs["id"]` → `state.id` | +| سطر المستخدم | `kickoff(user_message=...)` يُضاف إلى `state.messages` قبل تشغيل الرسم | +| اكتمال الجولة | `FlowFinished` لهذا **التشغيل** فقط؛ تستمر المحادثة في `kickoff` التالي | +| تتبع الجلسة | `ConversationalConfig(defer_trace_finalization=True)` + `finalize_session_traces()` | + +## نقطة دخول واحدة: `kickoff` + +استخدم **`flow.kickoff(user_message=..., session_id=...)`** لكل رسالة مستخدم (REST أو WebSocket أو CLI). لا تنشئ غلاف `chat()` مخصصاً على `Flow`. + +| API | الاستخدام | +|-----|-----------| +| `kickoff(user_message=..., session_id=...)` | كل رسالة مستخدم | +| `kickoff_async(...)` | نفس المعاملات؛ دخول async أصلي | +| `ask()` | مطالبة حاجزة **داخل** خطوة واحدة | +| `@human_feedback` | الموافقة/الرفض على **مخرجات خطوة** — وليس السطر التالي | +| `ChatSession.handle_turn(...)` | طبقة نقل فوق `kickoff` | + +## بداية سريعة + +```python +from uuid import uuid4 + +from crewai.flow import ( + ChatState, + ConversationalConfig, + Flow, + listen, + or_, + persist, + router, + start, +) +from crewai.flow.persistence import SQLiteFlowPersistence + + +class SupportFlow(Flow[ChatState]): + conversational_config = ConversationalConfig( + default_intents=["order", "help", "goodbye"], + intent_llm="gpt-4o-mini", + defer_trace_finalization=True, + ) + + @start() + def bootstrap(self): + if not self.state.session_ready: + self.state.session_ready = True + return "ready" + + @router(bootstrap) + def route(self): + return self.state.last_intent or "help" + + @listen("order") + def handle_order(self): + reply = "طلبك في الطريق." + self.append_message("assistant", reply) + return reply + + @listen("help") + def handle_help(self): + reply = "كيف يمكنني المساعدة؟" + self.append_message("assistant", reply) + return reply + + @listen("goodbye") + def handle_goodbye(self): + reply = "وداعاً!" + self.append_message("assistant", reply) + return reply + + @persist(SQLiteFlowPersistence("support.db")) + @listen(or_(handle_order, handle_help, handle_goodbye)) + def finalize(self): + return self.state.model_dump() + + +session_id = str(uuid4()) +flow = SupportFlow() + +flow.kickoff(user_message="أين طلبي؟", session_id=session_id) +flow.kickoff(user_message="وماذا عن الإرجاع؟", session_id=session_id) +flow.finalize_session_traces() +``` + +## دورة حياة الجولة + +كل `kickoff` مع `user_message` يشغّل: + +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_message("assistant", reply)`** في المعالجات. سطر المستخدم محفوظ عند kickoff — لا تُضفه مرة أخرى. + +## `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` المحادثاتي (تجريبي) + + + **ميزة تجريبية.** سطح `Flow` المحادثاتي (`conversational = True`، + `handle_turn`، `ConversationConfig`، `RouterConfig`، + `ConversationState`، الرسم البياني المدمج والمساعدات) يقع تحت + `crewai.experimental` وقد يتغير شكله قبل التخرج. ثبّت إصدار CrewAI إذا + كنت تعتمد على سلوك محدد، وراقب changelog للتحديثات الكاسرة. الملاحظات + والمشاكل مرحب بها. + + +فعّل الرسم المحادثاتي بتعيين `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() +``` + +### `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` يُلحقها نيابةً عنك. + +يمكنك أيضاً استدعاء `flow.kickoff(user_message=..., session_id=...)` مباشرةً — نفس منطق الإعادة والتشغيل يعمل. `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 +try: + while True: + line = input("You: ").strip() + if not line: + break + flow.kickoff(user_message=line, session_id=session_id) +finally: + flow.finalize_session_traces() +``` + +`ChatSession.close()` يستدعي `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 diff --git a/docs/ar/guides/flows/first-flow.mdx b/docs/ar/guides/flows/first-flow.mdx index d4ae8f898..ffeb30c2d 100644 --- a/docs/ar/guides/flows/first-flow.mdx +++ b/docs/ar/guides/flows/first-flow.mdx @@ -272,6 +272,7 @@ crewai flow plot 3. استكشف دوال `and_` و`or_` لتنفيذ متوازٍ أكثر تعقيدًا 4. اربط Flow بواجهات API خارجية وقواعد بيانات وواجهات مستخدم 5. ادمج عدة Crews متخصصة في Flow واحد +6. أنشئ تطبيقات دردشة متعددة الجولات مع [تدفقات المحادثة](/ar/guides/flows/conversational-flows) (`kickoff` لكل رسالة، `ChatSession`، تأجيل التتبع) تهانينا! لقد بنيت بنجاح أول CrewAI Flow يجمع بين الكود العادي واستدعاءات LLM المباشرة ومعالجة Crew لإنشاء دليل شامل. هذه المهارات الأساسية تمكّنك من إنشاء تطبيقات AI متطورة بشكل متزايد. diff --git a/docs/ar/guides/flows/mastering-flow-state.mdx b/docs/ar/guides/flows/mastering-flow-state.mdx index 09e56c3df..2311a4462 100644 --- a/docs/ar/guides/flows/mastering-flow-state.mdx +++ b/docs/ar/guides/flows/mastering-flow-state.mdx @@ -20,6 +20,8 @@ mode: "wide" 5. **توسيع تطبيقاتك** - دعم سير العمل المعقدة بتنظيم بيانات مناسب 6. **تمكين التطبيقات الحوارية** - تخزين والوصول إلى سجل المحادثات للتفاعلات الواعية بالسياق +للدردشة متعددة الجولات (`kickoff` لكل سطر مستخدم، `ChatState`، توجيه النية، تأجيل التتبع، و`ChatSession`)، راجع [تدفقات المحادثة](/ar/guides/flows/conversational-flows). + ## أساسيات إدارة الحالة ### نهجان لإدارة الحالة diff --git a/docs/docs.json b/docs/docs.json index ccd578b69..e407a8226 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -115,6 +115,7 @@ "pages": [ "en/guides/flows/first-flow", "en/guides/flows/mastering-flow-state", + "en/guides/flows/conversational-flows", "en/guides/flows/inputs-id-deprecation" ] }, @@ -1153,6 +1154,7 @@ "pages": [ "en/guides/flows/first-flow", "en/guides/flows/mastering-flow-state", + "en/guides/flows/conversational-flows", "en/guides/flows/inputs-id-deprecation" ] }, @@ -1639,6 +1641,7 @@ "pages": [ "en/guides/flows/first-flow", "en/guides/flows/mastering-flow-state", + "en/guides/flows/conversational-flows", "en/guides/flows/inputs-id-deprecation" ] }, @@ -2124,6 +2127,7 @@ "pages": [ "en/guides/flows/first-flow", "en/guides/flows/mastering-flow-state", + "en/guides/flows/conversational-flows", "en/guides/flows/inputs-id-deprecation" ] }, @@ -2608,6 +2612,7 @@ "pages": [ "en/guides/flows/first-flow", "en/guides/flows/mastering-flow-state", + "en/guides/flows/conversational-flows", "en/guides/flows/inputs-id-deprecation" ] }, @@ -3103,6 +3108,7 @@ "pages": [ "en/guides/flows/first-flow", "en/guides/flows/mastering-flow-state", + "en/guides/flows/conversational-flows", "en/guides/flows/inputs-id-deprecation" ] }, @@ -3598,6 +3604,7 @@ "pages": [ "en/guides/flows/first-flow", "en/guides/flows/mastering-flow-state", + "en/guides/flows/conversational-flows", "en/guides/flows/inputs-id-deprecation" ] }, @@ -4093,6 +4100,7 @@ "pages": [ "en/guides/flows/first-flow", "en/guides/flows/mastering-flow-state", + "en/guides/flows/conversational-flows", "en/guides/flows/inputs-id-deprecation" ] }, @@ -4588,6 +4596,7 @@ "pages": [ "en/guides/flows/first-flow", "en/guides/flows/mastering-flow-state", + "en/guides/flows/conversational-flows", "en/guides/flows/inputs-id-deprecation" ] }, @@ -5072,6 +5081,7 @@ "pages": [ "en/guides/flows/first-flow", "en/guides/flows/mastering-flow-state", + "en/guides/flows/conversational-flows", "en/guides/flows/inputs-id-deprecation" ] }, @@ -5556,6 +5566,7 @@ "pages": [ "en/guides/flows/first-flow", "en/guides/flows/mastering-flow-state", + "en/guides/flows/conversational-flows", "en/guides/flows/inputs-id-deprecation" ] }, @@ -6041,6 +6052,7 @@ "pages": [ "en/guides/flows/first-flow", "en/guides/flows/mastering-flow-state", + "en/guides/flows/conversational-flows", "en/guides/flows/inputs-id-deprecation" ] }, @@ -6527,6 +6539,7 @@ "pages": [ "en/guides/flows/first-flow", "en/guides/flows/mastering-flow-state", + "en/guides/flows/conversational-flows", "en/guides/flows/inputs-id-deprecation" ] }, @@ -7011,6 +7024,7 @@ "pages": [ "en/guides/flows/first-flow", "en/guides/flows/mastering-flow-state", + "en/guides/flows/conversational-flows", "en/guides/flows/inputs-id-deprecation" ] }, @@ -7528,6 +7542,7 @@ "pages": [ "pt-BR/guides/flows/first-flow", "pt-BR/guides/flows/mastering-flow-state", + "pt-BR/guides/flows/conversational-flows", "pt-BR/guides/flows/inputs-id-deprecation" ] }, @@ -8520,6 +8535,7 @@ "pages": [ "pt-BR/guides/flows/first-flow", "pt-BR/guides/flows/mastering-flow-state", + "pt-BR/guides/flows/conversational-flows", "pt-BR/guides/flows/inputs-id-deprecation" ] }, @@ -8983,6 +8999,7 @@ "pages": [ "pt-BR/guides/flows/first-flow", "pt-BR/guides/flows/mastering-flow-state", + "pt-BR/guides/flows/conversational-flows", "pt-BR/guides/flows/inputs-id-deprecation" ] }, @@ -9446,6 +9463,7 @@ "pages": [ "pt-BR/guides/flows/first-flow", "pt-BR/guides/flows/mastering-flow-state", + "pt-BR/guides/flows/conversational-flows", "pt-BR/guides/flows/inputs-id-deprecation" ] }, @@ -9908,6 +9926,7 @@ "pages": [ "pt-BR/guides/flows/first-flow", "pt-BR/guides/flows/mastering-flow-state", + "pt-BR/guides/flows/conversational-flows", "pt-BR/guides/flows/inputs-id-deprecation" ] }, @@ -10380,6 +10399,7 @@ "pages": [ "pt-BR/guides/flows/first-flow", "pt-BR/guides/flows/mastering-flow-state", + "pt-BR/guides/flows/conversational-flows", "pt-BR/guides/flows/inputs-id-deprecation" ] }, @@ -10852,6 +10872,7 @@ "pages": [ "pt-BR/guides/flows/first-flow", "pt-BR/guides/flows/mastering-flow-state", + "pt-BR/guides/flows/conversational-flows", "pt-BR/guides/flows/inputs-id-deprecation" ] }, @@ -11324,6 +11345,7 @@ "pages": [ "pt-BR/guides/flows/first-flow", "pt-BR/guides/flows/mastering-flow-state", + "pt-BR/guides/flows/conversational-flows", "pt-BR/guides/flows/inputs-id-deprecation" ] }, @@ -11796,6 +11818,7 @@ "pages": [ "pt-BR/guides/flows/first-flow", "pt-BR/guides/flows/mastering-flow-state", + "pt-BR/guides/flows/conversational-flows", "pt-BR/guides/flows/inputs-id-deprecation" ] }, @@ -12258,6 +12281,7 @@ "pages": [ "pt-BR/guides/flows/first-flow", "pt-BR/guides/flows/mastering-flow-state", + "pt-BR/guides/flows/conversational-flows", "pt-BR/guides/flows/inputs-id-deprecation" ] }, @@ -12720,6 +12744,7 @@ "pages": [ "pt-BR/guides/flows/first-flow", "pt-BR/guides/flows/mastering-flow-state", + "pt-BR/guides/flows/conversational-flows", "pt-BR/guides/flows/inputs-id-deprecation" ] }, @@ -13182,6 +13207,7 @@ "pages": [ "pt-BR/guides/flows/first-flow", "pt-BR/guides/flows/mastering-flow-state", + "pt-BR/guides/flows/conversational-flows", "pt-BR/guides/flows/inputs-id-deprecation" ] }, @@ -13643,6 +13669,7 @@ "pages": [ "pt-BR/guides/flows/first-flow", "pt-BR/guides/flows/mastering-flow-state", + "pt-BR/guides/flows/conversational-flows", "pt-BR/guides/flows/inputs-id-deprecation" ] }, @@ -14104,6 +14131,7 @@ "pages": [ "pt-BR/guides/flows/first-flow", "pt-BR/guides/flows/mastering-flow-state", + "pt-BR/guides/flows/conversational-flows", "pt-BR/guides/flows/inputs-id-deprecation" ] }, @@ -14596,6 +14624,7 @@ "pages": [ "ko/guides/flows/first-flow", "ko/guides/flows/mastering-flow-state", + "ko/guides/flows/conversational-flows", "ko/guides/flows/inputs-id-deprecation" ] }, @@ -15612,6 +15641,7 @@ "pages": [ "ko/guides/flows/first-flow", "ko/guides/flows/mastering-flow-state", + "ko/guides/flows/conversational-flows", "ko/guides/flows/inputs-id-deprecation" ] }, @@ -16087,6 +16117,7 @@ "pages": [ "ko/guides/flows/first-flow", "ko/guides/flows/mastering-flow-state", + "ko/guides/flows/conversational-flows", "ko/guides/flows/inputs-id-deprecation" ] }, @@ -16562,6 +16593,7 @@ "pages": [ "ko/guides/flows/first-flow", "ko/guides/flows/mastering-flow-state", + "ko/guides/flows/conversational-flows", "ko/guides/flows/inputs-id-deprecation" ] }, @@ -17037,6 +17069,7 @@ "pages": [ "ko/guides/flows/first-flow", "ko/guides/flows/mastering-flow-state", + "ko/guides/flows/conversational-flows", "ko/guides/flows/inputs-id-deprecation" ] }, @@ -17522,6 +17555,7 @@ "pages": [ "ko/guides/flows/first-flow", "ko/guides/flows/mastering-flow-state", + "ko/guides/flows/conversational-flows", "ko/guides/flows/inputs-id-deprecation" ] }, @@ -18007,6 +18041,7 @@ "pages": [ "ko/guides/flows/first-flow", "ko/guides/flows/mastering-flow-state", + "ko/guides/flows/conversational-flows", "ko/guides/flows/inputs-id-deprecation" ] }, @@ -18492,6 +18527,7 @@ "pages": [ "ko/guides/flows/first-flow", "ko/guides/flows/mastering-flow-state", + "ko/guides/flows/conversational-flows", "ko/guides/flows/inputs-id-deprecation" ] }, @@ -18977,6 +19013,7 @@ "pages": [ "ko/guides/flows/first-flow", "ko/guides/flows/mastering-flow-state", + "ko/guides/flows/conversational-flows", "ko/guides/flows/inputs-id-deprecation" ] }, @@ -19452,6 +19489,7 @@ "pages": [ "ko/guides/flows/first-flow", "ko/guides/flows/mastering-flow-state", + "ko/guides/flows/conversational-flows", "ko/guides/flows/inputs-id-deprecation" ] }, @@ -19927,6 +19965,7 @@ "pages": [ "ko/guides/flows/first-flow", "ko/guides/flows/mastering-flow-state", + "ko/guides/flows/conversational-flows", "ko/guides/flows/inputs-id-deprecation" ] }, @@ -20402,6 +20441,7 @@ "pages": [ "ko/guides/flows/first-flow", "ko/guides/flows/mastering-flow-state", + "ko/guides/flows/conversational-flows", "ko/guides/flows/inputs-id-deprecation" ] }, @@ -20876,6 +20916,7 @@ "pages": [ "ko/guides/flows/first-flow", "ko/guides/flows/mastering-flow-state", + "ko/guides/flows/conversational-flows", "ko/guides/flows/inputs-id-deprecation" ] }, @@ -21350,6 +21391,7 @@ "pages": [ "ko/guides/flows/first-flow", "ko/guides/flows/mastering-flow-state", + "ko/guides/flows/conversational-flows", "ko/guides/flows/inputs-id-deprecation" ] }, @@ -21855,6 +21897,7 @@ "pages": [ "ar/guides/flows/first-flow", "ar/guides/flows/mastering-flow-state", + "ar/guides/flows/conversational-flows", "ar/guides/flows/inputs-id-deprecation" ] }, @@ -22871,6 +22914,7 @@ "pages": [ "ar/guides/flows/first-flow", "ar/guides/flows/mastering-flow-state", + "ar/guides/flows/conversational-flows", "ar/guides/flows/inputs-id-deprecation" ] }, @@ -23346,6 +23390,7 @@ "pages": [ "ar/guides/flows/first-flow", "ar/guides/flows/mastering-flow-state", + "ar/guides/flows/conversational-flows", "ar/guides/flows/inputs-id-deprecation" ] }, @@ -23821,6 +23866,7 @@ "pages": [ "ar/guides/flows/first-flow", "ar/guides/flows/mastering-flow-state", + "ar/guides/flows/conversational-flows", "ar/guides/flows/inputs-id-deprecation" ] }, @@ -24296,6 +24342,7 @@ "pages": [ "ar/guides/flows/first-flow", "ar/guides/flows/mastering-flow-state", + "ar/guides/flows/conversational-flows", "ar/guides/flows/inputs-id-deprecation" ] }, @@ -24781,6 +24828,7 @@ "pages": [ "ar/guides/flows/first-flow", "ar/guides/flows/mastering-flow-state", + "ar/guides/flows/conversational-flows", "ar/guides/flows/inputs-id-deprecation" ] }, @@ -25266,6 +25314,7 @@ "pages": [ "ar/guides/flows/first-flow", "ar/guides/flows/mastering-flow-state", + "ar/guides/flows/conversational-flows", "ar/guides/flows/inputs-id-deprecation" ] }, @@ -25751,6 +25800,7 @@ "pages": [ "ar/guides/flows/first-flow", "ar/guides/flows/mastering-flow-state", + "ar/guides/flows/conversational-flows", "ar/guides/flows/inputs-id-deprecation" ] }, @@ -26236,6 +26286,7 @@ "pages": [ "ar/guides/flows/first-flow", "ar/guides/flows/mastering-flow-state", + "ar/guides/flows/conversational-flows", "ar/guides/flows/inputs-id-deprecation" ] }, @@ -26711,6 +26762,7 @@ "pages": [ "ar/guides/flows/first-flow", "ar/guides/flows/mastering-flow-state", + "ar/guides/flows/conversational-flows", "ar/guides/flows/inputs-id-deprecation" ] }, @@ -27186,6 +27238,7 @@ "pages": [ "ar/guides/flows/first-flow", "ar/guides/flows/mastering-flow-state", + "ar/guides/flows/conversational-flows", "ar/guides/flows/inputs-id-deprecation" ] }, @@ -27661,6 +27714,7 @@ "pages": [ "ar/guides/flows/first-flow", "ar/guides/flows/mastering-flow-state", + "ar/guides/flows/conversational-flows", "ar/guides/flows/inputs-id-deprecation" ] }, @@ -28135,6 +28189,7 @@ "pages": [ "ar/guides/flows/first-flow", "ar/guides/flows/mastering-flow-state", + "ar/guides/flows/conversational-flows", "ar/guides/flows/inputs-id-deprecation" ] }, @@ -28609,6 +28664,7 @@ "pages": [ "ar/guides/flows/first-flow", "ar/guides/flows/mastering-flow-state", + "ar/guides/flows/conversational-flows", "ar/guides/flows/inputs-id-deprecation" ] }, diff --git a/docs/en/guides/flows/conversational-flows.mdx b/docs/en/guides/flows/conversational-flows.mdx new file mode 100644 index 000000000..832574095 --- /dev/null +++ b/docs/en/guides/flows/conversational-flows.mdx @@ -0,0 +1,454 @@ +--- +title: Conversational Flows +description: Build multi-turn chat apps with kickoff 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 classification, deferred tracing, and UI bridges — without a separate `chat()` API on `Flow`. + +| Concept | Implementation | +|---------|----------------| +| Session id | `kickoff(session_id=...)` → `inputs["id"]` → `state.id` | +| User line | `kickoff(user_message=...)` appends to `state.messages` before the graph runs | +| Turn complete | `FlowFinished` for **this run** only; chat continues on the next `kickoff` | +| Full-session trace | `ConversationalConfig(defer_trace_finalization=True)` + `finalize_session_traces()` | + +## One entry point: `kickoff` + +Use **`flow.kickoff(user_message=..., session_id=...)`** for every user message (REST, WebSocket, CLI). Do not add a custom `chat()` wrapper on `Flow`. + +| API | Use for | +|-----|---------| +| `kickoff(user_message=..., session_id=...)` | Each user message | +| `kickoff_async(...)` | Same parameters; native async entry | +| `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 `kickoff` (SSE / WebSocket) | + +## Quick start + +```python +from uuid import uuid4 + +from crewai.flow import ( + ChatState, + ConversationalConfig, + Flow, + listen, + or_, + persist, + router, + start, +) +from crewai.flow.persistence import SQLiteFlowPersistence + + +class SupportFlow(Flow[ChatState]): + conversational_config = ConversationalConfig( + default_intents=["order", "help", "goodbye"], + intent_llm="gpt-4o-mini", + defer_trace_finalization=True, + ) + + @start() + def bootstrap(self): + if not self.state.session_ready: + self.state.session_ready = True + return "ready" + + @router(bootstrap) + def route(self): + # last_intent set in prepare_conversational_turn when default_intents is set + return self.state.last_intent or "help" + + @listen("order") + def handle_order(self): + reply = "Your order is on the way." + self.append_message("assistant", reply) + return reply + + @listen("help") + def handle_help(self): + reply = "How can I help?" + self.append_message("assistant", reply) + return reply + + @listen("goodbye") + def handle_goodbye(self): + reply = "Goodbye!" + self.append_message("assistant", reply) + return reply + + @persist(SQLiteFlowPersistence("support.db")) + @listen(or_(handle_order, handle_help, handle_goodbye)) + def finalize(self): + return self.state.model_dump() + + +session_id = str(uuid4()) +flow = SupportFlow() + +flow.kickoff(user_message="Where is my order?", session_id=session_id) +flow.kickoff(user_message="What about returns?", session_id=session_id) +flow.finalize_session_traces() # one trace link for the whole chat +``` + +## Turn lifecycle + +Each `kickoff` with `user_message` runs this pipeline: + +1. **`_configure_conversational_kickoff`** — merges `session_id` / `user_message` into `inputs`, applies `ConversationalConfig`, enables deferred tracing when configured. +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. **`prepare_conversational_turn`** — appends the user message to `state.messages`, sets `last_user_message`, clears `last_intent`, optionally classifies when `intents` / `default_intents` + `intent_llm` are set. +5. **Graph execution** — `@start` → `@router` → `@listen` handlers. +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_message("assistant", reply)`** so the next turn’s `conversation_messages` includes assistant text. The user line is already stored at kickoff — do not append it again in handlers. + +## `ConversationalConfig` (class-level defaults) + +Set on your `Flow` subclass as `conversational_config: ClassVar[ConversationalConfig | None]`. + +| Field | Default | Purpose | +|-------|---------|---------| +| `default_intents` | `None` | Outcome labels for automatic pre-kickoff classification | +| `intent_llm` | `None` | Model for classification (required when intents are used) | +| `interactive_prompt` | `"You: "` | Prompt for `kickoff(interactive=True)` | +| `interactive_timeout` | `None` | Per-line timeout in interactive mode | +| `exit_commands` | `exit`, `quit` | Words that end interactive mode | +| `defer_trace_finalization` | `True` | Keep one trace batch open across turns | + +Override per kickoff with `intents=` and `intent_llm=` keyword arguments. + +## `ChatState` (recommended persisted shape) + +```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 `session_id` / `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 + +### `kickoff` / `kickoff_async` parameters + +| Parameter | Purpose | +|-----------|---------| +| `user_message` | This turn’s text (or `{"role": "user", "content": "..."}`) | +| `session_id` | Conversation UUID → `inputs["id"]` / `state.id` | +| `intents` | Outcome labels for pre-kickoff `classify_intent` | +| `intent_llm` | LLM for classification (required with `intents`) | +| `interactive` | CLI loop via `ask()` (local demos only) | +| `interactive_prompt` | Override prompt in interactive mode | +| `interactive_timeout` | Per-line `ask()` timeout | +| `exit_commands` | Words that end interactive mode | +| `inputs` | Additional state fields (merged with conversational keys) | +| `restore_from_state_id` | Fork hydration from another persisted flow | + +### Instance attributes + +| Attribute | Purpose | +|-----------|---------| +| `conversational_config` | Class-level `ConversationalConfig` defaults | +| `defer_trace_finalization` | Instance flag; set automatically from config on kickoff | +| `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_message(role, content, **extra)` | Append to `state.messages` (roles: `user`, `assistant`, `system`, `tool`) | +| `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=...)` | Turn hydration (usually called by kickoff) | +| `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 `ConversationalConfig` (simplest) + +Set `default_intents` and `intent_llm`. Each kickoff runs classification before your `@router`; read `self.state.last_intent` in `route()`. + +### B. Classify inside `@router` (richer prompts) + +Set `default_intents=None` so kickoff only appends the user message. In `route()`, call `classify_intent` with a custom prompt or descriptions: + +```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")`** (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 `kickoff` 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) + + + **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. + + +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`, drives the 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 an LLM-driven 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 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(), # routes + descriptions auto-discovered from @listen handlers +) +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("What can you do?") # routes to converse (built-in) + flow.handle_turn("Search the web for AI news.") # routes to INTERNET_SEARCH + flow.handle_turn("Summarize the first result.") # routes back to converse +finally: + flow.finalize_session_traces() +``` + +### `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 +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 +@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. + +You can also call `flow.kickoff(user_message=..., session_id=...)` directly — the same reset/run logic fires. `handle_turn` is the ergonomic wrapper. + +### Custom router behavior + +To run side effects (event bus setup, telemetry) on every routing decision, override `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) +``` + +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 `ConversationalConfig`): + +- **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 +try: + while True: + line = input("You: ").strip() + if not line: + break + flow.kickoff(user_message=line, session_id=session_id) +finally: + flow.finalize_session_traces() +``` + +`ChatSession.close()` calls `finalize_session_traces()` when deferral is enabled. + +`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 diff --git a/docs/en/guides/flows/first-flow.mdx b/docs/en/guides/flows/first-flow.mdx index a5b8c347c..f536976d2 100644 --- a/docs/en/guides/flows/first-flow.mdx +++ b/docs/en/guides/flows/first-flow.mdx @@ -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) 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. diff --git a/docs/en/guides/flows/mastering-flow-state.mdx b/docs/en/guides/flows/mastering-flow-state.mdx index 68a821246..648a82dbd 100644 --- a/docs/en/guides/flows/mastering-flow-state.mdx +++ b/docs/en/guides/flows/mastering-flow-state.mdx @@ -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 diff --git a/docs/ko/guides/flows/conversational-flows.mdx b/docs/ko/guides/flows/conversational-flows.mdx new file mode 100644 index 000000000..677a014a5 --- /dev/null +++ b/docs/ko/guides/flows/conversational-flows.mdx @@ -0,0 +1,453 @@ +--- +title: 대화형 Flow +description: 턴마다 kickoff, 메시지 기록, 의도 라우팅, 트레이싱, WebSocket 브리지로 멀티턴 채팅 앱을 만듭니다. +icon: comments +mode: "wide" +--- + +## 개요 + +대화형 앱은 각 사용자 입력을 **동일한 세션 id**로 **새 flow 실행**으로 처리합니다. CrewAI는 메시지 기록, 선택적 의도 분류, 지연 트레이싱, UI 브리지를 제공하며, `Flow`에 별도 `chat()` API는 없습니다. + +| 개념 | 구현 | +|------|------| +| 세션 id | `kickoff(session_id=...)` → `inputs["id"]` → `state.id` | +| 사용자 입력 | `kickoff(user_message=...)`가 그래프 실행 전 `state.messages`에 추가 | +| 턴 완료 | `FlowFinished`는 **이번 실행**만 의미; 다음 `kickoff`로 대화 계속 | +| 세션 전체 트레이스 | `ConversationalConfig(defer_trace_finalization=True)` + `finalize_session_traces()` | + +## 단일 진입점: `kickoff` + +모든 사용자 메시지에 **`flow.kickoff(user_message=..., session_id=...)`**를 사용하세요 (REST, WebSocket, CLI). `Flow`에 커스텀 `chat()` 래퍼를 만들지 마세요. + +| API | 용도 | +|-----|------| +| `kickoff(user_message=..., session_id=...)` | 각 사용자 메시지 | +| `kickoff_async(...)` | 동일 파라미터; 네이티브 async 진입 | +| `ask()` | 한 스텝 **내부** 블로킹 프롬프트 (마법사, 확인) | +| `@human_feedback` | **스텝 출력** 승인/거부 — 다음 채팅 줄이 아님 | +| `ChatSession.handle_turn(...)` | `kickoff` 위의 전송 계층 (SSE / WebSocket) | + +## 빠른 시작 + +```python +from uuid import uuid4 + +from crewai.flow import ( + ChatState, + ConversationalConfig, + Flow, + listen, + or_, + persist, + router, + start, +) +from crewai.flow.persistence import SQLiteFlowPersistence + + +class SupportFlow(Flow[ChatState]): + conversational_config = ConversationalConfig( + default_intents=["order", "help", "goodbye"], + intent_llm="gpt-4o-mini", + defer_trace_finalization=True, + ) + + @start() + def bootstrap(self): + if not self.state.session_ready: + self.state.session_ready = True + return "ready" + + @router(bootstrap) + def route(self): + # default_intents 설정 시 prepare_conversational_turn에서 last_intent 설정 + return self.state.last_intent or "help" + + @listen("order") + def handle_order(self): + reply = "주문이 배송 중입니다." + self.append_message("assistant", reply) + return reply + + @listen("help") + def handle_help(self): + reply = "무엇을 도와드릴까요?" + self.append_message("assistant", reply) + return reply + + @listen("goodbye") + def handle_goodbye(self): + reply = "안녕히 가세요!" + self.append_message("assistant", reply) + return reply + + @persist(SQLiteFlowPersistence("support.db")) + @listen(or_(handle_order, handle_help, handle_goodbye)) + def finalize(self): + return self.state.model_dump() + + +session_id = str(uuid4()) +flow = SupportFlow() + +flow.kickoff(user_message="주문 어디까지 왔나요?", session_id=session_id) +flow.kickoff(user_message="반품은 어떻게 하나요?", session_id=session_id) +flow.finalize_session_traces() # 전체 대화에 대한 단일 trace 링크 +``` + +## 턴 생명주기 + +`user_message`가 있는 각 `kickoff`는 다음 파이프라인을 실행합니다: + +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_message("assistant", reply)`**를 호출해 다음 턴의 `conversation_messages`에 어시스턴트 응답이 포함되게 하세요. 사용자 입력은 kickoff 시 이미 저장됩니다 — 핸들러에서 다시 추가하지 마세요. + +## `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` (실험적) + + + **실험적 기능입니다.** 대화형 `Flow`의 API 표면(`conversational = True`, + `handle_turn`, `ConversationConfig`, `RouterConfig`, `ConversationState`, + 내장 그래프와 헬퍼)은 `crewai.experimental` 하위에 있으며 정식 출시 + 전까지 변경될 수 있습니다. 특정 동작에 의존한다면 CrewAI 버전을 고정하고 + 변경 사항이 있는지 changelog를 확인하세요. 피드백과 이슈 환영합니다. + + +`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() +``` + +### `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`이 대신 추가해 줍니다. + +`flow.kickoff(user_message=..., session_id=...)`를 직접 호출해도 동일한 reset/run 로직이 동작합니다. `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 +try: + while True: + line = input("You: ").strip() + if not line: + break + flow.kickoff(user_message=line, session_id=session_id) +finally: + flow.finalize_session_traces() +``` + +지연 활성화 시 `ChatSession.close()`가 `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` diff --git a/docs/ko/guides/flows/first-flow.mdx b/docs/ko/guides/flows/first-flow.mdx index 72ed9866a..8c222f69f 100644 --- a/docs/ko/guides/flows/first-flow.mdx +++ b/docs/ko/guides/flows/first-flow.mdx @@ -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`, 지연 트레이싱) 축하합니다! 정규 코드, 직접적인 LLM 호출, crew 기반 처리를 결합하여 포괄적인 가이드를 생성하는 첫 번째 CrewAI Flow를 성공적으로 구축하셨습니다. 이러한 기초적인 역량을 바탕으로 절차적 제어와 협업적 인텔리전스를 결합하여 복잡하고 다단계의 문제를 해결할 수 있는 점점 더 정교한 AI 애플리케이션을 만들 수 있습니다. diff --git a/docs/ko/guides/flows/mastering-flow-state.mdx b/docs/ko/guides/flows/mastering-flow-state.mdx index eafd24b29..5e7727cb1 100644 --- a/docs/ko/guides/flows/mastering-flow-state.mdx +++ b/docs/ko/guides/flows/mastering-flow-state.mdx @@ -22,6 +22,8 @@ State 관리는 모든 고급 AI 워크플로우의 중추입니다. CrewAI Flow 5. **애플리케이션 확장** - 적절한 데이터 조직을 통해 복잡한 워크플로를 지원할 수 있습니다. 6. **대화형 애플리케이션 활성화** - 컨텍스트 기반 AI 상호작용을 위해 대화 내역을 저장하고 접근할 수 있습니다. +멀티턴 채팅(`kickoff` per user line, `ChatState`, 의도 라우팅, 지연 트레이싱, `ChatSession`)은 [대화형 Flow](/ko/guides/flows/conversational-flows)를 참고하세요. + 이러한 기능을 효과적으로 활용하는 방법을 살펴보겠습니다. ## 상태 관리 기본 사항 diff --git a/docs/pt-BR/guides/flows/conversational-flows.mdx b/docs/pt-BR/guides/flows/conversational-flows.mdx new file mode 100644 index 000000000..755f282e5 --- /dev/null +++ b/docs/pt-BR/guides/flows/conversational-flows.mdx @@ -0,0 +1,454 @@ +--- +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 e pontes para UI — sem uma API `chat()` separada em `Flow`. + +| Conceito | Implementação | +|---------|----------------| +| Id de sessão | `kickoff(session_id=...)` → `inputs["id"]` → `state.id` | +| Linha do usuário | `kickoff(user_message=...)` acrescenta em `state.messages` antes do grafo rodar | +| Fim do turno | `FlowFinished` só para **esta execução**; o chat segue no próximo `kickoff` | +| Trace da sessão | `ConversationalConfig(defer_trace_finalization=True)` + `finalize_session_traces()` | + +## Um ponto de entrada: `kickoff` + +Use **`flow.kickoff(user_message=..., session_id=...)`** para cada mensagem (REST, WebSocket, CLI). Não crie um wrapper `chat()` customizado em `Flow`. + +| API | Uso | +|-----|-----| +| `kickoff(user_message=..., session_id=...)` | Cada mensagem do usuário | +| `kickoff_async(...)` | Mesmos parâmetros; entrada async nativa | +| `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 `kickoff` (SSE / WebSocket) | + +## Início rápido + +```python +from uuid import uuid4 + +from crewai.flow import ( + ChatState, + ConversationalConfig, + Flow, + listen, + or_, + persist, + router, + start, +) +from crewai.flow.persistence import SQLiteFlowPersistence + + +class SupportFlow(Flow[ChatState]): + conversational_config = ConversationalConfig( + default_intents=["order", "help", "goodbye"], + intent_llm="gpt-4o-mini", + defer_trace_finalization=True, + ) + + @start() + def bootstrap(self): + if not self.state.session_ready: + self.state.session_ready = True + return "ready" + + @router(bootstrap) + def route(self): + # last_intent definido em prepare_conversational_turn quando default_intents está setado + return self.state.last_intent or "help" + + @listen("order") + def handle_order(self): + reply = "Seu pedido está a caminho." + self.append_message("assistant", reply) + return reply + + @listen("help") + def handle_help(self): + reply = "Como posso ajudar?" + self.append_message("assistant", reply) + return reply + + @listen("goodbye") + def handle_goodbye(self): + reply = "Até logo!" + self.append_message("assistant", reply) + return reply + + @persist(SQLiteFlowPersistence("support.db")) + @listen(or_(handle_order, handle_help, handle_goodbye)) + def finalize(self): + return self.state.model_dump() + + +session_id = str(uuid4()) +flow = SupportFlow() + +flow.kickoff(user_message="Onde está meu pedido?", session_id=session_id) +flow.kickoff(user_message="E as devoluções?", session_id=session_id) +flow.finalize_session_traces() # um link de trace para o chat inteiro +``` + +## Ciclo de vida do turno + +Cada `kickoff` com `user_message` 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_message("assistant", reply)`** para que o próximo turno inclua a resposta do assistente. A linha do usuário já é salva no kickoff — 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) + + + **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. + + +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() +``` + +### `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ê. + +Você também pode chamar `flow.kickoff(user_message=..., session_id=...)` diretamente — a mesma lógica de reset/run é acionada. `handle_turn` é o wrapper ergonômico. + +### 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 +try: + while True: + line = input("You: ").strip() + if not line: + break + flow.kickoff(user_message=line, session_id=session_id) +finally: + flow.finalize_session_traces() +``` + +`ChatSession.close()` chama `finalize_session_traces()` quando o adiamento está habilitado. + +`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 diff --git a/docs/pt-BR/guides/flows/first-flow.mdx b/docs/pt-BR/guides/flows/first-flow.mdx index 07ed7ae93..cb2330a90 100644 --- a/docs/pt-BR/guides/flows/first-flow.mdx +++ b/docs/pt-BR/guides/flows/first-flow.mdx @@ -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) 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. diff --git a/docs/pt-BR/guides/flows/mastering-flow-state.mdx b/docs/pt-BR/guides/flows/mastering-flow-state.mdx index 6589b51ad..1d3e6ee42 100644 --- a/docs/pt-BR/guides/flows/mastering-flow-state.mdx +++ b/docs/pt-BR/guides/flows/mastering-flow-state.mdx @@ -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 diff --git a/lib/crewai/src/crewai/events/event_listener.py b/lib/crewai/src/crewai/events/event_listener.py index 107f85428..10028cb00 100644 --- a/lib/crewai/src/crewai/events/event_listener.py +++ b/lib/crewai/src/crewai/events/event_listener.py @@ -306,20 +306,24 @@ class EventListener(BaseEventListener): self._telemetry.flow_execution_span( event.flow_name, list(source._methods.keys()) ) - self.formatter.handle_flow_created(event.flow_name, str(source.flow_id)) - self.formatter.handle_flow_started(event.flow_name, str(source.flow_id)) + if not getattr(source, "suppress_flow_events", False): + self.formatter.handle_flow_created(event.flow_name, str(source.flow_id)) + self.formatter.handle_flow_started(event.flow_name, str(source.flow_id)) @crewai_event_bus.on(FlowFinishedEvent) def on_flow_finished(source: Any, event: FlowFinishedEvent) -> None: - self.formatter.handle_flow_status( - event.flow_name, - source.flow_id, - ) + if not getattr(source, "suppress_flow_events", False): + self.formatter.handle_flow_status( + event.flow_name, + source.flow_id, + ) @crewai_event_bus.on(MethodExecutionStartedEvent) def on_method_execution_started( - _: Any, event: MethodExecutionStartedEvent + source: Any, event: MethodExecutionStartedEvent ) -> None: + if getattr(source, "suppress_flow_events", False): + return self.formatter.handle_method_status( event.method_name, "running", @@ -327,8 +331,10 @@ class EventListener(BaseEventListener): @crewai_event_bus.on(MethodExecutionFinishedEvent) def on_method_execution_finished( - _: Any, event: MethodExecutionFinishedEvent + source: Any, event: MethodExecutionFinishedEvent ) -> None: + if getattr(source, "suppress_flow_events", False): + return self.formatter.handle_method_status( event.method_name, "completed", diff --git a/lib/crewai/src/crewai/events/listeners/tracing/first_time_trace_handler.py b/lib/crewai/src/crewai/events/listeners/tracing/first_time_trace_handler.py index 436d50c27..e6fb4b32e 100644 --- a/lib/crewai/src/crewai/events/listeners/tracing/first_time_trace_handler.py +++ b/lib/crewai/src/crewai/events/listeners/tracing/first_time_trace_handler.py @@ -222,6 +222,8 @@ To enable tracing later, do any one of these: return self.batch_manager.batch_owner_type = None self.batch_manager.batch_owner_id = None + self.batch_manager.defer_session_finalization = False + self.batch_manager._batch_finalized = False self.batch_manager.current_batch = None self.batch_manager.event_buffer.clear() self.batch_manager.trace_batch_id = None diff --git a/lib/crewai/src/crewai/events/listeners/tracing/trace_batch_manager.py b/lib/crewai/src/crewai/events/listeners/tracing/trace_batch_manager.py index a20234a77..4b22275c6 100644 --- a/lib/crewai/src/crewai/events/listeners/tracing/trace_batch_manager.py +++ b/lib/crewai/src/crewai/events/listeners/tracing/trace_batch_manager.py @@ -62,6 +62,7 @@ class TraceBatchManager: self._pending_events_lock = Lock() self._pending_events_cv = Condition(self._pending_events_lock) self._pending_events_count = 0 + self._finalize_lock = Lock() self.is_current_batch_ephemeral = False self.trace_batch_id: str | None = None @@ -70,6 +71,8 @@ class TraceBatchManager: self.execution_start_times: dict[str, datetime] = {} self.batch_owner_type: str | None = None self.batch_owner_id: str | None = None + self.defer_session_finalization: bool = False + self._batch_finalized: bool = False self.backend_initialized: bool = False self.ephemeral_trace_url: str | None = None try: @@ -101,6 +104,7 @@ class TraceBatchManager: user_context=user_context, execution_metadata=execution_metadata ) self.is_current_batch_ephemeral = use_ephemeral + self._batch_finalized = False self.record_start_time("execution") @@ -312,6 +316,9 @@ class TraceBatchManager: def finalize_batch(self) -> TraceBatch | None: """Finalize batch and return it for sending""" + if self._batch_finalized: + return None + if not self.current_batch or not is_tracing_enabled_in_context(): return None @@ -340,16 +347,15 @@ class TraceBatchManager: self.current_batch.events = sorted_events events_sent_count = len(sorted_events) if sorted_events: - original_buffer = self.event_buffer self.event_buffer = sorted_events events_sent_to_backend_status = self._send_events_to_backend() - self.event_buffer = original_buffer if events_sent_to_backend_status == 500 and self.trace_batch_id: self._mark_batch_as_failed( self.trace_batch_id, "Error sending events to backend" ) return None - self._finalize_backend_batch(events_sent_count) + if not self._finalize_backend_batch(events_sent_count): + return None finalized_batch = self.current_batch @@ -360,80 +366,87 @@ class TraceBatchManager: self.event_buffer.clear() self.trace_batch_id = None self.is_current_batch_ephemeral = False + self._batch_finalized = True self._cleanup_batch_data() return finalized_batch - def _finalize_backend_batch(self, events_count: int = 0) -> None: + def _finalize_backend_batch(self, events_count: int = 0) -> bool: """Send batch finalization to backend Args: events_count: Number of events that were successfully sent """ - if not self.plus_api or not self.trace_batch_id: - return + with self._finalize_lock: + batch_id = self.trace_batch_id + is_ephemeral = self.is_current_batch_ephemeral + if self._batch_finalized or not self.plus_api or not batch_id: + return True - try: - payload: TraceFinalizePayload = { - "status": "completed", - "duration_ms": self.calculate_duration("execution"), - "final_event_count": events_count, - } + try: + payload: TraceFinalizePayload = { + "status": "completed", + "duration_ms": self.calculate_duration("execution"), + "final_event_count": events_count, + } - response = ( - self.plus_api.finalize_ephemeral_trace_batch( - self.trace_batch_id, payload - ) - if self.is_current_batch_ephemeral - else self.plus_api.finalize_trace_batch(self.trace_batch_id, payload) - ) - - if response.status_code == 200: - access_code = response.json().get("access_code", None) - console = Console() - settings = Settings() - base_url = settings.enterprise_base_url or DEFAULT_CREWAI_ENTERPRISE_URL - return_link = ( - f"{base_url}/crewai_plus/trace_batches/{self.trace_batch_id}" - if not self.is_current_batch_ephemeral and access_code is None - else f"{base_url}/crewai_plus/ephemeral_trace_batches/{self.trace_batch_id}?access_code={access_code}" + response = ( + self.plus_api.finalize_ephemeral_trace_batch(batch_id, payload) + if is_ephemeral + else self.plus_api.finalize_trace_batch(batch_id, payload) ) - if self.is_current_batch_ephemeral: - self.ephemeral_trace_url = return_link + if response.status_code == 200: + self._batch_finalized = True + access_code = response.json().get("access_code", None) + console = Console() + settings = Settings() + base_url = ( + settings.enterprise_base_url or DEFAULT_CREWAI_ENTERPRISE_URL + ) + return_link = ( + f"{base_url}/crewai_plus/trace_batches/{batch_id}" + if not is_ephemeral and access_code is None + else f"{base_url}/crewai_plus/ephemeral_trace_batches/{batch_id}?access_code={access_code}" + ) - message_parts = [ - f"✅ Trace batch finalized with session ID: {self.trace_batch_id}", - "", - f"🔗 View here: {return_link}", - ] + if is_ephemeral: + self.ephemeral_trace_url = return_link - if access_code: - message_parts.append(f"🔑 Access Code: {access_code}") + message_parts = [ + f"✅ Trace batch finalized with session ID: {batch_id}", + "", + f"🔗 View here: {return_link}", + ] - panel = Panel( - "\n".join(message_parts), - title="Trace Batch Finalization", - border_style="green", - ) - if not should_auto_collect_first_time_traces(): - console.print(panel) + if access_code: + message_parts.append(f"🔑 Access Code: {access_code}") + + panel = Panel( + "\n".join(message_parts), + title="Trace Batch Finalization", + border_style="green", + ) + if not should_auto_collect_first_time_traces(): + console.print(panel) + return True - else: logger.error( f"❌ Failed to finalize trace batch: {response.status_code} - {response.text}" ) - self._mark_batch_as_failed(self.trace_batch_id, response.text) + self._mark_batch_as_failed(batch_id, response.text) + return False - except Exception as e: - logger.error(f"❌ Error finalizing trace batch: {e}") - try: - self._mark_batch_as_failed(self.trace_batch_id, str(e)) - except Exception: - logger.debug( - "Could not mark trace batch as failed (network unavailable)" - ) + except Exception as e: + logger.error(f"❌ Error finalizing trace batch: {e}") + try: + self._mark_batch_as_failed(batch_id, str(e)) + except Exception: + logger.debug( + "Could not mark trace batch as failed (network unavailable)" + ) + return False def _cleanup_batch_data(self) -> None: """Clean up batch data after successful finalization to free memory""" diff --git a/lib/crewai/src/crewai/events/listeners/tracing/trace_listener.py b/lib/crewai/src/crewai/events/listeners/tracing/trace_listener.py index 23f4524e3..c7901fed3 100644 --- a/lib/crewai/src/crewai/events/listeners/tracing/trace_listener.py +++ b/lib/crewai/src/crewai/events/listeners/tracing/trace_listener.py @@ -1,5 +1,6 @@ """Trace collection listener for orchestrating trace collection.""" +from datetime import datetime, timezone import os from typing import Any, ClassVar import uuid @@ -230,11 +231,14 @@ class TraceCollectionListener(BaseEventListener): @event_bus.on(FlowStartedEvent) def on_flow_started(source: Any, event: FlowStartedEvent) -> None: - # Always call _initialize_flow_batch to claim ownership. - # If batch was already initialized by a concurrent action event - # (race condition), initialize_batch() returns early but - # batch_owner_type is still correctly set to "flow". - self._initialize_flow_batch(source, event) + # Only the first execution to open the session batch owns it. A flow + # that starts while a batch already exists is nested -- inside a crew + # (e.g. an agent's Flow-based executor), a conversational Flow, or a + # parent flow -- and must NOT re-claim ownership. Re-claiming would + # mark batch_owner_type="flow" and cause the nested flow to finalize + # the parent's batch prematurely when it completes. + if not self.batch_manager.is_batch_initialized(): + self._initialize_flow_batch(source, event) self._handle_trace_event("flow_started", source, event) @event_bus.on(MethodExecutionStartedEvent) @@ -264,18 +268,20 @@ class TraceCollectionListener(BaseEventListener): @event_bus.on(CrewKickoffStartedEvent) def on_crew_started(source: Any, event: CrewKickoffStartedEvent) -> None: - if self.batch_manager.batch_owner_type != "flow": - # Always call _initialize_crew_batch to claim ownership. - # If batch was already initialized by a concurrent action event - # (e.g. LLM/tool before crew_kickoff_started), initialize_batch() - # returns early but batch_owner_type is still correctly set to "crew". - # Skip only when a parent flow already owns the batch. + # Nested crew inside Flow.kickoff: never claim an existing flow session batch. + if not self._nested_in_flow_execution() and ( + not self.batch_manager.is_batch_initialized() + ): self._initialize_crew_batch(source, event) self._handle_trace_event("crew_kickoff_started", source, event) @event_bus.on(CrewKickoffCompletedEvent) def on_crew_completed(source: Any, event: CrewKickoffCompletedEvent) -> None: self._handle_trace_event("crew_kickoff_completed", source, event) + if self.batch_manager.defer_session_finalization: + return + if self._nested_in_flow_execution(): + return if self.batch_manager.batch_owner_type == "crew": if self.first_time_handler.is_first_time: self.first_time_handler.mark_events_collected() @@ -286,10 +292,14 @@ class TraceCollectionListener(BaseEventListener): @event_bus.on(CrewKickoffFailedEvent) def on_crew_failed(source: Any, event: CrewKickoffFailedEvent) -> None: self._handle_trace_event("crew_kickoff_failed", source, event) + if self.batch_manager.defer_session_finalization: + return + if self._nested_in_flow_execution(): + return if self.first_time_handler.is_first_time: self.first_time_handler.mark_events_collected() self.first_time_handler.handle_execution_completion() - else: + elif self.batch_manager.batch_owner_type == "crew": self.batch_manager.finalize_batch() @event_bus.on(TaskStartedEvent) @@ -707,8 +717,32 @@ class TraceCollectionListener(BaseEventListener): @on_signal def handle_signal(source: Any, event: SignalEvent) -> None: """Flush trace batch on system signals to prevent data loss.""" - if self.batch_manager.is_batch_initialized(): - self.batch_manager.finalize_batch() + if not self.batch_manager.is_batch_initialized(): + return + # Multi-turn flows defer batch finalization to finalize_session_traces(). + if self.batch_manager.defer_session_finalization: + return + self.batch_manager.finalize_batch() + + @staticmethod + def _is_inside_active_flow_context() -> bool: + """True when ``kickoff_async`` has set ``current_flow_id`` (nested crew).""" + from crewai.flow.flow_context import current_flow_id + + return current_flow_id.get() is not None + + def _flow_owns_trace_batch(self) -> bool: + """True when an in-flight conversational flow already owns the trace batch.""" + if self.batch_manager.batch_owner_type == "flow": + return True + batch = self.batch_manager.current_batch + if batch is not None: + return batch.execution_metadata.get("execution_type") == "flow" + return False + + def _nested_in_flow_execution(self) -> bool: + """True when a crew runs inside a flow session (context or batch ownership).""" + return self._is_inside_active_flow_context() or self._flow_owns_trace_batch() def _initialize_crew_batch(self, source: Any, event: BaseEvent) -> None: """Initialize trace batch. @@ -729,6 +763,33 @@ class TraceCollectionListener(BaseEventListener): self._initialize_batch(user_context, execution_metadata) + def _try_initialize_flow_batch_from_context(self, event: Any) -> bool: + """Claim a flow trace batch when an action event fires inside kickoff. + + When ``suppress_flow_events=True``, console panels are hidden but + ``FlowStartedEvent`` and method lifecycle events still emit; if no + batch exists yet, LLM/tool events must not fall back to implicit crew + batches. + """ + from crewai.flow.flow_context import current_flow_id, current_flow_name + + flow_id = current_flow_id.get() + if flow_id is None: + return False + + started_at = getattr(event, "timestamp", None) or datetime.now(timezone.utc) + user_context = self._get_user_context() + execution_metadata = { + "flow_name": current_flow_name.get() or "Unknown Flow", + "execution_start": started_at, + "crewai_version": get_crewai_version(), + "execution_type": "flow", + } + self.batch_manager.batch_owner_type = "flow" + self.batch_manager.batch_owner_id = flow_id + self._initialize_batch(user_context, execution_metadata) + return True + def _initialize_flow_batch(self, source: Any, event: BaseEvent) -> None: """Initialize trace batch for Flow execution. @@ -793,12 +854,19 @@ class TraceCollectionListener(BaseEventListener): event: Event object. """ if not self.batch_manager.is_batch_initialized(): - user_context = self._get_user_context() - execution_metadata = { - "crew_name": getattr(source, "name", "Unknown Crew"), - "crewai_version": get_crewai_version(), - } - self._initialize_batch(user_context, execution_metadata) + if self._try_initialize_flow_batch_from_context(event): + pass + elif not self._nested_in_flow_execution(): + user_context = self._get_user_context() + execution_metadata = { + "crew_name": getattr(source, "name", "Unknown Crew"), + "crewai_version": get_crewai_version(), + } + self.batch_manager.batch_owner_type = "crew" + self.batch_manager.batch_owner_id = getattr( + source, "id", str(uuid.uuid4()) + ) + self._initialize_batch(user_context, execution_metadata) self.batch_manager.begin_event_processing() try: diff --git a/lib/crewai/src/crewai/experimental/__init__.py b/lib/crewai/src/crewai/experimental/__init__.py index 662a722f3..1d3503f27 100644 --- a/lib/crewai/src/crewai/experimental/__init__.py +++ b/lib/crewai/src/crewai/experimental/__init__.py @@ -1,31 +1,32 @@ -from crewai.experimental.agent_executor import AgentExecutor, CrewAgentExecutorFlow -from crewai.experimental.evaluation import ( - AgentEvaluationResult, - AgentEvaluator, - BaseEvaluator, - EvaluationScore, - EvaluationTraceCallback, - ExperimentResult, - ExperimentResults, - ExperimentRunner, - GoalAlignmentEvaluator, - MetricCategory, - ParameterExtractionEvaluator, - ReasoningEfficiencyEvaluator, - SemanticQualityEvaluator, - ToolInvocationEvaluator, - ToolSelectionEvaluator, - create_default_evaluator, - create_evaluation_callbacks, +"""Experimental CrewAI surface — APIs here may change without major-version bumps.""" + +from __future__ import annotations + +from typing import Any + +# ``crewai.experimental.conversational`` is pure data shapes — no Flow or Task +# imports — so it's safe to eager-import. Everything else is resolved lazily +# below; otherwise the chain +# crewai → Flow → experimental.conversational → experimental.__init__ +# → experimental.agent_executor / experimental.evaluation +# → Flow / Task (mid-load) +# would deadlock with "partially initialized module" ImportErrors. +from crewai.experimental.conversational import ( + AgentMessage, + ConversationConfig, + ConversationEvent, + ConversationMessage, + ConversationState, + RouterConfig, ) -__all__ = [ +_LAZY_FROM_AGENT_EXECUTOR = {"AgentExecutor", "CrewAgentExecutorFlow"} + +_LAZY_FROM_EVALUATION = { "AgentEvaluationResult", "AgentEvaluator", - "AgentExecutor", "BaseEvaluator", - "CrewAgentExecutorFlow", # Deprecated alias for AgentExecutor "EvaluationScore", "EvaluationTraceCallback", "ExperimentResult", @@ -40,4 +41,62 @@ __all__ = [ "ToolSelectionEvaluator", "create_default_evaluator", "create_evaluation_callbacks", +} + + +def __getattr__(name: str) -> Any: + """Lazily resolve symbols whose modules import ``Flow`` or ``Task``. + + Eager re-exports would deadlock when ``Flow`` itself is the consumer that + triggered ``crewai.experimental.__init__`` (``Flow`` imports types from + :mod:`crewai.experimental.conversational`). Callers like + ``from crewai.experimental import AgentExecutor`` still work — the + real import just runs lazily, after the original loader finishes. + """ + if name in _LAZY_FROM_AGENT_EXECUTOR: + from crewai.experimental.agent_executor import ( + AgentExecutor, + CrewAgentExecutorFlow, + ) + + globals()["AgentExecutor"] = AgentExecutor + globals()["CrewAgentExecutorFlow"] = CrewAgentExecutorFlow + return globals()[name] + + if name in _LAZY_FROM_EVALUATION: + from crewai.experimental import evaluation as _evaluation_mod + + for attr in _LAZY_FROM_EVALUATION: + globals()[attr] = getattr(_evaluation_mod, attr) + return globals()[name] + + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + + +__all__ = [ + "AgentEvaluationResult", + "AgentEvaluator", + "AgentExecutor", + "AgentMessage", + "BaseEvaluator", + "ConversationConfig", + "ConversationEvent", + "ConversationMessage", + "ConversationState", + "CrewAgentExecutorFlow", # Deprecated alias for AgentExecutor + "EvaluationScore", + "EvaluationTraceCallback", + "ExperimentResult", + "ExperimentResults", + "ExperimentRunner", + "GoalAlignmentEvaluator", + "MetricCategory", + "ParameterExtractionEvaluator", + "ReasoningEfficiencyEvaluator", + "RouterConfig", + "SemanticQualityEvaluator", + "ToolInvocationEvaluator", + "ToolSelectionEvaluator", + "create_default_evaluator", + "create_evaluation_callbacks", ] diff --git a/lib/crewai/src/crewai/experimental/conversational.py b/lib/crewai/src/crewai/experimental/conversational.py new file mode 100644 index 000000000..ac20b9a4f --- /dev/null +++ b/lib/crewai/src/crewai/experimental/conversational.py @@ -0,0 +1,184 @@ +"""Conversational types and helpers shared by ``Flow`` (experimental). + +The conversational chat surface (``Flow`` with ``conversational = True``) is +EXPERIMENTAL. APIs in this module and the conversational methods on ``Flow`` +may change without a major-version bump until the feature graduates. + +This module hosts the **data shapes** — ``ConversationConfig``, +``RouterConfig``, ``ConversationState`` and its message types — plus the +``_conversational_only`` decorator used to gate built-in conversational +methods on the base ``Flow`` class. The methods themselves live on ``Flow`` +directly. +""" + +from __future__ import annotations + +from collections.abc import Callable, Sequence +from dataclasses import dataclass +from typing import Any, Literal, TypeVar, cast +from uuid import uuid4 + +from pydantic import BaseModel, ConfigDict, Field + +from crewai.utilities.types import LLMMessage + + +ConversationMessageRole = Literal["user", "assistant", "system", "tool"] +ConversationEventVisibility = Literal["private", "public"] + +F = TypeVar("F", bound=Callable[..., Any]) + + +def _conversational_only(func: F) -> F: + """Mark a method as part of the conversational built-in graph. + + Methods carrying this marker only register on a ``Flow`` subclass when + ``conversational = True``. Subclasses that don't opt in see them as + inert attributes — they don't fire and don't pollute the listener graph. + """ + func.__conversational_only__ = True # type: ignore[attr-defined] + return func + + +@dataclass +class RouterConfig: + """LLM router configuration for the experimental conversational ``Flow``. + + .. warning:: + + **EXPERIMENTAL.** Part of the conversational ``Flow`` surface. Fields + and defaults may change before the feature graduates from + ``crewai.experimental``. Pin your CrewAI version if you depend on + a specific shape. + + ``route_descriptions`` overrides the per-route descriptions used to build + the router LLM's "available routes" catalog. Routes without an entry fall + back to the handler's docstring first line (or, for built-in routes, the + framework's canned description). ``prompt`` is reserved for domain + policy/voice, not the route catalog — that's auto-built. + """ + + prompt: str | None = None + response_format: type[BaseModel] | None = None + llm: Any | None = None + routes: Sequence[str] | None = None + route_descriptions: dict[str, str] | None = None + default_intent: str | None = "converse" + fallback_intent: str | None = "converse" + intent_field: str = "intent" + + +@dataclass +class ConversationConfig: + """Class-level configuration for the experimental conversational ``Flow``. + + .. warning:: + + **EXPERIMENTAL.** Part of the conversational ``Flow`` surface. Fields + and defaults may change before the feature graduates from + ``crewai.experimental``. Pin your CrewAI version if you depend on + a specific shape. + + ``system_prompt`` defaults to the ``slices.conversational_system_prompt`` + translation when left as ``None``. Pass an empty string to opt out of any + system prompt for ``converse_turn``. ``answer_from_history_prompt`` falls + back to ``slices.conversational_answer_from_history_prompt`` when ``None``. + """ + + system_prompt: str | None = None + llm: Any | None = None + router: RouterConfig | None = None + answer_from_history_prompt: str | None = None + default_intents: Sequence[str] | None = None + intent_llm: Any | None = None + answer_from_history_llm: Any | None = None + visible_agent_outputs: Sequence[str] | Literal["all"] | None = None + defer_trace_finalization: bool = True + + def __call__(self, flow_cls: type[Any]) -> type[Any]: + """Use this config as a class decorator.""" + flow_cls.conversational_config = self + return flow_cls + + +class ConversationMessage(BaseModel): + """Canonical user-facing message shared across conversational turns.""" + + model_config = ConfigDict(arbitrary_types_allowed=True) + + role: ConversationMessageRole + content: str | list[dict[str, Any]] | None + name: str | None = None + tool_call_id: str | None = None + tool_calls: list[dict[str, Any]] | None = None + files: dict[str, Any] | None = None + metadata: dict[str, Any] = Field(default_factory=dict) + + +class AgentMessage(BaseModel): + """Private per-agent message or scratch result.""" + + role: ConversationMessageRole | str = "assistant" + content: Any + metadata: dict[str, Any] = Field(default_factory=dict) + + +class ConversationEvent(BaseModel): + """Structured trace/event that is separate from user-visible messages.""" + + type: str + payload: dict[str, Any] = Field(default_factory=dict) + agent_name: str | None = None + visibility: ConversationEventVisibility = "private" + + +class ConversationState(BaseModel): + """Structured state for the experimental conversational ``Flow``. + + .. warning:: + + **EXPERIMENTAL.** Field shape and defaults may change before the + conversational ``Flow`` graduates from ``crewai.experimental``. + + ``messages`` is the canonical user-facing history. Agent/tool scratch work + belongs in ``events`` or ``agent_threads`` unless explicitly made public. + """ + + id: str = Field(default_factory=lambda: str(uuid4())) + messages: list[ConversationMessage] = Field(default_factory=list) + current_user_message: str | None = None + last_user_message: str | None = None + last_intent: str | None = None + ended: bool = False + events: list[ConversationEvent] = Field(default_factory=list) + agent_threads: dict[str, list[AgentMessage]] = Field(default_factory=dict) + session_ready: bool = False + + +def message_to_llm_dict(message: Any) -> LLMMessage: + """Coerce a stored ``ConversationMessage`` (or dict) into an ``LLMMessage``.""" + if isinstance(message, BaseModel): + data = message.model_dump(exclude_none=True) + elif isinstance(message, dict): + data = dict(message) + else: + data = {"role": "user", "content": str(message)} + + return cast( + LLMMessage, + {key: value for key, value in data.items() if key != "metadata"}, + ) + + +__all__ = [ + "AgentMessage", + "ConversationConfig", + "ConversationEvent", + "ConversationEventVisibility", + "ConversationMessage", + "ConversationMessageRole", + "ConversationState", + "RouterConfig", + "_conversational_only", + "message_to_llm_dict", +] diff --git a/lib/crewai/src/crewai/experimental/conversational_mixin.py b/lib/crewai/src/crewai/experimental/conversational_mixin.py new file mode 100644 index 000000000..a66c5bc68 --- /dev/null +++ b/lib/crewai/src/crewai/experimental/conversational_mixin.py @@ -0,0 +1,814 @@ +"""Conversational graph + helpers as a mixin for ``Flow`` (experimental). + +The experimental conversational chat surface lives here as a mixin so that +``crewai.flow.runtime`` stays focused on the execution engine. ``Flow`` +inherits from ``_ConversationalMixin``; the methods only register on +subclasses that opt in via ``conversational = True`` (enforced by the +``_conversational_only`` marker + ``FlowMeta`` gating in +``crewai.flow.runtime``). + +Import surface: + - :class:`_ConversationalMixin` — internal; ``Flow`` mixes it in. Users + don't import it directly. + - The data types this mixin uses live in + :mod:`crewai.experimental.conversational`. +""" + +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from enum import Enum +import json +import logging +from typing import TYPE_CHECKING, Any, ClassVar, Literal, cast + +from pydantic import BaseModel, Field, create_model + +from crewai.experimental.conversational import ( + AgentMessage, + ConversationConfig, + ConversationEvent, + ConversationMessage, + ConversationState, + RouterConfig, + _conversational_only, + message_to_llm_dict, +) +from crewai.flow.conversation import ( + append_message as _append_conversation_message, + get_conversation_messages, + receive_user_message as _receive_user_message, +) +from crewai.flow.dsl import listen, router, start +from crewai.utilities.types import LLMMessage + + +if TYPE_CHECKING: + from crewai.flow.runtime import Flow + from crewai.llms.base_llm import BaseLLM + + +logger = logging.getLogger(__name__) + + +class _ConversationalMixin: + """Built-in conversational graph for ``Flow`` (gated on ``conversational``). + + Mixed into ``Flow`` so its execution engine (``runtime.py``) stays focused + on running graphs. The methods here only register on subclasses that set + ``conversational = True``; non-chat flows see them as inert attributes. + """ + + # The metaclass + state attributes referenced below live on ``Flow`` — + # this mixin is never instantiated standalone. These type-only + # declarations exist so static analyzers don't flag attribute access. + # Class-level slots use ``ClassVar`` to match Flow's actual declarations + # (otherwise mypy flags "Cannot override instance variable with class + # variable" when Flow declares them as ``ClassVar``). + if TYPE_CHECKING: + conversational: ClassVar[bool] + conversational_config: ClassVar[ConversationConfig | None] + builtin_routes: ClassVar[tuple[str, ...]] + internal_routes: ClassVar[tuple[str, ...]] + builtin_route_descriptions: ClassVar[dict[str, str]] + # Registry ClassVars populated by ``FlowMeta`` at class creation. + _listeners: ClassVar[dict[Any, Any]] + + # Instance attrs from ``Flow``. + state: Any + name: str | None + _completed_methods: set[Any] + _method_outputs: list[Any] + _pending_and_listeners: dict[Any, Any] + _method_call_counts: dict[Any, int] + _is_execution_resuming: bool + _pending_user_message: str | dict[str, Any] | None + _pending_intents: Sequence[str] | None + _pending_intent_llm: str | BaseLLM | None + + def _clear_or_listeners(self) -> None: + pass + + def _collapse_to_outcome( + self, + feedback: str, + outcomes: tuple[str, ...], + llm: str | BaseLLM | Any, + ) -> str: + pass + + def _copy_and_serialize_state(self) -> dict[str, Any]: + pass + + def kickoff(self, *args: Any, **kwargs: Any) -> Any: + pass + + @start() + @_conversational_only + def conversation_start(self) -> str | None: + """Internal Flow entrypoint that hands the user message to the router. + + In conversational mode, ``Flow.kickoff_async`` runs all ``@start`` + methods sequentially and this one is registered last, so any user + ``@start`` methods (e.g. permission loading) have already finished + before the returned value triggers ``route_conversation``. + """ + state = cast(ConversationState, self.state) + return state.current_user_message + + @router(conversation_start) + @_conversational_only + def route_conversation(self) -> str: + """Route the current turn to a listener label.""" + state = cast(ConversationState, self.state) + context = self.build_router_context() + configured_route = self.route_turn(context) + if configured_route: + state.last_intent = configured_route + return configured_route + + if state.last_intent: + return state.last_intent + + if self.can_answer_from_history(context): + state.last_intent = "answer_from_history" + return "answer_from_history" + + state.last_intent = "converse" + return "converse" + + @listen("converse") + @_conversational_only + def converse_turn(self) -> str: + """Built-in chat handler over canonical conversation history.""" + llm = self._default_conversation_llm() + if llm is None: + content = "I can continue the conversation once an LLM is configured." + self.append_assistant_message(content) + return content + + messages: list[LLMMessage] = [] + system_prompt = self._resolve_system_prompt() + if system_prompt: + messages.append({"role": "system", "content": system_prompt}) + messages.extend(self.conversation_messages) + + response = self._coerce_llm(llm).call(messages=messages) + content = self._stringify_result(response) + self.append_assistant_message(content) + return content + + @listen("end") + @_conversational_only + def end_conversation(self) -> str: + """Built-in conversation terminator.""" + cast(ConversationState, self.state).ended = True + content = "Conversation ended." + self.append_assistant_message(content) + return content + + @listen("answer_from_history") + @_conversational_only + def answer_from_history_turn(self) -> str | None: + """Answer directly from canonical conversation history when configured.""" + config = self._conversation_config + if config is None: + return None + llm = config.answer_from_history_llm + if llm is None: + return None + + llm_instance = self._coerce_llm(llm) + messages: list[LLMMessage] = [ + { + "role": "system", + "content": self._resolve_answer_from_history_prompt(), + }, + *self.build_agent_context("answer_from_history"), + ] + response = llm_instance.call(messages=messages) + content = self._stringify_result(response) + self.append_assistant_message(content) + return content + + def handle_turn( + self, + message: str, + *, + session_id: str | None = None, + intents: Sequence[str] | None = None, + intent_llm: str | BaseLLM | None = None, + **kickoff_kwargs: Any, + ) -> Any: + """Append a user message, run one conversational turn, and return output. + + .. warning:: + + **EXPERIMENTAL.** This is the public entry point for the + conversational ``Flow``. Signature and semantics may change before + the feature graduates from ``crewai.experimental``. + + Available only when ``conversational = True`` is set on the subclass. + Stashes the message + session_id as pending turn state, runs kickoff + (which restores from persist and then applies the pending turn), and + promotes the result to an assistant message when the handler didn't. + """ + state = cast(ConversationState, self.state) + sid = session_id or state.id + + # Stash the pending turn so ``_apply_pending_conversational_turn`` + # picks it up AFTER persist restore. + self._pending_user_message = message + self._pending_intents = list(intents) if intents else None + self._pending_intent_llm = intent_llm + + # Each turn is a fresh execution; clear graph tracking so the second + # turn re-runs instead of being treated as a checkpoint restore. + if "from_checkpoint" not in kickoff_kwargs: + self._reset_turn_execution_state() + + assistant_count = self._assistant_message_count() + try: + result = self.kickoff(inputs={"id": sid}, **kickoff_kwargs) + finally: + self._pending_user_message = None + self._pending_intents = None + self._pending_intent_llm = None + + if ( + result is not None + and self._assistant_message_count() == assistant_count + and self._is_public_turn_result(result) + ): + self.append_assistant_message(self._stringify_result(result)) + return result + + def build_router_context(self) -> dict[str, Any]: + """Build context used by the routing policy for the current turn.""" + state = cast(ConversationState, self.state) + return { + "system_prompt": self._resolve_system_prompt(), + "current_user_message": state.current_user_message, + "message_history": self.conversation_messages, + "events": [event.model_dump() for event in state.events], + "last_intent": state.last_intent, + } + + def build_agent_context(self, agent_name: str) -> list[LLMMessage]: + """Build canonical message context for an agent or direct LLM call.""" + state = cast(ConversationState, self.state) + messages = list(self.conversation_messages) + thread = state.agent_threads.get(agent_name, []) + messages.extend( + cast( + LLMMessage, + { + "role": msg.role, + "content": self._stringify_result(msg.content), + }, + ) + for msg in thread + ) + return messages + + def route_turn(self, context: dict[str, Any]) -> str | None: + """Route the current turn via the LLM router. + + When ``ConversationConfig.router`` is omitted, the router is + auto-enabled with default settings as long as the flow declares + custom ``@listen`` handlers (anything beyond the built-in + ``converse`` / ``end`` routes). ``@ConversationConfig(llm=ROUTER_LLM)`` + is enough to dispatch to your custom handlers — no explicit + ``RouterConfig()`` needed. + + Pass an explicit ``RouterConfig`` only to override the routing prompt, + supply per-route descriptions, or change the default/fallback intent. + Override this method to bypass the LLM router entirely (e.g., + permission gates before the LLM decision). + """ + config = self._conversation_config + if config is None: + return None + + router_config = config.router + if router_config is None: + if config.default_intents: + return None + custom_routes = self._effective_routes(None) - set(self.builtin_routes) + if not custom_routes: + return None + router_config = RouterConfig() + + return self._route_with_config(router_config, context) + + def can_answer_from_history(self, context: dict[str, Any]) -> bool: + """Return whether this turn can be answered from message history.""" + config = self._conversation_config + if config is None or config.answer_from_history_llm is None: + return False + if len(self.conversation_messages) < 2: + return False + + feedback = ( + f"{self._resolve_answer_from_history_prompt()}\n\n" + f"Current user message: {context.get('current_user_message')}\n\n" + f"Message history:\n{self._format_messages(self.conversation_messages)}" + ) + outcome = self._collapse_to_outcome( + feedback, + ("answer_from_history", "route_to_flow"), + config.answer_from_history_llm, + ) + return outcome == "answer_from_history" + + def append_agent_result( + self, + agent_name: str, + result: Any, + *, + visibility: Literal["private", "public"] = "private", + metadata: dict[str, Any] | None = None, + ) -> None: + """Record an agent result, optionally making it visible to the user.""" + content = self._stringify_result(result) + event_visibility = self._resolve_visibility(agent_name, visibility) + event = ConversationEvent( + type="agent_result", + agent_name=agent_name, + visibility=event_visibility, + payload={"content": content, **(metadata or {})}, + ) + state = cast(ConversationState, self.state) + state.events.append(event) + state.agent_threads.setdefault(agent_name, []).append( + AgentMessage(content=content, metadata=metadata or {}) + ) + if event_visibility == "public": + self.append_assistant_message(content) + + def append_assistant_message( + self, + content: str, + *, + metadata: dict[str, Any] | None = None, + ) -> None: + """Append a final user-visible assistant message.""" + cast(ConversationState, self.state).messages.append( + ConversationMessage( + role="assistant", + content=content, + metadata=metadata or {}, + ) + ) + + def append_message( + self, + role: Literal["user", "assistant", "system", "tool"], + content: str, + **extra: Any, + ) -> None: + """Append a message to conversation history (legacy ChatState path).""" + _append_conversation_message(cast("Flow[Any]", self), role, content, **extra) + + @property + def conversation_messages(self) -> list[LLMMessage]: + """Message history from state, coerced to LLM-shaped dicts.""" + return [ + message_to_llm_dict(message) + for message in get_conversation_messages(cast("Flow[Any]", self)) + ] + + def receive_user_message( + self, + text: str, + *, + outcomes: Sequence[str] | None = None, + llm: str | BaseLLM | None = None, + ) -> str: + """Append a user message and optionally classify intent. + + Conversational flows push a ``ConversationMessage`` onto + ``state.messages`` and preserve ``last_intent`` across turns. + Non-conversational flows fall through to the legacy helper. + """ + if self.conversational: + state = cast(ConversationState, self.state) + state.messages.append(ConversationMessage(role="user", content=text)) + state.current_user_message = text + state.last_user_message = text + if outcomes and llm is not None: + intent = self.classify_intent( + text, + outcomes, + llm=llm, + context=self.conversation_messages, + ) + state.last_intent = intent + return intent + return text + + return _receive_user_message( + cast("Flow[Any]", self), text, outcomes=outcomes, llm=llm + ) + + def classify_intent( + self, + text: str, + outcomes: Sequence[str], + *, + llm: str | BaseLLM, + context: Sequence[Mapping[str, Any]] | None = None, + ) -> str: + """Map user text to one of the given outcomes using an LLM.""" + if context: + context_blob = "\n".join( + f"{m.get('role', 'user')}: {m.get('content', '')}" for m in context + ) + feedback = f"{context_blob}\n\nLatest user message: {text}" + else: + feedback = text + return self._collapse_to_outcome(feedback, tuple(outcomes), llm) + + @property + def _conversation_config(self) -> ConversationConfig | None: + return getattr(type(self), "conversational_config", None) + + def _should_defer_trace_finalization(self) -> bool: + """Whether per-turn ``FlowFinished`` + ``finalize_batch`` should be skipped. + + True when either: + - ``flow.defer_trace_finalization`` is set on the instance, OR + - the class-level ``ConversationConfig.defer_trace_finalization`` + on a conversational subclass is True. + + Either source enables the deferred-session pattern. The caller + eventually invokes ``finalize_session_traces()`` to close the batch. + """ + if getattr(self, "defer_trace_finalization", False): + return True + config = self._conversation_config + return bool(config and config.defer_trace_finalization) + + def _reset_turn_execution_state(self) -> None: + """Clear per-execution tracking so the next turn re-runs the graph.""" + self._completed_methods.clear() + self._method_outputs.clear() + self._pending_and_listeners.clear() + self._method_call_counts.clear() + self._clear_or_listeners() + self._is_execution_resuming = False + + def _apply_pending_conversational_turn(self) -> None: + """Drain the stashed user message + classify if intents configured. + + Called from ``Flow.kickoff_async`` AFTER persist state restore so + the appended message survives ``self.persistence.load_state(...)``. + """ + if self._pending_user_message is None: + return + + text = self._coerce_user_message_text(self._pending_user_message) + if not text.strip(): + return + + cfg = self._conversation_config + outcomes = self._pending_intents + if outcomes is None and cfg is not None: + outcomes = cfg.default_intents + llm = self._pending_intent_llm + if llm is None and cfg is not None: + llm = cfg.intent_llm + + if outcomes: + if llm is None: + raise ValueError("intent_llm is required when intents are provided") + self.receive_user_message(text, outcomes=outcomes, llm=llm) + else: + self.receive_user_message(text) + + def _resolve_system_prompt(self) -> str | None: + """Return the effective conversational system prompt.""" + from crewai.utilities.i18n import I18N_DEFAULT + + config = self._conversation_config + if config is None or config.system_prompt is None: + return I18N_DEFAULT.slice("conversational_system_prompt") + return config.system_prompt or None + + def _resolve_answer_from_history_prompt(self) -> str: + """Return the effective ``answer_from_history`` prompt.""" + from crewai.utilities.i18n import I18N_DEFAULT + + config = self._conversation_config + if config is None or not config.answer_from_history_prompt: + return I18N_DEFAULT.slice("conversational_answer_from_history_prompt") + return config.answer_from_history_prompt + + def _route_with_config( + self, + router_config: RouterConfig, + context: dict[str, Any], + ) -> str | None: + router_llm = self._default_router_llm(router_config) + if router_llm is None: + return router_config.default_intent + + try: + llm = self._coerce_llm(router_llm) + response = self._call_router_llm( + llm, + messages=self._build_router_messages(router_config, context), + response_format=self._router_response_format(router_config), + ) + intent = self._extract_router_intent(response, router_config.intent_field) + except Exception: + return router_config.fallback_intent or router_config.default_intent + + if intent is None: + return router_config.fallback_intent or router_config.default_intent + + valid_labels = self._effective_routes(router_config) + if valid_labels and intent not in valid_labels: + return router_config.fallback_intent or router_config.default_intent + + return intent + + def _default_router_llm(self, router_config: RouterConfig) -> Any | None: + config = self._conversation_config + return ( + router_config.llm + or (config.intent_llm if config else None) + or (config.llm if config else None) + ) + + def _router_response_format( + self, + router_config: RouterConfig, + ) -> type[BaseModel]: + if router_config.response_format is not None: + return router_config.response_format + + routes = sorted(self._effective_routes(router_config)) + field_definitions: dict[str, Any] = { + router_config.intent_field: ( + str, + Field(description=f"One of: {', '.join(routes)}"), + ) + } + return cast( + type[BaseModel], + create_model("ConversationRoute", **field_definitions), + ) + + def _call_router_llm( + self, + llm: Any, + *, + messages: list[LLMMessage], + response_format: type[BaseModel], + ) -> Any: + try: + return llm.call(messages=messages, response_format=response_format) + except TypeError as exc: + if "response_format" not in str(exc): + raise + return llm.call(messages=messages, response_model=response_format) + + def _build_router_messages( + self, + router_config: RouterConfig, + context: dict[str, Any], + ) -> list[LLMMessage]: + catalog = self._build_route_catalog(router_config) + context = {**context, "available_routes": sorted(catalog.keys())} + domain_prompt = f"{router_config.prompt}\n\n" if router_config.prompt else "" + routes_section = "Routes:\n" + "\n".join( + f"- {label}: {description}" if description else f"- {label}" + for label, description in sorted(catalog.items()) + ) + routing_prompt = ( + domain_prompt + + routes_section + + "\n\nChoose exactly one route from the list above. Prefer " + "'converse' for follow-ups, summaries, and clarifications about " + "prior turns — even if they touch on a topic the user previously " + "invoked a custom route for. Use a custom route only when the user " + "is making a fresh request for that tool or workflow." + ) + return [ + {"role": "system", "content": routing_prompt}, + {"role": "user", "content": json.dumps(context, default=str)}, + ] + + def _build_route_catalog( + self, + router_config: RouterConfig | None, + ) -> dict[str, str]: + label_to_method: dict[str, str] = {} + for listener_name, condition in self._listeners.items(): + if isinstance(condition, tuple): + _, trigger_labels = condition + for trigger_label in trigger_labels: + label_to_method.setdefault(str(trigger_label), str(listener_name)) + + routes = self._effective_routes(router_config) + overrides = ( + router_config.route_descriptions + if router_config and router_config.route_descriptions + else {} + ) + + catalog: dict[str, str] = {} + for route_label in routes: + if route_label in overrides: + catalog[route_label] = overrides[route_label] + continue + if route_label in self.builtin_route_descriptions: + catalog[route_label] = self.builtin_route_descriptions[route_label] + continue + handler_name = label_to_method.get(route_label) + description = "" + if handler_name: + method = getattr(type(self), handler_name, None) + doc = getattr(method, "__doc__", None) + if doc: + description = doc.strip().split("\n", 1)[0].strip() + catalog[route_label] = description + + return catalog + + def _extract_router_intent(self, response: Any, intent_field: str) -> str | None: + if isinstance(response, BaseModel): + value = getattr(response, intent_field, None) + elif isinstance(response, dict): + value = response.get(intent_field) + elif isinstance(response, str): + try: + parsed = json.loads(response) + except json.JSONDecodeError: + value = response.strip() + else: + value = parsed.get(intent_field) + else: + value = getattr(response, intent_field, None) + + if value is None: + return None + if isinstance(value, Enum): + return str(value.value) + return str(value) + + def _valid_route_labels(self) -> set[str]: + labels: set[str] = set() + for condition in self._listeners.values(): + if isinstance(condition, tuple): + _, methods = condition + labels.update(str(method) for method in methods) + return labels + + def _effective_routes(self, router_config: RouterConfig | None = None) -> set[str]: + custom_routes = set(router_config.routes or ()) if router_config else set() + if not custom_routes: + custom_routes = ( + self._valid_route_labels() + - set(self.builtin_routes) + - set(self.internal_routes) + ) + return custom_routes | set(self.builtin_routes) + + def _default_conversation_llm(self) -> Any | None: + config = self._conversation_config + if config is None: + return None + if config.llm is not None: + return config.llm + if config.answer_from_history_llm is not None: + return config.answer_from_history_llm + if config.router is not None: + return config.router.llm + return config.intent_llm + + def _resolve_visibility( + self, + agent_name: str, + visibility: Literal["private", "public"], + ) -> Literal["private", "public"]: + if visibility == "public": + return "public" + config = self._conversation_config + visible = config.visible_agent_outputs if config else None + if visible == "all" or (visible is not None and agent_name in visible): + return "public" + return "private" + + def _assistant_message_count(self) -> int: + state = cast(ConversationState, self.state) + return sum(1 for message in state.messages if message.role == "assistant") + + def _is_public_turn_result(self, result: Any) -> bool: + if not isinstance(result, str): + return False + if result in { + "conversation", + "converse", + "end", + "answer_from_history", + "route_to_flow", + }: + return False + return result != cast(ConversationState, self.state).last_intent + + @staticmethod + def _coerce_user_message_text(user_message: str | dict[str, Any] | Any) -> str: + if isinstance(user_message, str): + return user_message + if isinstance(user_message, dict) and user_message.get("content") is not None: + return str(user_message["content"]) + return str(user_message) + + @staticmethod + def _stringify_result(result: Any) -> str: + if hasattr(result, "raw"): + return str(result.raw) + if isinstance(result, BaseModel): + return result.model_dump_json() + return str(result) + + @staticmethod + def _format_messages(messages: Sequence[Mapping[str, Any]]) -> str: + return "\n".join( + f"{message.get('role', 'user')}: {message.get('content', '')}" + for message in messages + ) + + @staticmethod + def _coerce_llm(llm: str | BaseLLM | Any) -> Any: + from crewai.llm import LLM + from crewai.llms.base_llm import BaseLLM as BaseLLMClass + + if isinstance(llm, str): + return LLM(model=llm) + if isinstance(llm, BaseLLMClass) or callable(getattr(llm, "call", None)): + return llm + raise ValueError(f"Invalid llm type: {type(llm)}. Expected str or BaseLLM.") + + def finalize_session_traces(self) -> None: + """Emit a final ``FlowFinishedEvent`` and finalize the trace batch. + + Pairs with ``flow.defer_trace_finalization = True`` (or + ``ConversationConfig(defer_trace_finalization=True)``): per-turn + ``handle_turn()`` skips the close, then a single call here at + session end emits one ``FlowFinishedEvent`` + ``finalize_batch()`` + so the whole conversation lands as one trace. + + Safe to call when not deferring — it's a no-op if the trace batch + was already finalized per-turn or never started. + """ + from crewai.events.event_bus import crewai_event_bus + from crewai.events.event_context import restore_event_scope + from crewai.events.listeners.tracing.trace_listener import ( + TraceCollectionListener, + ) + from crewai.events.types.flow_events import FlowFinishedEvent + + # Only emit the session-end event when a deferred flow_started is + # actually pending. ``_deferred_flow_started_event_id`` is set only by + # deferred kickoffs; when finalization was not deferred, each per-turn + # kickoff already emitted its own flow_finished, so emitting here would + # duplicate the session-end event and confuse tracing. Restoring the + # stashed scope also pairs this flow_finished with its opener instead + # of warning about an empty scope stack. + started_id = getattr(self, "_deferred_flow_started_event_id", None) + if started_id: + last_output = self._method_outputs[-1] if self._method_outputs else None + restore_event_scope(((started_id, "flow_started"),)) + try: + crewai_event_bus.emit( + self, + FlowFinishedEvent( + type="flow_finished", + flow_name=self.name or self.__class__.__name__, + result=last_output, + state=self._copy_and_serialize_state(), + ), + ) + except Exception: + logger.warning( + "FlowFinishedEvent emission failed during finalize_session_traces", + exc_info=True, + ) + finally: + restore_event_scope(()) + object.__setattr__(self, "_deferred_flow_started_event_id", None) + + trace_listener = TraceCollectionListener() + batch_manager = trace_listener.batch_manager + if batch_manager.batch_owner_type == "flow": + if trace_listener.first_time_handler.is_first_time: + trace_listener.first_time_handler.mark_events_collected() + trace_listener.first_time_handler.handle_execution_completion() + else: + batch_manager.finalize_batch() + + +__all__ = ["_ConversationalMixin"] diff --git a/lib/crewai/src/crewai/flow/__init__.py b/lib/crewai/src/crewai/flow/__init__.py index 6922725fa..7142403ad 100644 --- a/lib/crewai/src/crewai/flow/__init__.py +++ b/lib/crewai/src/crewai/flow/__init__.py @@ -4,6 +4,11 @@ from crewai.flow.async_feedback import ( HumanFeedbackProvider, PendingFeedbackContext, ) +from crewai.flow.conversation import ( + ChatState, + ConversationalConfig, + ConversationalInputs, +) from crewai.flow.flow import Flow, and_, listen, or_, router, start from crewai.flow.flow_config import flow_config from crewai.flow.flow_serializer import flow_structure @@ -18,7 +23,10 @@ from crewai.flow.visualization import ( __all__ = [ + "ChatState", "ConsoleProvider", + "ConversationalConfig", + "ConversationalInputs", "Flow", "FlowStructure", "HumanFeedbackPending", diff --git a/lib/crewai/src/crewai/flow/conversation.py b/lib/crewai/src/crewai/flow/conversation.py new file mode 100644 index 000000000..98a519b65 --- /dev/null +++ b/lib/crewai/src/crewai/flow/conversation.py @@ -0,0 +1,246 @@ +"""Conversational turn helpers for CrewAI Flows. + +Provides message history utilities, kickoff input normalization, and optional +class-level defaults via ``ConversationalConfig``. Session identity is ``state.id`` +(``inputs["id"]`` / ``kickoff(session_id=...)``), not a separate Flow field. +""" + +from __future__ import annotations + +from collections.abc import Sequence +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Any, Literal, TypedDict, cast +from uuid import uuid4 + +from pydantic import BaseModel, Field + +from crewai.utilities.types import LLMMessage + + +if TYPE_CHECKING: + from crewai.flow.flow import Flow + from crewai.llms.base_llm import BaseLLM + + +_EXIT_COMMANDS_DEFAULT: tuple[str, ...] = ("exit", "quit") + + +class ConversationalInputs(TypedDict, total=False): + """Conventional ``kickoff(inputs=...)`` keys for chat turns.""" + + id: str + user_message: str | dict[str, Any] + last_intent: str + + +@dataclass +class ConversationalConfig: + """Optional class-level defaults for conversational flows. + + Override per kickoff via ``user_message``, ``session_id``, ``intents``, etc. + """ + + default_intents: Sequence[str] | None = None + intent_llm: str | None = None + interactive_prompt: str = "You: " + interactive_timeout: float | None = None + exit_commands: Sequence[str] = field(default_factory=lambda: _EXIT_COMMANDS_DEFAULT) + defer_trace_finalization: bool = True + + +class ChatState(BaseModel): + """Recommended persisted state shape for multi-turn flows.""" + + id: str = Field(default_factory=lambda: str(uuid4())) + messages: list[LLMMessage] = Field(default_factory=list) + last_user_message: str | None = None + last_intent: str | None = None + session_ready: bool = False + + +def _coerce_user_message_text(user_message: str | dict[str, Any] | Any) -> str: + if isinstance(user_message, str): + return user_message + if isinstance(user_message, dict): + content = user_message.get("content") + if content is not None: + return str(content) + return str(user_message) + + +def normalize_kickoff_inputs( + inputs: dict[str, Any] | None, + *, + user_message: str | dict[str, Any] | None = None, + session_id: str | None = None, +) -> dict[str, Any] | None: + """Merge conversational kickoff kwargs into the inputs dict. + + Returns ``None`` when the caller passed no inputs and no conversational + kwargs — so ``FlowStartedEvent.inputs`` stays ``None`` for stateless flows + instead of being materialized as an empty dict. + """ + if inputs is None and user_message is None and session_id is None: + return None + + merged: dict[str, Any] = dict(inputs or {}) + + if session_id is not None: + merged["id"] = session_id + + if user_message is not None: + merged["user_message"] = user_message + + return merged + + +def get_conversation_messages(flow: Flow[Any]) -> list[LLMMessage]: + """Read message history from flow state or the internal fallback buffer.""" + buffer: list[LLMMessage] = getattr(flow, "_conversation_messages", []) + state = getattr(flow, "_state", None) + if state is None: + return list(buffer) + + if isinstance(state, dict): + messages = state.get("messages") + if isinstance(messages, list): + return cast(list[LLMMessage], messages) + elif isinstance(state, BaseModel) and hasattr(state, "messages"): + messages = getattr(state, "messages", None) + if isinstance(messages, list): + return cast(list[LLMMessage], messages) + + return list(buffer) + + +def append_message( + flow: Flow[Any], + role: Literal["user", "assistant", "system", "tool"], + content: str, + **extra: Any, +) -> None: + """Append a message to ``state.messages`` or the flow fallback buffer.""" + message: LLMMessage = {"role": role, "content": content} + for key, value in extra.items(): + if key in ("tool_call_id", "name", "tool_calls", "files"): + message[key] = value # type: ignore[literal-required] + + state = getattr(flow, "_state", None) + if state is not None: + if isinstance(state, dict): + messages = state.get("messages") + if isinstance(messages, list): + messages.append(message) + return + elif isinstance(state, BaseModel) and hasattr(state, "messages"): + messages = getattr(state, "messages", None) + if messages is None: + object.__setattr__(state, "messages", []) + messages = state.messages + if isinstance(messages, list): + messages.append(message) + return + + if not hasattr(flow, "_conversation_messages"): + object.__setattr__(flow, "_conversation_messages", []) + flow._conversation_messages.append(message) + + +def set_state_field(flow: Flow[Any], name: str, value: Any) -> None: + """Set a field on structured or dict flow state when present.""" + state = getattr(flow, "_state", None) + if state is None: + return + if isinstance(state, dict): + state[name] = value + elif isinstance(state, BaseModel) and hasattr(state, name): + object.__setattr__(state, name, value) + + +def receive_user_message( + flow: Flow[Any], + text: str, + *, + outcomes: Sequence[str] | None = None, + llm: str | BaseLLM | None = None, +) -> str: + """Record a user turn: append message and optionally classify intent.""" + append_message(flow, "user", text) + set_state_field(flow, "last_user_message", text) + + if outcomes and llm is not None: + intent = flow.classify_intent( + text, + outcomes, + llm=llm, + context=get_conversation_messages(flow), + ) + set_state_field(flow, "last_intent", intent) + return intent + + return text + + +def prepare_conversational_turn( + flow: Flow[Any], + *, + user_message: str | dict[str, Any] | None = None, + intents: Sequence[str] | None = None, + intent_llm: str | BaseLLM | None = None, + config: ConversationalConfig | None = None, +) -> None: + """Hydrate conversation state after inputs are merged into flow state.""" + if user_message is None: + state = getattr(flow, "_state", None) + if isinstance(state, dict) and "user_message" in state: + user_message = state["user_message"] + elif isinstance(state, BaseModel) and hasattr(state, "user_message"): + user_message = getattr(state, "user_message", None) + + if user_message is None: + return + + text = _coerce_user_message_text(user_message) + if not text.strip(): + return + + # Fresh classification each turn (do not reuse prior turn's route label). + set_state_field(flow, "last_intent", None) + + resolved_intents = intents + if resolved_intents is None and config is not None: + resolved_intents = config.default_intents + + resolved_llm = intent_llm + if resolved_llm is None and config is not None: + resolved_llm = config.intent_llm + + if resolved_intents: + if resolved_llm is None: + raise ValueError("intent_llm is required when intents are provided") + receive_user_message( + flow, + text, + outcomes=resolved_intents, + llm=resolved_llm, + ) + else: + receive_user_message(flow, text) + + +def input_history_to_messages(entries: Sequence[Any]) -> list[LLMMessage]: + """Convert ``Flow.input_history`` entries to LLM message format.""" + messages: list[LLMMessage] = [] + for entry in entries: + prompt = entry.get("message") if isinstance(entry, dict) else None + response = entry.get("response") if isinstance(entry, dict) else None + if prompt: + messages.append({"role": "assistant", "content": str(prompt)}) + if response: + messages.append({"role": "user", "content": str(response)}) + return messages + + +def get_conversational_config(flow: Flow[Any]) -> ConversationalConfig | None: + """Return class-level ``conversational_config`` if defined.""" + return getattr(type(flow), "conversational_config", None) diff --git a/lib/crewai/src/crewai/flow/flow_context.py b/lib/crewai/src/crewai/flow/flow_context.py index 0ff6cf973..474360aa3 100644 --- a/lib/crewai/src/crewai/flow/flow_context.py +++ b/lib/crewai/src/crewai/flow/flow_context.py @@ -18,3 +18,7 @@ current_flow_id: contextvars.ContextVar[str | None] = contextvars.ContextVar( current_flow_method_name: contextvars.ContextVar[str] = contextvars.ContextVar( "flow_method_name", default="unknown" ) + +current_flow_name: contextvars.ContextVar[str | None] = contextvars.ContextVar( + "flow_name", default=None +) diff --git a/lib/crewai/src/crewai/flow/flow_serializer.py b/lib/crewai/src/crewai/flow/flow_serializer.py index 9d1668da8..028b0a430 100644 --- a/lib/crewai/src/crewai/flow/flow_serializer.py +++ b/lib/crewai/src/crewai/flow/flow_serializer.py @@ -547,6 +547,16 @@ def flow_structure(flow_class: type) -> FlowStructureInfo: if not is_flow_method: continue + # Conversational built-ins on the base ``Flow`` class (``conversation_start``, + # ``route_conversation``, ``converse_turn``, etc.) are inert on non-chat + # subclasses — they're not registered in ``_start_methods`` / ``_listeners``, + # so excluding them here keeps the serialized structure aligned with what + # actually fires at runtime. + if getattr(attr, "__conversational_only__", False) and not getattr( + flow_class, "conversational", False + ): + continue + all_method_names.add(attr_name) method_type = _get_method_type(attr_name, attr, start_methods, routers) diff --git a/lib/crewai/src/crewai/flow/flow_wrappers.py b/lib/crewai/src/crewai/flow/flow_wrappers.py index 3eaa67699..80292671b 100644 --- a/lib/crewai/src/crewai/flow/flow_wrappers.py +++ b/lib/crewai/src/crewai/flow/flow_wrappers.py @@ -75,6 +75,7 @@ class FlowMethod(Generic[P, R]): "__is_router__", "__router_paths__", "__human_feedback_config__", + "__conversational_only__", # gates registration on Flow.conversational "_hf_llm", # Live LLM object for HITL resume ]: if hasattr(meth, attr): diff --git a/lib/crewai/src/crewai/flow/runtime.py b/lib/crewai/src/crewai/flow/runtime.py index 31567ed1a..0a2a84a95 100644 --- a/lib/crewai/src/crewai/flow/runtime.py +++ b/lib/crewai/src/crewai/flow/runtime.py @@ -84,6 +84,11 @@ from crewai.events.types.flow_events import ( MethodExecutionPausedEvent, MethodExecutionStartedEvent, ) +from crewai.experimental.conversational import ( + ConversationConfig, + ConversationState, +) +from crewai.experimental.conversational_mixin import _ConversationalMixin from crewai.flow.constants import AND_CONDITION, OR_CONDITION from crewai.flow.flow_context import current_flow_id, current_flow_request_id from crewai.flow.flow_definition import ( @@ -91,6 +96,7 @@ from crewai.flow.flow_definition import ( _extract_all_methods_recursive, _normalize_condition, extract_flow_definition, + get_possible_return_constants, is_flow_condition_dict, is_flow_method, is_flow_method_name, @@ -141,6 +147,16 @@ from crewai.utilities.streaming import ( signal_end, signal_error, ) +from crewai.utilities.types import LLMMessage + + +# Runtime alias so Pydantic can resolve the ``execution_context`` field's +# annotation in subclass modules without those modules needing to import +# ``crewai.context.ExecutionContext`` themselves. The real class is brought +# in under ``TYPE_CHECKING`` above for static analysis. We can't import it at +# runtime because ``crewai.context`` is loaded mid-initialization when this +# module is imported through ``crewai.__init__`` (circular). +ExecutionContext = Any # type: ignore[assignment,misc] logger = logging.getLogger(__name__) @@ -589,6 +605,82 @@ class FlowMeta(ModelMetaclass): namespace ) + # === EXPERIMENTAL: conversational gating === + # The built-in conversational graph (``conversation_start``, + # ``route_conversation``, ``converse_turn``, ``end_conversation``, + # ``answer_from_history_turn``) lives on ``Flow`` itself, decorated + # with ``@_conversational_only``. We don't want those methods to + # register on non-chat flows. The opt-in is ``conversational = True`` + # on the subclass; otherwise the methods exist as inert attributes. + is_conversational = bool(namespace.get("conversational", False)) + if not is_conversational: + for base in bases: + if getattr(base, "conversational", False): + is_conversational = True + break + + # 1. Strip conversational-only methods that landed in the namespace + # extraction when this class isn't conversational. Applies to ``Flow`` + # itself (its own namespace declares the conversational methods). + if not is_conversational: + + def _is_conv_only(attr_name: str) -> bool: + attr_value = namespace.get(attr_name) + return bool(getattr(attr_value, "__conversational_only__", False)) + + start_methods = [m for m in start_methods if not _is_conv_only(m)] + listeners = {k: v for k, v in listeners.items() if not _is_conv_only(k)} + routers = {r for r in routers if not _is_conv_only(r)} + router_paths = { + k: v for k, v in router_paths.items() if not _is_conv_only(k) + } + + # 2. Harvest conversational-only methods from base classes when this + # subclass opts in. (extract_flow_definition only scans the current + # namespace; without this step, ``class MyChat(Flow): conversational + # = True`` would have an empty graph.) + if is_conversational: + already_registered: set[str] = set(start_methods) | set(listeners.keys()) + for base in bases: + for attr_name in dir(base): + if attr_name.startswith("_") or attr_name in already_registered: + continue + attr_value = getattr(base, attr_name, None) + if not is_flow_method(attr_value): + continue + if not getattr(attr_value, "__conversational_only__", False): + continue + already_registered.add(attr_name) + + if hasattr(attr_value, "__is_start_method__"): + start_methods.append(attr_name) + + trigger_methods = getattr(attr_value, "__trigger_methods__", None) + if trigger_methods is not None: + condition_type = getattr( + attr_value, "__condition_type__", OR_CONDITION + ) + trigger_condition = getattr( + attr_value, "__trigger_condition__", None + ) + if trigger_condition is not None: + listeners[attr_name] = trigger_condition + else: + listeners[attr_name] = (condition_type, trigger_methods) + + if getattr(attr_value, "__is_router__", False): + routers.add(attr_name) + paths = getattr(attr_value, "__router_paths__", None) + if paths: + router_paths[attr_name] = paths + else: + possible_returns = get_possible_return_constants( + attr_value + ) + router_paths[attr_name] = ( + possible_returns if possible_returns else [] + ) + cls._start_methods = start_methods # type: ignore[attr-defined] cls._listeners = listeners # type: ignore[attr-defined] cls._routers = routers # type: ignore[attr-defined] @@ -597,7 +689,7 @@ class FlowMeta(ModelMetaclass): return cls -class Flow(BaseModel, Generic[T], metaclass=FlowMeta): +class Flow(_ConversationalMixin, BaseModel, Generic[T], metaclass=FlowMeta): """Base class for all flows. type parameter T must be either dict[str, Any] or a subclass of BaseModel.""" @@ -614,6 +706,39 @@ class Flow(BaseModel, Generic[T], metaclass=FlowMeta): _routers: ClassVar[set[FlowMethodName]] = set() _router_paths: ClassVar[dict[FlowMethodName, list[FlowMethodName]]] = {} + # === EXPERIMENTAL: conversational mode === + # When ``conversational = True`` on a subclass, the built-in conversational + # graph (``conversation_start`` -> ``route_conversation`` -> ``converse_turn`` + # / ``end_conversation`` / ``answer_from_history_turn``) registers and + # ``handle_turn`` becomes the chat entry point. When ``False`` (default), + # the methods exist as inert attributes and never register or fire — + # non-chat flows pay no runtime cost. + # + # ⚠ EXPERIMENTAL FEATURE. The whole conversational surface + # (``conversational`` ClassVar, ``handle_turn``, ``ConversationConfig``, + # ``RouterConfig``, ``ConversationState``, the built-in graph + helpers) + # lives under ``crewai.experimental`` and may change shape before + # graduating. Pin your CrewAI version if you depend on specific + # behavior, and watch the changelog for breaking updates. + conversational: ClassVar[bool] = False + conversational_config: ClassVar[ConversationConfig | None] = None + builtin_routes: ClassVar[tuple[str, ...]] = ("converse", "end") + internal_routes: ClassVar[tuple[str, ...]] = ( + "answer_from_history", + "conversation_start", + ) + builtin_route_descriptions: ClassVar[dict[str, str]] = { + "converse": ( + "Ordinary chat, follow-ups, summaries, clarifications, and " + "questions answerable from prior conversation history." + ), + "end": ("User signals the conversation is finished (goodbye, exit, done)."), + "answer_from_history": ( + "Answer directly from prior conversation history without invoking " + "tools, agents, or custom routes." + ), + } + entity_type: Literal["flow"] = "flow" initial_state: Annotated[ # type: ignore[type-arg] @@ -639,6 +764,15 @@ class Flow(BaseModel, Generic[T], metaclass=FlowMeta): ), ] = Field(default=None) suppress_flow_events: bool = Field(default=False) + defer_trace_finalization: bool = Field( + default=False, + description=( + "When True, skip per-kickoff ``FlowFinishedEvent`` + trace-batch " + "finalization. ``finalize_session_traces()`` does the final emit " + "+ finalize. Use for multi-turn chat sessions where every " + "``handle_turn()`` is a turn within one logical trace." + ), + ) human_feedback_history: list[HumanFeedbackResult] = Field(default_factory=list) last_human_feedback: HumanFeedbackResult | None = Field(default=None) @@ -769,6 +903,10 @@ class Flow(BaseModel, Generic[T], metaclass=FlowMeta): _human_feedback_method_outputs: dict[str, Any] = PrivateAttr(default_factory=dict) _input_history: list[InputHistoryEntry] = PrivateAttr(default_factory=list) _state: Any = PrivateAttr(default=None) + _conversation_messages: list[LLMMessage] = PrivateAttr(default_factory=list) + _pending_user_message: str | dict[str, Any] | None = PrivateAttr(default=None) + _pending_intents: Sequence[str] | None = PrivateAttr(default=None) + _pending_intent_llm: str | "BaseLLM" | None = PrivateAttr(default=None) def __class_getitem__(cls: type[Flow[T]], item: type[T]) -> type[Flow[T]]: # type: ignore[override] class _FlowGeneric(cls): # type: ignore[valid-type,misc] @@ -821,13 +959,48 @@ class Flow(BaseModel, Generic[T], metaclass=FlowMeta): flow_name = sanitize_scope_name(self.name or self.__class__.__name__) self.memory = Memory(root_scope=f"/flow/{flow_name}") - for method_name in dir(self): - if not method_name.startswith("_"): - method = getattr(self, method_name) - if is_flow_method(method): - if not hasattr(method, "__self__"): - method = method.__get__(self, self.__class__) - self._methods[method.__name__] = method + # Build the runtime method lookup. ``_start_methods`` / ``_listeners`` / + # ``_routers`` are populated by ``FlowMeta.__new__`` and are the source + # of truth for which slots are flow methods — including slots a + # subclass overrode without re-decorating. Walk those slots first so + # the override (which may be a plain function) still gets bound here. + registered_slots: set[str] = set() + registered_slots.update(getattr(type(self), "_start_methods", [])) + registered_slots.update(getattr(type(self), "_listeners", {}).keys()) + registered_slots.update(getattr(type(self), "_routers", set())) + for method_name in registered_slots: + method = getattr(self, method_name, None) + if method is None: + continue + if not hasattr(method, "__self__"): + method = method.__get__(self, self.__class__) + self._methods[FlowMethodName(method_name)] = method + + # Also pick up any leftover flow-decorated attributes that aren't + # already registered (defensive — preserves the prior catch-all scan). + # We walk the MRO's class ``__dict__`` rather than ``dir(self)`` + + # ``getattr`` so we don't trigger ``@property`` descriptors (those + # would run user code mid-init, before state is set up — e.g. a + # user property accessing ``self.state.messages`` would crash). + # Conversational-only methods are skipped on non-chat flows. + is_conversational = getattr(type(self), "conversational", False) + seen_in_dict: set[str] = set() + for klass in type(self).__mro__: + for method_name, raw in klass.__dict__.items(): + if method_name.startswith("_") or method_name in self._methods: + continue + if method_name in seen_in_dict: + continue + seen_in_dict.add(method_name) + if not is_flow_method(raw): + continue + if ( + getattr(raw, "__conversational_only__", False) + and not is_conversational + ): + continue + bound = raw.__get__(self, self.__class__) + self._methods[FlowMethodName(method_name)] = bound def recall(self, query: str, **kwargs: Any) -> Any: """Recall relevant memories. Delegates to this flow's memory. @@ -1458,6 +1631,18 @@ class Flow(BaseModel, Generic[T], metaclass=FlowMeta): """ init_state = self.initial_state + # Conversational subclasses default to ``ConversationState`` if the + # user didn't supply an explicit type parameter (``Flow[...]``) or an + # ``initial_state``. This makes ``class MyChat(Flow): conversational + # = True`` work without forcing every user to import and parameterize + # ``ConversationState`` themselves. + if ( + init_state is None + and getattr(type(self), "conversational", False) + and not hasattr(self, "_initial_state_t") + ): + return cast(T, ConversationState()) + if init_state is None and hasattr(self, "_initial_state_t"): state_type = self._initial_state_t if isinstance(state_type, type): @@ -2011,30 +2196,51 @@ class Flow(BaseModel, Generic[T], metaclass=FlowMeta): if filtered_inputs: self._initialize_state(filtered_inputs) + # Conversational hook: apply the pending user message AFTER state + # restore so it survives ``self.persistence.load_state(...)``. + # ``handle_turn`` stashes the message on ``self._pending_user_message`` + # before calling ``kickoff``; this drains it. + if ( + getattr(type(self), "conversational", False) + and self._pending_user_message is not None + ): + self._apply_pending_conversational_turn() + if get_current_parent_id() is None: reset_emission_counter() reset_last_event_id() - if not self.suppress_flow_events: - future = crewai_event_bus.emit( - self, - FlowStartedEvent( - type="flow_started", - flow_name=self.name or self.__class__.__name__, - inputs=inputs, - ), + # ``FlowStartedEvent`` always fires — ``suppress_flow_events`` + # only hides the Rich console panel (and the textual log line + # below), it doesn't gate observability events. Tracing / + # downstream listeners still need to see flow_started. + started_event = FlowStartedEvent( + type="flow_started", + flow_name=self.name or self.__class__.__name__, + inputs=inputs, + ) + future = crewai_event_bus.emit(self, started_event) + if future: + try: + await asyncio.wrap_future(future) + except Exception: + logger.warning("FlowStartedEvent handler failed", exc_info=True) + # Stash the started event id so a deferred + # ``finalize_session_traces()`` can restore the event scope + # before emitting ``FlowFinishedEvent`` (otherwise the bus + # warns "Ending event 'flow_finished' emitted with empty + # scope stack"). + if self._should_defer_trace_finalization(): + object.__setattr__( + self, "_deferred_flow_started_event_id", started_event.event_id ) - if future: - try: - await asyncio.wrap_future(future) - except Exception: - logger.warning("FlowStartedEvent handler failed", exc_info=True) + if not self.suppress_flow_events: self._log_flow_event( f"Flow started with ID: {self.flow_id}", color="bold magenta" ) - # After FlowStarted (when not suppressed): env events must not pre-empt - # trace batch init with implicit "crew" execution_type. + # After FlowStarted: env events must not pre-empt trace batch init + # with implicit "crew" execution_type. get_env_context() if inputs is not None and "id" not in inputs: @@ -2061,11 +2267,21 @@ class Flow(BaseModel, Generic[T], metaclass=FlowMeta): if unconditional_starts else self._start_methods ) - tasks = [ - self._execute_start_method(start_method) - for start_method in starts_to_execute - ] - await asyncio.gather(*tasks) + if getattr(type(self), "conversational", False): + # Conversational mode: run @start methods sequentially so + # user setup (e.g. permission loading) completes before + # the router fires. ``_start_methods`` preserves + # declaration + harvest order, with ``conversation_start`` + # at the end — its router decision only runs after every + # user start finishes. + for start_method in starts_to_execute: + await self._execute_start_method(start_method) + else: + tasks = [ + self._execute_start_method(start_method) + for start_method in starts_to_execute + ] + await asyncio.gather(*tasks) except Exception as e: # Check if flow was paused for human feedback from crewai.flow.async_feedback.types import HumanFeedbackPending @@ -2133,7 +2349,13 @@ class Flow(BaseModel, Generic[T], metaclass=FlowMeta): ) self._event_futures.clear() - if not self.suppress_flow_events: + # When ``defer_trace_finalization`` is set, skip both per-turn + # ``FlowFinishedEvent`` AND trace-batch finalization. The caller + # invokes ``finalize_session_traces()`` once at session end to + # close out the whole conversation as one trace. The flag is + # read from EITHER the instance attribute (set by user code) OR + # the class-level ``ConversationConfig.defer_trace_finalization``. + if not self._should_defer_trace_finalization(): future = crewai_event_bus.emit( self, FlowFinishedEvent( @@ -2151,7 +2373,6 @@ class Flow(BaseModel, Generic[T], metaclass=FlowMeta): "FlowFinishedEvent handler failed", exc_info=True ) - if not self.suppress_flow_events: trace_listener = TraceCollectionListener() if trace_listener.batch_manager.batch_owner_type == "flow": if trace_listener.first_time_handler.is_first_time: @@ -2343,19 +2564,20 @@ class Flow(BaseModel, Generic[T], metaclass=FlowMeta): kwargs or {} ) - if not self.suppress_flow_events: - future = crewai_event_bus.emit( - self, - MethodExecutionStartedEvent( - type="method_execution_started", - method_name=method_name, - flow_name=self.name or self.__class__.__name__, - params=dumped_params, - state=self._copy_and_serialize_state(), - ), - ) - if future: - self._event_futures.append(future) + # MethodExecution events always fire — ``suppress_flow_events`` + # only hides the Rich console panel, not observability events. + future = crewai_event_bus.emit( + self, + MethodExecutionStartedEvent( + type="method_execution_started", + method_name=method_name, + flow_name=self.name or self.__class__.__name__, + params=dumped_params, + state=self._copy_and_serialize_state(), + ), + ) + if future: + self._event_futures.append(future) # Set method name in context so ask() can read it without # stack inspection. Must happen before copy_context() so the @@ -2397,18 +2619,19 @@ class Flow(BaseModel, Generic[T], metaclass=FlowMeta): self._completed_methods.add(method_name) finished_event_id: str | None = None - if not self.suppress_flow_events: - finished_event = MethodExecutionFinishedEvent( - type="method_execution_finished", - method_name=method_name, - flow_name=self.name or self.__class__.__name__, - state=self._copy_and_serialize_state(), - result=result, - ) - finished_event_id = finished_event.event_id - future = crewai_event_bus.emit(self, finished_event) - if future: - self._event_futures.append(future) + # MethodExecution events always fire even when console panels are + # suppressed; tracing depends on them. + finished_event = MethodExecutionFinishedEvent( + type="method_execution_finished", + method_name=method_name, + flow_name=self.name or self.__class__.__name__, + state=self._copy_and_serialize_state(), + result=result, + ) + finished_event_id = finished_event.event_id + future = crewai_event_bus.emit(self, finished_event) + if future: + self._event_futures.append(future) return result, finished_event_id except Exception as e: diff --git a/lib/crewai/src/crewai/translations/en.json b/lib/crewai/src/crewai/translations/en.json index 51a862026..9e5c17d41 100644 --- a/lib/crewai/src/crewai/translations/en.json +++ b/lib/crewai/src/crewai/translations/en.json @@ -35,6 +35,8 @@ "knowledge_search_query": "The original query is: {task_prompt}.", "knowledge_search_query_system_prompt": "Your goal is to rewrite the user query so that it is optimized for retrieval from a vector database. Consider how the query will be used to find relevant documents, and aim to make it more specific and context-aware. \n\n Do not include any other text than the rewritten query, especially any preamble or postamble and only add expected output format if its relevant to the rewritten query. \n\n Focus on the key words of the intended task and to retrieve the most relevant information. \n\n There will be some extra context provided that might need to be removed such as expected_output formats structured_outputs and other instructions.", "human_feedback_collapse": "Based on the following human feedback, determine which outcome best matches their intent.\n\nFeedback: {feedback}\n\nPossible outcomes: {outcomes}\n\nRespond with ONLY one of the exact outcome values listed above, nothing else.", + "conversational_system_prompt": "You are a helpful conversational assistant. Maintain context across turns, answer using the canonical message history when possible, and respond clearly and concisely. Ask for clarification when the user's intent is ambiguous.", + "conversational_answer_from_history_prompt": "Given the current user message and the canonical message history, decide whether the assistant can answer from message_history without running additional tools or agents. If so, answer clearly using only that context.", "hitl_pre_review_system": "You are reviewing content before a human sees it. Apply the lessons from past human feedback to improve the output. Preserve the original meaning and structure, but incorporate the corrections and preferences indicated by the lessons.", "hitl_pre_review_user": "Output to review:\n{output}\n\nLessons from past human feedback:\n{lessons}\n\nApply the lessons to improve the output.", "hitl_distill_system": "You extract generalizable lessons from human feedback on system outputs. A lesson should be a reusable rule or preference that applies to future similar outputs -- not a one-time correction specific to this exact content.\n\nExamples of good lessons:\n- Always include source citations when making factual claims\n- Use bullet points instead of long paragraphs for action items\n- Avoid technical jargon when the audience is non-technical\n\nIf the feedback is just approval (e.g. looks good, approved) or contains no generalizable guidance, return an empty list.", diff --git a/lib/crewai/tests/cassettes/tracing/TestTraceListenerSetup.test_trace_listener_collects_crew_events.yaml b/lib/crewai/tests/cassettes/tracing/TestTraceListenerSetup.test_trace_listener_collects_crew_events.yaml index a729313c4..1d90dae96 100644 --- a/lib/crewai/tests/cassettes/tracing/TestTraceListenerSetup.test_trace_listener_collects_crew_events.yaml +++ b/lib/crewai/tests/cassettes/tracing/TestTraceListenerSetup.test_trace_listener_collects_crew_events.yaml @@ -17,251 +17,6 @@ interactions: status: code: 200 message: OK -- request: - body: '{"messages": [{"role": "system", "content": "You are Test Agent. Test backstory\nYour - personal goal is: Test goal\nTo give my best complete final answer to the task - respond using the exact following format:\n\nThought: I now can give a great - answer\nFinal Answer: Your final answer must be the great and the most complete - as possible, it must be outcome described.\n\nI MUST use these formats, my job - depends on it!"}, {"role": "user", "content": "\nCurrent Task: Say hello to - the world\n\nThis is the expected criteria for your final answer: hello world\nyou - MUST return the actual complete content as the final answer, not a summary.\n\nBegin! - This is VERY important to you, use the tools available and give your best Final - Answer, your job depends on it!\n\nThought:"}], "model": "gpt-4o-mini", "stop": - ["\nObservation:"]}' - headers: - accept: - - application/json - accept-encoding: - - gzip, deflate, zstd - connection: - - keep-alive - content-length: - - '825' - content-type: - - application/json - cookie: - - __cf_bm=ePO5hy0kEoADCuKcboFy1iS1qckCE5KCpifQaXnlomM-1754508545-1.0.1.1-ieWfjcdIxQIXGfaMizvmgTvZPRFehqDXliegaOT7EO.kt7KSSFGmNDcC35_D9hOhE.fJ5K302uX0snQF3nLaapds2dqgGbNcsyFPOKNvAdI; - _cfuvid=NaXWifUGChHp6Ap1mvfMrNzmO4HdzddrqXkSR9T.hYo-1754508545647-0.0.1.1-604800000 - host: - - api.openai.com - user-agent: - - OpenAI/Python 1.93.0 - x-stainless-arch: - - arm64 - x-stainless-async: - - 'false' - x-stainless-lang: - - python - x-stainless-os: - - MacOS - x-stainless-package-version: - - 1.93.0 - x-stainless-raw-response: - - 'true' - x-stainless-read-timeout: - - '600.0' - x-stainless-retry-count: - - '0' - x-stainless-runtime: - - CPython - x-stainless-runtime-version: - - 3.12.9 - method: POST - uri: https://api.openai.com/v1/chat/completions - response: - body: - string: "{\n \"id\": \"chatcmpl-C1e6uBsZ3iMw51p03hHTkBgHjA5ym\",\n \"object\"\ - : \"chat.completion\",\n \"created\": 1754508548,\n \"model\": \"gpt-4o-mini-2024-07-18\"\ - ,\n \"choices\": [\n {\n \"index\": 0,\n \"message\": {\n \ - \ \"role\": \"assistant\",\n \"content\": \"I now can give a great\ - \ answer \\nFinal Answer: hello world\",\n \"refusal\": null,\n \ - \ \"annotations\": []\n },\n \"logprobs\": null,\n \"\ - finish_reason\": \"stop\"\n }\n ],\n \"usage\": {\n \"prompt_tokens\"\ - : 157,\n \"completion_tokens\": 13,\n \"total_tokens\": 170,\n \"\ - prompt_tokens_details\": {\n \"cached_tokens\": 0,\n \"audio_tokens\"\ - : 0\n },\n \"completion_tokens_details\": {\n \"reasoning_tokens\"\ - : 0,\n \"audio_tokens\": 0,\n \"accepted_prediction_tokens\": 0,\n\ - \ \"rejected_prediction_tokens\": 0\n }\n },\n \"service_tier\"\ - : \"default\",\n \"system_fingerprint\": \"fp_34a54ae93c\"\n}\n" - headers: - CF-RAY: - - 96b0f0fb5c067ad9-SJC - Connection: - - keep-alive - Content-Type: - - application/json - Date: - - Wed, 06 Aug 2025 19:29:09 GMT - Server: - - cloudflare - Strict-Transport-Security: - - max-age=31536000; includeSubDomains; preload - Transfer-Encoding: - - chunked - X-Content-Type-Options: - - nosniff - access-control-expose-headers: - - X-Request-ID - alt-svc: - - h3=":443"; ma=86400 - cf-cache-status: - - DYNAMIC - openai-organization: - - crewai-iuxna1 - openai-processing-ms: - - '628' - openai-project: - - proj_xitITlrFeen7zjNSzML82h9x - openai-version: - - '2020-10-01' - x-envoy-upstream-service-time: - - '657' - x-ratelimit-limit-project-tokens: - - '150000000' - x-ratelimit-limit-requests: - - '30000' - x-ratelimit-limit-tokens: - - '150000000' - x-ratelimit-remaining-project-tokens: - - '149999827' - x-ratelimit-remaining-requests: - - '29999' - x-ratelimit-remaining-tokens: - - '149999827' - x-ratelimit-reset-project-tokens: - - 0s - x-ratelimit-reset-requests: - - 2ms - x-ratelimit-reset-tokens: - - 0s - x-request-id: - - req_a0daca00035c423daf0e9df208720180 - status: - code: 200 - message: OK -- request: - body: '{"messages": [{"role": "system", "content": "You are Test Agent. Test backstory\nYour - personal goal is: Test goal\nTo give my best complete final answer to the task - respond using the exact following format:\n\nThought: I now can give a great - answer\nFinal Answer: Your final answer must be the great and the most complete - as possible, it must be outcome described.\n\nI MUST use these formats, my job - depends on it!"}, {"role": "user", "content": "\nCurrent Task: Say hello to - the world\n\nThis is the expected criteria for your final answer: hello world\nyou - MUST return the actual complete content as the final answer, not a summary.\n\nBegin! - This is VERY important to you, use the tools available and give your best Final - Answer, your job depends on it!\n\nThought:"}], "model": "gpt-4o-mini"}' - headers: - accept: - - application/json - accept-encoding: - - gzip, deflate, zstd - connection: - - keep-alive - content-length: - - '797' - content-type: - - application/json - cookie: - - __cf_bm=f59gEPi_nA3TTxtjbKaSQpvkTwezaAqOvqfxiGzRnVQ-1754508546-1.0.1.1-JrSaytxVIQSVE00I.vyGj7d4HJbbMV6R9fWPJbkDKu0Y8ueMRzTwTUnfz0YzP5nsZX5oxoE6WlmFxOuz0rRuq9YhZZsO_TbaFBOFk1jGK9U; - _cfuvid=3D66v3.J_RcVoYy9dlF.jHwq1zTIm842xynZxzSy1Wc-1754508546352-0.0.1.1-604800000 - host: - - api.openai.com - user-agent: - - OpenAI/Python 1.93.0 - x-stainless-arch: - - arm64 - x-stainless-async: - - 'false' - x-stainless-lang: - - python - x-stainless-os: - - MacOS - x-stainless-package-version: - - 1.93.0 - x-stainless-raw-response: - - 'true' - x-stainless-read-timeout: - - '200.0' - x-stainless-retry-count: - - '0' - x-stainless-runtime: - - CPython - x-stainless-runtime-version: - - 3.12.9 - method: POST - uri: https://api.openai.com/v1/chat/completions - response: - body: - string: "{\n \"id\": \"chatcmpl-C1e6vUMjwrC8Zt9Htraa70hbbt5d7\",\n \"object\"\ - : \"chat.completion\",\n \"created\": 1754508549,\n \"model\": \"gpt-4o-mini-2024-07-18\"\ - ,\n \"choices\": [\n {\n \"index\": 0,\n \"message\": {\n \ - \ \"role\": \"assistant\",\n \"content\": \"I now can give a great\ - \ answer \\nFinal Answer: hello world\",\n \"refusal\": null,\n \ - \ \"annotations\": []\n },\n \"logprobs\": null,\n \"\ - finish_reason\": \"stop\"\n }\n ],\n \"usage\": {\n \"prompt_tokens\"\ - : 157,\n \"completion_tokens\": 13,\n \"total_tokens\": 170,\n \"\ - prompt_tokens_details\": {\n \"cached_tokens\": 0,\n \"audio_tokens\"\ - : 0\n },\n \"completion_tokens_details\": {\n \"reasoning_tokens\"\ - : 0,\n \"audio_tokens\": 0,\n \"accepted_prediction_tokens\": 0,\n\ - \ \"rejected_prediction_tokens\": 0\n }\n },\n \"service_tier\"\ - : \"default\",\n \"system_fingerprint\": \"fp_34a54ae93c\"\n}\n" - headers: - CF-RAY: - - 96b0f101793aeb2c-SJC - Connection: - - keep-alive - Content-Type: - - application/json - Date: - - Wed, 06 Aug 2025 19:29:09 GMT - Server: - - cloudflare - Strict-Transport-Security: - - max-age=31536000; includeSubDomains; preload - Transfer-Encoding: - - chunked - X-Content-Type-Options: - - nosniff - access-control-expose-headers: - - X-Request-ID - alt-svc: - - h3=":443"; ma=86400 - cf-cache-status: - - DYNAMIC - openai-organization: - - crewai-iuxna1 - openai-processing-ms: - - '541' - openai-project: - - proj_xitITlrFeen7zjNSzML82h9x - openai-version: - - '2020-10-01' - x-envoy-upstream-service-time: - - '557' - x-ratelimit-limit-project-tokens: - - '150000000' - x-ratelimit-limit-requests: - - '30000' - x-ratelimit-limit-tokens: - - '150000000' - x-ratelimit-remaining-project-tokens: - - '149999827' - x-ratelimit-remaining-requests: - - '29999' - x-ratelimit-remaining-tokens: - - '149999827' - x-ratelimit-reset-project-tokens: - - 0s - x-ratelimit-reset-requests: - - 2ms - x-ratelimit-reset-tokens: - - 0s - x-request-id: - - req_df70f95325b14817a692f23cf9cca880 - status: - code: 200 - message: OK - request: body: '{"trace_id": "2487456d-e03a-4eae-92a1-e9779e8f06a1", "execution_type": "crew", "execution_context": {"crew_fingerprint": null, "crew_name": "Unknown @@ -330,6 +85,117 @@ interactions: status: code: 404 message: Not Found +- request: + body: '{"messages":[{"role":"system","content":"You are Test Agent. Test backstory\nYour + personal goal is: Test goal"},{"role":"user","content":"\nCurrent Task: Say + hello to the world\n\nThis is the expected criteria for your final answer: hello + world\nyou MUST return the actual complete content as the final answer, not + a summary.\n\nProvide your complete response:"}],"model":"gpt-4o-mini"}' + headers: + User-Agent: + - X-USER-AGENT-XXX + accept: + - application/json + accept-encoding: + - ACCEPT-ENCODING-XXX + authorization: + - AUTHORIZATION-XXX + connection: + - keep-alive + content-length: + - '386' + content-type: + - application/json + host: + - api.openai.com + x-stainless-arch: + - X-STAINLESS-ARCH-XXX + x-stainless-async: + - 'false' + x-stainless-lang: + - python + x-stainless-os: + - X-STAINLESS-OS-XXX + x-stainless-package-version: + - 2.32.0 + x-stainless-read-timeout: + - X-STAINLESS-READ-TIMEOUT-XXX + x-stainless-retry-count: + - '0' + x-stainless-runtime: + - CPython + x-stainless-runtime-version: + - 3.13.3 + method: POST + uri: https://api.openai.com/v1/chat/completions + response: + body: + string: "{\n \"id\": \"chatcmpl-DmQBou4covfqbekZpX1DEVm8haN4N\",\n \"object\"\ + : \"chat.completion\",\n \"created\": 1780432788,\n \"model\": \"gpt-4o-mini-2024-07-18\"\ + ,\n \"choices\": [\n {\n \"index\": 0,\n \"message\": {\n \ + \ \"role\": \"assistant\",\n \"content\": \"hello world\",\n \ + \ \"refusal\": null,\n \"annotations\": []\n },\n \"\ + logprobs\": null,\n \"finish_reason\": \"stop\"\n }\n ],\n \"usage\"\ + : {\n \"prompt_tokens\": 71,\n \"completion_tokens\": 2,\n \"total_tokens\"\ + : 73,\n \"prompt_tokens_details\": {\n \"cached_tokens\": 0,\n \ + \ \"audio_tokens\": 0\n },\n \"completion_tokens_details\": {\n \ + \ \"reasoning_tokens\": 0,\n \"audio_tokens\": 0,\n \"accepted_prediction_tokens\"\ + : 0,\n \"rejected_prediction_tokens\": 0\n }\n },\n \"service_tier\"\ + : \"default\",\n \"system_fingerprint\": \"fp_df8c8d3b43\"\n}\n" + headers: + Access-Control-Expose-Headers: + - ACCESS-CONTROL-XXX + CF-Cache-Status: + - DYNAMIC + CF-Ray: + - a05944f98aa2456e-SJC + Connection: + - keep-alive + Content-Type: + - application/json + Date: + - Tue, 02 Jun 2026 20:39:48 GMT + Server: + - cloudflare + Strict-Transport-Security: + - STS-XXX + Transfer-Encoding: + - chunked + X-Content-Type-Options: + - X-CONTENT-TYPE-XXX + access-control-expose-headers: + - ACCESS-CONTROL-XXX + alt-svc: + - h3=":443"; ma=86400 + openai-organization: + - OPENAI-ORG-XXX + openai-processing-ms: + - '394' + openai-project: + - OPENAI-PROJECT-XXX + openai-version: + - '2020-10-01' + set-cookie: + - SET-COOKIE-XXX + x-openai-proxy-wasm: + - v0.1 + x-ratelimit-limit-requests: + - X-RATELIMIT-LIMIT-REQUESTS-XXX + x-ratelimit-limit-tokens: + - X-RATELIMIT-LIMIT-TOKENS-XXX + x-ratelimit-remaining-requests: + - X-RATELIMIT-REMAINING-REQUESTS-XXX + x-ratelimit-remaining-tokens: + - X-RATELIMIT-REMAINING-TOKENS-XXX + x-ratelimit-reset-requests: + - X-RATELIMIT-RESET-REQUESTS-XXX + x-ratelimit-reset-tokens: + - X-RATELIMIT-RESET-TOKENS-XXX + x-request-id: + - X-REQUEST-ID-XXX + status: + code: 200 + message: OK - request: body: '{"version": "0.152.0", "batch_id": "2487456d-e03a-4eae-92a1-e9779e8f06a1", "user_context": {"user_id": "anonymous", "organization_id": "", "session_id": @@ -471,4 +337,3 @@ interactions: status: code: 404 message: Not Found -version: 1 diff --git a/lib/crewai/tests/cassettes/tracing/TestTraceListenerSetup.test_trace_listener_ephemeral_batch.yaml b/lib/crewai/tests/cassettes/tracing/TestTraceListenerSetup.test_trace_listener_ephemeral_batch.yaml index b1092e35b..988c43346 100644 --- a/lib/crewai/tests/cassettes/tracing/TestTraceListenerSetup.test_trace_listener_ephemeral_batch.yaml +++ b/lib/crewai/tests/cassettes/tracing/TestTraceListenerSetup.test_trace_listener_ephemeral_batch.yaml @@ -1,4 +1,115 @@ interactions: +- request: + body: '{"messages":[{"role":"system","content":"You are Test Agent. Test backstory\nYour + personal goal is: Test goal"},{"role":"user","content":"\nCurrent Task: Say + hello to the world\n\nThis is the expected criteria for your final answer: hello + world\nyou MUST return the actual complete content as the final answer, not + a summary.\n\nProvide your complete response:"}],"model":"gpt-4o-mini"}' + headers: + User-Agent: + - X-USER-AGENT-XXX + accept: + - application/json + accept-encoding: + - ACCEPT-ENCODING-XXX + authorization: + - AUTHORIZATION-XXX + connection: + - keep-alive + content-length: + - '386' + content-type: + - application/json + host: + - api.openai.com + x-stainless-arch: + - X-STAINLESS-ARCH-XXX + x-stainless-async: + - 'false' + x-stainless-lang: + - python + x-stainless-os: + - X-STAINLESS-OS-XXX + x-stainless-package-version: + - 2.32.0 + x-stainless-read-timeout: + - X-STAINLESS-READ-TIMEOUT-XXX + x-stainless-retry-count: + - '0' + x-stainless-runtime: + - CPython + x-stainless-runtime-version: + - 3.13.3 + method: POST + uri: https://api.openai.com/v1/chat/completions + response: + body: + string: "{\n \"id\": \"chatcmpl-DmQXbDeAmcQ7GbteZjiMuOWd66nWp\",\n \"object\"\ + : \"chat.completion\",\n \"created\": 1780434139,\n \"model\": \"gpt-4o-mini-2024-07-18\"\ + ,\n \"choices\": [\n {\n \"index\": 0,\n \"message\": {\n \ + \ \"role\": \"assistant\",\n \"content\": \"hello world\",\n \ + \ \"refusal\": null,\n \"annotations\": []\n },\n \"\ + logprobs\": null,\n \"finish_reason\": \"stop\"\n }\n ],\n \"usage\"\ + : {\n \"prompt_tokens\": 71,\n \"completion_tokens\": 2,\n \"total_tokens\"\ + : 73,\n \"prompt_tokens_details\": {\n \"cached_tokens\": 0,\n \ + \ \"audio_tokens\": 0\n },\n \"completion_tokens_details\": {\n \ + \ \"reasoning_tokens\": 0,\n \"audio_tokens\": 0,\n \"accepted_prediction_tokens\"\ + : 0,\n \"rejected_prediction_tokens\": 0\n }\n },\n \"service_tier\"\ + : \"default\",\n \"system_fingerprint\": \"fp_df8c8d3b43\"\n}\n" + headers: + Access-Control-Expose-Headers: + - ACCESS-CONTROL-XXX + CF-Cache-Status: + - DYNAMIC + CF-Ray: + - a05965f97dd8ad81-SJC + Connection: + - keep-alive + Content-Type: + - application/json + Date: + - Tue, 02 Jun 2026 21:02:19 GMT + Server: + - cloudflare + Strict-Transport-Security: + - STS-XXX + Transfer-Encoding: + - chunked + X-Content-Type-Options: + - X-CONTENT-TYPE-XXX + access-control-expose-headers: + - ACCESS-CONTROL-XXX + alt-svc: + - h3=":443"; ma=86400 + openai-organization: + - OPENAI-ORG-XXX + openai-processing-ms: + - '330' + openai-project: + - OPENAI-PROJECT-XXX + openai-version: + - '2020-10-01' + set-cookie: + - SET-COOKIE-XXX + x-openai-proxy-wasm: + - v0.1 + x-ratelimit-limit-requests: + - X-RATELIMIT-LIMIT-REQUESTS-XXX + x-ratelimit-limit-tokens: + - X-RATELIMIT-LIMIT-TOKENS-XXX + x-ratelimit-remaining-requests: + - X-RATELIMIT-REMAINING-REQUESTS-XXX + x-ratelimit-remaining-tokens: + - X-RATELIMIT-REMAINING-TOKENS-XXX + x-ratelimit-reset-requests: + - X-RATELIMIT-RESET-REQUESTS-XXX + x-ratelimit-reset-tokens: + - X-RATELIMIT-RESET-TOKENS-XXX + x-request-id: + - X-REQUEST-ID-XXX + status: + code: 200 + message: OK - request: body: '{}' headers: @@ -88,129 +199,6 @@ interactions: status: code: 201 message: Created -- request: - body: '{"messages":[{"role":"system","content":"You are Test Agent. Test backstory\nYour - personal goal is: Test goal\nTo give my best complete final answer to the task - respond using the exact following format:\n\nThought: I now can give a great - answer\nFinal Answer: Your final answer must be the great and the most complete - as possible, it must be outcome described.\n\nI MUST use these formats, my job - depends on it!"},{"role":"user","content":"\nCurrent Task: Say hello to the - world\n\nThis is the expected criteria for your final answer: hello world\nyou - MUST return the actual complete content as the final answer, not a summary.\n\nBegin! - This is VERY important to you, use the tools available and give your best Final - Answer, your job depends on it!\n\nThought:"}],"model":"gpt-4o-mini"}' - headers: - accept: - - application/json - accept-encoding: - - gzip, deflate - authorization: - - AUTHORIZATION-XXX - connection: - - keep-alive - content-length: - - '787' - content-type: - - application/json - host: - - api.openai.com - user-agent: - - OpenAI/Python 1.109.1 - x-stainless-arch: - - X-STAINLESS-ARCH-XXX - x-stainless-async: - - 'false' - x-stainless-lang: - - python - x-stainless-os: - - X-STAINLESS-OS-XXX - x-stainless-package-version: - - 1.109.1 - x-stainless-read-timeout: - - X-STAINLESS-READ-TIMEOUT-XXX - x-stainless-retry-count: - - '0' - x-stainless-runtime: - - CPython - x-stainless-runtime-version: - - 3.12.10 - method: POST - uri: https://api.openai.com/v1/chat/completions - response: - body: - string: "{\n \"id\": \"chatcmpl-Ch5flaLDqIqyRdYlrrvZ3Pu6emSal\",\n \"object\"\ - : \"chat.completion\",\n \"created\": 1764385945,\n \"model\": \"gpt-4o-mini-2024-07-18\"\ - ,\n \"choices\": [\n {\n \"index\": 0,\n \"message\": {\n \ - \ \"role\": \"assistant\",\n \"content\": \"I now can give a great\ - \ answer \\nFinal Answer: hello world\",\n \"refusal\": null,\n \ - \ \"annotations\": []\n },\n \"logprobs\": null,\n \"\ - finish_reason\": \"stop\"\n }\n ],\n \"usage\": {\n \"prompt_tokens\"\ - : 157,\n \"completion_tokens\": 13,\n \"total_tokens\": 170,\n \"\ - prompt_tokens_details\": {\n \"cached_tokens\": 0,\n \"audio_tokens\"\ - : 0\n },\n \"completion_tokens_details\": {\n \"reasoning_tokens\"\ - : 0,\n \"audio_tokens\": 0,\n \"accepted_prediction_tokens\": 0,\n\ - \ \"rejected_prediction_tokens\": 0\n }\n },\n \"service_tier\"\ - : \"default\",\n \"system_fingerprint\": \"fp_560af6e559\"\n}\n" - headers: - CF-RAY: - - CF-RAY-XXX - Connection: - - keep-alive - Content-Type: - - application/json - Date: - - Sat, 29 Nov 2025 03:12:26 GMT - Server: - - cloudflare - Set-Cookie: - - SET-COOKIE-XXX - Strict-Transport-Security: - - STS-XXX - Transfer-Encoding: - - chunked - X-Content-Type-Options: - - X-CONTENT-TYPE-XXX - access-control-expose-headers: - - ACCESS-CONTROL-XXX - alt-svc: - - h3=":443"; ma=86400 - cf-cache-status: - - DYNAMIC - openai-organization: - - OPENAI-ORG-XXX - openai-processing-ms: - - '443' - openai-project: - - OPENAI-PROJECT-XXX - openai-version: - - '2020-10-01' - x-envoy-upstream-service-time: - - '655' - x-openai-proxy-wasm: - - v0.1 - x-ratelimit-limit-project-tokens: - - '150000000' - x-ratelimit-limit-requests: - - X-RATELIMIT-LIMIT-REQUESTS-XXX - x-ratelimit-limit-tokens: - - X-RATELIMIT-LIMIT-TOKENS-XXX - x-ratelimit-remaining-project-tokens: - - '149999827' - x-ratelimit-remaining-requests: - - X-RATELIMIT-REMAINING-REQUESTS-XXX - x-ratelimit-remaining-tokens: - - X-RATELIMIT-REMAINING-TOKENS-XXX - x-ratelimit-reset-project-tokens: - - 0s - x-ratelimit-reset-requests: - - X-RATELIMIT-RESET-REQUESTS-XXX - x-ratelimit-reset-tokens: - - X-RATELIMIT-RESET-TOKENS-XXX - x-request-id: - - X-REQUEST-ID-XXX - status: - code: 200 - message: OK - request: body: '{"events": [{"event_id": "e73c85bb-b00f-46be-b07f-cfbd398e24d9", "timestamp": "2025-11-29T03:12:24.774266+00:00", "type": "crew_kickoff_started", "event_data": @@ -416,4 +404,3 @@ interactions: status: code: 200 message: OK -version: 1 diff --git a/lib/crewai/tests/cassettes/tracing/TestTraceListenerSetup.test_trace_listener_with_authenticated_user.yaml b/lib/crewai/tests/cassettes/tracing/TestTraceListenerSetup.test_trace_listener_with_authenticated_user.yaml index aff3bee82..77f0f6745 100644 --- a/lib/crewai/tests/cassettes/tracing/TestTraceListenerSetup.test_trace_listener_with_authenticated_user.yaml +++ b/lib/crewai/tests/cassettes/tracing/TestTraceListenerSetup.test_trace_listener_with_authenticated_user.yaml @@ -18,126 +18,113 @@ interactions: code: 200 message: OK - request: - body: '{"messages": [{"role": "system", "content": "You are Test Agent. Test backstory\nYour - personal goal is: Test goal\nTo give my best complete final answer to the task - respond using the exact following format:\n\nThought: I now can give a great - answer\nFinal Answer: Your final answer must be the great and the most complete - as possible, it must be outcome described.\n\nI MUST use these formats, my job - depends on it!"}, {"role": "user", "content": "\nCurrent Task: Say hello to - the world\n\nThis is the expected criteria for your final answer: hello world\nyou - MUST return the actual complete content as the final answer, not a summary.\n\nBegin! - This is VERY important to you, use the tools available and give your best Final - Answer, your job depends on it!\n\nThought:"}], "model": "gpt-4o-mini", "stop": - ["\nObservation:"]}' + body: '{"messages":[{"role":"system","content":"You are Test Agent. Test backstory\nYour + personal goal is: Test goal"},{"role":"user","content":"\nCurrent Task: Say + hello to the world\n\nThis is the expected criteria for your final answer: hello + world\nyou MUST return the actual complete content as the final answer, not + a summary.\n\nProvide your complete response:"}],"model":"gpt-4o-mini"}' headers: + User-Agent: + - X-USER-AGENT-XXX accept: - application/json accept-encoding: - - gzip, deflate, zstd + - ACCEPT-ENCODING-XXX + authorization: + - AUTHORIZATION-XXX connection: - keep-alive content-length: - - '825' + - '386' content-type: - application/json - cookie: - - __cf_bm=oA9oTa3cE0ZaEUDRf0hCpnarSAQKzrVUhl6qDS4j09w-1755302115-1.0.1.1-gUUDl4ZqvBQkg7244DTwOmSiDUT2z_AiQu0P1xUaABjaufSpZuIlI5G0H7OSnW.ldypvpxjj45NGWesJ62M_2U7r20tHz_gMmDFw6D5ZiNc; - _cfuvid=ICenEGMmOE5jaOjwD30bAOwrF8.XRbSIKTBl1EyWs0o-1755302115700-0.0.1.1-604800000 host: - api.openai.com - user-agent: - - OpenAI/Python 1.93.0 x-stainless-arch: - - arm64 + - X-STAINLESS-ARCH-XXX x-stainless-async: - 'false' x-stainless-lang: - python x-stainless-os: - - MacOS + - X-STAINLESS-OS-XXX x-stainless-package-version: - - 1.93.0 - x-stainless-raw-response: - - 'true' + - 2.32.0 x-stainless-read-timeout: - - '600.0' + - X-STAINLESS-READ-TIMEOUT-XXX x-stainless-retry-count: - '0' x-stainless-runtime: - CPython x-stainless-runtime-version: - - 3.12.9 + - 3.13.3 method: POST uri: https://api.openai.com/v1/chat/completions response: body: - string: "{\n \"id\": \"chatcmpl-C4yYNqrSmM0fkSWqb4T6tcks2vwV3\",\n \"object\"\ - : \"chat.completion\",\n \"created\": 1755302115,\n \"model\": \"gpt-4o-mini-2024-07-18\"\ + string: "{\n \"id\": \"chatcmpl-DmQXhgUdsjl74u2ygEBsUUnuMOpRq\",\n \"object\"\ + : \"chat.completion\",\n \"created\": 1780434145,\n \"model\": \"gpt-4o-mini-2024-07-18\"\ ,\n \"choices\": [\n {\n \"index\": 0,\n \"message\": {\n \ - \ \"role\": \"assistant\",\n \"content\": \"I now can give a great\ - \ answer \\nFinal Answer: hello world\",\n \"refusal\": null,\n \ - \ \"annotations\": []\n },\n \"logprobs\": null,\n \"\ - finish_reason\": \"stop\"\n }\n ],\n \"usage\": {\n \"prompt_tokens\"\ - : 157,\n \"completion_tokens\": 13,\n \"total_tokens\": 170,\n \"\ - prompt_tokens_details\": {\n \"cached_tokens\": 0,\n \"audio_tokens\"\ - : 0\n },\n \"completion_tokens_details\": {\n \"reasoning_tokens\"\ - : 0,\n \"audio_tokens\": 0,\n \"accepted_prediction_tokens\": 0,\n\ - \ \"rejected_prediction_tokens\": 0\n }\n },\n \"service_tier\"\ - : \"default\",\n \"system_fingerprint\": \"fp_560af6e559\"\n}\n" + \ \"role\": \"assistant\",\n \"content\": \"hello world\",\n \ + \ \"refusal\": null,\n \"annotations\": []\n },\n \"\ + logprobs\": null,\n \"finish_reason\": \"stop\"\n }\n ],\n \"usage\"\ + : {\n \"prompt_tokens\": 71,\n \"completion_tokens\": 2,\n \"total_tokens\"\ + : 73,\n \"prompt_tokens_details\": {\n \"cached_tokens\": 0,\n \ + \ \"audio_tokens\": 0\n },\n \"completion_tokens_details\": {\n \ + \ \"reasoning_tokens\": 0,\n \"audio_tokens\": 0,\n \"accepted_prediction_tokens\"\ + : 0,\n \"rejected_prediction_tokens\": 0\n }\n },\n \"service_tier\"\ + : \"default\",\n \"system_fingerprint\": \"fp_df8c8d3b43\"\n}\n" headers: - CF-RAY: - - 96fc9f301bf7cf1f-SJC + Access-Control-Expose-Headers: + - ACCESS-CONTROL-XXX + CF-Cache-Status: + - DYNAMIC + CF-Ray: + - a059661ddb01cf93-SJC Connection: - keep-alive Content-Type: - application/json Date: - - Fri, 15 Aug 2025 23:55:16 GMT + - Tue, 02 Jun 2026 21:02:25 GMT Server: - cloudflare Strict-Transport-Security: - - max-age=31536000; includeSubDomains; preload + - STS-XXX Transfer-Encoding: - chunked X-Content-Type-Options: - - nosniff + - X-CONTENT-TYPE-XXX access-control-expose-headers: - - X-Request-ID + - ACCESS-CONTROL-XXX alt-svc: - h3=":443"; ma=86400 - cf-cache-status: - - DYNAMIC openai-organization: - - crewai-iuxna1 + - OPENAI-ORG-XXX openai-processing-ms: - - '685' + - '494' openai-project: - - proj_xitITlrFeen7zjNSzML82h9x + - OPENAI-PROJECT-XXX openai-version: - '2020-10-01' - x-envoy-upstream-service-time: - - '711' - x-ratelimit-limit-project-tokens: - - '150000000' + set-cookie: + - SET-COOKIE-XXX + x-openai-proxy-wasm: + - v0.1 x-ratelimit-limit-requests: - - '30000' + - X-RATELIMIT-LIMIT-REQUESTS-XXX x-ratelimit-limit-tokens: - - '150000000' - x-ratelimit-remaining-project-tokens: - - '149999827' + - X-RATELIMIT-LIMIT-TOKENS-XXX x-ratelimit-remaining-requests: - - '29999' + - X-RATELIMIT-REMAINING-REQUESTS-XXX x-ratelimit-remaining-tokens: - - '149999827' - x-ratelimit-reset-project-tokens: - - 0s + - X-RATELIMIT-REMAINING-TOKENS-XXX x-ratelimit-reset-requests: - - 2ms + - X-RATELIMIT-RESET-REQUESTS-XXX x-ratelimit-reset-tokens: - - 0s + - X-RATELIMIT-RESET-TOKENS-XXX x-request-id: - - req_3f0ec42447374a76a22a4cdb9f336279 + - X-REQUEST-ID-XXX status: code: 200 message: OK -version: 1 diff --git a/lib/crewai/tests/test_flow_conversation.py b/lib/crewai/tests/test_flow_conversation.py new file mode 100644 index 000000000..77567fe5d --- /dev/null +++ b/lib/crewai/tests/test_flow_conversation.py @@ -0,0 +1,1289 @@ +"""Tests for conversational Flow helpers and kickoff parameters.""" + +from __future__ import annotations + +from typing import Any, Literal +from unittest.mock import MagicMock, patch +from uuid import uuid4 + +from pydantic import BaseModel + +from crewai.events.event_bus import crewai_event_bus +from crewai.events.listeners.tracing.trace_listener import TraceCollectionListener +from crewai.events.types.flow_events import ( + FlowStartedEvent, + MethodExecutionFinishedEvent, + MethodExecutionStartedEvent, +) +from crewai.events.types.llm_events import LLMCallStartedEvent +from crewai.experimental import ( + ConversationConfig, + ConversationMessage, + ConversationState, + RouterConfig, +) +from crewai.flow import Flow, ChatState, listen, start +from crewai.flow.flow_context import current_flow_id, current_flow_name +from crewai.flow.conversation import ( + append_message, + get_conversation_messages, + normalize_kickoff_inputs, + prepare_conversational_turn, +) + + +class ConversationalFlow(Flow[ConversationState]): + """Test base: a ``Flow[ConversationState]`` with conversational mode enabled. + + Mirrors the documented ``class MyChat(Flow): conversational = True`` pattern + so the conversational subclasses below stay terse. + """ + + conversational = True + + +class SimpleChatFlow(Flow[ChatState]): + @start() + def begin(self): + return "done" + + +class DictChatFlow(Flow): + @start() + def begin(self): + return self.state.get("marker", "ok") + + +class TestNormalizeKickoffInputs: + def test_merges_session_and_user_message(self) -> None: + merged = normalize_kickoff_inputs( + {"foo": 1}, + user_message="hello", + session_id="sess-1", + ) + assert merged["id"] == "sess-1" + assert merged["user_message"] == "hello" + assert merged["foo"] == 1 + + +class TestMessageHelpers: + def test_append_message_on_pydantic_state(self) -> None: + flow = SimpleChatFlow() + flow._state = ChatState() + append_message(flow, "user", "hi") + assert get_conversation_messages(flow) == [{"role": "user", "content": "hi"}] + + def test_append_message_fallback_buffer(self) -> None: + flow = DictChatFlow() + + class _State: + id = str(uuid4()) + + flow._state = _State() + append_message(flow, "assistant", "reply") + assert get_conversation_messages(flow) == [ + {"role": "assistant", "content": "reply"} + ] + assert flow._conversation_messages == [ + {"role": "assistant", "content": "reply"} + ] + + +class TestIntentPerTurn: + def test_prepare_clears_stale_last_intent(self) -> None: + flow = SimpleChatFlow() + flow._state = ChatState(last_intent="ORDER", messages=[]) + prepare_conversational_turn(flow, user_message="hello") + assert flow.state.last_intent is None + + +class TestClassifyIntent: + def test_uses_collapse_with_context(self) -> None: + flow = SimpleChatFlow() + flow._state = ChatState( + messages=[{"role": "user", "content": "prior"}], + ) + + with patch.object(flow, "_collapse_to_outcome", return_value="help") as mock: + outcome = flow.classify_intent( + "I need help", + ["order", "help"], + llm="gpt-4o-mini", + context=flow.conversation_messages, + ) + + assert outcome == "help" + assert "I need help" in mock.call_args[0][0] + + +class TestConversationalFlow: + def test_deferred_multi_turn_emits_single_flow_finished(self) -> None: + """A deferred multi-turn session lands as one trace: exactly one + ``FlowFinishedEvent`` is emitted at ``finalize_session_traces()``, not + one per turn. (Each turn still opens its own ``flow_started``.) + """ + from crewai.events.types.flow_events import FlowFinishedEvent + + @ConversationConfig(defer_trace_finalization=True) + class TraceFlow(ConversationalFlow): + def route_turn(self, context: dict[str, Any]) -> str | None: + return "work" + + @listen("work") + def do_work(self) -> str: + reply = f"worked: {self.state.current_user_message}" + self.append_assistant_message(reply) + return reply + + flow = TraceFlow() + finished: list[FlowFinishedEvent] = [] + with crewai_event_bus.scoped_handlers(): + + @crewai_event_bus.on(FlowFinishedEvent) + def capture(_: Any, event: FlowFinishedEvent) -> None: + finished.append(event) + + flow.handle_turn("research apple stock") + flow.handle_turn("research google stock") + crewai_event_bus.flush() + assert finished == [], "deferred turns must not emit per-turn flow_finished" + + flow.finalize_session_traces() + crewai_event_bus.flush() + + assert len(finished) == 1, ( + "a deferred session must emit exactly one flow_finished at finalize" + ) + + + def test_handle_turn_routes_to_listener_and_records_public_result(self) -> None: + @ConversationConfig(default_intents=["research"], intent_llm="gpt-4o-mini") + class ResearchFlow(ConversationalFlow): + @listen("research") + def run_research(self) -> str: + self.append_agent_result( + "researcher", + "researched answer", + visibility="public", + ) + return "researched answer" + + flow = ResearchFlow() + + with patch.object(flow, "_collapse_to_outcome", return_value="research"): + result = flow.handle_turn("research CrewAI") + + assert result == "researched answer" + assert "conversation_start" in ResearchFlow._start_methods + assert flow.state.current_user_message == "research CrewAI" + assert flow.state.last_intent == "research" + assert [message.role for message in flow.state.messages] == [ + "user", + "assistant", + ] + assert flow.state.messages[-1].content == "researched answer" + assert flow.state.events[0].agent_name == "researcher" + assert flow.state.events[0].visibility == "public" + + def test_private_agent_results_stay_out_of_shared_history(self) -> None: + class PrivateFlow(ConversationalFlow): + def route_turn(self, context: dict[str, Any]) -> str | None: + return "work" + + @listen("work") + def do_work(self) -> None: + self.append_agent_result("planner", "private scratch") + + flow = PrivateFlow() + flow.handle_turn("plan quietly") + + assert [message.role for message in flow.state.messages] == ["user"] + assert flow.state.events[0].visibility == "private" + assert flow.state.agent_threads["planner"][0].content == "private scratch" + + def test_answer_from_history_uses_configured_llm_and_appends_reply(self) -> None: + @ConversationConfig(answer_from_history_llm="gpt-4o-mini") + class HistoryFlow(ConversationalFlow): + pass + + flow = HistoryFlow() + flow._state = ConversationState( + messages=[ + ConversationMessage(role="user", content="research topic"), + ConversationMessage(role="assistant", content="prior findings"), + ] + ) + llm = MagicMock() + llm.call.return_value = "summary from history" + + with ( + patch.object( + flow, + "_collapse_to_outcome", + return_value="answer_from_history", + ), + patch.object(flow, "_coerce_llm", return_value=llm), + ): + result = flow.handle_turn("summarize this") + + assert result == "summary from history" + assert flow.state.messages[-1].role == "assistant" + assert flow.state.messages[-1].content == "summary from history" + llm.call.assert_called_once() + + def test_router_config_uses_structured_intent_response(self) -> None: + class ResearchRoute(BaseModel): + intent: Literal["research", "clarify"] + + llm = MagicMock() + llm.call.return_value = ResearchRoute(intent="research") + + @ConversationConfig( + router=RouterConfig( + prompt="Classify the next action.", + response_format=ResearchRoute, + llm=llm, + routes=["research", "clarify"], + default_intent="clarify", + fallback_intent="clarify", + ) + ) + class RoutedFlow(ConversationalFlow): + @listen("research") + def run_research(self) -> str: + self.append_assistant_message("researched") + return "researched" + + @listen("clarify") + def ask_clarification(self) -> str: + self.append_assistant_message("clarify") + return "clarify" + + flow = RoutedFlow() + result = flow.handle_turn("research CrewAI") + + assert result == "researched" + llm.call.assert_called_once() + assert llm.call.call_args.kwargs["response_format"] is ResearchRoute + assert flow.state.messages[-1].content == "researched" + + def test_router_config_falls_back_for_invalid_intent(self) -> None: + class ResearchRoute(BaseModel): + intent: str + + llm = MagicMock() + llm.call.return_value = ResearchRoute(intent="unknown") + + @ConversationConfig( + router=RouterConfig( + prompt="Classify the next action.", + response_format=ResearchRoute, + llm=llm, + routes=["research", "clarify"], + default_intent="clarify", + fallback_intent="clarify", + ) + ) + class RoutedFlow(ConversationalFlow): + @listen("research") + def run_research(self) -> str: + self.append_assistant_message("researched") + return "researched" + + @listen("clarify") + def ask_clarification(self) -> str: + self.append_assistant_message("clarify") + return "clarify" + + flow = RoutedFlow() + result = flow.handle_turn("something vague") + + assert result == "clarify" + assert flow.state.messages[-1].content == "clarify" + + def test_router_effective_routes_include_builtins(self) -> None: + class ResearchRoute(BaseModel): + intent: Literal["research", "converse", "end"] + + @ConversationConfig( + router=RouterConfig( + prompt="Classify.", + response_format=ResearchRoute, + routes=["research"], + ) + ) + class RoutedFlow(ConversationalFlow): + @listen("research") + def run_research(self) -> str: + return "researched" + + flow = RoutedFlow() + + assert flow._effective_routes(flow.conversational_config.router) == { + "research", + "converse", + "end", + } + + def test_router_infers_custom_routes_without_internal_routes(self) -> None: + class ResearchRoute(BaseModel): + intent: Literal["research", "converse", "end"] + + @ConversationConfig( + router=RouterConfig( + prompt="Classify.", + response_format=ResearchRoute, + ) + ) + class RoutedFlow(ConversationalFlow): + @listen("research") + def run_research(self) -> str: + return "researched" + + flow = RoutedFlow() + + assert flow._effective_routes(flow.conversational_config.router) == { + "research", + "converse", + "end", + } + + def test_router_config_uses_conversational_defaults(self) -> None: + llm = MagicMock() + + @ConversationConfig( + llm=llm, + router=RouterConfig(), + ) + class RoutedFlow(ConversationalFlow): + @listen("research") + def run_research(self) -> str: + self.append_assistant_message("researched") + return "researched" + + flow = RoutedFlow() + response_format = flow._router_response_format(flow.conversational_config.router) + llm.call.return_value = response_format(intent="research") + + result = flow.handle_turn("research CrewAI") + + assert result == "researched" + llm.call.assert_called_once() + assert llm.call.call_args.kwargs["response_format"].__name__ == ( + "ConversationRoute" + ) + assert flow.state.messages[-1].content == "researched" + + def test_builtin_converse_appends_assistant_message_and_uses_history(self) -> None: + class ResearchRoute(BaseModel): + intent: Literal["research", "converse", "end"] + + router_llm = MagicMock() + router_llm.call.return_value = ResearchRoute(intent="converse") + chat_llm = MagicMock() + chat_llm.call.return_value = "summary from built-in converse" + + @ConversationConfig( + system_prompt="You are a helpful research assistant.", + llm=chat_llm, + router=RouterConfig( + prompt="Classify.", + response_format=ResearchRoute, + llm=router_llm, + routes=["research"], + default_intent="converse", + ), + ) + class RoutedFlow(ConversationalFlow): + @listen("research") + def run_research(self) -> str: + self.append_agent_result( + "researcher", + "prior findings", + visibility="public", + ) + return "prior findings" + + flow = RoutedFlow() + flow.state.messages = [ + ConversationMessage(role="user", content="research CrewAI"), + ConversationMessage(role="assistant", content="prior findings"), + ] + result = flow.handle_turn("summarize findings") + + assert result == "summary from built-in converse" + assert flow.state.messages[-1].content == "summary from built-in converse" + messages = chat_llm.call.call_args.kwargs["messages"] + assert messages[0] == { + "role": "system", + "content": "You are a helpful research assistant.", + } + assert any(message["content"] == "prior findings" for message in messages) + assert any(message["content"] == "summarize findings" for message in messages) + + def test_builtin_end_marks_conversation_ended(self) -> None: + class ResearchRoute(BaseModel): + intent: Literal["research", "converse", "end"] + + router_llm = MagicMock() + router_llm.call.return_value = ResearchRoute(intent="end") + + @ConversationConfig( + router=RouterConfig( + prompt="Classify.", + response_format=ResearchRoute, + llm=router_llm, + routes=["research"], + default_intent="converse", + ) + ) + class RoutedFlow(ConversationalFlow): + @listen("research") + def run_research(self) -> str: + return "researched" + + flow = RoutedFlow() + result = flow.handle_turn("bye") + + assert result == "Conversation ended." + assert flow.state.ended is True + assert flow.state.messages[-1].content == "Conversation ended." + + def test_router_auto_enables_when_custom_routes_declared_and_no_explicit_config( + self, + ) -> None: + """``ConversationConfig(llm=...)`` alone wires LLM routing for custom listeners. + + Users shouldn't have to pass ``router=RouterConfig()`` just to flip + the router on — declaring custom ``@listen`` handlers + giving the + config an LLM is sufficient. Only opt out by setting + ``default_intents`` (legacy path). + """ + + class Route(BaseModel): + intent: Literal["INTERNET_SEARCH", "converse", "end"] + + router_llm = MagicMock() + router_llm.call.return_value = Route(intent="INTERNET_SEARCH") + + @ConversationConfig(llm=router_llm) # no router= here + class AutoEnabledFlow(ConversationalFlow): + @listen("INTERNET_SEARCH") + def handle_search(self) -> str: + """Fresh web research.""" + self.append_assistant_message("searched") + return "searched" + + flow = AutoEnabledFlow() + result = flow.handle_turn("research today's AI news") + + assert result == "searched" + # Router LLM should have been invoked. + assert router_llm.call.call_count >= 1 + + def test_router_auto_enable_skipped_when_only_builtin_routes(self) -> None: + """No custom routes → no auto-enable; falls through to converse.""" + + chat_llm = MagicMock() + chat_llm.call.return_value = "hi there" + + @ConversationConfig(llm=chat_llm) + class NoCustomFlow(ConversationalFlow): + pass + + flow = NoCustomFlow() + flow.handle_turn("hello") + + assert flow.state.last_intent == "converse" + # chat_llm was used by converse_turn, not as a router. + assert chat_llm.call.call_count == 1 + + def test_router_auto_enable_skipped_when_default_intents_set(self) -> None: + """Legacy ``default_intents`` opts out of router auto-enable.""" + + @ConversationConfig(default_intents=["search"], intent_llm="gpt-4o-mini") + class LegacyFlow(ConversationalFlow): + @listen("search") + def handle_search(self) -> str: + """Web research.""" + self.append_assistant_message("legacy-searched") + return "legacy-searched" + + flow = LegacyFlow() + with patch.object(flow, "_collapse_to_outcome", return_value="search"): + result = flow.handle_turn("look it up") + + # Legacy path set state.last_intent via classify_intent; auto-router did NOT + # overwrite it because default_intents short-circuits the auto-enable. + assert result == "legacy-searched" + assert flow.state.last_intent == "search" + + def test_user_start_methods_run_sequentially_before_router_in_conversational_mode( + self, + ) -> None: + """Conversational flows: user ``@start`` methods finish before router fires. + + Non-chat flows run ``@start`` methods in parallel via ``asyncio.gather``, + which would race with ``conversation_start`` and let the router fire + before user setup finished. In conversational mode the framework runs + them sequentially, with ``conversation_start`` last. + """ + order: list[str] = [] + + @ConversationConfig() + class BootstrapFlow(ConversationalFlow): + @start() + def load_profile(self) -> None: + if not self.state.session_ready: + order.append("load_profile") + self.state.session_ready = True + + @start() + def attach_bus(self) -> None: + order.append("attach_bus") + + def route_turn(self, context: dict[str, Any]) -> str | None: + order.append("route_turn") + return "work" + + @listen("work") + def do_work(self) -> str: + order.append("do_work") + self.append_assistant_message("worked") + return "worked" + + flow = BootstrapFlow() + flow.handle_turn("turn 1") + + # Both user @start methods complete before route_turn fires. + load_idx = order.index("load_profile") + attach_idx = order.index("attach_bus") + route_idx = order.index("route_turn") + assert load_idx < route_idx + assert attach_idx < route_idx + + # Bootstrap gate works: load_profile only fires on the first turn. + order.clear() + flow.handle_turn("turn 2") + assert "load_profile" not in order + assert "attach_bus" in order # still fires every turn + assert "route_turn" in order + + def test_subclass_can_override_conversation_start_without_redecorating( + self, + ) -> None: + """Overriding an inherited ``@start`` method must not unregister it. + + Before the metaclass fix, subclasses had to re-apply ``@start()`` on + every override or the parent's ``conversation_start`` would silently + drop out of ``_start_methods`` — leaving the flow with nothing to fire. + """ + + bootstrap_calls: list[str] = [] + + @ConversationConfig() + class BootstrapFlow(ConversationalFlow): + def conversation_start(self) -> str | None: + bootstrap_calls.append("ran") + return super().conversation_start() + + def route_turn(self, context: dict[str, Any]) -> str | None: + return "work" + + @listen("work") + def do_work(self) -> str: + self.append_assistant_message("worked") + return "worked" + + flow = BootstrapFlow() + assert "conversation_start" in flow._start_methods + + flow.handle_turn("hi") + + assert bootstrap_calls == ["ran"] + assert flow.state.messages[-1].content == "worked" + + def test_handle_turn_reruns_graph_after_prior_turn_completed(self) -> None: + """Multi-turn must not flip ``_is_execution_resuming`` and short-circuit. + + ``Flow.kickoff`` with persistence enabled treats ``inputs={"id": ...}`` + as a checkpoint restore, so it skips clearing ``_completed_methods``. + Without ``ConversationalFlow.kickoff`` resetting that state, turn 2+ + sees every method as already-completed, short-circuits to + ``_method_outputs[-1]``, and returns the previous turn's output. + """ + + class Route(BaseModel): + intent: Literal["RESEARCH", "converse", "end"] + + router_llm = MagicMock() + router_llm.call.side_effect = [ + Route(intent="converse"), + Route(intent="RESEARCH"), + ] + chat_llm = MagicMock() + chat_llm.call.return_value = "general help" + + @ConversationConfig( + llm=chat_llm, + router=RouterConfig( + response_format=Route, + llm=router_llm, + routes=["RESEARCH"], + ), + ) + class DemoFlow(ConversationalFlow): + @listen("RESEARCH") + def handle_research(self) -> str: + self.append_assistant_message("fresh research") + return "fresh research" + + flow = DemoFlow() + from crewai.flow.persistence import SQLiteFlowPersistence + + import tempfile + from pathlib import Path + + flow.persistence = SQLiteFlowPersistence( + str(Path(tempfile.mkdtemp()) / "regression.db") + ) + + out1 = flow.handle_turn("tell me what you can do") + out2 = flow.handle_turn("now do research") + + assert out1 == "general help" + assert out2 == "fresh research" + assert chat_llm.call.call_count == 1 + assert router_llm.call.call_count == 2 + assert flow.state.messages[-1].content == "fresh research" + assert flow._is_execution_resuming is False + + def test_route_catalog_combines_docstrings_builtins_and_overrides(self) -> None: + """Catalog precedence: route_descriptions > built-in > docstring.""" + + @ConversationConfig( + router=RouterConfig( + routes=["RESEARCH", "ORDER"], + route_descriptions={"ORDER": "explicit override for order route"}, + ) + ) + class CatalogFlow(ConversationalFlow): + @listen("RESEARCH") + def handle_research(self) -> str: + """Fresh web research, current news, real-time lookups.""" + return "researched" + + @listen("ORDER") + def handle_order(self) -> str: + """This docstring should NOT win — override takes priority.""" + return "ordered" + + flow = CatalogFlow() + catalog = flow._build_route_catalog(flow.conversational_config.router) + + assert catalog["RESEARCH"] == ( + "Fresh web research, current news, real-time lookups." + ) + assert catalog["ORDER"] == "explicit override for order route" + # Built-in routes get framework-canned descriptions. + assert "Ordinary chat" in catalog["converse"] + assert "finished" in catalog["end"] + + def test_route_catalog_falls_back_to_empty_when_no_docstring(self) -> None: + @ConversationConfig(router=RouterConfig(routes=["BARE"])) + class BareFlow(ConversationalFlow): + @listen("BARE") + def handle_bare(self) -> str: + return "bare" + + flow = BareFlow() + catalog = flow._build_route_catalog(flow.conversational_config.router) + + assert catalog["BARE"] == "" + + def test_router_messages_include_route_catalog(self) -> None: + """The router system prompt must enumerate routes with descriptions.""" + + class Route(BaseModel): + intent: Literal["RESEARCH", "converse", "end"] + + router_llm = MagicMock() + router_llm.call.return_value = Route(intent="RESEARCH") + + @ConversationConfig( + router=RouterConfig( + prompt="A research-focused assistant.", + response_format=Route, + llm=router_llm, + routes=["RESEARCH"], + ) + ) + class RoutedFlow(ConversationalFlow): + @listen("RESEARCH") + def handle_research(self) -> str: + """Fresh web research and current news.""" + self.append_assistant_message("researched") + return "researched" + + flow = RoutedFlow() + flow.handle_turn("research today's AI news") + + system_message = router_llm.call.call_args.kwargs["messages"][0]["content"] + assert "Routes:" in system_message + assert "- RESEARCH: Fresh web research and current news." in system_message + assert "- converse: Ordinary chat" in system_message + assert system_message.startswith("A research-focused assistant.") + + def test_router_decision_persists_last_intent_and_passes_it_next_turn( + self, + ) -> None: + """Router must record its decision so the next turn's router LLM sees it.""" + + class Route(BaseModel): + intent: Literal["research", "converse", "end"] + + router_llm = MagicMock() + router_llm.call.side_effect = [ + Route(intent="research"), + Route(intent="converse"), + ] + chat_llm = MagicMock() + chat_llm.call.return_value = "follow-up reply" + + @ConversationConfig( + llm=chat_llm, + router=RouterConfig( + response_format=Route, + llm=router_llm, + routes=["research"], + ), + ) + class RoutedFlow(ConversationalFlow): + @listen("research") + def run_research(self) -> str: + self.append_assistant_message("researched") + return "researched" + + flow = RoutedFlow() + + flow.handle_turn("research CrewAI") + assert flow.state.last_intent == "research" + + flow.handle_turn("tell me more about that") + assert flow.state.last_intent == "converse" + + # Turn 2's router LLM must have seen last_intent='research' in its context. + second_call_user_content = router_llm.call.call_args_list[1].kwargs["messages"][1][ + "content" + ] + assert '"last_intent": "research"' in second_call_user_content + + def test_custom_route_still_runs_with_builtin_routes(self) -> None: + class ResearchRoute(BaseModel): + intent: Literal["research", "converse", "end"] + + router_llm = MagicMock() + router_llm.call.return_value = ResearchRoute(intent="research") + + @ConversationConfig( + router=RouterConfig( + prompt="Classify.", + response_format=ResearchRoute, + llm=router_llm, + routes=["research"], + default_intent="converse", + ) + ) + class RoutedFlow(ConversationalFlow): + @listen("research") + def run_research(self) -> str: + self.append_agent_result("researcher", "researched", visibility="public") + return "researched" + + flow = RoutedFlow() + result = flow.handle_turn("research CrewAI") + + assert result == "researched" + assert flow.state.messages[-1].content == "researched" + + def test_conversational_flow_auto_defaults_to_conversation_state(self) -> None: + """``class C(Flow): conversational = True`` resolves state to ConversationState. + + Pins the auto-default in ``_create_initial_state``: when the user opts + into conversational mode without an explicit ``Flow[...]`` type + parameter or ``initial_state``, state is a ``ConversationState`` with + the chat-shaped fields ready to use. + """ + + class BareChat(Flow): + conversational = True + + flow = BareChat() + # ``flow.state`` returns a ``StateProxy``; the underlying state is + # on ``flow._state``. Both views expose the same chat-shaped fields. + assert isinstance(flow._state, ConversationState) + assert flow.state.messages == [] + assert flow.state.current_user_message is None + assert flow.state.session_ready is False + + def test_mixin_handle_turn_resolves_on_flow_subclass(self) -> None: + """``Flow`` mixes in ``_ConversationalMixin`` — opt-in subclasses get its methods. + + The conversational graph + ``handle_turn`` live on the mixin in + ``crewai.experimental.conversational_mixin``; this test confirms + MRO resolution wires them onto a ``Flow`` subclass that opts in. + """ + from crewai.experimental.conversational_mixin import _ConversationalMixin + + @ConversationConfig() + class MyChat(Flow): + conversational = True + + @listen("work") + def do_work(self) -> str: + self.append_assistant_message("worked") + return "worked" + + def route_turn(self, context: dict[str, Any]) -> str | None: + return "work" + + flow = MyChat() + assert isinstance(flow, _ConversationalMixin) + assert callable(getattr(flow, "handle_turn", None)) + assert callable(getattr(flow, "finalize_session_traces", None)) + assert callable(getattr(flow, "append_assistant_message", None)) + + # Driving the mixin's handle_turn through to the listener proves + # the wiring is end-to-end, not just attribute presence. + flow.handle_turn("anything") + assert flow.state.messages[-1].content == "worked" + + def test_defer_trace_finalization_skips_per_turn_finalize(self) -> None: + """``defer_trace_finalization = True`` suppresses per-turn ``finalize_batch``. + + Without deferral, each ``handle_turn()`` ends with a trace-batch + finalize. With deferral on, the framework defers until + ``finalize_session_traces()`` is called at session end. + """ + + @ConversationConfig() + class DeferredFlow(ConversationalFlow): + def route_turn(self, context: dict[str, Any]) -> str | None: + return "work" + + @listen("work") + def do_work(self) -> str: + self.append_assistant_message("worked") + return "worked" + + flow = DeferredFlow() + flow.defer_trace_finalization = True + + listener = TraceCollectionListener() + with patch.object(listener.batch_manager, "finalize_batch") as mock_finalize: + flow.handle_turn("turn 1") + flow.handle_turn("turn 2") + flow.handle_turn("turn 3") + + assert mock_finalize.call_count == 0, ( + "defer_trace_finalization=True must skip per-turn finalize" + ) + + def test_finalize_session_traces_emits_finished_and_finalizes_batch(self) -> None: + """``finalize_session_traces()`` emits one ``FlowFinishedEvent`` + one ``finalize_batch``. + + Pairs with the deferral above: after N turns with deferral on, a + single ``finalize_session_traces()`` closes the whole session as + one trace batch with one terminal event. + """ + from crewai.events.types.flow_events import FlowFinishedEvent + + @ConversationConfig() + class DeferredFlow(ConversationalFlow): + def route_turn(self, context: dict[str, Any]) -> str | None: + return "work" + + @listen("work") + def do_work(self) -> str: + self.append_assistant_message("worked") + return "worked" + + flow = DeferredFlow() + flow.defer_trace_finalization = True + + listener = TraceCollectionListener() + listener.batch_manager.batch_owner_type = "flow" + listener.first_time_handler.is_first_time = False + + finished_events: list[FlowFinishedEvent] = [] + with crewai_event_bus.scoped_handlers(): + + @crewai_event_bus.on(FlowFinishedEvent) + def capture(_: Any, event: FlowFinishedEvent) -> None: + finished_events.append(event) + + with patch.object( + listener.batch_manager, "finalize_batch" + ) as mock_finalize: + flow.handle_turn("turn 1") + crewai_event_bus.flush() + flow.handle_turn("turn 2") + crewai_event_bus.flush() + # No flow_finished or finalize_batch yet — deferred. + assert finished_events == [] + assert mock_finalize.call_count == 0 + + flow.finalize_session_traces() + crewai_event_bus.flush() + + assert len(finished_events) == 1, ( + "finalize_session_traces must emit exactly one FlowFinishedEvent" + ) + assert mock_finalize.call_count == 1, ( + "finalize_session_traces must finalize the trace batch once" + ) + + def test_finalize_session_traces_restores_event_scope(self, capsys) -> None: + """No ``empty scope stack`` warning when deferred ``flow_finished`` fires. + + The first turn's ``flow_started`` event id is stashed on the flow + so ``finalize_session_traces`` can restore the scope before emitting + ``flow_finished``. Without this, the event bus prints + ``Warning: Ending event 'flow_finished' emitted with empty scope stack``. + """ + + @ConversationConfig() + class DeferredFlow(ConversationalFlow): + def route_turn(self, context: dict[str, Any]) -> str | None: + return "work" + + @listen("work") + def do_work(self) -> str: + self.append_assistant_message("worked") + return "worked" + + flow = DeferredFlow() + flow.defer_trace_finalization = True + + listener = TraceCollectionListener() + listener.batch_manager.batch_owner_type = "flow" + listener.first_time_handler.is_first_time = False + + with patch.object(listener.batch_manager, "finalize_batch"): + flow.handle_turn("hi") + flow.finalize_session_traces() + + captured = capsys.readouterr() + assert "Missing starting event" not in (captured.out + captured.err), ( + "finalize_session_traces should restore the flow_started scope so " + "the event bus pairs flow_finished with its opener" + ) + + def test_finalize_session_traces_is_noop_when_not_deferred(self) -> None: + """Without deferral, ``finalize_session_traces()`` must not re-emit. + + Each per-turn ``handle_turn()`` already emits its own + ``flow_finished``; a defensive ``try/finally`` call to + ``finalize_session_traces()`` at session end must not emit a second, + unpaired session-end event (which would confuse tracing). + """ + from crewai.events.types.flow_events import FlowFinishedEvent + + @ConversationConfig(defer_trace_finalization=False) + class PlainFlow(ConversationalFlow): + def route_turn(self, context: dict[str, Any]) -> str | None: + return "work" + + @listen("work") + def do_work(self) -> str: + self.append_assistant_message("worked") + return "worked" + + flow = PlainFlow() # finalization NOT deferred + + # A non-deferred turn closes itself (no flow_started stashed for later). + flow.handle_turn("turn 1") + crewai_event_bus.flush() + assert getattr(flow, "_deferred_flow_started_event_id", None) is None + + # Capture only what finalize_session_traces emits. + finished_events: list[FlowFinishedEvent] = [] + with crewai_event_bus.scoped_handlers(): + + @crewai_event_bus.on(FlowFinishedEvent) + def capture(_: Any, event: FlowFinishedEvent) -> None: + finished_events.append(event) + + flow.finalize_session_traces() + crewai_event_bus.flush() + + assert finished_events == [], ( + "finalize_session_traces must be a no-op when finalization was not " + "deferred — it should not emit a duplicate flow_finished" + ) + + +class TestFlowTracingWhenSuppressed: + def test_flow_started_emitted_when_panel_events_suppressed(self) -> None: + class QuietFlow(Flow[ChatState]): + suppress_flow_events = True + + @start() + def begin(self) -> str: + return "ok" + + started: list[str] = [] + original_emit = crewai_event_bus.emit + + def track_emit(source: Any, event: Any, *args: Any, **kwargs: Any) -> Any: + if isinstance(event, FlowStartedEvent): + started.append(event.flow_name) + return original_emit(source, event, *args, **kwargs) + + with patch.object(crewai_event_bus, "emit", side_effect=track_emit): + QuietFlow().kickoff() + + assert started == ["QuietFlow"] + + def test_method_execution_emitted_when_panel_events_suppressed(self) -> None: + class QuietFlow(Flow[ChatState]): + suppress_flow_events = True + + @start() + def begin(self) -> str: + return "ok" + + started: list[str] = [] + finished: list[str] = [] + original_emit = crewai_event_bus.emit + + def track_emit(source: Any, event: Any, *args: Any, **kwargs: Any) -> Any: + if isinstance(event, MethodExecutionStartedEvent): + started.append(event.method_name) + if isinstance(event, MethodExecutionFinishedEvent): + finished.append(event.method_name) + return original_emit(source, event, *args, **kwargs) + + with patch.object(crewai_event_bus, "emit", side_effect=track_emit): + QuietFlow().kickoff() + + assert started == ["begin"] + assert finished == ["begin"] + + def test_llm_action_inside_flow_claims_flow_trace_batch(self) -> None: + listener = TraceCollectionListener() + listener.batch_manager.current_batch = None + listener.batch_manager.batch_owner_type = None + listener.batch_manager.batch_owner_id = None + + flow_id_token = current_flow_id.set("flow-test-id") + flow_name_token = current_flow_name.set("DemoSupportFlow") + try: + event = LLMCallStartedEvent( + model="gpt-4o-mini", + messages=[], + call_id="call-test", + ) + listener._handle_action_event("llm_call_started", object(), event) + finally: + current_flow_id.reset(flow_id_token) + current_flow_name.reset(flow_name_token) + + assert listener.batch_manager.batch_owner_type == "flow" + assert listener.batch_manager.batch_owner_id == "flow-test-id" + assert ( + listener.batch_manager.current_batch.execution_metadata["execution_type"] + == "flow" + ) + assert ( + listener.batch_manager.current_batch.execution_metadata["flow_name"] + == "DemoSupportFlow" + ) + + +class TestDeferTraceFinalization: + def test_conversation_config_drives_defer_flag(self) -> None: + """``ConversationConfig(defer_trace_finalization=...)`` controls whether + a conversational subclass defers per-turn trace finalization.""" + + @ConversationConfig(defer_trace_finalization=True) + class DeferOn(ConversationalFlow): + pass + + @ConversationConfig(defer_trace_finalization=False) + class DeferOff(ConversationalFlow): + pass + + assert DeferOn()._should_defer_trace_finalization() is True + assert DeferOff()._should_defer_trace_finalization() is False + + + +class TestDeferredFlowLifecycleEvents: + def test_flow_finished_without_flow_started_warns(self, capsys) -> None: + from crewai.events.event_bus import crewai_event_bus + from crewai.events.event_context import restore_event_scope + from crewai.events.types.flow_events import FlowFinishedEvent + + class BareFlow(Flow[ChatState]): + @start() + def begin(self) -> str: + return "ok" + + restore_event_scope(()) + flow = BareFlow() + crewai_event_bus.emit( + flow, + FlowFinishedEvent( + type="flow_finished", + flow_name="BareFlow", + result="ok", + state={}, + ), + ) + captured = capsys.readouterr().out + assert "flow_finished" in captured + assert "Missing starting event" in captured + + def test_finalize_batch_is_idempotent(self) -> None: + from crewai.events.listeners.tracing.trace_batch_manager import TraceBatchManager + + with patch( + "crewai.events.listeners.tracing.trace_batch_manager.is_tracing_enabled_in_context", + return_value=True, + ): + bm = TraceBatchManager() + bm.current_batch = bm.initialize_batch( + user_context={"privacy_level": "standard"}, + execution_metadata={"execution_type": "flow", "flow_name": "ChatFlow"}, + ) + bm.trace_batch_id = "batch-idempotent" + bm.backend_initialized = True + + with ( + patch.object( + bm.plus_api, + "send_trace_events", + return_value=MagicMock(status_code=200), + ), + patch.object( + bm.plus_api, + "finalize_trace_batch", + return_value=MagicMock(status_code=200, json=MagicMock(return_value={})), + ) as mock_finalize_api, + ): + bm.finalize_batch() + bm.finalize_batch() + + assert mock_finalize_api.call_count == 1 + assert bm._batch_finalized is True + + def test_finalize_session_traces_is_idempotent(self) -> None: + """Calling ``finalize_session_traces()`` twice emits flow_finished once. + + The stashed ``_deferred_flow_started_event_id`` is cleared after the + first call, so a second call (e.g. a defensive ``try/finally``) does + not re-emit a session-end event. + """ + from crewai.events.types.flow_events import FlowFinishedEvent + + @ConversationConfig(defer_trace_finalization=True) + class DeferredFlow(ConversationalFlow): + def route_turn(self, context: dict[str, Any]) -> str | None: + return "work" + + @listen("work") + def do_work(self) -> str: + self.append_assistant_message("worked") + return "worked" + + flow = DeferredFlow() + listener = TraceCollectionListener() + listener.batch_manager.batch_owner_type = "flow" + listener.first_time_handler.is_first_time = False + + finished: list[FlowFinishedEvent] = [] + with crewai_event_bus.scoped_handlers(): + + @crewai_event_bus.on(FlowFinishedEvent) + def capture(_: Any, event: FlowFinishedEvent) -> None: + finished.append(event) + + with patch.object(listener.batch_manager, "finalize_batch"): + flow.handle_turn("hi") + crewai_event_bus.flush() + flow.finalize_session_traces() + flow.finalize_session_traces() # second call must be a no-op + crewai_event_bus.flush() + + assert len(finished) == 1, ( + "finalize_session_traces must emit flow_finished exactly once, even " + "when called more than once" + ) + + def test_sigint_skips_deferred_session_batch(self) -> None: + from crewai.events.listeners.tracing.trace_batch_manager import TraceBatch + + listener = TraceCollectionListener() + listener.batch_manager.current_batch = TraceBatch() + listener.batch_manager.defer_session_finalization = True + + with patch.object(listener.batch_manager, "finalize_batch") as mock_finalize: + if listener.batch_manager.is_batch_initialized(): + if not listener.batch_manager.defer_session_finalization: + listener.batch_manager.finalize_batch() + mock_finalize.assert_not_called() + + +class TestNestedCrewTracing: + def test_is_inside_active_flow_context_when_kickoff_running(self) -> None: + from crewai.events.listeners.tracing.trace_listener import ( + TraceCollectionListener, + ) + from crewai.flow.flow_context import current_flow_id + + assert TraceCollectionListener._is_inside_active_flow_context() is False + token = current_flow_id.set("parent-flow-id") + try: + assert TraceCollectionListener._is_inside_active_flow_context() is True + finally: + current_flow_id.reset(token) + + def test_nested_crew_completion_skips_finalize(self) -> None: + from crewai.events.listeners.tracing.trace_listener import ( + TraceCollectionListener, + ) + from crewai.flow.flow_context import current_flow_id + + listener = TraceCollectionListener() + listener.batch_manager.batch_owner_type = "crew" + + token = current_flow_id.set("parent-flow-id") + try: + with patch.object(listener.batch_manager, "finalize_batch") as mock_finalize: + if listener._nested_in_flow_execution(): + pass + elif listener.batch_manager.batch_owner_type == "crew": + listener.batch_manager.finalize_batch() + mock_finalize.assert_not_called() + finally: + current_flow_id.reset(token) + + def test_flow_owned_batch_skips_finalize_without_flow_context(self) -> None: + from crewai.events.listeners.tracing.trace_listener import ( + TraceCollectionListener, + ) + from crewai.events.listeners.tracing.trace_batch_manager import TraceBatch + + listener = TraceCollectionListener() + listener.batch_manager.batch_owner_type = "flow" + listener.batch_manager.current_batch = TraceBatch( + execution_metadata={"execution_type": "flow", "flow_name": "Demo"}, + ) + + with patch.object(listener.batch_manager, "finalize_batch") as mock_finalize: + if listener._nested_in_flow_execution(): + pass + elif listener.batch_manager.batch_owner_type == "crew": + listener.batch_manager.finalize_batch() + mock_finalize.assert_not_called() diff --git a/lib/crewai/tests/tracing/test_tracing.py b/lib/crewai/tests/tracing/test_tracing.py index 28f2d4c7e..11c25c222 100644 --- a/lib/crewai/tests/tracing/test_tracing.py +++ b/lib/crewai/tests/tracing/test_tracing.py @@ -1,4 +1,5 @@ import os +from threading import Thread from unittest.mock import MagicMock, Mock, patch import pytest @@ -867,6 +868,122 @@ class TestTraceListenerSetup: mock_mark_failed.assert_called_once_with( "test_batch_id_12345", "Internal Server Error" ) + assert batch_manager.current_batch is not None + assert batch_manager.trace_batch_id == "test_batch_id_12345" + assert batch_manager._batch_finalized is False + + def test_finalize_batch_clears_buffer_after_successful_send(self) -> None: + """Successful send must not restore a stale event buffer (duplicate events).""" + from crewai.events.listeners.tracing.types import TraceEvent + + with patch( + "crewai.events.listeners.tracing.trace_batch_manager.is_tracing_enabled_in_context", + return_value=True, + ): + batch_manager = TraceBatchManager() + batch_manager.current_batch = batch_manager.initialize_batch( + user_context={"privacy_level": "standard"}, + execution_metadata={ + "execution_type": "flow", + "flow_name": "TestFlow", + }, + ) + batch_manager.trace_batch_id = "batch-clear-test" + batch_manager.backend_initialized = True + batch_manager.event_buffer = [ + TraceEvent( + type="llm_call_started", + timestamp="2026-01-01T00:00:00", + event_id="evt-1", + emission_sequence=1, + ) + ] + + with ( + patch.object( + batch_manager.plus_api, + "send_trace_events", + return_value=MagicMock(status_code=200), + ), + patch.object( + batch_manager.plus_api, + "finalize_trace_batch", + return_value=MagicMock(status_code=200, json=MagicMock(return_value={})), + ), + ): + batch_manager.finalize_batch() + + assert batch_manager.event_buffer == [] + + def test_finalize_backend_batch_uses_captured_batch_id_for_ephemeral_panel( + self, + ) -> None: + """Finalization output must not render None if manager state is reset.""" + batch_manager = TraceBatchManager() + batch_manager.trace_batch_id = "ephemeral-batch-id" + batch_manager.is_current_batch_ephemeral = True + + def clear_batch_id_during_response() -> dict[str, str]: + batch_manager.trace_batch_id = None + return {"access_code": "TRACE-test"} + + with ( + patch.object( + batch_manager.plus_api, + "finalize_ephemeral_trace_batch", + return_value=MagicMock( + status_code=200, + json=clear_batch_id_during_response, + ), + ), + patch( + "crewai.events.listeners.tracing.trace_batch_manager.should_auto_collect_first_time_traces", + return_value=False, + ), + patch( + "crewai.events.listeners.tracing.trace_batch_manager.Console.print" + ) as mock_print, + ): + assert batch_manager._finalize_backend_batch() is True + + panel = mock_print.call_args.args[0] + panel_text = str(panel.renderable) + assert "session ID: ephemeral-batch-id" in panel_text + assert "ephemeral_trace_batches/ephemeral-batch-id" in panel_text + assert "session ID: None" not in panel_text + assert "ephemeral_trace_batches/None" not in panel_text + + def test_finalize_backend_batch_is_serialized(self) -> None: + """Concurrent finalizers must only call the backend once.""" + batch_manager = TraceBatchManager() + batch_manager.trace_batch_id = "ephemeral-batch-id" + batch_manager.is_current_batch_ephemeral = True + response = MagicMock(status_code=200, json=MagicMock(return_value={})) + + with ( + patch.object( + batch_manager.plus_api, + "finalize_ephemeral_trace_batch", + return_value=response, + ) as mock_finalize, + patch( + "crewai.events.listeners.tracing.trace_batch_manager.should_auto_collect_first_time_traces", + return_value=True, + ), + ): + results: list[bool] = [] + + def finalize() -> None: + results.append(batch_manager._finalize_backend_batch()) + + threads = [Thread(target=finalize), Thread(target=finalize)] + for thread in threads: + thread.start() + for thread in threads: + thread.join() + + assert results == [True, True] + mock_finalize.assert_called_once() def test_ephemeral_batch_includes_anon_id(self): """Test that ephemeral batch initialization sends anon_id from get_user_id()""" From d09e3f4544ae48c8be6512a647cb05981c6686d7 Mon Sep 17 00:00:00 2001 From: Lucas Gomide Date: Wed, 3 Jun 2026 16:13:30 -0300 Subject: [PATCH 06/18] feat: flatten LiteLLM cache/reasoning usage sub-counts in _usage_to_dict (#6033) LiteLLM returns provider usage as-is, nesting cache-read / cache-creation / reasoning counts under provider-specific shapes (e.g. prompt_tokens_details.cached_tokens, Anthropic-style cache_read_input_tokens). Surface them as flat cached_prompt_tokens / reasoning_tokens / cache_creation_tokens keys so the span pipeline can read them; prompt / completion / total token counts are left untouched. --- lib/crewai/src/crewai/llm.py | 60 ++++++++++++-- .../tests/events/test_llm_usage_event.py | 79 ++++++++++++++++++- 2 files changed, 130 insertions(+), 9 deletions(-) diff --git a/lib/crewai/src/crewai/llm.py b/lib/crewai/src/crewai/llm.py index c61639e04..08c1a1bf8 100644 --- a/lib/crewai/src/crewai/llm.py +++ b/lib/crewai/src/crewai/llm.py @@ -1925,16 +1925,62 @@ class LLM(BaseLLM): @staticmethod def _usage_to_dict(usage: Any) -> dict[str, Any] | None: + """Convert a provider usage object to a plain dict and flatten the + cache/reasoning sub-counts that LiteLLM nests under provider-specific + shapes into the top-level keys the rest of the pipeline expects. + + LiteLLM hands back provider usage as-is, so cache-read, cache-creation + and reasoning tokens may live in nested objects (e.g. + ``prompt_tokens_details.cached_tokens``) or under Anthropic-style keys + (``cache_read_input_tokens``). Downstream span mapping only reads the + flat ``cached_prompt_tokens`` / ``reasoning_tokens`` / + ``cache_creation_tokens`` keys, so we surface them here. + + Only those derived buckets are populated; ``prompt_tokens`` / + ``completion_tokens`` / ``total_tokens`` are left untouched. Extraction + precedence mirrors ``BaseLLM._track_token_usage_internal``. + """ if usage is None: return None if isinstance(usage, dict): - return usage - if isinstance(usage, BaseModel): - result: dict[str, Any] = usage.model_dump() - return result - if hasattr(usage, "__dict__"): - return {k: v for k, v in vars(usage).items() if not k.startswith("_")} - return None + data: dict[str, Any] = dict(usage) + elif isinstance(usage, BaseModel): + data = usage.model_dump() + elif hasattr(usage, "__dict__"): + data = {k: v for k, v in vars(usage).items() if not k.startswith("_")} + else: + return None + + def _nested(container: Any, key: str) -> Any: + if isinstance(container, dict): + return container.get(key) + return getattr(container, key, None) + + prompt_details = data.get("prompt_tokens_details") + completion_details = data.get("completion_tokens_details") + + cached_prompt_tokens = ( + data.get("cached_tokens") + or data.get("cached_prompt_tokens") + or data.get("cache_read_input_tokens") + or _nested(prompt_details, "cached_tokens") + ) + if cached_prompt_tokens is not None: + data["cached_prompt_tokens"] = cached_prompt_tokens + + reasoning_tokens = data.get("reasoning_tokens") or _nested( + completion_details, "reasoning_tokens" + ) + if reasoning_tokens is not None: + data["reasoning_tokens"] = reasoning_tokens + + cache_creation_tokens = data.get("cache_creation_tokens") or data.get( + "cache_creation_input_tokens" + ) + if cache_creation_tokens is not None: + data["cache_creation_tokens"] = cache_creation_tokens + + return data def _handle_emit_call_events( self, diff --git a/lib/crewai/tests/events/test_llm_usage_event.py b/lib/crewai/tests/events/test_llm_usage_event.py index 9be8c639f..d0b29c863 100644 --- a/lib/crewai/tests/events/test_llm_usage_event.py +++ b/lib/crewai/tests/events/test_llm_usage_event.py @@ -61,9 +61,84 @@ class TestUsageToDict: def test_none_returns_none(self): assert LLM._usage_to_dict(None) is None - def test_dict_passes_through(self): + def test_dict_without_nested_shapes_is_returned_unchanged(self): usage = {"prompt_tokens": 10, "total_tokens": 30} - assert LLM._usage_to_dict(usage) is usage + result = LLM._usage_to_dict(usage) + assert result == usage + # The input dict is copied, not mutated, so derived keys are not added. + assert "cached_prompt_tokens" not in result + + @pytest.mark.parametrize( + ("usage", "expected"), + [ + pytest.param( + {"prompt_tokens": 100, "prompt_tokens_details": {"cached_tokens": 40}}, + {"cached_prompt_tokens": 40}, + id="openai-nested-cached-tokens", + ), + pytest.param( + {"prompt_tokens": 100, "cached_tokens": 30}, + {"cached_prompt_tokens": 30}, + id="flat-cached-tokens", + ), + pytest.param( + {"input_tokens": 100, "cache_read_input_tokens": 25}, + {"cached_prompt_tokens": 25}, + id="anthropic-cache-read-input-tokens", + ), + pytest.param( + { + "completion_tokens": 200, + "completion_tokens_details": {"reasoning_tokens": 60}, + }, + {"reasoning_tokens": 60}, + id="openai-nested-reasoning-tokens", + ), + pytest.param( + {"input_tokens": 100, "cache_creation_input_tokens": 70}, + {"cache_creation_tokens": 70}, + id="anthropic-cache-creation-input-tokens", + ), + pytest.param( + { + "prompt_tokens": 100, + "completion_tokens": 200, + "prompt_tokens_details": {"cached_tokens": 40}, + "completion_tokens_details": {"reasoning_tokens": 60}, + "cache_creation_input_tokens": 10, + }, + { + "cached_prompt_tokens": 40, + "reasoning_tokens": 60, + "cache_creation_tokens": 10, + }, + id="all-buckets-from-nested-shapes", + ), + ], + ) + def test_normalizes_nested_litellm_buckets(self, usage, expected): + result = LLM._usage_to_dict(usage) + for key, value in expected.items(): + assert result[key] == value + + def test_does_not_alter_core_token_counts(self): + usage = { + "prompt_tokens": 100, + "completion_tokens": 200, + "total_tokens": 300, + "prompt_tokens_details": {"cached_tokens": 40}, + } + result = LLM._usage_to_dict(usage) + assert result["prompt_tokens"] == 100 + assert result["completion_tokens"] == 200 + assert result["total_tokens"] == 300 + + def test_absent_buckets_are_not_added(self): + usage = {"prompt_tokens": 100, "completion_tokens": 200, "total_tokens": 300} + result = LLM._usage_to_dict(usage) + assert "cached_prompt_tokens" not in result + assert "reasoning_tokens" not in result + assert "cache_creation_tokens" not in result def test_pydantic_model_uses_model_dump(self): class Usage(BaseModel): From 73d20fb0c3b969dd972eeac7c4bba02a248c8bd0 Mon Sep 17 00:00:00 2001 From: Gui Vieira Date: Wed, 3 Jun 2026 17:01:10 -0300 Subject: [PATCH 07/18] Document monorepo deployments (#6018) * Document monorepo deployments * Add localized monorepo docs --- docs/ar/enterprise/guides/deploy-to-amp.mdx | 6 + .../guides/monorepo-deployments.mdx | 220 +++++++++++++++++ docs/docs.json | 60 +++++ docs/en/enterprise/guides/deploy-to-amp.mdx | 6 + .../guides/monorepo-deployments.mdx | 225 +++++++++++++++++ docs/ko/enterprise/guides/deploy-to-amp.mdx | 8 +- .../guides/monorepo-deployments.mdx | 222 +++++++++++++++++ .../pt-BR/enterprise/guides/deploy-to-amp.mdx | 8 +- .../guides/monorepo-deployments.mdx | 230 ++++++++++++++++++ 9 files changed, 983 insertions(+), 2 deletions(-) create mode 100644 docs/ar/enterprise/guides/monorepo-deployments.mdx create mode 100644 docs/en/enterprise/guides/monorepo-deployments.mdx create mode 100644 docs/ko/enterprise/guides/monorepo-deployments.mdx create mode 100644 docs/pt-BR/enterprise/guides/monorepo-deployments.mdx diff --git a/docs/ar/enterprise/guides/deploy-to-amp.mdx b/docs/ar/enterprise/guides/deploy-to-amp.mdx index befc894d7..dd9041dee 100644 --- a/docs/ar/enterprise/guides/deploy-to-amp.mdx +++ b/docs/ar/enterprise/guides/deploy-to-amp.mdx @@ -164,6 +164,12 @@ crewai deploy remove ![اختيار المستودع](/images/enterprise/select-repo.png) + + إذا كان Crew أو Flow داخل مجلد فرعي في monorepo، فوسّع **Advanced** + وعيّن دليل عمل قبل النشر. راجع + [النشر من Monorepo](/ar/enterprise/guides/monorepo-deployments). + + diff --git a/docs/ar/enterprise/guides/monorepo-deployments.mdx b/docs/ar/enterprise/guides/monorepo-deployments.mdx new file mode 100644 index 000000000..519ab691e --- /dev/null +++ b/docs/ar/enterprise/guides/monorepo-deployments.mdx @@ -0,0 +1,220 @@ +--- +title: "النشر من Monorepo" +description: "انشر Crew أو Flow من مجلد فرعي داخل مستودع أكبر" +icon: "folder-tree" +mode: "wide" +--- + + + استخدم دليل عمل عندما يكون Crew أو Flow داخل مستودع أكبر. يتحقق CrewAI AMP + من الأتمتة ويبنيها ويشغلها من ذلك المجلد الفرعي بدلاً من جذر المستودع. + + +## متى تستخدم ذلك + +يكون النشر من 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 + + + اضبط أدلة العمل من واجهة AMP على الويب. لا يطلب تدفق CLI + `crewai deploy create` هذا الحقل. + + +يمكنك أيضاً إضافة دليل العمل أو تغييره في نشر موجود من صفحة **Settings** الخاصة +بالنشر. يسري التغيير في النشر التالي. + + + لا يمكن استخدام أدلة العمل وauto-deploy معاً. إذا كان للنشر دليل عمل، يتم + تعطيل auto-deploy لذلك النشر. أوقف auto-deploy قبل تعيين دليل عمل. + + +## إعداد نشر جديد + + + + في CrewAI AMP، أنشئ نشراً جديداً واختر المصدر: GitHub أو Git Repository أو + رفع ZIP. + + + + اختر المستودع والفرع اللذين يحتويان على monorepo، أو ارفع ملف ZIP يحتوي + جذره على محتويات monorepo. + + + + وسّع قسم **Advanced** في نموذج النشر. + + + + أدخل المسار من جذر المستودع إلى مشروع Crew أو Flow: + + ```text + crews/support_agent + ``` + + لا تضف شرطة مائلة في البداية. + + + + أضف أي متغيرات بيئة مطلوبة، ثم ابدأ النشر. + + + +## إعداد نشر موجود + + + + انتقل إلى الأتمتة في AMP وافتح **Settings**. + + + + إذا كان auto-deploy مفعلاً، أوقفه أولاً. لا يكون حقل دليل العمل متاحاً + أثناء تشغيل auto-deploy. + + + + في **Basic settings**، أدخل مسار المجلد الفرعي، مثل: + + ```text + crews/support_agent + ``` + + + + احفظ الإعداد وأعد نشر الأتمتة. سيتم استخدام دليل العمل الجديد في النشر + التالي. + + + +## قواعد المسار + +يجب أن يكون دليل العمل مساراً نسبياً داخل جذر المستودع أو 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: + + + + ```text + company-ai/ + `-- crews/ + `-- support_agent/ + |-- pyproject.toml + |-- uv.lock + `-- src/ + `-- support_agent/ + `-- main.py + ``` + + + + ```text + company-ai/ + |-- uv.lock + |-- packages/ + | `-- shared_tools/ + `-- crews/ + `-- support_agent/ + |-- pyproject.toml + `-- src/ + `-- support_agent/ + `-- main.py + ``` + + + + + إذا كانت الأتمتة تستورد حزماً مشتركة من مكان آخر في monorepo، فصرّح بهذه + الحزم في `pyproject.toml` باستخدام إعدادات UV workspace أو path أو source. + يشغل AMP الأتمتة من المجلد المحدد، لذلك يجب تثبيت الكود المشترك كاعتمادية + بدلاً من الاعتماد على وجود جذر المستودع في Python path. + + +## استكشاف الأخطاء وإصلاحها + +### لم يتم العثور على دليل العمل + +تحقق من أن المسار نسبي إلى جذر المستودع أو 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. + + + تابع دليل النشر بعد اختيار دليل عمل monorepo. + diff --git a/docs/docs.json b/docs/docs.json index e407a8226..83bec7a3b 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -510,6 +510,7 @@ "en/enterprise/guides/build-crew", "en/enterprise/guides/prepare-for-deployment", "en/enterprise/guides/deploy-to-amp", + "en/enterprise/guides/monorepo-deployments", "en/enterprise/guides/private-package-registry", "en/enterprise/guides/kickoff-crew", "en/enterprise/guides/update-crew", @@ -1029,6 +1030,7 @@ "en/enterprise/guides/build-crew", "en/enterprise/guides/prepare-for-deployment", "en/enterprise/guides/deploy-to-amp", + "en/enterprise/guides/monorepo-deployments", "en/enterprise/guides/private-package-registry", "en/enterprise/guides/kickoff-crew", "en/enterprise/guides/update-crew", @@ -1516,6 +1518,7 @@ "en/enterprise/guides/build-crew", "en/enterprise/guides/prepare-for-deployment", "en/enterprise/guides/deploy-to-amp", + "en/enterprise/guides/monorepo-deployments", "en/enterprise/guides/private-package-registry", "en/enterprise/guides/kickoff-crew", "en/enterprise/guides/update-crew", @@ -2002,6 +2005,7 @@ "en/enterprise/guides/build-crew", "en/enterprise/guides/prepare-for-deployment", "en/enterprise/guides/deploy-to-amp", + "en/enterprise/guides/monorepo-deployments", "en/enterprise/guides/private-package-registry", "en/enterprise/guides/kickoff-crew", "en/enterprise/guides/update-crew", @@ -2488,6 +2492,7 @@ "en/enterprise/guides/build-crew", "en/enterprise/guides/prepare-for-deployment", "en/enterprise/guides/deploy-to-amp", + "en/enterprise/guides/monorepo-deployments", "en/enterprise/guides/private-package-registry", "en/enterprise/guides/kickoff-crew", "en/enterprise/guides/update-crew", @@ -2984,6 +2989,7 @@ "en/enterprise/guides/build-crew", "en/enterprise/guides/prepare-for-deployment", "en/enterprise/guides/deploy-to-amp", + "en/enterprise/guides/monorepo-deployments", "en/enterprise/guides/private-package-registry", "en/enterprise/guides/kickoff-crew", "en/enterprise/guides/update-crew", @@ -3480,6 +3486,7 @@ "en/enterprise/guides/build-crew", "en/enterprise/guides/prepare-for-deployment", "en/enterprise/guides/deploy-to-amp", + "en/enterprise/guides/monorepo-deployments", "en/enterprise/guides/private-package-registry", "en/enterprise/guides/kickoff-crew", "en/enterprise/guides/update-crew", @@ -3976,6 +3983,7 @@ "en/enterprise/guides/build-crew", "en/enterprise/guides/prepare-for-deployment", "en/enterprise/guides/deploy-to-amp", + "en/enterprise/guides/monorepo-deployments", "en/enterprise/guides/private-package-registry", "en/enterprise/guides/kickoff-crew", "en/enterprise/guides/update-crew", @@ -4472,6 +4480,7 @@ "en/enterprise/guides/build-crew", "en/enterprise/guides/prepare-for-deployment", "en/enterprise/guides/deploy-to-amp", + "en/enterprise/guides/monorepo-deployments", "en/enterprise/guides/private-package-registry", "en/enterprise/guides/kickoff-crew", "en/enterprise/guides/update-crew", @@ -4957,6 +4966,7 @@ "en/enterprise/guides/build-crew", "en/enterprise/guides/prepare-for-deployment", "en/enterprise/guides/deploy-to-amp", + "en/enterprise/guides/monorepo-deployments", "en/enterprise/guides/private-package-registry", "en/enterprise/guides/kickoff-crew", "en/enterprise/guides/update-crew", @@ -5442,6 +5452,7 @@ "en/enterprise/guides/build-crew", "en/enterprise/guides/prepare-for-deployment", "en/enterprise/guides/deploy-to-amp", + "en/enterprise/guides/monorepo-deployments", "en/enterprise/guides/private-package-registry", "en/enterprise/guides/kickoff-crew", "en/enterprise/guides/update-crew", @@ -5927,6 +5938,7 @@ "en/enterprise/guides/build-crew", "en/enterprise/guides/prepare-for-deployment", "en/enterprise/guides/deploy-to-amp", + "en/enterprise/guides/monorepo-deployments", "en/enterprise/guides/private-package-registry", "en/enterprise/guides/kickoff-crew", "en/enterprise/guides/training-crews", @@ -6414,6 +6426,7 @@ "en/enterprise/guides/build-crew", "en/enterprise/guides/prepare-for-deployment", "en/enterprise/guides/deploy-to-amp", + "en/enterprise/guides/monorepo-deployments", "en/enterprise/guides/private-package-registry", "en/enterprise/guides/kickoff-crew", "en/enterprise/guides/training-crews", @@ -6899,6 +6912,7 @@ "en/enterprise/guides/build-crew", "en/enterprise/guides/prepare-for-deployment", "en/enterprise/guides/deploy-to-amp", + "en/enterprise/guides/monorepo-deployments", "en/enterprise/guides/private-package-registry", "en/enterprise/guides/kickoff-crew", "en/enterprise/guides/training-crews", @@ -7387,6 +7401,7 @@ "en/enterprise/guides/build-crew", "en/enterprise/guides/prepare-for-deployment", "en/enterprise/guides/deploy-to-amp", + "en/enterprise/guides/monorepo-deployments", "en/enterprise/guides/private-package-registry", "en/enterprise/guides/kickoff-crew", "en/enterprise/guides/training-crews", @@ -7898,6 +7913,7 @@ "pt-BR/enterprise/guides/build-crew", "pt-BR/enterprise/guides/prepare-for-deployment", "pt-BR/enterprise/guides/deploy-to-amp", + "pt-BR/enterprise/guides/monorepo-deployments", "pt-BR/enterprise/guides/private-package-registry", "pt-BR/enterprise/guides/kickoff-crew", "pt-BR/enterprise/guides/training-crews", @@ -8394,6 +8410,7 @@ "pt-BR/enterprise/guides/build-crew", "pt-BR/enterprise/guides/prepare-for-deployment", "pt-BR/enterprise/guides/deploy-to-amp", + "pt-BR/enterprise/guides/monorepo-deployments", "pt-BR/enterprise/guides/private-package-registry", "pt-BR/enterprise/guides/kickoff-crew", "pt-BR/enterprise/guides/training-crews", @@ -8858,6 +8875,7 @@ "pt-BR/enterprise/guides/build-crew", "pt-BR/enterprise/guides/prepare-for-deployment", "pt-BR/enterprise/guides/deploy-to-amp", + "pt-BR/enterprise/guides/monorepo-deployments", "pt-BR/enterprise/guides/private-package-registry", "pt-BR/enterprise/guides/kickoff-crew", "pt-BR/enterprise/guides/training-crews", @@ -9322,6 +9340,7 @@ "pt-BR/enterprise/guides/build-crew", "pt-BR/enterprise/guides/prepare-for-deployment", "pt-BR/enterprise/guides/deploy-to-amp", + "pt-BR/enterprise/guides/monorepo-deployments", "pt-BR/enterprise/guides/private-package-registry", "pt-BR/enterprise/guides/kickoff-crew", "pt-BR/enterprise/guides/training-crews", @@ -9785,6 +9804,7 @@ "pt-BR/enterprise/guides/build-crew", "pt-BR/enterprise/guides/prepare-for-deployment", "pt-BR/enterprise/guides/deploy-to-amp", + "pt-BR/enterprise/guides/monorepo-deployments", "pt-BR/enterprise/guides/private-package-registry", "pt-BR/enterprise/guides/kickoff-crew", "pt-BR/enterprise/guides/training-crews", @@ -10258,6 +10278,7 @@ "pt-BR/enterprise/guides/build-crew", "pt-BR/enterprise/guides/prepare-for-deployment", "pt-BR/enterprise/guides/deploy-to-amp", + "pt-BR/enterprise/guides/monorepo-deployments", "pt-BR/enterprise/guides/private-package-registry", "pt-BR/enterprise/guides/kickoff-crew", "pt-BR/enterprise/guides/training-crews", @@ -10731,6 +10752,7 @@ "pt-BR/enterprise/guides/build-crew", "pt-BR/enterprise/guides/prepare-for-deployment", "pt-BR/enterprise/guides/deploy-to-amp", + "pt-BR/enterprise/guides/monorepo-deployments", "pt-BR/enterprise/guides/private-package-registry", "pt-BR/enterprise/guides/kickoff-crew", "pt-BR/enterprise/guides/training-crews", @@ -11204,6 +11226,7 @@ "pt-BR/enterprise/guides/build-crew", "pt-BR/enterprise/guides/prepare-for-deployment", "pt-BR/enterprise/guides/deploy-to-amp", + "pt-BR/enterprise/guides/monorepo-deployments", "pt-BR/enterprise/guides/private-package-registry", "pt-BR/enterprise/guides/kickoff-crew", "pt-BR/enterprise/guides/training-crews", @@ -11677,6 +11700,7 @@ "pt-BR/enterprise/guides/build-crew", "pt-BR/enterprise/guides/prepare-for-deployment", "pt-BR/enterprise/guides/deploy-to-amp", + "pt-BR/enterprise/guides/monorepo-deployments", "pt-BR/enterprise/guides/private-package-registry", "pt-BR/enterprise/guides/kickoff-crew", "pt-BR/enterprise/guides/training-crews", @@ -12140,6 +12164,7 @@ "pt-BR/enterprise/guides/build-crew", "pt-BR/enterprise/guides/prepare-for-deployment", "pt-BR/enterprise/guides/deploy-to-amp", + "pt-BR/enterprise/guides/monorepo-deployments", "pt-BR/enterprise/guides/private-package-registry", "pt-BR/enterprise/guides/kickoff-crew", "pt-BR/enterprise/guides/training-crews", @@ -12603,6 +12628,7 @@ "pt-BR/enterprise/guides/build-crew", "pt-BR/enterprise/guides/prepare-for-deployment", "pt-BR/enterprise/guides/deploy-to-amp", + "pt-BR/enterprise/guides/monorepo-deployments", "pt-BR/enterprise/guides/private-package-registry", "pt-BR/enterprise/guides/kickoff-crew", "pt-BR/enterprise/guides/training-crews", @@ -13066,6 +13092,7 @@ "pt-BR/enterprise/guides/build-crew", "pt-BR/enterprise/guides/prepare-for-deployment", "pt-BR/enterprise/guides/deploy-to-amp", + "pt-BR/enterprise/guides/monorepo-deployments", "pt-BR/enterprise/guides/private-package-registry", "pt-BR/enterprise/guides/kickoff-crew", "pt-BR/enterprise/guides/training-crews", @@ -13528,6 +13555,7 @@ "pt-BR/enterprise/guides/build-crew", "pt-BR/enterprise/guides/prepare-for-deployment", "pt-BR/enterprise/guides/deploy-to-amp", + "pt-BR/enterprise/guides/monorepo-deployments", "pt-BR/enterprise/guides/private-package-registry", "pt-BR/enterprise/guides/kickoff-crew", "pt-BR/enterprise/guides/training-crews", @@ -13990,6 +14018,7 @@ "pt-BR/enterprise/guides/build-crew", "pt-BR/enterprise/guides/prepare-for-deployment", "pt-BR/enterprise/guides/deploy-to-amp", + "pt-BR/enterprise/guides/monorepo-deployments", "pt-BR/enterprise/guides/private-package-registry", "pt-BR/enterprise/guides/kickoff-crew", "pt-BR/enterprise/guides/training-crews", @@ -14453,6 +14482,7 @@ "pt-BR/enterprise/guides/build-crew", "pt-BR/enterprise/guides/prepare-for-deployment", "pt-BR/enterprise/guides/deploy-to-amp", + "pt-BR/enterprise/guides/monorepo-deployments", "pt-BR/enterprise/guides/private-package-registry", "pt-BR/enterprise/guides/kickoff-crew", "pt-BR/enterprise/guides/training-crews", @@ -14992,6 +15022,7 @@ "ko/enterprise/guides/build-crew", "ko/enterprise/guides/prepare-for-deployment", "ko/enterprise/guides/deploy-to-amp", + "ko/enterprise/guides/monorepo-deployments", "ko/enterprise/guides/private-package-registry", "ko/enterprise/guides/kickoff-crew", "ko/enterprise/guides/training-crews", @@ -15500,6 +15531,7 @@ "ko/enterprise/guides/build-crew", "ko/enterprise/guides/prepare-for-deployment", "ko/enterprise/guides/deploy-to-amp", + "ko/enterprise/guides/monorepo-deployments", "ko/enterprise/guides/private-package-registry", "ko/enterprise/guides/kickoff-crew", "ko/enterprise/guides/training-crews", @@ -15976,6 +16008,7 @@ "ko/enterprise/guides/build-crew", "ko/enterprise/guides/prepare-for-deployment", "ko/enterprise/guides/deploy-to-amp", + "ko/enterprise/guides/monorepo-deployments", "ko/enterprise/guides/private-package-registry", "ko/enterprise/guides/kickoff-crew", "ko/enterprise/guides/training-crews", @@ -16452,6 +16485,7 @@ "ko/enterprise/guides/build-crew", "ko/enterprise/guides/prepare-for-deployment", "ko/enterprise/guides/deploy-to-amp", + "ko/enterprise/guides/monorepo-deployments", "ko/enterprise/guides/private-package-registry", "ko/enterprise/guides/kickoff-crew", "ko/enterprise/guides/training-crews", @@ -16928,6 +16962,7 @@ "ko/enterprise/guides/build-crew", "ko/enterprise/guides/prepare-for-deployment", "ko/enterprise/guides/deploy-to-amp", + "ko/enterprise/guides/monorepo-deployments", "ko/enterprise/guides/private-package-registry", "ko/enterprise/guides/kickoff-crew", "ko/enterprise/guides/training-crews", @@ -17414,6 +17449,7 @@ "ko/enterprise/guides/build-crew", "ko/enterprise/guides/prepare-for-deployment", "ko/enterprise/guides/deploy-to-amp", + "ko/enterprise/guides/monorepo-deployments", "ko/enterprise/guides/private-package-registry", "ko/enterprise/guides/kickoff-crew", "ko/enterprise/guides/training-crews", @@ -17900,6 +17936,7 @@ "ko/enterprise/guides/build-crew", "ko/enterprise/guides/prepare-for-deployment", "ko/enterprise/guides/deploy-to-amp", + "ko/enterprise/guides/monorepo-deployments", "ko/enterprise/guides/private-package-registry", "ko/enterprise/guides/kickoff-crew", "ko/enterprise/guides/training-crews", @@ -18386,6 +18423,7 @@ "ko/enterprise/guides/build-crew", "ko/enterprise/guides/prepare-for-deployment", "ko/enterprise/guides/deploy-to-amp", + "ko/enterprise/guides/monorepo-deployments", "ko/enterprise/guides/private-package-registry", "ko/enterprise/guides/kickoff-crew", "ko/enterprise/guides/training-crews", @@ -18872,6 +18910,7 @@ "ko/enterprise/guides/build-crew", "ko/enterprise/guides/prepare-for-deployment", "ko/enterprise/guides/deploy-to-amp", + "ko/enterprise/guides/monorepo-deployments", "ko/enterprise/guides/private-package-registry", "ko/enterprise/guides/kickoff-crew", "ko/enterprise/guides/training-crews", @@ -19348,6 +19387,7 @@ "ko/enterprise/guides/build-crew", "ko/enterprise/guides/prepare-for-deployment", "ko/enterprise/guides/deploy-to-amp", + "ko/enterprise/guides/monorepo-deployments", "ko/enterprise/guides/private-package-registry", "ko/enterprise/guides/kickoff-crew", "ko/enterprise/guides/training-crews", @@ -19824,6 +19864,7 @@ "ko/enterprise/guides/build-crew", "ko/enterprise/guides/prepare-for-deployment", "ko/enterprise/guides/deploy-to-amp", + "ko/enterprise/guides/monorepo-deployments", "ko/enterprise/guides/private-package-registry", "ko/enterprise/guides/kickoff-crew", "ko/enterprise/guides/training-crews", @@ -20300,6 +20341,7 @@ "ko/enterprise/guides/build-crew", "ko/enterprise/guides/prepare-for-deployment", "ko/enterprise/guides/deploy-to-amp", + "ko/enterprise/guides/monorepo-deployments", "ko/enterprise/guides/private-package-registry", "ko/enterprise/guides/kickoff-crew", "ko/enterprise/guides/training-crews", @@ -20775,6 +20817,7 @@ "ko/enterprise/guides/build-crew", "ko/enterprise/guides/prepare-for-deployment", "ko/enterprise/guides/deploy-to-amp", + "ko/enterprise/guides/monorepo-deployments", "ko/enterprise/guides/private-package-registry", "ko/enterprise/guides/kickoff-crew", "ko/enterprise/guides/training-crews", @@ -21250,6 +21293,7 @@ "ko/enterprise/guides/build-crew", "ko/enterprise/guides/prepare-for-deployment", "ko/enterprise/guides/deploy-to-amp", + "ko/enterprise/guides/monorepo-deployments", "ko/enterprise/guides/private-package-registry", "ko/enterprise/guides/kickoff-crew", "ko/enterprise/guides/training-crews", @@ -21726,6 +21770,7 @@ "ko/enterprise/guides/build-crew", "ko/enterprise/guides/prepare-for-deployment", "ko/enterprise/guides/deploy-to-amp", + "ko/enterprise/guides/monorepo-deployments", "ko/enterprise/guides/private-package-registry", "ko/enterprise/guides/kickoff-crew", "ko/enterprise/guides/training-crews", @@ -22265,6 +22310,7 @@ "ar/enterprise/guides/build-crew", "ar/enterprise/guides/prepare-for-deployment", "ar/enterprise/guides/deploy-to-amp", + "ar/enterprise/guides/monorepo-deployments", "ar/enterprise/guides/private-package-registry", "ar/enterprise/guides/kickoff-crew", "ar/enterprise/guides/training-crews", @@ -22773,6 +22819,7 @@ "ar/enterprise/guides/build-crew", "ar/enterprise/guides/prepare-for-deployment", "ar/enterprise/guides/deploy-to-amp", + "ar/enterprise/guides/monorepo-deployments", "ar/enterprise/guides/private-package-registry", "ar/enterprise/guides/kickoff-crew", "ar/enterprise/guides/training-crews", @@ -23249,6 +23296,7 @@ "ar/enterprise/guides/build-crew", "ar/enterprise/guides/prepare-for-deployment", "ar/enterprise/guides/deploy-to-amp", + "ar/enterprise/guides/monorepo-deployments", "ar/enterprise/guides/private-package-registry", "ar/enterprise/guides/kickoff-crew", "ar/enterprise/guides/training-crews", @@ -23725,6 +23773,7 @@ "ar/enterprise/guides/build-crew", "ar/enterprise/guides/prepare-for-deployment", "ar/enterprise/guides/deploy-to-amp", + "ar/enterprise/guides/monorepo-deployments", "ar/enterprise/guides/private-package-registry", "ar/enterprise/guides/kickoff-crew", "ar/enterprise/guides/training-crews", @@ -24201,6 +24250,7 @@ "ar/enterprise/guides/build-crew", "ar/enterprise/guides/prepare-for-deployment", "ar/enterprise/guides/deploy-to-amp", + "ar/enterprise/guides/monorepo-deployments", "ar/enterprise/guides/private-package-registry", "ar/enterprise/guides/kickoff-crew", "ar/enterprise/guides/training-crews", @@ -24687,6 +24737,7 @@ "ar/enterprise/guides/build-crew", "ar/enterprise/guides/prepare-for-deployment", "ar/enterprise/guides/deploy-to-amp", + "ar/enterprise/guides/monorepo-deployments", "ar/enterprise/guides/private-package-registry", "ar/enterprise/guides/kickoff-crew", "ar/enterprise/guides/training-crews", @@ -25173,6 +25224,7 @@ "ar/enterprise/guides/build-crew", "ar/enterprise/guides/prepare-for-deployment", "ar/enterprise/guides/deploy-to-amp", + "ar/enterprise/guides/monorepo-deployments", "ar/enterprise/guides/private-package-registry", "ar/enterprise/guides/kickoff-crew", "ar/enterprise/guides/training-crews", @@ -25659,6 +25711,7 @@ "ar/enterprise/guides/build-crew", "ar/enterprise/guides/prepare-for-deployment", "ar/enterprise/guides/deploy-to-amp", + "ar/enterprise/guides/monorepo-deployments", "ar/enterprise/guides/private-package-registry", "ar/enterprise/guides/kickoff-crew", "ar/enterprise/guides/training-crews", @@ -26145,6 +26198,7 @@ "ar/enterprise/guides/build-crew", "ar/enterprise/guides/prepare-for-deployment", "ar/enterprise/guides/deploy-to-amp", + "ar/enterprise/guides/monorepo-deployments", "ar/enterprise/guides/private-package-registry", "ar/enterprise/guides/kickoff-crew", "ar/enterprise/guides/training-crews", @@ -26621,6 +26675,7 @@ "ar/enterprise/guides/build-crew", "ar/enterprise/guides/prepare-for-deployment", "ar/enterprise/guides/deploy-to-amp", + "ar/enterprise/guides/monorepo-deployments", "ar/enterprise/guides/private-package-registry", "ar/enterprise/guides/kickoff-crew", "ar/enterprise/guides/training-crews", @@ -27097,6 +27152,7 @@ "ar/enterprise/guides/build-crew", "ar/enterprise/guides/prepare-for-deployment", "ar/enterprise/guides/deploy-to-amp", + "ar/enterprise/guides/monorepo-deployments", "ar/enterprise/guides/private-package-registry", "ar/enterprise/guides/kickoff-crew", "ar/enterprise/guides/training-crews", @@ -27573,6 +27629,7 @@ "ar/enterprise/guides/build-crew", "ar/enterprise/guides/prepare-for-deployment", "ar/enterprise/guides/deploy-to-amp", + "ar/enterprise/guides/monorepo-deployments", "ar/enterprise/guides/private-package-registry", "ar/enterprise/guides/kickoff-crew", "ar/enterprise/guides/training-crews", @@ -28048,6 +28105,7 @@ "ar/enterprise/guides/build-crew", "ar/enterprise/guides/prepare-for-deployment", "ar/enterprise/guides/deploy-to-amp", + "ar/enterprise/guides/monorepo-deployments", "ar/enterprise/guides/private-package-registry", "ar/enterprise/guides/kickoff-crew", "ar/enterprise/guides/training-crews", @@ -28523,6 +28581,7 @@ "ar/enterprise/guides/build-crew", "ar/enterprise/guides/prepare-for-deployment", "ar/enterprise/guides/deploy-to-amp", + "ar/enterprise/guides/monorepo-deployments", "ar/enterprise/guides/private-package-registry", "ar/enterprise/guides/kickoff-crew", "ar/enterprise/guides/training-crews", @@ -28999,6 +29058,7 @@ "ar/enterprise/guides/build-crew", "ar/enterprise/guides/prepare-for-deployment", "ar/enterprise/guides/deploy-to-amp", + "ar/enterprise/guides/monorepo-deployments", "ar/enterprise/guides/private-package-registry", "ar/enterprise/guides/kickoff-crew", "ar/enterprise/guides/training-crews", diff --git a/docs/en/enterprise/guides/deploy-to-amp.mdx b/docs/en/enterprise/guides/deploy-to-amp.mdx index 25f6896b8..37ce80342 100644 --- a/docs/en/enterprise/guides/deploy-to-amp.mdx +++ b/docs/en/enterprise/guides/deploy-to-amp.mdx @@ -164,6 +164,12 @@ You need to push your crew to a GitHub repository. If you haven't created a crew ![Select Repository](/images/enterprise/select-repo.png) + + 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). + + diff --git a/docs/en/enterprise/guides/monorepo-deployments.mdx b/docs/en/enterprise/guides/monorepo-deployments.mdx new file mode 100644 index 000000000..260c398a4 --- /dev/null +++ b/docs/en/enterprise/guides/monorepo-deployments.mdx @@ -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" +--- + + + 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. + + +## 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 + + + Configure working directories in the AMP web interface. The + `crewai deploy create` CLI flow does not prompt for this field. + + +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. + + + 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. + + +## Configure a New Deployment + + + + In CrewAI AMP, create a new deployment and choose your source: GitHub, Git + Repository, or ZIP upload. + + + + Choose the repository and branch that contain your monorepo, or upload a ZIP + file whose root contains the monorepo contents. + + + + Expand the **Advanced** section in the deploy form. + + + + Enter the path from the repository root to the Crew or Flow project: + + ```text + crews/support_agent + ``` + + Do not include a leading slash. + + + + Add any required environment variables, then start the deployment. + + + +## Configure an Existing Deployment + + + + Go to your automation in AMP and open **Settings**. + + + + If auto-deploy is enabled, disable it first. The working directory field is + unavailable while auto-deploy is on. + + + + In **Basic settings**, enter the subfolder path, such as: + + ```text + crews/support_agent + ``` + + + + Save the setting and redeploy the automation. The new working directory is + used on the next deploy. + + + +## 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: + + + + ```text + company-ai/ + `-- crews/ + `-- support_agent/ + |-- pyproject.toml + |-- uv.lock + `-- src/ + `-- support_agent/ + `-- main.py + ``` + + + + ```text + company-ai/ + |-- uv.lock + |-- packages/ + | `-- shared_tools/ + `-- crews/ + `-- support_agent/ + |-- pyproject.toml + `-- src/ + `-- support_agent/ + `-- main.py + ``` + + + + + 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. + + +## 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. + + + Continue with the deployment guide after choosing your monorepo working + directory. + diff --git a/docs/ko/enterprise/guides/deploy-to-amp.mdx b/docs/ko/enterprise/guides/deploy-to-amp.mdx index 2a519b9d3..293726c2d 100644 --- a/docs/ko/enterprise/guides/deploy-to-amp.mdx +++ b/docs/ko/enterprise/guides/deploy-to-amp.mdx @@ -163,6 +163,12 @@ Crew를 GitHub 저장소에 푸시해야 합니다. 아직 Crew를 만들지 않 ![Select Repository](/images/enterprise/select-repo.png) + + Crew 또는 Flow가 모노레포 하위 폴더 안에 있다면 배포 전에 + **Advanced**를 펼치고 작업 디렉터리를 설정하세요. + [모노레포 배포](/ko/enterprise/guides/monorepo-deployments)를 참조하세요. + + @@ -440,4 +446,4 @@ type = "flow" 배포 문제 또는 AMP 플랫폼에 대한 문의 사항이 있으시면 지원팀에 연락해 주세요. - \ No newline at end of file + diff --git a/docs/ko/enterprise/guides/monorepo-deployments.mdx b/docs/ko/enterprise/guides/monorepo-deployments.mdx new file mode 100644 index 000000000..1d543cc96 --- /dev/null +++ b/docs/ko/enterprise/guides/monorepo-deployments.mdx @@ -0,0 +1,222 @@ +--- +title: "모노레포 배포" +description: "더 큰 저장소의 하위 폴더에서 Crew 또는 Flow 배포하기" +icon: "folder-tree" +mode: "wide" +--- + + + Crew 또는 Flow가 더 큰 저장소 안에 있을 때 작업 디렉터리를 사용하세요. + CrewAI AMP는 저장소 루트 대신 해당 하위 폴더에서 자동화를 검증, 빌드, + 실행합니다. + + +## 사용 시점 + +모노레포 배포는 하나의 저장소에 여러 자동화, 공유 패키지 또는 다른 애플리케이션 +코드가 함께 있을 때 유용합니다: + +```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 업로드 + + + 작업 디렉터리는 AMP 웹 인터페이스에서 구성하세요. + `crewai deploy create` CLI 흐름은 이 필드를 묻지 않습니다. + + +기존 배포의 **Settings** 페이지에서도 작업 디렉터리를 추가하거나 변경할 수 +있습니다. 변경 사항은 다음 배포부터 적용됩니다. + + + 작업 디렉터리와 auto-deploy는 함께 사용할 수 없습니다. 배포에 작업 + 디렉터리가 설정되어 있으면 해당 배포의 auto-deploy가 비활성화됩니다. + 작업 디렉터리를 설정하기 전에 auto-deploy를 끄세요. + + +## 새 배포 구성 + + + + CrewAI AMP에서 새 배포를 만들고 소스를 선택합니다: GitHub, Git + Repository 또는 ZIP 업로드. + + + + 모노레포가 들어 있는 저장소와 브랜치를 선택하거나, 루트에 모노레포 내용이 + 포함된 ZIP 파일을 업로드합니다. + + + + 배포 양식에서 **Advanced** 섹션을 펼칩니다. + + + + 저장소 루트에서 Crew 또는 Flow 프로젝트까지의 경로를 입력합니다: + + ```text + crews/support_agent + ``` + + 앞에 슬래시를 붙이지 마세요. + + + + 필요한 환경 변수를 추가한 다음 배포를 시작합니다. + + + +## 기존 배포 구성 + + + + AMP에서 자동화로 이동한 뒤 **Settings**를 엽니다. + + + + auto-deploy가 활성화되어 있으면 먼저 끄세요. auto-deploy가 켜져 있는 + 동안에는 작업 디렉터리 필드를 사용할 수 없습니다. + + + + **Basic settings**에서 다음과 같은 하위 폴더 경로를 입력합니다: + + ```text + crews/support_agent + ``` + + + + 설정을 저장하고 자동화를 다시 배포합니다. 새 작업 디렉터리는 다음 배포부터 + 사용됩니다. + + + +## 경로 규칙 + +작업 디렉터리는 저장소 또는 ZIP 루트 안의 상대 경로여야 합니다. + +| 규칙 | 예시 | +|------|------| +| 상대 경로를 사용합니다 | `crews/support_agent` | +| `/`로 시작하지 않습니다 | `/crews/support_agent`는 유효하지 않습니다 | +| `.` 또는 `..` 경로 세그먼트를 사용하지 않습니다 | `crews/../support_agent`는 유효하지 않습니다 | +| 문자, 숫자, 하이픈, 밑줄, 점, 슬래시만 사용합니다 | `crews/support agent`는 유효하지 않습니다 | +| 경로는 255자 이하로 유지합니다 | 더 긴 경로는 거부됩니다 | + +AMP는 앞뒤 공백을 제거하고, 반복된 슬래시를 하나로 줄이며, 끝의 슬래시를 +제거합니다. 빈 값은 저장소 루트를 사용합니다. + +## Lock 파일과 UV 워크스페이스 + +선택한 폴더에는 자동화의 `pyproject.toml`과 `src/` 디렉터리가 있어야 +합니다. `uv.lock` 또는 `poetry.lock` 파일은 선택한 폴더나 저장소 루트에 +둘 수 있습니다. + +이 방식은 일반적인 두 가지 모노레포 레이아웃을 모두 지원합니다: + + + + ```text + company-ai/ + `-- crews/ + `-- support_agent/ + |-- pyproject.toml + |-- uv.lock + `-- src/ + `-- support_agent/ + `-- main.py + ``` + + + + ```text + company-ai/ + |-- uv.lock + |-- packages/ + | `-- shared_tools/ + `-- crews/ + `-- support_agent/ + |-- pyproject.toml + `-- src/ + `-- support_agent/ + `-- main.py + ``` + + + + + 자동화가 모노레포의 다른 위치에 있는 공유 패키지를 가져온다면, UV + workspace, path 또는 source 설정을 사용해 해당 패키지를 `pyproject.toml`에 + 선언하세요. AMP는 선택한 폴더에서 자동화를 실행하므로, 저장소 루트가 + Python path에 있다고 가정하기보다 공유 코드를 종속성으로 설치해야 합니다. + + +## 문제 해결 + +### 작업 디렉터리를 찾을 수 없음 + +경로가 저장소 또는 ZIP 루트를 기준으로 한 상대 경로인지 확인하세요. ZIP +업로드의 경우 ZIP 내용에 입력한 작업 디렉터리 경로가 정확히 포함되어야 합니다. + +### pyproject.toml 누락 + +작업 디렉터리는 여러 프로젝트를 담은 상위 폴더가 아니라 Crew 또는 Flow 프로젝트 +폴더를 가리켜야 합니다. + +### uv.lock 또는 poetry.lock 누락 + +선택한 프로젝트 폴더 또는 저장소 루트에 lock 파일을 커밋하세요. UV +워크스페이스의 경우 `uv.lock`을 워크스페이스 루트에 두는 방식이 지원됩니다. + +### Auto-Deploy를 사용할 수 없음 + +작업 디렉터리가 설정되어 있으면 auto-deploy가 비활성화됩니다. 수동 재배포를 +사용하거나 AMP API로 CI/CD에서 재배포를 트리거하세요. + + + 모노레포 작업 디렉터리를 선택한 뒤 배포 가이드를 계속 진행하세요. + diff --git a/docs/pt-BR/enterprise/guides/deploy-to-amp.mdx b/docs/pt-BR/enterprise/guides/deploy-to-amp.mdx index db70a2711..e37266170 100644 --- a/docs/pt-BR/enterprise/guides/deploy-to-amp.mdx +++ b/docs/pt-BR/enterprise/guides/deploy-to-amp.mdx @@ -163,6 +163,12 @@ Você precisa enviar seu crew para um repositório do GitHub. Caso ainda não te ![Selecionar Repositório](/images/enterprise/select-repo.png) + + 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). + + @@ -441,4 +447,4 @@ type = "flow" Entre em contato com nossa equipe de suporte para ajuda com questões de implantação ou dúvidas sobre a plataforma AMP. - \ No newline at end of file + diff --git a/docs/pt-BR/enterprise/guides/monorepo-deployments.mdx b/docs/pt-BR/enterprise/guides/monorepo-deployments.mdx new file mode 100644 index 000000000..c852b40c3 --- /dev/null +++ b/docs/pt-BR/enterprise/guides/monorepo-deployments.mdx @@ -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" +--- + + + 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. + + +## 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 + + + Configure diretórios de trabalho na interface web do AMP. O fluxo + `crewai deploy create` da CLI não solicita esse campo. + + +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. + + + 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. + + +## Configurar uma Nova Implantação + + + + No CrewAI AMP, crie uma nova implantação e escolha sua fonte: GitHub, Git + Repository ou upload de ZIP. + + + + Escolha o repositório e a branch que contêm seu monorepo, ou envie um ZIP + cuja raiz contenha os arquivos do monorepo. + + + + Expanda a seção **Advanced** no formulário de deploy. + + + + Informe o caminho da raiz do repositório até o projeto Crew ou Flow: + + ```text + crews/support_agent + ``` + + Não inclua uma barra inicial. + + + + Adicione as variáveis de ambiente necessárias e inicie a implantação. + + + +## Configurar uma Implantação Existente + + + + Acesse sua automação no AMP e abra **Settings**. + + + + 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. + + + + Em **Basic settings**, informe o caminho da subpasta, como: + + ```text + crews/support_agent + ``` + + + + Salve a configuração e reimplante a automação. O novo diretório de trabalho + será usado no próximo deploy. + + + +## 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: + + + + ```text + company-ai/ + `-- crews/ + `-- support_agent/ + |-- pyproject.toml + |-- uv.lock + `-- src/ + `-- support_agent/ + `-- main.py + ``` + + + + ```text + company-ai/ + |-- uv.lock + |-- packages/ + | `-- shared_tools/ + `-- crews/ + `-- support_agent/ + |-- pyproject.toml + `-- src/ + `-- support_agent/ + `-- main.py + ``` + + + + + 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. + + +## 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. + + + Continue com o guia de implantação depois de escolher o diretório de trabalho + do monorepo. + From 051fa0c1cb7c54c21291b487c51d107ebb904b7c Mon Sep 17 00:00:00 2001 From: Vini Brasil Date: Wed, 3 Jun 2026 18:02:56 -0300 Subject: [PATCH 08/18] Build FlowDefinition from Flow DSL metadata (#6017) * Build FlowDefinition from Flow DSL metadata Introduce `FlowDefinition`, a serializable model built from the Flow DSL's runtime metadata. It becomes the structural contract for Flow methods, triggers, routers, state, and configuration. The visualization layer is the first consumer: `flow_structure` and `build_flow_structure` now project from the definition instead of re-introspecting the class. The runner still executes from live registries, but the definition gives future runners a single static contract to read. This replaces AST source parsing for router return values, crew references, and state schema with runtime metadata plus explicit `@router(paths=...)` or `Literal`/`Enum` return hints. AST parsing was fragile and could silently fail for dynamic or non-inspectable methods. The refactor removes obsolete introspection and serializer code: * Delete `flow_serializer.py`, `flow/utils.py`, and `visualization/schema.py` * Move flow structure modeling into `flow_definition.py` * Simplify visualization building around the static definition contract * Format files --- lib/crewai/src/crewai/flow/__init__.py | 2 - lib/crewai/src/crewai/flow/dsl.py | 955 +++++++++++-- lib/crewai/src/crewai/flow/flow.py | 4 +- lib/crewai/src/crewai/flow/flow_definition.py | 1232 ++++------------- lib/crewai/src/crewai/flow/flow_serializer.py | 602 -------- lib/crewai/src/crewai/flow/flow_wrappers.py | 18 +- lib/crewai/src/crewai/flow/human_feedback.py | 51 +- .../src/crewai/flow/persistence/__init__.py | 7 - .../src/crewai/flow/persistence/decorators.py | 63 +- lib/crewai/src/crewai/flow/runtime.py | 57 +- lib/crewai/src/crewai/flow/types.py | 1 - lib/crewai/src/crewai/flow/utils.py | 53 - .../flow/visualization/assets/interactive.js | 86 +- .../src/crewai/flow/visualization/builder.py | 500 +++---- .../visualization/renderers/interactive.py | 18 +- .../src/crewai/flow/visualization/schema.py | 104 -- .../src/crewai/flow/visualization/types.py | 20 +- lib/crewai/tests/test_async_human_feedback.py | 50 +- lib/crewai/tests/test_flow.py | 48 +- lib/crewai/tests/test_flow_definition.py | 839 +++++++++++ lib/crewai/tests/test_flow_serializer.py | 818 ----------- lib/crewai/tests/test_flow_visualization.py | 278 ++-- .../tests/test_human_feedback_decorator.py | 22 +- .../tests/test_human_feedback_integration.py | 26 +- 24 files changed, 2543 insertions(+), 3311 deletions(-) delete mode 100644 lib/crewai/src/crewai/flow/flow_serializer.py delete mode 100644 lib/crewai/src/crewai/flow/utils.py delete mode 100644 lib/crewai/src/crewai/flow/visualization/schema.py create mode 100644 lib/crewai/tests/test_flow_definition.py delete mode 100644 lib/crewai/tests/test_flow_serializer.py diff --git a/lib/crewai/src/crewai/flow/__init__.py b/lib/crewai/src/crewai/flow/__init__.py index 7142403ad..2475dd226 100644 --- a/lib/crewai/src/crewai/flow/__init__.py +++ b/lib/crewai/src/crewai/flow/__init__.py @@ -11,7 +11,6 @@ from crewai.flow.conversation import ( ) from crewai.flow.flow import Flow, and_, listen, or_, router, start from crewai.flow.flow_config import flow_config -from crewai.flow.flow_serializer import flow_structure from crewai.flow.human_feedback import HumanFeedbackResult, human_feedback from crewai.flow.input_provider import InputProvider, InputResponse from crewai.flow.persistence import persist @@ -38,7 +37,6 @@ __all__ = [ "and_", "build_flow_structure", "flow_config", - "flow_structure", "human_feedback", "listen", "or_", diff --git a/lib/crewai/src/crewai/flow/dsl.py b/lib/crewai/src/crewai/flow/dsl.py index 3181acd50..353d8f187 100644 --- a/lib/crewai/src/crewai/flow/dsl.py +++ b/lib/crewai/src/crewai/flow/dsl.py @@ -1,36 +1,342 @@ -"""Flow authoring DSL: the ``@start`` / ``@listen`` / ``@router`` decorators -plus the ``or_`` / ``and_`` condition combinators. +"""Flow DSL: the Python authoring layer for Flows. -These decorators wrap user methods into the typed wrappers defined in -``flow_wrappers`` and record their trigger conditions. The structural model -those conditions feed is built in ``flow_definition``; execution happens in -``runtime``. +Provides the ``@start`` / ``@listen`` / ``@router`` decorators and the +``or_`` / ``and_`` condition combinators used to write Flow classes in +Python. The DSL is one way to produce a Flow Structure: this module +extracts a :class:`~crewai.flow.flow_definition.FlowDefinition` from a +Python Flow class. Execution is handled by ``runtime``. """ from __future__ import annotations -from collections.abc import Callable -from typing import Any, ParamSpec, TypeVar +from collections.abc import Callable, Sequence +from enum import Enum +import inspect +import json +import logging +from types import UnionType +from typing import ( + Any, + Literal, + ParamSpec, + TypeVar, + Union, + get_args, + get_origin, + get_type_hints, +) + +from pydantic import BaseModel +from typing_extensions import TypeIs from crewai.flow.constants import AND_CONDITION, OR_CONDITION from crewai.flow.flow_definition import ( - _extract_all_methods, - is_flow_condition_dict, - is_flow_method_callable, - is_flow_method_name, + FlowConfigDefinition, + FlowDefinition, + FlowDefinitionCondition, + FlowDefinitionDiagnostic, + FlowHumanFeedbackDefinition, + FlowMethodDefinition, + FlowPersistenceDefinition, + FlowStateDefinition, ) from crewai.flow.flow_wrappers import ( FlowCondition, FlowConditions, + FlowMethod, ListenMethod, RouterMethod, + SimpleFlowCondition, StartMethod, ) +from crewai.flow.types import FlowMethodName P = ParamSpec("P") R = TypeVar("R") +logger = logging.getLogger(__name__) + +__all__ = ["and_", "listen", "or_", "router", "start"] + +_FLOW_METHOD_DEFINITION_ATTR = "__flow_method_definition__" + + +def is_simple_flow_condition(obj: Any) -> TypeIs[SimpleFlowCondition]: + """Check if the object is a ``(condition_type, methods)`` tuple.""" + return ( + isinstance(obj, tuple) + and len(obj) == 2 + and isinstance(obj[0], str) + and isinstance(obj[1], list) + ) + + +def is_flow_method(obj: Any) -> TypeIs[FlowMethod[Any, Any]]: + """Check if the object carries Flow method wrapper metadata.""" + return ( + hasattr(obj, "__is_flow_method__") + or hasattr(obj, "__is_start_method__") + or hasattr(obj, "__trigger_methods__") + or hasattr(obj, "__is_router__") + or hasattr(obj, _FLOW_METHOD_DEFINITION_ATTR) + ) + + +def _should_include_flow_method(flow_class: type, method: Any) -> bool: + if getattr(method, "__conversational_only__", False): + return bool(getattr(flow_class, "conversational", False)) + return True + + +def is_flow_condition_dict(obj: Any) -> TypeIs[FlowCondition]: + """Check if the object matches the FlowCondition structure.""" + if not isinstance(obj, dict): + return False + + type_value = obj.get("type") + if type_value not in ("AND", "OR"): + return False + + if "conditions" in obj: + conditions = obj["conditions"] + if not isinstance(conditions, list): + return False + for cond in conditions: + if not ( + isinstance(cond, str) + or (isinstance(cond, dict) and is_flow_condition_dict(cond)) + ): + return False + + if "methods" in obj: + methods = obj["methods"] + if not (isinstance(methods, list) and all(isinstance(m, str) for m in methods)): + return False + + allowed_keys = {"type", "conditions", "methods"} + if not set(obj).issubset(allowed_keys): + return False + + return True + + +def _method_reference_name(value: Any) -> FlowMethodName | None: + name = getattr(value, "__name__", None) + if callable(value) and isinstance(name, str): + return FlowMethodName(name) + return None + + +def _flow_method_names(values: Sequence[Any]) -> list[FlowMethodName]: + return [FlowMethodName(str(value)) for value in values] + + +def _extract_all_methods_recursive( + condition: str | FlowCondition | dict[str, Any] | list[Any], + flow: Any | None = None, +) -> list[FlowMethodName]: + if isinstance(condition, str): + if flow is not None: + if condition in flow._methods: + return [FlowMethodName(condition)] + return [] + return [FlowMethodName(condition)] + if is_flow_condition_dict(condition): + normalized = _normalize_condition(condition) + methods = [] + for sub_cond in normalized.get("conditions", []): + methods.extend(_extract_all_methods_recursive(sub_cond, flow)) + return methods + if isinstance(condition, list): + methods = [] + for item in condition: + methods.extend(_extract_all_methods_recursive(item, flow)) + return methods + return [] + + +def _normalize_condition( + condition: FlowConditions | FlowCondition | str, +) -> FlowCondition: + if isinstance(condition, str): + return {"type": OR_CONDITION, "conditions": [FlowMethodName(condition)]} + if is_flow_condition_dict(condition): + if "conditions" in condition: + return condition + if "methods" in condition: + return {"type": condition["type"], "conditions": condition["methods"]} + return condition + if isinstance(condition, list) and all( + isinstance(item, str) or is_flow_condition_dict(item) for item in condition + ): + return {"type": OR_CONDITION, "conditions": condition} + + raise ValueError(f"Cannot normalize condition: {condition}") + + +def _extract_all_methods( + condition: str | FlowCondition | dict[str, Any] | list[Any], +) -> list[FlowMethodName]: + if isinstance(condition, str): + return [FlowMethodName(condition)] + if is_flow_condition_dict(condition): + normalized = _normalize_condition(condition) + cond_type = normalized.get("type", OR_CONDITION) + + if cond_type == AND_CONDITION: + return [ + FlowMethodName(sub_cond) + for sub_cond in normalized.get("conditions", []) + if isinstance(sub_cond, str) + ] + return [] + if isinstance(condition, list): + methods = [] + for item in condition: + methods.extend(_extract_all_methods(item)) + return methods + return [] + + +def _unwrap_function(function: Any) -> Any: + if hasattr(function, "__func__"): + function = function.__func__ + + if hasattr(function, "__wrapped__"): + wrapped = function.__wrapped__ + if hasattr(wrapped, "unwrap"): + return wrapped.unwrap() + return wrapped + + if hasattr(function, "unwrap"): + return function.unwrap() + + return function + + +def _string_values_from_annotation(annotation: Any) -> list[str]: + if annotation is inspect.Signature.empty or isinstance(annotation, str): + return [] + if isinstance(annotation, type) and issubclass(annotation, Enum): + return [member.value for member in annotation if isinstance(member.value, str)] + + origin = get_origin(annotation) + if origin is None: + return [] + + args = get_args(annotation) + if origin is Literal or getattr(origin, "__name__", "") == "Literal": + return [arg for arg in args if isinstance(arg, str)] + + if not ( + origin is Union + or origin is UnionType + or getattr(origin, "__name__", "") == "Annotated" + ): + return [] + + values: list[str] = [] + for arg in args: + values.extend(_string_values_from_annotation(arg)) + return values + + +def _return_annotation(function: Any) -> Any: + unwrapped = _unwrap_function(function) + + try: + return get_type_hints(unwrapped, include_extras=True).get( + "return", inspect.Signature.empty + ) + except (NameError, TypeError, ValueError): + try: + return inspect.signature(unwrapped).return_annotation + except (TypeError, ValueError): + return inspect.Signature.empty + + +def _get_router_return_events(function: Any) -> list[str] | None: + values = _string_values_from_annotation(_return_annotation(function)) + return list(dict.fromkeys(values)) if values else None + + +def _normalize_router_emit(value: Sequence[Any] | str) -> list[str]: + if isinstance(value, str): + return [str(value)] + return list(dict.fromkeys(str(item) for item in value)) + + +def _set_trigger_metadata( + wrapper: StartMethod[P, R] | ListenMethod[P, R] | RouterMethod[P, R], + condition: str | FlowCondition | Callable[..., Any], +) -> None: + if isinstance(condition, str): + wrapper.__trigger_methods__ = [FlowMethodName(condition)] + wrapper.__condition_type__ = OR_CONDITION + return + + if is_flow_condition_dict(condition): + if "conditions" in condition: + wrapper.__trigger_condition__ = condition + wrapper.__trigger_methods__ = _extract_all_methods(condition) + wrapper.__condition_type__ = condition["type"] + return + if "methods" in condition: + wrapper.__trigger_methods__ = _flow_method_names(condition["methods"]) + wrapper.__condition_type__ = condition["type"] + return + raise ValueError("Condition dict must contain 'conditions' or 'methods'") + + method_name = _method_reference_name(condition) + if method_name is not None: + wrapper.__trigger_methods__ = [method_name] + wrapper.__condition_type__ = OR_CONDITION + return + + raise ValueError( + "Condition must be a method, string, or a result of or_() or and_()" + ) + + +def _condition_trigger( + condition: str | FlowCondition | Callable[..., Any], +) -> FlowMethodName | FlowCondition: + if isinstance(condition, str): + return FlowMethodName(condition) + if is_flow_condition_dict(condition): + return condition + method_name = _method_reference_name(condition) + if method_name is not None: + return method_name + raise ValueError("Invalid condition") + + +def _condition_triggers( + conditions: Sequence[str | FlowCondition | Callable[..., Any]], + error_message: str, +) -> FlowConditions: + try: + return [_condition_trigger(condition) for condition in conditions] + except ValueError as exc: + raise ValueError(error_message) from exc + + +def _set_flow_method_definition( + wrapper: StartMethod[P, R] | ListenMethod[P, R] | RouterMethod[P, R], + definition: FlowMethodDefinition, +) -> None: + setattr(wrapper, _FLOW_METHOD_DEFINITION_ATTR, definition) + + +def _get_flow_method_definition(method: Any) -> FlowMethodDefinition | None: + definition = getattr(method, _FLOW_METHOD_DEFINITION_ATTR, None) + if isinstance(definition, FlowMethodDefinition): + return definition + if definition is not None: + return FlowMethodDefinition.model_validate(definition) + return None + def start( condition: str | FlowCondition | Callable[..., Any] | None = None, @@ -69,39 +375,18 @@ def start( """ def decorator(func: Callable[P, R]) -> StartMethod[P, R]: - """Decorator that wraps a function as a start method. - - Args: - func: The function to wrap as a start method. - - Returns: - A StartMethod wrapper around the function. - """ wrapper = StartMethod(func) if condition is not None: - if is_flow_method_name(condition): - wrapper.__trigger_methods__ = [condition] - wrapper.__condition_type__ = OR_CONDITION - elif is_flow_condition_dict(condition): - if "conditions" in condition: - wrapper.__trigger_condition__ = condition - wrapper.__trigger_methods__ = _extract_all_methods(condition) - wrapper.__condition_type__ = condition["type"] - elif "methods" in condition: - wrapper.__trigger_methods__ = condition["methods"] - wrapper.__condition_type__ = condition["type"] - else: - raise ValueError( - "Condition dict must contain 'conditions' or 'methods'" - ) - elif is_flow_method_callable(condition): - wrapper.__trigger_methods__ = [condition.__name__] - wrapper.__condition_type__ = OR_CONDITION - else: - raise ValueError( - "Condition must be a method, string, or a result of or_() or and_()" - ) + _set_flow_method_definition( + wrapper, + FlowMethodDefinition( + start=_definition_condition_from_runtime(condition) + ), + ) + _set_trigger_metadata(wrapper, condition) + else: + _set_flow_method_definition(wrapper, FlowMethodDefinition(start=True)) return wrapper return decorator @@ -136,38 +421,13 @@ def listen( """ def decorator(func: Callable[P, R]) -> ListenMethod[P, R]: - """Decorator that wraps a function as a listener method. - - Args: - func: The function to wrap as a listener method. - - Returns: - A ListenMethod wrapper around the function. - """ wrapper = ListenMethod(func) - if is_flow_method_name(condition): - wrapper.__trigger_methods__ = [condition] - wrapper.__condition_type__ = OR_CONDITION - elif is_flow_condition_dict(condition): - if "conditions" in condition: - wrapper.__trigger_condition__ = condition - wrapper.__trigger_methods__ = _extract_all_methods(condition) - wrapper.__condition_type__ = condition["type"] - elif "methods" in condition: - wrapper.__trigger_methods__ = condition["methods"] - wrapper.__condition_type__ = condition["type"] - else: - raise ValueError( - "Condition dict must contain 'conditions' or 'methods'" - ) - elif is_flow_method_callable(condition): - wrapper.__trigger_methods__ = [condition.__name__] - wrapper.__condition_type__ = OR_CONDITION - else: - raise ValueError( - "Condition must be a method, string, or a result of or_() or and_()" - ) + _set_flow_method_definition( + wrapper, + FlowMethodDefinition(listen=_definition_condition_from_runtime(condition)), + ) + _set_trigger_metadata(wrapper, condition) return wrapper return decorator @@ -175,19 +435,23 @@ def listen( def router( condition: str | FlowCondition | Callable[..., Any], + *, + emit: Sequence[str] | str | None = None, ) -> Callable[[Callable[P, R]], RouterMethod[P, R]]: """Creates a routing method that directs flow execution based on conditions. This decorator marks a method as a router, which can dynamically determine the next steps in the flow based on its return value. Routers are triggered - by specified conditions and can return constants that determine which path - the flow should take. + by specified conditions and can return constants that emit downstream events. Args: condition: Specifies when the router should execute. Can be: - str: Name of a method that triggers this router - FlowCondition: Result from or_() or and_(), including nested conditions - Callable[..., Any]: A method reference that triggers this router + emit: Optional explicit router output events for static FlowDefinition + and visualization. If omitted, Literal/Enum return annotations are + used when available. Returns: A decorator function that wraps the method as a router and preserves its signature. @@ -207,41 +471,35 @@ def router( ... if all([self.state.valid, self.state.processed]): ... return "CONTINUE" ... return "STOP" + + >>> @router("check_status", emit=["SUCCESS", "FAILURE"]) + >>> def explicit_routing(self): + ... return "SUCCESS" """ def decorator(func: Callable[P, R]) -> RouterMethod[P, R]: - """Decorator that wraps a function as a router method. - - Args: - func: The function to wrap as a router method. - - Returns: - A RouterMethod wrapper around the function. - """ wrapper = RouterMethod(func) - if is_flow_method_name(condition): - wrapper.__trigger_methods__ = [condition] - wrapper.__condition_type__ = OR_CONDITION - elif is_flow_condition_dict(condition): - if "conditions" in condition: - wrapper.__trigger_condition__ = condition - wrapper.__trigger_methods__ = _extract_all_methods(condition) - wrapper.__condition_type__ = condition["type"] - elif "methods" in condition: - wrapper.__trigger_methods__ = condition["methods"] - wrapper.__condition_type__ = condition["type"] - else: - raise ValueError( - "Condition dict must contain 'conditions' or 'methods'" - ) - elif is_flow_method_callable(condition): - wrapper.__trigger_methods__ = [condition.__name__] - wrapper.__condition_type__ = OR_CONDITION + if emit is not None: + router_events = _normalize_router_emit(emit) else: - raise ValueError( - "Condition must be a method, string, or a result of or_() or and_()" - ) + router_events = _get_router_return_events(func) or [] + + _set_flow_method_definition( + wrapper, + FlowMethodDefinition( + listen=_definition_condition_from_runtime(condition), + router=True, + emit=router_events or None, + ), + ) + + _set_trigger_metadata(wrapper, condition) + + if emit is not None: + wrapper.__router_emit__ = router_events + elif router_events: + wrapper.__router_emit__ = router_events return wrapper return decorator @@ -272,15 +530,8 @@ def or_(*conditions: str | FlowCondition | Callable[..., Any]) -> FlowCondition: >>> def handle_nested(self): ... pass """ - processed_conditions: FlowConditions = [] - for condition in conditions: - if is_flow_condition_dict(condition) or is_flow_method_name(condition): - processed_conditions.append(condition) - elif is_flow_method_callable(condition): - processed_conditions.append(condition.__name__) - else: - raise ValueError("Invalid condition in or_()") - return {"type": OR_CONDITION, "conditions": processed_conditions} + processed_triggers = _condition_triggers(conditions, "Invalid condition in or_()") + return {"type": OR_CONDITION, "conditions": processed_triggers} def and_(*conditions: str | FlowCondition | Callable[..., Any]) -> FlowCondition: @@ -309,12 +560,474 @@ def and_(*conditions: str | FlowCondition | Callable[..., Any]) -> FlowCondition >>> def handle_nested(self): ... pass """ - processed_conditions: FlowConditions = [] - for condition in conditions: - if is_flow_condition_dict(condition) or is_flow_method_name(condition): - processed_conditions.append(condition) - elif is_flow_method_callable(condition): - processed_conditions.append(condition.__name__) - else: - raise ValueError("Invalid condition in and_()") - return {"type": AND_CONDITION, "conditions": processed_conditions} + processed_triggers = _condition_triggers(conditions, "Invalid condition in and_()") + return {"type": AND_CONDITION, "conditions": processed_triggers} + + +def _object_ref(value: Any) -> str: + target = value if isinstance(value, type) else type(value) + module = getattr(target, "__module__", "") + qualname = getattr(target, "__qualname__", getattr(target, "__name__", "")) + return f"{module}:{qualname}" if module and qualname else repr(value) + + +def _is_json_serializable(value: Any) -> bool: + try: + json.dumps(value) + except (TypeError, ValueError): + return False + return True + + +def _serialize_static_value( + value: Any, + diagnostics: list[FlowDefinitionDiagnostic], + path: str, +) -> Any: + if value is None or _is_json_serializable(value): + return value + + to_config = getattr(value, "to_config_dict", None) + if callable(to_config): + try: + config = to_config() + if _is_json_serializable(config): + return config + except Exception: + logger.debug( + "Failed to serialize %s via to_config_dict().", + path, + exc_info=True, + ) + + if isinstance(value, BaseModel): + try: + data = value.model_dump(mode="json") + if _is_json_serializable(data): + return data + except Exception: + logger.debug( + "Failed to serialize %s via Pydantic model_dump().", + path, + exc_info=True, + ) + + ref = _object_ref(value) + diagnostics.append( + FlowDefinitionDiagnostic( + code="non_serializable_value", + path=path, + message=f"value is not fully serializable; preserved import reference {ref}", + ) + ) + return {"ref": ref} + + +def _state_ref(value: Any) -> str | None: + if value is None: + return None + target = value if isinstance(value, type) else type(value) + module = getattr(target, "__module__", None) + qualname = getattr(target, "__qualname__", None) + if module and qualname: + return f"{module}:{qualname}" + return None + + +def _build_state_definition( + flow_class: type, + diagnostics: list[FlowDefinitionDiagnostic], +) -> FlowStateDefinition | None: + from pydantic import BaseModel as PydanticBaseModel + + state_value = getattr(flow_class, "_initial_state_t", None) + initial_state = getattr(flow_class, "initial_state", None) + if initial_state is not None: + state_value = initial_state + + if state_value is None: + return None + if state_value is dict or isinstance(state_value, dict): + default = None + if isinstance(state_value, dict): + default = _serialize_static_value(state_value, diagnostics, "state.default") + return FlowStateDefinition(type="dict", default=default) + if isinstance(state_value, type) and issubclass(state_value, PydanticBaseModel): + return FlowStateDefinition(type="pydantic", ref=_state_ref(state_value)) + if isinstance(state_value, PydanticBaseModel): + return FlowStateDefinition( + type="pydantic", + ref=_state_ref(state_value), + default=_serialize_static_value(state_value, diagnostics, "state.default"), + ) + diagnostics.append( + FlowDefinitionDiagnostic( + code="unknown_state_type", + path="state", + message=f"could not serialize state type {_object_ref(state_value)}", + ) + ) + return FlowStateDefinition(type="unknown", ref=_state_ref(state_value)) + + +def _build_config_definition( + flow_class: type, + diagnostics: list[FlowDefinitionDiagnostic], +) -> FlowConfigDefinition: + config_field_names = set(FlowConfigDefinition.model_fields) + field_defaults = { + name: field.default + for name, field in getattr(flow_class, "model_fields", {}).items() + if name in config_field_names + } + values: dict[str, Any] = {} + for field_name, default in field_defaults.items(): + value = getattr(flow_class, field_name, default) + values[field_name] = _serialize_static_value( + value, diagnostics, f"config.{field_name}" + ) + return FlowConfigDefinition(**values) + + +def _definition_condition_from_runtime(condition: Any) -> FlowDefinitionCondition: + if isinstance(condition, str): + return str(condition) + method_name = _method_reference_name(condition) + if method_name is not None: + return str(method_name) + if is_flow_condition_dict(condition): + normalized = _normalize_condition(condition) + key = "and" if normalized.get("type") == AND_CONDITION else "or" + return { + key: [ + _definition_condition_from_runtime(sub_condition) + for sub_condition in normalized.get("conditions", []) + ] + } + if isinstance(condition, list): + return {"or": [_definition_condition_from_runtime(item) for item in condition]} + return str(condition) + + +def _condition_from_method_metadata(method: Any) -> FlowDefinitionCondition | None: + trigger_condition = getattr(method, "__trigger_condition__", None) + if trigger_condition is not None: + return _definition_condition_from_runtime(trigger_condition) + + trigger_methods = getattr(method, "__trigger_methods__", None) + if trigger_methods is None: + return None + condition_type = getattr(method, "__condition_type__", OR_CONDITION) + method_names = [str(method_name) for method_name in trigger_methods] + if condition_type == AND_CONDITION: + return {"and": method_names} + if len(method_names) == 1: + return method_names[0] + return {"or": method_names} + + +def _flow_method_definition_from_legacy_metadata(method: Any) -> FlowMethodDefinition: + is_start = bool(getattr(method, "__is_start_method__", False)) + is_router = bool(getattr(method, "__is_router__", False)) + condition = _condition_from_method_metadata(method) + + if not is_start: + start_value: bool | FlowDefinitionCondition | None = None + elif condition is not None: + start_value = condition + else: + start_value = True + + definition = FlowMethodDefinition( + start=start_value, + listen=condition if not is_start else None, + router=is_router, + ) + + router_emit = getattr(method, "__router_emit__", None) + if router_emit: + definition.emit = [str(value) for value in router_emit] + return definition + + +def _definition_trigger_condition( + method_definition: FlowMethodDefinition, +) -> FlowDefinitionCondition | None: + if method_definition.listen is not None: + return method_definition.listen + if isinstance(method_definition.start, (str, dict)): + return method_definition.start + return None + + +def _runtime_condition_from_definition( + condition: FlowDefinitionCondition, +) -> FlowMethodName | FlowCondition: + if isinstance(condition, str): + return FlowMethodName(condition) + if is_flow_condition_dict(condition): + return condition + + if "and" in condition: + return { + "type": AND_CONDITION, + "conditions": [ + _runtime_condition_from_definition(item) + for item in condition.get("and", []) + ], + } + return { + "type": OR_CONDITION, + "conditions": [ + _runtime_condition_from_definition(item) for item in condition.get("or", []) + ], + } + + +def _runtime_listener_condition_from_definition( + condition: FlowDefinitionCondition, +) -> SimpleFlowCondition | FlowCondition: + runtime_condition = _runtime_condition_from_definition(condition) + if isinstance(runtime_condition, str): + return (OR_CONDITION, [FlowMethodName(str(runtime_condition))]) + return runtime_condition + + +def _build_human_feedback_definition( + method: Any, + diagnostics: list[FlowDefinitionDiagnostic], + path: str, +) -> FlowHumanFeedbackDefinition | None: + config = getattr(method, "__human_feedback_config__", None) + if config is None: + return None + emit = getattr(config, "emit", None) + return FlowHumanFeedbackDefinition( + message=str(config.message), + emit=[str(value) for value in emit] if emit is not None else None, + llm=_serialize_static_value( + getattr(config, "llm", None), diagnostics, f"{path}.llm" + ), + default_outcome=getattr(config, "default_outcome", None), + metadata=_serialize_static_value( + getattr(config, "metadata", None), diagnostics, f"{path}.metadata" + ), + provider=_serialize_static_value( + getattr(config, "provider", None), diagnostics, f"{path}.provider" + ), + learn=bool(getattr(config, "learn", False)), + learn_source=str(getattr(config, "learn_source", "hitl")), + learn_strict=bool(getattr(config, "learn_strict", False)), + ) + + +def _build_persistence_definition( + value: Any, + diagnostics: list[FlowDefinitionDiagnostic], + path: str, +) -> FlowPersistenceDefinition | None: + config = getattr(value, "__flow_persistence_config__", None) + if config is None: + return None + persistence = getattr(config, "persistence", None) + verbose = bool(getattr(config, "verbose", False)) + return FlowPersistenceDefinition( + enabled=True, + verbose=verbose, + persistence=_serialize_static_value( + persistence, diagnostics, f"{path}.persistence" + ), + ) + + +def _build_method_definition( + method: Any, + diagnostics: list[FlowDefinitionDiagnostic], + path: str, +) -> FlowMethodDefinition: + fragment = _get_flow_method_definition(method) + if fragment is None: + method_definition = _flow_method_definition_from_legacy_metadata(method) + else: + method_definition = fragment.model_copy(deep=True) + + if bool(getattr(method, "__is_router__", False)): + method_definition.router = True + + human_feedback = _build_human_feedback_definition( + method, diagnostics, f"{path}.human_feedback" + ) + if human_feedback is not None: + method_definition.human_feedback = human_feedback + if human_feedback.emit: + method_definition.router = True + method_definition.emit = None + + method_definition.persist = _build_persistence_definition( + method, diagnostics, f"{path}.persist" + ) + + router_emit = getattr(method, "__router_emit__", None) + if router_emit and not (human_feedback and human_feedback.emit): + if not method_definition.emit: + method_definition.emit = [str(value) for value in router_emit] + + return method_definition + + +def _iter_flow_methods(flow_class: type) -> dict[str, Any]: + methods: dict[str, Any] = {} + for attr_name in dir(flow_class): + if attr_name.startswith("_"): + continue + try: + attr_value = getattr(flow_class, attr_name) + except AttributeError: + continue + if is_flow_method(attr_value) and _should_include_flow_method( + flow_class, attr_value + ): + methods[attr_name] = attr_value + + # A wrapped method whose name collides with a base Flow model field + # (e.g. ``checkpoint``) is absorbed by Pydantic as a field; the underlying + # function is preserved as the field default. Recover those so the + # definition still reflects every method once the class is built. + for field_name, field in getattr(flow_class, "model_fields", {}).items(): + if field_name in methods or field_name.startswith("_"): + continue + default = getattr(field, "default", None) + if is_flow_method(default) and _should_include_flow_method(flow_class, default): + methods[field_name] = default + return methods + + +def _build_flow_definition_from_class( + flow_class: type, + namespace: dict[str, Any] | None = None, +) -> FlowDefinition: + diagnostics: list[FlowDefinitionDiagnostic] = [] + methods: dict[str, FlowMethodDefinition] = {} + flow_methods = _iter_flow_methods(flow_class) + if namespace is not None: + for attr_name, attr_value in namespace.items(): + if is_flow_method(attr_value) and _should_include_flow_method( + flow_class, attr_value + ): + flow_methods[attr_name] = attr_value + + for method_name, method in flow_methods.items(): + methods[method_name] = _build_method_definition( + method, diagnostics, f"methods.{method_name}" + ) + + description = None + docstring = flow_class.__doc__ + if docstring: + description = docstring.strip() + + definition = FlowDefinition( + name=getattr(flow_class, "__name__", "Flow"), + description=description, + state=_build_state_definition(flow_class, diagnostics), + config=_build_config_definition(flow_class, diagnostics), + persist=_build_persistence_definition(flow_class, diagnostics, "persist"), + methods=methods, + diagnostics=diagnostics, + ) + definition.diagnostics.extend(definition.validate_contract()) + definition.log_diagnostics() + return definition + + +def build_flow_definition( + flow_class: type, + namespace: dict[str, Any] | None = None, +) -> FlowDefinition: + """Build a FlowDefinition from a Python Flow class.""" + return _build_flow_definition_from_class(flow_class, namespace) + + +def extract_flow_definition( + namespace: dict[str, Any], +) -> tuple[list[str], dict[str, Any], set[str], dict[str, Any]]: + """Extract the structural flow registries from a Python class namespace.""" + start_methods = [] + listeners = {} + router_emit = {} + routers = set() + + for attr_name, attr_value in namespace.items(): + if is_flow_method(attr_value): + method_definition = _get_flow_method_definition(attr_value) + if method_definition is not None: + if method_definition.is_start: + start_methods.append(attr_name) + + condition = _definition_trigger_condition(method_definition) + if condition is not None: + listeners[attr_name] = _runtime_listener_condition_from_definition( + condition + ) + + is_router = method_definition.router or bool( + getattr(attr_value, "__is_router__", False) + ) + if is_router: + routers.add(attr_name) + if method_definition.emit: + router_emit[attr_name] = [ + str(value) for value in method_definition.emit + ] + elif ( + hasattr(attr_value, "__router_emit__") + and attr_value.__router_emit__ + ): + router_emit[attr_name] = attr_value.__router_emit__ + else: + router_emit[attr_name] = [] + continue + + if hasattr(attr_value, "__is_start_method__"): + start_methods.append(attr_name) + + if ( + hasattr(attr_value, "__trigger_methods__") + and attr_value.__trigger_methods__ is not None + ): + methods = attr_value.__trigger_methods__ + condition_type = getattr(attr_value, "__condition_type__", OR_CONDITION) + + if ( + hasattr(attr_value, "__trigger_condition__") + and attr_value.__trigger_condition__ is not None + ): + listeners[attr_name] = attr_value.__trigger_condition__ + else: + listeners[attr_name] = (condition_type, methods) + + if hasattr(attr_value, "__is_router__") and attr_value.__is_router__: + routers.add(attr_name) + if ( + hasattr(attr_value, "__router_emit__") + and attr_value.__router_emit__ + ): + router_emit[attr_name] = attr_value.__router_emit__ + else: + router_emit[attr_name] = [] + + if ( + hasattr(attr_value, "__is_start_method__") + and hasattr(attr_value, "__is_router__") + and attr_value.__is_router__ + ): + routers.add(attr_name) + if ( + hasattr(attr_value, "__router_emit__") + and attr_value.__router_emit__ + ): + router_emit[attr_name] = attr_value.__router_emit__ + else: + router_emit[attr_name] = [] + + return start_methods, listeners, routers, router_emit diff --git a/lib/crewai/src/crewai/flow/flow.py b/lib/crewai/src/crewai/flow/flow.py index 3cac04521..353f0ba9c 100644 --- a/lib/crewai/src/crewai/flow/flow.py +++ b/lib/crewai/src/crewai/flow/flow.py @@ -3,8 +3,8 @@ The implementation now lives in three modules, split by concern: - ``crewai.flow.dsl`` -- authoring decorators (``@start`` / ``@listen`` / - ``@router``, ``or_`` / ``and_``) -- ``crewai.flow.flow_definition`` -- the structural model extracted from the DSL + ``@router``, ``or_`` / ``and_``) and Python Flow class projection +- ``crewai.flow.flow_definition`` -- the serializable Flow Definition contract - ``crewai.flow.runtime`` -- the Flow execution engine and state Prefer importing from those modules in new code; this module preserves the diff --git a/lib/crewai/src/crewai/flow/flow_definition.py b/lib/crewai/src/crewai/flow/flow_definition.py index cc0b2d9ff..1c05a51a9 100644 --- a/lib/crewai/src/crewai/flow/flow_definition.py +++ b/lib/crewai/src/crewai/flow/flow_definition.py @@ -1,1036 +1,280 @@ -""" -Flow definition: the structural model derived from the DSL. +"""Flow Structure: the serializable, language-agnostic Flow contract. -Condition predicates, condition decoding, AST-based router-path extraction, -graph/level analysis, and ``extract_flow_definition`` (the structural -registries the runtime metaclass attaches to a Flow class). Previously these -lived in ``crewai.flow.utils``, which now re-exports from here. - -This module provides core functionality for analyzing and manipulating flow structures, -including node level calculation, ancestor tracking, and return value analysis. -Functions in this module are primarily used by the visualization system to create -accurate and informative flow diagrams. - -Example -------- ->>> flow = Flow() ->>> node_levels = calculate_node_levels(flow) ->>> ancestors = build_ancestor_dict(flow) +Defines :class:`FlowDefinition` and its sub-models — a static, textual +(JSON/YAML) representation of a Flow: its methods, trigger conditions, +state, and configuration. It is independent of the Python authoring +layer that may have produced it and of the engine that runs it (see +``runtime``). """ from __future__ import annotations -import ast -from collections import defaultdict, deque -from enum import Enum -import inspect -import textwrap -from typing import TYPE_CHECKING, Any +import json +import logging +from typing import Any, Literal as TypingLiteral -from crewai_core.printer import PRINTER -from typing_extensions import TypeIs - -from crewai.flow.constants import AND_CONDITION, OR_CONDITION -from crewai.flow.flow_wrappers import ( - FlowCondition, - FlowConditions, - FlowMethod, - SimpleFlowCondition, -) -from crewai.flow.types import FlowMethodCallable, FlowMethodName +from pydantic import BaseModel, ConfigDict, Field +import yaml -if TYPE_CHECKING: - from crewai.flow.flow import Flow +logger = logging.getLogger(__name__) + +FlowDefinitionCondition = str | dict[str, Any] + +__all__ = [ + "FlowConfigDefinition", + "FlowDefinition", + "FlowDefinitionCondition", + "FlowDefinitionDiagnostic", + "FlowHumanFeedbackDefinition", + "FlowMethodDefinition", + "FlowPersistenceDefinition", + "FlowStateDefinition", +] -def _extract_string_literals_from_type_annotation( - node: ast.expr, - function_globals: dict[str, Any] | None = None, -) -> list[str]: - """Extract string literals from a type annotation AST node. +class FlowDefinitionDiagnostic(BaseModel): + """A non-fatal Flow Definition build or validation diagnostic.""" - Handles: - - Literal["a", "b", "c"] - - "a" | "b" | "c" (union of string literals) - - Just "a" (single string constant annotation) - - Enum types with string values (e.g., class MyEnum(str, Enum)) - - Args: - node: The AST node representing a type annotation. - function_globals: The globals dict from the function, used to resolve Enum types. - - Returns: - List of string literals found in the annotation. - """ - - strings: list[str] = [] - - if isinstance(node, ast.Constant) and isinstance(node.value, str): - strings.append(node.value) - - elif isinstance(node, ast.Name) and function_globals: - enum_class = function_globals.get(node.id) - if ( - enum_class is not None - and isinstance(enum_class, type) - and issubclass(enum_class, Enum) - ): - strings.extend( - member.value for member in enum_class if isinstance(member.value, str) - ) - - elif isinstance(node, ast.Attribute) and function_globals: - try: - if isinstance(node.value, ast.Name): - module = function_globals.get(node.value.id) - if module is not None: - enum_class = getattr(module, node.attr, None) - if ( - enum_class is not None - and isinstance(enum_class, type) - and issubclass(enum_class, Enum) - ): - strings.extend( - member.value - for member in enum_class - if isinstance(member.value, str) - ) - except (AttributeError, TypeError): - pass - - elif isinstance(node, ast.Subscript): - is_literal = False - if isinstance(node.value, ast.Name) and node.value.id == "Literal": - is_literal = True - elif isinstance(node.value, ast.Attribute) and node.value.attr == "Literal": - is_literal = True - - if is_literal: - if isinstance(node.slice, ast.Tuple): - strings.extend( - elt.value - for elt in node.slice.elts - if isinstance(elt, ast.Constant) and isinstance(elt.value, str) - ) - elif isinstance(node.slice, ast.Constant) and isinstance( - node.slice.value, str - ): - strings.append(node.slice.value) - - elif isinstance(node, ast.BinOp) and isinstance(node.op, ast.BitOr): - strings.extend( - _extract_string_literals_from_type_annotation(node.left, function_globals) - ) - strings.extend( - _extract_string_literals_from_type_annotation(node.right, function_globals) - ) - - return strings + code: str + message: str + severity: TypingLiteral["warning", "error"] = "warning" + path: str | None = None -def _unwrap_function(function: Any) -> Any: - """Unwrap a function to get the original function with correct globals. +class FlowStateDefinition(BaseModel): + """Static description of a Flow state contract.""" - Flow methods are wrapped by decorators like @router, @listen, etc. - This function unwraps them to get the original function which has - the correct __globals__ for resolving type annotations like Enums. - - Args: - function: The potentially wrapped function. - - Returns: - The unwrapped original function. - """ - if hasattr(function, "__func__"): - function = function.__func__ - - if hasattr(function, "__wrapped__"): - wrapped = function.__wrapped__ - if hasattr(wrapped, "unwrap"): - return wrapped.unwrap() - return wrapped - - return function + type: TypingLiteral["dict", "pydantic", "unknown"] = "dict" + ref: str | None = None + default: Any = None -def get_possible_return_constants( - function: Any, verbose: bool = True -) -> list[str] | None: - """Extract possible string return values from a function using AST parsing. +class FlowConfigDefinition(BaseModel): + """Serializable Flow-level configuration.""" - This function analyzes the source code of a router method to identify - all possible string values it might return. It handles: - - Return type annotations: -> Literal["a", "b"] or -> "a" | "b" | "c" - - Enum type annotations: -> MyEnum (extracts string values from members) - - Direct string literals: return "value" - - Variable assignments: x = "value"; return x - - Dictionary lookups: d = {"k": "v"}; return d[key] - - Conditional returns: return "a" if cond else "b" - - State attributes: return self.state.attr (infers from class context) + tracing: bool | None = None + stream: bool = False + memory: Any = None + input_provider: Any = None + suppress_flow_events: bool = False + max_method_calls: int = 100 - Args: - function: The function to analyze. - Returns: - List of possible string return values, or None if analysis fails. - """ - unwrapped = _unwrap_function(function) +class FlowPersistenceDefinition(BaseModel): + """Static persistence configuration.""" - try: - source = inspect.getsource(function) - except OSError: - return None - except Exception as e: - if verbose: - PRINTER.print( - f"Error retrieving source code for function {function.__name__}: {e}", - color="red", - ) - return None + enabled: bool = False + verbose: bool = False + persistence: Any = None - try: - source = textwrap.dedent(source) - code_ast = ast.parse(source) - except IndentationError as e: - if verbose: - PRINTER.print( - f"IndentationError while parsing source code of {function.__name__}: {e}", - color="red", - ) - PRINTER.print(f"Source code:\n{source}", color="yellow") - return None - except SyntaxError as e: - if verbose: - PRINTER.print( - f"SyntaxError while parsing source code of {function.__name__}: {e}", - color="red", - ) - PRINTER.print(f"Source code:\n{source}", color="yellow") - return None - except Exception as e: - if verbose: - PRINTER.print( - f"Unexpected error while parsing source code of {function.__name__}: {e}", - color="red", - ) - PRINTER.print(f"Source code:\n{source}", color="yellow") - return None - return_values: set[str] = set() +class FlowHumanFeedbackDefinition(BaseModel): + """Static human feedback configuration.""" - function_globals = getattr(unwrapped, "__globals__", None) + message: str + emit: list[str] | None = None + llm: Any = "gpt-4o-mini" + default_outcome: str | None = None + metadata: dict[str, Any] | None = None + provider: Any = None + learn: bool = False + learn_source: str = "hitl" + learn_strict: bool = False - for node in ast.walk(code_ast): - if isinstance(node, ast.FunctionDef): - if node.returns: - annotation_values = _extract_string_literals_from_type_annotation( - node.returns, function_globals - ) - return_values.update(annotation_values) - break # Only process the first function definition - dict_definitions: dict[str, list[str]] = {} - variable_values: dict[str, list[str]] = {} - state_attribute_values: dict[str, list[str]] = {} - def extract_string_constants(node: ast.expr) -> list[str]: - """Recursively extract all string constants from an AST node.""" - strings: list[str] = [] - if isinstance(node, ast.Constant) and isinstance(node.value, str): - strings.append(node.value) - elif isinstance(node, ast.IfExp): - strings.extend(extract_string_constants(node.body)) - strings.extend(extract_string_constants(node.orelse)) - elif isinstance(node, ast.Call): - if ( - isinstance(node.func, ast.Attribute) - and node.func.attr == "get" - and len(node.args) >= 2 - ): - default_arg = node.args[1] - if isinstance(default_arg, ast.Constant) and isinstance( - default_arg.value, str - ): - strings.append(default_arg.value) - return strings +class FlowMethodDefinition(BaseModel): + """Static definition of one Flow method and its execution roles.""" - class VariableAssignmentVisitor(ast.NodeVisitor): - def visit_Assign(self, node: ast.Assign) -> None: - if isinstance(node.value, ast.Dict) and len(node.targets) == 1: - target = node.targets[0] - if isinstance(target, ast.Name): - var_name = target.id - dict_values = [ - val.value - for val in node.value.values - if isinstance(val, ast.Constant) and isinstance(val.value, str) - ] - if dict_values: - dict_definitions[var_name] = dict_values + start: bool | FlowDefinitionCondition | None = None + listen: FlowDefinitionCondition | None = None + router: bool = False + emit: list[str] | None = None + human_feedback: FlowHumanFeedbackDefinition | None = None + persist: FlowPersistenceDefinition | None = None - if len(node.targets) == 1: - target = node.targets[0] - var_name_alt: str | None = None - if isinstance(target, ast.Name): - var_name_alt = target.id - elif isinstance(target, ast.Attribute): - var_name_alt = f"{target.value.id if isinstance(target.value, ast.Name) else '_'}.{target.attr}" + @property + def is_start(self) -> bool: + """Whether this method is a start method. - if var_name_alt: - strings = extract_string_constants(node.value) - if strings: - variable_values[var_name_alt] = strings - - self.generic_visit(node) - - def get_attribute_chain(node: ast.expr) -> str | None: - """Extract the full attribute chain from an AST node. - - Examples: - self.state.run_type -> "self.state.run_type" - x.y.z -> "x.y.z" - simple_var -> "simple_var" + A loaded contract may carry ``start: false`` to mark a non-start + method explicitly, so falsy values (``False``/``None``/empty string) + are treated as "not a start method". """ - if isinstance(node, ast.Name): - return node.id - if isinstance(node, ast.Attribute): - base = get_attribute_chain(node.value) - if base: - return f"{base}.{node.attr}" - return None + return bool(self.start) - class ReturnVisitor(ast.NodeVisitor): - def visit_Return(self, node: ast.Return) -> None: - if ( - node.value - and isinstance(node.value, ast.Constant) - and isinstance(node.value.value, str) - ): - return_values.add(node.value.value) - elif node.value and isinstance(node.value, ast.Subscript): - if isinstance(node.value.value, ast.Name): - var_name_dict = node.value.value.id - if var_name_dict in dict_definitions: - for v in dict_definitions[var_name_dict]: - return_values.add(v) - elif node.value: - var_name_ret = get_attribute_chain(node.value) - if var_name_ret and var_name_ret in variable_values: - for v in variable_values[var_name_ret]: - return_values.add(v) - elif var_name_ret and var_name_ret in state_attribute_values: - for v in state_attribute_values[var_name_ret]: - return_values.add(v) +class FlowDefinition(BaseModel): + """Static, serializable definition of a Flow.""" - self.generic_visit(node) + model_config = ConfigDict(populate_by_name=True, arbitrary_types_allowed=True) - def visit_If(self, node: ast.If) -> None: - self.generic_visit(node) + schema_: str = Field(default="crewai.flow/v1", alias="schema") + name: str + description: str | None = None + state: FlowStateDefinition | None = None + config: FlowConfigDefinition = Field(default_factory=FlowConfigDefinition) + persist: FlowPersistenceDefinition | None = None + methods: dict[str, FlowMethodDefinition] = Field(default_factory=dict) + diagnostics: list[FlowDefinitionDiagnostic] = Field(default_factory=list) - try: - if hasattr(function, "__self__"): - class_obj = function.__self__.__class__ - elif hasattr(function, "__qualname__") and "." in function.__qualname__: - class_name = function.__qualname__.rsplit(".", 1)[0] - if hasattr(function, "__globals__"): - class_obj = function.__globals__.get(class_name) - else: - class_obj = None - else: - class_obj = None + def to_dict(self, *, exclude_none: bool = True) -> dict[str, Any]: + """Serialize the definition to a JSON/YAML-ready dictionary.""" + return self.model_dump(by_alias=True, exclude_none=exclude_none, mode="json") - if class_obj is not None: - try: - class_source = inspect.getsource(class_obj) - class_source = textwrap.dedent(class_source) - class_ast = ast.parse(class_source) + def to_json(self, *, indent: int | None = 2, exclude_none: bool = True) -> str: + """Serialize the definition to JSON.""" + data = self.to_dict(exclude_none=exclude_none) + return json.dumps(data, indent=indent) - class StateAttributeVisitor(ast.NodeVisitor): - def visit_Compare(self, node: ast.Compare) -> None: - """Find comparisons like: self.state.attr == "value" """ - left_attr = get_attribute_chain(node.left) + def to_yaml(self, *, exclude_none: bool = True) -> str: + """Serialize the definition to YAML.""" + return yaml.safe_dump( + self.to_dict(exclude_none=exclude_none), + sort_keys=False, + allow_unicode=True, + ) - if left_attr: - for comparator in node.comparators: - if isinstance(comparator, ast.Constant) and isinstance( - comparator.value, str - ): - if left_attr not in state_attribute_values: - state_attribute_values[left_attr] = [] - if ( - comparator.value - not in state_attribute_values[left_attr] - ): - state_attribute_values[left_attr].append( - comparator.value - ) + @classmethod + def from_dict(cls, data: dict[str, Any]) -> FlowDefinition: + """Load a definition from a dictionary and attach diagnostics.""" + serialized_diagnostics = _deserialize_diagnostics(data.get("diagnostics", [])) + definition = cls.model_validate(data) + definition.diagnostics = _merge_diagnostics( + serialized_diagnostics, definition.validate_contract() + ) + definition.log_diagnostics() + return definition - for comparator in node.comparators: - right_attr = get_attribute_chain(comparator) - if ( - right_attr - and isinstance(node.left, ast.Constant) - and isinstance(node.left.value, str) - ): - if right_attr not in state_attribute_values: - state_attribute_values[right_attr] = [] - if ( - node.left.value - not in state_attribute_values[right_attr] - ): - state_attribute_values[right_attr].append( - node.left.value - ) + @classmethod + def from_json(cls, data: str) -> FlowDefinition: + """Load a definition from JSON.""" + return cls.from_dict(json.loads(data)) - self.generic_visit(node) + @classmethod + def from_yaml(cls, data: str) -> FlowDefinition: + """Load a definition from YAML.""" + loaded = yaml.safe_load(data) or {} + if not isinstance(loaded, dict): + raise ValueError("Flow definition YAML must contain a mapping") + return cls.from_dict(loaded) - StateAttributeVisitor().visit(class_ast) - except Exception as e: - if verbose: - PRINTER.print( - f"Could not analyze class context for {function.__name__}: {e}", - color="yellow", + @classmethod + def json_schema(cls) -> dict[str, Any]: + """Return the JSON Schema for the Flow Definition contract.""" + return cls.model_json_schema(by_alias=True) + + def validate_contract(self) -> list[FlowDefinitionDiagnostic]: + """Validate the static contract without rejecting dynamic routing.""" + diagnostics: list[FlowDefinitionDiagnostic] = [] + for method_name, method in self.methods.items(): + path = f"methods.{method_name}" + if method.router and not method.is_start and method.listen is None: + diagnostics.append( + FlowDefinitionDiagnostic( + code="router_without_trigger", + severity="error", + path=path, + message="router: true requires either start or listen", ) - except Exception as e: - if verbose: - PRINTER.print( - f"Could not introspect class for {function.__name__}: {e}", - color="yellow", + ) + if method.emit and not method.router: + diagnostics.append( + FlowDefinitionDiagnostic( + code="emit_without_router", + path=f"{path}.emit", + message="emit is only used by routers to declare downstream events", + ) + ) + if method.human_feedback: + human_feedback_config = method.human_feedback + if human_feedback_config.emit and not human_feedback_config.llm: + diagnostics.append( + FlowDefinitionDiagnostic( + code="human_feedback_llm_required", + severity="error", + path=f"{path}.human_feedback.llm", + message="llm is required when human_feedback.emit is set", + ) + ) + if ( + human_feedback_config.default_outcome is not None + and not human_feedback_config.emit + ): + diagnostics.append( + FlowDefinitionDiagnostic( + code="human_feedback_default_requires_emit", + severity="error", + path=f"{path}.human_feedback.default_outcome", + message="default_outcome requires human_feedback.emit", + ) + ) + elif ( + human_feedback_config.default_outcome is not None + and human_feedback_config.emit + ): + if ( + human_feedback_config.default_outcome + not in human_feedback_config.emit + ): + diagnostics.append( + FlowDefinitionDiagnostic( + code="human_feedback_default_not_in_emit", + severity="error", + path=f"{path}.human_feedback.default_outcome", + message="default_outcome must be one of human_feedback.emit", + ) + ) + + return diagnostics + + def with_diagnostics(self) -> FlowDefinition: + """Attach fresh diagnostics and return this definition.""" + self.diagnostics = self.validate_contract() + self.log_diagnostics() + return self + + def log_diagnostics(self) -> None: + """Emit all attached diagnostics through the flow definition logger.""" + _log_flow_definition_diagnostics(self.name, self.diagnostics) + + +def _log_flow_definition_diagnostics( + definition_name: str, + diagnostics: list[FlowDefinitionDiagnostic], +) -> None: + for diagnostic in diagnostics: + level = logging.ERROR if diagnostic.severity == "error" else logging.WARNING + path = f" at {diagnostic.path}" if diagnostic.path else "" + logger.log( + level, + "Flow definition diagnostic for %s%s [%s]: %s", + definition_name, + path, + diagnostic.code, + diagnostic.message, + ) + + +def _deserialize_diagnostics(value: Any) -> list[FlowDefinitionDiagnostic]: + return [FlowDefinitionDiagnostic.model_validate(item) for item in value or []] + + +def _merge_diagnostics( + *diagnostic_groups: list[FlowDefinitionDiagnostic], +) -> list[FlowDefinitionDiagnostic]: + diagnostics: list[FlowDefinitionDiagnostic] = [] + seen: set[tuple[str, str, str | None, str]] = set() + for group in diagnostic_groups: + for diagnostic in group: + key = ( + diagnostic.code, + diagnostic.severity, + diagnostic.path, + diagnostic.message, ) - - VariableAssignmentVisitor().visit(code_ast) - ReturnVisitor().visit(code_ast) - - return list(return_values) if return_values else None - - -def calculate_node_levels(flow: Any) -> dict[str, int]: - """ - Calculate the hierarchical level of each node in the flow. - - Performs a breadth-first traversal of the flow graph to assign levels - to nodes, starting with start methods at level 0. - - Parameters - ---------- - flow : Any - The flow instance containing methods, listeners, and router configurations. - - Returns - ------- - Dict[str, int] - Dictionary mapping method names to their hierarchical levels. - - Notes - ----- - - Start methods are assigned level 0 - - Each subsequent connected node is assigned level = parent_level + 1 - - Handles both OR and AND conditions for listeners - - Processes router paths separately - """ - levels: dict[str, int] = {} - queue: deque[str] = deque() - visited: set[str] = set() - pending_and_listeners: dict[str, set[str]] = {} - - for method_name, method in flow._methods.items(): - if hasattr(method, "__is_start_method__"): - levels[method_name] = 0 - queue.append(method_name) - - or_listeners = defaultdict(list) - and_listeners = defaultdict(set) - for listener_name, condition_data in flow._listeners.items(): - if isinstance(condition_data, tuple): - condition_type, trigger_methods = condition_data - elif isinstance(condition_data, dict): - trigger_methods = _extract_all_methods_recursive(condition_data, flow) - condition_type = condition_data.get("type", "OR") - else: - continue - - if condition_type == "OR": - for method in trigger_methods: - or_listeners[method].append(listener_name) - elif condition_type == "AND": - and_listeners[listener_name] = set(trigger_methods) - - while queue: - current = queue.popleft() - current_level = levels[current] - visited.add(current) - - for listener_name in or_listeners[current]: - if listener_name not in levels or levels[listener_name] > current_level + 1: - levels[listener_name] = current_level + 1 - if listener_name not in visited: - queue.append(listener_name) - - for listener_name, required_methods in and_listeners.items(): - if current in required_methods: - if listener_name not in pending_and_listeners: - pending_and_listeners[listener_name] = set() - pending_and_listeners[listener_name].add(current) - - if required_methods == pending_and_listeners[listener_name]: - if ( - listener_name not in levels - or levels[listener_name] > current_level + 1 - ): - levels[listener_name] = current_level + 1 - if listener_name not in visited: - queue.append(listener_name) - - process_router_paths(flow, current, current_level, levels, queue) - - max_level = max(levels.values()) if levels else 0 - for method_name in flow._methods: - if method_name not in levels: - levels[method_name] = max_level + 1 - - return levels - - -def count_outgoing_edges(flow: Any) -> dict[str, int]: - """ - Count the number of outgoing edges for each method in the flow. - - Parameters - ---------- - flow : Any - The flow instance to analyze. - - Returns - ------- - Dict[str, int] - Dictionary mapping method names to their outgoing edge count. - """ - counts = {} - for method_name in flow._methods: - counts[method_name] = 0 - for condition_data in flow._listeners.values(): - if isinstance(condition_data, tuple): - _, trigger_methods = condition_data - elif isinstance(condition_data, dict): - trigger_methods = _extract_all_methods_recursive(condition_data, flow) - else: - continue - - for trigger in trigger_methods: - if trigger in flow._methods: - counts[trigger] += 1 - return counts - - -def build_ancestor_dict(flow: Any) -> dict[str, set[str]]: - """ - Build a dictionary mapping each node to its ancestor nodes. - - Parameters - ---------- - flow : Any - The flow instance to analyze. - - Returns - ------- - Dict[str, Set[str]] - Dictionary mapping each node to a set of its ancestor nodes. - """ - ancestors: dict[str, set[str]] = {node: set() for node in flow._methods} - visited: set[str] = set() - for node in flow._methods: - if node not in visited: - dfs_ancestors(node, ancestors, visited, flow) - return ancestors - - -def dfs_ancestors( - node: str, ancestors: dict[str, set[str]], visited: set[str], flow: Any -) -> None: - """ - Perform depth-first search to build ancestor relationships. - - Parameters - ---------- - node : str - Current node being processed. - ancestors : Dict[str, Set[str]] - Dictionary tracking ancestor relationships. - visited : Set[str] - Set of already visited nodes. - flow : Any - The flow instance being analyzed. - - Notes - ----- - This function modifies the ancestors dictionary in-place to build - the complete ancestor graph. - """ - if node in visited: - return - visited.add(node) - - for listener_name, condition_data in flow._listeners.items(): - if isinstance(condition_data, tuple): - _, trigger_methods = condition_data - elif isinstance(condition_data, dict): - trigger_methods = _extract_all_methods_recursive(condition_data, flow) - else: - continue - - if node in trigger_methods: - ancestors[listener_name].add(node) - ancestors[listener_name].update(ancestors[node]) - dfs_ancestors(listener_name, ancestors, visited, flow) - - if node in flow._routers: - router_method_name = node - paths = flow._router_paths.get(router_method_name, []) - for path in paths: - for listener_name, condition_data in flow._listeners.items(): - if isinstance(condition_data, tuple): - _, trigger_methods = condition_data - elif isinstance(condition_data, dict): - trigger_methods = _extract_all_methods_recursive( - condition_data, flow - ) - else: - continue - - if path in trigger_methods: - ancestors[listener_name].update(ancestors[node]) - dfs_ancestors(listener_name, ancestors, visited, flow) - - -def is_ancestor( - node: str, ancestor_candidate: str, ancestors: dict[str, set[str]] -) -> bool: - """ - Check if one node is an ancestor of another. - - Parameters - ---------- - node : str - The node to check ancestors for. - ancestor_candidate : str - The potential ancestor node. - ancestors : Dict[str, Set[str]] - Dictionary containing ancestor relationships. - - Returns - ------- - bool - True if ancestor_candidate is an ancestor of node, False otherwise. - """ - return ancestor_candidate in ancestors.get(node, set()) - - -def build_parent_children_dict(flow: Any) -> dict[str, list[str]]: - """ - Build a dictionary mapping parent nodes to their children. - - Parameters - ---------- - flow : Any - The flow instance to analyze. - - Returns - ------- - Dict[str, List[str]] - Dictionary mapping parent method names to lists of their child method names. - - Notes - ----- - - Maps listeners to their trigger methods - - Maps router methods to their paths and listeners - - Children lists are sorted for consistent ordering - """ - parent_children: dict[str, list[str]] = {} - - for listener_name, condition_data in flow._listeners.items(): - if isinstance(condition_data, tuple): - _, trigger_methods = condition_data - elif isinstance(condition_data, dict): - trigger_methods = _extract_all_methods_recursive(condition_data, flow) - else: - continue - - for trigger in trigger_methods: - if trigger not in parent_children: - parent_children[trigger] = [] - if listener_name not in parent_children[trigger]: - parent_children[trigger].append(listener_name) - - for router_method_name, paths in flow._router_paths.items(): - for path in paths: - for listener_name, condition_data in flow._listeners.items(): - if isinstance(condition_data, tuple): - _, trigger_methods = condition_data - elif isinstance(condition_data, dict): - trigger_methods = _extract_all_methods_recursive( - condition_data, flow - ) - else: - continue - - if path in trigger_methods: - if router_method_name not in parent_children: - parent_children[router_method_name] = [] - if listener_name not in parent_children[router_method_name]: - parent_children[router_method_name].append(listener_name) - - return parent_children - - -def get_child_index( - parent: str, child: str, parent_children: dict[str, list[str]] -) -> int: - """ - Get the index of a child node in its parent's sorted children list. - - Parameters - ---------- - parent : str - The parent node name. - child : str - The child node name to find the index for. - parent_children : Dict[str, List[str]] - Dictionary mapping parents to their children lists. - - Returns - ------- - int - Zero-based index of the child in its parent's sorted children list. - """ - children = parent_children.get(parent, []) - children.sort() - return children.index(child) - - -def process_router_paths( - flow: Any, - current: str, - current_level: int, - levels: dict[str, int], - queue: deque[str], -) -> None: - """Handle the router connections for the current node.""" - if current in flow._routers: - paths = flow._router_paths.get(current, []) - for path in paths: - for listener_name, condition_data in flow._listeners.items(): - if isinstance(condition_data, tuple): - _condition_type, trigger_methods = condition_data - elif isinstance(condition_data, dict): - trigger_methods = _extract_all_methods_recursive( - condition_data, flow - ) - else: - continue - - if path in trigger_methods: - if ( - listener_name not in levels - or levels[listener_name] > current_level + 1 - ): - levels[listener_name] = current_level + 1 - queue.append(listener_name) - - -def is_flow_method_name(obj: Any) -> TypeIs[FlowMethodName]: - """Check if the object is a valid flow method name. - - Args: - obj: The object to check. - Returns: - True if the object is a valid flow method name, False otherwise. - """ - return isinstance(obj, str) - - -def is_flow_method_callable(obj: Any) -> TypeIs[FlowMethodCallable[..., Any]]: - """Check if the object is a callable flow method. - - Args: - obj: The object to check. - - Returns: - True if the object is a callable, False otherwise. - """ - return callable(obj) and hasattr(obj, "__name__") - - -def is_flow_condition_list(obj: Any) -> TypeIs[FlowConditions]: - """Check if the object is a list of FlowCondition dictionaries. - - Args: - obj: The object to check. - - Returns: - True if the object is a list of FlowCondition dictionaries, False otherwise. - """ - if not isinstance(obj, list): - return False - - for item in obj: - if not (is_flow_method_name(item) or is_flow_condition_dict(item)): - return False - - return True - - -def is_simple_flow_condition(obj: Any) -> TypeIs[SimpleFlowCondition]: - """Check if the object is a simple flow condition tuple. - - Args: - obj: The object to check. - - Returns: - True if the object is a (condition_type, methods) tuple, False otherwise. - """ - return ( - isinstance(obj, tuple) - and len(obj) == 2 - and isinstance(obj[0], str) - and isinstance(obj[1], list) - ) - - -def is_flow_method(obj: Any) -> TypeIs[FlowMethod[Any, Any]]: - """Check if the object is a flow method wrapper. - - Checks for attributes added by @start, @listen, or @router decorators. - - Args: - obj: The object to check. - - Returns: - True if the object is a FlowMethod subclass (StartMethod, ListenMethod, or RouterMethod). - """ - return ( - hasattr(obj, "__is_flow_method__") - or hasattr(obj, "__is_start_method__") - or hasattr(obj, "__trigger_methods__") - or hasattr(obj, "__is_router__") - ) - - -def is_flow_condition_dict(obj: Any) -> TypeIs[FlowCondition]: - """Check if the object matches the FlowCondition structure. - - Args: - obj: The object to check. - - Returns: - True if the object is a valid FlowCondition dictionary, False otherwise. - """ - if not isinstance(obj, dict): - return False - - type_value = obj.get("type") - if type_value not in ("AND", "OR"): - return False - - if "conditions" in obj: - conditions = obj["conditions"] - if not isinstance(conditions, list): - return False - for cond in conditions: - if not ( - isinstance(cond, str) - or (isinstance(cond, dict) and is_flow_condition_dict(cond)) - ): - return False - - if "methods" in obj: - methods = obj["methods"] - if not (isinstance(methods, list) and all(isinstance(m, str) for m in methods)): - return False - - allowed_keys = {"type", "conditions", "methods"} - if not set(obj).issubset(allowed_keys): - return False - - return True - - -def _extract_all_methods_recursive( - condition: str | FlowCondition | dict[str, Any] | list[Any], - flow: Flow[Any] | None = None, -) -> list[FlowMethodName]: - """Extract ALL method names from a condition tree recursively. - - This function recursively extracts every method name from the entire - condition tree, regardless of nesting. Used for visualization and debugging. - - Note: Only extracts actual method names, not router output strings. - If flow is provided, it will filter out strings that are not in flow._methods. - - Args: - condition: Can be a string, dict, or list - flow: Optional flow instance to filter out non-method strings - - Returns: - List of all method names found in the condition tree - """ - if is_flow_method_name(condition): - if flow is not None: - if condition in flow._methods: - return [condition] - return [] - return [condition] - if is_flow_condition_dict(condition): - normalized = _normalize_condition(condition) - methods = [] - for sub_cond in normalized.get("conditions", []): - methods.extend(_extract_all_methods_recursive(sub_cond, flow)) - return methods - if isinstance(condition, list): - methods = [] - for item in condition: - methods.extend(_extract_all_methods_recursive(item, flow)) - return methods - return [] - - -def _normalize_condition( - condition: FlowConditions | FlowCondition | FlowMethodName, -) -> FlowCondition: - """Normalize a condition to standard format with 'conditions' key. - - Args: - condition: Can be a string (method name), dict (condition), or list - - Returns: - Normalized dict with 'type' and 'conditions' keys - """ - if is_flow_method_name(condition): - return {"type": OR_CONDITION, "conditions": [condition]} - if is_flow_condition_dict(condition): - if "conditions" in condition: - return condition - if "methods" in condition: - return {"type": condition["type"], "conditions": condition["methods"]} - return condition - if is_flow_condition_list(condition): - return {"type": OR_CONDITION, "conditions": condition} - - raise ValueError(f"Cannot normalize condition: {condition}") - - -def _extract_all_methods( - condition: str | FlowCondition | dict[str, Any] | list[Any], -) -> list[FlowMethodName]: - """Extract all method names from a condition (including nested). - - For AND conditions, this extracts methods that must ALL complete. - For OR conditions nested inside AND, we don't extract their methods - since only one branch of the OR needs to trigger, not all methods. - - This function is used for runtime execution logic, where we need to know - which methods must complete for AND conditions. For visualization purposes, - use _extract_all_methods_recursive() instead. - - Args: - condition: Can be a string, dict, or list - - Returns: - List of all method names in the condition tree that must complete - """ - if is_flow_method_name(condition): - return [condition] - if is_flow_condition_dict(condition): - normalized = _normalize_condition(condition) - cond_type = normalized.get("type", OR_CONDITION) - - if cond_type == AND_CONDITION: - return [ - sub_cond - for sub_cond in normalized.get("conditions", []) - if is_flow_method_name(sub_cond) - ] - return [] - if isinstance(condition, list): - methods = [] - for item in condition: - methods.extend(_extract_all_methods(item)) - return methods - return [] - - -def extract_flow_definition( - namespace: dict[str, Any], -) -> tuple[list[str], dict[str, Any], set[str], dict[str, Any]]: - """Extract the structural flow registries from a class namespace. - - Walks the decorated methods in ``namespace`` and returns the - ``(start_methods, listeners, routers, router_paths)`` registries that the - runtime metaclass attaches to a Flow class. This is the structural half of - what used to live inline in ``FlowMeta.__new__``. - """ - start_methods = [] - listeners = {} - router_paths = {} - routers = set() - - for attr_name, attr_value in namespace.items(): - if ( - hasattr(attr_value, "__is_flow_method__") - or hasattr(attr_value, "__is_start_method__") - or hasattr(attr_value, "__trigger_methods__") - or hasattr(attr_value, "__is_router__") - ): - if hasattr(attr_value, "__is_start_method__"): - start_methods.append(attr_name) - - if ( - hasattr(attr_value, "__trigger_methods__") - and attr_value.__trigger_methods__ is not None - ): - methods = attr_value.__trigger_methods__ - condition_type = getattr(attr_value, "__condition_type__", OR_CONDITION) - - if ( - hasattr(attr_value, "__trigger_condition__") - and attr_value.__trigger_condition__ is not None - ): - listeners[attr_name] = attr_value.__trigger_condition__ - else: - listeners[attr_name] = (condition_type, methods) - - if hasattr(attr_value, "__is_router__") and attr_value.__is_router__: - routers.add(attr_name) - # Explicit __router_paths__ set by @human_feedback(emit=[...]) takes priority over source analysis - if ( - hasattr(attr_value, "__router_paths__") - and attr_value.__router_paths__ - ): - router_paths[attr_name] = attr_value.__router_paths__ - else: - possible_returns = get_possible_return_constants(attr_value) - if possible_returns: - router_paths[attr_name] = possible_returns - else: - router_paths[attr_name] = [] - - # Handle start methods that are also routers (e.g., @human_feedback with emit) - if ( - hasattr(attr_value, "__is_start_method__") - and hasattr(attr_value, "__is_router__") - and attr_value.__is_router__ - ): - routers.add(attr_name) - if ( - hasattr(attr_value, "__router_paths__") - and attr_value.__router_paths__ - ): - router_paths[attr_name] = attr_value.__router_paths__ - else: - possible_returns = get_possible_return_constants(attr_value) - if possible_returns: - router_paths[attr_name] = possible_returns - else: - router_paths[attr_name] = [] - - return start_methods, listeners, routers, router_paths + if key in seen: + continue + seen.add(key) + diagnostics.append(diagnostic) + return diagnostics diff --git a/lib/crewai/src/crewai/flow/flow_serializer.py b/lib/crewai/src/crewai/flow/flow_serializer.py deleted file mode 100644 index 028b0a430..000000000 --- a/lib/crewai/src/crewai/flow/flow_serializer.py +++ /dev/null @@ -1,602 +0,0 @@ -"""Flow structure serializer for introspecting Flow classes. - -This module provides the flow_structure() function that analyzes a Flow class -and returns a JSON-serializable dictionary describing its graph structure. -This is used by Studio UI to render a visual flow graph. - -Example: - >>> from crewai.flow import Flow, start, listen - >>> from crewai.flow.flow_serializer import flow_structure - >>> - >>> class MyFlow(Flow): - ... @start() - ... def begin(self): - ... return "started" - ... - ... @listen(begin) - ... def process(self): - ... return "done" - >>> - >>> structure = flow_structure(MyFlow) - >>> print(structure["name"]) - 'MyFlow' -""" - -from __future__ import annotations - -import inspect -import logging -import re -import textwrap -from typing import Any, TypedDict, get_args, get_origin - -from pydantic import BaseModel -from pydantic_core import PydanticUndefined - -from crewai.flow.flow_wrappers import ( - FlowCondition, - FlowMethod, - ListenMethod, - RouterMethod, - StartMethod, -) - - -logger = logging.getLogger(__name__) - - -class MethodInfo(TypedDict, total=False): - """Information about a single flow method. - - Attributes: - name: The method name. - type: Method type - start, listen, router, or start_router. - trigger_methods: List of method names that trigger this method. - condition_type: 'AND' or 'OR' for composite conditions, null otherwise. - router_paths: For routers, the possible route names returned. - has_human_feedback: Whether the method has @human_feedback decorator. - has_crew: Whether the method body references a Crew. - """ - - name: str - type: str - trigger_methods: list[str] - condition_type: str | None - router_paths: list[str] - has_human_feedback: bool - has_crew: bool - - -class EdgeInfo(TypedDict, total=False): - """Information about an edge between flow methods. - - Attributes: - from_method: Source method name. - to_method: Target method name. - edge_type: Type of edge - 'listen' or 'route'. - condition: Route name for router edges, null for listen edges. - """ - - from_method: str - to_method: str - edge_type: str - condition: str | None - - -class StateFieldInfo(TypedDict, total=False): - """Information about a state field. - - Attributes: - name: Field name. - type: Field type as string. - default: Default value if any. - """ - - name: str - type: str - default: Any - - -class StateSchemaInfo(TypedDict, total=False): - """Information about the flow's state schema. - - Attributes: - fields: List of field information. - """ - - fields: list[StateFieldInfo] - - -class FlowStructureInfo(TypedDict, total=False): - """Complete flow structure information. - - Attributes: - name: Flow class name. - description: Flow docstring if available. - methods: List of method information. - edges: List of edge information. - state_schema: State schema if typed, null otherwise. - inputs: Detected flow inputs if available. - """ - - name: str - description: str | None - methods: list[MethodInfo] - edges: list[EdgeInfo] - state_schema: StateSchemaInfo | None - inputs: list[str] - - -def _get_method_type( - method_name: str, - method: Any, - start_methods: list[str], - routers: set[str], -) -> str: - """Determine the type of a flow method. - - Args: - method_name: Name of the method. - method: The method object. - start_methods: List of start method names. - routers: Set of router method names. - - Returns: - One of: 'start', 'listen', 'router', or 'start_router'. - """ - is_start = method_name in start_methods or getattr( - method, "__is_start_method__", False - ) - is_router = method_name in routers or getattr(method, "__is_router__", False) - - if is_start and is_router: - return "start_router" - if is_start: - return "start" - if is_router: - return "router" - return "listen" - - -def _has_human_feedback(method: Any) -> bool: - """Check if a method has the @human_feedback decorator. - - Args: - method: The method object to check. - - Returns: - True if the method has __human_feedback_config__ attribute. - """ - return hasattr(method, "__human_feedback_config__") - - -def _detect_crew_reference(method: Any) -> bool: - """Detect if a method body references a Crew. - - Checks for patterns like: - - .crew() method calls - - Crew( instantiation - - References to Crew class in type hints - - Note: - This is a **best-effort heuristic for UI hints**, not a guarantee. - Uses inspect.getsource + regex which can false-positive on comments - or string literals, and may fail on dynamically generated methods - or lambdas. Do not rely on this for correctness-critical logic. - - Args: - method: The method object to inspect. - - Returns: - True if crew reference detected, False otherwise. - """ - try: - func = method - if hasattr(method, "_meth"): - func = method._meth - elif hasattr(method, "__wrapped__"): - func = method.__wrapped__ - - source = inspect.getsource(func) - source = textwrap.dedent(source) - - crew_patterns = [ - r"\.crew\(\)", # .crew() method call - r"Crew\s*\(", # Crew( instantiation - r":\s*Crew\b", # Type hint with Crew - r"->.*Crew", # Return type hint with Crew - ] - - for pattern in crew_patterns: - if re.search(pattern, source): - return True - - return False - except (OSError, TypeError): - return False - - -def _extract_trigger_methods(method: Any) -> tuple[list[str], str | None]: - """Extract trigger methods and condition type from a method. - - Args: - method: The method object to inspect. - - Returns: - Tuple of (trigger_methods list, condition_type or None). - """ - trigger_methods: list[str] = [] - condition_type: str | None = None - - if hasattr(method, "__trigger_methods__") and method.__trigger_methods__: - trigger_methods = [str(m) for m in method.__trigger_methods__] - - # For complex conditions (or_/and_ combinators), extract from __trigger_condition__ - if ( - not trigger_methods - and hasattr(method, "__trigger_condition__") - and method.__trigger_condition__ - ): - trigger_condition = method.__trigger_condition__ - trigger_methods = _extract_all_methods_from_condition(trigger_condition) - - if hasattr(method, "__condition_type__") and method.__condition_type__: - condition_type = str(method.__condition_type__) - - return trigger_methods, condition_type - - -def _extract_router_paths( - method: Any, router_paths_registry: dict[str, list[str]] -) -> list[str]: - """Extract router paths for a router method. - - Args: - method: The method object. - router_paths_registry: The class-level _router_paths dict. - - Returns: - List of possible route names. - """ - method_name = getattr(method, "__name__", "") - - if hasattr(method, "__router_paths__") and method.__router_paths__: - return [str(p) for p in method.__router_paths__] - - if method_name in router_paths_registry: - return [str(p) for p in router_paths_registry[method_name]] - - return [] - - -def _extract_all_methods_from_condition( - condition: str | FlowCondition | dict[str, Any] | list[Any], -) -> list[str]: - """Extract all method names from a condition tree recursively. - - Args: - condition: Can be a string, FlowCondition tuple, dict, or list. - - Returns: - List of all method names found in the condition. - """ - if isinstance(condition, str): - return [condition] - if isinstance(condition, tuple) and len(condition) == 2: - # FlowCondition: (condition_type, methods_list) - _, methods = condition - if isinstance(methods, list): - result: list[str] = [] - for m in methods: - result.extend(_extract_all_methods_from_condition(m)) - return result - return [] - if isinstance(condition, dict): - conditions_list = condition.get("conditions", []) - dict_methods: list[str] = [] - for sub_cond in conditions_list: - dict_methods.extend(_extract_all_methods_from_condition(sub_cond)) - return dict_methods - if isinstance(condition, list): - list_methods: list[str] = [] - for item in condition: - list_methods.extend(_extract_all_methods_from_condition(item)) - return list_methods - return [] - - -def _generate_edges( - listeners: dict[str, tuple[str, list[str]] | FlowCondition], - routers: set[str], - router_paths: dict[str, list[str]], - all_methods: set[str], -) -> list[EdgeInfo]: - """Generate edges from listeners and routers. - - Args: - listeners: Map of listener_name -> (condition_type, trigger_methods) or FlowCondition. - routers: Set of router method names. - router_paths: Map of router_name -> possible return values. - all_methods: Set of all method names in the flow. - - Returns: - List of EdgeInfo dictionaries. - """ - edges: list[EdgeInfo] = [] - - for listener_name, condition_data in listeners.items(): - trigger_methods: list[str] = [] - - if isinstance(condition_data, tuple) and len(condition_data) == 2: - _condition_type, methods = condition_data - trigger_methods = [str(m) for m in methods] - elif isinstance(condition_data, dict): - trigger_methods = _extract_all_methods_from_condition(condition_data) - - edges.extend( - EdgeInfo( - from_method=trigger, - to_method=listener_name, - edge_type="listen", - condition=None, - ) - for trigger in trigger_methods - if trigger in all_methods - ) - - for router_name, paths in router_paths.items(): - for path in paths: - for listener_name, condition_data in listeners.items(): - path_triggers: list[str] = [] - - if isinstance(condition_data, tuple) and len(condition_data) == 2: - _, methods = condition_data - path_triggers = [str(m) for m in methods] - elif isinstance(condition_data, dict): - path_triggers = _extract_all_methods_from_condition(condition_data) - - if str(path) in path_triggers: - edges.append( - EdgeInfo( - from_method=router_name, - to_method=listener_name, - edge_type="route", - condition=str(path), - ) - ) - - return edges - - -def _extract_state_schema(flow_class: type) -> StateSchemaInfo | None: - """Extract state schema from a Flow class. - - Checks for: - - Generic type parameter (Flow[MyState]) - - initial_state class attribute - - Args: - flow_class: The Flow class to inspect. - - Returns: - StateSchemaInfo if a Pydantic model state is detected, None otherwise. - """ - state_type: type | None = None - - # _initial_state_t is set by Flow.__class_getitem__ - if hasattr(flow_class, "_initial_state_t"): - state_type = flow_class._initial_state_t - - if state_type is None and hasattr(flow_class, "initial_state"): - initial_state = flow_class.initial_state - if isinstance(initial_state, type) and issubclass(initial_state, BaseModel): - state_type = initial_state - elif isinstance(initial_state, BaseModel): - state_type = type(initial_state) - - if state_type is None and hasattr(flow_class, "__orig_bases__"): - for base in flow_class.__orig_bases__: - origin = get_origin(base) - if origin is not None: - args = get_args(base) - if args: - candidate = args[0] - if isinstance(candidate, type) and issubclass(candidate, BaseModel): - state_type = candidate - break - - if state_type is None or not issubclass(state_type, BaseModel): - return None - - fields: list[StateFieldInfo] = [] - try: - model_fields = state_type.model_fields - for field_name, field_info in model_fields.items(): - field_type_str = "Any" - if field_info.annotation is not None: - field_type_str = str(field_info.annotation) - field_type_str = field_type_str.replace("typing.", "") - field_type_str = field_type_str.replace("", "" - ) - - default_value = None - if ( - field_info.default is not PydanticUndefined - and field_info.default is not None - and not callable(field_info.default) - ): - try: - default_value = field_info.default - except Exception: - default_value = str(field_info.default) - - fields.append( - StateFieldInfo( - name=field_name, - type=field_type_str, - default=default_value, - ) - ) - except Exception: - logger.debug( - "Failed to extract state schema fields for %s", flow_class.__name__ - ) - - return StateSchemaInfo(fields=fields) if fields else None - - -def _detect_flow_inputs(flow_class: type) -> list[str]: - """Detect flow input parameters. - - Inspects the __init__ signature for custom parameters beyond standard Flow params. - - Args: - flow_class: The Flow class to inspect. - - Returns: - List of detected input names. - """ - inputs: list[str] = [] - - try: - init_method = flow_class.__init__ # type: ignore[misc] - init_sig = inspect.signature(init_method) - standard_params = { - "self", - "persistence", - "tracing", - "suppress_flow_events", - "max_method_calls", - "kwargs", - } - inputs.extend( - param_name - for param_name in init_sig.parameters - if param_name not in standard_params and not param_name.startswith("_") - ) - except Exception: - logger.debug( - "Failed to detect inputs from __init__ for %s", flow_class.__name__ - ) - - return inputs - - -def flow_structure(flow_class: type) -> FlowStructureInfo: - """Introspect a Flow class and return its structure as a JSON-serializable dict. - - This function analyzes a Flow CLASS (not instance) and returns complete - information about its graph structure including methods, edges, and state. - - Args: - flow_class: A Flow class (not an instance) to introspect. - - Returns: - FlowStructureInfo dictionary containing: - - name: Flow class name - - description: Docstring if available - - methods: List of method info dicts - - edges: List of edge info dicts - - state_schema: State schema if typed, None otherwise - - inputs: Detected input names - - Raises: - TypeError: If flow_class is not a class. - - Example: - >>> structure = flow_structure(MyFlow) - >>> print(structure["name"]) - 'MyFlow' - >>> for method in structure["methods"]: - ... print(method["name"], method["type"]) - """ - if not isinstance(flow_class, type): - raise TypeError( - f"flow_structure requires a Flow class, not an instance. " - f"Got {type(flow_class).__name__}" - ) - - start_methods: list[str] = getattr(flow_class, "_start_methods", []) - listeners: dict[str, Any] = getattr(flow_class, "_listeners", {}) - routers: set[str] = getattr(flow_class, "_routers", set()) - router_paths_registry: dict[str, list[str]] = getattr( - flow_class, "_router_paths", {} - ) - - methods: list[MethodInfo] = [] - all_method_names: set[str] = set() - - for attr_name in dir(flow_class): - if attr_name.startswith("_"): - continue - - try: - attr = getattr(flow_class, attr_name) - except AttributeError: - continue - - is_flow_method = ( - isinstance(attr, (FlowMethod, StartMethod, ListenMethod, RouterMethod)) - or hasattr(attr, "__is_flow_method__") - or hasattr(attr, "__is_start_method__") - or hasattr(attr, "__trigger_methods__") - or hasattr(attr, "__is_router__") - ) - - if not is_flow_method: - continue - - # Conversational built-ins on the base ``Flow`` class (``conversation_start``, - # ``route_conversation``, ``converse_turn``, etc.) are inert on non-chat - # subclasses — they're not registered in ``_start_methods`` / ``_listeners``, - # so excluding them here keeps the serialized structure aligned with what - # actually fires at runtime. - if getattr(attr, "__conversational_only__", False) and not getattr( - flow_class, "conversational", False - ): - continue - - all_method_names.add(attr_name) - - method_type = _get_method_type(attr_name, attr, start_methods, routers) - - trigger_methods, condition_type = _extract_trigger_methods(attr) - - router_paths_list: list[str] = [] - if method_type in ("router", "start_router"): - router_paths_list = _extract_router_paths(attr, router_paths_registry) - - has_hf = _has_human_feedback(attr) - - has_crew = _detect_crew_reference(attr) - - method_info = MethodInfo( - name=attr_name, - type=method_type, - trigger_methods=trigger_methods, - condition_type=condition_type, - router_paths=router_paths_list, - has_human_feedback=has_hf, - has_crew=has_crew, - ) - methods.append(method_info) - - edges = _generate_edges(listeners, routers, router_paths_registry, all_method_names) - - state_schema = _extract_state_schema(flow_class) - - inputs = _detect_flow_inputs(flow_class) - - description: str | None = None - if flow_class.__doc__: - description = flow_class.__doc__.strip() - - return FlowStructureInfo( - name=flow_class.__name__, - description=description, - methods=methods, - edges=edges, - state_schema=state_schema, - inputs=inputs, - ) diff --git a/lib/crewai/src/crewai/flow/flow_wrappers.py b/lib/crewai/src/crewai/flow/flow_wrappers.py index 80292671b..7e42859c8 100644 --- a/lib/crewai/src/crewai/flow/flow_wrappers.py +++ b/lib/crewai/src/crewai/flow/flow_wrappers.py @@ -18,6 +18,17 @@ R = TypeVar("R") FlowConditionType: TypeAlias = Literal["OR", "AND"] SimpleFlowCondition: TypeAlias = tuple[FlowConditionType, list[FlowMethodName]] +__all__ = [ + "FlowCondition", + "FlowConditionType", + "FlowConditions", + "FlowMethod", + "ListenMethod", + "RouterMethod", + "SimpleFlowCondition", + "StartMethod", +] + class FlowCondition(TypedDict, total=False): """Type definition for flow trigger conditions. @@ -73,10 +84,12 @@ class FlowMethod(Generic[P, R]): # Preserve flow-related attributes from wrapped method (e.g., from @human_feedback) for attr in [ "__is_router__", - "__router_paths__", + "__router_emit__", "__human_feedback_config__", "__conversational_only__", # gates registration on Flow.conversational - "_hf_llm", # Live LLM object for HITL resume + "__flow_persistence_config__", + "__flow_method_definition__", + "_human_feedback_llm", # Live LLM object for HITL resume ]: if hasattr(meth, attr): setattr(self, attr, getattr(meth, attr)) @@ -166,3 +179,4 @@ class RouterMethod(FlowMethod[P, R]): __trigger_methods__: list[FlowMethodName] | None = None __condition_type__: FlowConditionType | None = None __trigger_condition__: FlowCondition | None = None + __router_emit__: list[str] | None = None diff --git a/lib/crewai/src/crewai/flow/human_feedback.py b/lib/crewai/src/crewai/flow/human_feedback.py index 2985dab13..65a61d5b2 100644 --- a/lib/crewai/src/crewai/flow/human_feedback.py +++ b/lib/crewai/src/crewai/flow/human_feedback.py @@ -65,6 +65,7 @@ from typing import TYPE_CHECKING, Any, TypeVar from pydantic import BaseModel, Field +from crewai.flow.flow_definition import FlowMethodDefinition from crewai.flow.flow_wrappers import FlowMethod @@ -78,14 +79,10 @@ logger = logging.getLogger(__name__) F = TypeVar("F", bound=Callable[..., Any]) +__all__ = ["HumanFeedbackResult", "human_feedback"] + def _serialize_llm_for_context(llm: Any) -> dict[str, Any] | str | None: - """Serialize a BaseLLM object to a dict preserving full config. - - Delegates to ``llm.to_config_dict()`` when available (BaseLLM and - subclasses). Falls back to extracting the model string with provider - prefix for unknown LLM types. - """ to_config: Callable[[], dict[str, Any]] | None = getattr( llm, "to_config_dict", None ) @@ -103,13 +100,6 @@ def _serialize_llm_for_context(llm: Any) -> dict[str, Any] | str | None: def _deserialize_llm_from_context( llm_data: dict[str, Any] | str | None, ) -> BaseLLM | None: - """Reconstruct an LLM instance from serialized context data. - - Handles both the new dict format (with full config) and the legacy - string format (model name only) for backward compatibility. - - Returns a BaseLLM instance, or None if llm_data is None. - """ if llm_data is None: return None @@ -202,12 +192,12 @@ class HumanFeedbackMethod(FlowMethod[Any, Any]): Attributes: __is_router__: True when emit is specified, enabling router behavior. - __router_paths__: List of possible outcomes when acting as a router. + __router_emit__: List of possible outcomes when acting as a router. __human_feedback_config__: The HumanFeedbackConfig for this method. """ __is_router__: bool = False - __router_paths__: list[str] | None = None + __router_emit__: list[str] | None = None __human_feedback_config__: HumanFeedbackConfig | None = None @@ -356,20 +346,12 @@ def human_feedback( raise ValueError("default_outcome requires emit to be specified.") def decorator(func: F) -> F: - """Inner decorator that wraps the function.""" - def _get_hitl_prompt(key: str) -> str: - """Read a HITL prompt from the i18n translations.""" from crewai.utilities.i18n import I18N_DEFAULT return I18N_DEFAULT.slice(key) def _resolve_llm_instance() -> Any: - """Resolve the ``llm`` parameter to a BaseLLM instance. - - Uses the SAME model specified in the decorator so pre-review, - distillation, and outcome collapsing all share one model. - """ if llm is None: from crewai.llm import LLM @@ -383,7 +365,6 @@ def human_feedback( def _pre_review_with_lessons( flow_instance: Flow[Any], method_output: Any ) -> Any: - """Recall past HITL lessons and use LLM to pre-review the output.""" try: mem = flow_instance.memory if mem is None: @@ -431,7 +412,6 @@ def human_feedback( def _distill_and_store_lessons( flow_instance: Flow[Any], method_output: Any, raw_feedback: str ) -> None: - """Extract generalizable lessons from output + feedback, store in memory.""" try: mem = flow_instance.memory if mem is None: @@ -485,7 +465,6 @@ def human_feedback( def _build_feedback_context( flow_instance: Flow[Any], method_output: Any ) -> tuple[Any, Any]: - """Build the PendingFeedbackContext and resolve the effective provider.""" from crewai.flow.async_feedback.types import PendingFeedbackContext context = PendingFeedbackContext( @@ -509,7 +488,6 @@ def human_feedback( return context, effective_provider def _request_feedback(flow_instance: Flow[Any], method_output: Any) -> str: - """Request feedback using provider or default console (sync).""" context, effective_provider = _build_feedback_context( flow_instance, method_output ) @@ -535,7 +513,6 @@ def human_feedback( async def _request_feedback_async( flow_instance: Flow[Any], method_output: Any ) -> str: - """Request feedback, awaiting the provider if it returns a coroutine.""" context, effective_provider = _build_feedback_context( flow_instance, method_output ) @@ -559,7 +536,6 @@ def human_feedback( method_output: Any, raw_feedback: str, ) -> HumanFeedbackResult | str: - """Process feedback and return result or outcome.""" collapsed_outcome: str | None = None if not raw_feedback.strip(): @@ -661,6 +637,10 @@ def human_feedback( "__condition_type__", "__trigger_condition__", "__is_flow_method__", + "__flow_persistence_config__", + "__is_router__", + "__router_emit__", + "__flow_method_definition__", ]: if hasattr(func, attr): setattr(wrapper, attr, getattr(func, attr)) @@ -681,7 +661,16 @@ def human_feedback( if emit: wrapper.__is_router__ = True - wrapper.__router_paths__ = list(emit) + wrapper.__router_emit__ = list(emit) + # Keep the definition fragment in sync: emit promotes the method to + # a router and the feedback outcomes replace any emit recorded by an + # inner @router. Copy before updating so the wrapped method's own + # fragment (shared by reference) is left untouched. + fragment = getattr(wrapper, "__flow_method_definition__", None) + if isinstance(fragment, FlowMethodDefinition): + wrapper.__flow_method_definition__ = fragment.model_copy( + update={"router": True, "emit": list(emit)} + ) # Stash the live LLM object for HITL resume to retrieve. # When a flow pauses for human feedback and later resumes (possibly in a @@ -689,7 +678,7 @@ def human_feedback( # By storing the original LLM on the wrapper, resume_async can retrieve # the fully-configured LLM (with credentials, project, safety_settings, etc.) # instead of creating a bare LLM from just the model string. - wrapper._hf_llm = llm + wrapper._human_feedback_llm = llm return wrapper # type: ignore[no-any-return] diff --git a/lib/crewai/src/crewai/flow/persistence/__init__.py b/lib/crewai/src/crewai/flow/persistence/__init__.py index 50de9abcc..e9f0b1807 100644 --- a/lib/crewai/src/crewai/flow/persistence/__init__.py +++ b/lib/crewai/src/crewai/flow/persistence/__init__.py @@ -4,16 +4,9 @@ CrewAI Flow Persistence. This module provides interfaces and implementations for persisting flow states. """ -from typing import Any, TypeVar - -from pydantic import BaseModel - from crewai.flow.persistence.base import FlowPersistence from crewai.flow.persistence.decorators import persist from crewai.flow.persistence.sqlite import SQLiteFlowPersistence __all__ = ["FlowPersistence", "SQLiteFlowPersistence", "persist"] - -StateType = TypeVar("StateType", bound=dict[str, Any] | BaseModel) -DictStateType = dict[str, Any] diff --git a/lib/crewai/src/crewai/flow/persistence/decorators.py b/lib/crewai/src/crewai/flow/persistence/decorators.py index 83dd6d69a..3fc5f9bf9 100644 --- a/lib/crewai/src/crewai/flow/persistence/decorators.py +++ b/lib/crewai/src/crewai/flow/persistence/decorators.py @@ -28,6 +28,7 @@ import asyncio from collections.abc import Callable import functools import logging +from types import SimpleNamespace from typing import TYPE_CHECKING, Any, Final, TypeVar, cast from crewai_core.printer import PRINTER @@ -44,6 +45,8 @@ if TYPE_CHECKING: logger = logging.getLogger(__name__) T = TypeVar("T") +__all__ = ["PersistenceDecorator", "persist"] + LOG_MESSAGES: Final[dict[str, str]] = { "save_state": "Saving flow state to memory for ID: {}", "save_error": "Failed to persist state for method {}: {}", @@ -52,6 +55,31 @@ LOG_MESSAGES: Final[dict[str, str]] = { } +def _stamp_persistence_metadata( + target: Any, + persistence: FlowPersistence, + verbose: bool, +) -> None: + target.__flow_persistence_config__ = SimpleNamespace( + persistence=persistence, + verbose=verbose, + ) + + +_PRESERVED_FLOW_ATTRS: Final[tuple[str, ...]] = ( + "__is_start_method__", + "__trigger_methods__", + "__condition_type__", + "__trigger_condition__", + "__is_router__", + "__router_emit__", + "__human_feedback_config__", + "__flow_persistence_config__", + "__flow_method_definition__", + "_human_feedback_llm", +) + + class PersistenceDecorator: """Class to handle flow state persistence with consistent logging.""" @@ -163,10 +191,10 @@ def persist( """ def decorator(target: type | Callable[..., T]) -> type | Callable[..., T]: - """Decorator that handles both class and method decoration.""" actual_persistence = persistence or SQLiteFlowPersistence() if isinstance(target, type): + _stamp_persistence_metadata(target, actual_persistence, verbose) original_init = target.__init__ # type: ignore[misc] @functools.wraps(original_init) @@ -211,12 +239,7 @@ def persist( wrapped = create_async_wrapper(name, method) - for attr in [ - "__is_start_method__", - "__trigger_methods__", - "__condition_type__", - "__is_router__", - ]: + for attr in _PRESERVED_FLOW_ATTRS: if hasattr(method, attr): setattr(wrapped, attr, getattr(method, attr)) wrapped.__is_flow_method__ = True # type: ignore[attr-defined] @@ -239,12 +262,7 @@ def persist( wrapped = create_sync_wrapper(name, method) - for attr in [ - "__is_start_method__", - "__trigger_methods__", - "__condition_type__", - "__is_router__", - ]: + for attr in _PRESERVED_FLOW_ATTRS: if hasattr(method, attr): setattr(wrapped, attr, getattr(method, attr)) wrapped.__is_flow_method__ = True # type: ignore[attr-defined] @@ -254,6 +272,7 @@ def persist( return target method = target method.__is_flow_method__ = True # type: ignore[attr-defined] + _stamp_persistence_metadata(method, actual_persistence, verbose) if asyncio.iscoroutinefunction(method): @@ -271,15 +290,13 @@ def persist( ) return cast(T, result) - for attr in [ - "__is_start_method__", - "__trigger_methods__", - "__condition_type__", - "__is_router__", - ]: + for attr in _PRESERVED_FLOW_ATTRS: if hasattr(method, attr): setattr(method_async_wrapper, attr, getattr(method, attr)) method_async_wrapper.__is_flow_method__ = True # type: ignore[attr-defined] + _stamp_persistence_metadata( + method_async_wrapper, actual_persistence, verbose + ) return cast(Callable[..., T], method_async_wrapper) @functools.wraps(method) @@ -290,15 +307,11 @@ def persist( ) return result - for attr in [ - "__is_start_method__", - "__trigger_methods__", - "__condition_type__", - "__is_router__", - ]: + for attr in _PRESERVED_FLOW_ATTRS: if hasattr(method, attr): setattr(method_sync_wrapper, attr, getattr(method, attr)) method_sync_wrapper.__is_flow_method__ = True # type: ignore[attr-defined] + _stamp_persistence_metadata(method_sync_wrapper, actual_persistence, verbose) return cast(Callable[..., T], method_sync_wrapper) return decorator diff --git a/lib/crewai/src/crewai/flow/runtime.py b/lib/crewai/src/crewai/flow/runtime.py index 0a2a84a95..33bfbacea 100644 --- a/lib/crewai/src/crewai/flow/runtime.py +++ b/lib/crewai/src/crewai/flow/runtime.py @@ -1,9 +1,9 @@ -"""Flow runtime: the Flow execution engine, its metaclass, and state proxies. +"""Flow Runtime: the engine that executes a Flow. -Holds the Flow class (kickoff/resume/listener dispatch), the FlowMeta -metaclass (Pydantic model construction; structural extraction is delegated to -``flow_definition.extract_flow_definition``), and the thread-safe state -proxies. The authoring decorators live in ``crewai.flow.dsl``. +Provides the ``Flow`` class (kickoff/resume/listener dispatch), the +``FlowMeta`` metaclass, and the thread-safe state proxies. Flows +authored with the Python DSL (see ``dsl``) are described by a Flow +Structure (see ``flow_definition``) and executed here. """ from __future__ import annotations @@ -90,18 +90,18 @@ from crewai.experimental.conversational import ( ) from crewai.experimental.conversational_mixin import _ConversationalMixin from crewai.flow.constants import AND_CONDITION, OR_CONDITION -from crewai.flow.flow_context import current_flow_id, current_flow_request_id -from crewai.flow.flow_definition import ( +from crewai.flow.dsl import ( _extract_all_methods, _extract_all_methods_recursive, _normalize_condition, + build_flow_definition, extract_flow_definition, - get_possible_return_constants, is_flow_condition_dict, is_flow_method, - is_flow_method_name, is_simple_flow_condition, ) +from crewai.flow.flow_context import current_flow_id, current_flow_request_id +from crewai.flow.flow_definition import FlowDefinition from crewai.flow.flow_wrappers import ( FlowCondition, FlowMethod, @@ -601,7 +601,7 @@ class FlowMeta(ModelMetaclass): cls = super().__new__(mcs, name, bases, namespace) - start_methods, listeners, routers, router_paths = extract_flow_definition( + start_methods, listeners, routers, router_emit = extract_flow_definition( namespace ) @@ -631,9 +631,7 @@ class FlowMeta(ModelMetaclass): start_methods = [m for m in start_methods if not _is_conv_only(m)] listeners = {k: v for k, v in listeners.items() if not _is_conv_only(k)} routers = {r for r in routers if not _is_conv_only(r)} - router_paths = { - k: v for k, v in router_paths.items() if not _is_conv_only(k) - } + router_emit = {k: v for k, v in router_emit.items() if not _is_conv_only(k)} # 2. Harvest conversational-only methods from base classes when this # subclass opts in. (extract_flow_definition only scans the current @@ -670,21 +668,16 @@ class FlowMeta(ModelMetaclass): if getattr(attr_value, "__is_router__", False): routers.add(attr_name) - paths = getattr(attr_value, "__router_paths__", None) - if paths: - router_paths[attr_name] = paths - else: - possible_returns = get_possible_return_constants( - attr_value - ) - router_paths[attr_name] = ( - possible_returns if possible_returns else [] - ) + emit = getattr(attr_value, "__router_emit__", None) + router_emit[attr_name] = list(emit) if emit else [] cls._start_methods = start_methods # type: ignore[attr-defined] cls._listeners = listeners # type: ignore[attr-defined] cls._routers = routers # type: ignore[attr-defined] - cls._router_paths = router_paths # type: ignore[attr-defined] + cls._router_emit = router_emit # type: ignore[attr-defined] + # The static FlowDefinition is built lazily (on first access via + # ``Flow.flow_definition()`` or visualization), not at class-definition + # time, to avoid AST parsing and diagnostic logging on every import. return cls @@ -704,7 +697,8 @@ class Flow(_ConversationalMixin, BaseModel, Generic[T], metaclass=FlowMeta): _start_methods: ClassVar[list[FlowMethodName]] = [] _listeners: ClassVar[dict[FlowMethodName, SimpleFlowCondition | FlowCondition]] = {} _routers: ClassVar[set[FlowMethodName]] = set() - _router_paths: ClassVar[dict[FlowMethodName, list[FlowMethodName]]] = {} + _router_emit: ClassVar[dict[FlowMethodName, list[FlowMethodName]]] = {} + _flow_definition: ClassVar[FlowDefinition | None] = None # === EXPERIMENTAL: conversational mode === # When ``conversational = True`` on a subclass, the built-in conversational @@ -741,6 +735,15 @@ class Flow(_ConversationalMixin, BaseModel, Generic[T], metaclass=FlowMeta): entity_type: Literal["flow"] = "flow" + @classmethod + def flow_definition(cls) -> FlowDefinition: + """Return the static Flow Definition built from this Flow class.""" + flow_definition = cls.__dict__.get("_flow_definition") + if flow_definition is None: + flow_definition = build_flow_definition(cls) + cls._flow_definition = flow_definition + return flow_definition + initial_state: Annotated[ # type: ignore[type-arg] type[BaseModel] | type[dict] | dict[str, Any] | BaseModel | None, BeforeValidator(_deserialize_initial_state), @@ -1466,7 +1469,7 @@ class Flow(_ConversationalMixin, BaseModel, Generic[T], metaclass=FlowMeta): llm = None method = self._methods.get(FlowMethodName(context.method_name)) if method is not None: - live_llm = getattr(method, "_hf_llm", None) + live_llm = getattr(method, "_human_feedback_llm", None) if live_llm is not None: from crewai.llms.base_llm import BaseLLM as BaseLLMClass @@ -2841,7 +2844,7 @@ class Flow(_ConversationalMixin, BaseModel, Generic[T], metaclass=FlowMeta): Returns: True if the condition is satisfied, False otherwise """ - if is_flow_method_name(condition): + if isinstance(condition, str): return condition == trigger_method if is_flow_condition_dict(condition): diff --git a/lib/crewai/src/crewai/flow/types.py b/lib/crewai/src/crewai/flow/types.py index 65ed3a995..46a285bbe 100644 --- a/lib/crewai/src/crewai/flow/types.py +++ b/lib/crewai/src/crewai/flow/types.py @@ -22,7 +22,6 @@ P = ParamSpec("P") R = TypeVar("R", covariant=True) FlowMethodName = NewType("FlowMethodName", str) -FlowRouteName = NewType("FlowRouteName", str) PendingListenerKey = NewType( "PendingListenerKey", Annotated[str, "nested flow conditions use 'listener_name:object_id'"], diff --git a/lib/crewai/src/crewai/flow/utils.py b/lib/crewai/src/crewai/flow/utils.py deleted file mode 100644 index e23354784..000000000 --- a/lib/crewai/src/crewai/flow/utils.py +++ /dev/null @@ -1,53 +0,0 @@ -"""Backwards-compatible shim. The implementation moved to ``crewai.flow.flow_definition``. - -Import from ``crewai.flow.flow_definition`` directly in new code. -""" - -from crewai.flow.flow_definition import ( - _extract_all_methods, - _extract_all_methods_recursive, - _extract_string_literals_from_type_annotation, - _normalize_condition, - _unwrap_function, - build_ancestor_dict, - build_parent_children_dict, - calculate_node_levels, - count_outgoing_edges, - dfs_ancestors, - extract_flow_definition, - get_child_index, - get_possible_return_constants, - is_ancestor, - is_flow_condition_dict, - is_flow_condition_list, - is_flow_method, - is_flow_method_callable, - is_flow_method_name, - is_simple_flow_condition, - process_router_paths, -) - - -__all__ = [ - "_extract_all_methods", - "_extract_all_methods_recursive", - "_extract_string_literals_from_type_annotation", - "_normalize_condition", - "_unwrap_function", - "build_ancestor_dict", - "build_parent_children_dict", - "calculate_node_levels", - "count_outgoing_edges", - "dfs_ancestors", - "extract_flow_definition", - "get_child_index", - "get_possible_return_constants", - "is_ancestor", - "is_flow_condition_dict", - "is_flow_condition_list", - "is_flow_method", - "is_flow_method_callable", - "is_flow_method_name", - "is_simple_flow_condition", - "process_router_paths", -] diff --git a/lib/crewai/src/crewai/flow/visualization/assets/interactive.js b/lib/crewai/src/crewai/flow/visualization/assets/interactive.js index 10788727f..83f8691b7 100644 --- a/lib/crewai/src/crewai/flow/visualization/assets/interactive.js +++ b/lib/crewai/src/crewai/flow/visualization/assets/interactive.js @@ -684,7 +684,7 @@ class TriggeredByHighlighter { }); } else { for (const [nodeName, nodeInfo] of Object.entries(nodeData)) { - if (nodeInfo.router_paths && nodeInfo.router_paths.includes(triggerNodeId)) { + if (nodeInfo.router_events && nodeInfo.router_events.includes(triggerNodeId)) { const routerNode = nodeName; const routerEdges = allEdges.filter( @@ -768,7 +768,7 @@ class TriggeredByHighlighter { this.animateEdgeStyles(); } - highlightAllRouterPaths() { + highlightAllRouterEvents() { this.clear(); if (!this.activeDrawerNodeId) { @@ -792,10 +792,10 @@ class TriggeredByHighlighter { routerEdges.forEach(edge => { pathNodes.add(edge.to); }); - } else if (activeMetadata && activeMetadata.router_paths && activeMetadata.router_paths.length > 0) { - activeMetadata.router_paths.forEach(pathName => { + } else if (activeMetadata && activeMetadata.router_events && activeMetadata.router_events.length > 0) { + activeMetadata.router_events.forEach(eventName => { for (const [nodeName, nodeInfo] of Object.entries(nodeData)) { - if (nodeInfo.router_paths && nodeInfo.router_paths.includes(pathName)) { + if (nodeInfo.router_events && nodeInfo.router_events.includes(eventName)) { const edgeFromRouter = allEdges.filter( (edge) => edge.from === nodeName && edge.to === this.activeDrawerNodeId && edge.dashes ); @@ -821,6 +821,42 @@ class TriggeredByHighlighter { this.animateEdgeStyles(); } + highlightRouterEvent(eventName) { + this.clear(); + + if (this.activeDrawerEdges && this.activeDrawerEdges.length > 0) { + this.resetEdgesToDefault(this.activeDrawerEdges); + this.activeDrawerEdges = []; + } + + if (!this.activeDrawerNodeId || !eventName) { + return; + } + + const routerEdges = this.edges.get().filter( + (edge) => + edge.from === this.activeDrawerNodeId && + edge.dashes && + edge.label === eventName, + ); + + if (routerEdges.length === 0) { + return; + } + + const pathNodes = new Set([this.activeDrawerNodeId]); + routerEdges.forEach((edge) => { + pathNodes.add(edge.from); + pathNodes.add(edge.to); + }); + + this.highlightedNodes = Array.from(pathNodes); + this.highlightedEdges = routerEdges.map((e) => e.id); + + this.animateNodeOpacity(); + this.animateEdgeStyles(); + } + highlightTriggeredBy(triggerNodeId) { this.clear(); @@ -892,8 +928,8 @@ class TriggeredByHighlighter { ) { for (const [nodeName, nodeInfo] of Object.entries(nodeData)) { if ( - nodeInfo.router_paths && - nodeInfo.router_paths.includes(triggerNodeId) + nodeInfo.router_events && + nodeInfo.router_events.includes(triggerNodeId) ) { const routerNode = nodeName; @@ -1501,7 +1537,7 @@ class DrawerManager { const activeMetadata = nodeData[activeNodeId]; if (activeMetadata && activeMetadata.trigger_methods && activeMetadata.trigger_methods.includes(triggerNodeId)) { for (const [nodeName, nodeInfo] of Object.entries(nodeData)) { - if (nodeInfo.router_paths && nodeInfo.router_paths.includes(triggerNodeId)) { + if (nodeInfo.router_events && nodeInfo.router_events.includes(triggerNodeId)) { const routerEdges = allEdges.filter( (edge) => edge.from === nodeName && edge.dashes ); @@ -1660,16 +1696,16 @@ class DrawerManager { `; } - if (metadata.router_paths && metadata.router_paths.length > 0) { - const uniqueRouterPaths = [...new Set(metadata.router_paths)]; - const routerPathsJson = JSON.stringify(uniqueRouterPaths).replace(/"/g, '"'); + if (metadata.router_events && metadata.router_events.length > 0) { + const uniqueRouterEvents = [...new Set(metadata.router_events)]; + const routerEventsJson = JSON.stringify(uniqueRouterEvents).replace(/"/g, '"'); metadataContent += `
-
- Router Paths +
+ Router Events
    - ${uniqueRouterPaths.map((p) => `
  • ${p}
  • `).join("")} + ${uniqueRouterEvents.map((eventName) => `
  • ${eventName}
  • `).join("")}
`; @@ -1823,14 +1859,26 @@ class DrawerManager { }); }); - const routerPathsTitle = this.elements.content.querySelector( - ".router-paths-title[data-router-paths]", + const routerEventLinks = this.elements.content.querySelectorAll( + ".drawer-code-link[data-router-event]", ); - if (routerPathsTitle) { - routerPathsTitle.addEventListener("click", (e) => { + routerEventLinks.forEach((link) => { + link.addEventListener("click", (e) => { e.preventDefault(); e.stopPropagation(); - this.triggeredByHighlighter.highlightAllRouterPaths(); + const routerEvent = link.getAttribute("data-router-event"); + this.triggeredByHighlighter.highlightRouterEvent(routerEvent); + }); + }); + + const routerEventsTitle = this.elements.content.querySelector( + ".router-events-title[data-router-events]", + ); + if (routerEventsTitle) { + routerEventsTitle.addEventListener("click", (e) => { + e.preventDefault(); + e.stopPropagation(); + this.triggeredByHighlighter.highlightAllRouterEvents(); }); } } diff --git a/lib/crewai/src/crewai/flow/visualization/builder.py b/lib/crewai/src/crewai/flow/visualization/builder.py index e277c1bbc..987eaf760 100644 --- a/lib/crewai/src/crewai/flow/visualization/builder.py +++ b/lib/crewai/src/crewai/flow/visualization/builder.py @@ -1,131 +1,118 @@ -"""Flow structure builder for analyzing Flow execution.""" +"""Flow structure builder for definition-only Flow visualization.""" from __future__ import annotations from collections import defaultdict -import inspect import logging -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, cast from crewai.flow.constants import AND_CONDITION, OR_CONDITION -from crewai.flow.flow_wrappers import FlowCondition -from crewai.flow.types import FlowMethodName -from crewai.flow.utils import ( - is_flow_condition_dict, - is_simple_flow_condition, +from crewai.flow.flow_definition import ( + FlowDefinition, + FlowDefinitionCondition, + FlowMethodDefinition, ) -from crewai.flow.visualization.schema import extract_method_signature from crewai.flow.visualization.types import FlowStructure, NodeMetadata, StructureEdge logger = logging.getLogger(__name__) +__all__ = ["build_flow_structure", "calculate_execution_paths"] + if TYPE_CHECKING: from crewai.flow.flow import Flow +def _definition_condition_items( + condition: dict[str, Any], + key: str, +) -> list[FlowDefinitionCondition]: + return cast(list[FlowDefinitionCondition], condition.get(key, [])) + + +def _definition_condition_parts( + condition: dict[str, Any], +) -> tuple[str, list[FlowDefinitionCondition]]: + if "and" in condition: + return AND_CONDITION, _definition_condition_items(condition, "and") + return OR_CONDITION, _definition_condition_items(condition, "or") + + +def _condition_type_from_definition( + condition: FlowDefinitionCondition | None, +) -> str | None: + if isinstance(condition, dict): + if "and" in condition: + return AND_CONDITION + if "or" in condition: + return OR_CONDITION + if isinstance(condition, str): + return OR_CONDITION + return None + + +def _runtime_condition_from_definition( + condition: FlowDefinitionCondition, +) -> str | dict[str, Any]: + if isinstance(condition, str): + return condition + condition_type, conditions = _definition_condition_parts(condition) + return { + "type": condition_type, + "conditions": [_runtime_condition_from_definition(item) for item in conditions], + } + + +def _method_trigger_condition( + method_definition: FlowMethodDefinition, +) -> FlowDefinitionCondition | None: + if method_definition.listen is not None: + return method_definition.listen + if isinstance(method_definition.start, str | dict): + return method_definition.start + return None + + +def _method_router_events(method_definition: FlowMethodDefinition) -> list[str]: + if method_definition.human_feedback and method_definition.human_feedback.emit: + return [str(event) for event in method_definition.human_feedback.emit] + if method_definition.emit: + return [str(event) for event in method_definition.emit] + return [] + + def _extract_direct_or_triggers( - condition: str | dict[str, Any] | list[Any] | FlowCondition, + condition: FlowDefinitionCondition, ) -> list[str]: - """Extract direct OR-level trigger strings from a condition. - - This function extracts strings that would directly trigger a listener, - meaning they appear at the top level of an OR condition. Strings nested - inside AND conditions are NOT considered direct triggers for router paths. - - For example: - - or_("a", "b") -> ["a", "b"] (both are direct triggers) - - and_("a", "b") -> [] (neither are direct triggers, both required) - - or_(and_("a", "b"), "c") -> ["c"] (only "c" is a direct trigger) - - Args: - condition: Can be a string, dict, or list. - - Returns: - List of direct OR-level trigger strings. - """ if isinstance(condition, str): return [condition] - if isinstance(condition, dict): - cond_type = condition.get("type", OR_CONDITION) - conditions_list = condition.get("conditions", []) - - if cond_type == OR_CONDITION: - strings = [] - for sub_cond in conditions_list: - strings.extend(_extract_direct_or_triggers(sub_cond)) - return strings + condition_type, conditions = _definition_condition_parts(condition) + if condition_type == AND_CONDITION: return [] - if isinstance(condition, list): - strings = [] - for item in condition: - strings.extend(_extract_direct_or_triggers(item)) - return strings - if callable(condition) and hasattr(condition, "__name__"): - return [condition.__name__] - return [] + strings: list[str] = [] + for sub_condition in conditions: + strings.extend(_extract_direct_or_triggers(sub_condition)) + return strings def _extract_all_trigger_names( - condition: str | dict[str, Any] | list[Any] | FlowCondition, + condition: FlowDefinitionCondition, ) -> list[str]: - """Extract ALL trigger names from a condition for display purposes. - - Unlike _extract_direct_or_triggers, this extracts ALL strings and method - names from the entire condition tree, including those nested in AND conditions. - This is used for displaying trigger information in the UI. - - For example: - - or_("a", "b") -> ["a", "b"] - - and_("a", "b") -> ["a", "b"] - - or_(and_("a", method_6), method_4) -> ["a", "method_6", "method_4"] - - Args: - condition: Can be a string, dict, or list. - - Returns: - List of all trigger names found in the condition. - """ if isinstance(condition, str): return [condition] - if isinstance(condition, dict): - conditions_list = condition.get("conditions", []) - strings = [] - for sub_cond in conditions_list: - strings.extend(_extract_all_trigger_names(sub_cond)) - return strings - if isinstance(condition, list): - strings = [] - for item in condition: - strings.extend(_extract_all_trigger_names(item)) - return strings - if callable(condition) and hasattr(condition, "__name__"): - return [condition.__name__] - return [] + _, conditions = _definition_condition_parts(condition) + strings: list[str] = [] + for sub_condition in conditions: + strings.extend(_extract_all_trigger_names(sub_condition)) + return strings def _create_edges_from_condition( - condition: str | dict[str, Any] | list[Any] | FlowCondition, + condition: FlowDefinitionCondition, target: str, nodes: dict[str, NodeMetadata], ) -> list[StructureEdge]: - """Create edges from a condition tree, preserving AND/OR semantics. - - This function recursively processes the condition tree and creates edges - with the appropriate condition_type for each trigger. - - For AND conditions, all triggers get edges with condition_type="AND". - For OR conditions, triggers get edges with condition_type="OR". - - Args: - condition: The condition tree (string, dict, or list). - target: The target node name. - nodes: Dictionary of all nodes for validation. - - Returns: - List of StructureEdge objects representing the condition. - """ edges: list[StructureEdge] = [] if isinstance(condition, str): @@ -135,24 +122,11 @@ def _create_edges_from_condition( source=condition, target=target, condition_type=OR_CONDITION, - is_router_path=False, - ) - ) - elif callable(condition) and hasattr(condition, "__name__"): - method_name = condition.__name__ - if method_name in nodes: - edges.append( - StructureEdge( - source=method_name, - target=target, - condition_type=OR_CONDITION, - is_router_path=False, + is_router_event=False, ) ) elif isinstance(condition, dict): - cond_type = condition.get("type", OR_CONDITION) - conditions_list = condition.get("conditions", []) - + cond_type, conditions = _definition_condition_parts(condition) if cond_type == AND_CONDITION: triggers = _extract_all_trigger_names(condition) edges.extend( @@ -160,277 +134,144 @@ def _create_edges_from_condition( source=trigger, target=target, condition_type=AND_CONDITION, - is_router_path=False, + is_router_event=False, ) for trigger in triggers if trigger in nodes ) else: - for sub_cond in conditions_list: - edges.extend(_create_edges_from_condition(sub_cond, target, nodes)) - elif isinstance(condition, list): - for item in condition: - edges.extend(_create_edges_from_condition(item, target, nodes)) + for sub_condition in conditions: + edges.extend(_create_edges_from_condition(sub_condition, target, nodes)) return edges -def build_flow_structure(flow: Flow[Any]) -> FlowStructure: - """Build a structure representation of a Flow's execution. +def _flow_definition_from( + flow_or_definition: Flow[Any] | type[Flow[Any]] | FlowDefinition, +) -> FlowDefinition: + if isinstance(flow_or_definition, FlowDefinition): + return flow_or_definition - Args: - flow: Flow instance to analyze. + flow_class = ( + flow_or_definition + if isinstance(flow_or_definition, type) + else type(flow_or_definition) + ) + flow_definition = getattr(flow_class, "flow_definition", None) + if callable(flow_definition): + return cast(FlowDefinition, flow_definition()) + raise TypeError( + "build_flow_structure requires a FlowDefinition or a Flow class/instance " + "with flow_definition()." + ) - Returns: - Dictionary with nodes, edges, start_methods, and router_methods. - """ + +def build_flow_structure( + flow_or_definition: Flow[Any] | type[Flow[Any]] | FlowDefinition, +) -> FlowStructure: + """Build a visualization structure projection from a FlowDefinition.""" + definition = _flow_definition_from(flow_or_definition) nodes: dict[str, NodeMetadata] = {} edges: list[StructureEdge] = [] start_methods: list[str] = [] router_methods: list[str] = [] - for method_name, method in flow._methods.items(): - node_metadata: NodeMetadata = {"type": "listen"} + for method_name, method_definition in definition.methods.items(): + node_metadata: NodeMetadata = {"type": "listen", "class_name": definition.name} - if hasattr(method, "__is_start_method__") and method.__is_start_method__: + if method_definition.is_start: node_metadata["type"] = "start" start_methods.append(method_name) - if hasattr(method, "__is_router__") and method.__is_router__: + if method_definition.router: node_metadata["is_router"] = True node_metadata["type"] = "router" router_methods.append(method_name) + router_events = _method_router_events(method_definition) + if router_events: + node_metadata["router_events"] = router_events - if method_name in flow._router_paths: - node_metadata["router_paths"] = [ - str(p) for p in flow._router_paths[method_name] - ] - - if hasattr(method, "__trigger_methods__") and method.__trigger_methods__: - node_metadata["trigger_methods"] = [ - str(m) for m in method.__trigger_methods__ - ] - - if hasattr(method, "__condition_type__") and method.__condition_type__: - node_metadata["trigger_condition_type"] = method.__condition_type__ - if "condition_type" not in node_metadata: - node_metadata["condition_type"] = method.__condition_type__ + trigger_condition = _method_trigger_condition(method_definition) + condition_type = _condition_type_from_definition(trigger_condition) + if condition_type is not None and trigger_condition is not None: + node_metadata["trigger_condition_type"] = condition_type + node_metadata["condition_type"] = condition_type + extracted = _extract_all_trigger_names(trigger_condition) + if extracted: + node_metadata["trigger_methods"] = extracted + runtime_condition = _runtime_condition_from_definition(trigger_condition) + if isinstance(runtime_condition, dict): + node_metadata["trigger_condition"] = runtime_condition if node_metadata.get("is_router") and "condition_type" not in node_metadata: node_metadata["condition_type"] = "IF" - if ( - hasattr(method, "__trigger_condition__") - and method.__trigger_condition__ is not None - ): - node_metadata["trigger_condition"] = method.__trigger_condition__ - - if "trigger_methods" not in node_metadata: - extracted = _extract_all_trigger_names(method.__trigger_condition__) - if extracted: - node_metadata["trigger_methods"] = extracted - - node_metadata["method_signature"] = extract_method_signature( - method, method_name - ) - - try: - source_code = inspect.getsource(method) - node_metadata["source_code"] = source_code - - try: - source_lines, start_line = inspect.getsourcelines(method) - node_metadata["source_lines"] = source_lines - node_metadata["source_start_line"] = start_line - except (OSError, TypeError): - pass - - try: - source_file = inspect.getsourcefile(method) - if source_file: - node_metadata["source_file"] = source_file - except (OSError, TypeError): - try: - class_file = inspect.getsourcefile(flow.__class__) - if class_file: - node_metadata["source_file"] = class_file - except (OSError, TypeError): - pass - except (OSError, TypeError): - pass - - try: - class_obj = flow.__class__ - - if class_obj: - class_name = class_obj.__name__ - - bases = class_obj.__bases__ - if bases: - base_strs = [] - for base in bases: - if hasattr(base, "__name__"): - if hasattr(base, "__origin__"): - base_strs.append(str(base)) - else: - base_strs.append(base.__name__) - else: - base_strs.append(str(base)) - - try: - source_lines = inspect.getsource(class_obj).split("\n") - _, class_start_line = inspect.getsourcelines(class_obj) - - for idx, line in enumerate(source_lines): - stripped = line.strip() - if stripped.startswith("class ") and class_name in stripped: - class_signature = stripped.rstrip(":") - node_metadata["class_signature"] = class_signature - node_metadata["class_line_number"] = ( - class_start_line + idx - ) - break - except (OSError, TypeError): - class_signature = f"class {class_name}({', '.join(base_strs)})" - node_metadata["class_signature"] = class_signature - else: - class_signature = f"class {class_name}" - node_metadata["class_signature"] = class_signature - - node_metadata["class_name"] = class_name - except (OSError, TypeError, AttributeError): - pass - nodes[method_name] = node_metadata - for listener_name, condition_data in flow._listeners.items(): - if listener_name in router_methods: + for method_name, method_definition in definition.methods.items(): + trigger_condition = _method_trigger_condition(method_definition) + if trigger_condition is None: continue - - if is_simple_flow_condition(condition_data): - cond_type, methods = condition_data - edges.extend( - StructureEdge( - source=str(trigger_method), - target=str(listener_name), - condition_type=cond_type, - is_router_path=False, - ) - for trigger_method in methods - if str(trigger_method) in nodes - ) - elif is_flow_condition_dict(condition_data): - edges.extend( - _create_edges_from_condition(condition_data, str(listener_name), nodes) - ) - - for method_name, node_metadata in nodes.items(): # type: ignore[assignment] - if node_metadata.get("is_router") and "trigger_methods" in node_metadata: - trigger_methods = node_metadata["trigger_methods"] - condition_type = node_metadata.get("trigger_condition_type", OR_CONDITION) - - if "trigger_condition" in node_metadata: - edges.extend( - _create_edges_from_condition( - node_metadata["trigger_condition"], # type: ignore[arg-type] - method_name, - nodes, - ) - ) - else: - edges.extend( - StructureEdge( - source=trigger_method, - target=method_name, - condition_type=condition_type, - is_router_path=False, - ) - for trigger_method in trigger_methods - if trigger_method in nodes - ) + edges.extend( + _create_edges_from_condition(trigger_condition, method_name, nodes) + ) all_string_triggers: set[str] = set() - for condition_data in flow._listeners.values(): - if is_simple_flow_condition(condition_data): - _, methods = condition_data - for m in methods: - if str(m) not in nodes: # It's a string trigger, not a method name - all_string_triggers.add(str(m)) - elif is_flow_condition_dict(condition_data): - for trigger in _extract_direct_or_triggers(condition_data): - if trigger not in nodes: - all_string_triggers.add(trigger) + for method_definition in definition.methods.values(): + trigger_condition = _method_trigger_condition(method_definition) + if trigger_condition is None: + continue + for trigger in _extract_direct_or_triggers(trigger_condition): + if trigger not in nodes: + all_string_triggers.add(trigger) - all_router_outputs: set[str] = set() + all_router_events: set[str] = set() for router_method_name in router_methods: - if router_method_name not in flow._router_paths: - flow._router_paths[FlowMethodName(router_method_name)] = [] + router_events = _method_router_events(definition.methods[router_method_name]) + if router_events and router_method_name in nodes: + nodes[router_method_name]["router_events"] = router_events + all_router_events.update(router_events) - current_paths = flow._router_paths.get(FlowMethodName(router_method_name), []) - if current_paths and router_method_name in nodes: - nodes[router_method_name]["router_paths"] = [str(p) for p in current_paths] - all_router_outputs.update(str(p) for p in current_paths) - - if not current_paths: + if not router_events: logger.warning( - f"Could not determine return paths for router '{router_method_name}'. " - f"Add a return type annotation like " - f"'-> Literal[\"path1\", \"path2\"]' or '-> YourEnum' " - f"to enable proper flow visualization." + f"Router events for '{router_method_name}' are dynamic or not " + f"statically inferable; static visualization may omit event edges." ) - orphaned_triggers = all_string_triggers - all_router_outputs + orphaned_triggers = all_string_triggers - all_router_events if orphaned_triggers: - logger.error( - f"Found listeners waiting for triggers {orphaned_triggers} " - f"but no router outputs these values explicitly. " - f"If your router returns a non-static value, check that your router has proper return type annotations." + logger.warning( + f"Static visualization could not match listener triggers " + f"{orphaned_triggers} to explicit router events. " + f"Dynamic router values may still trigger these listeners at runtime." ) for router_method_name in router_methods: - if router_method_name not in flow._router_paths: - continue + router_events = _method_router_events(definition.methods[router_method_name]) - router_paths = flow._router_paths[FlowMethodName(router_method_name)] - - for path in router_paths: - for listener_name, condition_data in flow._listeners.items(): + for event in router_events: + for listener_name, method_definition in definition.methods.items(): if listener_name == router_method_name: continue - trigger_strings_from_cond: list[str] = [] + trigger_condition = _method_trigger_condition(method_definition) + if trigger_condition is None: + continue + trigger_strings_from_cond = _extract_direct_or_triggers( + trigger_condition + ) - if is_simple_flow_condition(condition_data): - _, methods = condition_data - trigger_strings_from_cond = [str(m) for m in methods] - elif is_flow_condition_dict(condition_data): - trigger_strings_from_cond = _extract_direct_or_triggers( - condition_data - ) - - if str(path) in trigger_strings_from_cond: + if str(event) in trigger_strings_from_cond: edges.append( StructureEdge( source=router_method_name, - target=str(listener_name), + target=listener_name, condition_type=None, - is_router_path=True, - router_path_label=str(path), + is_router_event=True, + router_event=str(event), ) ) - for start_method in flow._start_methods: - if start_method not in nodes and start_method in flow._methods: - method = flow._methods[start_method] - nodes[str(start_method)] = NodeMetadata(type="start") - - if hasattr(method, "__trigger_methods__") and method.__trigger_methods__: - nodes[str(start_method)]["trigger_methods"] = [ - str(m) for m in method.__trigger_methods__ - ] - if hasattr(method, "__condition_type__") and method.__condition_type__: - nodes[str(start_method)]["condition_type"] = method.__condition_type__ - return FlowStructure( nodes=nodes, edges=edges, @@ -453,7 +294,7 @@ def calculate_execution_paths(structure: FlowStructure) -> int: graph[edge["source"]].append( { "target": edge["target"], - "is_router": edge["is_router_path"], + "is_router": edge["is_router_event"], "condition": edge["condition_type"], } ) @@ -466,15 +307,6 @@ def calculate_execution_paths(structure: FlowStructure) -> int: return 0 def count_paths_from(node: str, visited: set[str]) -> int: - """Recursively count execution paths from a given node. - - Args: - node: Node name to start counting from. - visited: Set of already visited nodes to prevent cycles. - - Returns: - Number of execution paths from this node to terminal nodes. - """ if node in terminal_nodes: return 1 diff --git a/lib/crewai/src/crewai/flow/visualization/renderers/interactive.py b/lib/crewai/src/crewai/flow/visualization/renderers/interactive.py index 88242bea6..0ad8943f1 100644 --- a/lib/crewai/src/crewai/flow/visualization/renderers/interactive.py +++ b/lib/crewai/src/crewai/flow/visualization/renderers/interactive.py @@ -309,18 +309,18 @@ def render_interactive(
""") - if metadata.get("router_paths"): - paths = metadata["router_paths"] - paths_items = "".join( + if metadata.get("router_events"): + router_events = metadata["router_events"] + event_items = "".join( [ f'
  • {p}
  • ' - for p in paths + for p in router_events ] ) title_parts.append(f"""
    -
    Router Paths
    -
      {paths_items}
    +
    Router Events
    +
      {event_items}
    """) @@ -364,11 +364,11 @@ def render_interactive( edge_color: str = GRAY edge_dashes: bool | list[int] = False - if edge["is_router_path"]: + if edge["is_router_event"]: edge_color = CREWAI_ORANGE edge_dashes = [15, 10] - if "router_path_label" in edge: - edge_label = edge["router_path_label"] + if "router_event" in edge: + edge_label = edge["router_event"] or "" elif edge["condition_type"] == "AND": edge_label = "AND" edge_color = CREWAI_ORANGE diff --git a/lib/crewai/src/crewai/flow/visualization/schema.py b/lib/crewai/src/crewai/flow/visualization/schema.py deleted file mode 100644 index fe0de7fd1..000000000 --- a/lib/crewai/src/crewai/flow/visualization/schema.py +++ /dev/null @@ -1,104 +0,0 @@ -"""OpenAPI schema conversion utilities for Flow methods.""" - -import inspect -from typing import Any, get_args, get_origin - - -def type_to_openapi_schema(type_hint: Any) -> dict[str, Any]: - """Convert Python type hint to OpenAPI schema. - - Args: - type_hint: Python type hint to convert. - - Returns: - OpenAPI schema dictionary. - """ - if type_hint is inspect.Parameter.empty: - return {} - - if type_hint is None or type_hint is type(None): - return {"type": "null"} - - if hasattr(type_hint, "__module__") and hasattr(type_hint, "__name__"): - if type_hint.__module__ == "typing" and type_hint.__name__ == "Any": - return {} - - type_str = str(type_hint) - if type_str == "typing.Any" or type_str == "": - return {} - - if isinstance(type_hint, str): - return {"type": type_hint} - - origin = get_origin(type_hint) - args = get_args(type_hint) - - if type_hint is str: - return {"type": "string"} - if type_hint is int: - return {"type": "integer"} - if type_hint is float: - return {"type": "number"} - if type_hint is bool: - return {"type": "boolean"} - if type_hint is dict or origin is dict: - if args and len(args) > 1: - return { - "type": "object", - "additionalProperties": type_to_openapi_schema(args[1]), - } - return {"type": "object"} - if type_hint is list or origin is list: - if args: - return {"type": "array", "items": type_to_openapi_schema(args[0])} - return {"type": "array"} - if hasattr(type_hint, "__name__"): - return {"type": "object", "className": type_hint.__name__} - - return {} - - -def extract_method_signature(method: Any, method_name: str) -> dict[str, Any]: - """Extract method signature as OpenAPI schema with documentation. - - Args: - method: Method to analyze. - method_name: Method name. - - Returns: - Dictionary with operationId, parameters, returns, summary, and description. - """ - try: - sig = inspect.signature(method) - - parameters = {} - for param_name, param in sig.parameters.items(): - if param_name == "self": - continue - parameters[param_name] = type_to_openapi_schema(param.annotation) - - return_type = type_to_openapi_schema(sig.return_annotation) - - docstring = inspect.getdoc(method) - - result: dict[str, Any] = { - "operationId": method_name, - "parameters": parameters, - "returns": return_type, - } - - if docstring: - lines = docstring.strip().split("\n") - summary = lines[0].strip() - - if summary: - result["summary"] = summary - - if len(lines) > 1: - description = "\n".join(line.strip() for line in lines[1:]).strip() - if description: - result["description"] = description - - return result - except Exception: - return {"operationId": method_name, "parameters": {}, "returns": {}} diff --git a/lib/crewai/src/crewai/flow/visualization/types.py b/lib/crewai/src/crewai/flow/visualization/types.py index 6ce57069e..6fe01589a 100644 --- a/lib/crewai/src/crewai/flow/visualization/types.py +++ b/lib/crewai/src/crewai/flow/visualization/types.py @@ -1,6 +1,11 @@ """Type definitions for Flow structure visualization.""" -from typing import Any, TypedDict +from typing import Any + +from typing_extensions import Required, TypedDict + + +__all__ = ["FlowStructure", "NodeMetadata", "StructureEdge"] class NodeMetadata(TypedDict, total=False): @@ -8,19 +13,12 @@ class NodeMetadata(TypedDict, total=False): type: str is_router: bool - router_paths: list[str] + router_events: list[str] condition_type: str | None trigger_condition_type: str | None trigger_methods: list[str] trigger_condition: dict[str, Any] | None - method_signature: dict[str, Any] - source_code: str - source_lines: list[str] - source_start_line: int - source_file: str - class_signature: str class_name: str - class_line_number: int class StructureEdge(TypedDict, total=False): @@ -29,8 +27,8 @@ class StructureEdge(TypedDict, total=False): source: str target: str condition_type: str | None - is_router_path: bool - router_path_label: str + is_router_event: Required[bool] + router_event: str | None class FlowStructure(TypedDict): diff --git a/lib/crewai/tests/test_async_human_feedback.py b/lib/crewai/tests/test_async_human_feedback.py index fbd047ccf..19b682405 100644 --- a/lib/crewai/tests/test_async_human_feedback.py +++ b/lib/crewai/tests/test_async_human_feedback.py @@ -1012,7 +1012,7 @@ class TestLLMObjectPreservedInContext: call_kwargs = mock_collapse.call_args assert call_kwargs.kwargs["feedback"] == "this looks good, proceed!" assert call_kwargs.kwargs["outcomes"] == ["needs_changes", "approved"] - # LLM should be a live object (from _hf_llm) or reconstructed, not None + # LLM should be a live object (from _human_feedback_llm) or reconstructed, not None assert call_kwargs.kwargs["llm"] is not None assert getattr(call_kwargs.kwargs["llm"], "model", None) == "gemini-2.0-flash" assert flow2.last_human_feedback.outcome == "approved" @@ -1171,8 +1171,8 @@ class TestAsyncHumanFeedbackEdgeCases: class TestLiveLLMPreservationOnResume: """Tests for preserving the full LLM config across HITL resume.""" - def test_hf_llm_attribute_set_on_wrapper_with_basellm(self) -> None: - """Test that _hf_llm is set on the wrapper when llm is a BaseLLM instance.""" + def test_human_feedback_llm_attribute_set_on_wrapper_with_basellm(self) -> None: + """Test that _human_feedback_llm is set on the wrapper when llm is a BaseLLM instance.""" from crewai.llms.base_llm import BaseLLM mock_llm = MagicMock(spec=BaseLLM) @@ -1191,11 +1191,11 @@ class TestLiveLLMPreservationOnResume: flow = TestFlow() method = flow._methods.get("review") assert method is not None - assert hasattr(method, "_hf_llm") - assert method._hf_llm is mock_llm + assert hasattr(method, "_human_feedback_llm") + assert method._human_feedback_llm is mock_llm - def test_hf_llm_attribute_set_on_wrapper_with_string(self) -> None: - """Test that _hf_llm is set on the wrapper even when llm is a string.""" + def test_human_feedback_llm_attribute_set_on_wrapper_with_string(self) -> None: + """Test that _human_feedback_llm is set on the wrapper even when llm is a string.""" class TestFlow(Flow): @start() @@ -1210,8 +1210,8 @@ class TestLiveLLMPreservationOnResume: flow = TestFlow() method = flow._methods.get("review") assert method is not None - assert hasattr(method, "_hf_llm") - assert method._hf_llm == "gpt-4o-mini" + assert hasattr(method, "_human_feedback_llm") + assert method._human_feedback_llm == "gpt-4o-mini" @patch("crewai.flow.runtime.crewai_event_bus.emit") def test_resume_async_uses_live_basellm_over_serialized_string( @@ -1277,20 +1277,20 @@ class TestLiveLLMPreservationOnResume: flow.resume("looks good!") # NOT the serialized string. The live_llm was captured at class definition - # time and stored on the method wrapper as _hf_llm. + # time and stored on the method wrapper as _human_feedback_llm. assert len(captured_llm) == 1 - # (which is stored on the method's _hf_llm attribute) + # (which is stored on the method's _human_feedback_llm attribute) method = flow._methods.get("review") assert method is not None - assert captured_llm[0] is method._hf_llm + assert captured_llm[0] is method._human_feedback_llm # And verify it's a BaseLLM instance, not a string assert isinstance(captured_llm[0], BaseLLM) @patch("crewai.flow.runtime.crewai_event_bus.emit") - def test_resume_async_falls_back_to_serialized_string_when_no_hf_llm( + def test_resume_async_falls_back_to_serialized_string_when_no_human_feedback_llm( self, mock_emit: MagicMock ) -> None: - """Test that resume_async falls back to context.llm when _hf_llm is not available. + """Test that resume_async falls back to context.llm when _human_feedback_llm is not available. This ensures backward compatibility with flows that were paused before this fix. """ @@ -1325,10 +1325,10 @@ class TestLiveLLMPreservationOnResume: flow = TestFlow.from_pending("fallback-test", persistence) - # Remove _hf_llm to simulate old decorator without this attribute + # Remove _human_feedback_llm to simulate old decorator without this attribute method = flow._methods.get("review") - if hasattr(method, "_hf_llm"): - delattr(method, "_hf_llm") + if hasattr(method, "_human_feedback_llm"): + delattr(method, "_human_feedback_llm") captured_llm = [] @@ -1345,10 +1345,10 @@ class TestLiveLLMPreservationOnResume: assert captured_llm[0].model == "gpt-4o-mini" @patch("crewai.flow.runtime.crewai_event_bus.emit") - def test_resume_async_uses_string_from_context_when_hf_llm_is_string( + def test_resume_async_uses_string_from_context_when_human_feedback_llm_is_string( self, mock_emit: MagicMock ) -> None: - """Test that when _hf_llm is a string (not BaseLLM), we still use context.llm. + """Test that when _human_feedback_llm is a string (not BaseLLM), we still use context.llm. String LLM values offer no benefit over the serialized context.llm, so we don't prefer them. @@ -1385,7 +1385,7 @@ class TestLiveLLMPreservationOnResume: flow = TestFlow.from_pending("string-llm-test", persistence) method = flow._methods.get("review") - assert method._hf_llm == "gpt-4o-mini" + assert method._human_feedback_llm == "gpt-4o-mini" captured_llm = [] @@ -1396,14 +1396,14 @@ class TestLiveLLMPreservationOnResume: with patch.object(flow, "_collapse_to_outcome", side_effect=capture_llm): flow.resume("looks good!") - # _hf_llm is a string, so resume deserializes context.llm into an LLM instance + # _human_feedback_llm is a string, so resume deserializes context.llm into an LLM instance assert len(captured_llm) == 1 from crewai.llms.base_llm import BaseLLM as BaseLLMClass assert isinstance(captured_llm[0], BaseLLMClass) assert captured_llm[0].model == "gpt-4o-mini" - def test_hf_llm_set_for_async_wrapper(self) -> None: - """Test that _hf_llm is set on async wrapper functions.""" + def test_human_feedback_llm_set_for_async_wrapper(self) -> None: + """Test that _human_feedback_llm is set on async wrapper functions.""" import asyncio from crewai.llms.base_llm import BaseLLM @@ -1423,5 +1423,5 @@ class TestLiveLLMPreservationOnResume: flow = TestFlow() method = flow._methods.get("async_review") assert method is not None - assert hasattr(method, "_hf_llm") - assert method._hf_llm is mock_llm + assert hasattr(method, "_human_feedback_llm") + assert method._human_feedback_llm is mock_llm diff --git a/lib/crewai/tests/test_flow.py b/lib/crewai/tests/test_flow.py index bc9a4ab87..e5eaade21 100644 --- a/lib/crewai/tests/test_flow.py +++ b/lib/crewai/tests/test_flow.py @@ -1160,9 +1160,9 @@ def test_router_cascade_chain(): @router(process_level_1) def router_level_2(self): execution_order.append("router_level_2") - return "level_2_path" + return "level_2_event" - @listen("level_2_path") + @listen("level_2_event") def process_level_2(self): execution_order.append("process_level_2") self.state["level"] = 3 @@ -1171,9 +1171,9 @@ def test_router_cascade_chain(): @router(process_level_2) def router_level_3(self): execution_order.append("router_level_3") - return "final_path" + return "final_event" - @listen("final_path") + @listen("final_event") def finalize(self): execution_order.append("finalize") return "complete" @@ -1261,14 +1261,14 @@ def test_complex_and_or_branching(): assert execution_order.index("final") > execution_order.index("branch_2b") -def test_conditional_router_paths_exclusivity(): - """Test that only the returned router path activates, not all paths.""" +def test_conditional_router_events_exclusivity(): + """Test that only the returned router event activates, not all events.""" execution_order = [] class ConditionalRouterFlow(Flow): def __init__(self): super().__init__() - self.state["condition"] = "take_path_b" + self.state["condition"] = "take_event_b" @start() def begin(self): @@ -1277,33 +1277,33 @@ def test_conditional_router_paths_exclusivity(): @router(begin) def decision_point(self): execution_order.append("decision_point") - if self.state["condition"] == "take_path_a": - return "path_a" - elif self.state["condition"] == "take_path_b": - return "path_b" + if self.state["condition"] == "take_event_a": + return "event_a" + elif self.state["condition"] == "take_event_b": + return "event_b" else: - return "path_c" + return "event_c" - @listen("path_a") - def handle_path_a(self): - execution_order.append("handle_path_a") + @listen("event_a") + def handle_event_a(self): + execution_order.append("handle_event_a") - @listen("path_b") - def handle_path_b(self): - execution_order.append("handle_path_b") + @listen("event_b") + def handle_event_b(self): + execution_order.append("handle_event_b") - @listen("path_c") - def handle_path_c(self): - execution_order.append("handle_path_c") + @listen("event_c") + def handle_event_c(self): + execution_order.append("handle_event_c") flow = ConditionalRouterFlow() flow.kickoff() assert "begin" in execution_order assert "decision_point" in execution_order - assert "handle_path_b" in execution_order - assert "handle_path_a" not in execution_order - assert "handle_path_c" not in execution_order + assert "handle_event_b" in execution_order + assert "handle_event_a" not in execution_order + assert "handle_event_c" not in execution_order def test_state_consistency_across_parallel_branches(): diff --git a/lib/crewai/tests/test_flow_definition.py b/lib/crewai/tests/test_flow_definition.py new file mode 100644 index 000000000..a02036f66 --- /dev/null +++ b/lib/crewai/tests/test_flow_definition.py @@ -0,0 +1,839 @@ +"""Tests for the static Flow Definition contract.""" + +import ast +from enum import Enum +import importlib +import inspect +import logging +from pathlib import Path +from typing import Annotated, Literal + +from pydantic import BaseModel + +import crewai.flow.dsl as flow_dsl +import crewai.flow.flow_definition as flow_definition +import crewai.flow.visualization.builder as visualization_builder +from crewai.flow import Flow, and_, human_feedback, listen, or_, persist, router, start + + +def test_flow_public_exports_are_explicit(): + import crewai.flow.visualization as flow_visualization + + flow_package = importlib.import_module("crewai.flow") + + assert "FlowDefinition" not in flow_package.__all__ + assert "FlowDefinitionDiagnostic" not in flow_package.__all__ + assert "build_flow_definition" not in flow_package.__all__ + assert "flow_structure" not in flow_package.__all__ + assert set(flow_dsl.__all__) == {"and_", "listen", "or_", "router", "start"} + assert set(flow_definition.__all__) == { + "FlowConfigDefinition", + "FlowDefinition", + "FlowDefinitionCondition", + "FlowDefinitionDiagnostic", + "FlowHumanFeedbackDefinition", + "FlowMethodDefinition", + "FlowPersistenceDefinition", + "FlowStateDefinition", + } + assert "build_flow_structure" in flow_visualization.__all__ + assert "calculate_node_levels" not in flow_visualization.__all__ + + +def test_private_flow_helpers_do_not_have_docstrings(): + import crewai.flow.flow_wrappers as flow_wrappers + import crewai.flow.human_feedback as human_feedback + import crewai.flow.persistence.decorators as persistence_decorators + import crewai.flow.visualization.types as visualization_types + + modules = [ + flow_dsl, + flow_definition, + flow_wrappers, + human_feedback, + persistence_decorators, + visualization_builder, + visualization_types, + ] + violations: list[str] = [] + + for module in modules: + source_path = Path(inspect.getsourcefile(module) or "") + tree = ast.parse(source_path.read_text()) + stack: list[ast.AST] = [] + if getattr(module, "__all__", None) == [] and ast.get_docstring(tree): + violations.append(f"{source_path}:1:") + + class PrivateDocstringVisitor(ast.NodeVisitor): + def visit_ClassDef(self, node: ast.ClassDef) -> None: + self._check_docstring(node) + stack.append(node) + self.generic_visit(node) + stack.pop() + + def visit_FunctionDef(self, node: ast.FunctionDef) -> None: + self._check_docstring(node) + stack.append(node) + self.generic_visit(node) + stack.pop() + + def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None: + self._check_docstring(node) + stack.append(node) + self.generic_visit(node) + stack.pop() + + def _check_docstring( + self, + node: ast.ClassDef | ast.FunctionDef | ast.AsyncFunctionDef, + ) -> None: + is_dunder = node.name.startswith("__") and node.name.endswith("__") + is_private_name = node.name.startswith("_") and not is_dunder + is_nested_function = any( + isinstance(parent, (ast.FunctionDef, ast.AsyncFunctionDef)) + for parent in stack + ) + if (is_private_name or is_nested_function) and ast.get_docstring(node): + violations.append(f"{source_path}:{node.lineno}:{node.name}") + + PrivateDocstringVisitor().visit(tree) + + assert violations == [] + + +def test_flow_definition_contract_is_dsl_agnostic(): + source_path = Path(inspect.getsourcefile(flow_definition) or "") + source = source_path.read_text() + + assert "DSL" not in source + assert "flow_wrappers" not in source + assert "build_flow_definition" not in source + assert "extract_flow_definition" not in source + + +def test_flow_definition_maps_dsl_to_static_contract(): + class ContractState(BaseModel): + topic: str = "" + + class ContractFlow(Flow[ContractState]): + """A flow with every core DSL role.""" + + initial_state = ContractState + stream = True + max_method_calls = 7 + + @start() + def begin(self): + return "started" + + @listen(begin) + def process(self): + return "processed" + + @router(process) + def decide(self): + return "approved" + + @listen(or_("approved", "revise")) + @human_feedback( + message="Review this output.", + emit=["done", "revise"], + llm="gpt-4o-mini", + default_outcome="done", + metadata={"team": "qa"}, + learn=True, + learn_source="hitl", + learn_strict=True, + ) + def review(self): + return "review" + + @listen(and_(begin, process)) + def audit(self): + return "audit" + + definition = ContractFlow.flow_definition() + + assert definition.schema_ == "crewai.flow/v1" + assert definition.name == "ContractFlow" + assert definition.description == "A flow with every core DSL role." + assert definition.state is not None + assert definition.state.type == "pydantic" + assert definition.state.ref and "ContractState" in definition.state.ref + assert definition.config.stream is True + assert definition.config.max_method_calls == 7 + + assert definition.methods["begin"].start is True + assert definition.methods["process"].listen == "begin" + + decide = definition.methods["decide"] + assert decide.listen == "process" + assert decide.router is True + assert decide.emit is None + + review = definition.methods["review"] + assert review.listen == {"or": ["approved", "revise"]} + assert review.router is True + assert review.emit is None + assert review.human_feedback is not None + assert review.human_feedback.emit == ["done", "revise"] + assert review.human_feedback.default_outcome == "done" + assert review.human_feedback.metadata == {"team": "qa"} + assert review.human_feedback.learn is True + assert review.human_feedback.learn_strict is True + + assert definition.methods["audit"].listen == {"and": ["begin", "process"]} + assert definition.diagnostics == [] + + +def test_flow_definition_excludes_conversational_builtins_for_regular_flows(): + class RegularFlow(Flow): + @start() + def begin(self): + return "begin" + + methods = RegularFlow.flow_definition().methods + + assert set(methods) == {"begin"} + assert "conversation_start" not in methods + assert "route_conversation" not in methods + assert "converse_turn" not in methods + + +def test_flow_definition_includes_conversational_builtins_when_enabled(): + class ChatFlow(Flow): + conversational = True + + methods = ChatFlow.flow_definition().methods + + assert "conversation_start" in methods + assert "route_conversation" in methods + assert "converse_turn" in methods + assert methods["conversation_start"].start is True + + +def test_flow_definition_serializes_human_feedback_metadata(): + marker = object() + + class MetadataFlow(Flow): + @start() + def begin(self): + return "started" + + @listen(begin) + @human_feedback(message="Review this output.", metadata={"marker": marker}) + def review(self): + return "review" + + definition = MetadataFlow.flow_definition() + review = definition.methods["review"] + + assert review.human_feedback is not None + assert review.human_feedback.metadata == {"ref": "builtins:dict"} + assert any( + diagnostic.code == "non_serializable_value" + and diagnostic.path == "methods.review.human_feedback.metadata" + for diagnostic in definition.diagnostics + ) + definition.to_json() + + +def test_flow_definition_fragments_cover_start_listen_and_condition_sugar(): + class FragmentFlow(Flow): + @start() + def begin(self): + return "begin" + + @start("restart_event") + def restart(self): + return "restart" + + @listen(begin) + def by_callable(self): + return "callable" + + @listen("manual_event") + def by_string(self): + return "string" + + @listen(and_(begin, by_callable)) + def by_and(self): + return "and" + + @listen(or_(and_("manual_event", by_string), "fallback_event")) + def nested(self): + return "nested" + + definition = FragmentFlow.flow_definition() + + assert definition.methods["begin"].start is True + assert definition.methods["restart"].start == "restart_event" + assert definition.methods["by_callable"].listen == "begin" + assert definition.methods["by_string"].listen == "manual_event" + assert definition.methods["by_and"].listen == {"and": ["begin", "by_callable"]} + assert definition.methods["nested"].listen == { + "or": [{"and": ["manual_event", "by_string"]}, "fallback_event"] + } + + assert set(FragmentFlow._start_methods) == {"begin", "restart"} + assert FragmentFlow._listeners["restart"] == ("OR", ["restart_event"]) + assert FragmentFlow._listeners["by_callable"] == ("OR", ["begin"]) + assert FragmentFlow._listeners["by_string"] == ("OR", ["manual_event"]) + assert FragmentFlow._listeners["by_and"] == { + "type": "AND", + "conditions": ["begin", "by_callable"], + } + assert FragmentFlow._listeners["nested"] == { + "type": "OR", + "conditions": [ + {"type": "AND", "conditions": ["manual_event", "by_string"]}, + "fallback_event", + ], + } + + +def test_extract_flow_definition_prefers_fragments_over_legacy_metadata(): + class RegistryFlow(Flow): + @start() + def begin(self): + return "begin" + + @listen(begin) + def handle(self): + return "handle" + + @router(handle, emit=["done"]) + def decide(self): + return "done" + + handle = RegistryFlow.__dict__["handle"] + original_trigger_methods = handle.__trigger_methods__ + handle.__trigger_methods__ = ["wrong"] + try: + _, listeners, routers, router_emit = flow_dsl.extract_flow_definition( + { + "begin": RegistryFlow.__dict__["begin"], + "handle": handle, + "decide": RegistryFlow.__dict__["decide"], + } + ) + finally: + handle.__trigger_methods__ = original_trigger_methods + + assert listeners["handle"] == ("OR", ["begin"]) + assert listeners["decide"] == ("OR", ["handle"]) + assert routers == {"decide"} + assert router_emit == {"decide": ["done"]} + + +def test_flow_definition_falls_back_to_legacy_metadata_without_fragment(): + class LegacyMetadataFlow(Flow): + @start() + def begin(self): + return "begin" + + @router(begin, emit=["left"]) + def decide(self): + return "left" + + @listen("left") + def left(self): + return "left" + + for method_name in ("begin", "decide", "left"): + method = LegacyMetadataFlow.__dict__[method_name] + delattr(method, "__flow_method_definition__") + + definition = flow_dsl.build_flow_definition(LegacyMetadataFlow) + + assert definition.methods["begin"].start is True + assert definition.methods["decide"].listen == "begin" + assert definition.methods["decide"].router is True + assert definition.methods["decide"].emit == ["left"] + assert definition.methods["left"].listen == "left" + + +def test_human_feedback_emit_overrides_inner_router_emit(): + class FeedbackOverRouterFlow(Flow): + @start() + def begin(self): + return "data" + + @human_feedback( + message="Review:", + emit=["approved", "rejected"], + llm="gpt-4o-mini", + ) + @router(begin, emit=["x", "y"]) + def route(self): + return "approved" + + @listen("approved") + def proceed(self): + return "ok" + + assert "route" in FeedbackOverRouterFlow._routers + assert FeedbackOverRouterFlow._router_emit["route"] == ["approved", "rejected"] + + route = FeedbackOverRouterFlow.flow_definition().methods["route"] + assert route.router is True + assert route.human_feedback is not None + assert route.human_feedback.emit == ["approved", "rejected"] + assert route.emit is None + + +def test_flow_definition_classifies_start_router_from_human_feedback_emit(): + class StartRouterFlow(Flow): + @start() + @human_feedback( + message="Review:", + emit=["continue", "stop"], + llm="gpt-4o-mini", + ) + def entry_point(self): + return "data" + + @listen("continue") + def proceed(self): + return "proceeding" + + @listen("stop") + def halt(self): + return "halted" + + definition = StartRouterFlow.flow_definition() + entry_point = definition.methods["entry_point"] + + assert entry_point.is_start is True + assert entry_point.router is True + assert entry_point.human_feedback is not None + assert entry_point.human_feedback.emit == ["continue", "stop"] + assert entry_point.emit is None + + +def test_flow_definition_round_trips_json_and_yaml(): + class RoundTripFlow(Flow): + @start() + def begin(self): + return "started" + + @router(begin) + def decide(self): + return "left" + + @listen("left") + def left(self): + return "left" + + definition = RoundTripFlow.flow_definition() + + json_round_trip = flow_definition.FlowDefinition.from_json(definition.to_json()) + yaml_round_trip = flow_definition.FlowDefinition.from_yaml(definition.to_yaml()) + + assert json_round_trip.to_dict() == definition.to_dict() + assert yaml_round_trip.to_dict() == definition.to_dict() + assert yaml_round_trip.methods["decide"].router is True + assert yaml_round_trip.methods["decide"].listen == "begin" + + +def test_flow_definition_detects_persist_metadata(): + @persist(verbose=True) + class PersistedFlow(Flow[dict]): + initial_state = {} + + @start() + def begin(self): + return "started" + + @persist(verbose=False) + @listen(begin) + def checkpoint(self): + return "saved" + + definition = PersistedFlow.flow_definition() + + assert definition.persist is not None + assert definition.persist.enabled is True + assert definition.persist.verbose is True + + assert definition.methods["begin"].persist is None + + method_persist = definition.methods["checkpoint"].persist + assert method_persist is not None + assert method_persist.enabled is True + assert method_persist.verbose is False + + +def test_flow_definition_allows_dynamic_router_emit(): + class DynamicRouterFlow(Flow): + @start() + def begin(self): + return "started" + + @router(begin) + def decide(self): + return self.state["dynamic_event"] + + definition = DynamicRouterFlow.flow_definition() + + assert definition.methods["decide"].emit is None + assert definition.diagnostics == [] + + +def test_flow_definition_infers_literal_router_emit(): + class LiteralRouterFlow(Flow): + @start() + def begin(self): + return "started" + + @router(begin) + def decide(self) -> Literal["left", "right"]: + return "left" + + @listen("left") + def left(self): + return "left" + + @listen("right") + def right(self): + return "right" + + definition = LiteralRouterFlow.flow_definition() + + assert definition.methods["decide"].emit == ["left", "right"] + + +def test_flow_definition_infers_enum_router_emit(): + class Decision(str, Enum): + APPROVE = "approve" + REJECT = "reject" + + class EnumRouterFlow(Flow): + @start() + def begin(self): + return "started" + + @router(begin) + def decide(self) -> Decision: + return Decision.APPROVE + + @listen("approve") + def approve(self): + return "approve" + + @listen("reject") + def reject(self): + return "reject" + + definition = EnumRouterFlow.flow_definition() + + assert definition.methods["decide"].emit == ["approve", "reject"] + + +def test_flow_definition_infers_literal_union_router_emit(): + class LiteralUnionRouterFlow(Flow): + @start() + def begin(self): + return "started" + + @router(begin) + def decide(self) -> Literal["left"] | Literal["right"]: + return "left" + + @listen("left") + def left(self): + return "left" + + @listen("right") + def right(self): + return "right" + + definition = LiteralUnionRouterFlow.flow_definition() + + assert definition.methods["decide"].emit == ["left", "right"] + + +def test_flow_definition_infers_annotated_literal_router_emit(): + class AnnotatedRouterFlow(Flow): + @start() + def begin(self): + return "started" + + @router(begin) + def decide(self) -> Annotated[Literal["left"] | None, "route"]: + return "left" + + definition = AnnotatedRouterFlow.flow_definition() + + assert definition.methods["decide"].emit == ["left"] + + +def test_flow_definition_does_not_infer_container_literal_router_emit(): + class ContainerLiteralRouterFlow(Flow): + @start() + def begin(self): + return "started" + + @router(begin) + def list_route(self) -> list[Literal["left"]]: + return ["left"] + + @router(begin) + def dict_route(self) -> dict[str, Literal["right"]]: + return {"route": "right"} + + definition = ContainerLiteralRouterFlow.flow_definition() + + assert definition.methods["list_route"].emit is None + assert definition.methods["dict_route"].emit is None + + +def test_flow_definition_does_not_infer_unannotated_router_body_emit(): + class UnannotatedRouterFlow(Flow): + @start() + def begin(self): + return "started" + + @router(begin) + def decide(self): + return "left" + + @listen("left") + def left(self): + return "left" + + definition = UnannotatedRouterFlow.flow_definition() + + assert definition.methods["decide"].emit is None + + +def test_flow_definition_accepts_explicit_router_events(): + class ExplicitRouterFlow(Flow): + @start() + def begin(self): + return "started" + + @router(begin, emit=["left", "right", "left"]) + def decide(self): + return self.state["dynamic_event"] + + @listen("left") + def left(self): + return "left" + + @listen("right") + def right(self): + return "right" + + definition = ExplicitRouterFlow.flow_definition() + + assert definition.methods["decide"].emit == ["left", "right"] + + +def test_flow_definition_preserves_diagnostics_loaded_from_contract(): + definition = flow_definition.FlowDefinition.from_dict( + { + "schema": "crewai.flow/v1", + "name": "LoadedDiagnosticsFlow", + "methods": { + "decision": { + "router": True, + "emit": ["continue"], + } + }, + "diagnostics": [ + { + "code": "serialized_warning", + "message": "Preserved serialized diagnostic", + "severity": "warning", + "path": "methods.decision", + }, + { + "code": "router_without_trigger", + "message": "router: true requires either start or listen", + "severity": "error", + "path": "methods.decision", + }, + ], + } + ) + + codes = [diagnostic.code for diagnostic in definition.diagnostics] + assert "serialized_warning" in codes + assert codes.count("router_without_trigger") == 1 + + +def test_router_start_false_without_listen_reports_missing_trigger(): + definition = flow_definition.FlowDefinition.from_dict( + { + "schema": "crewai.flow/v1", + "name": "LoadedFlow", + "methods": { + "decision": { + "router": True, + "start": False, + "emit": ["continue"], + } + }, + } + ) + + assert any( + diagnostic.code == "router_without_trigger" + and diagnostic.path == "methods.decision" + for diagnostic in definition.diagnostics + ) + + +def test_router_human_feedback_preserves_existing_router_metadata(): + class RouterHumanFeedbackFlow(Flow): + @start() + def begin(self): + return "started" + + @human_feedback(message="Review route:") + @router(begin, emit=["approved", "rejected"]) + def decide(self): + return "approved" + + @listen("approved") + def approved(self): + return "approved" + + definition = RouterHumanFeedbackFlow.flow_definition() + method = definition.methods["decide"] + + assert method.router is True + assert method.listen == "begin" + assert method.emit == ["approved", "rejected"] + assert method.human_feedback is not None + + +def test_dynamic_router_flow_definition_has_no_diagnostics(): + class LazyDynamicRouterFlow(Flow): + @start() + def begin(self): + return "started" + + @router(begin) + def decide(self): + return self.state["dynamic_event"] + + definition = LazyDynamicRouterFlow.flow_definition() + assert definition.diagnostics == [] + + +def test_dynamic_router_string_listener_is_valid_contract(): + class DynamicRouterListenerFlow(Flow): + @start() + def begin(self): + return "started" + + @router(begin) + def decide(self): + return self.state["dynamic_event"] + + @listen("dynamic_event") + def handle(self): + return "handled" + + definition = DynamicRouterListenerFlow.flow_definition() + + assert definition.diagnostics == [] + + +def test_static_string_listener_is_allowed_by_contract(): + definition = flow_definition.FlowDefinition.from_dict( + { + "schema": "crewai.flow/v1", + "name": "TypoFlow", + "methods": { + "begin": {"start": True}, + "handle": {"listen": "begni"}, + }, + } + ) + assert definition.diagnostics == [] + + +def test_start_false_not_classified_as_start_method(): + definition = flow_definition.FlowDefinition.from_dict( + { + "schema": "crewai.flow/v1", + "name": "ExplicitNonStartFlow", + "methods": { + "begin": {"start": True}, + "handle": {"start": False, "listen": "begin"}, + }, + } + ) + + assert definition.methods["begin"].is_start is True + assert definition.methods["handle"].is_start is False + + class ExplicitNonStartFlow(Flow): + @start() + def begin(self): + return "started" + + @listen(begin) + def handle(self): + return "handled" + + # Attach the loaded contract (with explicit ``start: false``) so the + # projections read from it rather than rebuilding from the DSL. + ExplicitNonStartFlow._flow_definition = definition + + flow = ExplicitNonStartFlow() + viz_structure = visualization_builder.build_flow_structure(flow) + assert "handle" not in viz_structure["start_methods"] + assert viz_structure["nodes"]["handle"]["type"] != "start" + + +def test_flow_definition_cache_is_not_inherited_by_subclasses(): + class ParentFlow(Flow): + @start() + def begin(self): + return "begin" + + parent_definition = ParentFlow.flow_definition() + + class ChildFlow(ParentFlow): + @listen(ParentFlow.begin) + def child_step(self): + return "child" + + child_definition = ChildFlow.flow_definition() + + assert parent_definition.name == "ParentFlow" + assert child_definition.name == "ChildFlow" + assert child_definition is not parent_definition + assert set(child_definition.methods) == {"begin", "child_step"} + + +def test_flow_definition_logs_diagnostics_when_loaded_from_contract(caplog): + caplog.set_level(logging.WARNING, logger="crewai.flow.flow_definition") + + definition = flow_definition.FlowDefinition.from_dict( + { + "schema": "crewai.flow/v1", + "name": "LoadedFlow", + "methods": { + "decision": { + "router": True, + "emit": ["continue"], + } + }, + } + ) + + assert any( + diagnostic.code == "router_without_trigger" + for diagnostic in definition.diagnostics + ) + assert any( + record.levelno == logging.ERROR + and "LoadedFlow" in record.message + and "router_without_trigger" in record.message + for record in caplog.records + ) diff --git a/lib/crewai/tests/test_flow_serializer.py b/lib/crewai/tests/test_flow_serializer.py deleted file mode 100644 index 4ff423f7f..000000000 --- a/lib/crewai/tests/test_flow_serializer.py +++ /dev/null @@ -1,818 +0,0 @@ -"""Tests for flow_serializer.py - Flow structure serialization for Studio UI.""" - -from typing import Literal - -import pytest -from pydantic import BaseModel, Field - -from crewai.flow.flow import Flow, and_, listen, or_, router, start -from crewai.flow.flow_serializer import flow_structure -from crewai.flow.human_feedback import human_feedback - - -class TestSimpleLinearFlow: - """Test simple linear flow (start → listen → listen).""" - - def test_linear_flow_structure(self): - """Test a simple sequential flow structure.""" - - class LinearFlow(Flow): - """A simple linear flow for testing.""" - - @start() - def begin(self): - return "started" - - @listen(begin) - def process(self): - return "processed" - - @listen(process) - def finalize(self): - return "done" - - structure = flow_structure(LinearFlow) - - assert structure["name"] == "LinearFlow" - assert structure["description"] == "A simple linear flow for testing." - assert len(structure["methods"]) == 3 - - method_map = {m["name"]: m for m in structure["methods"]} - - assert method_map["begin"]["type"] == "start" - assert method_map["process"]["type"] == "listen" - assert method_map["finalize"]["type"] == "listen" - - assert len(structure["edges"]) == 2 - - edge_pairs = [(e["from_method"], e["to_method"]) for e in structure["edges"]] - assert ("begin", "process") in edge_pairs - assert ("process", "finalize") in edge_pairs - - for edge in structure["edges"]: - assert edge["edge_type"] == "listen" - assert edge["condition"] is None - - -class TestRouterFlow: - """Test flow with router branching.""" - - def test_router_flow_structure(self): - """Test a flow with router that branches to different paths.""" - - class BranchingFlow(Flow): - @start() - def init(self): - return "initialized" - - @router(init) - def decide(self) -> Literal["path_a", "path_b"]: - return "path_a" - - @listen("path_a") - def handle_a(self): - return "handled_a" - - @listen("path_b") - def handle_b(self): - return "handled_b" - - structure = flow_structure(BranchingFlow) - - assert structure["name"] == "BranchingFlow" - assert len(structure["methods"]) == 4 - - method_map = {m["name"]: m for m in structure["methods"]} - - assert method_map["init"]["type"] == "start" - assert method_map["decide"]["type"] == "router" - assert method_map["handle_a"]["type"] == "listen" - assert method_map["handle_b"]["type"] == "listen" - - assert "path_a" in method_map["decide"]["router_paths"] - assert "path_b" in method_map["decide"]["router_paths"] - - # Should have: init -> decide (listen), decide -> handle_a (route), decide -> handle_b (route) - listen_edges = [e for e in structure["edges"] if e["edge_type"] == "listen"] - route_edges = [e for e in structure["edges"] if e["edge_type"] == "route"] - - assert len(listen_edges) == 1 - assert listen_edges[0]["from_method"] == "init" - assert listen_edges[0]["to_method"] == "decide" - - assert len(route_edges) == 2 - route_targets = {e["to_method"] for e in route_edges} - assert "handle_a" in route_targets - assert "handle_b" in route_targets - - route_conditions = {e["to_method"]: e["condition"] for e in route_edges} - assert route_conditions["handle_a"] == "path_a" - assert route_conditions["handle_b"] == "path_b" - - -class TestAndOrConditions: - """Test flow with AND/OR conditions.""" - - def test_and_condition_flow(self): - """Test a flow where a method waits for multiple methods (AND).""" - - class AndConditionFlow(Flow): - @start() - def step_a(self): - return "a" - - @start() - def step_b(self): - return "b" - - @listen(and_(step_a, step_b)) - def converge(self): - return "converged" - - structure = flow_structure(AndConditionFlow) - - assert len(structure["methods"]) == 3 - - method_map = {m["name"]: m for m in structure["methods"]} - - assert method_map["step_a"]["type"] == "start" - assert method_map["step_b"]["type"] == "start" - assert method_map["converge"]["type"] == "listen" - - assert method_map["converge"]["condition_type"] == "AND" - - triggers = method_map["converge"]["trigger_methods"] - assert "step_a" in triggers - assert "step_b" in triggers - - converge_edges = [e for e in structure["edges"] if e["to_method"] == "converge"] - assert len(converge_edges) == 2 - - def test_or_condition_flow(self): - """Test a flow where a method is triggered by any of multiple methods (OR).""" - - class OrConditionFlow(Flow): - @start() - def path_1(self): - return "1" - - @start() - def path_2(self): - return "2" - - @listen(or_(path_1, path_2)) - def handle_any(self): - return "handled" - - structure = flow_structure(OrConditionFlow) - - method_map = {m["name"]: m for m in structure["methods"]} - - assert method_map["handle_any"]["condition_type"] == "OR" - - triggers = method_map["handle_any"]["trigger_methods"] - assert "path_1" in triggers - assert "path_2" in triggers - - -class TestHumanFeedbackMethods: - """Test flow with @human_feedback decorated methods.""" - - def test_human_feedback_detection(self): - """Test that human feedback methods are correctly identified.""" - - class HumanFeedbackFlow(Flow): - @start() - @human_feedback( - message="Please review:", - emit=["approved", "rejected"], - llm="gpt-4o-mini", - ) - def review_step(self): - return "content to review" - - @listen("approved") - def handle_approved(self): - return "approved" - - @listen("rejected") - def handle_rejected(self): - return "rejected" - - structure = flow_structure(HumanFeedbackFlow) - - method_map = {m["name"]: m for m in structure["methods"]} - - # review_step should have human feedback - assert method_map["review_step"]["has_human_feedback"] is True - # It's a start+router (due to emit) - assert method_map["review_step"]["type"] == "start_router" - assert "approved" in method_map["review_step"]["router_paths"] - assert "rejected" in method_map["review_step"]["router_paths"] - - # Other methods should not have human feedback - assert method_map["handle_approved"]["has_human_feedback"] is False - assert method_map["handle_rejected"]["has_human_feedback"] is False - - def test_listen_plus_human_feedback_router_edges(self): - """Test that @listen + @human_feedback(emit=...) generates router edges. - - This is the pattern used in the whitepaper generator: - a listener method that also acts as a router via @human_feedback(emit=[...]). - The serializer must generate edges from this method to listeners of its emit paths. - """ - - class ReviewFlow(Flow): - @start() - def generate(self): - return "content" - - @listen(generate) - @human_feedback( - message="Review this:", - emit=["approved", "needs_changes", "cancelled"], - llm="gpt-4o-mini", - ) - def review(self): - return "review result" - - @listen("approved") - def handle_approved(self): - return "done" - - @listen("needs_changes") - def handle_changes(self): - return "regenerating" - - @listen("cancelled") - def handle_cancelled(self): - return "cancelled" - - structure = flow_structure(ReviewFlow) - - method_map = {m["name"]: m for m in structure["methods"]} - edge_set = {(e["from_method"], e["to_method"], e.get("condition")) for e in structure["edges"]} - - # review should be detected as a router with the emit paths - assert method_map["review"]["type"] == "router" - assert set(method_map["review"]["router_paths"]) == {"approved", "needs_changes", "cancelled"} - assert method_map["review"]["has_human_feedback"] is True - - assert ("generate", "review", None) in edge_set - - assert ("review", "handle_approved", "approved") in edge_set - assert ("review", "handle_changes", "needs_changes") in edge_set - assert ("review", "handle_cancelled", "cancelled") in edge_set - - -class TestCrewReferences: - """Test detection of Crew references in method bodies.""" - - def test_crew_detection_with_crew_call(self): - """Test that .crew() calls are detected.""" - - class FlowWithCrew(Flow): - @start() - def run_crew(self): - return "result" - - @listen(run_crew) - def no_crew(self): - return "done" - - structure = flow_structure(FlowWithCrew) - - method_map = {m["name"]: m for m in structure["methods"]} - - # Note: Since the actual .crew() call is in a comment/string, - # We're testing the mechanism exists. - assert "has_crew" in method_map["run_crew"] - assert "has_crew" in method_map["no_crew"] - - def test_no_crew_when_absent(self): - """Test that methods without Crew refs return has_crew=False.""" - - class SimpleNonCrewFlow(Flow): - @start() - def calculate(self): - return 1 + 1 - - @listen(calculate) - def display(self): - return "result" - - structure = flow_structure(SimpleNonCrewFlow) - - method_map = {m["name"]: m for m in structure["methods"]} - - assert method_map["calculate"]["has_crew"] is False - assert method_map["display"]["has_crew"] is False - - -class TestTypedStateSchema: - """Test flow with typed Pydantic state.""" - - def test_pydantic_state_schema_extraction(self): - """Test extracting state schema from a Flow with Pydantic state.""" - - class MyState(BaseModel): - counter: int = 0 - message: str = "" - items: list[str] = Field(default_factory=list) - - class TypedStateFlow(Flow[MyState]): - initial_state = MyState - - @start() - def increment(self): - self.state.counter += 1 - return self.state.counter - - @listen(increment) - def display(self): - return f"Count: {self.state.counter}" - - structure = flow_structure(TypedStateFlow) - - assert structure["state_schema"] is not None - fields = structure["state_schema"]["fields"] - - field_names = {f["name"] for f in fields} - assert "counter" in field_names - assert "message" in field_names - assert "items" in field_names - - field_map = {f["name"]: f for f in fields} - assert "int" in field_map["counter"]["type"] - assert "str" in field_map["message"]["type"] - - assert field_map["counter"]["default"] == 0 - assert field_map["message"]["default"] == "" - - def test_dict_state_returns_none(self): - """Test that flows using dict state return None for state_schema.""" - - class DictStateFlow(Flow): - @start() - def begin(self): - self.state["count"] = 1 - return "started" - - structure = flow_structure(DictStateFlow) - - assert structure["state_schema"] is None - - -class TestEdgeCases: - """Test edge cases and special scenarios.""" - - def test_start_router_combo(self): - """Test a method that is both @start and a router (via human_feedback emit).""" - - class StartRouterFlow(Flow): - @start() - @human_feedback( - message="Review:", - emit=["continue", "stop"], - llm="gpt-4o-mini", - ) - def entry_point(self): - return "data" - - @listen("continue") - def proceed(self): - return "proceeding" - - @listen("stop") - def halt(self): - return "halted" - - structure = flow_structure(StartRouterFlow) - - method_map = {m["name"]: m for m in structure["methods"]} - - assert method_map["entry_point"]["type"] == "start_router" - assert method_map["entry_point"]["has_human_feedback"] is True - assert "continue" in method_map["entry_point"]["router_paths"] - assert "stop" in method_map["entry_point"]["router_paths"] - - def test_multiple_start_methods(self): - """Test a flow with multiple start methods.""" - - class MultiStartFlow(Flow): - @start() - def start_a(self): - return "a" - - @start() - def start_b(self): - return "b" - - @listen(and_(start_a, start_b)) - def combine(self): - return "combined" - - structure = flow_structure(MultiStartFlow) - - start_methods = [m for m in structure["methods"] if m["type"] == "start"] - assert len(start_methods) == 2 - - start_names = {m["name"] for m in start_methods} - assert "start_a" in start_names - assert "start_b" in start_names - - def test_orphan_methods(self): - """Test that orphan methods (not connected to flow) are still captured.""" - - class FlowWithOrphan(Flow): - @start() - def begin(self): - return "started" - - @listen(begin) - def connected(self): - return "connected" - - @listen("never_triggered") - def orphan(self): - return "orphan" - - structure = flow_structure(FlowWithOrphan) - - method_names = {m["name"] for m in structure["methods"]} - assert "orphan" in method_names - - method_map = {m["name"]: m for m in structure["methods"]} - assert method_map["orphan"]["trigger_methods"] == ["never_triggered"] - - def test_empty_flow(self): - """Test building structure for a flow with no methods.""" - - class EmptyFlow(Flow): - pass - - structure = flow_structure(EmptyFlow) - - assert structure["name"] == "EmptyFlow" - assert structure["methods"] == [] - assert structure["edges"] == [] - assert structure["state_schema"] is None - - def test_flow_with_docstring(self): - """Test that flow docstring is captured.""" - - class DocumentedFlow(Flow): - """This is a well-documented flow. - - It has multiple lines of documentation. - """ - - @start() - def begin(self): - return "started" - - structure = flow_structure(DocumentedFlow) - - assert structure["description"] is not None - assert "well-documented flow" in structure["description"] - - def test_flow_without_docstring(self): - """Test that missing docstring returns None.""" - - class UndocumentedFlow(Flow): - @start() - def begin(self): - return "started" - - structure = flow_structure(UndocumentedFlow) - - assert structure["description"] is None - - def test_nested_conditions(self): - """Test flow with nested AND/OR conditions.""" - - class NestedConditionFlow(Flow): - @start() - def a(self): - return "a" - - @start() - def b(self): - return "b" - - @start() - def c(self): - return "c" - - @listen(or_(and_(a, b), c)) - def complex_trigger(self): - return "triggered" - - structure = flow_structure(NestedConditionFlow) - - method_map = {m["name"]: m for m in structure["methods"]} - - triggers = method_map["complex_trigger"]["trigger_methods"] - assert len(triggers) == 3 - assert "a" in triggers - assert "b" in triggers - assert "c" in triggers - - -class TestErrorHandling: - """Test error handling and validation.""" - - def test_instance_raises_type_error(self): - """Test that passing an instance raises TypeError.""" - - class TestFlow(Flow): - @start() - def begin(self): - return "started" - - flow_instance = TestFlow() - - with pytest.raises(TypeError) as exc_info: - flow_structure(flow_instance) - - assert "requires a Flow class, not an instance" in str(exc_info.value) - - def test_non_class_raises_type_error(self): - """Test that passing non-class raises TypeError.""" - - with pytest.raises(TypeError): - flow_structure("not a class") - - with pytest.raises(TypeError): - flow_structure(123) - - -class TestEdgeGeneration: - """Test edge generation in various scenarios.""" - - def test_all_edges_generated_correctly(self): - """Verify all edges are correctly generated for a complex flow.""" - - class ComplexFlow(Flow): - @start() - def entry(self): - return "started" - - @listen(entry) - def step_1(self): - return "step_1" - - @router(step_1) - def branch(self) -> Literal["left", "right"]: - return "left" - - @listen("left") - def left_path(self): - return "left_done" - - @listen("right") - def right_path(self): - return "right_done" - - @listen(or_(left_path, right_path)) - def converge(self): - return "done" - - structure = flow_structure(ComplexFlow) - - edges = structure["edges"] - - listen_edges = [(e["from_method"], e["to_method"]) for e in edges if e["edge_type"] == "listen"] - - assert ("entry", "step_1") in listen_edges - assert ("step_1", "branch") in listen_edges - assert ("left_path", "converge") in listen_edges - assert ("right_path", "converge") in listen_edges - - route_edges = [(e["from_method"], e["to_method"], e["condition"]) for e in edges if e["edge_type"] == "route"] - - assert ("branch", "left_path", "left") in route_edges - assert ("branch", "right_path", "right") in route_edges - - def test_router_edge_conditions(self): - """Test that router edge conditions are properly set.""" - - class RouterConditionFlow(Flow): - @start() - def begin(self): - return "start" - - @router(begin) - def route(self) -> Literal["option_1", "option_2", "option_3"]: - return "option_1" - - @listen("option_1") - def handle_1(self): - return "1" - - @listen("option_2") - def handle_2(self): - return "2" - - @listen("option_3") - def handle_3(self): - return "3" - - structure = flow_structure(RouterConditionFlow) - - route_edges = [e for e in structure["edges"] if e["edge_type"] == "route"] - - assert len(route_edges) == 3 - - conditions = {e["to_method"]: e["condition"] for e in route_edges} - assert conditions["handle_1"] == "option_1" - assert conditions["handle_2"] == "option_2" - assert conditions["handle_3"] == "option_3" - - -class TestMethodTypeClassification: - """Test method type classification.""" - - def test_all_method_types(self): - """Test classification of all method types.""" - - class AllTypesFlow(Flow): - @start() - def start_only(self): - return "start" - - @listen(start_only) - def listen_only(self): - return "listen" - - @router(listen_only) - def router_only(self) -> Literal["path"]: - return "path" - - @listen("path") - def after_router(self): - return "after" - - @start() - @human_feedback( - message="Review", - emit=["yes", "no"], - llm="gpt-4o-mini", - ) - def start_and_router(self): - return "data" - - structure = flow_structure(AllTypesFlow) - - method_map = {m["name"]: m for m in structure["methods"]} - - assert method_map["start_only"]["type"] == "start" - assert method_map["listen_only"]["type"] == "listen" - assert method_map["router_only"]["type"] == "router" - assert method_map["after_router"]["type"] == "listen" - assert method_map["start_and_router"]["type"] == "start_router" - - -class TestInputDetection: - """Test flow input detection.""" - - def test_inputs_list_exists(self): - """Test that inputs list is always present.""" - - class SimpleFlow(Flow): - @start() - def begin(self): - return "started" - - structure = flow_structure(SimpleFlow) - - assert "inputs" in structure - assert isinstance(structure["inputs"], list) - - -class TestJsonSerializable: - """Test that output is JSON serializable.""" - - def test_structure_is_json_serializable(self): - """Test that the entire structure can be JSON serialized.""" - import json - - class MyState(BaseModel): - value: int = 0 - - class SerializableFlow(Flow[MyState]): - """Test flow for JSON serialization.""" - - initial_state = MyState - - @start() - @human_feedback( - message="Review", - emit=["ok", "not_ok"], - llm="gpt-4o-mini", - ) - def begin(self): - return "data" - - @listen("ok") - def proceed(self): - return "done" - - structure = flow_structure(SerializableFlow) - - json_str = json.dumps(structure) - assert json_str is not None - - parsed = json.loads(json_str) - assert parsed["name"] == "SerializableFlow" - assert len(parsed["methods"]) > 0 - - -class TestFlowInheritance: - """Test flow inheritance scenarios.""" - - def test_child_flow_inherits_parent_methods(self): - """Test that FlowB inheriting from FlowA includes methods from both. - - Note: FlowMeta propagates methods but does NOT fully propagate the - _listeners registry from parent classes. This means edges defined - in the parent class (e.g., parent_start -> parent_process) may not - appear in the child's structure. This is a known FlowMeta limitation. - """ - - class FlowA(Flow): - """Parent flow with start method.""" - - @start() - def parent_start(self): - return "parent started" - - @listen(parent_start) - def parent_process(self): - return "parent processed" - - class FlowB(FlowA): - """Child flow with additional methods.""" - - @listen(FlowA.parent_process) - def child_continue(self): - return "child continued" - - @listen(child_continue) - def child_finalize(self): - return "child finalized" - - structure = flow_structure(FlowB) - - assert structure["name"] == "FlowB" - - method_names = {m["name"] for m in structure["methods"]} - assert "parent_start" in method_names - assert "parent_process" in method_names - assert "child_continue" in method_names - assert "child_finalize" in method_names - - method_map = {m["name"]: m for m in structure["methods"]} - assert method_map["parent_start"]["type"] == "start" - assert method_map["parent_process"]["type"] == "listen" - assert method_map["child_continue"]["type"] == "listen" - assert method_map["child_finalize"]["type"] == "listen" - - edge_pairs = [(e["from_method"], e["to_method"]) for e in structure["edges"]] - assert ("parent_process", "child_continue") in edge_pairs - assert ("child_continue", "child_finalize") in edge_pairs - - # KNOWN LIMITATION: Edges defined in parent class (parent_start -> parent_process) - # are NOT propagated to child's _listeners registry by FlowMeta. - # This is a FlowMeta limitation, not a serializer bug. - - def test_child_flow_can_override_parent_method(self): - """Test that child can override parent methods.""" - - class BaseFlow(Flow): - @start() - def begin(self): - return "base begin" - - @listen(begin) - def process(self): - return "base process" - - class ExtendedFlow(BaseFlow): - @listen(BaseFlow.begin) - def process(self): - return "extended process" - - @listen(process) - def finalize(self): - return "extended finalize" - - structure = flow_structure(ExtendedFlow) - - method_names = {m["name"] for m in structure["methods"]} - assert "begin" in method_names - assert "process" in method_names - assert "finalize" in method_names - - # Should have 3 methods total (not 4, since process is overridden) - assert len(structure["methods"]) == 3 diff --git a/lib/crewai/tests/test_flow_visualization.py b/lib/crewai/tests/test_flow_visualization.py index 0efca3fe8..167703a14 100644 --- a/lib/crewai/tests/test_flow_visualization.py +++ b/lib/crewai/tests/test_flow_visualization.py @@ -8,6 +8,7 @@ from pathlib import Path import pytest from crewai.flow.flow import Flow, and_, listen, or_, router, start +from crewai.flow.flow_definition import FlowDefinition from crewai.flow.visualization import ( build_flow_structure, visualize_flow_structure, @@ -36,14 +37,14 @@ class RouterFlow(Flow): @router(init) def decide(self): if hasattr(self, "state") and self.state.get("path") == "b": - return "path_b" - return "path_a" + return "event_b" + return "event_a" - @listen("path_a") + @listen("event_a") def handle_a(self): return "handled_a" - @listen("path_b") + @listen("event_b") def handle_b(self): return "handled_b" @@ -69,13 +70,23 @@ class ComplexFlow(Flow): @router(converge_and) def router_decision(self): - return "final_path" + return "final_event" - @listen("final_path") + @listen("final_event") def finalize(self): return "complete" +def _attach_flow_definition(flow_class: type[Flow], methods: dict[str, object]) -> None: + flow_class._flow_definition = FlowDefinition.from_dict( + { + "schema": "crewai.flow/v1", + "name": flow_class.__name__, + "methods": methods, + } + ) + + def test_build_flow_structure_simple(): """Test building structure for a simple sequential flow.""" flow = SimpleFlow() @@ -98,6 +109,47 @@ def test_build_flow_structure_simple(): assert edge["condition_type"] == "OR" +def test_build_flow_structure_from_flow_class(): + """Test building structure from a Flow class via its FlowDefinition.""" + structure = build_flow_structure(SimpleFlow) + + assert set(structure["nodes"]) == {"begin", "process"} + assert structure["start_methods"] == ["begin"] + assert structure["nodes"]["begin"]["class_name"] == "SimpleFlow" + + +def test_build_flow_structure_from_flow_definition(): + """Test building visualization directly from a FlowDefinition.""" + definition = FlowDefinition.from_dict( + { + "schema": "crewai.flow/v1", + "name": "DefinedFlow", + "methods": { + "begin": {"start": True}, + "decide": { + "listen": "begin", + "router": True, + "emit": ["done"], + }, + "finish": {"listen": "done"}, + }, + } + ) + + structure = build_flow_structure(definition) + + assert set(structure["nodes"]) == {"begin", "decide", "finish"} + assert structure["start_methods"] == ["begin"] + assert structure["router_methods"] == ["decide"] + assert structure["nodes"]["begin"]["class_name"] == "DefinedFlow" + assert any( + edge["source"] == "decide" + and edge["target"] == "finish" + and edge["router_event"] == "done" + for edge in structure["edges"] + ) + + def test_build_flow_structure_with_router(): """Test building structure for a flow with router.""" flow = RouterFlow() @@ -111,13 +163,10 @@ def test_build_flow_structure_with_router(): router_node = structure["nodes"]["decide"] assert router_node["type"] == "router" + assert "router_events" not in router_node - if "router_paths" in router_node: - assert len(router_node["router_paths"]) >= 1 - assert any("path" in path for path in router_node["router_paths"]) - - router_edges = [edge for edge in structure["edges"] if edge["is_router_path"]] - assert len(router_edges) >= 1 + router_edges = [edge for edge in structure["edges"] if edge["is_router_event"]] + assert router_edges == [] def test_build_flow_structure_with_and_or_conditions(): @@ -203,49 +252,40 @@ def test_visualize_flow_structure_json_data(): assert "handle_b" in js_content assert "router" in js_content.lower() - assert "path_a" in js_content - assert "path_b" in js_content + assert "event_a" in js_content + assert "event_b" in js_content -def test_node_metadata_includes_source_info(): - """Test that nodes include source code and line number information.""" +def test_node_metadata_omits_source_info(): + """Test that definition-only visualization omits Python source metadata.""" flow = SimpleFlow() structure = build_flow_structure(flow) - for node_name, node_metadata in structure["nodes"].items(): - assert node_metadata["source_code"] is not None - assert len(node_metadata["source_code"]) > 0 - assert node_metadata["source_start_line"] is not None - assert node_metadata["source_start_line"] > 0 - assert node_metadata["source_file"] is not None - assert node_metadata["source_file"].endswith(".py") + for node_metadata in structure["nodes"].values(): + assert "source_code" not in node_metadata + assert "source_lines" not in node_metadata + assert "source_start_line" not in node_metadata + assert "source_file" not in node_metadata -def test_node_metadata_includes_method_signature(): - """Test that nodes include method signature information.""" +def test_node_metadata_omits_method_signature(): + """Test that definition-only visualization omits Python method signatures.""" flow = SimpleFlow() structure = build_flow_structure(flow) begin_node = structure["nodes"]["begin"] - assert begin_node["method_signature"] is not None - assert "operationId" in begin_node["method_signature"] - assert begin_node["method_signature"]["operationId"] == "begin" - assert "parameters" in begin_node["method_signature"] - assert "returns" in begin_node["method_signature"] + assert "method_signature" not in begin_node def test_router_node_has_correct_metadata(): - """Test that router nodes have correct type and paths.""" + """Test that router nodes have correct type and event metadata.""" flow = RouterFlow() structure = build_flow_structure(flow) router_node = structure["nodes"]["decide"] assert router_node["type"] == "router" assert router_node["is_router"] is True - assert router_node["router_paths"] is not None - assert len(router_node["router_paths"]) == 2 - assert "path_a" in router_node["router_paths"] - assert "path_b" in router_node["router_paths"] + assert "router_events" not in router_node def test_listen_node_has_trigger_methods(): @@ -255,7 +295,7 @@ def test_listen_node_has_trigger_methods(): handle_a_node = structure["nodes"]["handle_a"] assert handle_a_node["trigger_methods"] is not None - assert "path_a" in handle_a_node["trigger_methods"] + assert "event_a" in handle_a_node["trigger_methods"] def test_and_condition_node_metadata(): @@ -317,16 +357,15 @@ def test_topological_path_counting(): assert len(structure["edges"]) > 0 -def test_class_signature_metadata(): - """Test that nodes include class signature information.""" +def test_class_metadata_comes_from_definition(): + """Test that nodes include only definition-derived class metadata.""" flow = SimpleFlow() structure = build_flow_structure(flow) - for node_name, node_metadata in structure["nodes"].items(): + for node_metadata in structure["nodes"].values(): assert node_metadata["class_name"] is not None assert node_metadata["class_name"] == "SimpleFlow" - assert node_metadata["class_signature"] is not None - assert "SimpleFlow" in node_metadata["class_signature"] + assert "class_signature" not in node_metadata def test_visualization_plot_method(): @@ -338,8 +377,8 @@ def test_visualization_plot_method(): assert os.path.exists(html_file) -def test_router_paths_to_string_conditions(): - """Test that router paths correctly connect to listeners with string conditions.""" +def test_router_events_to_string_conditions(): + """Test that router events correctly connect to listeners with string conditions.""" class RouterToStringFlow(Flow): @start() @@ -349,25 +388,34 @@ def test_router_paths_to_string_conditions(): @router(init) def decide(self): if hasattr(self, "state") and self.state.get("path") == "b": - return "path_b" - return "path_a" + return "event_b" + return "event_a" - @listen(or_("path_a", "path_b")) + @listen(or_("event_a", "event_b")) def handle_either(self): return "handled" - @listen("path_b") + @listen("event_b") def handle_b_only(self): return "handled_b" flow = RouterToStringFlow() + _attach_flow_definition( + RouterToStringFlow, + { + "init": {"start": True}, + "decide": {"listen": "init", "router": True, "emit": ["event_a", "event_b"]}, + "handle_either": {"listen": {"or": ["event_a", "event_b"]}}, + "handle_b_only": {"listen": "event_b"}, + }, + ) structure = build_flow_structure(flow) decide_node = structure["nodes"]["decide"] - assert "path_a" in decide_node["router_paths"] - assert "path_b" in decide_node["router_paths"] + assert "event_a" in decide_node["router_events"] + assert "event_b" in decide_node["router_events"] - router_edges = [edge for edge in structure["edges"] if edge["is_router_path"]] + router_edges = [edge for edge in structure["edges"] if edge["is_router_event"]] assert len(router_edges) == 3 @@ -382,8 +430,8 @@ def test_router_paths_to_string_conditions(): assert len(edges_to_handle_b_only) == 1 -def test_router_paths_not_in_and_conditions(): - """Test that router paths don't create edges to AND-nested conditions.""" +def test_router_events_not_in_and_conditions(): + """Test that router events don't create edges to AND-nested conditions.""" class RouterAndConditionFlow(Flow): @start() @@ -392,24 +440,34 @@ def test_router_paths_not_in_and_conditions(): @router(init) def decide(self): - return "path_a" + return "event_a" - @listen("path_a") + @listen("event_a") def step_1(self): return "step_1_done" - @listen(and_("path_a", step_1)) + @listen(and_("event_a", step_1)) def step_2_and(self): return "step_2_done" - @listen(or_(and_("path_a", step_1), "path_a")) + @listen(or_(and_("event_a", step_1), "event_a")) def step_3_or(self): return "step_3_done" flow = RouterAndConditionFlow() + _attach_flow_definition( + RouterAndConditionFlow, + { + "init": {"start": True}, + "decide": {"listen": "init", "router": True, "emit": ["event_a"]}, + "step_1": {"listen": "event_a"}, + "step_2_and": {"listen": {"and": ["event_a", "step_1"]}}, + "step_3_or": {"listen": {"or": [{"and": ["event_a", "step_1"]}, "event_a"]}}, + }, + ) structure = build_flow_structure(flow) - router_edges = [edge for edge in structure["edges"] if edge["is_router_path"]] + router_edges = [edge for edge in structure["edges"] if edge["is_router_event"]] targets = [edge["target"] for edge in router_edges] @@ -454,6 +512,17 @@ def test_chained_routers_no_self_loops(): return "need_auth" flow = ChainedRouterFlow() + _attach_flow_definition( + ChainedRouterFlow, + { + "entrance": {"start": True}, + "session_in_cache": {"listen": "entrance", "router": True, "emit": ["exp"]}, + "check_exp": {"listen": "exp", "router": True, "emit": ["auth"]}, + "call_ai_auth": {"listen": "auth", "router": True, "emit": ["action"]}, + "forward_to_action": {"listen": "action"}, + "forward_to_authenticate": {"listen": "authenticate"}, + }, + ) structure = build_flow_structure(flow) for edge in structure["edges"]: @@ -461,13 +530,13 @@ def test_chained_routers_no_self_loops(): f"Self-loop detected: {edge['source']} -> {edge['target']}" ) - router_edges = [edge for edge in structure["edges"] if edge["is_router_path"]] + router_edges = [edge for edge in structure["edges"] if edge["is_router_event"]] # session_in_cache -> check_exp (via 'exp') exp_edges = [ edge for edge in router_edges - if edge["router_path_label"] == "exp" and edge["source"] == "session_in_cache" + if edge["router_event"] == "exp" and edge["source"] == "session_in_cache" ] assert len(exp_edges) == 1 assert exp_edges[0]["target"] == "check_exp" @@ -476,7 +545,7 @@ def test_chained_routers_no_self_loops(): auth_edges = [ edge for edge in router_edges - if edge["router_path_label"] == "auth" and edge["source"] == "check_exp" + if edge["router_event"] == "auth" and edge["source"] == "check_exp" ] assert len(auth_edges) == 1 assert auth_edges[0]["target"] == "call_ai_auth" @@ -485,7 +554,7 @@ def test_chained_routers_no_self_loops(): action_edges = [ edge for edge in router_edges - if edge["router_path_label"] == "action" and edge["source"] == "call_ai_auth" + if edge["router_event"] == "action" and edge["source"] == "call_ai_auth" ] assert len(action_edges) == 1 assert action_edges[0]["target"] == "forward_to_action" @@ -523,6 +592,16 @@ def test_routers_with_shared_output_strings(): return "skipped" flow = SharedOutputRouterFlow() + _attach_flow_definition( + SharedOutputRouterFlow, + { + "start": {"start": True}, + "router_a": {"listen": "start", "router": True, "emit": ["auth"]}, + "router_b": {"listen": "auth", "router": True, "emit": ["done"]}, + "finalize": {"listen": "done"}, + "handle_skip": {"listen": "skip"}, + }, + ) structure = build_flow_structure(flow) for edge in structure["edges"]: @@ -531,11 +610,11 @@ def test_routers_with_shared_output_strings(): ) # router_a should connect to router_b via 'auth' - router_edges = [edge for edge in structure["edges"] if edge["is_router_path"]] + router_edges = [edge for edge in structure["edges"] if edge["is_router_event"]] auth_from_a = [ edge for edge in router_edges - if edge["source"] == "router_a" and edge["router_path_label"] == "auth" + if edge["source"] == "router_a" and edge["router_event"] == "auth" ] assert len(auth_from_a) == 1 assert auth_from_a[0]["target"] == "router_b" @@ -544,17 +623,17 @@ def test_routers_with_shared_output_strings(): done_from_b = [ edge for edge in router_edges - if edge["source"] == "router_b" and edge["router_path_label"] == "done" + if edge["source"] == "router_b" and edge["router_event"] == "done" ] assert len(done_from_b) == 1 assert done_from_b[0]["target"] == "finalize" -def test_warning_for_router_without_paths(caplog): - """Test that a warning is logged when a router has no determinable paths.""" +def test_warning_for_router_without_events(caplog): + """Test that a warning is logged when a router has no determinable events.""" import logging - class RouterWithoutPathsFlow(Flow): + class RouterWithoutEventsFlow(Flow): """Flow with a router that returns a dynamic value.""" @start() @@ -564,34 +643,35 @@ def test_warning_for_router_without_paths(caplog): @router(begin) def dynamic_router(self): import random - return random.choice(["path_a", "path_b"]) + return random.choice(["event_a", "event_b"]) - @listen("path_a") + @listen("event_a") def handle_a(self): return "a" - @listen("path_b") + @listen("event_b") def handle_b(self): return "b" - flow = RouterWithoutPathsFlow() + flow = RouterWithoutEventsFlow() with caplog.at_level(logging.WARNING): build_flow_structure(flow) assert any( - "Could not determine return paths for router 'dynamic_router'" in record.message + "Router events for 'dynamic_router' are dynamic" in record.message for record in caplog.records ) assert any( - "Found listeners waiting for triggers" in record.message + "Static visualization could not match listener triggers" in record.message for record in caplog.records ) + assert not any(record.levelno >= logging.ERROR for record in caplog.records) def test_warning_for_orphaned_listeners(caplog): - """Test that an error is logged when listeners wait for triggers no router outputs.""" + """Test that a warning is logged when a trigger has no explicit router output.""" import logging from typing import Literal @@ -615,19 +695,33 @@ def test_warning_for_orphaned_listeners(caplog): return "orphan" flow = OrphanedListenerFlow() + _attach_flow_definition( + OrphanedListenerFlow, + { + "begin": {"start": True}, + "my_router": { + "listen": "begin", + "router": True, + "emit": ["option_a", "option_b"], + }, + "handle_a": {"listen": "option_a"}, + "handle_orphan": {"listen": "option_c"}, + }, + ) - with caplog.at_level(logging.ERROR): + with caplog.at_level(logging.WARNING): build_flow_structure(flow) assert any( - "Found listeners waiting for triggers" in record.message + "Static visualization could not match listener triggers" in record.message and "option_c" in record.message for record in caplog.records ) + assert not any(record.levelno >= logging.ERROR for record in caplog.records) -def test_no_warning_for_properly_typed_router(caplog): - """Test that no warning is logged when router has proper type annotations.""" +def test_no_warning_for_explicit_contract_router_events(caplog): + """Test no warning is logged when router events are declared in the contract.""" import logging from typing import Literal @@ -639,23 +733,39 @@ def test_no_warning_for_properly_typed_router(caplog): return "started" @router(begin) - def typed_router(self) -> Literal["path_a", "path_b"]: - return "path_a" + def typed_router(self) -> Literal["event_a", "event_b"]: + return "event_a" - @listen("path_a") + @listen("event_a") def handle_a(self): return "a" - @listen("path_b") + @listen("event_b") def handle_b(self): return "b" flow = ProperlyTypedRouterFlow() + _attach_flow_definition( + ProperlyTypedRouterFlow, + { + "begin": {"start": True}, + "typed_router": { + "listen": "begin", + "router": True, + "emit": ["event_a", "event_b"], + }, + "handle_a": {"listen": "event_a"}, + "handle_b": {"listen": "event_b"}, + }, + ) with caplog.at_level(logging.WARNING): build_flow_structure(flow) # No warnings should be logged warning_messages = [r.message for r in caplog.records if r.levelno >= logging.WARNING] - assert not any("Could not determine return paths" in msg for msg in warning_messages) - assert not any("Found listeners waiting for triggers" in msg for msg in warning_messages) \ No newline at end of file + assert not any("Router events for" in msg for msg in warning_messages) + assert not any( + "Static visualization could not match listener triggers" in msg + for msg in warning_messages + ) diff --git a/lib/crewai/tests/test_human_feedback_decorator.py b/lib/crewai/tests/test_human_feedback_decorator.py index 68428ee71..63fe56f53 100644 --- a/lib/crewai/tests/test_human_feedback_decorator.py +++ b/lib/crewai/tests/test_human_feedback_decorator.py @@ -13,7 +13,7 @@ from unittest.mock import MagicMock, patch import pytest -from crewai.flow import Flow, human_feedback, listen, start +from crewai.flow import Flow, human_feedback, listen, persist, start from crewai.flow.human_feedback import ( HumanFeedbackConfig, HumanFeedbackResult, @@ -79,7 +79,7 @@ class TestHumanFeedbackValidation: assert hasattr(test_method, "__human_feedback_config__") assert test_method.__is_router__ is True - assert test_method.__router_paths__ == ["approve", "reject"] + assert test_method.__router_emit__ == ["approve", "reject"] def test_valid_configuration_without_routing(self): """Test that valid configuration without routing doesn't raise.""" @@ -91,6 +91,22 @@ class TestHumanFeedbackValidation: assert hasattr(test_method, "__human_feedback_config__") assert not hasattr(test_method, "__is_router__") or not test_method.__is_router__ + def test_persist_preserves_human_feedback_llm_attribute(self): + """Test @persist preserves the live LLM stashed by @human_feedback.""" + llm = object() + + @persist() + @human_feedback( + message="Review this:", + emit=["approve", "reject"], + llm=llm, + ) + def test_method(self): + return "output" + + assert hasattr(test_method, "_human_feedback_llm") + assert test_method._human_feedback_llm is llm + class TestHumanFeedbackConfig: """Tests for HumanFeedbackConfig dataclass.""" @@ -189,7 +205,7 @@ class TestDecoratorAttributePreservation: return "output" assert review_method.__is_router__ is True - assert review_method.__router_paths__ == ["approved", "rejected"] + assert review_method.__router_emit__ == ["approved", "rejected"] class TestAsyncSupport: diff --git a/lib/crewai/tests/test_human_feedback_integration.py b/lib/crewai/tests/test_human_feedback_integration.py index d8cdf3f6c..8036fdb90 100644 --- a/lib/crewai/tests/test_human_feedback_integration.py +++ b/lib/crewai/tests/test_human_feedback_integration.py @@ -778,14 +778,14 @@ class TestEdgeCases: class TestLLMConfigPreservation: """Tests that LLM config is preserved through @human_feedback serialization. - PR #4970 introduced _hf_llm stashing so the live LLM object survives + PR #4970 introduced _human_feedback_llm stashing so the live LLM object survives decorator wrapping for same-process resume. The serialization path (_serialize_llm_for_context / _deserialize_llm_from_context) preserves config for cross-process resume. """ - def test_hf_llm_stashed_on_wrapper_with_llm_instance(self): - """Test that passing an LLM instance stashes it on the wrapper as _hf_llm.""" + def test_human_feedback_llm_stashed_on_wrapper_with_llm_instance(self): + """Test that passing an LLM instance stashes it on the wrapper as _human_feedback_llm.""" from crewai.llm import LLM llm_instance = LLM(model="gpt-4o-mini", temperature=0.42) @@ -801,11 +801,11 @@ class TestLLMConfigPreservation: return "content" method = ConfigFlow.review - assert hasattr(method, "_hf_llm"), "_hf_llm not found on wrapper" - assert method._hf_llm is llm_instance, "_hf_llm is not the same object" + assert hasattr(method, "_human_feedback_llm"), "_human_feedback_llm not found on wrapper" + assert method._human_feedback_llm is llm_instance, "_human_feedback_llm is not the same object" - def test_hf_llm_preserved_on_listen_method(self): - """Test that _hf_llm is preserved when @human_feedback is on a @listen method.""" + def test_human_feedback_llm_preserved_on_listen_method(self): + """Test that _human_feedback_llm is preserved when @human_feedback is on a @listen method.""" from crewai.llm import LLM llm_instance = LLM(model="gpt-4o-mini", temperature=0.7) @@ -825,11 +825,11 @@ class TestLLMConfigPreservation: return "content" method = ListenConfigFlow.review - assert hasattr(method, "_hf_llm") - assert method._hf_llm is llm_instance + assert hasattr(method, "_human_feedback_llm") + assert method._human_feedback_llm is llm_instance - def test_hf_llm_accessible_on_instance(self): - """Test that _hf_llm survives Flow instantiation (bound method access).""" + def test_human_feedback_llm_accessible_on_instance(self): + """Test that _human_feedback_llm survives Flow instantiation (bound method access).""" from crewai.llm import LLM llm_instance = LLM(model="gpt-4o-mini", temperature=0.42) @@ -846,8 +846,8 @@ class TestLLMConfigPreservation: flow = InstanceFlow() instance_method = flow.review - assert hasattr(instance_method, "_hf_llm") - assert instance_method._hf_llm is llm_instance + assert hasattr(instance_method, "_human_feedback_llm") + assert instance_method._human_feedback_llm is llm_instance def test_serialize_llm_preserves_config_fields(self): """Test that _serialize_llm_for_context captures temperature, base_url, etc.""" From aed69237d4eb7ad6ba8fe3d0b4777979fe949869 Mon Sep 17 00:00:00 2001 From: alex-clawd Date: Thu, 4 Jun 2026 05:22:41 -0700 Subject: [PATCH 09/18] docs: add NVIDIA Nemotron LLM guide (#6037) --- docs/en/concepts/llms.mdx | 55 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/docs/en/concepts/llms.mdx b/docs/en/concepts/llms.mdx index 4a4de3f1a..85921b6ea 100644 --- a/docs/en/concepts/llms.mdx +++ b/docs/en/concepts/llms.mdx @@ -952,6 +952,61 @@ In this section, you'll find detailed examples that help you select, configure, ``` + + 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. + + + `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. + + + **Hosted NVIDIA NIM usage:** + ```toml Code + NVIDIA_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]' + ``` + + NVIDIA NIM enables you to run powerful LLMs locally on your Windows machine using WSL2 (Windows Subsystem for Linux). From 75dad212a2f77e8456d26258574ca4a0a53bb26c Mon Sep 17 00:00:00 2001 From: Vini Brasil Date: Thu, 4 Jun 2026 15:02:06 -0300 Subject: [PATCH 10/18] Split flow DSL monolith into focused decorator modules (#6040) The Flow DSL lived in one 1033-line `dsl.py` that mixed every decorator (`@start`/`@listen`/`@router`), the `human_feedback` decorator, condition combinators, and FlowDefinition extraction helpers in a single file. Split it into a `dsl/` package where each decorator gets its own module (`start.py` 68 lines, `listen.py` 55, `router.py` 164, `human_feedback.py` 98) and the shared extraction/condition helpers stay in `utils.py`. The public API is re-exported from `dsl/__init__.py`, so import paths are unchanged. This is simpler because each decorator is now read and changed in isolation instead of scanning a 1000-line file to find one of them, and router-specific annotation parsing no longer sits next to unrelated start/listen logic. --- lib/crewai/src/crewai/flow/__init__.py | 2 +- lib/crewai/src/crewai/flow/dsl/__init__.py | 32 ++ lib/crewai/src/crewai/flow/dsl/_conditions.py | 276 ++++++++++ .../src/crewai/flow/dsl/_human_feedback.py | 98 ++++ lib/crewai/src/crewai/flow/dsl/_listen.py | 55 ++ lib/crewai/src/crewai/flow/dsl/_router.py | 164 ++++++ lib/crewai/src/crewai/flow/dsl/_start.py | 68 +++ .../src/crewai/flow/{dsl.py => dsl/_utils.py} | 520 +----------------- lib/crewai/src/crewai/flow/human_feedback.py | 175 +----- lib/crewai/src/crewai/flow/runtime.py | 8 +- lib/crewai/tests/test_flow_definition.py | 10 +- 11 files changed, 744 insertions(+), 664 deletions(-) create mode 100644 lib/crewai/src/crewai/flow/dsl/__init__.py create mode 100644 lib/crewai/src/crewai/flow/dsl/_conditions.py create mode 100644 lib/crewai/src/crewai/flow/dsl/_human_feedback.py create mode 100644 lib/crewai/src/crewai/flow/dsl/_listen.py create mode 100644 lib/crewai/src/crewai/flow/dsl/_router.py create mode 100644 lib/crewai/src/crewai/flow/dsl/_start.py rename lib/crewai/src/crewai/flow/{dsl.py => dsl/_utils.py} (51%) diff --git a/lib/crewai/src/crewai/flow/__init__.py b/lib/crewai/src/crewai/flow/__init__.py index 2475dd226..364d2ab49 100644 --- a/lib/crewai/src/crewai/flow/__init__.py +++ b/lib/crewai/src/crewai/flow/__init__.py @@ -9,9 +9,9 @@ from crewai.flow.conversation import ( ConversationalConfig, ConversationalInputs, ) +from crewai.flow.dsl import HumanFeedbackResult, human_feedback from crewai.flow.flow import Flow, and_, listen, or_, router, start from crewai.flow.flow_config import flow_config -from crewai.flow.human_feedback import HumanFeedbackResult, human_feedback from crewai.flow.input_provider import InputProvider, InputResponse from crewai.flow.persistence import persist from crewai.flow.visualization import ( diff --git a/lib/crewai/src/crewai/flow/dsl/__init__.py b/lib/crewai/src/crewai/flow/dsl/__init__.py new file mode 100644 index 000000000..1dfb14ddb --- /dev/null +++ b/lib/crewai/src/crewai/flow/dsl/__init__.py @@ -0,0 +1,32 @@ +"""Flow DSL: the Python authoring layer for Flows. + +Provides the ``@start`` / ``@listen`` / ``@router`` decorators and the +``or_`` / ``and_`` condition combinators used to write Flow classes in +Python. The DSL is one way to produce a Flow Structure: this package +extracts a :class:`~crewai.flow.flow_definition.FlowDefinition` from a +Python Flow class. Execution is handled by ``runtime``. +""" + +from crewai.flow.dsl._conditions import and_, or_ +from crewai.flow.dsl._human_feedback import ( + HumanFeedbackResult, + human_feedback, +) +from crewai.flow.dsl._listen import listen +from crewai.flow.dsl._router import router +from crewai.flow.dsl._start import start +from crewai.flow.dsl._utils import ( + build_flow_definition as build_flow_definition, + extract_flow_definition as extract_flow_definition, +) + + +__all__ = [ + "HumanFeedbackResult", + "and_", + "human_feedback", + "listen", + "or_", + "router", + "start", +] diff --git a/lib/crewai/src/crewai/flow/dsl/_conditions.py b/lib/crewai/src/crewai/flow/dsl/_conditions.py new file mode 100644 index 000000000..f2051a63b --- /dev/null +++ b/lib/crewai/src/crewai/flow/dsl/_conditions.py @@ -0,0 +1,276 @@ +"""Flow DSL condition primitives. + +Type guards, the public ``or_`` / ``and_`` combinators, and the conversions +between runtime conditions, normalized conditions, and the +``FlowDefinitionCondition`` shape stored on a :class:`FlowDefinition`. These are +the lower layer of the DSL: the decorators and the definition builder +(``_utils``) build on top of them, so this module imports nothing from its +siblings. +""" + +from __future__ import annotations + +from collections.abc import Callable, Sequence +from typing import Any + +from typing_extensions import TypeIs + +from crewai.flow.constants import AND_CONDITION, OR_CONDITION +from crewai.flow.flow_definition import FlowDefinitionCondition +from crewai.flow.flow_wrappers import ( + FlowCondition, + FlowConditions, + SimpleFlowCondition, +) +from crewai.flow.types import FlowMethodName + + +def is_simple_flow_condition(obj: Any) -> TypeIs[SimpleFlowCondition]: + """Check if the object is a ``(condition_type, methods)`` tuple.""" + return ( + isinstance(obj, tuple) + and len(obj) == 2 + and isinstance(obj[0], str) + and isinstance(obj[1], list) + ) + + +def is_flow_condition_dict(obj: Any) -> TypeIs[FlowCondition]: + """Check if the object matches the FlowCondition structure.""" + if not isinstance(obj, dict): + return False + + type_value = obj.get("type") + if type_value not in ("AND", "OR"): + return False + + if "conditions" in obj: + conditions = obj["conditions"] + if not isinstance(conditions, list): + return False + for cond in conditions: + if not ( + isinstance(cond, str) + or (isinstance(cond, dict) and is_flow_condition_dict(cond)) + ): + return False + + if "methods" in obj: + methods = obj["methods"] + if not (isinstance(methods, list) and all(isinstance(m, str) for m in methods)): + return False + + allowed_keys = {"type", "conditions", "methods"} + if not set(obj).issubset(allowed_keys): + return False + + return True + + +def _method_reference_name(value: Any) -> FlowMethodName | None: + name = getattr(value, "__name__", None) + if callable(value) and isinstance(name, str): + return FlowMethodName(name) + return None + + +def _normalize_condition( + condition: FlowConditions | FlowCondition | str, +) -> FlowCondition: + if isinstance(condition, str): + return {"type": OR_CONDITION, "conditions": [FlowMethodName(condition)]} + if is_flow_condition_dict(condition): + if "conditions" in condition: + return condition + if "methods" in condition: + return {"type": condition["type"], "conditions": condition["methods"]} + return condition + if isinstance(condition, list) and all( + isinstance(item, str) or is_flow_condition_dict(item) for item in condition + ): + return {"type": OR_CONDITION, "conditions": condition} + + raise ValueError(f"Cannot normalize condition: {condition}") + + +def _extract_all_methods_recursive( + condition: str | FlowCondition | dict[str, Any] | list[Any], + flow: Any | None = None, +) -> list[FlowMethodName]: + if isinstance(condition, str): + if flow is not None: + if condition in flow._methods: + return [FlowMethodName(condition)] + return [] + return [FlowMethodName(condition)] + if is_flow_condition_dict(condition): + normalized = _normalize_condition(condition) + methods = [] + for sub_cond in normalized.get("conditions", []): + methods.extend(_extract_all_methods_recursive(sub_cond, flow)) + return methods + if isinstance(condition, list): + methods = [] + for item in condition: + methods.extend(_extract_all_methods_recursive(item, flow)) + return methods + return [] + + +def _extract_all_methods( + condition: str | FlowCondition | dict[str, Any] | list[Any], +) -> list[FlowMethodName]: + if isinstance(condition, str): + return [FlowMethodName(condition)] + if is_flow_condition_dict(condition): + normalized = _normalize_condition(condition) + cond_type = normalized.get("type", OR_CONDITION) + + if cond_type == AND_CONDITION: + return [ + FlowMethodName(sub_cond) + for sub_cond in normalized.get("conditions", []) + if isinstance(sub_cond, str) + ] + return [] + if isinstance(condition, list): + methods = [] + for item in condition: + methods.extend(_extract_all_methods(item)) + return methods + return [] + + +def _condition_trigger( + condition: str | FlowCondition | Callable[..., Any], +) -> FlowMethodName | FlowCondition: + if isinstance(condition, str): + return FlowMethodName(condition) + if is_flow_condition_dict(condition): + return condition + method_name = _method_reference_name(condition) + if method_name is not None: + return method_name + raise ValueError("Invalid condition") + + +def _condition_triggers( + conditions: Sequence[str | FlowCondition | Callable[..., Any]], + error_message: str, +) -> FlowConditions: + try: + return [_condition_trigger(condition) for condition in conditions] + except ValueError as exc: + raise ValueError(error_message) from exc + + +def _definition_condition_from_runtime(condition: Any) -> FlowDefinitionCondition: + if isinstance(condition, str): + return str(condition) + method_name = _method_reference_name(condition) + if method_name is not None: + return str(method_name) + if is_flow_condition_dict(condition): + normalized = _normalize_condition(condition) + key = "and" if normalized.get("type") == AND_CONDITION else "or" + return { + key: [ + _definition_condition_from_runtime(sub_condition) + for sub_condition in normalized.get("conditions", []) + ] + } + if isinstance(condition, list): + return {"or": [_definition_condition_from_runtime(item) for item in condition]} + return str(condition) + + +def or_(*conditions: str | FlowCondition | Callable[..., Any]) -> FlowCondition: + """Combines multiple conditions with OR logic for flow control. + + Creates a condition that is satisfied when any of the specified conditions + are met. This is used with @start, @listen, or @router decorators to create + complex triggering conditions. + + Args: + conditions: Variable number of conditions that can be method names, existing condition dictionaries, or method references. + + Returns: + A condition dictionary with format {"type": "OR", "conditions": list_of_conditions} where each condition can be a string (method name) or a nested dict + + Raises: + ValueError: If condition format is invalid. + + Examples: + >>> @listen(or_("success", "timeout")) + >>> def handle_completion(self): + ... pass + + >>> @listen(or_(and_("step1", "step2"), "step3")) + >>> def handle_nested(self): + ... pass + """ + processed_triggers = _condition_triggers(conditions, "Invalid condition in or_()") + return {"type": OR_CONDITION, "conditions": processed_triggers} + + +def and_(*conditions: str | FlowCondition | Callable[..., Any]) -> FlowCondition: + """Combines multiple conditions with AND logic for flow control. + + Creates a condition that is satisfied only when all specified conditions + are met. This is used with @start, @listen, or @router decorators to create + complex triggering conditions. + + Args: + *conditions: Variable number of conditions that can be method names, existing condition dictionaries, or method references. + + Returns: + A condition dictionary with format {"type": "AND", "conditions": list_of_conditions} + where each condition can be a string (method name) or a nested dict + + Raises: + ValueError: If any condition is invalid. + + Examples: + >>> @listen(and_("validated", "processed")) + >>> def handle_complete_data(self): + ... pass + + >>> @listen(and_(or_("step1", "step2"), "step3")) + >>> def handle_nested(self): + ... pass + """ + processed_triggers = _condition_triggers(conditions, "Invalid condition in and_()") + return {"type": AND_CONDITION, "conditions": processed_triggers} + + +def _runtime_condition_from_definition( + condition: FlowDefinitionCondition, +) -> FlowMethodName | FlowCondition: + if isinstance(condition, str): + return FlowMethodName(condition) + if is_flow_condition_dict(condition): + return condition + + if "and" in condition: + return { + "type": AND_CONDITION, + "conditions": [ + _runtime_condition_from_definition(item) + for item in condition.get("and", []) + ], + } + return { + "type": OR_CONDITION, + "conditions": [ + _runtime_condition_from_definition(item) for item in condition.get("or", []) + ], + } + + +def _runtime_listener_condition_from_definition( + condition: FlowDefinitionCondition, +) -> SimpleFlowCondition | FlowCondition: + runtime_condition = _runtime_condition_from_definition(condition) + if isinstance(runtime_condition, str): + return (OR_CONDITION, [FlowMethodName(str(runtime_condition))]) + return runtime_condition diff --git a/lib/crewai/src/crewai/flow/dsl/_human_feedback.py b/lib/crewai/src/crewai/flow/dsl/_human_feedback.py new file mode 100644 index 000000000..9fa2b7e67 --- /dev/null +++ b/lib/crewai/src/crewai/flow/dsl/_human_feedback.py @@ -0,0 +1,98 @@ +from __future__ import annotations + +from collections.abc import Callable, Sequence +from typing import TYPE_CHECKING, Any, TypeVar + +from crewai.flow.flow_definition import FlowMethodDefinition +from crewai.flow.human_feedback import ( + HumanFeedbackConfig, + HumanFeedbackResult, + _build_human_feedback_runtime_decorator, +) + + +if TYPE_CHECKING: + from crewai.flow.async_feedback.types import HumanFeedbackProvider + from crewai.llms.base_llm import BaseLLM + + +F = TypeVar("F", bound=Callable[..., Any]) + +__all__ = ["HumanFeedbackResult", "human_feedback"] + + +def _stamp_human_feedback_metadata( + wrapper: Any, + func: Callable[..., Any], + config: HumanFeedbackConfig, +) -> None: + for attr in [ + "__is_start_method__", + "__trigger_methods__", + "__condition_type__", + "__trigger_condition__", + "__is_flow_method__", + "__flow_persistence_config__", + "__is_router__", + "__router_emit__", + "__flow_method_definition__", + ]: + if hasattr(func, attr): + setattr(wrapper, attr, getattr(func, attr)) + + wrapper.__human_feedback_config__ = config + wrapper.__is_flow_method__ = True + + if config.emit: + wrapper.__is_router__ = True + wrapper.__router_emit__ = list(config.emit) + fragment = getattr(wrapper, "__flow_method_definition__", None) + if isinstance(fragment, FlowMethodDefinition): + wrapper.__flow_method_definition__ = fragment.model_copy( + update={"router": True, "emit": list(config.emit)} + ) + + wrapper._human_feedback_llm = config.llm + + +def human_feedback( + message: str, + emit: Sequence[str] | None = None, + llm: str | BaseLLM | None = "gpt-4o-mini", + default_outcome: str | None = None, + metadata: dict[str, Any] | None = None, + provider: HumanFeedbackProvider | None = None, + learn: bool = False, + learn_source: str = "hitl", + learn_strict: bool = False, +) -> Callable[[F], F]: + """Decorator for Flow methods that require human feedback.""" + runtime_decorator = _build_human_feedback_runtime_decorator( + message=message, + emit=emit, + llm=llm, + default_outcome=default_outcome, + metadata=metadata, + provider=provider, + learn=learn, + learn_source=learn_source, + learn_strict=learn_strict, + ) + config = HumanFeedbackConfig( + message=message, + emit=emit, + llm=llm, + default_outcome=default_outcome, + metadata=metadata, + provider=provider, + learn=learn, + learn_source=learn_source, + learn_strict=learn_strict, + ) + + def decorator(func: F) -> F: + wrapper = runtime_decorator(func) + _stamp_human_feedback_metadata(wrapper, func, config) + return wrapper + + return decorator diff --git a/lib/crewai/src/crewai/flow/dsl/_listen.py b/lib/crewai/src/crewai/flow/dsl/_listen.py new file mode 100644 index 000000000..16a93a175 --- /dev/null +++ b/lib/crewai/src/crewai/flow/dsl/_listen.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +from collections.abc import Callable +from typing import Any + +from crewai.flow.dsl._conditions import _definition_condition_from_runtime +from crewai.flow.dsl._utils import ( + P, + R, + _set_flow_method_definition, + _set_trigger_metadata, +) +from crewai.flow.flow_definition import FlowMethodDefinition +from crewai.flow.flow_wrappers import FlowCondition, ListenMethod + + +def listen( + condition: str | FlowCondition | Callable[..., Any], +) -> Callable[[Callable[P, R]], ListenMethod[P, R]]: + """Creates a listener that executes when specified conditions are met. + + This decorator sets up a method to execute in response to other method + executions in the flow. It supports both simple and complex triggering + conditions. + + Args: + condition: Specifies when the listener should execute. + + Returns: + A decorator function that wraps the method as a flow listener and preserves its signature. + + Raises: + ValueError: If the condition format is invalid. + + Examples: + >>> @listen("process_data") + >>> def handle_processed_data(self): + ... pass + + >>> @listen("method_name") + >>> def handle_completion(self): + ... pass + """ + + def decorator(func: Callable[P, R]) -> ListenMethod[P, R]: + wrapper = ListenMethod(func) + + _set_flow_method_definition( + wrapper, + FlowMethodDefinition(listen=_definition_condition_from_runtime(condition)), + ) + _set_trigger_metadata(wrapper, condition) + return wrapper + + return decorator diff --git a/lib/crewai/src/crewai/flow/dsl/_router.py b/lib/crewai/src/crewai/flow/dsl/_router.py new file mode 100644 index 000000000..11ffc9d0b --- /dev/null +++ b/lib/crewai/src/crewai/flow/dsl/_router.py @@ -0,0 +1,164 @@ +from __future__ import annotations + +from collections.abc import Callable, Sequence +from enum import Enum +import inspect +from types import UnionType +from typing import ( + Any, + Literal, + Union, + get_args, + get_origin, + get_type_hints, +) + +from crewai.flow.dsl._conditions import _definition_condition_from_runtime +from crewai.flow.dsl._utils import ( + P, + R, + _set_flow_method_definition, + _set_trigger_metadata, +) +from crewai.flow.flow_definition import FlowMethodDefinition +from crewai.flow.flow_wrappers import FlowCondition, RouterMethod + + +def _unwrap_function(function: Any) -> Any: + if hasattr(function, "__func__"): + function = function.__func__ + + if hasattr(function, "__wrapped__"): + wrapped = function.__wrapped__ + if hasattr(wrapped, "unwrap"): + return wrapped.unwrap() + return wrapped + + if hasattr(function, "unwrap"): + return function.unwrap() + + return function + + +def _string_values_from_annotation(annotation: Any) -> list[str]: + if annotation is inspect.Signature.empty or isinstance(annotation, str): + return [] + if isinstance(annotation, type) and issubclass(annotation, Enum): + return [member.value for member in annotation if isinstance(member.value, str)] + + origin = get_origin(annotation) + if origin is None: + return [] + + args = get_args(annotation) + if origin is Literal or getattr(origin, "__name__", "") == "Literal": + return [arg for arg in args if isinstance(arg, str)] + + if not ( + origin is Union + or origin is UnionType + or getattr(origin, "__name__", "") == "Annotated" + ): + return [] + + values: list[str] = [] + for arg in args: + values.extend(_string_values_from_annotation(arg)) + return values + + +def _return_annotation(function: Any) -> Any: + unwrapped = _unwrap_function(function) + + try: + return get_type_hints(unwrapped, include_extras=True).get( + "return", inspect.Signature.empty + ) + except (NameError, TypeError, ValueError): + try: + return inspect.signature(unwrapped).return_annotation + except (TypeError, ValueError): + return inspect.Signature.empty + + +def _get_router_return_events(function: Any) -> list[str] | None: + values = _string_values_from_annotation(_return_annotation(function)) + return list(dict.fromkeys(values)) if values else None + + +def _normalize_router_emit(value: Sequence[Any] | str) -> list[str]: + if isinstance(value, str): + return [str(value)] + return list(dict.fromkeys(str(item) for item in value)) + + +def router( + condition: str | FlowCondition | Callable[..., Any], + *, + emit: Sequence[str] | str | None = None, +) -> Callable[[Callable[P, R]], RouterMethod[P, R]]: + """Creates a routing method that directs flow execution based on conditions. + + This decorator marks a method as a router, which can dynamically determine + the next steps in the flow based on its return value. Routers are triggered + by specified conditions and can return constants that emit downstream events. + + Args: + condition: Specifies when the router should execute. Can be: + - str: Name of a method that triggers this router + - FlowCondition: Result from or_() or and_(), including nested conditions + - Callable[..., Any]: A method reference that triggers this router + emit: Optional explicit router output events for static FlowDefinition + and visualization. If omitted, Literal/Enum return annotations are + used when available. + + Returns: + A decorator function that wraps the method as a router and preserves its signature. + + Raises: + ValueError: If the condition format is invalid. + + Examples: + >>> @router("check_status") + >>> def route_based_on_status(self): + ... if self.state.status == "success": + ... return "SUCCESS" + ... return "FAILURE" + + >>> @router(and_("validate", "process")) + >>> def complex_routing(self): + ... if all([self.state.valid, self.state.processed]): + ... return "CONTINUE" + ... return "STOP" + + >>> @router("check_status", emit=["SUCCESS", "FAILURE"]) + >>> def explicit_routing(self): + ... return "SUCCESS" + """ + + def decorator(func: Callable[P, R]) -> RouterMethod[P, R]: + wrapper = RouterMethod(func) + + if emit is not None: + router_events = _normalize_router_emit(emit) + else: + router_events = _get_router_return_events(func) or [] + + _set_flow_method_definition( + wrapper, + FlowMethodDefinition( + listen=_definition_condition_from_runtime(condition), + router=True, + emit=router_events or None, + ), + ) + + _set_trigger_metadata(wrapper, condition) + + if emit is not None: + wrapper.__router_emit__ = router_events + elif router_events: + wrapper.__router_emit__ = router_events + return wrapper + + return decorator diff --git a/lib/crewai/src/crewai/flow/dsl/_start.py b/lib/crewai/src/crewai/flow/dsl/_start.py new file mode 100644 index 000000000..652a8332f --- /dev/null +++ b/lib/crewai/src/crewai/flow/dsl/_start.py @@ -0,0 +1,68 @@ +from __future__ import annotations + +from collections.abc import Callable +from typing import Any + +from crewai.flow.dsl._conditions import _definition_condition_from_runtime +from crewai.flow.dsl._utils import ( + P, + R, + _set_flow_method_definition, + _set_trigger_metadata, +) +from crewai.flow.flow_definition import FlowMethodDefinition +from crewai.flow.flow_wrappers import FlowCondition, StartMethod + + +def start( + condition: str | FlowCondition | Callable[..., Any] | None = None, +) -> Callable[[Callable[P, R]], StartMethod[P, R]]: + """Marks a method as a flow's starting point. + + This decorator designates a method as an entry point for the flow execution. + It can optionally specify conditions that trigger the start based on other + method executions. + + Args: + condition: Defines when the start method should execute. Can be: + - str: Name of a method that triggers this start + - FlowCondition: Result from or_() or and_(), including nested conditions + - Callable[..., Any]: A method reference that triggers this start + Default is None, meaning unconditional start. + + Returns: + A decorator function that wraps the method as a flow start point and preserves its signature. + + Raises: + ValueError: If the condition format is invalid. + + Examples: + >>> @start() # Unconditional start + >>> def begin_flow(self): + ... pass + + >>> @start("method_name") # Start after specific method + >>> def conditional_start(self): + ... pass + + >>> @start(and_("method1", "method2")) # Start after multiple methods + >>> def complex_start(self): + ... pass + """ + + def decorator(func: Callable[P, R]) -> StartMethod[P, R]: + wrapper = StartMethod(func) + + if condition is not None: + _set_flow_method_definition( + wrapper, + FlowMethodDefinition( + start=_definition_condition_from_runtime(condition) + ), + ) + _set_trigger_metadata(wrapper, condition) + else: + _set_flow_method_definition(wrapper, FlowMethodDefinition(start=True)) + return wrapper + + return decorator diff --git a/lib/crewai/src/crewai/flow/dsl.py b/lib/crewai/src/crewai/flow/dsl/_utils.py similarity index 51% rename from lib/crewai/src/crewai/flow/dsl.py rename to lib/crewai/src/crewai/flow/dsl/_utils.py index 353d8f187..d23bc3886 100644 --- a/lib/crewai/src/crewai/flow/dsl.py +++ b/lib/crewai/src/crewai/flow/dsl/_utils.py @@ -1,35 +1,21 @@ -"""Flow DSL: the Python authoring layer for Flows. - -Provides the ``@start`` / ``@listen`` / ``@router`` decorators and the -``or_`` / ``and_`` condition combinators used to write Flow classes in -Python. The DSL is one way to produce a Flow Structure: this module -extracts a :class:`~crewai.flow.flow_definition.FlowDefinition` from a -Python Flow class. Execution is handled by ``runtime``. -""" - from __future__ import annotations from collections.abc import Callable, Sequence -from enum import Enum -import inspect import json import logging -from types import UnionType -from typing import ( - Any, - Literal, - ParamSpec, - TypeVar, - Union, - get_args, - get_origin, - get_type_hints, -) +from typing import Any, ParamSpec, TypeVar from pydantic import BaseModel from typing_extensions import TypeIs from crewai.flow.constants import AND_CONDITION, OR_CONDITION +from crewai.flow.dsl._conditions import ( + _definition_condition_from_runtime, + _extract_all_methods, + _method_reference_name, + _runtime_listener_condition_from_definition, + is_flow_condition_dict, +) from crewai.flow.flow_definition import ( FlowConfigDefinition, FlowDefinition, @@ -42,11 +28,9 @@ from crewai.flow.flow_definition import ( ) from crewai.flow.flow_wrappers import ( FlowCondition, - FlowConditions, FlowMethod, ListenMethod, RouterMethod, - SimpleFlowCondition, StartMethod, ) from crewai.flow.types import FlowMethodName @@ -57,21 +41,9 @@ R = TypeVar("R") logger = logging.getLogger(__name__) -__all__ = ["and_", "listen", "or_", "router", "start"] - _FLOW_METHOD_DEFINITION_ATTR = "__flow_method_definition__" -def is_simple_flow_condition(obj: Any) -> TypeIs[SimpleFlowCondition]: - """Check if the object is a ``(condition_type, methods)`` tuple.""" - return ( - isinstance(obj, tuple) - and len(obj) == 2 - and isinstance(obj[0], str) - and isinstance(obj[1], list) - ) - - def is_flow_method(obj: Any) -> TypeIs[FlowMethod[Any, Any]]: """Check if the object carries Flow method wrapper metadata.""" return ( @@ -89,184 +61,10 @@ def _should_include_flow_method(flow_class: type, method: Any) -> bool: return True -def is_flow_condition_dict(obj: Any) -> TypeIs[FlowCondition]: - """Check if the object matches the FlowCondition structure.""" - if not isinstance(obj, dict): - return False - - type_value = obj.get("type") - if type_value not in ("AND", "OR"): - return False - - if "conditions" in obj: - conditions = obj["conditions"] - if not isinstance(conditions, list): - return False - for cond in conditions: - if not ( - isinstance(cond, str) - or (isinstance(cond, dict) and is_flow_condition_dict(cond)) - ): - return False - - if "methods" in obj: - methods = obj["methods"] - if not (isinstance(methods, list) and all(isinstance(m, str) for m in methods)): - return False - - allowed_keys = {"type", "conditions", "methods"} - if not set(obj).issubset(allowed_keys): - return False - - return True - - -def _method_reference_name(value: Any) -> FlowMethodName | None: - name = getattr(value, "__name__", None) - if callable(value) and isinstance(name, str): - return FlowMethodName(name) - return None - - def _flow_method_names(values: Sequence[Any]) -> list[FlowMethodName]: return [FlowMethodName(str(value)) for value in values] -def _extract_all_methods_recursive( - condition: str | FlowCondition | dict[str, Any] | list[Any], - flow: Any | None = None, -) -> list[FlowMethodName]: - if isinstance(condition, str): - if flow is not None: - if condition in flow._methods: - return [FlowMethodName(condition)] - return [] - return [FlowMethodName(condition)] - if is_flow_condition_dict(condition): - normalized = _normalize_condition(condition) - methods = [] - for sub_cond in normalized.get("conditions", []): - methods.extend(_extract_all_methods_recursive(sub_cond, flow)) - return methods - if isinstance(condition, list): - methods = [] - for item in condition: - methods.extend(_extract_all_methods_recursive(item, flow)) - return methods - return [] - - -def _normalize_condition( - condition: FlowConditions | FlowCondition | str, -) -> FlowCondition: - if isinstance(condition, str): - return {"type": OR_CONDITION, "conditions": [FlowMethodName(condition)]} - if is_flow_condition_dict(condition): - if "conditions" in condition: - return condition - if "methods" in condition: - return {"type": condition["type"], "conditions": condition["methods"]} - return condition - if isinstance(condition, list) and all( - isinstance(item, str) or is_flow_condition_dict(item) for item in condition - ): - return {"type": OR_CONDITION, "conditions": condition} - - raise ValueError(f"Cannot normalize condition: {condition}") - - -def _extract_all_methods( - condition: str | FlowCondition | dict[str, Any] | list[Any], -) -> list[FlowMethodName]: - if isinstance(condition, str): - return [FlowMethodName(condition)] - if is_flow_condition_dict(condition): - normalized = _normalize_condition(condition) - cond_type = normalized.get("type", OR_CONDITION) - - if cond_type == AND_CONDITION: - return [ - FlowMethodName(sub_cond) - for sub_cond in normalized.get("conditions", []) - if isinstance(sub_cond, str) - ] - return [] - if isinstance(condition, list): - methods = [] - for item in condition: - methods.extend(_extract_all_methods(item)) - return methods - return [] - - -def _unwrap_function(function: Any) -> Any: - if hasattr(function, "__func__"): - function = function.__func__ - - if hasattr(function, "__wrapped__"): - wrapped = function.__wrapped__ - if hasattr(wrapped, "unwrap"): - return wrapped.unwrap() - return wrapped - - if hasattr(function, "unwrap"): - return function.unwrap() - - return function - - -def _string_values_from_annotation(annotation: Any) -> list[str]: - if annotation is inspect.Signature.empty or isinstance(annotation, str): - return [] - if isinstance(annotation, type) and issubclass(annotation, Enum): - return [member.value for member in annotation if isinstance(member.value, str)] - - origin = get_origin(annotation) - if origin is None: - return [] - - args = get_args(annotation) - if origin is Literal or getattr(origin, "__name__", "") == "Literal": - return [arg for arg in args if isinstance(arg, str)] - - if not ( - origin is Union - or origin is UnionType - or getattr(origin, "__name__", "") == "Annotated" - ): - return [] - - values: list[str] = [] - for arg in args: - values.extend(_string_values_from_annotation(arg)) - return values - - -def _return_annotation(function: Any) -> Any: - unwrapped = _unwrap_function(function) - - try: - return get_type_hints(unwrapped, include_extras=True).get( - "return", inspect.Signature.empty - ) - except (NameError, TypeError, ValueError): - try: - return inspect.signature(unwrapped).return_annotation - except (TypeError, ValueError): - return inspect.Signature.empty - - -def _get_router_return_events(function: Any) -> list[str] | None: - values = _string_values_from_annotation(_return_annotation(function)) - return list(dict.fromkeys(values)) if values else None - - -def _normalize_router_emit(value: Sequence[Any] | str) -> list[str]: - if isinstance(value, str): - return [str(value)] - return list(dict.fromkeys(str(item) for item in value)) - - def _set_trigger_metadata( wrapper: StartMethod[P, R] | ListenMethod[P, R] | RouterMethod[P, R], condition: str | FlowCondition | Callable[..., Any], @@ -299,29 +97,6 @@ def _set_trigger_metadata( ) -def _condition_trigger( - condition: str | FlowCondition | Callable[..., Any], -) -> FlowMethodName | FlowCondition: - if isinstance(condition, str): - return FlowMethodName(condition) - if is_flow_condition_dict(condition): - return condition - method_name = _method_reference_name(condition) - if method_name is not None: - return method_name - raise ValueError("Invalid condition") - - -def _condition_triggers( - conditions: Sequence[str | FlowCondition | Callable[..., Any]], - error_message: str, -) -> FlowConditions: - try: - return [_condition_trigger(condition) for condition in conditions] - except ValueError as exc: - raise ValueError(error_message) from exc - - def _set_flow_method_definition( wrapper: StartMethod[P, R] | ListenMethod[P, R] | RouterMethod[P, R], definition: FlowMethodDefinition, @@ -338,232 +113,6 @@ def _get_flow_method_definition(method: Any) -> FlowMethodDefinition | None: return None -def start( - condition: str | FlowCondition | Callable[..., Any] | None = None, -) -> Callable[[Callable[P, R]], StartMethod[P, R]]: - """Marks a method as a flow's starting point. - - This decorator designates a method as an entry point for the flow execution. - It can optionally specify conditions that trigger the start based on other - method executions. - - Args: - condition: Defines when the start method should execute. Can be: - - str: Name of a method that triggers this start - - FlowCondition: Result from or_() or and_(), including nested conditions - - Callable[..., Any]: A method reference that triggers this start - Default is None, meaning unconditional start. - - Returns: - A decorator function that wraps the method as a flow start point and preserves its signature. - - Raises: - ValueError: If the condition format is invalid. - - Examples: - >>> @start() # Unconditional start - >>> def begin_flow(self): - ... pass - - >>> @start("method_name") # Start after specific method - >>> def conditional_start(self): - ... pass - - >>> @start(and_("method1", "method2")) # Start after multiple methods - >>> def complex_start(self): - ... pass - """ - - def decorator(func: Callable[P, R]) -> StartMethod[P, R]: - wrapper = StartMethod(func) - - if condition is not None: - _set_flow_method_definition( - wrapper, - FlowMethodDefinition( - start=_definition_condition_from_runtime(condition) - ), - ) - _set_trigger_metadata(wrapper, condition) - else: - _set_flow_method_definition(wrapper, FlowMethodDefinition(start=True)) - return wrapper - - return decorator - - -def listen( - condition: str | FlowCondition | Callable[..., Any], -) -> Callable[[Callable[P, R]], ListenMethod[P, R]]: - """Creates a listener that executes when specified conditions are met. - - This decorator sets up a method to execute in response to other method - executions in the flow. It supports both simple and complex triggering - conditions. - - Args: - condition: Specifies when the listener should execute. - - Returns: - A decorator function that wraps the method as a flow listener and preserves its signature. - - Raises: - ValueError: If the condition format is invalid. - - Examples: - >>> @listen("process_data") - >>> def handle_processed_data(self): - ... pass - - >>> @listen("method_name") - >>> def handle_completion(self): - ... pass - """ - - def decorator(func: Callable[P, R]) -> ListenMethod[P, R]: - wrapper = ListenMethod(func) - - _set_flow_method_definition( - wrapper, - FlowMethodDefinition(listen=_definition_condition_from_runtime(condition)), - ) - _set_trigger_metadata(wrapper, condition) - return wrapper - - return decorator - - -def router( - condition: str | FlowCondition | Callable[..., Any], - *, - emit: Sequence[str] | str | None = None, -) -> Callable[[Callable[P, R]], RouterMethod[P, R]]: - """Creates a routing method that directs flow execution based on conditions. - - This decorator marks a method as a router, which can dynamically determine - the next steps in the flow based on its return value. Routers are triggered - by specified conditions and can return constants that emit downstream events. - - Args: - condition: Specifies when the router should execute. Can be: - - str: Name of a method that triggers this router - - FlowCondition: Result from or_() or and_(), including nested conditions - - Callable[..., Any]: A method reference that triggers this router - emit: Optional explicit router output events for static FlowDefinition - and visualization. If omitted, Literal/Enum return annotations are - used when available. - - Returns: - A decorator function that wraps the method as a router and preserves its signature. - - Raises: - ValueError: If the condition format is invalid. - - Examples: - >>> @router("check_status") - >>> def route_based_on_status(self): - ... if self.state.status == "success": - ... return "SUCCESS" - ... return "FAILURE" - - >>> @router(and_("validate", "process")) - >>> def complex_routing(self): - ... if all([self.state.valid, self.state.processed]): - ... return "CONTINUE" - ... return "STOP" - - >>> @router("check_status", emit=["SUCCESS", "FAILURE"]) - >>> def explicit_routing(self): - ... return "SUCCESS" - """ - - def decorator(func: Callable[P, R]) -> RouterMethod[P, R]: - wrapper = RouterMethod(func) - - if emit is not None: - router_events = _normalize_router_emit(emit) - else: - router_events = _get_router_return_events(func) or [] - - _set_flow_method_definition( - wrapper, - FlowMethodDefinition( - listen=_definition_condition_from_runtime(condition), - router=True, - emit=router_events or None, - ), - ) - - _set_trigger_metadata(wrapper, condition) - - if emit is not None: - wrapper.__router_emit__ = router_events - elif router_events: - wrapper.__router_emit__ = router_events - return wrapper - - return decorator - - -def or_(*conditions: str | FlowCondition | Callable[..., Any]) -> FlowCondition: - """Combines multiple conditions with OR logic for flow control. - - Creates a condition that is satisfied when any of the specified conditions - are met. This is used with @start, @listen, or @router decorators to create - complex triggering conditions. - - Args: - conditions: Variable number of conditions that can be method names, existing condition dictionaries, or method references. - - Returns: - A condition dictionary with format {"type": "OR", "conditions": list_of_conditions} where each condition can be a string (method name) or a nested dict - - Raises: - ValueError: If condition format is invalid. - - Examples: - >>> @listen(or_("success", "timeout")) - >>> def handle_completion(self): - ... pass - - >>> @listen(or_(and_("step1", "step2"), "step3")) - >>> def handle_nested(self): - ... pass - """ - processed_triggers = _condition_triggers(conditions, "Invalid condition in or_()") - return {"type": OR_CONDITION, "conditions": processed_triggers} - - -def and_(*conditions: str | FlowCondition | Callable[..., Any]) -> FlowCondition: - """Combines multiple conditions with AND logic for flow control. - - Creates a condition that is satisfied only when all specified conditions - are met. This is used with @start, @listen, or @router decorators to create - complex triggering conditions. - - Args: - *conditions: Variable number of conditions that can be method names, existing condition dictionaries, or method references. - - Returns: - A condition dictionary with format {"type": "AND", "conditions": list_of_conditions} - where each condition can be a string (method name) or a nested dict - - Raises: - ValueError: If any condition is invalid. - - Examples: - >>> @listen(and_("validated", "processed")) - >>> def handle_complete_data(self): - ... pass - - >>> @listen(and_(or_("step1", "step2"), "step3")) - >>> def handle_nested(self): - ... pass - """ - processed_triggers = _condition_triggers(conditions, "Invalid condition in and_()") - return {"type": AND_CONDITION, "conditions": processed_triggers} - - def _object_ref(value: Any) -> str: target = value if isinstance(value, type) else type(value) module = getattr(target, "__module__", "") @@ -689,26 +238,6 @@ def _build_config_definition( return FlowConfigDefinition(**values) -def _definition_condition_from_runtime(condition: Any) -> FlowDefinitionCondition: - if isinstance(condition, str): - return str(condition) - method_name = _method_reference_name(condition) - if method_name is not None: - return str(method_name) - if is_flow_condition_dict(condition): - normalized = _normalize_condition(condition) - key = "and" if normalized.get("type") == AND_CONDITION else "or" - return { - key: [ - _definition_condition_from_runtime(sub_condition) - for sub_condition in normalized.get("conditions", []) - ] - } - if isinstance(condition, list): - return {"or": [_definition_condition_from_runtime(item) for item in condition]} - return str(condition) - - def _condition_from_method_metadata(method: Any) -> FlowDefinitionCondition | None: trigger_condition = getattr(method, "__trigger_condition__", None) if trigger_condition is not None: @@ -760,39 +289,6 @@ def _definition_trigger_condition( return None -def _runtime_condition_from_definition( - condition: FlowDefinitionCondition, -) -> FlowMethodName | FlowCondition: - if isinstance(condition, str): - return FlowMethodName(condition) - if is_flow_condition_dict(condition): - return condition - - if "and" in condition: - return { - "type": AND_CONDITION, - "conditions": [ - _runtime_condition_from_definition(item) - for item in condition.get("and", []) - ], - } - return { - "type": OR_CONDITION, - "conditions": [ - _runtime_condition_from_definition(item) for item in condition.get("or", []) - ], - } - - -def _runtime_listener_condition_from_definition( - condition: FlowDefinitionCondition, -) -> SimpleFlowCondition | FlowCondition: - runtime_condition = _runtime_condition_from_definition(condition) - if isinstance(runtime_condition, str): - return (OR_CONDITION, [FlowMethodName(str(runtime_condition))]) - return runtime_condition - - def _build_human_feedback_definition( method: Any, diagnostics: list[FlowDefinitionDiagnostic], diff --git a/lib/crewai/src/crewai/flow/human_feedback.py b/lib/crewai/src/crewai/flow/human_feedback.py index 65a61d5b2..010f9d6c7 100644 --- a/lib/crewai/src/crewai/flow/human_feedback.py +++ b/lib/crewai/src/crewai/flow/human_feedback.py @@ -65,7 +65,6 @@ from typing import TYPE_CHECKING, Any, TypeVar from pydantic import BaseModel, Field -from crewai.flow.flow_definition import FlowMethodDefinition from crewai.flow.flow_wrappers import FlowMethod @@ -222,7 +221,7 @@ class DistilledLessons(BaseModel): ) -def human_feedback( +def _build_human_feedback_runtime_decorator( message: str, emit: Sequence[str] | None = None, llm: str | BaseLLM | None = "gpt-4o-mini", @@ -233,102 +232,6 @@ def human_feedback( learn_source: str = "hitl", learn_strict: bool = False, ) -> Callable[[F], F]: - """Decorator for Flow methods that require human feedback. - - This decorator wraps a Flow method to: - 1. Execute the method and capture its output - 2. Display the output to the human with a feedback request - 3. Collect the human's free-form feedback - 4. Optionally collapse the feedback to a predefined outcome using an LLM - 5. Store the result for access by downstream methods - - When `emit` is specified, the decorator acts as a router, and the - collapsed outcome triggers the appropriate @listen decorated method. - - Supports both synchronous (blocking) and asynchronous (non-blocking) - feedback collection through the `provider` parameter. If no provider - is specified, defaults to synchronous console input. - - Args: - message: The message shown to the human when requesting feedback. - This should clearly explain what kind of feedback is expected. - emit: Optional sequence of outcome strings. When provided, the - human's feedback will be collapsed to one of these outcomes - using the specified LLM. The outcome then triggers @listen - methods that match. - llm: The LLM model to use for collapsing feedback to outcomes. - Required when emit is specified. Can be a model string - like "gpt-4o-mini" or a BaseLLM instance. - default_outcome: The outcome to use when the human provides no - feedback (empty input). Must be one of the emit values - if emit is specified. - metadata: Optional metadata for enterprise integrations. This is - passed through to the HumanFeedbackResult and can be used - by enterprise forks for features like Slack/Teams integration. - provider: Optional HumanFeedbackProvider for custom feedback - collection. Use this for async workflows that integrate with - external systems like Slack, Teams, or webhooks. When the - provider raises HumanFeedbackPending, the flow pauses and - can be resumed later with Flow.resume(). - learn: Enable HITL learning. Recall past lessons to pre-review - output before the human sees it, and distill new lessons - from feedback after. - learn_source: Memory source tag for stored/recalled lessons. - learn_strict: When True, re-raise exceptions from the pre-review - and distillation steps instead of falling back to raw output. - Default False preserves graceful degradation; failures are - always logged via ``logger.warning`` regardless of this flag. - - Returns: - A decorator function that wraps the method with human feedback - collection logic. - - Raises: - ValueError: If emit is specified but llm is not provided. - ValueError: If default_outcome is specified but emit is not. - ValueError: If default_outcome is not in the emit list. - HumanFeedbackPending: When an async provider pauses execution. - - Example: - Basic feedback without routing: - ```python - @start() - @human_feedback(message="Please review this output:") - def generate_content(self): - return "Generated content..." - ``` - - With routing based on feedback: - ```python - @start() - @human_feedback( - message="Review and approve or reject:", - emit=["approved", "rejected", "needs_revision"], - llm="gpt-4o-mini", - default_outcome="needs_revision", - ) - def review_document(self): - return document_content - - - @listen("approved") - def publish(self): - print(f"Publishing: {self.last_human_feedback.output}") - ``` - - Async feedback with custom provider: - ```python - @start() - @human_feedback( - message="Review this content:", - emit=["approved", "rejected"], - llm="gpt-4o-mini", - provider=SlackProvider(channel="#reviews"), - ) - def generate_content(self): - return "Content to review..." - ``` - """ if emit is not None: if not llm: raise ValueError( @@ -631,55 +534,33 @@ def human_feedback( wrapper = sync_wrapper - for attr in [ - "__is_start_method__", - "__trigger_methods__", - "__condition_type__", - "__trigger_condition__", - "__is_flow_method__", - "__flow_persistence_config__", - "__is_router__", - "__router_emit__", - "__flow_method_definition__", - ]: - if hasattr(func, attr): - setattr(wrapper, attr, getattr(func, attr)) - - # Create config inline to avoid race conditions - wrapper.__human_feedback_config__ = HumanFeedbackConfig( - message=message, - emit=emit, - llm=llm, - default_outcome=default_outcome, - metadata=metadata, - provider=provider, - learn=learn, - learn_source=learn_source, - learn_strict=learn_strict, - ) - wrapper.__is_flow_method__ = True - - if emit: - wrapper.__is_router__ = True - wrapper.__router_emit__ = list(emit) - # Keep the definition fragment in sync: emit promotes the method to - # a router and the feedback outcomes replace any emit recorded by an - # inner @router. Copy before updating so the wrapped method's own - # fragment (shared by reference) is left untouched. - fragment = getattr(wrapper, "__flow_method_definition__", None) - if isinstance(fragment, FlowMethodDefinition): - wrapper.__flow_method_definition__ = fragment.model_copy( - update={"router": True, "emit": list(emit)} - ) - - # Stash the live LLM object for HITL resume to retrieve. - # When a flow pauses for human feedback and later resumes (possibly in a - # different process), the serialized context only contains a model string. - # By storing the original LLM on the wrapper, resume_async can retrieve - # the fully-configured LLM (with credentials, project, safety_settings, etc.) - # instead of creating a bare LLM from just the model string. - wrapper._human_feedback_llm = llm - return wrapper # type: ignore[no-any-return] return decorator + + +def human_feedback( + message: str, + emit: Sequence[str] | None = None, + llm: str | BaseLLM | None = "gpt-4o-mini", + default_outcome: str | None = None, + metadata: dict[str, Any] | None = None, + provider: HumanFeedbackProvider | None = None, + learn: bool = False, + learn_source: str = "hitl", + learn_strict: bool = False, +) -> Callable[[F], F]: + """Compatibility import path for the Flow human-feedback DSL decorator.""" + from crewai.flow.dsl._human_feedback import human_feedback as dsl_human_feedback + + return dsl_human_feedback( + message=message, + emit=emit, + llm=llm, + default_outcome=default_outcome, + metadata=metadata, + provider=provider, + learn=learn, + learn_source=learn_source, + learn_strict=learn_strict, + ) diff --git a/lib/crewai/src/crewai/flow/runtime.py b/lib/crewai/src/crewai/flow/runtime.py index 33bfbacea..0290b7ff1 100644 --- a/lib/crewai/src/crewai/flow/runtime.py +++ b/lib/crewai/src/crewai/flow/runtime.py @@ -90,15 +90,17 @@ from crewai.experimental.conversational import ( ) from crewai.experimental.conversational_mixin import _ConversationalMixin from crewai.flow.constants import AND_CONDITION, OR_CONDITION -from crewai.flow.dsl import ( +from crewai.flow.dsl._conditions import ( _extract_all_methods, _extract_all_methods_recursive, _normalize_condition, + is_flow_condition_dict, + is_simple_flow_condition, +) +from crewai.flow.dsl._utils import ( build_flow_definition, extract_flow_definition, - is_flow_condition_dict, is_flow_method, - is_simple_flow_condition, ) from crewai.flow.flow_context import current_flow_id, current_flow_request_id from crewai.flow.flow_definition import FlowDefinition diff --git a/lib/crewai/tests/test_flow_definition.py b/lib/crewai/tests/test_flow_definition.py index a02036f66..1b8325e68 100644 --- a/lib/crewai/tests/test_flow_definition.py +++ b/lib/crewai/tests/test_flow_definition.py @@ -25,7 +25,15 @@ def test_flow_public_exports_are_explicit(): assert "FlowDefinitionDiagnostic" not in flow_package.__all__ assert "build_flow_definition" not in flow_package.__all__ assert "flow_structure" not in flow_package.__all__ - assert set(flow_dsl.__all__) == {"and_", "listen", "or_", "router", "start"} + assert set(flow_dsl.__all__) == { + "HumanFeedbackResult", + "and_", + "human_feedback", + "listen", + "or_", + "router", + "start", + } assert set(flow_definition.__all__) == { "FlowConfigDefinition", "FlowDefinition", From f3a15a4f07d7d235b9f46eb4536af2e827a9f8cf Mon Sep 17 00:00:00 2001 From: Matt Aitchison Date: Thu, 4 Jun 2026 13:28:31 -0500 Subject: [PATCH 11/18] feat(lock_store): make locking backend overridable (#6015) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(lock_store): make locking backend overridable Allow the centralised lock factory to use a pluggable backend instead of the hardcoded Redis/file selection. Backends are resolved with precedence override > CREWAI_LOCK_FACTORY env > built-in default: - set_lock_backend()/reset_lock_backend() and a scoped lock_backend() context manager for programmatic overrides - CREWAI_LOCK_FACTORY="module:callable" env import-path, resolved lazily and cached, with clear errors on malformed or non-callable specs - LockBackend Protocol documenting the contract (raw name in, context manager out; backend owns its namespacing) Default Redis/file behavior is unchanged when nothing is overridden. * refactor(lock_store): use explicit body for LockBackend protocol method Replace the no-op `...` body with `raise NotImplementedError` to satisfy the CodeQL ineffectual-statement check while keeping the Protocol structural-typing only. * refactor(lock_store): drop scoped lock_backend context manager Keep the backend overridable via set_lock_backend/reset_lock_backend and the CREWAI_LOCK_FACTORY env path, but remove the scoped lock_backend() context manager. It was speculative surface and the only thread-unsafe piece (racy save/restore of the module global); nothing depends on it. * refactor(lock_store): drop reset_lock_backend alias reset_lock_backend() was just set_lock_backend(None); callers use that directly. Clearing the override is documented on set_lock_backend. * style(lock_store): apply ruff format * refactor(lock_store): simplify overridable backend to a single setter Reduce the override surface to just set_lock_backend(): lock() uses the custom backend when one is set, otherwise the unchanged Redis/file default. Drop the CREWAI_LOCK_FACTORY env import-path, the runtime_checkable Protocol, the precedence resolver, and the getter — a custom backend is now any callable(name, *, timeout) -> context manager, registered in process. * fix(lock_store): snapshot backend to avoid check-then-call race Read the module-global backend once into a local before the None check and the call, so a concurrent set_lock_backend(None) cannot make lock() invoke None. * docs(lock_store): clarify name handling for custom backends The default namespaces the lock name; custom backends receive it verbatim. Correct the lock() docstring which implied namespacing always happens. * docs(lock_store): note set_lock_backend is for one-time startup setup --- lib/crewai-core/src/crewai_core/lock_store.py | 46 ++++++++++++++--- lib/crewai/tests/utilities/test_lock_store.py | 51 ++++++++++++++++++- 2 files changed, 88 insertions(+), 9 deletions(-) diff --git a/lib/crewai-core/src/crewai_core/lock_store.py b/lib/crewai-core/src/crewai_core/lock_store.py index 0f09fa7f6..be1d08faa 100644 --- a/lib/crewai-core/src/crewai_core/lock_store.py +++ b/lib/crewai-core/src/crewai_core/lock_store.py @@ -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(): diff --git a/lib/crewai/tests/utilities/test_lock_store.py b/lib/crewai/tests/utilities/test_lock_store.py index 1baa0169a..baad049d8 100644 --- a/lib/crewai/tests/utilities/test_lock_store.py +++ b/lib/crewai/tests/utilities/test_lock_store.py @@ -1,11 +1,13 @@ """Tests for lock_store. -We verify our own logic: the _redis_available guard and which portalocker -backend is selected. We trust portalocker to handle actual locking mechanics. +We verify our own logic: the _redis_available guard, which portalocker +backend is selected, and that a custom backend can be plugged in. We trust +portalocker to handle actual locking mechanics. """ from __future__ import annotations +from contextlib import contextmanager import sys from unittest import mock @@ -20,6 +22,14 @@ def no_redis_url(monkeypatch): monkeypatch.setattr(lock_store, "_REDIS_URL", None) +@pytest.fixture(autouse=True) +def reset_backend(): + """Ensure a custom backend never leaks across tests.""" + lock_store.set_lock_backend(None) + yield + lock_store.set_lock_backend(None) + + # _redis_available @@ -64,3 +74,40 @@ def test_uses_redis_lock_when_redis_available(monkeypatch): kwargs = mock_redis_lock.call_args.kwargs assert kwargs["channel"].startswith("crewai:") assert kwargs["connection"] is fake_conn + + +# custom backend + + +def test_custom_backend_is_used(): + calls = [] + + @contextmanager + def fake_backend(name, *, timeout): + calls.append((name, timeout)) + yield + + lock_store.set_lock_backend(fake_backend) + + # The default file/redis path must not be touched when overridden. + with mock.patch("portalocker.Lock") as mock_lock: + with lock("custom_test", timeout=5): + pass + + mock_lock.assert_not_called() + assert calls == [("custom_test", 5)] + + +def test_clearing_backend_restores_default(): + @contextmanager + def fake_backend(name, *, timeout): + yield + + lock_store.set_lock_backend(fake_backend) + lock_store.set_lock_backend(None) + + with mock.patch("portalocker.Lock") as mock_lock: + with lock("after_clear"): + pass + + mock_lock.assert_called_once() From 14ce97d787873a2c310d40c508a9d70c0ffd3616 Mon Sep 17 00:00:00 2001 From: Lorenze Jay <63378463+lorenzejay@users.noreply.github.com> Date: Thu, 4 Jun 2026 13:36:48 -0700 Subject: [PATCH 12/18] chat api for convo flows (#6034) * Add conversational Flow chat helper * Document conversational flow chat APIs in translations * Stringify conversational chat REPL output --- docs/ar/guides/flows/conversational-flows.mdx | 58 +++++++++++--- docs/en/guides/flows/conversational-flows.mdx | 60 +++++++++++--- docs/ko/guides/flows/conversational-flows.mdx | 58 +++++++++++--- .../guides/flows/conversational-flows.mdx | 58 +++++++++++--- .../experimental/conversational_mixin.py | 55 ++++++++++++- lib/crewai/src/crewai/flow/runtime.py | 16 ++-- lib/crewai/tests/test_flow_conversation.py | 80 +++++++++++++++++++ 7 files changed, 328 insertions(+), 57 deletions(-) diff --git a/docs/ar/guides/flows/conversational-flows.mdx b/docs/ar/guides/flows/conversational-flows.mdx index bfb45c90b..9d397d73e 100644 --- a/docs/ar/guides/flows/conversational-flows.mdx +++ b/docs/ar/guides/flows/conversational-flows.mdx @@ -7,7 +7,7 @@ mode: "wide" ## نظرة عامة -تعامل التطبيقات المحادثية مع كل سطر من المستخدم كـ **تشغيل flow جديد** بنفس **معرّف الجلسة**. توفر CrewAI مساعدات لسجل الرسائل وتصنيف النية الاختياري وتأجيل التتبع وجسور الواجهة — دون API منفصل `chat()` على `Flow`. +تعامل التطبيقات المحادثية مع كل سطر من المستخدم كـ **تشغيل flow جديد** بنفس **معرّف الجلسة**. توفر CrewAI مساعدات لسجل الرسائل وتصنيف النية الاختياري وتأجيل التتبع وجسور الواجهة، إضافة إلى REPL محلي `flow.chat()` للتدفقات المحادثية. | المفهوم | التنفيذ | |---------|---------| @@ -16,13 +16,15 @@ mode: "wide" | اكتمال الجولة | `FlowFinished` لهذا **التشغيل** فقط؛ تستمر المحادثة في `kickoff` التالي | | تتبع الجلسة | `ConversationalConfig(defer_trace_finalization=True)` + `finalize_session_traces()` | -## نقطة دخول واحدة: `kickoff` +## واجهات الجولات -استخدم **`flow.kickoff(user_message=..., session_id=...)`** لكل رسالة مستخدم (REST أو WebSocket أو CLI). لا تنشئ غلاف `chat()` مخصصاً على `Flow`. +استخدم **`flow.kickoff(user_message=..., session_id=...)`** أو **`flow.handle_turn(...)`** لكل رسالة مستخدم من REST أو WebSocket أو الاختبارات أو الواجهات المخصصة. استخدم **`flow.chat()`** عندما تريد حلقة دردشة محلية في الطرفية لـ `Flow` محادثي. | API | الاستخدام | |-----|-----------| | `kickoff(user_message=..., session_id=...)` | كل رسالة مستخدم | +| `handle_turn(message, session_id=...)` | غلاف مريح لجولة واحدة في `Flow` محادثي | +| `chat()` | REPL محلي في الطرفية لـ `Flow` محادثي | | `kickoff_async(...)` | نفس المعاملات؛ دخول async أصلي | | `ask()` | مطالبة حاجزة **داخل** خطوة واحدة | | `@human_feedback` | الموافقة/الرفض على **مخرجات خطوة** — وليس السطر التالي | @@ -290,6 +292,15 @@ finally: flow.finalize_session_traces() ``` +للدردشة المحلية في الطرفية، استخدم `chat()`: + +```python +def kickoff() -> None: + SupportFlow().chat() +``` + +يلف `chat()` استدعاءات `handle_turn()` داخل REPL، ويخرج عند `exit` / `quit`، ويتجاهل الأسطر الفارغة افتراضياً، ويستدعي `finalize_session_traces()` عند انتهاء الجلسة. + ### `ConversationConfig` مزخرف صنف يُلحق افتراضيات الدردشة على مستوى الصنف. @@ -373,6 +384,36 @@ Routes: يمكنك أيضاً استدعاء `flow.kickoff(user_message=..., session_id=...)` مباشرةً — نفس منطق الإعادة والتشغيل يعمل. `handle_turn` هو الغلاف المريح. +### `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`: @@ -407,17 +448,10 @@ class SupportFlow(Flow[ConversationState]): - **العمل المتداخل** (`Agent.kickoff()`, crews, Exa) يُلحق بدفعة **الأب**؛ flow داخلي من `AgentExecutor` لا يغلق دفعة الجلسة مبكراً. ```python -try: - while True: - line = input("You: ").strip() - if not line: - break - flow.kickoff(user_message=line, session_id=session_id) -finally: - flow.finalize_session_traces() +flow.chat(session_id=session_id) ``` -`ChatSession.close()` يستدعي `finalize_session_traces()` عند التأجيل. +`flow.chat()` يستدعي `finalize_session_traces()` نيابةً عنك. عندما تملك الحلقة عبر `handle_turn()` أو `kickoff(...)`، استدعِ `finalize_session_traces()` عند انتهاء الجلسة. `suppress_flow_events=True` يخفي لوحات Rich فقط؛ أحداث trace والـ methods تُصدر. diff --git a/docs/en/guides/flows/conversational-flows.mdx b/docs/en/guides/flows/conversational-flows.mdx index 832574095..00084cae7 100644 --- a/docs/en/guides/flows/conversational-flows.mdx +++ b/docs/en/guides/flows/conversational-flows.mdx @@ -7,7 +7,7 @@ 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 classification, deferred tracing, and UI bridges — without a separate `chat()` API on `Flow`. +Conversational apps treat each user line as a **new flow run** with the **same session id**. CrewAI adds helpers for message history, optional intent classification, deferred tracing, UI bridges, and a local `flow.chat()` REPL for conversational flows. | Concept | Implementation | |---------|----------------| @@ -16,13 +16,15 @@ Conversational apps treat each user line as a **new flow run** with the **same s | Turn complete | `FlowFinished` for **this run** only; chat continues on the next `kickoff` | | Full-session trace | `ConversationalConfig(defer_trace_finalization=True)` + `finalize_session_traces()` | -## One entry point: `kickoff` +## Turn APIs -Use **`flow.kickoff(user_message=..., session_id=...)`** for every user message (REST, WebSocket, CLI). Do not add a custom `chat()` wrapper on `Flow`. +Use **`flow.kickoff(user_message=..., session_id=...)`** or **`flow.handle_turn(...)`** 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`. | API | Use for | |-----|---------| | `kickoff(user_message=..., session_id=...)` | Each user message | +| `handle_turn(message, session_id=...)` | Ergonomic one-turn wrapper for conversational `Flow` | +| `chat()` | Local terminal REPL for conversational `Flow` | | `kickoff_async(...)` | Same parameters; native async entry | | `ask()` | Blocking prompt **inside** one step (wizard, clarification) | | `@human_feedback` | Approve/reject **a step output** — not the next chat line | @@ -293,6 +295,15 @@ 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. @@ -376,6 +387,36 @@ You can override any of these by defining a same-named handler in your subclass. You can also call `flow.kickoff(user_message=..., session_id=...)` directly — the same reset/run logic fires. `handle_turn` is the ergonomic 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`: @@ -410,17 +451,12 @@ With `defer_trace_finalization=True` (default in `ConversationalConfig`): - **Nested work** (`Agent.kickoff()`, crews, Exa tools) appends to the **parent** batch; inner `AgentExecutor` flows do not close the session batch early. ```python -try: - while True: - line = input("You: ").strip() - if not line: - break - flow.kickoff(user_message=line, session_id=session_id) -finally: - flow.finalize_session_traces() +flow.chat(session_id=session_id) ``` -`ChatSession.close()` calls `finalize_session_traces()` when deferral is enabled. +`flow.chat()` calls `finalize_session_traces()` for you. When you own the loop +with `handle_turn()` or `kickoff(...)`, 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. diff --git a/docs/ko/guides/flows/conversational-flows.mdx b/docs/ko/guides/flows/conversational-flows.mdx index 677a014a5..35c63cacc 100644 --- a/docs/ko/guides/flows/conversational-flows.mdx +++ b/docs/ko/guides/flows/conversational-flows.mdx @@ -7,7 +7,7 @@ mode: "wide" ## 개요 -대화형 앱은 각 사용자 입력을 **동일한 세션 id**로 **새 flow 실행**으로 처리합니다. CrewAI는 메시지 기록, 선택적 의도 분류, 지연 트레이싱, UI 브리지를 제공하며, `Flow`에 별도 `chat()` API는 없습니다. +대화형 앱은 각 사용자 입력을 **동일한 세션 id**로 **새 flow 실행**으로 처리합니다. CrewAI는 메시지 기록, 선택적 의도 분류, 지연 트레이싱, UI 브리지, 그리고 대화형 flow용 로컬 `flow.chat()` REPL을 제공합니다. | 개념 | 구현 | |------|------| @@ -16,13 +16,15 @@ mode: "wide" | 턴 완료 | `FlowFinished`는 **이번 실행**만 의미; 다음 `kickoff`로 대화 계속 | | 세션 전체 트레이스 | `ConversationalConfig(defer_trace_finalization=True)` + `finalize_session_traces()` | -## 단일 진입점: `kickoff` +## 턴 API -모든 사용자 메시지에 **`flow.kickoff(user_message=..., session_id=...)`**를 사용하세요 (REST, WebSocket, CLI). `Flow`에 커스텀 `chat()` 래퍼를 만들지 마세요. +REST, WebSocket, 테스트, 커스텀 UI에서 오는 모든 사용자 메시지에는 **`flow.kickoff(user_message=..., session_id=...)`** 또는 **`flow.handle_turn(...)`**를 사용하세요. 대화형 `Flow`를 로컬 터미널 채팅 루프로 실행하고 싶을 때는 **`flow.chat()`**을 사용하세요. | API | 용도 | |-----|------| | `kickoff(user_message=..., session_id=...)` | 각 사용자 메시지 | +| `handle_turn(message, session_id=...)` | 대화형 `Flow`용 한 턴 편의 래퍼 | +| `chat()` | 대화형 `Flow`용 로컬 터미널 REPL | | `kickoff_async(...)` | 동일 파라미터; 네이티브 async 진입 | | `ask()` | 한 스텝 **내부** 블로킹 프롬프트 (마법사, 확인) | | `@human_feedback` | **스텝 출력** 승인/거부 — 다음 채팅 줄이 아님 | @@ -292,6 +294,15 @@ finally: flow.finalize_session_traces() ``` +로컬 터미널 채팅에는 `chat()`을 사용하세요: + +```python +def kickoff() -> None: + SupportFlow().chat() +``` + +`chat()`은 `handle_turn()`을 REPL로 감싸고, `exit` / `quit`에서 종료하며, 기본적으로 빈 줄을 건너뛰고, 세션이 끝날 때 `finalize_session_traces()`를 호출합니다. + ### `ConversationConfig` 클래스 단위의 챗 기본값을 부착하는 클래스 데코레이터입니다. @@ -375,6 +386,36 @@ Routes: `flow.kickoff(user_message=..., session_id=...)`를 직접 호출해도 동일한 reset/run 로직이 동작합니다. `handle_turn`은 그 위에 얹은 편의 래퍼입니다. +### 로컬 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`을 오버라이드하세요: @@ -409,17 +450,10 @@ LLM router를 우회해 프로그램적으로 라우트를 선택하려면 `rout - **중첩 작업** (`Agent.kickoff()`, crew, Exa tool)은 **부모** batch에 추가; 내부 `AgentExecutor` flow가 세션 batch를 조기 종료하지 않음. ```python -try: - while True: - line = input("You: ").strip() - if not line: - break - flow.kickoff(user_message=line, session_id=session_id) -finally: - flow.finalize_session_traces() +flow.chat(session_id=session_id) ``` -지연 활성화 시 `ChatSession.close()`가 `finalize_session_traces()`를 호출합니다. +`flow.chat()`이 `finalize_session_traces()`를 대신 호출합니다. `handle_turn()`이나 `kickoff(...)`로 직접 루프를 소유하는 경우, 세션이 끝날 때 `finalize_session_traces()`를 호출하세요. `suppress_flow_events=True`는 Rich 콘솔 패널만 숨깁니다. trace 및 method 이벤트는 계속 발생합니다. diff --git a/docs/pt-BR/guides/flows/conversational-flows.mdx b/docs/pt-BR/guides/flows/conversational-flows.mdx index 755f282e5..905cdce3a 100644 --- a/docs/pt-BR/guides/flows/conversational-flows.mdx +++ b/docs/pt-BR/guides/flows/conversational-flows.mdx @@ -7,7 +7,7 @@ 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 e pontes para UI — sem uma API `chat()` separada em `Flow`. +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 | |---------|----------------| @@ -16,13 +16,15 @@ Apps conversacionais tratam cada linha do usuário como uma **nova execução do | Fim do turno | `FlowFinished` só para **esta execução**; o chat segue no próximo `kickoff` | | Trace da sessão | `ConversationalConfig(defer_trace_finalization=True)` + `finalize_session_traces()` | -## Um ponto de entrada: `kickoff` +## APIs de turno -Use **`flow.kickoff(user_message=..., session_id=...)`** para cada mensagem (REST, WebSocket, CLI). Não crie um wrapper `chat()` customizado em `Flow`. +Use **`flow.kickoff(user_message=..., session_id=...)`** ou **`flow.handle_turn(...)`** 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. | API | Uso | |-----|-----| | `kickoff(user_message=..., session_id=...)` | Cada mensagem do usuário | +| `handle_turn(message, session_id=...)` | Wrapper ergonômico de um turno para `Flow` conversacional | +| `chat()` | REPL local no terminal para `Flow` conversacional | | `kickoff_async(...)` | Mesmos parâmetros; entrada async nativa | | `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 | @@ -293,6 +295,15 @@ 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. @@ -376,6 +387,36 @@ Você pode sobrescrever qualquer uma definindo um handler com o mesmo nome na su Você também pode chamar `flow.kickoff(user_message=..., session_id=...)` diretamente — a mesma lógica de reset/run é acionada. `handle_turn` é o wrapper ergonômico. +### `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`: @@ -410,17 +451,10 @@ Com `defer_trace_finalization=True` (padrão em `ConversationalConfig`): - **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 -try: - while True: - line = input("You: ").strip() - if not line: - break - flow.kickoff(user_message=line, session_id=session_id) -finally: - flow.finalize_session_traces() +flow.chat(session_id=session_id) ``` -`ChatSession.close()` chama `finalize_session_traces()` quando o adiamento está habilitado. +`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. diff --git a/lib/crewai/src/crewai/experimental/conversational_mixin.py b/lib/crewai/src/crewai/experimental/conversational_mixin.py index a66c5bc68..3d3a8d05d 100644 --- a/lib/crewai/src/crewai/experimental/conversational_mixin.py +++ b/lib/crewai/src/crewai/experimental/conversational_mixin.py @@ -16,7 +16,7 @@ Import surface: from __future__ import annotations -from collections.abc import Mapping, Sequence +from collections.abc import Callable, Mapping, Sequence from enum import Enum import json import logging @@ -243,6 +243,59 @@ class _ConversationalMixin: self.append_assistant_message(self._stringify_result(result)) return result + def chat( + self, + *, + session_id: str | None = None, + prompt: str = "\nYou: ", + assistant_prefix: str = "\nAssistant: ", + exit_commands: Sequence[str] = ("exit", "quit"), + input_fn: Callable[[str], str] = input, + output_fn: Callable[[str], None] = print, + skip_empty: bool = True, + defer_trace_finalization: bool = True, + **handle_turn_kwargs: Any, + ) -> None: + """Run an interactive terminal chat loop for a conversational Flow. + + ``chat()`` is a convenience wrapper around ``handle_turn()`` for local + REPLs. For web apps, tests, and custom transports, call + ``handle_turn()`` directly. The input/output callables are injectable so + callers can customize prompts or exercise the loop without patching + builtins. + """ + if not getattr(type(self), "conversational", False): + raise ValueError("Flow.chat() is only available on conversational flows") + + exit_set = {command.lower() for command in exit_commands} + previous_defer = getattr(self, "defer_trace_finalization", False) + if defer_trace_finalization: + self.defer_trace_finalization = True + + try: + while True: + try: + message = input_fn(prompt).strip() + except (EOFError, KeyboardInterrupt): + output_fn("") + break + + if message.lower() in exit_set: + break + if skip_empty and not message: + continue + + result = self.handle_turn( + message, + session_id=session_id, + **handle_turn_kwargs, + ) + output_fn(f"{assistant_prefix}{self._stringify_result(result)}") + finally: + self.finalize_session_traces() + if defer_trace_finalization: + self.defer_trace_finalization = previous_defer + def build_router_context(self) -> dict[str, Any]: """Build context used by the routing policy for the current turn.""" state = cast(ConversationState, self.state) diff --git a/lib/crewai/src/crewai/flow/runtime.py b/lib/crewai/src/crewai/flow/runtime.py index 0290b7ff1..07e48e0e9 100644 --- a/lib/crewai/src/crewai/flow/runtime.py +++ b/lib/crewai/src/crewai/flow/runtime.py @@ -706,16 +706,16 @@ class Flow(_ConversationalMixin, BaseModel, Generic[T], metaclass=FlowMeta): # When ``conversational = True`` on a subclass, the built-in conversational # graph (``conversation_start`` -> ``route_conversation`` -> ``converse_turn`` # / ``end_conversation`` / ``answer_from_history_turn``) registers and - # ``handle_turn`` becomes the chat entry point. When ``False`` (default), - # the methods exist as inert attributes and never register or fire — - # non-chat flows pay no runtime cost. + # ``handle_turn`` / ``chat`` become the chat entry points. When ``False`` + # (default), the methods exist as inert attributes and never register or + # fire — non-chat flows pay no runtime cost. # # ⚠ EXPERIMENTAL FEATURE. The whole conversational surface - # (``conversational`` ClassVar, ``handle_turn``, ``ConversationConfig``, - # ``RouterConfig``, ``ConversationState``, the built-in graph + helpers) - # lives under ``crewai.experimental`` and may change shape before - # graduating. Pin your CrewAI version if you depend on specific - # behavior, and watch the changelog for breaking updates. + # (``conversational`` ClassVar, ``handle_turn``, ``chat``, + # ``ConversationConfig``, ``RouterConfig``, ``ConversationState``, the + # built-in graph + helpers) lives under ``crewai.experimental`` and may + # change shape before graduating. Pin your CrewAI version if you depend on + # specific behavior, and watch the changelog for breaking updates. conversational: ClassVar[bool] = False conversational_config: ClassVar[ConversationConfig | None] = None builtin_routes: ClassVar[tuple[str, ...]] = ("converse", "end") diff --git a/lib/crewai/tests/test_flow_conversation.py b/lib/crewai/tests/test_flow_conversation.py index 77567fe5d..4ed61394e 100644 --- a/lib/crewai/tests/test_flow_conversation.py +++ b/lib/crewai/tests/test_flow_conversation.py @@ -858,6 +858,86 @@ class TestConversationalFlow: flow.handle_turn("anything") assert flow.state.messages[-1].content == "worked" + def test_chat_runs_repl_over_handle_turn_and_finalizes(self) -> None: + @ConversationConfig(defer_trace_finalization=False) + class MyChat(ConversationalFlow): + turns: int = 0 + + def route_turn(self, context: dict[str, Any]) -> str | None: + return "work" + + @listen("work") + def do_work(self) -> str: + self.turns += 1 + reply = f"worked: {self.state.current_user_message}" + self.append_assistant_message(reply) + return reply + + flow = MyChat() + inputs = iter(["first", "", "second", "quit"]) + prompts: list[str] = [] + outputs: list[str] = [] + + def input_fn(prompt: str) -> str: + prompts.append(prompt) + return next(inputs) + + with patch.object(flow, "finalize_session_traces") as mock_finalize: + flow.chat( + session_id="session-1", + input_fn=input_fn, + output_fn=outputs.append, + ) + + assert flow.turns == 2 + assert prompts == ["\nYou: ", "\nYou: ", "\nYou: ", "\nYou: "] + assert outputs == [ + "\nAssistant: worked: first", + "\nAssistant: worked: second", + ] + mock_finalize.assert_called_once_with() + assert flow.defer_trace_finalization is False + + def test_chat_stringifies_repl_output_like_conversation_helpers(self) -> None: + class RawResult: + raw = "raw assistant output" + + @ConversationConfig(defer_trace_finalization=False) + class MyChat(ConversationalFlow): + def route_turn(self, context: dict[str, Any]) -> str | None: + return "work" + + @listen("work") + def do_work(self) -> RawResult: + return RawResult() + + flow = MyChat() + inputs = iter(["first", "quit"]) + outputs: list[str] = [] + + with patch.object(flow, "finalize_session_traces"): + flow.chat( + input_fn=lambda _: next(inputs), + output_fn=outputs.append, + ) + + assert outputs == ["\nAssistant: raw assistant output"] + + def test_chat_rejects_non_conversational_flows(self) -> None: + class PlainFlow(Flow): + @start() + def begin(self) -> str: + return "done" + + flow = PlainFlow() + + try: + flow.chat(input_fn=lambda _: "quit") + except ValueError as exc: + assert "conversational flows" in str(exc) + else: + raise AssertionError("Flow.chat() should reject regular flows") + def test_defer_trace_finalization_skips_per_turn_finalize(self) -> None: """``defer_trace_finalization = True`` suppresses per-turn ``finalize_batch``. From 906cd9769d7e2125485bbc09e8d8ef5cb1c29805 Mon Sep 17 00:00:00 2001 From: Vini Brasil Date: Thu, 4 Jun 2026 18:07:49 -0300 Subject: [PATCH 13/18] feat(flow): type DSL triggers as route-aware decorators (#6042) Centralize FlowTrigger and FlowMethodDecorator so start/listen/router and the boolean trigger helpers share one authoring contract. This preserves decorated method signatures for static checking while allowing route-label strings in nested FlowCondition data. Export the shared typing helpers for static analyzers, use an explicit Protocol body, align condition validation with Sequence-backed condition data, and drop the stale call-arg ignore exposed by the signature-preserving decorators. Update the flow guide to use or_(...) for multi-label listeners. --- .../guides/concepts/evaluating-use-cases.mdx | 4 +- lib/crewai/src/crewai/flow/dsl/_conditions.py | 57 +++++++++++-------- lib/crewai/src/crewai/flow/dsl/_listen.py | 16 +++--- lib/crewai/src/crewai/flow/dsl/_router.py | 16 +++--- lib/crewai/src/crewai/flow/dsl/_start.py | 17 +++--- lib/crewai/src/crewai/flow/dsl/_types.py | 27 +++++++++ lib/crewai/src/crewai/flow/dsl/_utils.py | 6 +- lib/crewai/src/crewai/flow/flow_wrappers.py | 10 ++-- lib/crewai/src/crewai/flow/runtime.py | 2 +- lib/crewai/src/crewai/flow/types.py | 2 +- lib/crewai/src/crewai/memory/recall_flow.py | 2 +- lib/crewai/tests/test_flow_definition.py | 15 +++++ 12 files changed, 115 insertions(+), 59 deletions(-) create mode 100644 lib/crewai/src/crewai/flow/dsl/_types.py diff --git a/docs/en/guides/concepts/evaluating-use-cases.mdx b/docs/en/guides/concepts/evaluating-use-cases.mdx index f7895deec..186afa2c6 100644 --- a/docs/en/guides/concepts/evaluating-use-cases.mdx +++ b/docs/en/guides/concepts/evaluating-use-cases.mdx @@ -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}" diff --git a/lib/crewai/src/crewai/flow/dsl/_conditions.py b/lib/crewai/src/crewai/flow/dsl/_conditions.py index f2051a63b..395bf2bc5 100644 --- a/lib/crewai/src/crewai/flow/dsl/_conditions.py +++ b/lib/crewai/src/crewai/flow/dsl/_conditions.py @@ -10,12 +10,13 @@ siblings. from __future__ import annotations -from collections.abc import Callable, Sequence +from collections.abc import Sequence from typing import Any from typing_extensions import TypeIs from crewai.flow.constants import AND_CONDITION, OR_CONDITION +from crewai.flow.dsl._types import FlowTrigger from crewai.flow.flow_definition import FlowDefinitionCondition from crewai.flow.flow_wrappers import ( FlowCondition, @@ -25,6 +26,10 @@ from crewai.flow.flow_wrappers import ( from crewai.flow.types import FlowMethodName +def _is_non_string_sequence(value: Any) -> bool: + return isinstance(value, Sequence) and not isinstance(value, (str, bytes)) + + def is_simple_flow_condition(obj: Any) -> TypeIs[SimpleFlowCondition]: """Check if the object is a ``(condition_type, methods)`` tuple.""" return ( @@ -46,7 +51,7 @@ def is_flow_condition_dict(obj: Any) -> TypeIs[FlowCondition]: if "conditions" in obj: conditions = obj["conditions"] - if not isinstance(conditions, list): + if not _is_non_string_sequence(conditions): return False for cond in conditions: if not ( @@ -57,7 +62,10 @@ def is_flow_condition_dict(obj: Any) -> TypeIs[FlowCondition]: if "methods" in obj: methods = obj["methods"] - if not (isinstance(methods, list) and all(isinstance(m, str) for m in methods)): + if not ( + _is_non_string_sequence(methods) + and all(isinstance(m, str) for m in methods) + ): return False allowed_keys = {"type", "conditions", "methods"} @@ -83,9 +91,12 @@ def _normalize_condition( if "conditions" in condition: return condition if "methods" in condition: - return {"type": condition["type"], "conditions": condition["methods"]} + normalized_methods: list[str | FlowMethodName | FlowCondition] = list( + condition["methods"] + ) + return {"type": condition["type"], "conditions": normalized_methods} return condition - if isinstance(condition, list) and all( + if _is_non_string_sequence(condition) and all( isinstance(item, str) or is_flow_condition_dict(item) for item in condition ): return {"type": OR_CONDITION, "conditions": condition} @@ -141,9 +152,7 @@ def _extract_all_methods( return [] -def _condition_trigger( - condition: str | FlowCondition | Callable[..., Any], -) -> FlowMethodName | FlowCondition: +def _condition_trigger(condition: FlowTrigger) -> FlowMethodName | FlowCondition: if isinstance(condition, str): return FlowMethodName(condition) if is_flow_condition_dict(condition): @@ -155,7 +164,7 @@ def _condition_trigger( def _condition_triggers( - conditions: Sequence[str | FlowCondition | Callable[..., Any]], + conditions: Sequence[FlowTrigger], error_message: str, ) -> FlowConditions: try: @@ -184,21 +193,22 @@ def _definition_condition_from_runtime(condition: Any) -> FlowDefinitionConditio return str(condition) -def or_(*conditions: str | FlowCondition | Callable[..., Any]) -> FlowCondition: - """Combines multiple conditions with OR logic for flow control. +def or_(*triggers: FlowTrigger) -> FlowCondition: + """Combine multiple triggers with OR logic for flow control. - Creates a condition that is satisfied when any of the specified conditions + Creates a condition that is satisfied when any of the specified triggers are met. This is used with @start, @listen, or @router decorators to create complex triggering conditions. Args: - conditions: Variable number of conditions that can be method names, existing condition dictionaries, or method references. + triggers: Route labels, method references, or existing conditions + returned by or_() / and_(). Returns: - A condition dictionary with format {"type": "OR", "conditions": list_of_conditions} where each condition can be a string (method name) or a nested dict + A condition dictionary with format {"type": "OR", "conditions": list_of_triggers}. Raises: - ValueError: If condition format is invalid. + ValueError: If a trigger format is invalid. Examples: >>> @listen(or_("success", "timeout")) @@ -209,26 +219,27 @@ def or_(*conditions: str | FlowCondition | Callable[..., Any]) -> FlowCondition: >>> def handle_nested(self): ... pass """ - processed_triggers = _condition_triggers(conditions, "Invalid condition in or_()") + processed_triggers = _condition_triggers(triggers, "Invalid trigger in or_()") return {"type": OR_CONDITION, "conditions": processed_triggers} -def and_(*conditions: str | FlowCondition | Callable[..., Any]) -> FlowCondition: - """Combines multiple conditions with AND logic for flow control. +def and_(*triggers: FlowTrigger) -> FlowCondition: + """Combine multiple triggers with AND logic for flow control. - Creates a condition that is satisfied only when all specified conditions + Creates a condition that is satisfied only when all specified triggers are met. This is used with @start, @listen, or @router decorators to create complex triggering conditions. Args: - *conditions: Variable number of conditions that can be method names, existing condition dictionaries, or method references. + triggers: Route labels, method references, or existing conditions + returned by or_() / and_(). Returns: A condition dictionary with format {"type": "AND", "conditions": list_of_conditions} - where each condition can be a string (method name) or a nested dict + where each condition can be a route label, method name, or nested condition. Raises: - ValueError: If any condition is invalid. + ValueError: If any trigger is invalid. Examples: >>> @listen(and_("validated", "processed")) @@ -239,7 +250,7 @@ def and_(*conditions: str | FlowCondition | Callable[..., Any]) -> FlowCondition >>> def handle_nested(self): ... pass """ - processed_triggers = _condition_triggers(conditions, "Invalid condition in and_()") + processed_triggers = _condition_triggers(triggers, "Invalid trigger in and_()") return {"type": AND_CONDITION, "conditions": processed_triggers} diff --git a/lib/crewai/src/crewai/flow/dsl/_listen.py b/lib/crewai/src/crewai/flow/dsl/_listen.py index 16a93a175..c8ada4c65 100644 --- a/lib/crewai/src/crewai/flow/dsl/_listen.py +++ b/lib/crewai/src/crewai/flow/dsl/_listen.py @@ -1,9 +1,10 @@ from __future__ import annotations from collections.abc import Callable -from typing import Any +from typing import cast from crewai.flow.dsl._conditions import _definition_condition_from_runtime +from crewai.flow.dsl._types import FlowMethodDecorator, FlowTrigger from crewai.flow.dsl._utils import ( P, R, @@ -11,12 +12,10 @@ from crewai.flow.dsl._utils import ( _set_trigger_metadata, ) from crewai.flow.flow_definition import FlowMethodDefinition -from crewai.flow.flow_wrappers import FlowCondition, ListenMethod +from crewai.flow.flow_wrappers import ListenMethod -def listen( - condition: str | FlowCondition | Callable[..., Any], -) -> Callable[[Callable[P, R]], ListenMethod[P, R]]: +def listen(condition: FlowTrigger) -> FlowMethodDecorator: """Creates a listener that executes when specified conditions are met. This decorator sets up a method to execute in response to other method @@ -24,10 +23,11 @@ def listen( conditions. Args: - condition: Specifies when the listener should execute. + condition: Route label, method reference, or condition returned by + or_() / and_() that triggers the listener. Returns: - A decorator function that wraps the method as a flow listener and preserves its signature. + A flow method decorator that preserves the decorated method's static signature. Raises: ValueError: If the condition format is invalid. @@ -52,4 +52,4 @@ def listen( _set_trigger_metadata(wrapper, condition) return wrapper - return decorator + return cast(FlowMethodDecorator, decorator) diff --git a/lib/crewai/src/crewai/flow/dsl/_router.py b/lib/crewai/src/crewai/flow/dsl/_router.py index 11ffc9d0b..89a666cb5 100644 --- a/lib/crewai/src/crewai/flow/dsl/_router.py +++ b/lib/crewai/src/crewai/flow/dsl/_router.py @@ -8,12 +8,14 @@ from typing import ( Any, Literal, Union, + cast, get_args, get_origin, get_type_hints, ) from crewai.flow.dsl._conditions import _definition_condition_from_runtime +from crewai.flow.dsl._types import FlowMethodDecorator, FlowTrigger from crewai.flow.dsl._utils import ( P, R, @@ -21,7 +23,7 @@ from crewai.flow.dsl._utils import ( _set_trigger_metadata, ) from crewai.flow.flow_definition import FlowMethodDefinition -from crewai.flow.flow_wrappers import FlowCondition, RouterMethod +from crewai.flow.flow_wrappers import RouterMethod def _unwrap_function(function: Any) -> Any: @@ -93,10 +95,10 @@ def _normalize_router_emit(value: Sequence[Any] | str) -> list[str]: def router( - condition: str | FlowCondition | Callable[..., Any], + condition: FlowTrigger, *, emit: Sequence[str] | str | None = None, -) -> Callable[[Callable[P, R]], RouterMethod[P, R]]: +) -> FlowMethodDecorator: """Creates a routing method that directs flow execution based on conditions. This decorator marks a method as a router, which can dynamically determine @@ -105,15 +107,15 @@ def router( Args: condition: Specifies when the router should execute. Can be: - - str: Name of a method that triggers this router + - str: Route label or method name that triggers this router - FlowCondition: Result from or_() or and_(), including nested conditions - - Callable[..., Any]: A method reference that triggers this router + - Flow method reference: A method whose completion triggers this router emit: Optional explicit router output events for static FlowDefinition and visualization. If omitted, Literal/Enum return annotations are used when available. Returns: - A decorator function that wraps the method as a router and preserves its signature. + A flow method decorator that preserves the decorated method's static signature. Raises: ValueError: If the condition format is invalid. @@ -161,4 +163,4 @@ def router( wrapper.__router_emit__ = router_events return wrapper - return decorator + return cast(FlowMethodDecorator, decorator) diff --git a/lib/crewai/src/crewai/flow/dsl/_start.py b/lib/crewai/src/crewai/flow/dsl/_start.py index 652a8332f..dcfde940d 100644 --- a/lib/crewai/src/crewai/flow/dsl/_start.py +++ b/lib/crewai/src/crewai/flow/dsl/_start.py @@ -1,9 +1,10 @@ from __future__ import annotations from collections.abc import Callable -from typing import Any +from typing import cast from crewai.flow.dsl._conditions import _definition_condition_from_runtime +from crewai.flow.dsl._types import FlowMethodDecorator, FlowTrigger from crewai.flow.dsl._utils import ( P, R, @@ -11,12 +12,12 @@ from crewai.flow.dsl._utils import ( _set_trigger_metadata, ) from crewai.flow.flow_definition import FlowMethodDefinition -from crewai.flow.flow_wrappers import FlowCondition, StartMethod +from crewai.flow.flow_wrappers import StartMethod def start( - condition: str | FlowCondition | Callable[..., Any] | None = None, -) -> Callable[[Callable[P, R]], StartMethod[P, R]]: + condition: FlowTrigger | None = None, +) -> FlowMethodDecorator: """Marks a method as a flow's starting point. This decorator designates a method as an entry point for the flow execution. @@ -25,13 +26,13 @@ def start( Args: condition: Defines when the start method should execute. Can be: - - str: Name of a method that triggers this start + - str: Route label or method name that triggers this start - FlowCondition: Result from or_() or and_(), including nested conditions - - Callable[..., Any]: A method reference that triggers this start + - Flow method reference: A method whose completion triggers this start Default is None, meaning unconditional start. Returns: - A decorator function that wraps the method as a flow start point and preserves its signature. + A flow method decorator that preserves the decorated method's static signature. Raises: ValueError: If the condition format is invalid. @@ -65,4 +66,4 @@ def start( _set_flow_method_definition(wrapper, FlowMethodDefinition(start=True)) return wrapper - return decorator + return cast(FlowMethodDecorator, decorator) diff --git a/lib/crewai/src/crewai/flow/dsl/_types.py b/lib/crewai/src/crewai/flow/dsl/_types.py new file mode 100644 index 000000000..829227fbc --- /dev/null +++ b/lib/crewai/src/crewai/flow/dsl/_types.py @@ -0,0 +1,27 @@ +"""Private typing helpers for the Python Flow DSL.""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Any, Protocol, TypeAlias, TypeVar + +from crewai.flow.flow_wrappers import FlowCondition +from crewai.flow.types import FlowMethodCallable + + +__all__ = ["FlowMethodDecorator", "FlowTrigger"] + +F = TypeVar("F", bound=Callable[..., Any]) + +FlowTrigger: TypeAlias = str | FlowMethodCallable[..., Any] | FlowCondition + + +class FlowMethodDecorator(Protocol): + """Decorator returned by Flow DSL authoring helpers. + + The runtime wraps methods in FlowMethod subclasses, but the authoring + contract preserves the decorated method's static callable type. + """ + + def __call__(self, func: F) -> F: + raise NotImplementedError diff --git a/lib/crewai/src/crewai/flow/dsl/_utils.py b/lib/crewai/src/crewai/flow/dsl/_utils.py index d23bc3886..d31a785f5 100644 --- a/lib/crewai/src/crewai/flow/dsl/_utils.py +++ b/lib/crewai/src/crewai/flow/dsl/_utils.py @@ -1,6 +1,6 @@ from __future__ import annotations -from collections.abc import Callable, Sequence +from collections.abc import Sequence import json import logging from typing import Any, ParamSpec, TypeVar @@ -16,6 +16,7 @@ from crewai.flow.dsl._conditions import ( _runtime_listener_condition_from_definition, is_flow_condition_dict, ) +from crewai.flow.dsl._types import FlowTrigger from crewai.flow.flow_definition import ( FlowConfigDefinition, FlowDefinition, @@ -27,7 +28,6 @@ from crewai.flow.flow_definition import ( FlowStateDefinition, ) from crewai.flow.flow_wrappers import ( - FlowCondition, FlowMethod, ListenMethod, RouterMethod, @@ -67,7 +67,7 @@ def _flow_method_names(values: Sequence[Any]) -> list[FlowMethodName]: def _set_trigger_metadata( wrapper: StartMethod[P, R] | ListenMethod[P, R] | RouterMethod[P, R], - condition: str | FlowCondition | Callable[..., Any], + condition: FlowTrigger, ) -> None: if isinstance(condition, str): wrapper.__trigger_methods__ = [FlowMethodName(condition)] diff --git a/lib/crewai/src/crewai/flow/flow_wrappers.py b/lib/crewai/src/crewai/flow/flow_wrappers.py index 7e42859c8..2fdaeb193 100644 --- a/lib/crewai/src/crewai/flow/flow_wrappers.py +++ b/lib/crewai/src/crewai/flow/flow_wrappers.py @@ -37,16 +37,16 @@ class FlowCondition(TypedDict, total=False): Attributes: type: The type of the condition. - conditions: A list of conditions types. - methods: A list of methods. + conditions: A sequence of route labels, method names, or nested conditions. + methods: A legacy sequence of route labels or method names. """ type: Required[FlowConditionType] - conditions: Sequence[FlowMethodName | FlowCondition] - methods: list[FlowMethodName] + conditions: Sequence[str | FlowMethodName | FlowCondition] + methods: Sequence[str | FlowMethodName] -FlowConditions: TypeAlias = list[FlowMethodName | FlowCondition] +FlowConditions: TypeAlias = Sequence[str | FlowMethodName | FlowCondition] class FlowMethod(Generic[P, R]): diff --git a/lib/crewai/src/crewai/flow/runtime.py b/lib/crewai/src/crewai/flow/runtime.py index 07e48e0e9..80b7a84da 100644 --- a/lib/crewai/src/crewai/flow/runtime.py +++ b/lib/crewai/src/crewai/flow/runtime.py @@ -2832,7 +2832,7 @@ class Flow(_ConversationalMixin, BaseModel, Generic[T], metaclass=FlowMeta): def _evaluate_condition( self, - condition: FlowMethodName | FlowCondition, + condition: str | FlowMethodName | FlowCondition, trigger_method: FlowMethodName, listener_name: FlowMethodName, ) -> bool: diff --git a/lib/crewai/src/crewai/flow/types.py b/lib/crewai/src/crewai/flow/types.py index 46a285bbe..31f45f658 100644 --- a/lib/crewai/src/crewai/flow/types.py +++ b/lib/crewai/src/crewai/flow/types.py @@ -31,7 +31,7 @@ PendingListenerKey = NewType( class FlowMethodCallable(Protocol[P, R]): """A callable that can be used as a flow method reference.""" - __name__: FlowMethodName + __name__: str def __call__(self, *args: P.args, **kwargs: P.kwargs) -> R: ... diff --git a/lib/crewai/src/crewai/memory/recall_flow.py b/lib/crewai/src/crewai/memory/recall_flow.py index 9da5dca64..e09278983 100644 --- a/lib/crewai/src/crewai/memory/recall_flow.py +++ b/lib/crewai/src/crewai/memory/recall_flow.py @@ -337,7 +337,7 @@ class RecallFlow(Flow[RecallState]): @router(re_search) def re_decide_depth(self) -> str: """Re-evaluate depth after re-search. Same logic as decide_depth.""" - return self.decide_depth() # type: ignore[call-arg] + return self.decide_depth() @listen("synthesize") def synthesize_results(self) -> list[MemoryMatch]: diff --git a/lib/crewai/tests/test_flow_definition.py b/lib/crewai/tests/test_flow_definition.py index 1b8325e68..302997735 100644 --- a/lib/crewai/tests/test_flow_definition.py +++ b/lib/crewai/tests/test_flow_definition.py @@ -14,6 +14,7 @@ import crewai.flow.dsl as flow_dsl import crewai.flow.flow_definition as flow_definition import crewai.flow.visualization.builder as visualization_builder from crewai.flow import Flow, and_, human_feedback, listen, or_, persist, router, start +from crewai.flow.dsl._conditions import is_flow_condition_dict def test_flow_public_exports_are_explicit(): @@ -48,6 +49,20 @@ def test_flow_public_exports_are_explicit(): assert "calculate_node_levels" not in flow_visualization.__all__ +def test_flow_condition_dict_accepts_non_string_sequences(): + condition = { + "type": "OR", + "conditions": ( + "approved", + {"type": "AND", "methods": ("validated", "processed")}, + ), + } + + assert is_flow_condition_dict(condition) + assert not is_flow_condition_dict({"type": "OR", "conditions": "approved"}) + assert not is_flow_condition_dict({"type": "OR", "methods": b"approved"}) + + def test_private_flow_helpers_do_not_have_docstrings(): import crewai.flow.flow_wrappers as flow_wrappers import crewai.flow.human_feedback as human_feedback From cab3319af92a10e5da6269a7cc78176959a3484f Mon Sep 17 00:00:00 2001 From: Lucas Gomide Date: Fri, 5 Jun 2026 08:23:38 -0300 Subject: [PATCH 14/18] feat(otel): surface real finish_reason + sampling params + response.id on LLM events (#5945) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(otel): surface real finish_reason + sampling params + response.id on LLM events Companion to the OTel GenAI emitter compliance work in crewai-enterprise (CON-172). Today the enterprise emitter reads these fields off the OSS LLM events via `getattr(..., None)`, so it produces valid (but partial) spans against the existing OSS surface. This change makes those fields first-class on the events so spans can carry the real provider data. What this adds: - `LLMCallStartedEvent` gains the sampling-param fields the emitter needs for `gen_ai.request.*`: `temperature`, `top_p`, `max_tokens`, `stream`, `seed`, `stop_sequences`, `frequency_penalty`, `presence_penalty`, `n`. All optional; existing call sites keep working. - `BaseLLM._emit_call_started_event` introspects those values off `self` (the LLM instance) via `getattr(..., None)` so every provider gets the fields propagated for free without per-provider plumbing. - `LLMCallCompletedEvent` gains `finish_reason: str | None` and `response_id: str | None`. A field validator coerces any non-string value (MagicMock, unexpected provider object) to None so the event never raises on construction. - `LLM._emit_call_completed_event` accepts both as kwargs. - `LLM` (LiteLLM path) gets a defensive `_extract_finish_reason_and_response_id` helper that handles both streaming (`StreamingChoices`) and non-streaming (`Choices`) shapes and is wired into every completion-event emission site. - Provider completions extract native values from their SDK responses and pass them through: - OpenAI: `_extract_responses_finish_reason_and_id` for Responses-API, `_extract_finish_reason_and_id` for Chat-Completions. - Anthropic: `_extract_finish_reason_and_id` (Messages API + streaming). - Bedrock: `_extract_finish_reason_and_id` (`stopReason` from converse). - Gemini: `_extract_finish_reason_and_id` (`finish_reason` from candidates). - Azure: inherits via OpenAI sub-class; adds the helper for Azure-specific response shapes. - openai_compatible: inherits from OpenAICompletion, no edits needed. Compatibility: - All new fields are optional with sensible defaults. No existing call sites need to change. - The validator on `LLMCallCompletedEvent` swallows non-string values for the new fields so legacy mocks / exotic provider types don't blow up event construction. - Enterprise side already reads these fields defensively, so OSS and enterprise can merge independently and cut on the same synchronized release. Tested against the full LLM + events + provider test suite — all green; the 14 pre-existing multimodal failures on main are unrelated and reproduce without this diff. * fix(bedrock): propagate finish_reason + response_id on async paths The original commit covered every provider's sync path and Bedrock's sync streaming path, but two Bedrock async paths still emitted LLMCallCompletedEvent without finish_reason/response_id: - _ahandle_converse: the final fallback emit_call_completed_event call was missing both fields. Added stop_reason + response_id matching the other emission sites in the same function. - _ahandle_streaming_converse: response_id was never seeded from the initial response object, and stream_finish_reason wasn't propagated to the structured-output and final-text emissions. Now extracts response_id up front and threads stream_finish_reason through every completion event. Adds a dedicated test file covering the new event fields end-to-end: - LLMCallCompletedEvent.finish_reason / response_id Pydantic validation (string accepted, None default, non-string coerced to None). - LLMCallStartedEvent sampling params (all nine fields accepted, default to None). - BaseLLM._emit_call_started_event introspecting sampling params off self, with explicit kwargs overriding. - BaseLLM._emit_call_completed_event passing finish_reason/response_id through to the event. - LLM._extract_finish_reason_and_response_id across the LiteLLM shapes (non-streaming response, streaming chunk, dict, missing fields, non-string values, unexpected input). * fix(otel): correct streaming finish_reason + bedrock response_id semantics Two correctness fixes uncovered while landing the OTel finish_reason + response_id plumbing: - LiteLLM streaming (sync + async): `stream_options={"include_usage": True}` causes LiteLLM to emit a final usage-only chunk with `choices=[]`. The post-loop `_extract_finish_reason_and_response_id(last_chunk)` silently returned `(None, None)` because the last chunk has no choices, even though earlier chunks carried `finish_reason="stop"`. Track both fields incrementally inside the loop (mirroring how OpenAI/Gemini/Azure already handle their native streams) and use the tracked values for the LLMCallCompletedEvent emission and the partial-response error path. - Bedrock Converse: `ResponseMetadata.RequestId` is an AWS infra trace id, not a model-level response id (semantically different from OpenAI's `chatcmpl-XXX`). Return None for `response_id` rather than mislead downstream telemetry consumers. The audit-fix's async propagation chain still works — None propagates through unchanged. Adds `test_llm_streaming_finish_reason.py` pinning both the sync and async LiteLLM streaming paths against the include_usage chunk shape. * refactor(otel): unify LLM event introspection + drop redundant defensive code Three cohesion cleanups uncovered during PR review, all behavior-preserving: - LLM.call / LLM.acall in llm.py now delegate to BaseLLM._emit_call_started_event instead of constructing LLMCallStartedEvent inline. The base helper already introspects sampling params off self via getattr; the inline duplication was accidental, not justified, and a duplication risk if anyone adds a tenth OTel sampling param later. - Extracted lib/crewai/llms/_finish_reason_utils.py:extract_choices_finish_reason_and_id as the shared extractor for the choices-based response shape. OpenAI Chat, Azure, and LiteLLM all read the same shape (response.id + choices[0].finish_reason) as both object attrs and dict keys. Providers with genuinely different shapes - Anthropic (stop_reason), Bedrock (stopReason), Gemini (protobuf enum), OpenAI Responses (status) - keep their own provider-specific helpers. - Dropped redundant try/except (AttributeError, TypeError) wrappers around bare getattr(obj, "field", None) calls across the new extraction helpers. getattr with a default already suppresses AttributeError, and the inner isinstance / dict.get / int-coercion ops can't raise TypeError in practice. Kept the catches that legitimately guard against IndexError (e.g. choices[0] on an empty list). Tests: 600 passed, 23 skipped, 14 pre-existing multimodal failures unchanged. Added 12 parametrized tests for the shared helper covering object + dict shapes, missing fields, non-string coercion, and never-raises invariants. * chore(otel): drop dead last_chunk variable from async streaming The streaming-fix commit (49e5581b5) replaced the post-loop `_extract_finish_reason_and_response_id(last_chunk)` call with the incrementally-tracked `stream_finish_reason` / `stream_response_id`, which removed the only reader of `last_chunk` in `_ahandle_streaming_response`. The declaration and per-iteration assignment were left behind — harmless but confusing for future readers because the sync sibling still legitimately uses `last_chunk` (for usage and content fallbacks via `_handle_streaming_callbacks`). The async path inlines its usage extraction directly inside the loop (`chunk.model_extra.get("usage")`), so there's no fallback consumer. Drop both lines. Sync path untouched — `last_chunk` there is still load-bearing. * fix(otel): coerce non-list stop_sequences to list[str] on LLMCallStartedEvent Observed in Datadog: gen_ai.request.stop_sequences on a Gemini/Vertex span surfaced the textproto repr of a google.protobuf.struct_pb2.ListValue (values { string_value: "\nObservation:" }) instead of a real Sequence[str]. Root cause is upstream - a Vertex AI / Gemini code path stores the stop list in a protobuf container (RepeatedScalarContainer or ListValue) rather than a plain Python list. When that container reaches LLMCallStartedEvent and then BaseLLM._emit_call_started_event hands it to the OTel SDK as a span attribute, the SDK falls back to str(value) because the type isn't a recognised Sequence[str] - producing the protobuf textproto string instead of an array attribute. * chore: fix ruff lint findings * refactor(otel): declare sampling params on BaseLLM + honor stop overrides + dict chunk id * fix: widen max_tokens to int | float | None + apply ruff format * fix(otel): coerce unknown finish_reason / response_id to None instead of stringifying * fix(otel): extract Azure stream finish_reason/id before usage-continue Match the LiteLLM ordering so a finish_reason or response id riding on a usage-carrying chunk isn't dropped by the early `continue`. * fix(otel): report effective max_tokens cap + bedrock structured finish_reason --- .../src/crewai/events/types/llm_events.py | 56 +- lib/crewai/src/crewai/llm.py | 148 ++++- .../src/crewai/llms/_finish_reason_utils.py | 55 ++ lib/crewai/src/crewai/llms/base_llm.py | 58 ++ .../llms/providers/anthropic/completion.py | 66 +++ .../crewai/llms/providers/azure/completion.py | 56 ++ .../llms/providers/bedrock/completion.py | 61 +- .../llms/providers/gemini/completion.py | 114 +++- .../llms/providers/openai/completion.py | 146 +++++ .../test_llm_finish_reason_response_id.py | 526 ++++++++++++++++++ lib/crewai/tests/llms/google/test_google.py | 14 + .../tests/test_llm_streaming_finish_reason.py | 96 ++++ 12 files changed, 1358 insertions(+), 38 deletions(-) create mode 100644 lib/crewai/src/crewai/llms/_finish_reason_utils.py create mode 100644 lib/crewai/tests/events/test_llm_finish_reason_response_id.py create mode 100644 lib/crewai/tests/test_llm_streaming_finish_reason.py diff --git a/lib/crewai/src/crewai/events/types/llm_events.py b/lib/crewai/src/crewai/events/types/llm_events.py index b138f908c..b1be8c3d5 100644 --- a/lib/crewai/src/crewai/events/types/llm_events.py +++ b/lib/crewai/src/crewai/events/types/llm_events.py @@ -1,7 +1,7 @@ from enum import Enum from typing import Any, Literal -from pydantic import BaseModel +from pydantic import BaseModel, field_validator from crewai.events.base_events import BaseEvent @@ -48,6 +48,43 @@ class LLMCallStartedEvent(LLMEventBase): tools: list[dict[str, Any]] | None = None callbacks: list[Any] | None = None available_functions: dict[str, Any] | None = None + # Sampling/request parameters forwarded for OTel GenAI compliance. + # All optional so legacy emitters keep working unchanged. + temperature: float | None = None + top_p: float | None = None + max_tokens: int | float | None = None + stream: bool | None = None + seed: int | None = None + stop_sequences: list[str] | None = None + frequency_penalty: float | None = None + presence_penalty: float | None = None + n: int | None = None + + @field_validator("stop_sequences", mode="before") + @classmethod + def _coerce_stop_sequences_to_str_list(cls, value: Any) -> list[str] | None: + """Normalize stop_sequences to ``list[str] | None``. + + Some providers store stop sequences in non-Python-list containers — + e.g. a Vertex AI / Gemini code path can hand back a + ``google.protobuf.struct_pb2.ListValue`` or a ``RepeatedScalarContainer``. + Without coercion the OTel SDK falls back to ``str(value)`` when + ``gen_ai.request.stop_sequences`` is set, producing the protobuf + textproto repr (``values { string_value: \"...\" }``) instead of a + proper ``Sequence[str]``. + + A bare string is treated as a single stop sequence. Anything that + can't be iterated cleanly falls back to ``None`` rather than crashing + event construction. + """ + if value is None: + return None + if isinstance(value, str): + return [value] + try: + return [item if isinstance(item, str) else str(item) for item in value] + except TypeError: + return None class LLMCallCompletedEvent(LLMEventBase): @@ -58,6 +95,23 @@ class LLMCallCompletedEvent(LLMEventBase): response: Any call_type: LLMCallType usage: dict[str, Any] | None = None + finish_reason: str | None = None + response_id: str | None = None + + @field_validator("finish_reason", "response_id", mode="before") + @classmethod + def _coerce_non_string_to_none(cls, value: Any) -> str | None: + """Drop non-string values so test mocks and exotic provider types + (MagicMock, protobuf enums, etc.) never crash event construction. + + Provider helpers are best-effort: when extraction returns something + non-string (e.g. a ``MagicMock`` in unit tests), we treat it as + "no value" rather than raising. Downstream telemetry already + handles the missing-attribute case. + """ + if value is None or isinstance(value, str): + return value + return None class LLMCallFailedEvent(LLMEventBase): diff --git a/lib/crewai/src/crewai/llm.py b/lib/crewai/src/crewai/llm.py index 08c1a1bf8..af5dff68e 100644 --- a/lib/crewai/src/crewai/llm.py +++ b/lib/crewai/src/crewai/llm.py @@ -23,7 +23,6 @@ from crewai.events.event_bus import crewai_event_bus from crewai.events.types.llm_events import ( LLMCallCompletedEvent, LLMCallFailedEvent, - LLMCallStartedEvent, LLMCallType, LLMStreamChunkEvent, ) @@ -32,6 +31,7 @@ from crewai.events.types.tool_usage_events import ( ToolUsageFinishedEvent, ToolUsageStartedEvent, ) +from crewai.llms._finish_reason_utils import extract_choices_finish_reason_and_id from crewai.llms.base_llm import ( BaseLLM, JsonResponseFormat, @@ -732,6 +732,11 @@ class LLM(BaseLLM): last_chunk = None chunk_count = 0 usage_info = None + # Tracked across the loop: LiteLLM with include_usage emits a final + # usage-only chunk with empty choices, so the post-loop last_chunk has + # no finish_reason. Capture both incrementally instead. + stream_finish_reason: str | None = None + stream_response_id: str | None = None accumulated_tool_args: defaultdict[int, AccumulatedToolArgs] = defaultdict( AccumulatedToolArgs @@ -750,6 +755,16 @@ class LLM(BaseLLM): if isinstance(chunk, ModelResponseBase): response_id = chunk.id + elif isinstance(chunk, dict): + response_id = chunk.get("id") + + chunk_finish, chunk_id = self._extract_finish_reason_and_response_id( + chunk + ) + if chunk_finish: + stream_finish_reason = chunk_finish + if chunk_id and not stream_response_id: + stream_response_id = chunk_id try: choices = None @@ -922,6 +937,11 @@ class LLM(BaseLLM): if tool_calls_list: return tool_calls_list + finish_reason, response_id_last = ( + stream_finish_reason, + stream_response_id, + ) + if not tool_calls or not available_functions: if response_model and self.is_litellm: instructor_instance = InternalInstructor( @@ -939,6 +959,8 @@ class LLM(BaseLLM): from_agent=from_agent, messages=params["messages"], usage=usage_dict, + finish_reason=finish_reason, + response_id=response_id_last, ) return structured_response @@ -950,6 +972,8 @@ class LLM(BaseLLM): from_agent=from_agent, messages=params["messages"], usage=usage_dict, + finish_reason=finish_reason, + response_id=response_id_last, ) return full_response @@ -965,6 +989,8 @@ class LLM(BaseLLM): from_agent=from_agent, messages=params["messages"], usage=usage_dict, + finish_reason=finish_reason, + response_id=response_id_last, ) return full_response @@ -978,6 +1004,10 @@ class LLM(BaseLLM): logging.error(f"Error in streaming response: {e!s}") if full_response.strip(): logging.warning(f"Returning partial response despite error: {e!s}") + finish_reason, response_id_last = ( + stream_finish_reason, + stream_response_id, + ) self._handle_emit_call_events( response=full_response, call_type=LLMCallType.LLM_CALL, @@ -985,6 +1015,8 @@ class LLM(BaseLLM): from_agent=from_agent, messages=params["messages"], usage=self._usage_to_dict(usage_info), + finish_reason=finish_reason, + response_id=response_id_last, ) return full_response @@ -1169,6 +1201,10 @@ class LLM(BaseLLM): else None ) + finish_reason, response_id = self._extract_finish_reason_and_response_id( + response + ) + if response_model is not None: # When using instructor/response_model, litellm returns a Pydantic model instance if isinstance(response, BaseModel): @@ -1180,6 +1216,8 @@ class LLM(BaseLLM): from_agent=from_agent, messages=params["messages"], usage=response_usage, + finish_reason=finish_reason, + response_id=response_id, ) return structured_response @@ -1216,6 +1254,8 @@ class LLM(BaseLLM): from_agent=from_agent, messages=params["messages"], usage=response_usage, + finish_reason=finish_reason, + response_id=response_id, ) return text_response @@ -1233,6 +1273,8 @@ class LLM(BaseLLM): from_agent=from_agent, messages=params["messages"], usage=response_usage, + finish_reason=finish_reason, + response_id=response_id, ) return text_response @@ -1310,6 +1352,10 @@ class LLM(BaseLLM): else None ) + finish_reason, response_id = self._extract_finish_reason_and_response_id( + response + ) + if response_model is not None: if isinstance(response, BaseModel): structured_response = response.model_dump_json() @@ -1320,6 +1366,8 @@ class LLM(BaseLLM): from_agent=from_agent, messages=params["messages"], usage=response_usage, + finish_reason=finish_reason, + response_id=response_id, ) return structured_response @@ -1358,6 +1406,8 @@ class LLM(BaseLLM): from_agent=from_agent, messages=params["messages"], usage=response_usage, + finish_reason=finish_reason, + response_id=response_id, ) return text_response @@ -1375,6 +1425,8 @@ class LLM(BaseLLM): from_agent=from_agent, messages=params["messages"], usage=response_usage, + finish_reason=finish_reason, + response_id=response_id, ) return text_response @@ -1412,12 +1464,29 @@ class LLM(BaseLLM): params["stream"] = True params["stream_options"] = {"include_usage": True} response_id = None + # See sync sibling: incrementally track finish_reason/response_id so the + # usage-only final chunk doesn't wipe them. + stream_finish_reason: str | None = None + stream_response_id: str | None = None try: async for chunk in await litellm.acompletion(**params): chunk_count += 1 chunk_content = None - response_id = chunk.id if isinstance(chunk, ModelResponseBase) else None + if isinstance(chunk, ModelResponseBase): + response_id = chunk.id + elif isinstance(chunk, dict): + response_id = chunk.get("id") + else: + response_id = None + + chunk_finish, chunk_id = self._extract_finish_reason_and_response_id( + chunk + ) + if chunk_finish: + stream_finish_reason = chunk_finish + if chunk_id and not stream_response_id: + stream_response_id = chunk_id try: choices = None @@ -1525,6 +1594,10 @@ class LLM(BaseLLM): return tool_calls_list usage_dict = self._usage_to_dict(usage_info) + finish_reason, response_id_last = ( + stream_finish_reason, + stream_response_id, + ) self._handle_emit_call_events( response=full_response, call_type=LLMCallType.LLM_CALL, @@ -1532,6 +1605,8 @@ class LLM(BaseLLM): from_agent=from_agent, messages=params.get("messages"), usage=usage_dict, + finish_reason=finish_reason, + response_id=response_id_last, ) return full_response @@ -1545,6 +1620,10 @@ class LLM(BaseLLM): if chunk_count == 0: raise if full_response: + finish_reason, response_id_last = ( + stream_finish_reason, + stream_response_id, + ) self._handle_emit_call_events( response=full_response, call_type=LLMCallType.LLM_CALL, @@ -1552,6 +1631,8 @@ class LLM(BaseLLM): from_agent=from_agent, messages=params.get("messages"), usage=self._usage_to_dict(usage_info), + finish_reason=finish_reason, + response_id=response_id_last, ) return full_response raise @@ -1678,19 +1759,14 @@ class LLM(BaseLLM): ValueError: If response format is not supported LLMContextLengthExceededError: If input exceeds model's context limit """ - with llm_call_context() as call_id: - crewai_event_bus.emit( - self, - event=LLMCallStartedEvent( - messages=messages, - tools=tools, - callbacks=callbacks, - available_functions=available_functions, - from_task=from_task, - from_agent=from_agent, - model=self.model, - call_id=call_id, - ), + with llm_call_context(): + self._emit_call_started_event( + messages=messages, + tools=tools, + callbacks=callbacks, + available_functions=available_functions, + from_task=from_task, + from_agent=from_agent, ) self._validate_call_params() @@ -1822,19 +1898,14 @@ class LLM(BaseLLM): ValueError: If response format is not supported LLMContextLengthExceededError: If input exceeds model's context limit """ - with llm_call_context() as call_id: - crewai_event_bus.emit( - self, - event=LLMCallStartedEvent( - messages=messages, - tools=tools, - callbacks=callbacks, - available_functions=available_functions, - from_task=from_task, - from_agent=from_agent, - model=self.model, - call_id=call_id, - ), + with llm_call_context(): + self._emit_call_started_event( + messages=messages, + tools=tools, + callbacks=callbacks, + available_functions=available_functions, + from_task=from_task, + from_agent=from_agent, ) self._validate_call_params() @@ -1990,6 +2061,8 @@ class LLM(BaseLLM): from_agent: BaseAgent | None = None, messages: str | list[LLMMessage] | None = None, usage: dict[str, Any] | None = None, + finish_reason: str | None = None, + response_id: str | None = None, ) -> None: """Handle the events for the LLM call. @@ -2000,6 +2073,10 @@ class LLM(BaseLLM): from_agent: Optional agent object messages: Optional messages object usage: Optional token usage data + finish_reason: Raw provider finish reason (e.g. "stop", "length", + "tool_calls"). Optional; downstream telemetry coerces to the + OTel GenAI enum. + response_id: Raw provider response identifier. Optional. """ crewai_event_bus.emit( self, @@ -2012,9 +2089,24 @@ class LLM(BaseLLM): model=self.model, call_id=get_current_call_id(), usage=usage, + finish_reason=finish_reason, + response_id=response_id, ), ) + def _effective_max_tokens(self) -> int | float | None: + """LiteLLM sends ``max_tokens or max_completion_tokens`` as the cap.""" + return self.max_tokens or self.max_completion_tokens + + @staticmethod + def _extract_finish_reason_and_response_id( + response_or_chunk: Any, + ) -> tuple[str | None, str | None]: + """LiteLLM responses/chunks share the choices-shape with OpenAI/Azure; + delegate to the shared extractor. + """ + return extract_choices_finish_reason_and_id(response_or_chunk) + def _process_message_files(self, messages: list[LLMMessage]) -> list[LLMMessage]: """Process files attached to messages and format for provider. diff --git a/lib/crewai/src/crewai/llms/_finish_reason_utils.py b/lib/crewai/src/crewai/llms/_finish_reason_utils.py new file mode 100644 index 000000000..e79befcc7 --- /dev/null +++ b/lib/crewai/src/crewai/llms/_finish_reason_utils.py @@ -0,0 +1,55 @@ +"""Shared extractors for ``finish_reason`` + ``response_id`` across LLM providers. + +OpenAI Chat Completions, Azure AI Inference, and LiteLLM all expose the same +choices-based response shape (``response.id`` + ``response.choices[0].finish_reason``), +both as object attributes and (for LiteLLM stream chunks) as dict keys. This +module centralises that introspection so every provider doesn't reinvent the +defensive walk. Providers with genuinely different shapes — Anthropic +(``stop_reason``), Bedrock (``stopReason``), Gemini (protobuf enum), OpenAI +Responses (``status``) — keep their own helpers. +""" + +from __future__ import annotations + +from typing import Any + + +def _as_str(value: Any) -> str | None: + return value if isinstance(value, str) else None + + +def extract_choices_finish_reason_and_id( + response_or_chunk: Any, +) -> tuple[str | None, str | None]: + """Extract ``(finish_reason, response_id)`` from a choices-shaped response. + + Handles both object-style (``response.id``, ``response.choices[0].finish_reason``) + and dict-style (``response["id"]``, ``response["choices"][0]["finish_reason"]``) + inputs. Returns ``(None, None)`` on any failure; never raises. Non-string + raw values are coerced to ``None`` so test mocks and exotic provider types + (MagicMock, protobuf enums, etc.) don't propagate downstream. + """ + raw_id = getattr(response_or_chunk, "id", None) + if raw_id is None and isinstance(response_or_chunk, dict): + raw_id = response_or_chunk.get("id") + response_id = _as_str(raw_id) + + if isinstance(response_or_chunk, dict): + choices = response_or_chunk.get("choices") + else: + choices = getattr(response_or_chunk, "choices", None) + + finish_reason: str | None = None + if choices: + try: + first = choices[0] + except (IndexError, TypeError, KeyError): + first = None + if first is not None: + if isinstance(first, dict): + raw_finish = first.get("finish_reason") + else: + raw_finish = getattr(first, "finish_reason", None) + finish_reason = _as_str(raw_finish) + + return finish_reason, response_id diff --git a/lib/crewai/src/crewai/llms/base_llm.py b/lib/crewai/src/crewai/llms/base_llm.py index 83429cdf1..03f277855 100644 --- a/lib/crewai/src/crewai/llms/base_llm.py +++ b/lib/crewai/src/crewai/llms/base_llm.py @@ -150,6 +150,13 @@ class BaseLLM(BaseModel, ABC): llm_type: str = "base" model: str temperature: float | None = None + top_p: float | None = None + max_tokens: int | float | None = None + stream: bool | None = None + seed: int | None = None + frequency_penalty: float | None = None + presence_penalty: float | None = None + n: int | None = None api_key: str | None = None base_url: str | None = None provider: str = Field(default="openai") @@ -464,6 +471,16 @@ class BaseLLM(BaseModel, ABC): """ return None + def _effective_max_tokens(self) -> int | float | None: + """Token cap actually sent to the provider, for start-event telemetry. + + Defaults to ``self.max_tokens``. Providers that cap generation through a + differently named field (e.g. ``max_completion_tokens`` on OpenAI/Azure, + ``max_output_tokens`` on Gemini) override this so ``LLMCallStartedEvent`` + reports the real limit instead of ``None``. + """ + return self.max_tokens + def _emit_call_started_event( self, messages: str | list[LLMMessage], @@ -472,10 +489,38 @@ class BaseLLM(BaseModel, ABC): available_functions: dict[str, Any] | None = None, from_task: Task | None = None, from_agent: BaseAgent | None = None, + temperature: float | None = None, + top_p: float | None = None, + max_tokens: int | float | None = None, + stream: bool | None = None, + seed: int | None = None, + stop_sequences: list[str] | None = None, + frequency_penalty: float | None = None, + presence_penalty: float | None = None, + n: int | None = None, ) -> None: """Emit LLM call started event.""" from crewai.utilities.serialization import to_serializable + if temperature is None: + temperature = self.temperature + if top_p is None: + top_p = self.top_p + if max_tokens is None: + max_tokens = self._effective_max_tokens() + if stream is None: + stream = self.stream + if seed is None: + seed = self.seed + if stop_sequences is None: + stop_sequences = self.stop_sequences or None + if frequency_penalty is None: + frequency_penalty = self.frequency_penalty + if presence_penalty is None: + presence_penalty = self.presence_penalty + if n is None: + n = self.n + crewai_event_bus.emit( self, event=LLMCallStartedEvent( @@ -487,6 +532,15 @@ class BaseLLM(BaseModel, ABC): from_agent=from_agent, model=self.model, call_id=get_current_call_id(), + temperature=temperature, + top_p=top_p, + max_tokens=max_tokens, + stream=stream, + seed=seed, + stop_sequences=stop_sequences, + frequency_penalty=frequency_penalty, + presence_penalty=presence_penalty, + n=n, ), ) @@ -498,6 +552,8 @@ class BaseLLM(BaseModel, ABC): from_agent: BaseAgent | None = None, messages: str | list[LLMMessage] | None = None, usage: dict[str, Any] | None = None, + finish_reason: str | None = None, + response_id: str | None = None, ) -> None: """Emit LLM call completed event.""" from crewai.utilities.serialization import to_serializable @@ -513,6 +569,8 @@ class BaseLLM(BaseModel, ABC): model=self.model, call_id=get_current_call_id(), usage=usage, + finish_reason=finish_reason, + response_id=response_id, ), ) diff --git a/lib/crewai/src/crewai/llms/providers/anthropic/completion.py b/lib/crewai/src/crewai/llms/providers/anthropic/completion.py index 28122d4db..599ec5a3b 100644 --- a/lib/crewai/src/crewai/llms/providers/anthropic/completion.py +++ b/lib/crewai/src/crewai/llms/providers/anthropic/completion.py @@ -923,6 +923,8 @@ class AnthropicCompletion(BaseLLM): usage = self._extract_anthropic_token_usage(response) self._track_token_usage_internal(usage) + finish_reason, response_id = self._extract_finish_reason_and_id(response) + if _is_pydantic_model_class(response_model) and response.content: if use_native_structured_output: for block in response.content: @@ -935,6 +937,8 @@ class AnthropicCompletion(BaseLLM): from_agent=from_agent, messages=params["messages"], usage=usage, + finish_reason=finish_reason, + response_id=response_id, ) return structured_data else: @@ -951,6 +955,8 @@ class AnthropicCompletion(BaseLLM): from_agent=from_agent, messages=params["messages"], usage=usage, + finish_reason=finish_reason, + response_id=response_id, ) return structured_data @@ -973,6 +979,8 @@ class AnthropicCompletion(BaseLLM): from_agent=from_agent, messages=params["messages"], usage=usage, + finish_reason=finish_reason, + response_id=response_id, ) return list(tool_uses) @@ -1005,6 +1013,8 @@ class AnthropicCompletion(BaseLLM): from_agent=from_agent, messages=params["messages"], usage=usage, + finish_reason=finish_reason, + response_id=response_id, ) if usage.get("total_tokens", 0) > 0: @@ -1147,6 +1157,10 @@ class AnthropicCompletion(BaseLLM): usage = self._extract_anthropic_token_usage(final_message) self._track_token_usage_internal(usage) + finish_reason, final_response_id = self._extract_finish_reason_and_id( + final_message + ) + if _is_pydantic_model_class(response_model): if use_native_structured_output: structured_data = response_model.model_validate_json(full_response) @@ -1157,6 +1171,8 @@ class AnthropicCompletion(BaseLLM): from_agent=from_agent, messages=params["messages"], usage=usage, + finish_reason=finish_reason, + response_id=final_response_id, ) return structured_data for block in final_message.content: @@ -1172,6 +1188,8 @@ class AnthropicCompletion(BaseLLM): from_agent=from_agent, messages=params["messages"], usage=usage, + finish_reason=finish_reason, + response_id=final_response_id, ) return structured_data @@ -1201,6 +1219,8 @@ class AnthropicCompletion(BaseLLM): from_agent=from_agent, messages=params["messages"], usage=usage, + finish_reason=finish_reason, + response_id=final_response_id, ) return self._invoke_after_llm_call_hooks( @@ -1361,6 +1381,10 @@ class AnthropicCompletion(BaseLLM): final_content = self._apply_stop_words(final_content) + finish_reason, final_response_id = self._extract_finish_reason_and_id( + final_response + ) + self._emit_call_completed_event( response=final_content, call_type=LLMCallType.LLM_CALL, @@ -1368,6 +1392,8 @@ class AnthropicCompletion(BaseLLM): from_agent=from_agent, messages=follow_up_params["messages"], usage=follow_up_usage, + finish_reason=finish_reason, + response_id=final_response_id, ) total_usage = { @@ -1447,6 +1473,8 @@ class AnthropicCompletion(BaseLLM): usage = self._extract_anthropic_token_usage(response) self._track_token_usage_internal(usage) + finish_reason, response_id = self._extract_finish_reason_and_id(response) + if _is_pydantic_model_class(response_model) and response.content: if use_native_structured_output: for block in response.content: @@ -1459,6 +1487,8 @@ class AnthropicCompletion(BaseLLM): from_agent=from_agent, messages=params["messages"], usage=usage, + finish_reason=finish_reason, + response_id=response_id, ) return structured_data else: @@ -1475,6 +1505,8 @@ class AnthropicCompletion(BaseLLM): from_agent=from_agent, messages=params["messages"], usage=usage, + finish_reason=finish_reason, + response_id=response_id, ) return structured_data @@ -1495,6 +1527,8 @@ class AnthropicCompletion(BaseLLM): from_agent=from_agent, messages=params["messages"], usage=usage, + finish_reason=finish_reason, + response_id=response_id, ) return list(tool_uses) @@ -1519,6 +1553,8 @@ class AnthropicCompletion(BaseLLM): from_agent=from_agent, messages=params["messages"], usage=usage, + finish_reason=finish_reason, + response_id=response_id, ) if usage.get("total_tokens", 0) > 0: @@ -1647,6 +1683,10 @@ class AnthropicCompletion(BaseLLM): usage = self._extract_anthropic_token_usage(final_message) self._track_token_usage_internal(usage) + finish_reason, final_response_id = self._extract_finish_reason_and_id( + final_message + ) + if _is_pydantic_model_class(response_model): if use_native_structured_output: structured_data = response_model.model_validate_json(full_response) @@ -1657,6 +1697,8 @@ class AnthropicCompletion(BaseLLM): from_agent=from_agent, messages=params["messages"], usage=usage, + finish_reason=finish_reason, + response_id=final_response_id, ) return structured_data for block in final_message.content: @@ -1672,6 +1714,8 @@ class AnthropicCompletion(BaseLLM): from_agent=from_agent, messages=params["messages"], usage=usage, + finish_reason=finish_reason, + response_id=final_response_id, ) return structured_data @@ -1701,6 +1745,8 @@ class AnthropicCompletion(BaseLLM): from_agent=from_agent, messages=params["messages"], usage=usage, + finish_reason=finish_reason, + response_id=final_response_id, ) return full_response @@ -1753,6 +1799,10 @@ class AnthropicCompletion(BaseLLM): final_content = self._apply_stop_words(final_content) + finish_reason, final_response_id = self._extract_finish_reason_and_id( + final_response + ) + self._emit_call_completed_event( response=final_content, call_type=LLMCallType.LLM_CALL, @@ -1760,6 +1810,8 @@ class AnthropicCompletion(BaseLLM): from_agent=from_agent, messages=follow_up_params["messages"], usage=follow_up_usage, + finish_reason=finish_reason, + response_id=final_response_id, ) total_usage = { @@ -1813,6 +1865,20 @@ class AnthropicCompletion(BaseLLM): return int(200000 * CONTEXT_WINDOW_USAGE_RATIO) + @staticmethod + def _extract_finish_reason_and_id( + message: Any, + ) -> tuple[str | None, str | None]: + """Extract raw finish_reason and response_id from an Anthropic + ``Message`` / ``BetaMessage``. Anthropic exposes ``stop_reason`` (e.g. + ``"end_turn"``, ``"max_tokens"``, ``"tool_use"``); we forward it raw + and let downstream telemetry map to the OTel GenAI enum. + """ + return ( + getattr(message, "stop_reason", None), + getattr(message, "id", None), + ) + @staticmethod def _extract_anthropic_token_usage( response: Message | BetaMessage, diff --git a/lib/crewai/src/crewai/llms/providers/azure/completion.py b/lib/crewai/src/crewai/llms/providers/azure/completion.py index d357939bb..618ed5811 100644 --- a/lib/crewai/src/crewai/llms/providers/azure/completion.py +++ b/lib/crewai/src/crewai/llms/providers/azure/completion.py @@ -9,6 +9,7 @@ from urllib.parse import urlparse from pydantic import BaseModel, PrivateAttr, model_validator from typing_extensions import Self +from crewai.llms._finish_reason_utils import extract_choices_finish_reason_and_id from crewai.llms.hooks.base import BaseInterceptor from crewai.utilities.agent_utils import is_context_length_exceeded from crewai.utilities.exceptions.context_window_exceeding_exception import ( @@ -783,6 +784,8 @@ class AzureCompletion(BaseLLM): from_task: Any | None = None, from_agent: Any | None = None, usage: dict[str, Any] | None = None, + finish_reason: str | None = None, + response_id: str | None = None, ) -> BaseModel: """Validate content against response model and emit completion event. @@ -792,6 +795,8 @@ class AzureCompletion(BaseLLM): params: Completion parameters containing messages from_task: Task that initiated the call from_agent: Agent that initiated the call + finish_reason: Raw provider finish reason. + response_id: Raw provider response id. Returns: Validated Pydantic model instance @@ -809,6 +814,8 @@ class AzureCompletion(BaseLLM): from_agent=from_agent, messages=params["messages"], usage=usage, + finish_reason=finish_reason, + response_id=response_id, ) return structured_data @@ -848,6 +855,8 @@ class AzureCompletion(BaseLLM): usage = self._extract_azure_token_usage(response) self._track_token_usage_internal(usage) + finish_reason, response_id = self._extract_finish_reason_and_id(response) + # Without available_functions, return tool_calls so the caller (executor) handles execution if message.tool_calls and not available_functions: self._emit_call_completed_event( @@ -857,6 +866,8 @@ class AzureCompletion(BaseLLM): from_agent=from_agent, messages=params["messages"], usage=usage, + finish_reason=finish_reason, + response_id=response_id, ) return list(message.tool_calls) @@ -892,6 +903,8 @@ class AzureCompletion(BaseLLM): from_task=from_task, from_agent=from_agent, usage=usage, + finish_reason=finish_reason, + response_id=response_id, ) content = self._apply_stop_words(content) @@ -903,6 +916,8 @@ class AzureCompletion(BaseLLM): from_agent=from_agent, messages=params["messages"], usage=usage, + finish_reason=finish_reason, + response_id=response_id, ) return self._invoke_after_llm_call_hooks( @@ -1011,6 +1026,8 @@ class AzureCompletion(BaseLLM): from_task: Any | None = None, from_agent: Any | None = None, response_model: type[BaseModel] | None = None, + finish_reason: str | None = None, + response_id: str | None = None, ) -> str | Any: """Finalize streaming response with usage tracking, tool execution, and events. @@ -1039,6 +1056,8 @@ class AzureCompletion(BaseLLM): from_task=from_task, from_agent=from_agent, usage=usage_data, + finish_reason=finish_reason, + response_id=response_id, ) # Without available_functions, return tool calls in OpenAI-compatible format for the executor @@ -1061,6 +1080,8 @@ class AzureCompletion(BaseLLM): from_agent=from_agent, messages=params["messages"], usage=usage_data, + finish_reason=finish_reason, + response_id=response_id, ) return formatted_tool_calls @@ -1094,6 +1115,8 @@ class AzureCompletion(BaseLLM): from_agent=from_agent, messages=params["messages"], usage=usage_data, + finish_reason=finish_reason, + response_id=response_id, ) return self._invoke_after_llm_call_hooks( @@ -1113,8 +1136,16 @@ class AzureCompletion(BaseLLM): tool_calls: dict[int, dict[str, Any]] = {} usage_data: dict[str, Any] | None = None + stream_finish_reason: str | None = None + stream_response_id: str | None = None for update in self._get_sync_client().complete(**params): if isinstance(update, StreamingChatCompletionsUpdate): + chunk_finish, chunk_id = self._extract_finish_reason_and_id(update) + if chunk_finish: + stream_finish_reason = chunk_finish + if chunk_id: + stream_response_id = chunk_id + if update.usage: usage = update.usage usage_data = { @@ -1141,6 +1172,8 @@ class AzureCompletion(BaseLLM): from_task=from_task, from_agent=from_agent, response_model=response_model, + finish_reason=stream_finish_reason, + response_id=stream_response_id, ) async def _ahandle_completion( @@ -1180,10 +1213,18 @@ class AzureCompletion(BaseLLM): tool_calls: dict[int, dict[str, Any]] = {} usage_data: dict[str, Any] | None = None + stream_finish_reason: str | None = None + stream_response_id: str | None = None stream = await self._get_async_client().complete(**params) async for update in stream: if isinstance(update, StreamingChatCompletionsUpdate): + chunk_finish, chunk_id = self._extract_finish_reason_and_id(update) + if chunk_finish: + stream_finish_reason = chunk_finish + if chunk_id: + stream_response_id = chunk_id + if hasattr(update, "usage") and update.usage: usage = update.usage usage_data = { @@ -1210,6 +1251,8 @@ class AzureCompletion(BaseLLM): from_task=from_task, from_agent=from_agent, response_model=response_model, + finish_reason=stream_finish_reason, + response_id=stream_response_id, ) def supports_function_calling(self) -> bool: @@ -1271,6 +1314,19 @@ class AzureCompletion(BaseLLM): return int(8192 * CONTEXT_WINDOW_USAGE_RATIO) + def _effective_max_tokens(self) -> int | float | None: + """Azure reasoning/newer chat models cap via ``max_completion_tokens``.""" + return self.max_tokens or self.max_completion_tokens + + @staticmethod + def _extract_finish_reason_and_id( + response_or_update: Any, + ) -> tuple[str | None, str | None]: + """Azure ``ChatCompletions`` / ``StreamingChatCompletionsUpdate`` + share the choices-shape; delegate to the shared extractor. + """ + return extract_choices_finish_reason_and_id(response_or_update) + @staticmethod def _extract_azure_token_usage(response: ChatCompletions) -> dict[str, Any]: """Extract token usage and response metadata from Azure response.""" diff --git a/lib/crewai/src/crewai/llms/providers/bedrock/completion.py b/lib/crewai/src/crewai/llms/providers/bedrock/completion.py index e9790c577..0f34b6723 100644 --- a/lib/crewai/src/crewai/llms/providers/bedrock/completion.py +++ b/lib/crewai/src/crewai/llms/providers/bedrock/completion.py @@ -677,7 +677,7 @@ class BedrockCompletion(BaseLLM): if usage: self._track_token_usage_internal(usage) - stop_reason = response.get("stopReason") + stop_reason, response_id = self._extract_finish_reason_and_id(response) if stop_reason: logging.debug(f"Response stop reason: {stop_reason}") if stop_reason == "max_tokens": @@ -716,6 +716,8 @@ class BedrockCompletion(BaseLLM): from_agent=from_agent, messages=messages, usage=usage, + finish_reason=stop_reason, + response_id=response_id, ) return result except Exception as e: @@ -738,6 +740,8 @@ class BedrockCompletion(BaseLLM): from_agent=from_agent, messages=messages, usage=usage, + finish_reason=stop_reason, + response_id=response_id, ) return non_structured_output_tool_uses @@ -812,6 +816,8 @@ class BedrockCompletion(BaseLLM): from_agent=from_agent, messages=messages, usage=usage, + finish_reason=stop_reason, + response_id=response_id, ) return self._invoke_after_llm_call_hooks( @@ -951,7 +957,9 @@ class BedrockCompletion(BaseLLM): ) stream = response.get("stream") - response_id = None + _, stream_response_id = self._extract_finish_reason_and_id(response) + response_id = stream_response_id + stream_finish_reason: str | None = None if stream: for event in stream: if "messageStart" in event: @@ -1042,6 +1050,9 @@ class BedrockCompletion(BaseLLM): result = response_model.model_validate( function_args ) + # contentBlockStop fires before messageStop sets + # stream_finish_reason; structured output always + # completes via the tool-call path. self._emit_call_completed_event( response=result.model_dump_json(), call_type=LLMCallType.LLM_CALL, @@ -1049,6 +1060,9 @@ class BedrockCompletion(BaseLLM): from_agent=from_agent, messages=messages, usage=usage_data, + finish_reason=stream_finish_reason + or "tool_use", + response_id=response_id, ) return result # type: ignore[return-value] except Exception as e: @@ -1102,6 +1116,7 @@ class BedrockCompletion(BaseLLM): tool_use_id = None elif "messageStop" in event: stop_reason = event["messageStop"].get("stopReason") + stream_finish_reason = stop_reason logging.debug(f"Streaming message stopped: {stop_reason}") if stop_reason == "max_tokens": logging.warning( @@ -1147,6 +1162,8 @@ class BedrockCompletion(BaseLLM): from_agent=from_agent, messages=messages, usage=usage_data, + finish_reason=stream_finish_reason, + response_id=response_id, ) return full_response @@ -1262,7 +1279,7 @@ class BedrockCompletion(BaseLLM): if usage: self._track_token_usage_internal(usage) - stop_reason = response.get("stopReason") + stop_reason, response_id = self._extract_finish_reason_and_id(response) if stop_reason: logging.debug(f"Response stop reason: {stop_reason}") if stop_reason == "max_tokens": @@ -1300,6 +1317,8 @@ class BedrockCompletion(BaseLLM): from_agent=from_agent, messages=messages, usage=usage, + finish_reason=stop_reason, + response_id=response_id, ) return result except Exception as e: @@ -1322,6 +1341,8 @@ class BedrockCompletion(BaseLLM): from_agent=from_agent, messages=messages, usage=usage, + finish_reason=stop_reason, + response_id=response_id, ) return non_structured_output_tool_uses @@ -1397,6 +1418,8 @@ class BedrockCompletion(BaseLLM): from_agent=from_agent, messages=messages, usage=usage, + finish_reason=stop_reason, + response_id=response_id, ) return text_content @@ -1531,7 +1554,9 @@ class BedrockCompletion(BaseLLM): ) stream = response.get("stream") - response_id = None + _, stream_response_id = self._extract_finish_reason_and_id(response) + response_id = stream_response_id + stream_finish_reason: str | None = None if stream: async for event in stream: if "messageStart" in event: @@ -1623,6 +1648,9 @@ class BedrockCompletion(BaseLLM): result = response_model.model_validate( function_args ) + # contentBlockStop fires before messageStop sets + # stream_finish_reason; structured output always + # completes via the tool-call path. self._emit_call_completed_event( response=result.model_dump_json(), call_type=LLMCallType.LLM_CALL, @@ -1630,6 +1658,9 @@ class BedrockCompletion(BaseLLM): from_agent=from_agent, messages=messages, usage=usage_data, + finish_reason=stream_finish_reason + or "tool_use", + response_id=response_id, ) return result # type: ignore[return-value] except Exception as e: @@ -1687,6 +1718,7 @@ class BedrockCompletion(BaseLLM): elif "messageStop" in event: stop_reason = event["messageStop"].get("stopReason") + stream_finish_reason = stop_reason logging.debug(f"Streaming message stopped: {stop_reason}") if stop_reason == "max_tokens": logging.warning( @@ -1733,6 +1765,8 @@ class BedrockCompletion(BaseLLM): from_agent=from_agent, messages=messages, usage=usage_data, + finish_reason=stream_finish_reason, + response_id=response_id, ) return self._invoke_after_llm_call_hooks( @@ -1988,6 +2022,25 @@ class BedrockCompletion(BaseLLM): return config + @staticmethod + def _extract_finish_reason_and_id( + response: Any, + ) -> tuple[str | None, str | None]: + """Extract raw finish_reason (``stopReason``) from a Bedrock Converse + response dict. Defensive — returns (None, None) on any failure. + + Bedrock Converse has no model-level response id; ResponseMetadata.RequestId + is an AWS infra trace id (semantically different from OpenAI's chatcmpl-XXX), + so we omit response_id rather than mislead downstream telemetry consumers. + """ + finish_reason: str | None = None + try: + if isinstance(response, dict): + finish_reason = response.get("stopReason") + except (AttributeError, KeyError, TypeError, IndexError): + finish_reason = None + return finish_reason, None + def _handle_client_error(self, e: ClientError) -> str: """Handle AWS ClientError with specific error codes and return error message.""" error_code = e.response.get("Error", {}).get("Code", "Unknown") diff --git a/lib/crewai/src/crewai/llms/providers/gemini/completion.py b/lib/crewai/src/crewai/llms/providers/gemini/completion.py index 8914b6b26..b811614a1 100644 --- a/lib/crewai/src/crewai/llms/providers/gemini/completion.py +++ b/lib/crewai/src/crewai/llms/providers/gemini/completion.py @@ -682,6 +682,8 @@ class GeminiCompletion(BaseLLM): from_task: Any | None = None, from_agent: Any | None = None, usage: dict[str, Any] | None = None, + finish_reason: str | None = None, + response_id: str | None = None, ) -> BaseModel: """Validate content against response model and emit completion event. @@ -691,6 +693,8 @@ class GeminiCompletion(BaseLLM): messages_for_event: Messages to include in event from_task: Task that initiated the call from_agent: Agent that initiated the call + finish_reason: Raw provider finish reason. + response_id: Raw provider response id. Returns: Validated Pydantic model instance @@ -708,6 +712,8 @@ class GeminiCompletion(BaseLLM): from_agent=from_agent, messages=messages_for_event, usage=usage, + finish_reason=finish_reason, + response_id=response_id, ) return structured_data @@ -724,6 +730,8 @@ class GeminiCompletion(BaseLLM): from_task: Any | None = None, from_agent: Any | None = None, usage: dict[str, Any] | None = None, + finish_reason: str | None = None, + response_id: str | None = None, ) -> str | BaseModel: """Finalize completion response with validation and event emission. @@ -747,6 +755,8 @@ class GeminiCompletion(BaseLLM): from_task=from_task, from_agent=from_agent, usage=usage, + finish_reason=finish_reason, + response_id=response_id, ) self._emit_call_completed_event( @@ -756,6 +766,8 @@ class GeminiCompletion(BaseLLM): from_agent=from_agent, messages=messages_for_event, usage=usage, + finish_reason=finish_reason, + response_id=response_id, ) return self._invoke_after_llm_call_hooks( @@ -770,6 +782,8 @@ class GeminiCompletion(BaseLLM): from_task: Any | None = None, from_agent: Any | None = None, usage: dict[str, Any] | None = None, + finish_reason: str | None = None, + response_id: str | None = None, ) -> BaseModel: """Validate and emit event for structured_output tool call. @@ -795,6 +809,8 @@ class GeminiCompletion(BaseLLM): from_agent=from_agent, messages=self._convert_contents_to_dict(contents), usage=usage, + finish_reason=finish_reason, + response_id=response_id, ) return validated_data except Exception as e: @@ -828,6 +844,8 @@ class GeminiCompletion(BaseLLM): Returns: Final response content or function call result """ + finish_reason, response_id = self._extract_finish_reason_and_id(response) + if response.candidates and (self.tools or available_functions): candidate = response.candidates[0] if candidate.content and candidate.content.parts: @@ -854,6 +872,8 @@ class GeminiCompletion(BaseLLM): from_task=from_task, from_agent=from_agent, usage=usage, + finish_reason=finish_reason, + response_id=response_id, ) non_structured_output_parts = [ @@ -875,6 +895,8 @@ class GeminiCompletion(BaseLLM): from_agent=from_agent, messages=self._convert_contents_to_dict(contents), usage=usage, + finish_reason=finish_reason, + response_id=response_id, ) return non_structured_output_parts @@ -915,6 +937,8 @@ class GeminiCompletion(BaseLLM): from_task=from_task, from_agent=from_agent, usage=usage, + finish_reason=finish_reason, + response_id=response_id, ) def _process_stream_chunk( @@ -925,7 +949,13 @@ class GeminiCompletion(BaseLLM): usage_data: dict[str, int] | None, from_task: Any | None = None, from_agent: Any | None = None, - ) -> tuple[str, dict[int, dict[str, Any]], dict[str, int] | None]: + ) -> tuple[ + str, + dict[int, dict[str, Any]], + dict[str, int] | None, + str | None, + str | None, + ]: """Process a single streaming chunk. Args: @@ -937,9 +967,13 @@ class GeminiCompletion(BaseLLM): from_agent: Agent that initiated the call Returns: - Tuple of (updated full_response, updated function_calls, updated usage_data) + Tuple of (updated full_response, updated function_calls, updated + usage_data, chunk finish_reason, chunk response_id). """ response_id = chunk.response_id if hasattr(chunk, "response_id") else None + chunk_finish_reason, chunk_response_id = self._extract_finish_reason_and_id( + chunk + ) if chunk.usage_metadata: usage_data = self._extract_token_usage(chunk) @@ -996,7 +1030,13 @@ class GeminiCompletion(BaseLLM): response_id=response_id, ) - return full_response, function_calls, usage_data + return ( + full_response, + function_calls, + usage_data, + chunk_finish_reason, + chunk_response_id, + ) def _finalize_streaming_response( self, @@ -1008,6 +1048,8 @@ class GeminiCompletion(BaseLLM): from_task: Any | None = None, from_agent: Any | None = None, response_model: type[BaseModel] | None = None, + finish_reason: str | None = None, + response_id: str | None = None, ) -> str | BaseModel | list[dict[str, Any]]: """Finalize streaming response with usage tracking, function execution, and events. @@ -1038,6 +1080,8 @@ class GeminiCompletion(BaseLLM): from_task=from_task, from_agent=from_agent, usage=usage_data, + finish_reason=finish_reason, + response_id=response_id, ) non_structured_output_calls = { @@ -1058,6 +1102,8 @@ class GeminiCompletion(BaseLLM): from_agent=from_agent, messages=self._convert_contents_to_dict(contents), usage=usage_data, + finish_reason=finish_reason, + response_id=response_id, ) return raw_parts @@ -1095,6 +1141,8 @@ class GeminiCompletion(BaseLLM): from_task=from_task, from_agent=from_agent, usage=usage_data, + finish_reason=finish_reason, + response_id=response_id, ) def _handle_completion( @@ -1148,6 +1196,8 @@ class GeminiCompletion(BaseLLM): full_response = "" function_calls: dict[int, dict[str, Any]] = {} usage_data: dict[str, int] | None = None + stream_finish_reason: str | None = None + stream_response_id: str | None = None # The API accepts list[Content] but mypy is overly strict about variance contents_for_api: Any = contents @@ -1156,7 +1206,13 @@ class GeminiCompletion(BaseLLM): contents=contents_for_api, config=config, ): - full_response, function_calls, usage_data = self._process_stream_chunk( + ( + full_response, + function_calls, + usage_data, + chunk_finish_reason, + chunk_response_id, + ) = self._process_stream_chunk( chunk=chunk, full_response=full_response, function_calls=function_calls, @@ -1164,6 +1220,10 @@ class GeminiCompletion(BaseLLM): from_task=from_task, from_agent=from_agent, ) + if chunk_finish_reason: + stream_finish_reason = chunk_finish_reason + if chunk_response_id: + stream_response_id = chunk_response_id return self._finalize_streaming_response( full_response=full_response, @@ -1174,6 +1234,8 @@ class GeminiCompletion(BaseLLM): from_task=from_task, from_agent=from_agent, response_model=response_model, + finish_reason=stream_finish_reason, + response_id=stream_response_id, ) async def _ahandle_completion( @@ -1227,6 +1289,8 @@ class GeminiCompletion(BaseLLM): full_response = "" function_calls: dict[int, dict[str, Any]] = {} usage_data: dict[str, int] | None = None + stream_finish_reason: str | None = None + stream_response_id: str | None = None # The API accepts list[Content] but mypy is overly strict about variance contents_for_api: Any = contents @@ -1236,7 +1300,13 @@ class GeminiCompletion(BaseLLM): config=config, ) async for chunk in stream: - full_response, function_calls, usage_data = self._process_stream_chunk( + ( + full_response, + function_calls, + usage_data, + chunk_finish_reason, + chunk_response_id, + ) = self._process_stream_chunk( chunk=chunk, full_response=full_response, function_calls=function_calls, @@ -1244,6 +1314,10 @@ class GeminiCompletion(BaseLLM): from_task=from_task, from_agent=from_agent, ) + if chunk_finish_reason: + stream_finish_reason = chunk_finish_reason + if chunk_response_id: + stream_response_id = chunk_response_id return self._finalize_streaming_response( full_response=full_response, @@ -1254,6 +1328,8 @@ class GeminiCompletion(BaseLLM): from_task=from_task, from_agent=from_agent, response_model=response_model, + finish_reason=stream_finish_reason, + response_id=stream_response_id, ) def supports_function_calling(self) -> bool: @@ -1300,6 +1376,34 @@ class GeminiCompletion(BaseLLM): return int(1048576 * CONTEXT_WINDOW_USAGE_RATIO) # 1M tokens default + def _effective_max_tokens(self) -> int | float | None: + """Gemini caps generation via ``max_output_tokens``.""" + return self.max_output_tokens or self.max_tokens + + @staticmethod + def _extract_finish_reason_and_id( + response: Any, + ) -> tuple[str | None, str | None]: + """Extract raw finish_reason and response_id from a Gemini + ``GenerateContentResponse``. ``finish_reason`` is the protobuf enum's + ``.name`` attribute (e.g. ``"STOP"``, ``"MAX_TOKENS"``); we forward + it raw and let downstream telemetry map to the OTel GenAI enum. + """ + raw_response_id = getattr(response, "response_id", None) + response_id = raw_response_id if isinstance(raw_response_id, str) else None + + finish_reason: str | None = None + candidates = getattr(response, "candidates", None) + if candidates: + try: + candidate_finish = getattr(candidates[0], "finish_reason", None) + except (IndexError, TypeError, KeyError): + candidate_finish = None + if candidate_finish is not None: + name = getattr(candidate_finish, "name", None) + finish_reason = name if isinstance(name, str) else None + return finish_reason, response_id + @staticmethod def _extract_token_usage(response: GenerateContentResponse) -> dict[str, Any]: """Extract token usage and response metadata from Gemini response.""" diff --git a/lib/crewai/src/crewai/llms/providers/openai/completion.py b/lib/crewai/src/crewai/llms/providers/openai/completion.py index 0adcd82d6..4a610423c 100644 --- a/lib/crewai/src/crewai/llms/providers/openai/completion.py +++ b/lib/crewai/src/crewai/llms/providers/openai/completion.py @@ -29,6 +29,7 @@ from openai.types.responses import ( from pydantic import BaseModel, PrivateAttr, model_validator from crewai.events.types.llm_events import LLMCallType +from crewai.llms._finish_reason_utils import extract_choices_finish_reason_and_id from crewai.llms.base_llm import BaseLLM, JsonResponseFormat, llm_call_context from crewai.llms.hooks.base import BaseInterceptor from crewai.llms.hooks.transport import AsyncHTTPTransport, HTTPTransport @@ -825,6 +826,10 @@ class OpenAICompletion(BaseLLM): usage = self._extract_responses_token_usage(response) self._track_token_usage_internal(usage) + finish_reason, response_id = self._extract_responses_finish_reason_and_id( + response + ) + if self.parse_tool_outputs: parsed_result = self._extract_builtin_tool_outputs(response) parsed_result.text = self._apply_stop_words(parsed_result.text) @@ -836,6 +841,8 @@ class OpenAICompletion(BaseLLM): from_agent=from_agent, messages=params.get("input", []), usage=usage, + finish_reason=finish_reason, + response_id=response_id, ) return parsed_result @@ -849,6 +856,8 @@ class OpenAICompletion(BaseLLM): from_agent=from_agent, messages=params.get("input", []), usage=usage, + finish_reason=finish_reason, + response_id=response_id, ) return function_calls @@ -887,6 +896,8 @@ class OpenAICompletion(BaseLLM): from_agent=from_agent, messages=params.get("input", []), usage=usage, + finish_reason=finish_reason, + response_id=response_id, ) return structured_result except ValueError as e: @@ -901,6 +912,8 @@ class OpenAICompletion(BaseLLM): from_agent=from_agent, messages=params.get("input", []), usage=usage, + finish_reason=finish_reason, + response_id=response_id, ) content = self._invoke_after_llm_call_hooks( @@ -960,6 +973,10 @@ class OpenAICompletion(BaseLLM): usage = self._extract_responses_token_usage(response) self._track_token_usage_internal(usage) + finish_reason, response_id = self._extract_responses_finish_reason_and_id( + response + ) + if self.parse_tool_outputs: parsed_result = self._extract_builtin_tool_outputs(response) parsed_result.text = self._apply_stop_words(parsed_result.text) @@ -971,6 +988,8 @@ class OpenAICompletion(BaseLLM): from_agent=from_agent, messages=params.get("input", []), usage=usage, + finish_reason=finish_reason, + response_id=response_id, ) return parsed_result @@ -984,6 +1003,8 @@ class OpenAICompletion(BaseLLM): from_agent=from_agent, messages=params.get("input", []), usage=usage, + finish_reason=finish_reason, + response_id=response_id, ) return function_calls @@ -1022,6 +1043,8 @@ class OpenAICompletion(BaseLLM): from_agent=from_agent, messages=params.get("input", []), usage=usage, + finish_reason=finish_reason, + response_id=response_id, ) return structured_result except ValueError as e: @@ -1036,6 +1059,8 @@ class OpenAICompletion(BaseLLM): from_agent=from_agent, messages=params.get("input", []), usage=usage, + finish_reason=finish_reason, + response_id=response_id, ) except NotFoundError as e: @@ -1123,6 +1148,12 @@ class OpenAICompletion(BaseLLM): usage = self._extract_responses_token_usage(event.response) self._track_token_usage_internal(usage) + finish_reason, response_id = ( + self._extract_responses_finish_reason_and_id(final_response) + if final_response is not None + else (None, response_id_stream) + ) + if self.parse_tool_outputs and final_response: parsed_result = self._extract_builtin_tool_outputs(final_response) parsed_result.text = self._apply_stop_words(parsed_result.text) @@ -1134,6 +1165,8 @@ class OpenAICompletion(BaseLLM): from_agent=from_agent, messages=params.get("input", []), usage=usage, + finish_reason=finish_reason, + response_id=response_id, ) return parsed_result @@ -1171,6 +1204,8 @@ class OpenAICompletion(BaseLLM): from_agent=from_agent, messages=params.get("input", []), usage=usage, + finish_reason=finish_reason, + response_id=response_id, ) return structured_result except ValueError as e: @@ -1185,6 +1220,8 @@ class OpenAICompletion(BaseLLM): from_agent=from_agent, messages=params.get("input", []), usage=usage, + finish_reason=finish_reason, + response_id=response_id, ) return self._invoke_after_llm_call_hooks( @@ -1248,6 +1285,12 @@ class OpenAICompletion(BaseLLM): usage = self._extract_responses_token_usage(event.response) self._track_token_usage_internal(usage) + finish_reason, response_id = ( + self._extract_responses_finish_reason_and_id(final_response) + if final_response is not None + else (None, response_id_stream) + ) + if self.parse_tool_outputs and final_response: parsed_result = self._extract_builtin_tool_outputs(final_response) parsed_result.text = self._apply_stop_words(parsed_result.text) @@ -1259,6 +1302,8 @@ class OpenAICompletion(BaseLLM): from_agent=from_agent, messages=params.get("input", []), usage=usage, + finish_reason=finish_reason, + response_id=response_id, ) return parsed_result @@ -1296,6 +1341,8 @@ class OpenAICompletion(BaseLLM): from_agent=from_agent, messages=params.get("input", []), usage=usage, + finish_reason=finish_reason, + response_id=response_id, ) return structured_result except ValueError as e: @@ -1310,6 +1357,8 @@ class OpenAICompletion(BaseLLM): from_agent=from_agent, messages=params.get("input", []), usage=usage, + finish_reason=finish_reason, + response_id=response_id, ) return full_response @@ -1603,6 +1652,9 @@ class OpenAICompletion(BaseLLM): usage = self._extract_openai_token_usage(parsed_response) self._track_token_usage_internal(usage) + parsed_finish_reason, parsed_response_id = ( + self._extract_chat_finish_reason_and_id(parsed_response) + ) parsed_object = parsed_response.choices[0].message.parsed if parsed_object: self._emit_call_completed_event( @@ -1612,6 +1664,8 @@ class OpenAICompletion(BaseLLM): from_agent=from_agent, messages=params["messages"], usage=usage, + finish_reason=parsed_finish_reason, + response_id=parsed_response_id, ) return parsed_object @@ -1625,6 +1679,9 @@ class OpenAICompletion(BaseLLM): choice: Choice = response.choices[0] message = choice.message + finish_reason, response_id = self._extract_chat_finish_reason_and_id( + response + ) # Without available_functions, return tool_calls so the caller (executor) handles execution if message.tool_calls and not available_functions: @@ -1635,6 +1692,8 @@ class OpenAICompletion(BaseLLM): from_agent=from_agent, messages=params["messages"], usage=usage, + finish_reason=finish_reason, + response_id=response_id, ) return list(message.tool_calls) @@ -1675,6 +1734,8 @@ class OpenAICompletion(BaseLLM): from_agent=from_agent, messages=params["messages"], usage=usage, + finish_reason=finish_reason, + response_id=response_id, ) return structured_result except ValueError as e: @@ -1689,6 +1750,8 @@ class OpenAICompletion(BaseLLM): from_agent=from_agent, messages=params["messages"], usage=usage, + finish_reason=finish_reason, + response_id=response_id, ) if usage.get("total_tokens", 0) > 0: @@ -1734,6 +1797,8 @@ class OpenAICompletion(BaseLLM): available_functions: dict[str, Any] | None = None, from_task: Any | None = None, from_agent: Any | None = None, + finish_reason: str | None = None, + response_id: str | None = None, ) -> str | list[dict[str, Any]]: """Finalize a streaming response with usage tracking, tool call handling, and events. @@ -1745,6 +1810,9 @@ class OpenAICompletion(BaseLLM): available_functions: Available functions for tool calling. from_task: Task that initiated the call. from_agent: Agent that initiated the call. + finish_reason: Raw provider finish reason (e.g. "stop", "length", + "tool_calls") extracted from the last streaming chunk. + response_id: Raw provider response id from any chunk. Returns: Tool calls list when tools were invoked without available_functions, @@ -1774,6 +1842,8 @@ class OpenAICompletion(BaseLLM): from_agent=from_agent, messages=params["messages"], usage=usage_data, + finish_reason=finish_reason, + response_id=response_id, ) return tool_calls_list @@ -1817,6 +1887,8 @@ class OpenAICompletion(BaseLLM): from_agent=from_agent, messages=params["messages"], usage=usage_data, + finish_reason=finish_reason, + response_id=response_id, ) return full_response @@ -1861,6 +1933,9 @@ class OpenAICompletion(BaseLLM): if final_completion: usage = self._extract_openai_token_usage(final_completion) self._track_token_usage_internal(usage) + parsed_finish_reason, parsed_response_id = ( + self._extract_chat_finish_reason_and_id(final_completion) + ) if final_completion.choices: parsed_result = final_completion.choices[0].message.parsed if parsed_result: @@ -1871,6 +1946,8 @@ class OpenAICompletion(BaseLLM): from_agent=from_agent, messages=params["messages"], usage=usage, + finish_reason=parsed_finish_reason, + response_id=parsed_response_id, ) return parsed_result @@ -1882,11 +1959,15 @@ class OpenAICompletion(BaseLLM): ) usage_data: dict[str, Any] | None = None + stream_finish_reason: str | None = None + stream_response_id: str | None = None for completion_chunk in completion_stream: response_id_stream = ( completion_chunk.id if hasattr(completion_chunk, "id") else None ) + if response_id_stream: + stream_response_id = response_id_stream if hasattr(completion_chunk, "usage") and completion_chunk.usage: usage_data = self._extract_openai_token_usage(completion_chunk) @@ -1897,6 +1978,9 @@ class OpenAICompletion(BaseLLM): choice = completion_chunk.choices[0] chunk_delta: ChoiceDelta = choice.delta + chunk_finish = getattr(choice, "finish_reason", None) + if chunk_finish: + stream_finish_reason = chunk_finish if chunk_delta.content: full_response += chunk_delta.content @@ -1954,6 +2038,8 @@ class OpenAICompletion(BaseLLM): available_functions=available_functions, from_task=from_task, from_agent=from_agent, + finish_reason=stream_finish_reason, + response_id=stream_response_id, ) if isinstance(result, str): return self._invoke_after_llm_call_hooks( @@ -1989,6 +2075,9 @@ class OpenAICompletion(BaseLLM): usage = self._extract_openai_token_usage(parsed_response) self._track_token_usage_internal(usage) + parsed_finish_reason, parsed_response_id = ( + self._extract_chat_finish_reason_and_id(parsed_response) + ) parsed_object = parsed_response.choices[0].message.parsed if parsed_object: self._emit_call_completed_event( @@ -1998,6 +2087,8 @@ class OpenAICompletion(BaseLLM): from_agent=from_agent, messages=params["messages"], usage=usage, + finish_reason=parsed_finish_reason, + response_id=parsed_response_id, ) return parsed_object @@ -2011,6 +2102,9 @@ class OpenAICompletion(BaseLLM): choice: Choice = response.choices[0] message = choice.message + finish_reason, response_id = self._extract_chat_finish_reason_and_id( + response + ) # Without available_functions, return tool_calls so the caller (executor) handles execution if message.tool_calls and not available_functions: @@ -2021,6 +2115,8 @@ class OpenAICompletion(BaseLLM): from_agent=from_agent, messages=params["messages"], usage=usage, + finish_reason=finish_reason, + response_id=response_id, ) return list(message.tool_calls) @@ -2065,6 +2161,8 @@ class OpenAICompletion(BaseLLM): from_agent=from_agent, messages=params["messages"], usage=usage, + finish_reason=finish_reason, + response_id=response_id, ) return structured_result except ValueError as e: @@ -2079,6 +2177,8 @@ class OpenAICompletion(BaseLLM): from_agent=from_agent, messages=params["messages"], usage=usage, + finish_reason=finish_reason, + response_id=response_id, ) if usage.get("total_tokens", 0) > 0: @@ -2130,8 +2230,12 @@ class OpenAICompletion(BaseLLM): accumulated_content = "" usage_data: dict[str, Any] | None = None + parsed_stream_finish_reason: str | None = None + parsed_stream_response_id: str | None = None async for chunk in completion_stream: response_id_stream = chunk.id if hasattr(chunk, "id") else None + if response_id_stream: + parsed_stream_response_id = response_id_stream if hasattr(chunk, "usage") and chunk.usage: usage_data = self._extract_openai_token_usage(chunk) @@ -2142,6 +2246,9 @@ class OpenAICompletion(BaseLLM): choice = chunk.choices[0] delta: ChoiceDelta = choice.delta + chunk_finish = getattr(choice, "finish_reason", None) + if chunk_finish: + parsed_stream_finish_reason = chunk_finish if delta.content: accumulated_content += delta.content @@ -2165,6 +2272,8 @@ class OpenAICompletion(BaseLLM): from_agent=from_agent, messages=params["messages"], usage=usage_data, + finish_reason=parsed_stream_finish_reason, + response_id=parsed_stream_response_id, ) return parsed_object @@ -2177,6 +2286,8 @@ class OpenAICompletion(BaseLLM): from_agent=from_agent, messages=params["messages"], usage=usage_data, + finish_reason=parsed_stream_finish_reason, + response_id=parsed_stream_response_id, ) return accumulated_content @@ -2185,9 +2296,13 @@ class OpenAICompletion(BaseLLM): ] = await self._get_async_client().chat.completions.create(**params) usage_data = None + stream_finish_reason: str | None = None + stream_response_id: str | None = None async for chunk in stream: response_id_stream = chunk.id if hasattr(chunk, "id") else None + if response_id_stream: + stream_response_id = response_id_stream if hasattr(chunk, "usage") and chunk.usage: usage_data = self._extract_openai_token_usage(chunk) @@ -2198,6 +2313,9 @@ class OpenAICompletion(BaseLLM): choice = chunk.choices[0] chunk_delta: ChoiceDelta = choice.delta + chunk_finish = getattr(choice, "finish_reason", None) + if chunk_finish: + stream_finish_reason = chunk_finish if chunk_delta.content: full_response += chunk_delta.content @@ -2255,6 +2373,8 @@ class OpenAICompletion(BaseLLM): available_functions=available_functions, from_task=from_task, from_agent=from_agent, + finish_reason=stream_finish_reason, + response_id=stream_response_id, ) def supports_function_calling(self) -> bool: @@ -2305,6 +2425,32 @@ class OpenAICompletion(BaseLLM): return int(8192 * CONTEXT_WINDOW_USAGE_RATIO) + def _effective_max_tokens(self) -> int | float | None: + """Newer OpenAI chat models cap via ``max_completion_tokens``.""" + return self.max_tokens or self.max_completion_tokens + + @staticmethod + def _extract_chat_finish_reason_and_id( + response: Any, + ) -> tuple[str | None, str | None]: + """ChatCompletion / ChatCompletionChunk share the choices-shape; + delegate to the shared extractor. + """ + return extract_choices_finish_reason_and_id(response) + + @staticmethod + def _extract_responses_finish_reason_and_id( + response: Any, + ) -> tuple[str | None, str | None]: + """Extract finish_reason and response_id from an OpenAI Responses + API ``Response`` object. The Responses API exposes ``status`` rather + than ``finish_reason``; we forward the raw status value. + """ + return ( + getattr(response, "status", None), + getattr(response, "id", None), + ) + def _extract_openai_token_usage( self, response: ChatCompletion | ChatCompletionChunk ) -> dict[str, Any]: diff --git a/lib/crewai/tests/events/test_llm_finish_reason_response_id.py b/lib/crewai/tests/events/test_llm_finish_reason_response_id.py new file mode 100644 index 000000000..091875fdf --- /dev/null +++ b/lib/crewai/tests/events/test_llm_finish_reason_response_id.py @@ -0,0 +1,526 @@ +from types import SimpleNamespace +from typing import Any +from unittest.mock import MagicMock, patch + +import pytest + +from crewai.events.event_bus import CrewAIEventsBus +from crewai.events.types.llm_events import ( + LLMCallCompletedEvent, + LLMCallStartedEvent, + LLMCallType, + LLMStreamChunkEvent, +) +from crewai.llm import LLM +from crewai.llms._finish_reason_utils import extract_choices_finish_reason_and_id +from crewai.llms.base_llm import BaseLLM + + +class _StubLLM(BaseLLM): + model: str = "test-model" + + def call(self, *args: Any, **kwargs: Any) -> str: + return "" + + async def acall(self, *args: Any, **kwargs: Any) -> str: + return "" + + def supports_function_calling(self) -> bool: + return False + + +@pytest.fixture +def mock_emit(): + with patch.object(CrewAIEventsBus, "emit") as mock: + yield mock + + +class TestLLMCallCompletedEventFinishReasonAndResponseId: + def test_accepts_string_values(self): + event = LLMCallCompletedEvent( + response="hi", + call_type=LLMCallType.LLM_CALL, + call_id="call-1", + finish_reason="stop", + response_id="resp_123", + ) + assert event.finish_reason == "stop" + assert event.response_id == "resp_123" + + def test_defaults_to_none(self): + event = LLMCallCompletedEvent( + response="hi", + call_type=LLMCallType.LLM_CALL, + call_id="call-1", + ) + assert event.finish_reason is None + assert event.response_id is None + + @pytest.mark.parametrize( + "value", + [MagicMock(), 42, 1.5, ["stop"], {"reason": "stop"}, object()], + ) + def test_coerces_non_string_to_none(self, value): + event = LLMCallCompletedEvent( + response="hi", + call_type=LLMCallType.LLM_CALL, + call_id="call-1", + finish_reason=value, + response_id=value, + ) + assert event.finish_reason is None + assert event.response_id is None + + +class TestLLMCallStartedEventSamplingParams: + def test_accepts_all_sampling_params(self): + event = LLMCallStartedEvent( + call_id="call-1", + temperature=0.7, + top_p=0.9, + max_tokens=512, + stream=True, + seed=42, + stop_sequences=["END"], + frequency_penalty=0.1, + presence_penalty=0.2, + n=3, + ) + assert event.temperature == 0.7 + assert event.top_p == 0.9 + assert event.max_tokens == 512 + assert event.stream is True + assert event.seed == 42 + assert event.stop_sequences == ["END"] + assert event.frequency_penalty == 0.1 + assert event.presence_penalty == 0.2 + assert event.n == 3 + + def test_all_sampling_params_default_to_none(self): + event = LLMCallStartedEvent(call_id="call-1") + assert event.temperature is None + assert event.top_p is None + assert event.max_tokens is None + assert event.stream is None + assert event.seed is None + assert event.stop_sequences is None + assert event.frequency_penalty is None + assert event.presence_penalty is None + assert event.n is None + + +class TestStopSequencesCoercion: + # The OTel SDK falls back to str(value) when a span attribute isn't a + # recognised Sequence[str], producing the protobuf textproto repr + # ("values { string_value: ... }") in downstream telemetry. The + # field_validator coerces exotic iterables (Vertex/Gemini protobuf + # containers, tuples, generators) to a clean list[str] up front so the + # OTel attribute is always shaped correctly. + def test_bare_string_is_wrapped_in_list(self): + event = LLMCallStartedEvent(call_id="call-1", stop_sequences="\nObservation:") + assert event.stop_sequences == ["\nObservation:"] + + @pytest.mark.parametrize( + "raw, expected", + [ + (["\nObservation:", "Final Answer:"], ["\nObservation:", "Final Answer:"]), + (("\nObservation:",), ["\nObservation:"]), + ((s for s in ["a", "b"]), ["a", "b"]), + ([], []), + ], + ) + def test_python_iterables_pass_through( + self, raw: Any, expected: list[str] + ) -> None: + event = LLMCallStartedEvent(call_id="call-1", stop_sequences=raw) + assert event.stop_sequences == expected + + def test_protobuf_like_repeated_container_is_coerced(self): + # Mirrors google.protobuf RepeatedScalarContainer: iterable yielding + # actual Python str objects. Should pass through cleanly. + class _RepeatedScalar: + def __init__(self, items: list[str]) -> None: + self._items = items + + def __iter__(self): + return iter(self._items) + + event = LLMCallStartedEvent( + call_id="call-1", + stop_sequences=_RepeatedScalar(["\nObservation:"]), + ) + assert event.stop_sequences == ["\nObservation:"] + + def test_protobuf_listvalue_with_nested_values_coerces_to_textproto_strings(self): + # Mirrors google.protobuf.struct_pb2.ListValue: iterable yielding + # `Value` messages whose str() is "string_value: \"...\"". The + # coercion will str() each element, which is still wrong-shaped but + # at least lands as a real list[str] for the OTel attribute instead + # of a single textproto-blob string. Documents observed behaviour; + # the upstream fix is to pass list[str] to LLM.stop, not ListValue. + class _PbValue: + def __init__(self, string_value: str) -> None: + self.string_value = string_value + + def __str__(self) -> str: + return f'string_value: "{self.string_value}"' + + class _PbListValue: + def __init__(self, values: list[_PbValue]) -> None: + self.values = values + + def __iter__(self): + return iter(self.values) + + event = LLMCallStartedEvent( + call_id="call-1", + stop_sequences=_PbListValue([_PbValue("\\nObservation:")]), + ) + assert event.stop_sequences == ['string_value: "\\nObservation:"'] + + @pytest.mark.parametrize("bad_input", [123, 12.5, object()]) + def test_non_iterable_falls_back_to_none(self, bad_input: Any) -> None: + event = LLMCallStartedEvent(call_id="call-1", stop_sequences=bad_input) + assert event.stop_sequences is None + + def test_none_stays_none(self): + event = LLMCallStartedEvent(call_id="call-1", stop_sequences=None) + assert event.stop_sequences is None + + +class TestEmitCallStartedEventIntrospectsSamplingParams: + def test_reads_sampling_params_off_self(self, mock_emit): + llm = _StubLLM(model="test-model", temperature=0.4) + llm.top_p = 0.8 + llm.max_tokens = 256 + llm.stream = False + llm.seed = 7 + llm.frequency_penalty = 0.5 + llm.presence_penalty = 0.6 + llm.n = 2 + llm.stop = ["STOP"] + + llm._emit_call_started_event(messages="hi") + + event = mock_emit.call_args[1]["event"] + assert isinstance(event, LLMCallStartedEvent) + assert event.temperature == 0.4 + assert event.top_p == 0.8 + assert event.max_tokens == 256 + assert event.stream is False + assert event.seed == 7 + assert event.stop_sequences == ["STOP"] + assert event.frequency_penalty == 0.5 + assert event.presence_penalty == 0.6 + assert event.n == 2 + + def test_explicit_kwargs_override_introspection(self, mock_emit): + llm = _StubLLM(model="test-model", temperature=0.4) + + llm._emit_call_started_event(messages="hi", temperature=0.9) + + event = mock_emit.call_args[1]["event"] + assert event.temperature == 0.9 + + +class TestBaseLLMSamplingParamFields: + # Regression: PR #5945 review feedback. Sampling params are declared as + # typed fields on BaseLLM so ``_emit_call_started_event`` reads them via + # plain attribute access instead of getattr/hasattr fallbacks. Kwargs + # like ``n=1`` bind directly to the typed field via Pydantic; there is + # no promotion from ``additional_params``. + def test_sampling_kwargs_bind_to_typed_fields(self, mock_emit): + from crewai.llms.providers.openai.completion import OpenAICompletion + + llm = LLM(model="gpt-4", n=1, temperature=0.5, seed=42) + + assert isinstance(llm, OpenAICompletion) + assert llm.n == 1 + assert llm.temperature == 0.5 + assert llm.seed == 42 + assert "n" not in llm.additional_params + assert "temperature" not in llm.additional_params + assert "seed" not in llm.additional_params + + llm._emit_call_started_event(messages="hi") + + event = mock_emit.call_args[1]["event"] + assert isinstance(event, LLMCallStartedEvent) + assert event.n == 1 + assert event.temperature == 0.5 + assert event.seed == 42 + + def test_additional_params_are_not_promoted_to_typed_fields(self, mock_emit): + # Callers who pass sampling params through ``additional_params`` + # opt out of typed-field semantics. We intentionally do NOT promote + # those values back into ``self.n`` / ``self.temperature``, so the + # emitter sees ``None`` for those attributes. If a caller wants the + # value surfaced in telemetry, they pass it as a kwarg. + llm = LLM( + model="gpt-4", + additional_params={"n": 1, "temperature": 0.5, "seed": 42}, + ) + + assert llm.n is None + assert llm.temperature is None + assert llm.seed is None + assert llm.additional_params == {"n": 1, "temperature": 0.5, "seed": 42} + + llm._emit_call_started_event(messages="hi") + + event = mock_emit.call_args[1]["event"] + assert isinstance(event, LLMCallStartedEvent) + assert event.n is None + assert event.temperature is None + assert event.seed is None + + def test_emit_uses_call_scoped_stop_override(self, mock_emit): + from crewai.llms.base_llm import call_stop_override + + llm = _StubLLM(model="test-model", stop=["A"]) + + with call_stop_override(llm, ["X"]): + llm._emit_call_started_event(messages="hi") + + event = mock_emit.call_args[1]["event"] + assert isinstance(event, LLMCallStartedEvent) + assert event.stop_sequences == ["X"] + # Instance-level stop is never mutated by the override. + assert llm.stop == ["A"] + + +class TestEffectiveMaxTokensTelemetry: + def test_base_defaults_to_max_tokens(self, mock_emit): + llm = _StubLLM(model="test-model", max_tokens=256) + + llm._emit_call_started_event(messages="hi") + + event = mock_emit.call_args[1]["event"] + assert event.max_tokens == 256 + + def test_openai_surfaces_max_completion_tokens(self, mock_emit): + from crewai.llms.providers.openai.completion import OpenAICompletion + + llm = LLM(model="gpt-4o", max_completion_tokens=512) + assert isinstance(llm, OpenAICompletion) + assert llm.max_tokens is None + + llm._emit_call_started_event(messages="hi") + + event = mock_emit.call_args[1]["event"] + assert event.max_tokens == 512 + + def test_explicit_max_tokens_takes_precedence(self, mock_emit): + llm = LLM(model="gpt-4o", max_tokens=128, max_completion_tokens=512) + + llm._emit_call_started_event(messages="hi") + + event = mock_emit.call_args[1]["event"] + assert event.max_tokens == 128 + + +class TestStreamingDictChunkResponseIdPropagation: + # Regression: PR #5945 coderabbitai feedback. The streaming loop only + # extracted ``chunk.id`` for ``ModelResponseBase`` instances; dict-shaped + # chunks (LiteLLM emits these in some configs) silently dropped the id + # and ``LLMStreamChunkEvent.response_id`` came through as ``None``. + def _dict_chunks(self) -> list[dict[str, Any]]: + return [ + { + "id": "test-chunk-id", + "choices": [{"delta": {"content": "hi"}, "finish_reason": None}], + }, + { + "id": "test-chunk-id", + "choices": [{"delta": {"content": " there"}, "finish_reason": "stop"}], + }, + ] + + def _stream_event_response_ids(self, mock_emit) -> list[str | None]: + return [ + call.kwargs["event"].response_id + for call in mock_emit.call_args_list + if isinstance(call.kwargs.get("event"), LLMStreamChunkEvent) + ] + + def test_sync_dict_chunk_id_propagates_to_stream_event(self, mock_emit): + llm = LLM(model="gpt-4o-mini", is_litellm=True, stream=True) + + with patch( + "crewai.llm.litellm.completion", + return_value=iter(self._dict_chunks()), + ): + llm.call("anything") + + ids = self._stream_event_response_ids(mock_emit) + assert ids, "expected at least one LLMStreamChunkEvent" + assert all(rid == "test-chunk-id" for rid in ids), ids + + @pytest.mark.asyncio + async def test_async_dict_chunk_id_propagates_to_stream_event(self, mock_emit): + llm = LLM(model="gpt-4o-mini", is_litellm=True, stream=True) + + async def _aiter(): + for chunk in self._dict_chunks(): + yield chunk + + async def _acompletion(*_args, **_kwargs): + return _aiter() + + with patch("crewai.llm.litellm.acompletion", side_effect=_acompletion): + await llm.acall("anything") + + ids = self._stream_event_response_ids(mock_emit) + assert ids, "expected at least one LLMStreamChunkEvent" + assert all(rid == "test-chunk-id" for rid in ids), ids + + +class TestEmitCallCompletedEventPassesFinishReasonAndResponseId: + def test_passes_through_to_event(self, mock_emit): + llm = _StubLLM(model="test-model") + + llm._emit_call_completed_event( + response="hi", + call_type=LLMCallType.LLM_CALL, + finish_reason="stop", + response_id="resp_123", + ) + + event = mock_emit.call_args[1]["event"] + assert isinstance(event, LLMCallCompletedEvent) + assert event.finish_reason == "stop" + assert event.response_id == "resp_123" + + def test_omitted_defaults_to_none(self, mock_emit): + llm = _StubLLM(model="test-model") + + llm._emit_call_completed_event( + response="hi", + call_type=LLMCallType.LLM_CALL, + ) + + event = mock_emit.call_args[1]["event"] + assert event.finish_reason is None + assert event.response_id is None + + +class TestLLMExtractFinishReasonAndResponseId: + def test_non_streaming_litellm_shape(self): + response = SimpleNamespace( + id="chatcmpl-abc", + choices=[SimpleNamespace(finish_reason="stop", message=SimpleNamespace())], + ) + + finish_reason, response_id = LLM._extract_finish_reason_and_response_id( + response + ) + + assert finish_reason == "stop" + assert response_id == "chatcmpl-abc" + + def test_streaming_litellm_chunk_shape(self): + last_chunk = SimpleNamespace( + id="chatcmpl-stream-xyz", + choices=[SimpleNamespace(finish_reason="tool_calls", delta=SimpleNamespace())], + ) + + finish_reason, response_id = LLM._extract_finish_reason_and_response_id( + last_chunk + ) + + assert finish_reason == "tool_calls" + assert response_id == "chatcmpl-stream-xyz" + + def test_dict_shape(self): + chunk = { + "id": "chatcmpl-dict", + "choices": [{"finish_reason": "length", "delta": {}}], + } + + finish_reason, response_id = LLM._extract_finish_reason_and_response_id(chunk) + + assert finish_reason == "length" + assert response_id == "chatcmpl-dict" + + def test_missing_fields_return_none(self): + finish_reason, response_id = LLM._extract_finish_reason_and_response_id( + SimpleNamespace() + ) + + assert finish_reason is None + assert response_id is None + + def test_non_string_values_coerced_to_none(self): + response = SimpleNamespace( + id=12345, + choices=[SimpleNamespace(finish_reason=MagicMock(), delta=SimpleNamespace())], + ) + + finish_reason, response_id = LLM._extract_finish_reason_and_response_id( + response + ) + + assert finish_reason is None + assert response_id is None + + def test_never_raises_on_unexpected_input(self): + assert LLM._extract_finish_reason_and_response_id(None) == (None, None) + assert LLM._extract_finish_reason_and_response_id(42) == (None, None) + assert LLM._extract_finish_reason_and_response_id("string") == (None, None) + + +class TestExtractChoicesFinishReasonAndIdHelper: + # The shared extractor is consumed by LLM (LiteLLM), OpenAI Chat, and Azure. + # TestLLMExtractFinishReasonAndResponseId exercises the choices-shape paths + # transitively; these tests cover the direct-call surface and the + # import contract. + @pytest.mark.parametrize( + "response, expected", + [ + ( + SimpleNamespace( + id="resp-1", choices=[SimpleNamespace(finish_reason="stop")] + ), + ("stop", "resp-1"), + ), + ( + {"id": "resp-2", "choices": [{"finish_reason": "length"}]}, + ("length", "resp-2"), + ), + ( + SimpleNamespace( + id="resp-3", choices=[{"finish_reason": "tool_calls"}] + ), + ("tool_calls", "resp-3"), + ), + ( + { + "id": "resp-4", + "choices": [SimpleNamespace(finish_reason="content_filter")], + }, + ("content_filter", "resp-4"), + ), + ], + ) + def test_extracts_choices_shape( + self, response: Any, expected: tuple[str | None, str | None] + ) -> None: + assert extract_choices_finish_reason_and_id(response) == expected + + @pytest.mark.parametrize( + "bad_input", + [ + None, + 42, + "string", + {}, + SimpleNamespace(), + SimpleNamespace(choices=[]), + SimpleNamespace(choices=[SimpleNamespace()]), + {"id": 12345, "choices": [{"finish_reason": MagicMock()}]}, + ], + ) + def test_never_raises_returns_nones_or_coerces(self, bad_input: Any) -> None: + finish_reason, response_id = extract_choices_finish_reason_and_id(bad_input) + assert finish_reason is None or isinstance(finish_reason, str) + assert response_id is None or isinstance(response_id, str) diff --git a/lib/crewai/tests/llms/google/test_google.py b/lib/crewai/tests/llms/google/test_google.py index 3bcdb0951..0213eb525 100644 --- a/lib/crewai/tests/llms/google/test_google.py +++ b/lib/crewai/tests/llms/google/test_google.py @@ -122,6 +122,20 @@ def test_gemini_completion_initialization_parameters(): assert llm.top_k == 40 +def test_gemini_started_event_surfaces_max_output_tokens(): + from crewai.events.event_bus import CrewAIEventsBus + from crewai.events.types.llm_events import LLMCallStartedEvent + + llm = LLM(model="google/gemini-2.0-flash-001", max_output_tokens=2000, api_key="test-key") + + with patch.object(CrewAIEventsBus, "emit") as mock_emit: + llm._emit_call_started_event(messages="hi") + + event = mock_emit.call_args[1]["event"] + assert isinstance(event, LLMCallStartedEvent) + assert event.max_tokens == 2000 + + def test_gemini_specific_parameters(): """ Test Gemini-specific parameters like stop_sequences, streaming, and safety settings diff --git a/lib/crewai/tests/test_llm_streaming_finish_reason.py b/lib/crewai/tests/test_llm_streaming_finish_reason.py new file mode 100644 index 000000000..ff8a94d4e --- /dev/null +++ b/lib/crewai/tests/test_llm_streaming_finish_reason.py @@ -0,0 +1,96 @@ +"""Regression: LiteLLM emits a final usage-only chunk (choices=[]) when +``stream_options.include_usage`` is set. The old post-loop +``_extract_finish_reason_and_response_id(last_chunk)`` then silently returned +(None, None). These tests pin that we capture finish_reason/response_id +incrementally during the stream loop instead. +""" +from __future__ import annotations + +from typing import Any +from unittest.mock import patch + +import pytest + +from crewai.events.event_bus import CrewAIEventsBus +from crewai.events.types.llm_events import LLMCallCompletedEvent +from crewai.llm import LLM + + +@pytest.fixture +def mock_emit(): + with patch.object(CrewAIEventsBus, "emit") as mock: + yield mock + + +def _completed_event(mock_emit) -> LLMCallCompletedEvent: + matches = [ + call.kwargs["event"] + for call in mock_emit.call_args_list + if isinstance(call.kwargs.get("event"), LLMCallCompletedEvent) + ] + assert matches, "expected an LLMCallCompletedEvent to be emitted" + assert len(matches) == 1, f"expected one completed event, got {len(matches)}" + return matches[0] + + +def _chunks_with_usage_tail() -> list[dict[str, Any]]: + """Three-chunk stream mirroring LiteLLM's include_usage behavior: + two content chunks where the second carries finish_reason="stop", + then a final usage-only chunk with choices=[].""" + return [ + { + "id": "chatcmpl-stream-1", + "choices": [ + {"delta": {"content": "hi"}, "finish_reason": None} + ], + }, + { + "id": "chatcmpl-stream-1", + "choices": [ + {"delta": {"content": " there"}, "finish_reason": "stop"} + ], + }, + { + "id": "chatcmpl-stream-1", + "choices": [], + "usage": { + "prompt_tokens": 1, + "completion_tokens": 2, + "total_tokens": 3, + }, + }, + ] + + +def test_sync_stream_emits_finish_reason_and_response_id_from_loop(mock_emit): + llm = LLM(model="gpt-4o-mini", is_litellm=True, stream=True) + + with patch("crewai.llm.litellm.completion", return_value=iter(_chunks_with_usage_tail())): + result = llm.call("anything") + + assert result == "hi there" + + event = _completed_event(mock_emit) + assert event.finish_reason == "stop" + assert event.response_id == "chatcmpl-stream-1" + + +@pytest.mark.asyncio +async def test_async_stream_emits_finish_reason_and_response_id_from_loop(mock_emit): + llm = LLM(model="gpt-4o-mini", is_litellm=True, stream=True) + + async def _aiter(): + for chunk in _chunks_with_usage_tail(): + yield chunk + + async def _acompletion(*_args, **_kwargs): + return _aiter() + + with patch("crewai.llm.litellm.acompletion", side_effect=_acompletion): + result = await llm.acall("anything") + + assert result == "hi there" + + event = _completed_event(mock_emit) + assert event.finish_reason == "stop" + assert event.response_id == "chatcmpl-stream-1" From 3723f0db768aa60d27bd5e94e21d5e0a77e6eadb Mon Sep 17 00:00:00 2001 From: Lorenze Jay <63378463+lorenzejay@users.noreply.github.com> Date: Fri, 5 Jun 2026 11:04:28 -0700 Subject: [PATCH 15/18] Update conversational flow docs to use handle_turn (#6053) --- docs/ar/guides/flows/conversational-flows.mdx | 82 +++---- docs/en/guides/flows/conversational-flows.mdx | 219 +++++++++--------- docs/ko/guides/flows/conversational-flows.mdx | 83 +++---- .../guides/flows/conversational-flows.mdx | 83 +++---- 4 files changed, 220 insertions(+), 247 deletions(-) diff --git a/docs/ar/guides/flows/conversational-flows.mdx b/docs/ar/guides/flows/conversational-flows.mdx index 9d397d73e..371de79be 100644 --- a/docs/ar/guides/flows/conversational-flows.mdx +++ b/docs/ar/guides/flows/conversational-flows.mdx @@ -11,95 +11,83 @@ mode: "wide" | المفهوم | التنفيذ | |---------|---------| -| معرّف الجلسة | `kickoff(session_id=...)` → `inputs["id"]` → `state.id` | -| سطر المستخدم | `kickoff(user_message=...)` يُضاف إلى `state.messages` قبل تشغيل الرسم | -| اكتمال الجولة | `FlowFinished` لهذا **التشغيل** فقط؛ تستمر المحادثة في `kickoff` التالي | -| تتبع الجلسة | `ConversationalConfig(defer_trace_finalization=True)` + `finalize_session_traces()` | +| معرّف الجلسة | `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.kickoff(user_message=..., session_id=...)`** أو **`flow.handle_turn(...)`** لكل رسالة مستخدم من REST أو WebSocket أو الاختبارات أو الواجهات المخصصة. استخدم **`flow.chat()`** عندما تريد حلقة دردشة محلية في الطرفية لـ `Flow` محادثي. +استخدم **`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 | الاستخدام | |-----|-----------| -| `kickoff(user_message=..., session_id=...)` | كل رسالة مستخدم | | `handle_turn(message, session_id=...)` | غلاف مريح لجولة واحدة في `Flow` محادثي | | `chat()` | REPL محلي في الطرفية لـ `Flow` محادثي | -| `kickoff_async(...)` | نفس المعاملات؛ دخول async أصلي | +| `kickoff(inputs={...})` | تشغيل متقدم للـ flow بدون معالجة جولة محادثية | | `ask()` | مطالبة حاجزة **داخل** خطوة واحدة | | `@human_feedback` | الموافقة/الرفض على **مخرجات خطوة** — وليس السطر التالي | -| `ChatSession.handle_turn(...)` | طبقة نقل فوق `kickoff` | +| `ChatSession.handle_turn(...)` | طبقة نقل فوق `handle_turn` | ## بداية سريعة ```python from uuid import uuid4 -from crewai.flow import ( - ChatState, - ConversationalConfig, - Flow, - listen, - or_, - persist, - router, - start, +from crewai import Flow +from crewai.flow import listen +from crewai.experimental.conversational import ( + ConversationConfig, + ConversationState, ) -from crewai.flow.persistence import SQLiteFlowPersistence -class SupportFlow(Flow[ChatState]): - conversational_config = ConversationalConfig( - default_intents=["order", "help", "goodbye"], - intent_llm="gpt-4o-mini", - defer_trace_finalization=True, - ) +@ConversationConfig(defer_trace_finalization=True) +class SupportFlow(Flow[ConversationState]): + conversational = True - @start() - def bootstrap(self): - if not self.state.session_ready: - self.state.session_ready = True - return "ready" - - @router(bootstrap) - def route(self): - return self.state.last_intent or "help" + 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_message("assistant", reply) + self.append_assistant_message(reply) return reply @listen("help") def handle_help(self): reply = "كيف يمكنني المساعدة؟" - self.append_message("assistant", reply) + self.append_assistant_message(reply) return reply @listen("goodbye") def handle_goodbye(self): reply = "وداعاً!" - self.append_message("assistant", reply) + self.append_assistant_message(reply) return reply - @persist(SQLiteFlowPersistence("support.db")) - @listen(or_(handle_order, handle_help, handle_goodbye)) - def finalize(self): - return self.state.model_dump() - session_id = str(uuid4()) flow = SupportFlow() -flow.kickoff(user_message="أين طلبي؟", session_id=session_id) -flow.kickoff(user_message="وماذا عن الإرجاع؟", session_id=session_id) -flow.finalize_session_traces() +try: + flow.handle_turn("أين طلبي؟", session_id=session_id) + flow.handle_turn("وماذا عن الإرجاع؟", session_id=session_id) +finally: + flow.finalize_session_traces() ``` ## دورة حياة الجولة -كل `kickoff` مع `user_message` يشغّل: +كل `handle_turn` يشغّل: 1. **`_configure_conversational_kickoff`** — دمج `session_id` / `user_message` في `inputs` وتطبيق `ConversationalConfig`. 2. **استعادة الحالة** — عند وجود `inputs["id"]` و`@persist`. @@ -108,7 +96,7 @@ flow.finalize_session_traces() 5. **تنفيذ الرسم** — `@start` → `@router` → معالجات `@listen`. 6. **نهاية التشغيل** — يُتخطى `flow_finished` والتتبع لكل جولة عند التأجيل؛ `Agent.kickoff()` / crews لا تغلق دفعة الأب. -استدعِ **`append_message("assistant", reply)`** في المعالجات. سطر المستخدم محفوظ عند kickoff — لا تُضفه مرة أخرى. +استدعِ **`append_assistant_message(reply)`** في المعالجات. سطر المستخدم محفوظ عبر `handle_turn` — لا تُضفه مرة أخرى. ## `ConversationalConfig` (افتراضيات على مستوى الصنف) @@ -382,7 +370,7 @@ Routes: 4. يخزّن الموجّه قراره في `state.last_intent` (يكون مرئياً لسياق التوجيه في الجولة التالية). 5. إذا أعاد معالجك سلسلة نصية ولم يستدعِ `append_assistant_message`، فإن `handle_turn` يُلحقها نيابةً عنك. -يمكنك أيضاً استدعاء `flow.kickoff(user_message=..., session_id=...)` مباشرةً — نفس منطق الإعادة والتشغيل يعمل. `handle_turn` هو الغلاف المريح. +استدعِ `handle_turn()` لرسائل الدردشة. استدعاء `kickoff(inputs={"id": ...})` مباشرةً يشغل الرسم بدون غلاف الجولة المحادثية. ### `chat()` للـ REPL المحلي diff --git a/docs/en/guides/flows/conversational-flows.mdx b/docs/en/guides/flows/conversational-flows.mdx index 00084cae7..c231fe010 100644 --- a/docs/en/guides/flows/conversational-flows.mdx +++ b/docs/en/guides/flows/conversational-flows.mdx @@ -1,132 +1,121 @@ --- title: Conversational Flows -description: Build multi-turn chat apps with kickoff per turn, message history, intent routing, tracing, and WebSocket bridges. +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 classification, deferred tracing, UI bridges, and a local `flow.chat()` REPL for conversational flows. +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 | `kickoff(session_id=...)` → `inputs["id"]` → `state.id` | -| User line | `kickoff(user_message=...)` appends to `state.messages` before the graph runs | -| Turn complete | `FlowFinished` for **this run** only; chat continues on the next `kickoff` | -| Full-session trace | `ConversationalConfig(defer_trace_finalization=True)` + `finalize_session_traces()` | +| 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.kickoff(user_message=..., session_id=...)`** or **`flow.handle_turn(...)`** 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`. +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 | |-----|---------| -| `kickoff(user_message=..., session_id=...)` | Each user message | | `handle_turn(message, session_id=...)` | Ergonomic one-turn wrapper for conversational `Flow` | | `chat()` | Local terminal REPL for conversational `Flow` | -| `kickoff_async(...)` | Same parameters; native async entry | +| `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 `kickoff` (SSE / WebSocket) | +| `ChatSession.handle_turn(...)` | Transport layer over `handle_turn` (SSE / WebSocket) | ## Quick start ```python from uuid import uuid4 -from crewai.flow import ( - ChatState, - ConversationalConfig, - Flow, - listen, - or_, - persist, - router, - start, +from crewai import Flow +from crewai.flow import listen +from crewai.experimental.conversational import ( + ConversationConfig, + ConversationState, ) -from crewai.flow.persistence import SQLiteFlowPersistence -class SupportFlow(Flow[ChatState]): - conversational_config = ConversationalConfig( - default_intents=["order", "help", "goodbye"], - intent_llm="gpt-4o-mini", - defer_trace_finalization=True, - ) +@ConversationConfig(defer_trace_finalization=True) +class SupportFlow(Flow[ConversationState]): + conversational = True - @start() - def bootstrap(self): - if not self.state.session_ready: - self.state.session_ready = True - return "ready" - - @router(bootstrap) - def route(self): - # last_intent set in prepare_conversational_turn when default_intents is set - return self.state.last_intent or "help" + 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_message("assistant", reply) + self.append_assistant_message(reply) return reply @listen("help") def handle_help(self): reply = "How can I help?" - self.append_message("assistant", reply) + self.append_assistant_message(reply) return reply @listen("goodbye") def handle_goodbye(self): reply = "Goodbye!" - self.append_message("assistant", reply) + self.append_assistant_message(reply) return reply - @persist(SQLiteFlowPersistence("support.db")) - @listen(or_(handle_order, handle_help, handle_goodbye)) - def finalize(self): - return self.state.model_dump() - session_id = str(uuid4()) flow = SupportFlow() -flow.kickoff(user_message="Where is my order?", session_id=session_id) -flow.kickoff(user_message="What about returns?", session_id=session_id) -flow.finalize_session_traces() # one trace link for the whole chat +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 `kickoff` with `user_message` runs this pipeline: +Each `handle_turn` runs this pipeline: -1. **`_configure_conversational_kickoff`** — merges `session_id` / `user_message` into `inputs`, applies `ConversationalConfig`, enables deferred tracing when configured. +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. **`prepare_conversational_turn`** — appends the user message to `state.messages`, sets `last_user_message`, clears `last_intent`, optionally classifies when `intents` / `default_intents` + `intent_llm` are set. -5. **Graph execution** — `@start` → `@router` → `@listen` handlers. +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_message("assistant", reply)`** so the next turn’s `conversation_messages` includes assistant text. The user line is already stored at kickoff — do not append it again in handlers. +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. -## `ConversationalConfig` (class-level defaults) +## `ConversationConfig` (class-level defaults) -Set on your `Flow` subclass as `conversational_config: ClassVar[ConversationalConfig | None]`. +Decorate your conversational `Flow` subclass with `ConversationConfig`. | Field | Default | Purpose | |-------|---------|---------| -| `default_intents` | `None` | Outcome labels for automatic pre-kickoff classification | -| `intent_llm` | `None` | Model for classification (required when intents are used) | -| `interactive_prompt` | `"You: "` | Prompt for `kickoff(interactive=True)` | -| `interactive_timeout` | `None` | Per-line timeout in interactive mode | -| `exit_commands` | `exit`, `quit` | Words that end interactive mode | -| `defer_trace_finalization` | `True` | Keep one trace batch open across turns | +| `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 per kickoff with `intents=` and `intent_llm=` keyword arguments. +Override pre-classification per turn with `handle_turn(..., intents=..., intent_llm=...)`. -## `ChatState` (recommended persisted shape) +## 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 @@ -140,7 +129,7 @@ class MyChatState(ChatState): | Field | Role | |-------|------| -| `id` | Session UUID (same as `session_id` / `inputs["id"]`) | +| `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) | @@ -150,27 +139,26 @@ class MyChatState(ChatState): ## `Flow` conversational API -### `kickoff` / `kickoff_async` parameters +### `handle_turn` parameters | Parameter | Purpose | |-----------|---------| -| `user_message` | This turn’s text (or `{"role": "user", "content": "..."}`) | +| `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`) | -| `interactive` | CLI loop via `ask()` (local demos only) | -| `interactive_prompt` | Override prompt in interactive mode | -| `interactive_timeout` | Per-line `ask()` timeout | -| `exit_commands` | Words that end interactive mode | -| `inputs` | Additional state fields (merged with conversational keys) | -| `restore_from_state_id` | Fork hydration from another persisted flow | +| `**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_config` | Class-level `ConversationalConfig` defaults | -| `defer_trace_finalization` | Instance flag; set automatically from config on kickoff | +| `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)` | @@ -178,7 +166,8 @@ class MyChatState(ChatState): | Name | Description | |------|-------------| -| `append_message(role, content, **extra)` | Append to `state.messages` (roles: `user`, `assistant`, `system`, `tool`) | +| `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` | @@ -195,7 +184,7 @@ Importable for tests or custom orchestration: | `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=...)` | Turn hydration (usually called by kickoff) | +| `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` | @@ -203,21 +192,20 @@ Importable for tests or custom orchestration: ## Intent routing patterns -### A. Pre-classify via `ConversationalConfig` (simplest) +### A. Pre-classify via `ConversationConfig` (simplest) -Set `default_intents` and `intent_llm`. Each kickoff runs classification before your `@router`; read `self.state.last_intent` in `route()`. +Set `default_intents` and `intent_llm`. Each `handle_turn()` runs classification before routing; read `self.state.last_intent` in `route_turn()`. -### B. Classify inside `@router` (richer prompts) +### B. Classify inside `route_turn` (richer prompts) -Set `default_intents=None` so kickoff only appends the user message. In `route()`, call `classify_intent` with a custom prompt or descriptions: +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 -@router(bootstrap) -def route(self): +def route_turn(self, context): intent = self.classify_intent( - self._routing_prompt(self.state.last_user_message), + self._routing_prompt(self.state.current_user_message), ("GREETING", "ORDER", "RESEARCH", "GOODBYE"), - llm=self.conversational_config.intent_llm or "gpt-4o-mini", + llm="gpt-4o-mini", ) self.state.last_intent = intent return intent @@ -227,7 +215,7 @@ Use **`@listen("RESEARCH")`** (or similar) for steps that run `Agent.kickoff()` ## When the flow finishes but the user keeps chatting -`FlowFinished` means **this graph run** completed. The conversation continues with another `kickoff` and the same `session_id`. `@persist` restores `messages`, flags, and context. +`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. @@ -244,53 +232,53 @@ Do **not** use `@human_feedback` for follow-up chat lines unless a human must ap changelog for breaking updates. Open issues / feedback welcome. -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`, drives the router LLM, and keeps the trace batch open across turns. You write the **custom routes**; the framework owns the rest. +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 an LLM-driven router and per-route handlers without wiring the lifecycle yourself. Use `Flow[ChatState]` (the lower-level pattern above) when you need full control. +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 LLM, Flow +from crewai import 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(), # routes + descriptions auto-discovered from @listen handlers -) +@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 (built-in) + 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("Summarize the first result.") # routes back to converse + flow.handle_turn("Check the CrewAI docs.") # routes to CREWAI_DOCS finally: flow.finalize_session_traces() ``` @@ -323,7 +311,21 @@ Class decorator that attaches per-class chat defaults. ### `RouterConfig` and the auto-built route catalog ```python -RouterConfig( +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 @@ -347,6 +349,9 @@ The router prompt that gets sent to the LLM is built automatically. For each rou 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.""" @@ -385,7 +390,7 @@ You can override any of these by defining a same-named handler in your subclass. 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. -You can also call `flow.kickoff(user_message=..., session_id=...)` directly — the same reset/run logic fires. `handle_turn` is the ergonomic wrapper. +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 @@ -422,6 +427,12 @@ For web apps, background workers, tests, and custom transports, keep using `hand 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 @@ -443,7 +454,7 @@ Inside a `@listen(label)` handler, choose: ## Tracing across turns -With `defer_trace_finalization=True` (default in `ConversationalConfig`): +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()`. @@ -455,7 +466,7 @@ flow.chat(session_id=session_id) ``` `flow.chat()` calls `finalize_session_traces()` for you. When you own the loop -with `handle_turn()` or `kickoff(...)`, call `finalize_session_traces()` when +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. diff --git a/docs/ko/guides/flows/conversational-flows.mdx b/docs/ko/guides/flows/conversational-flows.mdx index 35c63cacc..3a18cd1da 100644 --- a/docs/ko/guides/flows/conversational-flows.mdx +++ b/docs/ko/guides/flows/conversational-flows.mdx @@ -11,96 +11,83 @@ mode: "wide" | 개념 | 구현 | |------|------| -| 세션 id | `kickoff(session_id=...)` → `inputs["id"]` → `state.id` | -| 사용자 입력 | `kickoff(user_message=...)`가 그래프 실행 전 `state.messages`에 추가 | -| 턴 완료 | `FlowFinished`는 **이번 실행**만 의미; 다음 `kickoff`로 대화 계속 | -| 세션 전체 트레이스 | `ConversationalConfig(defer_trace_finalization=True)` + `finalize_session_traces()` | +| 세션 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.kickoff(user_message=..., session_id=...)`** 또는 **`flow.handle_turn(...)`**를 사용하세요. 대화형 `Flow`를 로컬 터미널 채팅 루프로 실행하고 싶을 때는 **`flow.chat()`**을 사용하세요. +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 | 용도 | |-----|------| -| `kickoff(user_message=..., session_id=...)` | 각 사용자 메시지 | | `handle_turn(message, session_id=...)` | 대화형 `Flow`용 한 턴 편의 래퍼 | | `chat()` | 대화형 `Flow`용 로컬 터미널 REPL | -| `kickoff_async(...)` | 동일 파라미터; 네이티브 async 진입 | +| `kickoff(inputs={...})` | 대화형 턴 처리 없이 flow를 직접 실행 | | `ask()` | 한 스텝 **내부** 블로킹 프롬프트 (마법사, 확인) | | `@human_feedback` | **스텝 출력** 승인/거부 — 다음 채팅 줄이 아님 | -| `ChatSession.handle_turn(...)` | `kickoff` 위의 전송 계층 (SSE / WebSocket) | +| `ChatSession.handle_turn(...)` | `handle_turn` 위의 전송 계층 (SSE / WebSocket) | ## 빠른 시작 ```python from uuid import uuid4 -from crewai.flow import ( - ChatState, - ConversationalConfig, - Flow, - listen, - or_, - persist, - router, - start, +from crewai import Flow +from crewai.flow import listen +from crewai.experimental.conversational import ( + ConversationConfig, + ConversationState, ) -from crewai.flow.persistence import SQLiteFlowPersistence -class SupportFlow(Flow[ChatState]): - conversational_config = ConversationalConfig( - default_intents=["order", "help", "goodbye"], - intent_llm="gpt-4o-mini", - defer_trace_finalization=True, - ) +@ConversationConfig(defer_trace_finalization=True) +class SupportFlow(Flow[ConversationState]): + conversational = True - @start() - def bootstrap(self): - if not self.state.session_ready: - self.state.session_ready = True - return "ready" - - @router(bootstrap) - def route(self): - # default_intents 설정 시 prepare_conversational_turn에서 last_intent 설정 - return self.state.last_intent or "help" + 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_message("assistant", reply) + self.append_assistant_message(reply) return reply @listen("help") def handle_help(self): reply = "무엇을 도와드릴까요?" - self.append_message("assistant", reply) + self.append_assistant_message(reply) return reply @listen("goodbye") def handle_goodbye(self): reply = "안녕히 가세요!" - self.append_message("assistant", reply) + self.append_assistant_message(reply) return reply - @persist(SQLiteFlowPersistence("support.db")) - @listen(or_(handle_order, handle_help, handle_goodbye)) - def finalize(self): - return self.state.model_dump() - session_id = str(uuid4()) flow = SupportFlow() -flow.kickoff(user_message="주문 어디까지 왔나요?", session_id=session_id) -flow.kickoff(user_message="반품은 어떻게 하나요?", session_id=session_id) -flow.finalize_session_traces() # 전체 대화에 대한 단일 trace 링크 +try: + flow.handle_turn("주문 어디까지 왔나요?", session_id=session_id) + flow.handle_turn("반품은 어떻게 하나요?", session_id=session_id) +finally: + flow.finalize_session_traces() # 전체 대화에 대한 단일 trace 링크 ``` ## 턴 생명주기 -`user_message`가 있는 각 `kickoff`는 다음 파이프라인을 실행합니다: +각 `handle_turn`은 다음 파이프라인을 실행합니다: 1. **`_configure_conversational_kickoff`** — `session_id` / `user_message`를 `inputs`에 병합, `ConversationalConfig` 적용, 설정 시 지연 트레이싱 활성화. 2. **상태 복원** — `inputs["id"]`가 있고 `@persist`가 설정되면 최신 스냅샷 로드. @@ -109,7 +96,7 @@ flow.finalize_session_traces() # 전체 대화에 대한 단일 trace 링크 5. **그래프 실행** — `@start` → `@router` → `@listen` 핸들러. 6. **실행 종료** — 지연 활성화 시 턴별 `flow_finished` 및 trace 종료 **건너뜀**; 중첩 `Agent.kickoff()` / crew도 부모 batch를 닫지 않음. -핸들러는 **`append_message("assistant", reply)`**를 호출해 다음 턴의 `conversation_messages`에 어시스턴트 응답이 포함되게 하세요. 사용자 입력은 kickoff 시 이미 저장됩니다 — 핸들러에서 다시 추가하지 마세요. +핸들러는 **`append_assistant_message(reply)`**를 호출해 다음 턴의 `conversation_messages`에 어시스턴트 응답이 포함되게 하세요. 사용자 입력은 `handle_turn`이 이미 저장합니다 — 핸들러에서 다시 추가하지 마세요. ## `ConversationalConfig` (클래스 수준 기본값) @@ -384,7 +371,7 @@ Routes: 4. router는 결정을 `state.last_intent`에 저장합니다 (다음 턴의 router 컨텍스트에서 보입니다). 5. 핸들러가 문자열을 반환했지만 `append_assistant_message`를 직접 호출하지 않았다면, `handle_turn`이 대신 추가해 줍니다. -`flow.kickoff(user_message=..., session_id=...)`를 직접 호출해도 동일한 reset/run 로직이 동작합니다. `handle_turn`은 그 위에 얹은 편의 래퍼입니다. +채팅 메시지에는 `handle_turn()`을 호출하세요. `kickoff(inputs={"id": ...})`를 직접 호출하면 대화형 턴 래퍼 없이 flow 그래프가 실행됩니다. ### 로컬 REPL용 `chat()` diff --git a/docs/pt-BR/guides/flows/conversational-flows.mdx b/docs/pt-BR/guides/flows/conversational-flows.mdx index 905cdce3a..10ffdcbd6 100644 --- a/docs/pt-BR/guides/flows/conversational-flows.mdx +++ b/docs/pt-BR/guides/flows/conversational-flows.mdx @@ -11,96 +11,83 @@ Apps conversacionais tratam cada linha do usuário como uma **nova execução do | Conceito | Implementação | |---------|----------------| -| Id de sessão | `kickoff(session_id=...)` → `inputs["id"]` → `state.id` | -| Linha do usuário | `kickoff(user_message=...)` acrescenta em `state.messages` antes do grafo rodar | -| Fim do turno | `FlowFinished` só para **esta execução**; o chat segue no próximo `kickoff` | -| Trace da sessão | `ConversationalConfig(defer_trace_finalization=True)` + `finalize_session_traces()` | +| 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.kickoff(user_message=..., session_id=...)`** ou **`flow.handle_turn(...)`** 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. +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 | |-----|-----| -| `kickoff(user_message=..., session_id=...)` | Cada mensagem do usuário | | `handle_turn(message, session_id=...)` | Wrapper ergonômico de um turno para `Flow` conversacional | | `chat()` | REPL local no terminal para `Flow` conversacional | -| `kickoff_async(...)` | Mesmos parâmetros; entrada async nativa | +| `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 `kickoff` (SSE / WebSocket) | +| `ChatSession.handle_turn(...)` | Camada de transporte sobre `handle_turn` (SSE / WebSocket) | ## Início rápido ```python from uuid import uuid4 -from crewai.flow import ( - ChatState, - ConversationalConfig, - Flow, - listen, - or_, - persist, - router, - start, +from crewai import Flow +from crewai.flow import listen +from crewai.experimental.conversational import ( + ConversationConfig, + ConversationState, ) -from crewai.flow.persistence import SQLiteFlowPersistence -class SupportFlow(Flow[ChatState]): - conversational_config = ConversationalConfig( - default_intents=["order", "help", "goodbye"], - intent_llm="gpt-4o-mini", - defer_trace_finalization=True, - ) +@ConversationConfig(defer_trace_finalization=True) +class SupportFlow(Flow[ConversationState]): + conversational = True - @start() - def bootstrap(self): - if not self.state.session_ready: - self.state.session_ready = True - return "ready" - - @router(bootstrap) - def route(self): - # last_intent definido em prepare_conversational_turn quando default_intents está setado - return self.state.last_intent or "help" + 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_message("assistant", reply) + self.append_assistant_message(reply) return reply @listen("help") def handle_help(self): reply = "Como posso ajudar?" - self.append_message("assistant", reply) + self.append_assistant_message(reply) return reply @listen("goodbye") def handle_goodbye(self): reply = "Até logo!" - self.append_message("assistant", reply) + self.append_assistant_message(reply) return reply - @persist(SQLiteFlowPersistence("support.db")) - @listen(or_(handle_order, handle_help, handle_goodbye)) - def finalize(self): - return self.state.model_dump() - session_id = str(uuid4()) flow = SupportFlow() -flow.kickoff(user_message="Onde está meu pedido?", session_id=session_id) -flow.kickoff(user_message="E as devoluções?", session_id=session_id) -flow.finalize_session_traces() # um link de trace para o chat inteiro +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 `kickoff` com `user_message` executa este pipeline: +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. @@ -109,7 +96,7 @@ Cada `kickoff` com `user_message` executa este pipeline: 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_message("assistant", reply)`** para que o próximo turno inclua a resposta do assistente. A linha do usuário já é salva no kickoff — não acrescente de novo nos handlers. +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) @@ -385,7 +372,7 @@ Você pode sobrescrever qualquer uma definindo um handler com o mesmo nome na su 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ê. -Você também pode chamar `flow.kickoff(user_message=..., session_id=...)` diretamente — a mesma lógica de reset/run é acionada. `handle_turn` é o wrapper ergonômico. +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 From 8cd51fc67ea339f578384737187871a3012eba96 Mon Sep 17 00:00:00 2001 From: Lorenze Jay <63378463+lorenzejay@users.noreply.github.com> Date: Fri, 5 Jun 2026 14:10:19 -0700 Subject: [PATCH 16/18] Lorenze/imp/conversational flow traces (#6044) * feat: add conversation message and route selection events - Introduced `ConversationMessageAddedEvent` and `ConversationRouteSelectedEvent` to enhance conversational flow tracking. - Updated event listeners to emit these events during message handling and routing decisions. - Enhanced the `_ConversationalMixin` class to emit events for user and assistant messages, as well as selected routes. - Added tests to verify the correct emission of these events during conversational turns. * ensure flow started events only emiited once * refactor(tracing): rename trace event handler methods to action event handlers Updated the class to replace with for and events, improving clarity in event handling. Additionally, adjusted comments in the class to clarify the application of pending user messages in relation to state restoration and flow scope initialization. * fix(conversational_mixin): handle empty message index in route events Updated the message index handling in the class to return when there are no messages. Added tests to ensure that route events do not reference index zero when the transcript is empty, and verified the correct emission of conversation message events during flow handling. --- lib/crewai/src/crewai/events/__init__.py | 6 + lib/crewai/src/crewai/events/event_types.py | 4 + .../listeners/tracing/trace_listener.py | 14 +++ .../src/crewai/events/types/flow_events.py | 25 ++++ .../experimental/conversational_mixin.py | 77 +++++++++++- lib/crewai/src/crewai/flow/runtime.py | 91 ++++++++------ lib/crewai/tests/test_flow_conversation.py | 118 ++++++++++++++++++ 7 files changed, 296 insertions(+), 39 deletions(-) diff --git a/lib/crewai/src/crewai/events/__init__.py b/lib/crewai/src/crewai/events/__init__.py index 4d9d836c3..b026c451d 100644 --- a/lib/crewai/src/crewai/events/__init__.py +++ b/lib/crewai/src/crewai/events/__init__.py @@ -61,6 +61,8 @@ if TYPE_CHECKING: CrewTrainStartedEvent, ) from crewai.events.types.flow_events import ( + ConversationMessageAddedEvent, + ConversationRouteSelectedEvent, FlowCreatedEvent, FlowEvent, FlowFinishedEvent, @@ -176,6 +178,8 @@ _LAZY_EVENT_MAPPING: dict[str, str] = { "CrewTrainCompletedEvent": "crewai.events.types.crew_events", "CrewTrainFailedEvent": "crewai.events.types.crew_events", "CrewTrainStartedEvent": "crewai.events.types.crew_events", + "ConversationMessageAddedEvent": "crewai.events.types.flow_events", + "ConversationRouteSelectedEvent": "crewai.events.types.flow_events", "FlowCreatedEvent": "crewai.events.types.flow_events", "FlowEvent": "crewai.events.types.flow_events", "FlowFinishedEvent": "crewai.events.types.flow_events", @@ -291,6 +295,8 @@ __all__ = [ "CheckpointRestoreStartedEvent", "CheckpointStartedEvent", "CircularDependencyError", + "ConversationMessageAddedEvent", + "ConversationRouteSelectedEvent", "CrewKickoffCompletedEvent", "CrewKickoffFailedEvent", "CrewKickoffStartedEvent", diff --git a/lib/crewai/src/crewai/events/event_types.py b/lib/crewai/src/crewai/events/event_types.py index f336ce75a..dcf31cb03 100644 --- a/lib/crewai/src/crewai/events/event_types.py +++ b/lib/crewai/src/crewai/events/event_types.py @@ -53,6 +53,8 @@ from crewai.events.types.crew_events import ( CrewTrainStartedEvent, ) from crewai.events.types.flow_events import ( + ConversationMessageAddedEvent, + ConversationRouteSelectedEvent, FlowFinishedEvent, FlowStartedEvent, MethodExecutionFailedEvent, @@ -154,6 +156,8 @@ EventTypes = ( | TaskStartedEvent | TaskCompletedEvent | TaskFailedEvent + | ConversationMessageAddedEvent + | ConversationRouteSelectedEvent | FlowStartedEvent | FlowFinishedEvent | MethodExecutionStartedEvent diff --git a/lib/crewai/src/crewai/events/listeners/tracing/trace_listener.py b/lib/crewai/src/crewai/events/listeners/tracing/trace_listener.py index c7901fed3..c85e6202a 100644 --- a/lib/crewai/src/crewai/events/listeners/tracing/trace_listener.py +++ b/lib/crewai/src/crewai/events/listeners/tracing/trace_listener.py @@ -62,6 +62,8 @@ from crewai.events.types.crew_events import ( CrewKickoffStartedEvent, ) from crewai.events.types.flow_events import ( + ConversationMessageAddedEvent, + ConversationRouteSelectedEvent, FlowCreatedEvent, FlowFinishedEvent, FlowPlotEvent, @@ -255,6 +257,18 @@ class TraceCollectionListener(BaseEventListener): def on_method_failed(source: Any, event: MethodExecutionFailedEvent) -> None: self._handle_trace_event("method_execution_failed", source, event) + @event_bus.on(ConversationMessageAddedEvent) + def on_conversation_message_added( + source: Any, event: ConversationMessageAddedEvent + ) -> None: + self._handle_action_event("conversation_message_added", source, event) + + @event_bus.on(ConversationRouteSelectedEvent) + def on_conversation_route_selected( + source: Any, event: ConversationRouteSelectedEvent + ) -> None: + self._handle_action_event("conversation_route_selected", source, event) + @event_bus.on(FlowFinishedEvent) def on_flow_finished(source: Any, event: FlowFinishedEvent) -> None: self._handle_trace_event("flow_finished", source, event) diff --git a/lib/crewai/src/crewai/events/types/flow_events.py b/lib/crewai/src/crewai/events/types/flow_events.py index c2c1e2912..5ff4d4038 100644 --- a/lib/crewai/src/crewai/events/types/flow_events.py +++ b/lib/crewai/src/crewai/events/types/flow_events.py @@ -166,6 +166,31 @@ class FlowInputReceivedEvent(FlowEvent): type: Literal["flow_input_received"] = "flow_input_received" +class ConversationMessageAddedEvent(FlowEvent): + """Event emitted when a conversational Flow records a message. + + This gives trace consumers a first-class transcript signal instead of + requiring them to inspect the full method state payload. + """ + + session_id: str + role: Literal["user", "assistant", "system", "tool"] + content: Any + message_index: int + type: Literal["conversation_message_added"] = "conversation_message_added" + + +class ConversationRouteSelectedEvent(FlowEvent): + """Event emitted when a conversational Flow selects a route for a turn.""" + + session_id: str + route: str + user_message: str | None = None + message_index: int | None = None + previous_intent: str | None = None + type: Literal["conversation_route_selected"] = "conversation_route_selected" + + class HumanFeedbackRequestedEvent(FlowEvent): """Event emitted when human feedback is requested. diff --git a/lib/crewai/src/crewai/experimental/conversational_mixin.py b/lib/crewai/src/crewai/experimental/conversational_mixin.py index 3d3a8d05d..3801d0570 100644 --- a/lib/crewai/src/crewai/experimental/conversational_mixin.py +++ b/lib/crewai/src/crewai/experimental/conversational_mixin.py @@ -24,6 +24,11 @@ from typing import TYPE_CHECKING, Any, ClassVar, Literal, cast from pydantic import BaseModel, Field, create_model +from crewai.events.event_bus import crewai_event_bus +from crewai.events.types.flow_events import ( + ConversationMessageAddedEvent, + ConversationRouteSelectedEvent, +) from crewai.experimental.conversational import ( AgentMessage, ConversationConfig, @@ -122,19 +127,36 @@ class _ConversationalMixin: """Route the current turn to a listener label.""" state = cast(ConversationState, self.state) context = self.build_router_context() + previous_intent = state.last_intent configured_route = self.route_turn(context) if configured_route: state.last_intent = configured_route + self._emit_conversation_route_selected( + configured_route, + previous_intent=previous_intent, + ) return configured_route if state.last_intent: + self._emit_conversation_route_selected( + state.last_intent, + previous_intent=previous_intent, + ) return state.last_intent if self.can_answer_from_history(context): state.last_intent = "answer_from_history" + self._emit_conversation_route_selected( + "answer_from_history", + previous_intent=previous_intent, + ) return "answer_from_history" state.last_intent = "converse" + self._emit_conversation_route_selected( + "converse", + previous_intent=previous_intent, + ) return "converse" @listen("converse") @@ -406,13 +428,61 @@ class _ConversationalMixin: metadata: dict[str, Any] | None = None, ) -> None: """Append a final user-visible assistant message.""" - cast(ConversationState, self.state).messages.append( + state = cast(ConversationState, self.state) + state.messages.append( ConversationMessage( role="assistant", content=content, metadata=metadata or {}, ) ) + self._emit_conversation_message_added( + role="assistant", + content=content, + message_index=len(state.messages) - 1, + ) + + def _emit_conversation_message_added( + self, + *, + role: Literal["user", "assistant", "system", "tool"], + content: Any, + message_index: int, + ) -> None: + """Emit a compact transcript event for conversational trace views.""" + state = cast(ConversationState, self.state) + crewai_event_bus.emit( + self, + ConversationMessageAddedEvent( + type="conversation_message_added", + flow_name=self.name or self.__class__.__name__, + session_id=state.id, + role=role, + content=content, + message_index=message_index, + ), + ) + + def _emit_conversation_route_selected( + self, + route: str, + *, + previous_intent: str | None = None, + ) -> None: + """Emit the conversational routing decision for the current turn.""" + state = cast(ConversationState, self.state) + crewai_event_bus.emit( + self, + ConversationRouteSelectedEvent( + type="conversation_route_selected", + flow_name=self.name or self.__class__.__name__, + session_id=state.id, + route=route, + user_message=state.current_user_message, + message_index=(len(state.messages) - 1) if state.messages else None, + previous_intent=previous_intent, + ), + ) def append_message( self, @@ -447,6 +517,11 @@ class _ConversationalMixin: if self.conversational: state = cast(ConversationState, self.state) state.messages.append(ConversationMessage(role="user", content=text)) + self._emit_conversation_message_added( + role="user", + content=text, + message_index=len(state.messages) - 1, + ) state.current_user_message = text state.last_user_message = text if outcomes and llm is not None: diff --git a/lib/crewai/src/crewai/flow/runtime.py b/lib/crewai/src/crewai/flow/runtime.py index 80b7a84da..7dfefd3d8 100644 --- a/lib/crewai/src/crewai/flow/runtime.py +++ b/lib/crewai/src/crewai/flow/runtime.py @@ -912,6 +912,7 @@ class Flow(_ConversationalMixin, BaseModel, Generic[T], metaclass=FlowMeta): _pending_user_message: str | dict[str, Any] | None = PrivateAttr(default=None) _pending_intents: Sequence[str] | None = PrivateAttr(default=None) _pending_intent_llm: str | "BaseLLM" | None = PrivateAttr(default=None) + _deferred_flow_started_event_id: str | None = PrivateAttr(default=None) def __class_getitem__(cls: type[Flow[T]], item: type[T]) -> type[Flow[T]]: # type: ignore[override] class _FlowGeneric(cls): # type: ignore[valid-type,misc] @@ -2201,8 +2202,59 @@ class Flow(_ConversationalMixin, BaseModel, Generic[T], metaclass=FlowMeta): if filtered_inputs: self._initialize_state(filtered_inputs) + defer_trace_finalization = self._should_defer_trace_finalization() + deferred_started_event_id = self._deferred_flow_started_event_id + should_emit_flow_started = not ( + defer_trace_finalization and deferred_started_event_id + ) + + if ( + defer_trace_finalization + and deferred_started_event_id + and get_current_parent_id() is None + ): + restore_event_scope(((deferred_started_event_id, "flow_started"),)) + elif get_current_parent_id() is None: + reset_emission_counter() + reset_last_event_id() + + if should_emit_flow_started: + # In normal flows, each kickoff owns its own flow lifecycle. + # Deferred conversational sessions are different: the first + # turn opens the flow scope and later turns reuse it until + # ``finalize_session_traces()`` emits the single finish event. + started_event = FlowStartedEvent( + type="flow_started", + flow_name=self.name or self.__class__.__name__, + inputs=inputs, + ) + future = crewai_event_bus.emit(self, started_event) + if future: + try: + await asyncio.wrap_future(future) + except Exception: + logger.warning("FlowStartedEvent handler failed", exc_info=True) + # Stash the started event id so a deferred + # ``finalize_session_traces()`` can restore the event scope + # before emitting ``FlowFinishedEvent`` (otherwise the bus + # warns "Ending event 'flow_finished' emitted with empty + # scope stack"). + if defer_trace_finalization: + object.__setattr__( + self, "_deferred_flow_started_event_id", started_event.event_id + ) + if not self.suppress_flow_events: + self._log_flow_event( + f"Flow started with ID: {self.flow_id}", color="bold magenta" + ) + + # After FlowStarted: env events must not pre-empt trace batch init + # with implicit "crew" execution_type. + get_env_context() + # Conversational hook: apply the pending user message AFTER state - # restore so it survives ``self.persistence.load_state(...)``. + # restore and AFTER flow scope initialization, so transcript events + # are parented under the current conversation trace. # ``handle_turn`` stashes the message on ``self._pending_user_message`` # before calling ``kickoff``; this drains it. if ( @@ -2211,43 +2263,6 @@ class Flow(_ConversationalMixin, BaseModel, Generic[T], metaclass=FlowMeta): ): self._apply_pending_conversational_turn() - if get_current_parent_id() is None: - reset_emission_counter() - reset_last_event_id() - - # ``FlowStartedEvent`` always fires — ``suppress_flow_events`` - # only hides the Rich console panel (and the textual log line - # below), it doesn't gate observability events. Tracing / - # downstream listeners still need to see flow_started. - started_event = FlowStartedEvent( - type="flow_started", - flow_name=self.name or self.__class__.__name__, - inputs=inputs, - ) - future = crewai_event_bus.emit(self, started_event) - if future: - try: - await asyncio.wrap_future(future) - except Exception: - logger.warning("FlowStartedEvent handler failed", exc_info=True) - # Stash the started event id so a deferred - # ``finalize_session_traces()`` can restore the event scope - # before emitting ``FlowFinishedEvent`` (otherwise the bus - # warns "Ending event 'flow_finished' emitted with empty - # scope stack"). - if self._should_defer_trace_finalization(): - object.__setattr__( - self, "_deferred_flow_started_event_id", started_event.event_id - ) - if not self.suppress_flow_events: - self._log_flow_event( - f"Flow started with ID: {self.flow_id}", color="bold magenta" - ) - - # After FlowStarted: env events must not pre-empt trace batch init - # with implicit "crew" execution_type. - get_env_context() - if inputs is not None and "id" not in inputs: self._initialize_state(inputs) diff --git a/lib/crewai/tests/test_flow_conversation.py b/lib/crewai/tests/test_flow_conversation.py index 4ed61394e..fab03ad29 100644 --- a/lib/crewai/tests/test_flow_conversation.py +++ b/lib/crewai/tests/test_flow_conversation.py @@ -11,6 +11,8 @@ from pydantic import BaseModel from crewai.events.event_bus import crewai_event_bus from crewai.events.listeners.tracing.trace_listener import TraceCollectionListener from crewai.events.types.flow_events import ( + ConversationMessageAddedEvent, + ConversationRouteSelectedEvent, FlowStartedEvent, MethodExecutionFinishedEvent, MethodExecutionStartedEvent, @@ -421,6 +423,56 @@ class TestConversationalFlow: assert any(message["content"] == "prior findings" for message in messages) assert any(message["content"] == "summarize findings" for message in messages) + def test_conversational_turn_emits_message_and_route_events(self) -> None: + class ResearchRoute(BaseModel): + intent: Literal["research", "converse", "end"] + + router_llm = MagicMock() + router_llm.call.return_value = ResearchRoute(intent="converse") + chat_llm = MagicMock() + chat_llm.call.return_value = "hello back" + + @ConversationConfig( + llm=chat_llm, + router=RouterConfig( + response_format=ResearchRoute, + llm=router_llm, + routes=["research"], + ), + ) + class RoutedFlow(ConversationalFlow): + @listen("research") + def run_research(self) -> str: + self.append_assistant_message("researched") + return "researched" + + messages: list[ConversationMessageAddedEvent] = [] + routes: list[ConversationRouteSelectedEvent] = [] + + with crewai_event_bus.scoped_handlers(): + + @crewai_event_bus.on(ConversationMessageAddedEvent) + def capture_message(_: Any, event: ConversationMessageAddedEvent) -> None: + messages.append(event) + + @crewai_event_bus.on(ConversationRouteSelectedEvent) + def capture_route(_: Any, event: ConversationRouteSelectedEvent) -> None: + routes.append(event) + + flow = RoutedFlow() + flow.handle_turn("just chat") + crewai_event_bus.flush() + + assert [(event.role, event.content) for event in messages] == [ + ("user", "just chat"), + ("assistant", "hello back"), + ] + assert [event.message_index for event in messages] == [0, 1] + assert len(routes) == 1 + assert routes[0].route == "converse" + assert routes[0].user_message == "just chat" + assert routes[0].session_id == messages[0].session_id + def test_builtin_end_marks_conversation_ended(self) -> None: class ResearchRoute(BaseModel): intent: Literal["research", "converse", "end"] @@ -969,6 +1021,72 @@ class TestConversationalFlow: "defer_trace_finalization=True must skip per-turn finalize" ) + def test_deferred_conversation_emits_one_flow_started(self) -> None: + """Deferred conversational sessions emit one flow_started for the session.""" + from crewai.events.types.flow_events import FlowStartedEvent + + @ConversationConfig(defer_trace_finalization=True) + class DeferredFlow(ConversationalFlow): + def route_turn(self, context: dict[str, Any]) -> str | None: + return "work" + + @listen("work") + def do_work(self) -> str: + self.append_assistant_message("worked") + return "worked" + + flow = DeferredFlow() + observed_events: list[str] = [] + started_events: list[FlowStartedEvent] = [] + + with crewai_event_bus.scoped_handlers(): + + @crewai_event_bus.on(FlowStartedEvent) + def capture(_: Any, event: FlowStartedEvent) -> None: + observed_events.append(event.type) + started_events.append(event) + + @crewai_event_bus.on(ConversationMessageAddedEvent) + def capture_message( + _: Any, event: ConversationMessageAddedEvent + ) -> None: + if event.role == "user": + observed_events.append(event.type) + + flow.handle_turn("turn 1") + flow.handle_turn("turn 2") + flow.handle_turn("turn 3") + crewai_event_bus.flush() + + assert len(started_events) == 1, ( + "deferred conversational traces should emit one session-level " + "flow_started event, not one per turn" + ) + assert observed_events[0] == "flow_started" + assert observed_events[1] == "conversation_message_added" + + def test_route_event_uses_no_message_index_for_empty_transcript(self) -> None: + """Route events do not reference index zero when no message exists.""" + + @ConversationConfig() + class DeferredFlow(ConversationalFlow): + pass + + flow = DeferredFlow() + route_events: list[ConversationRouteSelectedEvent] = [] + + with crewai_event_bus.scoped_handlers(): + + @crewai_event_bus.on(ConversationRouteSelectedEvent) + def capture(_: Any, event: ConversationRouteSelectedEvent) -> None: + route_events.append(event) + + flow._emit_conversation_route_selected("converse") + crewai_event_bus.flush() + + assert len(route_events) == 1 + assert route_events[0].message_index is None + def test_finalize_session_traces_emits_finished_and_finalizes_batch(self) -> None: """``finalize_session_traces()`` emits one ``FlowFinishedEvent`` + one ``finalize_batch``. From 17cfbdf95f8f5ca76a6289d26b4c5a09602f8867 Mon Sep 17 00:00:00 2001 From: Lorenze Jay <63378463+lorenzejay@users.noreply.github.com> Date: Fri, 5 Jun 2026 14:15:43 -0700 Subject: [PATCH 17/18] feat: bump versions to 1.14.7a2 (#6054) --- lib/cli/pyproject.toml | 2 +- lib/cli/src/crewai_cli/__init__.py | 2 +- lib/cli/src/crewai_cli/templates/crew/pyproject.toml | 2 +- lib/cli/src/crewai_cli/templates/flow/pyproject.toml | 2 +- lib/cli/src/crewai_cli/templates/tool/pyproject.toml | 2 +- lib/crewai-core/src/crewai_core/__init__.py | 2 +- lib/crewai-files/src/crewai_files/__init__.py | 2 +- lib/crewai-tools/pyproject.toml | 2 +- lib/crewai-tools/src/crewai_tools/__init__.py | 2 +- lib/crewai/pyproject.toml | 6 +++--- lib/crewai/src/crewai/__init__.py | 2 +- lib/devtools/src/crewai_devtools/__init__.py | 2 +- 12 files changed, 14 insertions(+), 14 deletions(-) diff --git a/lib/cli/pyproject.toml b/lib/cli/pyproject.toml index 0df51e128..3618ffaf1 100644 --- a/lib/cli/pyproject.toml +++ b/lib/cli/pyproject.toml @@ -8,7 +8,7 @@ authors = [ ] requires-python = ">=3.10, <3.14" dependencies = [ - "crewai-core==1.14.7a1", + "crewai-core==1.14.7a2", "click>=8.1.7,<9", "pydantic>=2.11.9,<2.13", "pydantic-settings~=2.10.1", diff --git a/lib/cli/src/crewai_cli/__init__.py b/lib/cli/src/crewai_cli/__init__.py index 378e531da..3cc86d93b 100644 --- a/lib/cli/src/crewai_cli/__init__.py +++ b/lib/cli/src/crewai_cli/__init__.py @@ -1 +1 @@ -__version__ = "1.14.7a1" +__version__ = "1.14.7a2" diff --git a/lib/cli/src/crewai_cli/templates/crew/pyproject.toml b/lib/cli/src/crewai_cli/templates/crew/pyproject.toml index eab69283b..090f27573 100644 --- a/lib/cli/src/crewai_cli/templates/crew/pyproject.toml +++ b/lib/cli/src/crewai_cli/templates/crew/pyproject.toml @@ -5,7 +5,7 @@ description = "{{name}} using crewAI" authors = [{ name = "Your Name", email = "you@example.com" }] requires-python = ">=3.10,<3.14" dependencies = [ - "crewai[tools]==1.14.7a1" + "crewai[tools]==1.14.7a2" ] [project.scripts] diff --git a/lib/cli/src/crewai_cli/templates/flow/pyproject.toml b/lib/cli/src/crewai_cli/templates/flow/pyproject.toml index 88a0691d8..7f82b6b86 100644 --- a/lib/cli/src/crewai_cli/templates/flow/pyproject.toml +++ b/lib/cli/src/crewai_cli/templates/flow/pyproject.toml @@ -5,7 +5,7 @@ description = "{{name}} using crewAI" authors = [{ name = "Your Name", email = "you@example.com" }] requires-python = ">=3.10,<3.14" dependencies = [ - "crewai[tools]==1.14.7a1" + "crewai[tools]==1.14.7a2" ] [project.scripts] diff --git a/lib/cli/src/crewai_cli/templates/tool/pyproject.toml b/lib/cli/src/crewai_cli/templates/tool/pyproject.toml index 4af8825ef..db312e916 100644 --- a/lib/cli/src/crewai_cli/templates/tool/pyproject.toml +++ b/lib/cli/src/crewai_cli/templates/tool/pyproject.toml @@ -5,7 +5,7 @@ description = "Power up your crews with {{folder_name}}" readme = "README.md" requires-python = ">=3.10,<3.14" dependencies = [ - "crewai[tools]==1.14.7a1" + "crewai[tools]==1.14.7a2" ] [tool.crewai] diff --git a/lib/crewai-core/src/crewai_core/__init__.py b/lib/crewai-core/src/crewai_core/__init__.py index 378e531da..3cc86d93b 100644 --- a/lib/crewai-core/src/crewai_core/__init__.py +++ b/lib/crewai-core/src/crewai_core/__init__.py @@ -1 +1 @@ -__version__ = "1.14.7a1" +__version__ = "1.14.7a2" diff --git a/lib/crewai-files/src/crewai_files/__init__.py b/lib/crewai-files/src/crewai_files/__init__.py index d9048a33a..c125e65c4 100644 --- a/lib/crewai-files/src/crewai_files/__init__.py +++ b/lib/crewai-files/src/crewai_files/__init__.py @@ -152,4 +152,4 @@ __all__ = [ "wrap_file_source", ] -__version__ = "1.14.7a1" +__version__ = "1.14.7a2" diff --git a/lib/crewai-tools/pyproject.toml b/lib/crewai-tools/pyproject.toml index 543c0ff7d..5f08dff5f 100644 --- a/lib/crewai-tools/pyproject.toml +++ b/lib/crewai-tools/pyproject.toml @@ -10,7 +10,7 @@ requires-python = ">=3.10, <3.14" dependencies = [ "pytube~=15.0.0", "requests>=2.33.0,<3", - "crewai==1.14.7a1", + "crewai==1.14.7a2", "tiktoken>=0.8.0,<0.13", "beautifulsoup4~=4.13.4", "python-docx~=1.2.0", diff --git a/lib/crewai-tools/src/crewai_tools/__init__.py b/lib/crewai-tools/src/crewai_tools/__init__.py index 4a7dc7103..e75673927 100644 --- a/lib/crewai-tools/src/crewai_tools/__init__.py +++ b/lib/crewai-tools/src/crewai_tools/__init__.py @@ -330,4 +330,4 @@ __all__ = [ "ZapierActionTools", ] -__version__ = "1.14.7a1" +__version__ = "1.14.7a2" diff --git a/lib/crewai/pyproject.toml b/lib/crewai/pyproject.toml index 8c7282e29..979b46331 100644 --- a/lib/crewai/pyproject.toml +++ b/lib/crewai/pyproject.toml @@ -8,8 +8,8 @@ authors = [ ] requires-python = ">=3.10, <3.14" dependencies = [ - "crewai-core==1.14.7a1", - "crewai-cli==1.14.7a1", + "crewai-core==1.14.7a2", + "crewai-cli==1.14.7a2", # Core Dependencies "pydantic>=2.11.9,<2.13", "openai>=2.30.0,<3", @@ -54,7 +54,7 @@ Repository = "https://github.com/crewAIInc/crewAI" [project.optional-dependencies] tools = [ - "crewai-tools==1.14.7a1", + "crewai-tools==1.14.7a2", ] embeddings = [ "tiktoken>=0.8.0,<0.13" diff --git a/lib/crewai/src/crewai/__init__.py b/lib/crewai/src/crewai/__init__.py index 241b250de..428082aaf 100644 --- a/lib/crewai/src/crewai/__init__.py +++ b/lib/crewai/src/crewai/__init__.py @@ -48,7 +48,7 @@ def _suppress_pydantic_deprecation_warnings() -> None: _suppress_pydantic_deprecation_warnings() -__version__ = "1.14.7a1" +__version__ = "1.14.7a2" _LAZY_IMPORTS: dict[str, tuple[str, str]] = { "Memory": ("crewai.memory.unified_memory", "Memory"), diff --git a/lib/devtools/src/crewai_devtools/__init__.py b/lib/devtools/src/crewai_devtools/__init__.py index e60677184..d45edd41d 100644 --- a/lib/devtools/src/crewai_devtools/__init__.py +++ b/lib/devtools/src/crewai_devtools/__init__.py @@ -1,3 +1,3 @@ """CrewAI development tools.""" -__version__ = "1.14.7a1" +__version__ = "1.14.7a2" From 913a3abeadb0491eb09f98af2f36082b67747b68 Mon Sep 17 00:00:00 2001 From: Lorenze Jay <63378463+lorenzejay@users.noreply.github.com> Date: Fri, 5 Jun 2026 14:19:42 -0700 Subject: [PATCH 18/18] docs: update changelog and version for v1.14.7a2 (#6055) --- docs/ar/changelog.mdx | 29 +++++++++++++++++++++++++++++ docs/en/changelog.mdx | 29 +++++++++++++++++++++++++++++ docs/ko/changelog.mdx | 29 +++++++++++++++++++++++++++++ docs/pt-BR/changelog.mdx | 29 +++++++++++++++++++++++++++++ 4 files changed, 116 insertions(+) diff --git a/docs/ar/changelog.mdx b/docs/ar/changelog.mdx index 67a17eba4..8a6d1fe20 100644 --- a/docs/ar/changelog.mdx +++ b/docs/ar/changelog.mdx @@ -4,6 +4,35 @@ description: "تحديثات المنتج والتحسينات وإصلاحات icon: "clock" mode: "wide" --- + + ## 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 + + + ## v1.14.7a1 diff --git a/docs/en/changelog.mdx b/docs/en/changelog.mdx index 3077979fe..1fcf70971 100644 --- a/docs/en/changelog.mdx +++ b/docs/en/changelog.mdx @@ -4,6 +4,35 @@ description: "Product updates, improvements, and bug fixes for CrewAI" icon: "clock" mode: "wide" --- + + ## 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 + + + ## v1.14.7a1 diff --git a/docs/ko/changelog.mdx b/docs/ko/changelog.mdx index 74408caf3..d0a3d3393 100644 --- a/docs/ko/changelog.mdx +++ b/docs/ko/changelog.mdx @@ -4,6 +4,35 @@ description: "CrewAI의 제품 업데이트, 개선 사항 및 버그 수정" icon: "clock" mode: "wide" --- + + ## 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 + + + ## v1.14.7a1 diff --git a/docs/pt-BR/changelog.mdx b/docs/pt-BR/changelog.mdx index 349529138..fe20ebf86 100644 --- a/docs/pt-BR/changelog.mdx +++ b/docs/pt-BR/changelog.mdx @@ -4,6 +4,35 @@ description: "Atualizações de produto, melhorias e correções do CrewAI" icon: "clock" mode: "wide" --- + + ## 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 + + + ## v1.14.7a1