From 4dfd074fae84fc287af9f9e8cce7e74096c4f626 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Moura?= Date: Wed, 19 Aug 2026 11:48:18 -0300 Subject: [PATCH] docs(flow): document declarative conversational flows (#7035) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test(flow): unskip the conversational end-to-end suite The `conversational_graph_broken` marker parked 21 end-to-end conversational tests with the reason "the definition-first start migration intentionally stopped scanning inherited methods, so that graph no longer registers". That is no longer true: `_iter_flow_methods` walks the MRO for `__conversational_only__` methods (dsl/_utils.py:406-420), so a `conversational = True` subclass does register `route_conversation`, `converse_turn`, `end_conversation` and `answer_from_history_turn` — which `test_flow_definition.py:391-407` already asserts. Removing the marker takes the file from 47 passed / 21 skipped to 68 passed. Co-Authored-By: Claude Opus 5 (1M context) * feat(flow): make conversational opt-in unmistakable Opting a Flow into chat took two statements, and forgetting one failed silently. With `@ConversationConfig(...)` but no `conversational = True`: `FlowDefinition.conversational` came back `None`, the built-in graph never registered, and `handle_turn()` returned `None` without appending a message or raising — while `chat()` reported "only available on conversational flows" on a class that was literally decorated with a conversational config. Three changes: - `ConversationConfig.__call__` now also sets `conversational = True`. Every field on the config is consumed only by the conversational graph, so a decorated non-conversational Flow could only ever discard it. - `FlowConversationalDefinition.enabled` defaults to True. The block is absent on non-conversational flows, so declaring it is the opt-in; `enabled: false` remains an explicit opt-out. - `handle_turn()` raises like `chat()` and `stream_turn()` already do instead of silently returning `None`. Setting `conversational = True` by hand still works and is still the way to opt in without a config. Co-Authored-By: Claude Opus 5 (1M context) * feat(flow): let a declaration drive conversational mode `FlowDefinition.conversational` was written by the DSL projection and read by nothing: every conversational gate resolved through `type(self).flow_definition()` — the class projection — instead of `self._definition`, the declaration a flow was actually built from. So `Flow.from_declaration()` on a definition with a conversational block produced a flow that reported itself non-conversational, dropped the user message, and never registered its declared routes. Resolution rules, applied consistently: - Structure (enabled, methods, route labels, builtin/internal routes) comes from `self._definition`, which is the loaded declaration for a declarative flow and the class projection otherwise. Both paths now agree. - Behavior (`conversational_config`) still prefers the class attribute, which can hold live objects — a configured LLM, a custom BaseLLM, a response_format model class — that the serializable definition degrades to a config dict or a `module:qualname` ref. Reading the definition first would silently downgrade every decorated Python flow. A declaration-built flow has no class config, so `_config_from_definition` supplies one, cached for stable identity. - A declared `state:` block is never replaced. `_create_default_extension_state` is consulted before `_create_definition_state`, so returning `ConversationState` there discarded every field the declaration asked for. It now yields to a declared state and only supplies the default when nothing else does. The class-scoped `_is_conversational` / `_conversational_definition` classmethods are gone; the existing instance-scoped `_is_conversational_enabled` is the single gate. A router `response_format` that survived serialization as a ref or schema dict is dropped with a warning rather than handed to `llm.call()`, which needs a real class. Co-Authored-By: Claude Opus 5 (1M context) * feat(flow): synthesize the built-in conversational methods for declarations A declaration carrying `conversational: {}` loaded clean and then ran zero methods and returned `None`, because the four built-in graph handlers are inherited from `_ConversationalMixin` and a declaration has nothing to inherit from. Authors had to name `crewai.experimental.conversational_mixin:_Conversational Mixin.route_conversation` and three siblings by hand. `Flow._extend_definition` is a new runtime extension hook, called once `_definition` is resolved and before methods are bound. The conversational mixin overrides it to fill in `route_conversation`, `converse_turn`, `end_conversation` and `answer_from_history_turn` when they are missing, using the same code refs the DSL projection already emits so a declaration and a class projection of the same flow produce identical method definitions. Synthesis is deliberately a runtime concern, not a contract one: `FlowDefinition` stays independent of the authoring layer and of the engine, as `test_flow_definition_contract_is_dsl_agnostic` requires, and a loaded declaration still serializes back to exactly what its author wrote. Route descriptions are now carried by the contract. The DSL projects a handler docstring's first line into `FlowMethodDefinition.description`, and the router catalog reads that before falling back to the live docstring. This also fixes a real defect: for a declarative flow `getattr(type(self), handler_name, None)` is `None`, and the old code read `None.__doc__` — so the router LLM was told a route's description was "The type of the None singleton." Co-Authored-By: Claude Opus 5 (1M context) * fix(flow): let an agent or crew handler reply in a conversation (#7034) `handle_turn` promotes a handler's return value to the assistant message when the handler did not append one itself, but the check required `isinstance(result, str)`. Declarative `agent` and `crew` actions return `LiteAgentOutput` and `CrewOutput`, whose text lives on `.raw` — so the most natural declarative handler was exactly the one whose reply never reached the transcript. `_is_public_turn_result` now unwraps `.raw` before deciding, matching `_stringify_result`, which already did. The routing-artefact guards are applied to the unwrapped text, so an output echoing a route label or this turn's intent is still not promoted. Co-authored-by: Claude Opus 5 (1M context) * fix(flow): keep privately recorded agent results out of the transcript Unwrapping `.raw` in `_is_public_turn_result` made the end-of-turn fallback promote `LiteAgentOutput` / `CrewOutput` objects that the handler had already recorded via `append_agent_result` with the default private visibility. That call does not set `_assistant_reply_appended`, so the fallback republished the very object the handler asked to keep private — defeating `visible_agent_outputs`. Reproduced against `main` for contrast: no leak before the unwrap, leak after. `append_agent_result` now remembers the object it recorded for the duration of the turn, and the fallback skips anything already routed that way. The check is identity-based on purpose: a handler that records scratch work privately and then returns a user-facing summary still gets that summary promoted, which a simple "handler already handled it" flag would have broken. Found by Cursor Bugbot on #7033. * feat(flow): mark a declarative chat flow conversational on the instance A declaration enables chat through `conversational.enabled`, without the `conversational = True` class attribute. Callers outside this package capability-check that attribute -- the AG-UI serving guide states it as a requirement -- so it disagreed with `_is_conversational_enabled()` and a declarative conversational flow looked non-conversational from outside. `_extend_definition` now sets it on the instance when the definition enables chat. Instance-only on purpose: the DSL projection reads the attribute off the *class* to decide whether to emit a conversational block, so setting it there would make every later subclass look conversational. Verified on a real declarative flow: `conversational` and `stream_turn` both now satisfy the documented capability check, while `Flow.conversational` and any later subclass stay False. * refactor(flow): derive routing-artefact labels from the effective routes `_is_public_turn_result` matched a literal set of route labels, duplicating knowledge that `_effective_builtin_routes()` already owns. A declaration that adds a builtin route was not covered, so a handler echoing that label could be promoted into the transcript -- the same class of divergence already fixed for `route_turn`. Verified the derived set is byte-identical to the old literal one for a class-based flow, so this is a pure generalization: `conversation` and `route_to_flow` stay explicit because neither is a route. Also replaces a tuple-index lambda in the chat REPL test with a named `input_fn`; it relied on tuple evaluation order and on the list being mutated before its length was read. Both found by CodeRabbit on #7033. * docs(flow): document declarative conversational flows The authoring skill told LLM authors "use top-level `conversational` only when the user asks for a chat flow" while documenting none of its 19 fields — there was no ModelSpec for either conversational model, so the API reference appendix skipped them entirely. - Adds both conversational models to the skill reference, with field descriptions, and registers them under the existing `conversational` skip so `skills(skips=["conversational"])` still suppresses the whole block. - Adds authoring rules: do not declare the built-in graph, do not name a handler after the route it listens to, do not declare state unless it needs extra fields, and give every route handler a description. - Documents the declarative form in the conversational-flows guide across en, ar, ko and pt-BR, including what is supplied automatically, how to run it, and what a declaration cannot express (live LLM objects, a response_format class, route_turn overrides). - `crewai run` on a conversational declaration now says it has no chat loop and points at handle_turn/chat, instead of quietly running a single turn and exiting. It fails closed: a flow that cannot be inspected runs normally. Co-Authored-By: Claude Opus 5 (1M context) * fix(flow): render the conversational router section in the skill Both conversational models shared one `Conversational` section, and the template renders only the first model of a non-union section. The router's fields were therefore dropped from the API reference and the generated link to them pointed at a heading that did not exist. Also softens the built-in-handler rule: `_extend_definition` keeps an author-supplied entry and the guide documents that override, so the skill should say to omit those handlers by default rather than never declare them. Adds regression tests for both sections rendering, for every field of both models appearing, and for `skips=["conversational"]` suppressing both. Both found by CodeRabbit on #7035. Co-Authored-By: Claude Opus 5 (1M context) * docs(flow): correct the route-description rule in the authoring skill The rule said every route handler must define `description`, but `conversational.router.route_descriptions` is the higher-precedence source -- `_build_route_catalog` checks the overrides before falling back to the method description. Either one describes a route; the rule now says so, and says what happens when a route has neither. --------- Co-authored-by: Claude Opus 5 (1M context) Co-authored-by: ViditOstwal Co-authored-by: Vidit Ostwal <110953813+Vidit-Ostwal@users.noreply.github.com> --- .../ar/guides/flows/conversational-flows.mdx | 64 +++++++++++++++++++ .../en/guides/flows/conversational-flows.mdx | 64 +++++++++++++++++++ .../ko/guides/flows/conversational-flows.mdx | 64 +++++++++++++++++++ .../guides/flows/conversational-flows.mdx | 64 +++++++++++++++++++ .../src/crewai_cli/run_declarative_flow.py | 26 ++++++++ lib/cli/tests/test_run_declarative_flow.py | 45 +++++++++++++ lib/crewai/src/crewai/flow/skill.py | 33 ++++++++++ .../templates/flow_definition_skill.md.j2 | 4 ++ lib/crewai/tests/test_flow_definition.py | 35 ++++++++++ 9 files changed, 399 insertions(+) diff --git a/docs/edge/ar/guides/flows/conversational-flows.mdx b/docs/edge/ar/guides/flows/conversational-flows.mdx index 371de79be..e96814b82 100644 --- a/docs/edge/ar/guides/flows/conversational-flows.mdx +++ b/docs/edge/ar/guides/flows/conversational-flows.mdx @@ -426,6 +426,70 @@ class SupportFlow(Flow[ConversationState]): يمكن لـ `ConversationConfig.visible_agent_outputs` رفع النتائج الخاصة لـ agents محددين إلى عامة عالمياً (`"all"` أو قائمة بالأسماء). +## تعريف تدفق محادثاتي بصيغة JSON/YAML + +يمكن لـ [التدفق التعريفي](/edge/en/concepts/cli) أن يكون محادثاتيًا أيضًا. أضف كتلة `conversational` في المستوى الأعلى وعرّف مساراتك الخاصة كطرق تستمع (`listen`) إلى تسمية مسار: + +```yaml +schema: crewai.flow/v1 +name: SupportFlow + +conversational: + system_prompt: You are a terse support assistant. + llm: gpt-4o-mini + router: + llm: gpt-4o-mini + +methods: + handle_order: + description: Order status, shipping and delivery questions. + listen: order + do: + call: agent + with: + role: Support specialist + goal: Answer order questions accurately + backstory: Knows the fulfilment pipeline. + input: "${state.current_user_message}" +``` + +تعريف الكتلة هو الاشتراك نفسه — القيمة الافتراضية لـ `enabled` هي `true`. اضبطها على `enabled: false` للاحتفاظ بالإعدادات مع إيقاف المحادثة. + +تُوفَّر لك ثلاثة أشياء: + +| المُوفَّر | التفاصيل | +|----------|--------| +| الرسم البياني المدمج | تُضاف `route_conversation` و`converse_turn` و`end_conversation` و`answer_from_history_turn` تلقائيًا. عرّف طريقة بأحد هذه الأسماء لتجاوزها. | +| حالة المحادثة | تُستخدم `ConversationState` عندما لا تحتوي التعريفة على كتلة `state`. لإضافة حقول، وجّه `state` إلى نموذج Pydantic يرث من `ConversationState`. | +| كتالوج المسارات | يُبنى من الطرق التي تعلن تسمية `listen`. وصف كل طريقة (`description`) هو ما يقرأه نموذج التوجيه عند الاختيار بين المسارات. | + +شغّله من Python بنفس واجهات الجولة المستخدمة مع تدفق محادثاتي معرّف بصنف: + +```python +from crewai.flow import Flow + +flow = Flow.from_declaration(path="flow.yaml") + +try: + flow.handle_turn("Where is my order?", session_id="session-1") +finally: + flow.finalize_session_traces() +``` + +### تسمية المسارات + +تتشارك تسميات المسارات وأسماء الطرق مساحة اسم واحدة للمشغّلات، لذا يجب ألا يحمل المعالج اسم المسار الذي يستمع إليه — يُرفض `create_video` الذي يستمع إلى `create_video` عند بناء التدفق. استخدم بادئة `handle_*`. + +### ما لا يمكن للتعريفة التعبير عنه + +| غير قابل للتعبير | استخدم بدلًا منه | +|-----------------|-------------| +| مثيل `LLM` حي أو `BaseLLM` مخصص | سلسلة معرّف النموذج، مثل `gpt-4o-mini` | +| `router.response_format` كصنف نموذج | احذفه؛ يولّد الإطار واحدًا. يُتجاهل المرجع أو المخطط مع تحذير | +| تجاوزات `route_turn()` / `can_answer_from_history()` | اكتب التدفق بلغة Python، أو وجّه `do` لطريقة إلى مرجع `call: code` | + +لا يملك `crewai run` حلقة محادثة بعد: فهو يبلّغ أن التدفق محادثاتي ويخرج بدلًا من تنفيذ جولة واحدة. شغّل التدفق المحادثاتي التعريفي من Python عبر `handle_turn()` أو `stream_turn()` أو `chat()`. + ## التتبع عبر الجولات مع `defer_trace_finalization=True` (افتراضي في `ConversationalConfig`): diff --git a/docs/edge/en/guides/flows/conversational-flows.mdx b/docs/edge/en/guides/flows/conversational-flows.mdx index b8fd7f43d..e42c4966b 100644 --- a/docs/edge/en/guides/flows/conversational-flows.mdx +++ b/docs/edge/en/guides/flows/conversational-flows.mdx @@ -491,6 +491,70 @@ Inside a `@listen(label)` handler, choose: `ConversationConfig.visible_agent_outputs` can promote specific agents' private results to public globally (`"all"`, or a list of agent names). +## Declaring a conversational flow in JSON/YAML + +A [declarative Flow](/edge/en/concepts/cli) can be conversational too. Add a top-level `conversational` block and declare your own routes as methods that `listen` to a route label: + +```yaml +schema: crewai.flow/v1 +name: SupportFlow + +conversational: + system_prompt: You are a terse support assistant. + llm: gpt-4o-mini + router: + llm: gpt-4o-mini + +methods: + handle_order: + description: Order status, shipping and delivery questions. + listen: order + do: + call: agent + with: + role: Support specialist + goal: Answer order questions accurately + backstory: Knows the fulfilment pipeline. + input: "${state.current_user_message}" +``` + +Declaring the block is the opt-in — `enabled` defaults to `true`. Set `enabled: false` to keep the configuration while turning chat off. + +Three things are supplied for you: + +| Supplied | Detail | +|----------|--------| +| The built-in graph | `route_conversation`, `converse_turn`, `end_conversation` and `answer_from_history_turn` are added automatically. Declare a method under one of those names to override it. | +| Conversation state | `ConversationState` is used when the declaration has no `state` block. To add fields, point `state` at a Pydantic model that extends `ConversationState`. | +| The route catalog | Built from the methods that declare a `listen` label. Each method's `description` is what the routing model reads when choosing between routes. | + +Run it from Python with the same turn APIs as a class-based conversational Flow: + +```python +from crewai.flow import Flow + +flow = Flow.from_declaration(path="flow.yaml") + +try: + flow.handle_turn("Where is my order?", session_id="session-1") +finally: + flow.finalize_session_traces() +``` + +### Naming routes + +Route labels and method names share one trigger namespace, so a handler must not be named after the route it listens to — `create_video` listening to `create_video` is rejected when the flow is built. Use a `handle_*` prefix. + +### What a declaration cannot express + +| Not expressible | Use instead | +|-----------------|-------------| +| A live `LLM` instance or a custom `BaseLLM` | A model id string, such as `gpt-4o-mini` | +| `router.response_format` as a model class | Omit it; the framework synthesizes one. A ref or schema is ignored with a warning | +| `route_turn()` / `can_answer_from_history()` overrides | Author the Flow in Python, or point a method's `do` at a `call: code` ref | + +`crewai run` has no chat loop yet: it reports that the flow is conversational and exits rather than running a single turn. Drive a declarative conversational flow from Python with `handle_turn()`, `stream_turn()` or `chat()`. + ## Tracing across turns With `defer_trace_finalization=True` (default in `ConversationConfig`): diff --git a/docs/edge/ko/guides/flows/conversational-flows.mdx b/docs/edge/ko/guides/flows/conversational-flows.mdx index 3a18cd1da..fd3a7c256 100644 --- a/docs/edge/ko/guides/flows/conversational-flows.mdx +++ b/docs/edge/ko/guides/flows/conversational-flows.mdx @@ -427,6 +427,70 @@ LLM router를 우회해 프로그램적으로 라우트를 선택하려면 `rout `ConversationConfig.visible_agent_outputs`로 특정 에이전트의 private 결과를 전역적으로 public으로 승격할 수 있습니다 (`"all"` 또는 이름 리스트). +## JSON/YAML로 대화형 플로우 선언하기 + +[선언적 플로우](/edge/en/concepts/cli)도 대화형이 될 수 있습니다. 최상위 `conversational` 블록을 추가하고, 라우트 레이블을 `listen`하는 메서드로 직접 라우트를 선언하세요: + +```yaml +schema: crewai.flow/v1 +name: SupportFlow + +conversational: + system_prompt: You are a terse support assistant. + llm: gpt-4o-mini + router: + llm: gpt-4o-mini + +methods: + handle_order: + description: Order status, shipping and delivery questions. + listen: order + do: + call: agent + with: + role: Support specialist + goal: Answer order questions accurately + backstory: Knows the fulfilment pipeline. + input: "${state.current_user_message}" +``` + +블록을 선언하는 것 자체가 옵트인입니다 — `enabled`의 기본값은 `true`입니다. 설정은 유지하면서 채팅만 끄려면 `enabled: false`로 지정하세요. + +세 가지가 자동으로 제공됩니다: + +| 제공 항목 | 설명 | +|----------|--------| +| 내장 그래프 | `route_conversation`, `converse_turn`, `end_conversation`, `answer_from_history_turn`이 자동으로 추가됩니다. 같은 이름의 메서드를 선언하면 재정의됩니다. | +| 대화 상태 | 선언에 `state` 블록이 없으면 `ConversationState`가 사용됩니다. 필드를 추가하려면 `ConversationState`를 상속한 Pydantic 모델을 `state`에 지정하세요. | +| 라우트 카탈로그 | `listen` 레이블을 선언한 메서드들로부터 구성됩니다. 각 메서드의 `description`이 라우팅 모델이 라우트를 선택할 때 읽는 내용입니다. | + +클래스 기반 대화형 플로우와 동일한 턴 API로 Python에서 실행합니다: + +```python +from crewai.flow import Flow + +flow = Flow.from_declaration(path="flow.yaml") + +try: + flow.handle_turn("Where is my order?", session_id="session-1") +finally: + flow.finalize_session_traces() +``` + +### 라우트 이름 짓기 + +라우트 레이블과 메서드 이름은 하나의 트리거 네임스페이스를 공유하므로, 핸들러 이름이 자신이 listen하는 라우트와 같으면 안 됩니다 — `create_video`가 `create_video`를 listen하면 플로우 생성 시 거부됩니다. `handle_*` 접두사를 사용하세요. + +### 선언으로 표현할 수 없는 것 + +| 표현 불가 | 대신 사용 | +|-----------------|-------------| +| 살아 있는 `LLM` 인스턴스나 커스텀 `BaseLLM` | `gpt-4o-mini` 같은 모델 ID 문자열 | +| 모델 클래스로서의 `router.response_format` | 생략하세요; 프레임워크가 생성합니다. ref나 스키마는 경고와 함께 무시됩니다 | +| `route_turn()` / `can_answer_from_history()` 재정의 | 플로우를 Python으로 작성하거나, 메서드의 `do`를 `call: code` ref로 지정하세요 | + +`crewai run`에는 아직 채팅 루프가 없습니다: 단일 턴을 실행하는 대신 플로우가 대화형임을 알리고 종료합니다. 선언적 대화형 플로우는 Python에서 `handle_turn()`, `stream_turn()`, `chat()`으로 실행하세요. + ## 턴 간 트레이싱 `defer_trace_finalization=True` (`ConversationalConfig` 기본값): diff --git a/docs/edge/pt-BR/guides/flows/conversational-flows.mdx b/docs/edge/pt-BR/guides/flows/conversational-flows.mdx index 10ffdcbd6..9012bbe6e 100644 --- a/docs/edge/pt-BR/guides/flows/conversational-flows.mdx +++ b/docs/edge/pt-BR/guides/flows/conversational-flows.mdx @@ -428,6 +428,70 @@ Dentro de um handler `@listen(label)`, escolha: `ConversationConfig.visible_agent_outputs` pode promover globalmente os resultados privados de agentes específicos para públicos (`"all"` ou lista de nomes). +## Declarando um flow conversacional em JSON/YAML + +Um [flow declarativo](/edge/en/concepts/cli) também pode ser conversacional. Adicione um bloco `conversational` no nível raiz e declare suas próprias rotas como métodos que escutam (`listen`) um rótulo de rota: + +```yaml +schema: crewai.flow/v1 +name: SupportFlow + +conversational: + system_prompt: You are a terse support assistant. + llm: gpt-4o-mini + router: + llm: gpt-4o-mini + +methods: + handle_order: + description: Order status, shipping and delivery questions. + listen: order + do: + call: agent + with: + role: Support specialist + goal: Answer order questions accurately + backstory: Knows the fulfilment pipeline. + input: "${state.current_user_message}" +``` + +Declarar o bloco já é o opt-in — `enabled` tem valor padrão `true`. Use `enabled: false` para manter a configuração e desligar o chat. + +Três coisas são fornecidas para você: + +| Fornecido | Detalhe | +|----------|--------| +| O grafo interno | `route_conversation`, `converse_turn`, `end_conversation` e `answer_from_history_turn` são adicionados automaticamente. Declare um método com um desses nomes para sobrescrevê-lo. | +| Estado da conversa | `ConversationState` é usado quando a declaração não tem bloco `state`. Para adicionar campos, aponte `state` para um modelo Pydantic que estenda `ConversationState`. | +| O catálogo de rotas | Construído a partir dos métodos que declaram um rótulo `listen`. O `description` de cada método é o que o modelo de roteamento lê ao escolher entre rotas. | + +Execute a partir do Python com as mesmas APIs de turno de um Flow conversacional baseado em classe: + +```python +from crewai.flow import Flow + +flow = Flow.from_declaration(path="flow.yaml") + +try: + flow.handle_turn("Where is my order?", session_id="session-1") +finally: + flow.finalize_session_traces() +``` + +### Nomeando rotas + +Rótulos de rota e nomes de métodos compartilham um único namespace de gatilhos, então um handler não pode ter o nome da rota que escuta — `create_video` escutando `create_video` é rejeitado na construção do flow. Use o prefixo `handle_*`. + +### O que uma declaração não consegue expressar + +| Não expressável | Use no lugar | +|-----------------|-------------| +| Uma instância `LLM` viva ou um `BaseLLM` customizado | Uma string de id de modelo, como `gpt-4o-mini` | +| `router.response_format` como classe de modelo | Omita; o framework sintetiza uma. Um ref ou schema é ignorado com um aviso | +| Overrides de `route_turn()` / `can_answer_from_history()` | Escreva o Flow em Python, ou aponte o `do` de um método para um ref `call: code` | + +O `crewai run` ainda não tem loop de chat: ele informa que o flow é conversacional e sai, em vez de rodar um único turno. Conduza um flow conversacional declarativo pelo Python com `handle_turn()`, `stream_turn()` ou `chat()`. + ## Tracing entre turnos Com `defer_trace_finalization=True` (padrão em `ConversationalConfig`): diff --git a/lib/cli/src/crewai_cli/run_declarative_flow.py b/lib/cli/src/crewai_cli/run_declarative_flow.py index 927965f59..0b7cb8cd2 100644 --- a/lib/cli/src/crewai_cli/run_declarative_flow.py +++ b/lib/cli/src/crewai_cli/run_declarative_flow.py @@ -72,6 +72,18 @@ def run_declarative_flow(definition: str | Path, inputs: str | None = None) -> N provided = parse_inputs_json(inputs) or {} flow = load_declarative_flow(definition) + + if _flow_is_conversational(flow): + click.secho( + " This flow declares `conversational`, and `crewai run` has no chat " + "loop yet — it would run a single turn and exit.\n" + " Drive it from Python for now: `flow.chat()` for a terminal REPL, " + "or `flow.handle_turn(message, session_id=...)` per message.", + fg="yellow", + err=True, + ) + raise SystemExit(1) + resolved_inputs = _resolve_flow_inputs(flow, provided) # The TUI is the interactive default. Headless contexts run directly on the @@ -156,6 +168,20 @@ def _run_declarative_flow_tui( return app._crew_result +def _flow_is_conversational(flow: Flow[Any]) -> bool: + """True if the declaration turns on conversational mode. + + Fails closed: a flow we cannot inspect runs the normal single-kickoff path + rather than being blocked from running at all. + """ + try: + conversational = flow._definition.conversational + except AttributeError: + logger.debug("Could not inspect flow for conversational mode", exc_info=True) + return False + return conversational is not None and conversational.enabled + + def _flow_uses_human_feedback(flow: Flow[Any]) -> bool: """True if any declarative method declares ``@human_feedback``. diff --git a/lib/cli/tests/test_run_declarative_flow.py b/lib/cli/tests/test_run_declarative_flow.py index 378156725..32df4be7a 100644 --- a/lib/cli/tests/test_run_declarative_flow.py +++ b/lib/cli/tests/test_run_declarative_flow.py @@ -611,3 +611,48 @@ def test_flow_method_types_from_definition() -> None: } # No definition → empty map, no error. assert run_declarative_flow_module._flow_method_types(SimpleNamespace()) == {} + + +CONVERSATIONAL_FLOW_YAML = """schema: crewai.flow/v1 +name: SupportFlow +conversational: + llm: gpt-4o-mini +methods: + handle_order: + description: Order status questions. + listen: order + do: + call: expression + expr: "'shipped'" +""" + + +def test_run_declarative_flow_refuses_a_conversational_flow( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + definition_path = tmp_path / "flow.yaml" + definition_path.write_text(CONVERSATIONAL_FLOW_YAML, encoding="utf-8") + + with pytest.raises(SystemExit): + run_declarative_flow_module.run_declarative_flow(str(definition_path)) + + err = capsys.readouterr().err + assert "has no chat loop yet" in err + assert "flow.chat()" in err + + +def test_run_declarative_flow_still_runs_a_disabled_conversational_flow( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + definition_path = tmp_path / "flow.yaml" + definition_path.write_text( + CONVERSATIONAL_FLOW_YAML.replace( + "conversational:\n llm: gpt-4o-mini", + "conversational:\n enabled: false", + ).replace(" listen: order\n", " start: true\n"), + encoding="utf-8", + ) + + run_declarative_flow_module.run_declarative_flow(str(definition_path)) + + assert capsys.readouterr().out == "shipped\n" diff --git a/lib/crewai/src/crewai/flow/skill.py b/lib/crewai/src/crewai/flow/skill.py index ad2862197..ca26113e8 100644 --- a/lib/crewai/src/crewai/flow/skill.py +++ b/lib/crewai/src/crewai/flow/skill.py @@ -26,6 +26,8 @@ SKIP_BY_MODEL: dict[str, str] = { "FlowConfigDefinition": "config", "FlowHumanFeedbackDefinition": "hitl", "FlowPersistenceDefinition": "persistence", + "FlowConversationalDefinition": "conversational", + "FlowConversationalRouterDefinition": "conversational", } FIELD_TYPE_OVERRIDES: dict[tuple[str, str], str] = { @@ -138,6 +140,8 @@ MODEL_TITLES = { "FlowConfigDefinition": "Config", "FlowPersistenceDefinition": "Persistence", "FlowHumanFeedbackDefinition": "Human Feedback", + "FlowConversationalDefinition": "Conversational", + "FlowConversationalRouterDefinition": "Conversational Router", } @@ -280,6 +284,35 @@ MODEL_SPECS: tuple[ModelSpec, ...] = ( "Human Feedback", "methods..human_feedback", ), + ModelSpec( + "FlowConversationalDefinition", + "Conversational", + "conversational", + descriptions={ + "enabled": "Whether conversational mode is active. Declaring the conversational block is the opt-in, so leave this out unless you are turning chat off with `false`.", + "llm": "Model id used by the built-in `converse` handler, and the router's fallback model.", + "system_prompt": "System message for the built-in `converse` handler. Omit for the framework default; use an empty string for none.", + "default_intents": "Outcome labels classified before routing. Requires `intent_llm`.", + "answer_from_history_llm": "Setting this enables the optional `answer_from_history` route, which answers from the transcript without invoking a route handler.", + "visible_agent_outputs": "Agent names whose recorded results are promoted to user-visible assistant messages, or `all`.", + "builtin_routes": "Route labels the framework handles itself. Do not add your own routes here; declare them as a method `listen` label.", + "internal_routes": "Route labels the framework handles that are excluded from the router's catalog.", + }, + examples=True, + ), + ModelSpec( + "FlowConversationalRouterDefinition", + "Conversational Router", + "conversational.router", + descriptions={ + "prompt": "Domain framing for the routing decision: persona, policy, voice. Do not list routes here; the catalog is built automatically.", + "routes": "Route labels the router may choose. Omit to infer them from the methods that declare a `listen` label.", + "route_descriptions": "Per-route text for the router's catalog. A method's `description` is used when a route has no entry here.", + "default_intent": "Route used when no routing model is configured or the call fails.", + "fallback_intent": "Route used when the model returns a label that is not in the catalog.", + "response_format": "Leave this out in a declaration. A declaration cannot carry a model class, so it is ignored with a warning and the framework synthesizes one.", + }, + ), ) _SPECS_BY_NAME: dict[str, ModelSpec] = {spec.name: spec for spec in MODEL_SPECS} diff --git a/lib/crewai/src/crewai/flow/templates/flow_definition_skill.md.j2 b/lib/crewai/src/crewai/flow/templates/flow_definition_skill.md.j2 index 987c252e2..5b270107d 100644 --- a/lib/crewai/src/crewai/flow/templates/flow_definition_skill.md.j2 +++ b/lib/crewai/src/crewai/flow/templates/flow_definition_skill.md.j2 @@ -148,6 +148,10 @@ Dynamic value rules: - Do not set `config.stream: true` unless the caller is expected to consume a streaming result. For normal generated flows and CLI smoke tests, omit it. {% if include_conversational %} - Do not put conversational settings under `state`, `config`, or a method. Use top-level `conversational` only when the user asks for a chat or conversation flow. +- Do not declare `route_conversation`, `converse_turn`, `end_conversation`, or `answer_from_history_turn` unless you are deliberately replacing one. Declaring the `conversational` block adds them for you, and a method you declare under one of those names overrides the built-in. +- Do not name a route handler the same as the route it listens to. Route labels and method names share one trigger namespace, so `create_video` listening to `create_video` is rejected. Use `handle_create_video`. +- Do not declare `state` for a chat flow unless it needs extra fields. The conversational state shape is supplied by default, and a custom state must extend it. +- Do describe every custom route, either with the handler's `description` or an entry in `conversational.router.route_descriptions`. That text is what the routing model reads to choose between routes; a route with neither is offered to it unlabelled. {% endif %} {% if include_each_action %} - Do not use `each` without at least one named step. diff --git a/lib/crewai/tests/test_flow_definition.py b/lib/crewai/tests/test_flow_definition.py index 8a714676d..13a5c7cca 100644 --- a/lib/crewai/tests/test_flow_definition.py +++ b/lib/crewai/tests/test_flow_definition.py @@ -5,6 +5,7 @@ import importlib import inspect import logging from pathlib import Path +import re from typing import Annotated, Literal import pytest @@ -1479,6 +1480,40 @@ def test_skill_documents_flow_wiring(): assert example["code"] in skill +def test_skill_renders_both_conversational_sections(): + """Both models must render; the router shares no section with its parent. + + Non-union sections render only their first model, so grouping them would + drop the router and leave the link to it without a target. + """ + skill = flow_definition.FlowDefinition.skill() + + assert "### Conversational (`conversational`)" in skill + assert "### Conversational Router (`conversational.router`)" in skill + + link = "[Conversational Router (`conversational.router`)]" + assert link in skill + anchor = re.search(re.escape(link) + r"\((#[^)]+)\)", skill).group(1) + assert anchor == "#conversational-router-conversationalrouter" + + +def test_skill_documents_every_conversational_field(): + skill = flow_definition.FlowDefinition.skill() + section = skill[skill.index("### Conversational (`conversational`)") :] + + for field in flow_definition.FlowConversationalDefinition.model_fields: + assert f"`{field}`" in section, field + for field in flow_definition.FlowConversationalRouterDefinition.model_fields: + assert f"`{field}`" in section, field + + +def test_skill_conversational_skip_suppresses_both_sections(): + skill = flow_definition.FlowDefinition.skill(skips=["conversational"]) + + assert "### Conversational" not in skill + assert "conversational-router" not in skill + + def test_skill_can_render_json_examples(): skill = flow_definition.FlowDefinition.skill(examples_format="json")