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/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). 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-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/src/crewai/experimental/conversational_mixin.py b/lib/crewai/src/crewai/experimental/conversational_mixin.py index 0b46a96d1..829029a0a 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 @@ -265,6 +265,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/__init__.py b/lib/crewai/src/crewai/flow/__init__.py index 7142403ad..364d2ab49 100644 --- a/lib/crewai/src/crewai/flow/__init__.py +++ b/lib/crewai/src/crewai/flow/__init__.py @@ -9,10 +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.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 from crewai.flow.visualization import ( @@ -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 deleted file mode 100644 index 3181acd50..000000000 --- a/lib/crewai/src/crewai/flow/dsl.py +++ /dev/null @@ -1,320 +0,0 @@ -"""Flow authoring DSL: the ``@start`` / ``@listen`` / ``@router`` decorators -plus the ``or_`` / ``and_`` condition combinators. - -These decorators wrap user methods into the typed wrappers defined in -``flow_wrappers`` and record their trigger conditions. The structural model -those conditions feed is built in ``flow_definition``; execution happens in -``runtime``. -""" - -from __future__ import annotations - -from collections.abc import Callable -from typing import Any, ParamSpec, TypeVar - -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, -) -from crewai.flow.flow_wrappers import ( - FlowCondition, - FlowConditions, - ListenMethod, - RouterMethod, - StartMethod, -) - - -P = ParamSpec("P") -R = TypeVar("R") - - -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]: - """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_()" - ) - 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]: - """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_()" - ) - return wrapper - - return decorator - - -def router( - condition: str | FlowCondition | Callable[..., Any], -) -> Callable[[Callable[P, R]], RouterMethod[P, R]]: - """Creates a routing method that directs flow execution based on conditions. - - This decorator marks a method as a router, which can dynamically determine - the next steps in the flow based on its return value. Routers are triggered - by specified conditions and can return constants that determine which path - the flow should take. - - Args: - condition: Specifies when the router should execute. Can be: - - str: Name of a method that triggers this router - - FlowCondition: Result from or_() or and_(), including nested conditions - - Callable[..., Any]: A method reference that triggers this router - - 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" - """ - - 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 - else: - raise ValueError( - "Condition must be a method, string, or a result of or_() or and_()" - ) - return wrapper - - return decorator - - -def or_(*conditions: str | FlowCondition | Callable[..., Any]) -> FlowCondition: - """Combines multiple conditions with OR logic for flow control. - - Creates a condition that is satisfied when any of the specified conditions - are met. This is used with @start, @listen, or @router decorators to create - complex triggering conditions. - - Args: - conditions: Variable number of conditions that can be method names, existing condition dictionaries, or method references. - - Returns: - A condition dictionary with format {"type": "OR", "conditions": list_of_conditions} where each condition can be a string (method name) or a nested dict - - Raises: - ValueError: If condition format is invalid. - - Examples: - >>> @listen(or_("success", "timeout")) - >>> def handle_completion(self): - ... pass - - >>> @listen(or_(and_("step1", "step2"), "step3")) - >>> def handle_nested(self): - ... pass - """ - processed_conditions: FlowConditions = [] - for condition in conditions: - if is_flow_condition_dict(condition) or is_flow_method_name(condition): - processed_conditions.append(condition) - elif is_flow_method_callable(condition): - processed_conditions.append(condition.__name__) - else: - raise ValueError("Invalid condition in or_()") - return {"type": OR_CONDITION, "conditions": processed_conditions} - - -def and_(*conditions: str | FlowCondition | Callable[..., Any]) -> FlowCondition: - """Combines multiple conditions with AND logic for flow control. - - Creates a condition that is satisfied only when all specified conditions - are met. This is used with @start, @listen, or @router decorators to create - complex triggering conditions. - - Args: - *conditions: Variable number of conditions that can be method names, existing condition dictionaries, or method references. - - Returns: - A condition dictionary with format {"type": "AND", "conditions": list_of_conditions} - where each condition can be a string (method name) or a nested dict - - Raises: - ValueError: If any condition is invalid. - - Examples: - >>> @listen(and_("validated", "processed")) - >>> def handle_complete_data(self): - ... pass - - >>> @listen(and_(or_("step1", "step2"), "step3")) - >>> def handle_nested(self): - ... pass - """ - processed_conditions: FlowConditions = [] - for condition in conditions: - if is_flow_condition_dict(condition) or is_flow_method_name(condition): - processed_conditions.append(condition) - elif is_flow_method_callable(condition): - processed_conditions.append(condition.__name__) - else: - raise ValueError("Invalid condition in and_()") - return {"type": AND_CONDITION, "conditions": processed_conditions} 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/_utils.py b/lib/crewai/src/crewai/flow/dsl/_utils.py new file mode 100644 index 000000000..d23bc3886 --- /dev/null +++ b/lib/crewai/src/crewai/flow/dsl/_utils.py @@ -0,0 +1,529 @@ +from __future__ import annotations + +from collections.abc import Callable, Sequence +import json +import logging +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, + FlowDefinitionCondition, + FlowDefinitionDiagnostic, + FlowHumanFeedbackDefinition, + FlowMethodDefinition, + FlowPersistenceDefinition, + FlowStateDefinition, +) +from crewai.flow.flow_wrappers import ( + FlowCondition, + FlowMethod, + ListenMethod, + RouterMethod, + StartMethod, +) +from crewai.flow.types import FlowMethodName + + +P = ParamSpec("P") +R = TypeVar("R") + +logger = logging.getLogger(__name__) + +_FLOW_METHOD_DEFINITION_ATTR = "__flow_method_definition__" + + +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 _flow_method_names(values: Sequence[Any]) -> list[FlowMethodName]: + return [FlowMethodName(str(value)) for value in values] + + +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 _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 _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 _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 _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..010f9d6c7 100644 --- a/lib/crewai/src/crewai/flow/human_feedback.py +++ b/lib/crewai/src/crewai/flow/human_feedback.py @@ -78,14 +78,10 @@ logger = logging.getLogger(__name__) F = TypeVar("F", bound=Callable[..., Any]) +__all__ = ["HumanFeedbackResult", "human_feedback"] + def _serialize_llm_for_context(llm: Any) -> dict[str, Any] | str | None: - """Serialize a BaseLLM object to a dict preserving full config. - - Delegates to ``llm.to_config_dict()`` when available (BaseLLM and - subclasses). Falls back to extracting the model string with provider - prefix for unknown LLM types. - """ to_config: Callable[[], dict[str, Any]] | None = getattr( llm, "to_config_dict", None ) @@ -103,13 +99,6 @@ def _serialize_llm_for_context(llm: Any) -> dict[str, Any] | str | None: def _deserialize_llm_from_context( llm_data: dict[str, Any] | str | None, ) -> BaseLLM | None: - """Reconstruct an LLM instance from serialized context data. - - Handles both the new dict format (with full config) and the legacy - string format (model name only) for backward compatibility. - - Returns a BaseLLM instance, or None if llm_data is None. - """ if llm_data is None: return None @@ -202,12 +191,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 @@ -232,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", @@ -243,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( @@ -356,20 +249,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 +268,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 +315,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 +368,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 +391,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 +416,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 +439,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(): @@ -655,42 +534,33 @@ def human_feedback( wrapper = sync_wrapper - for attr in [ - "__is_start_method__", - "__trigger_methods__", - "__condition_type__", - "__trigger_condition__", - "__is_flow_method__", - ]: - 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_paths__ = 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._hf_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/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 bb2387cb5..8ea7f74df 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,20 @@ 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._conditions import ( _extract_all_methods, _extract_all_methods_recursive, _normalize_condition, - 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.dsl._utils import ( + build_flow_definition, + extract_flow_definition, + is_flow_method, +) +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 +603,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 +633,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 +670,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,22 +699,23 @@ 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 # 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") @@ -741,6 +737,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), @@ -1467,7 +1472,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 @@ -2855,7 +2860,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_conversation.py b/lib/crewai/tests/test_flow_conversation.py index bd14d0cf0..cd01ca4cb 100644 --- a/lib/crewai/tests/test_flow_conversation.py +++ b/lib/crewai/tests/test_flow_conversation.py @@ -910,6 +910,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``. diff --git a/lib/crewai/tests/test_flow_definition.py b/lib/crewai/tests/test_flow_definition.py new file mode 100644 index 000000000..1b8325e68 --- /dev/null +++ b/lib/crewai/tests/test_flow_definition.py @@ -0,0 +1,847 @@ +"""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__) == { + "HumanFeedbackResult", + "and_", + "human_feedback", + "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.""" 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()