diff --git a/docs/ar/guides/flows/conversational-flows.mdx b/docs/ar/guides/flows/conversational-flows.mdx new file mode 100644 index 0000000000..aaf84b5912 --- /dev/null +++ b/docs/ar/guides/flows/conversational-flows.mdx @@ -0,0 +1,434 @@ +--- +title: تدفقات المحادثة +description: أنشئ تطبيقات دردشة متعددة الجولات مع kickoff لكل جولة وسجل الرسائل وتوجيه النية والتتبع وجسور WebSocket. +icon: comments +mode: "wide" +--- + +## نظرة عامة + +تعامل التطبيقات المحادثية مع كل سطر من المستخدم كـ **تشغيل flow جديد** بنفس **معرّف الجلسة**. توفر CrewAI مساعدات لسجل الرسائل وتصنيف النية الاختياري وتأجيل التتبع وجسور الواجهة — دون API منفصل `chat()` على `Flow`. + +| المفهوم | التنفيذ | +|---------|---------| +| معرّف الجلسة | `kickoff(session_id=...)` → `inputs["id"]` → `state.id` | +| سطر المستخدم | `kickoff(user_message=...)` يُضاف إلى `state.messages` قبل تشغيل الرسم | +| اكتمال الجولة | `FlowFinished` لهذا **التشغيل** فقط؛ تستمر المحادثة في `kickoff` التالي | +| تتبع الجلسة | `ConversationalConfig(defer_trace_finalization=True)` + `finalize_session_traces()` | + +## نقطة دخول واحدة: `kickoff` + +استخدم **`flow.kickoff(user_message=..., session_id=...)`** لكل رسالة مستخدم (REST أو WebSocket أو CLI). لا تنشئ غلاف `chat()` مخصصاً على `Flow`. + +| API | الاستخدام | +|-----|-----------| +| `kickoff(user_message=..., session_id=...)` | كل رسالة مستخدم | +| `kickoff_async(...)` | نفس المعاملات؛ دخول async أصلي | +| `ask()` | مطالبة حاجزة **داخل** خطوة واحدة | +| `@human_feedback` | الموافقة/الرفض على **مخرجات خطوة** — وليس السطر التالي | +| `ChatSession.handle_turn(...)` | طبقة نقل فوق `kickoff` | + +## بداية سريعة + +```python +from uuid import uuid4 + +from crewai.flow import ( + ChatState, + ConversationalConfig, + Flow, + listen, + or_, + persist, + router, + start, +) +from crewai.flow.persistence import SQLiteFlowPersistence + + +class SupportFlow(Flow[ChatState]): + conversational_config = ConversationalConfig( + default_intents=["order", "help", "goodbye"], + intent_llm="gpt-4o-mini", + defer_trace_finalization=True, + ) + + @start() + def bootstrap(self): + if not self.state.session_ready: + self.state.session_ready = True + return "ready" + + @router(bootstrap) + def route(self): + return self.state.last_intent or "help" + + @listen("order") + def handle_order(self): + reply = "طلبك في الطريق." + self.append_message("assistant", reply) + return reply + + @listen("help") + def handle_help(self): + reply = "كيف يمكنني المساعدة؟" + self.append_message("assistant", reply) + return reply + + @listen("goodbye") + def handle_goodbye(self): + reply = "وداعاً!" + self.append_message("assistant", reply) + return reply + + @persist(SQLiteFlowPersistence("support.db")) + @listen(or_(handle_order, handle_help, handle_goodbye)) + def finalize(self): + return self.state.model_dump() + + +session_id = str(uuid4()) +flow = SupportFlow() + +flow.kickoff(user_message="أين طلبي؟", session_id=session_id) +flow.kickoff(user_message="وماذا عن الإرجاع؟", session_id=session_id) +flow.finalize_session_traces() +``` + +## دورة حياة الجولة + +كل `kickoff` مع `user_message` يشغّل: + +1. **`_configure_conversational_kickoff`** — دمج `session_id` / `user_message` في `inputs` وتطبيق `ConversationalConfig`. +2. **استعادة الحالة** — عند وجود `inputs["id"]` و`@persist`. +3. **`FlowStarted`** — في أول جولة للجلسة المؤجلة فقط. +4. **`prepare_conversational_turn`** — إضافة رسالة المستخدم و`last_user_message` وتصنيف اختياري. +5. **تنفيذ الرسم** — `@start` → `@router` → معالجات `@listen`. +6. **نهاية التشغيل** — يُتخطى `flow_finished` والتتبع لكل جولة عند التأجيل؛ `Agent.kickoff()` / crews لا تغلق دفعة الأب. + +استدعِ **`append_message("assistant", reply)`** في المعالجات. سطر المستخدم محفوظ عند kickoff — لا تُضفه مرة أخرى. + +## `ConversationalConfig` (افتراضيات على مستوى الصنف) + +عيّن على صنف `Flow` كـ `conversational_config: ClassVar[ConversationalConfig | None]`. + +| الحقل | الافتراضي | الغرض | +|-------|-----------|--------| +| `default_intents` | `None` | تسميات outcome للتصنيف التلقائي قبل kickoff | +| `intent_llm` | `None` | نموذج التصنيف (مطلوب عند وجود intents) | +| `interactive_prompt` | `"You: "` | مطالبة `kickoff(interactive=True)` | +| `interactive_timeout` | `None` | مهلة لكل سطر في الوضع التفاعلي | +| `exit_commands` | `exit`, `quit` | كلمات إنهاء الوضع التفاعلي | +| `defer_trace_finalization` | `True` | إبقاء دفعة trace واحدة مفتوحة بين الجولات | + +يمكن التجاوز لكل kickoff عبر `intents=` و`intent_llm=`. + +## `ChatState` (شكل الحالة الموصى به للحفظ) + +```python +from crewai.flow import ChatState + + +class MyChatState(ChatState): + # موروث: id, messages, last_user_message, last_intent, session_ready + research_turn_count: int = 0 + custom_flag: bool = False +``` + +| الحقل | الدور | +|-------|------| +| `id` | UUID الجلسة (مثل `session_id` / `inputs["id"]`) | +| `messages` | قائمة `{role, content}` لسجل LLM | +| `last_user_message` | آخر سطر مستخدم في هذه الجولة | +| `last_intent` | تسمية المسار بعد التصنيف (إن وُجد) | +| `session_ready` | علم bootstrap لمرة واحدة | + +`ConversationalInputs` هو `TypedDict` لـ `kickoff(inputs={...})`: `id`, `user_message`, `last_intent`. + +## API المحادثة على `Flow` + +### معاملات `kickoff` / `kickoff_async` + +| المعامل | الغرض | +|---------|--------| +| `user_message` | نص هذه الجولة (أو `{"role": "user", "content": "..."}`) | +| `session_id` | UUID المحادثة → `inputs["id"]` / `state.id` | +| `intents` | تسميات outcome لـ `classify_intent` قبل kickoff | +| `intent_llm` | LLM للتصنيف (مطلوب مع `intents`) | +| `interactive` | حلقة CLI عبر `ask()` (للعروض المحلية فقط) | +| `interactive_prompt` | مطالبة الوضع التفاعلي | +| `interactive_timeout` | مهلة `ask()` لكل سطر | +| `exit_commands` | كلمات إنهاء الوضع التفاعلي | +| `inputs` | حقول حالة إضافية | +| `restore_from_state_id` | استنساخ من flow محفوظ آخر | + +### سمات المثيل + +| السمة | الغرض | +|-------|--------| +| `conversational_config` | افتراضيات `ConversationalConfig` على مستوى الصنف | +| `defer_trace_finalization` | علم المثيل؛ يُضبط تلقائياً من config عند kickoff | +| `suppress_flow_events` | يخفي لوحات console؛ **التتبع يُسجّل** | +| `stream` | بث؛ مع `ChatSession.handle_turn(..., stream=True)` | + +### طرق وخصائص + +| الاسم | الوصف | +|------|--------| +| `append_message(role, content, **extra)` | إضافة إلى `state.messages` | +| `conversation_messages` | سجل للقراءة فقط لاستدعاءات LLM | +| `classify_intent(text, outcomes, *, llm, context=None)` | تعيين outcome | +| `receive_user_message(text, *, outcomes=None, llm=None)` | إضافة رسالة مستخدم؛ `last_intent` اختياري | +| `finalize_session_traces()` | إصدار `flow_finished` المؤجل وإنهاء دفعة trace | +| `_should_defer_trace_finalization()` | هل يُؤجل إنهاء trace لكل جولة | +| `input_history` | سجل تدقيق مطالبات وردود `ask()` | + +### مساعدات الوحدة (`crewai.flow.conversation`) + +| الدالة | الوصف | +|--------|--------| +| `normalize_kickoff_inputs(...)` | دمج kwargs المحادثة في `inputs` | +| `get_conversation_messages(flow)` | قراءة الرسائل من الحالة أو المخزن | +| `append_message(flow, ...)` | مثل طريقة المثيل | +| `prepare_conversational_turn(flow, ...)` | تهيئة الجولة (عادةً kickoff يستدعيها) | +| `receive_user_message(flow, ...)` | مثل طريقة المثيل | +| `set_state_field(flow, name, value)` | تعيين حقل dict أو Pydantic | +| `get_conversational_config(flow)` | قراءة `conversational_config` | +| `input_history_to_messages(entries)` | تحويل `input_history` لصيغة رسائل LLM | + +## أنماط توجيه النية + +### أ. تصنيف مسبق عبر `ConversationalConfig` (الأبسط) + +عيّن `default_intents` و`intent_llm`. كل kickoff يصنّف قبل `@router`؛ اقرأ `self.state.last_intent` في `route()`. + +### ب. تصنيف داخل `@router` (مطالبات أغنى) + +عيّن `default_intents=None` ليضيف kickoff الرسالة فقط. في `route()` استدعِ `classify_intent`: + +```python +@router(bootstrap) +def route(self): + intent = self.classify_intent( + self._routing_prompt(self.state.last_user_message), + ("GREETING", "ORDER", "RESEARCH", "GOODBYE"), + llm=self.conversational_config.intent_llm or "gpt-4o-mini", + ) + self.state.last_intent = intent + return intent +``` + +للبحث على الويب أو أدوات متعددة الخطوات استخدم **`@listen("RESEARCH")`** مع `Agent.kickoff()` وأدوات — وليس `LLM.call()` فقط. + +## عندما ينتهي الـ flow ويستمر المستخدم + +`FlowFinished` يعني أن **تنفيذ الرسم هذا** اكتمل. تستمر المحادثة بـ `kickoff` آخر ونفس `session_id`. `@persist` يستعيد `messages` والأعلام والسياق. + +**نمط الحفظ:** يُفضّل `@persist` على **خطوة نهائية واحدة** (مثل `finalize`) وليس على صنف `Flow` بالكامل. الحفظ على مستوى الصنف بعد كل method قد يفقد تحديثات المعالجات في نفس الجولة. + +لا تستخدم `@human_feedback` لأسطر المتابعة في الدردشة إلا عند الحاجة لموافقة بشرية على مخرجات خطوة محددة. + +## `ConversationalFlow` عالي المستوى (تجريبي) + +`crewai.experimental.ConversationalFlow` هو صنف فرعي ذو رأي يتولى السباكة لكل جولة نيابةً عنك: يأتي مع رسم `@start` / `@router` / `converse_turn` / `end_conversation` مدمج، ويدير `state.messages`، ويُشغّل LLM التوجيه، ويبقي دفعة trace مفتوحة عبر الجولات. أنت تكتب **المسارات المخصصة** فقط؛ والإطار يتولى الباقي. + +استخدمه عندما تريد دردشة متعددة الجولات مع موجّه قائم على LLM ومعالجات لكل مسار دون توصيل دورة الحياة يدوياً. انزل إلى `Flow[ChatState]` (في الأعلى) عندما تحتاج تحكماً كاملاً. + +### مثال سريع + +```python +from crewai import LLM +from crewai.experimental import ConversationConfig, ConversationalFlow, RouterConfig +from crewai.flow import listen + + +ROUTER_LLM = LLM(model="gpt-4o-mini") + + +@ConversationConfig( + system_prompt="A multi-agent assistant for ordinary chat and tool-backed tasks.", + llm=ROUTER_LLM, + router=RouterConfig(), # المسارات + الأوصاف تُكتشف تلقائياً من معالجات @listen +) +class SupportFlow(ConversationalFlow): + @listen("INTERNET_SEARCH") + def handle_internet_search(self) -> str: + """Fresh web research, current news, real-time lookups.""" + ... + self.append_assistant_message(reply) + return reply + + @listen("CREWAI_DOCS") + def handle_crewai_docs(self) -> str: + """Look up the CrewAI documentation for framework/API questions.""" + ... + self.append_assistant_message(reply) + return reply + + +flow = SupportFlow() +try: + flow.handle_turn("ماذا يمكنك أن تفعل؟") # يوجَّه إلى converse (مدمج) + flow.handle_turn("ابحث في الويب عن أخبار الذكاء الاصطناعي.") # يوجَّه إلى INTERNET_SEARCH + flow.handle_turn("لخص النتيجة الأولى.") # يعود إلى converse +finally: + flow.finalize_session_traces() +``` + +### `ConversationConfig` + +مزخرف صنف يُلحق افتراضيات الدردشة على مستوى الصنف. + +| الحقل | الافتراضي | الغرض | +|-------|-----------|-------| +| `system_prompt` | `slices.conversational_system_prompt` من i18n | رسالة system يستخدمها `converse_turn` المدمج. مرر `""` للتعطيل التام. | +| `llm` | `None` | LLM المحادثة (يستخدمه `converse_turn` وكاحتياطي للموجّه). | +| `router` | `None` | `RouterConfig` للتوجيه عبر LLM. بدونه، يسقط الـ flow دائماً إلى `converse`. | +| `answer_from_history_prompt` | افتراضي الإطار | رسالة system للمسار الاختياري `answer_from_history`. | +| `answer_from_history_llm` | `None` | يُفعّل الاختصار `answer_from_history` عند تعيينه. | +| `intent_llm` | `None` | LLM لمسار التصنيف المسبق القديم `intents=`/`default_intents`. | +| `default_intents` | `None` | تسميات النتائج للتصنيف المسبق القديم. | +| `visible_agent_outputs` | `None` | `"all"` أو قائمة بأسماء الـ agents الذين تُرفع مخرجاتهم من `append_agent_result()` إلى رسائل عامة. | +| `defer_trace_finalization` | `True` | يبقي دفعة trace واحدة مفتوحة عبر استدعاءات `handle_turn()`. | + +### `RouterConfig` وفهرس المسارات المُولَّد تلقائياً + +```python +RouterConfig( + prompt="تأطير اختياري للنطاق (سياسة، صوت، شخصية).", + response_format=MyRoute, # اختياري؛ يُولَّد تلقائياً عند الإغفال + llm=ROUTER_LLM, # يسقط إلى ConversationConfig.llm + routes=["INTERNET_SEARCH", "CREWAI_DOCS"], # اختياري؛ يُستنتج من المستمعين + route_descriptions={ + "INTERNET_SEARCH": "تجاوز الـ docstring لهذا المسار فقط.", + }, + default_intent="converse", # يُستخدم عند فشل LLM أو غيابه + fallback_intent="converse", # يُستخدم عندما يعيد LLM مساراً غير صالح + intent_field="intent", +) +``` + +تُبنى رسالة الموجّه إلى LLM تلقائياً. لكل مسار يختار الإطار وصفاً بهذا الترتيب من الأولوية: + +1. `RouterConfig.route_descriptions[label]` — تجاوز صريح. +2. `ConversationalFlow.builtin_route_descriptions[label]` — نص جاهز من الإطار لـ `converse` و`end` و`answer_from_history` (مصاغ لـ LLM التوجيه). +3. أول سطر غير فارغ من docstring معالج `@listen(label)`. +4. فارغ (المسار يظهر في الفهرس بلا وصف). + +عملياً، **إضافة مسار جديد = `@listen("X")` + docstring من سطر واحد**: + +```python +@listen("INTERNET_SEARCH") +def handle_internet_search(self) -> str: + """Fresh web research, current news, real-time lookups.""" + ... +``` + +…وسيرى LLM التوجيه: + +``` +Routes: +- CREWAI_DOCS: Look up the CrewAI documentation for framework/API questions. +- INTERNET_SEARCH: Fresh web research, current news, real-time lookups. +- converse: Ordinary chat, follow-ups, summaries, clarifications… +- end: User signals the conversation is finished (goodbye, exit, done). +``` + +`RouterConfig.prompt` مخصص لـ **تأطير النطاق** (شخصية المساعد، قواعد العمل، النبرة). فهرس المسارات يُبنى تلقائياً — لا تُدرج المسارات في `prompt`؛ سيختل التزامن لحظة إضافة معالج جديد. + +### المسارات المدمجة + +| المسار | المعالج | الغرض | +|--------|---------|-------| +| `converse` | `converse_turn` | معالج الدردشة الافتراضي. يستدعي `ConversationConfig.llm` بـ system prompt + التاريخ القانوني للرسائل. | +| `end` | `end_conversation` | يضبط `state.ended = True` ويُصدر رد إنهاء. | +| `answer_from_history` | `answer_from_history_turn` | اختياري. يُوجَّه إليه عندما يكون `ConversationConfig.answer_from_history_llm` مُعيَّناً ويمكن الإجابة على الرسالة من التاريخ فقط. | + +يمكنك تجاوز أي من هذه بتعريف معالج بنفس الاسم في الصنف الفرعي. + +### دلالات `handle_turn()` + +`flow.handle_turn(message)` يُشغّل جولة واحدة: + +1. يعيد ضبط تعقّب التنفيذ لكل جولة (`_completed_methods`, `_method_outputs`) ليُعاد تشغيل الرسم — بدون ذلك، استدعاءات `kickoff` المتكررة على نفس النسخة ستُحدث دائرة قصر من الجولة الثانية لأن `Flow.kickoff_async` يعتبر `inputs={"id": ...}` استعادة من نقطة تفتيش. +2. يُلحق رسالة المستخدم بـ `state.messages` ويضبط `current_user_message` / `last_user_message`. يُحافَظ على `last_intent` **من الجولة السابقة** كي يستخدمها LLM التوجيه كإشارة. +3. يُشغّل `conversation_start` → `route_conversation` → معالج `@listen` المختار. +4. يخزّن الموجّه قراره في `state.last_intent` (يكون مرئياً لسياق التوجيه في الجولة التالية). +5. إذا أعاد معالجك سلسلة نصية ولم يستدعِ `append_assistant_message`، فإن `handle_turn` يُلحقها نيابةً عنك. + +يمكنك أيضاً استدعاء `flow.kickoff(user_message=..., session_id=...)` مباشرةً — نفس منطق الإعادة والتشغيل يعمل. `handle_turn` هو الغلاف المريح. + +### سلوك موجّه مخصص + +لتشغيل آثار جانبية (إعداد ناقل أحداث، قياس عن بُعد) في كل قرار توجيه، تجاوز `route_turn`: + +```python +class SupportFlow(ConversationalFlow): + def route_turn(self, context: dict[str, Any]) -> str | None: + self.event_bus = MyBus(self) + return super().route_turn(context) +``` + +لتجاوز موجّه LLM واختيار مسار برمجياً، أعد سلسلة نصية من `route_turn`؛ إعادة `None` تسقط إلى `_route_with_config(...)`. + +### `append_assistant_message` و`append_agent_result` + +داخل معالج `@listen(label)`، اختر: + +- `self.append_assistant_message(text)` — يضيف جولة مساعد مرئية للمستخدم إلى `state.messages`. سيراها `converse_turn` في الجولة التالية. +- `self.append_agent_result(agent_name, result, visibility="private")` — يسجّل حدثاً منظماً في `state.events` وموضوعاً في `state.agent_threads[agent_name]`. الرؤية العامة تستدعي `append_assistant_message` أيضاً. استخدم النتائج الخاصة للعمل الجانبي الذي يجب ألا يلوث التاريخ القانوني. + +يمكن لـ `ConversationConfig.visible_agent_outputs` رفع النتائج الخاصة لـ agents محددين إلى عامة عالمياً (`"all"` أو قائمة بالأسماء). + +## التتبع عبر الجولات + +مع `defer_trace_finalization=True` (افتراضي في `ConversationalConfig`): + +- **دفعة trace واحدة** لجلسة الدردشة. +- **`flow_started`** في الجولة الأولى فقط؛ **`flow_finished`** مرة في `finalize_session_traces()`. +- **`kickoff` لكل جولة** لا يطبع "Trace batch finalized". +- **العمل المتداخل** (`Agent.kickoff()`, crews, Exa) يُلحق بدفعة **الأب**؛ flow داخلي من `AgentExecutor` لا يغلق دفعة الجلسة مبكراً. + +```python +try: + while True: + line = input("You: ").strip() + if not line: + break + flow.kickoff(user_message=line, session_id=session_id) +finally: + flow.finalize_session_traces() +``` + +`ChatSession.close()` يستدعي `finalize_session_traces()` عند التأجيل. + +`suppress_flow_events=True` يخفي لوحات Rich فقط؛ أحداث trace والـ methods تُصدر. + +### دورة حياة trace لـ `ConversationalFlow` + +تستخدم [`ConversationalFlow`](#conversationalflow-عالي-المستوى-تجريبي) التجريبية نفس دورة حياة tracing: `defer_trace_finalization` افتراضياً `True`، فيبقي كل `handle_turn()` أثر الجلسة مفتوحاً. أنهِ دوماً عند نهاية الجلسة — لُف حلقتك بـ `try/finally` واستدعِ `flow.finalize_session_traces()` عند الخروج. بدون ذلك، تبقى الدفعة مفتوحة وقد لا تُصدَّر آخر محادثة أبداً. + +## البث + +اضبط `stream = True` على صنف `Flow`. عندئذٍ يُصدر `kickoff(...)` أحداث `assistant_delta` (وما يرتبط بها) عبر ناقل الأحداث القياسي. + +## الاستيراد + +```python +from crewai.flow import ( + ChatState, + ConversationalConfig, + ConversationalInputs, + Flow, + listen, + persist, + router, + start, +) +``` + +## مراجع + +- [إتقان إدارة حالة Flow](/ar/guides/flows/mastering-flow-state) +- [أنشئ أول Flow](/ar/guides/flows/first-flow) +- Demo: `lib/crewai/runner_conversational_flow_simple.py` — REPL بسيط مع `RESEARCH` ووكيل Exa diff --git a/docs/ar/guides/flows/first-flow.mdx b/docs/ar/guides/flows/first-flow.mdx index d4ae8f8981..ffeb30c2d2 100644 --- a/docs/ar/guides/flows/first-flow.mdx +++ b/docs/ar/guides/flows/first-flow.mdx @@ -272,6 +272,7 @@ crewai flow plot 3. استكشف دوال `and_` و`or_` لتنفيذ متوازٍ أكثر تعقيدًا 4. اربط Flow بواجهات API خارجية وقواعد بيانات وواجهات مستخدم 5. ادمج عدة Crews متخصصة في Flow واحد +6. أنشئ تطبيقات دردشة متعددة الجولات مع [تدفقات المحادثة](/ar/guides/flows/conversational-flows) (`kickoff` لكل رسالة، `ChatSession`، تأجيل التتبع) تهانينا! لقد بنيت بنجاح أول CrewAI Flow يجمع بين الكود العادي واستدعاءات LLM المباشرة ومعالجة Crew لإنشاء دليل شامل. هذه المهارات الأساسية تمكّنك من إنشاء تطبيقات AI متطورة بشكل متزايد. diff --git a/docs/ar/guides/flows/mastering-flow-state.mdx b/docs/ar/guides/flows/mastering-flow-state.mdx index 09e56c3df4..2311a44627 100644 --- a/docs/ar/guides/flows/mastering-flow-state.mdx +++ b/docs/ar/guides/flows/mastering-flow-state.mdx @@ -20,6 +20,8 @@ mode: "wide" 5. **توسيع تطبيقاتك** - دعم سير العمل المعقدة بتنظيم بيانات مناسب 6. **تمكين التطبيقات الحوارية** - تخزين والوصول إلى سجل المحادثات للتفاعلات الواعية بالسياق +للدردشة متعددة الجولات (`kickoff` لكل سطر مستخدم، `ChatState`، توجيه النية، تأجيل التتبع، و`ChatSession`)، راجع [تدفقات المحادثة](/ar/guides/flows/conversational-flows). + ## أساسيات إدارة الحالة ### نهجان لإدارة الحالة diff --git a/docs/docs.json b/docs/docs.json index bcbcefe0e0..d79eeabf05 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -115,6 +115,7 @@ "pages": [ "en/guides/flows/first-flow", "en/guides/flows/mastering-flow-state", + "en/guides/flows/conversational-flows", "en/guides/flows/inputs-id-deprecation" ] }, @@ -1149,6 +1150,7 @@ "pages": [ "en/guides/flows/first-flow", "en/guides/flows/mastering-flow-state", + "en/guides/flows/conversational-flows", "en/guides/flows/inputs-id-deprecation" ] }, @@ -1633,6 +1635,7 @@ "pages": [ "en/guides/flows/first-flow", "en/guides/flows/mastering-flow-state", + "en/guides/flows/conversational-flows", "en/guides/flows/inputs-id-deprecation" ] }, @@ -2116,6 +2119,7 @@ "pages": [ "en/guides/flows/first-flow", "en/guides/flows/mastering-flow-state", + "en/guides/flows/conversational-flows", "en/guides/flows/inputs-id-deprecation" ] }, @@ -2598,6 +2602,7 @@ "pages": [ "en/guides/flows/first-flow", "en/guides/flows/mastering-flow-state", + "en/guides/flows/conversational-flows", "en/guides/flows/inputs-id-deprecation" ] }, @@ -3091,6 +3096,7 @@ "pages": [ "en/guides/flows/first-flow", "en/guides/flows/mastering-flow-state", + "en/guides/flows/conversational-flows", "en/guides/flows/inputs-id-deprecation" ] }, @@ -3584,6 +3590,7 @@ "pages": [ "en/guides/flows/first-flow", "en/guides/flows/mastering-flow-state", + "en/guides/flows/conversational-flows", "en/guides/flows/inputs-id-deprecation" ] }, @@ -4077,6 +4084,7 @@ "pages": [ "en/guides/flows/first-flow", "en/guides/flows/mastering-flow-state", + "en/guides/flows/conversational-flows", "en/guides/flows/inputs-id-deprecation" ] }, @@ -4570,6 +4578,7 @@ "pages": [ "en/guides/flows/first-flow", "en/guides/flows/mastering-flow-state", + "en/guides/flows/conversational-flows", "en/guides/flows/inputs-id-deprecation" ] }, @@ -5052,6 +5061,7 @@ "pages": [ "en/guides/flows/first-flow", "en/guides/flows/mastering-flow-state", + "en/guides/flows/conversational-flows", "en/guides/flows/inputs-id-deprecation" ] }, @@ -5534,6 +5544,7 @@ "pages": [ "en/guides/flows/first-flow", "en/guides/flows/mastering-flow-state", + "en/guides/flows/conversational-flows", "en/guides/flows/inputs-id-deprecation" ] }, @@ -6017,6 +6028,7 @@ "pages": [ "en/guides/flows/first-flow", "en/guides/flows/mastering-flow-state", + "en/guides/flows/conversational-flows", "en/guides/flows/inputs-id-deprecation" ] }, @@ -6501,6 +6513,7 @@ "pages": [ "en/guides/flows/first-flow", "en/guides/flows/mastering-flow-state", + "en/guides/flows/conversational-flows", "en/guides/flows/inputs-id-deprecation" ] }, @@ -6983,6 +6996,7 @@ "pages": [ "en/guides/flows/first-flow", "en/guides/flows/mastering-flow-state", + "en/guides/flows/conversational-flows", "en/guides/flows/inputs-id-deprecation" ] }, @@ -7498,6 +7512,7 @@ "pages": [ "pt-BR/guides/flows/first-flow", "pt-BR/guides/flows/mastering-flow-state", + "pt-BR/guides/flows/conversational-flows", "pt-BR/guides/flows/inputs-id-deprecation" ] }, @@ -8486,6 +8501,7 @@ "pages": [ "pt-BR/guides/flows/first-flow", "pt-BR/guides/flows/mastering-flow-state", + "pt-BR/guides/flows/conversational-flows", "pt-BR/guides/flows/inputs-id-deprecation" ] }, @@ -8947,6 +8963,7 @@ "pages": [ "pt-BR/guides/flows/first-flow", "pt-BR/guides/flows/mastering-flow-state", + "pt-BR/guides/flows/conversational-flows", "pt-BR/guides/flows/inputs-id-deprecation" ] }, @@ -9408,6 +9425,7 @@ "pages": [ "pt-BR/guides/flows/first-flow", "pt-BR/guides/flows/mastering-flow-state", + "pt-BR/guides/flows/conversational-flows", "pt-BR/guides/flows/inputs-id-deprecation" ] }, @@ -9868,6 +9886,7 @@ "pages": [ "pt-BR/guides/flows/first-flow", "pt-BR/guides/flows/mastering-flow-state", + "pt-BR/guides/flows/conversational-flows", "pt-BR/guides/flows/inputs-id-deprecation" ] }, @@ -10338,6 +10357,7 @@ "pages": [ "pt-BR/guides/flows/first-flow", "pt-BR/guides/flows/mastering-flow-state", + "pt-BR/guides/flows/conversational-flows", "pt-BR/guides/flows/inputs-id-deprecation" ] }, @@ -10808,6 +10828,7 @@ "pages": [ "pt-BR/guides/flows/first-flow", "pt-BR/guides/flows/mastering-flow-state", + "pt-BR/guides/flows/conversational-flows", "pt-BR/guides/flows/inputs-id-deprecation" ] }, @@ -11278,6 +11299,7 @@ "pages": [ "pt-BR/guides/flows/first-flow", "pt-BR/guides/flows/mastering-flow-state", + "pt-BR/guides/flows/conversational-flows", "pt-BR/guides/flows/inputs-id-deprecation" ] }, @@ -11748,6 +11770,7 @@ "pages": [ "pt-BR/guides/flows/first-flow", "pt-BR/guides/flows/mastering-flow-state", + "pt-BR/guides/flows/conversational-flows", "pt-BR/guides/flows/inputs-id-deprecation" ] }, @@ -12208,6 +12231,7 @@ "pages": [ "pt-BR/guides/flows/first-flow", "pt-BR/guides/flows/mastering-flow-state", + "pt-BR/guides/flows/conversational-flows", "pt-BR/guides/flows/inputs-id-deprecation" ] }, @@ -12668,6 +12692,7 @@ "pages": [ "pt-BR/guides/flows/first-flow", "pt-BR/guides/flows/mastering-flow-state", + "pt-BR/guides/flows/conversational-flows", "pt-BR/guides/flows/inputs-id-deprecation" ] }, @@ -13128,6 +13153,7 @@ "pages": [ "pt-BR/guides/flows/first-flow", "pt-BR/guides/flows/mastering-flow-state", + "pt-BR/guides/flows/conversational-flows", "pt-BR/guides/flows/inputs-id-deprecation" ] }, @@ -13587,6 +13613,7 @@ "pages": [ "pt-BR/guides/flows/first-flow", "pt-BR/guides/flows/mastering-flow-state", + "pt-BR/guides/flows/conversational-flows", "pt-BR/guides/flows/inputs-id-deprecation" ] }, @@ -14046,6 +14073,7 @@ "pages": [ "pt-BR/guides/flows/first-flow", "pt-BR/guides/flows/mastering-flow-state", + "pt-BR/guides/flows/conversational-flows", "pt-BR/guides/flows/inputs-id-deprecation" ] }, @@ -14536,6 +14564,7 @@ "pages": [ "ko/guides/flows/first-flow", "ko/guides/flows/mastering-flow-state", + "ko/guides/flows/conversational-flows", "ko/guides/flows/inputs-id-deprecation" ] }, @@ -15548,6 +15577,7 @@ "pages": [ "ko/guides/flows/first-flow", "ko/guides/flows/mastering-flow-state", + "ko/guides/flows/conversational-flows", "ko/guides/flows/inputs-id-deprecation" ] }, @@ -16021,6 +16051,7 @@ "pages": [ "ko/guides/flows/first-flow", "ko/guides/flows/mastering-flow-state", + "ko/guides/flows/conversational-flows", "ko/guides/flows/inputs-id-deprecation" ] }, @@ -16494,6 +16525,7 @@ "pages": [ "ko/guides/flows/first-flow", "ko/guides/flows/mastering-flow-state", + "ko/guides/flows/conversational-flows", "ko/guides/flows/inputs-id-deprecation" ] }, @@ -16967,6 +16999,7 @@ "pages": [ "ko/guides/flows/first-flow", "ko/guides/flows/mastering-flow-state", + "ko/guides/flows/conversational-flows", "ko/guides/flows/inputs-id-deprecation" ] }, @@ -17450,6 +17483,7 @@ "pages": [ "ko/guides/flows/first-flow", "ko/guides/flows/mastering-flow-state", + "ko/guides/flows/conversational-flows", "ko/guides/flows/inputs-id-deprecation" ] }, @@ -17933,6 +17967,7 @@ "pages": [ "ko/guides/flows/first-flow", "ko/guides/flows/mastering-flow-state", + "ko/guides/flows/conversational-flows", "ko/guides/flows/inputs-id-deprecation" ] }, @@ -18416,6 +18451,7 @@ "pages": [ "ko/guides/flows/first-flow", "ko/guides/flows/mastering-flow-state", + "ko/guides/flows/conversational-flows", "ko/guides/flows/inputs-id-deprecation" ] }, @@ -18899,6 +18935,7 @@ "pages": [ "ko/guides/flows/first-flow", "ko/guides/flows/mastering-flow-state", + "ko/guides/flows/conversational-flows", "ko/guides/flows/inputs-id-deprecation" ] }, @@ -19372,6 +19409,7 @@ "pages": [ "ko/guides/flows/first-flow", "ko/guides/flows/mastering-flow-state", + "ko/guides/flows/conversational-flows", "ko/guides/flows/inputs-id-deprecation" ] }, @@ -19845,6 +19883,7 @@ "pages": [ "ko/guides/flows/first-flow", "ko/guides/flows/mastering-flow-state", + "ko/guides/flows/conversational-flows", "ko/guides/flows/inputs-id-deprecation" ] }, @@ -20318,6 +20357,7 @@ "pages": [ "ko/guides/flows/first-flow", "ko/guides/flows/mastering-flow-state", + "ko/guides/flows/conversational-flows", "ko/guides/flows/inputs-id-deprecation" ] }, @@ -20790,6 +20830,7 @@ "pages": [ "ko/guides/flows/first-flow", "ko/guides/flows/mastering-flow-state", + "ko/guides/flows/conversational-flows", "ko/guides/flows/inputs-id-deprecation" ] }, @@ -21262,6 +21303,7 @@ "pages": [ "ko/guides/flows/first-flow", "ko/guides/flows/mastering-flow-state", + "ko/guides/flows/conversational-flows", "ko/guides/flows/inputs-id-deprecation" ] }, @@ -21765,6 +21807,7 @@ "pages": [ "ar/guides/flows/first-flow", "ar/guides/flows/mastering-flow-state", + "ar/guides/flows/conversational-flows", "ar/guides/flows/inputs-id-deprecation" ] }, @@ -22777,6 +22820,7 @@ "pages": [ "ar/guides/flows/first-flow", "ar/guides/flows/mastering-flow-state", + "ar/guides/flows/conversational-flows", "ar/guides/flows/inputs-id-deprecation" ] }, @@ -23250,6 +23294,7 @@ "pages": [ "ar/guides/flows/first-flow", "ar/guides/flows/mastering-flow-state", + "ar/guides/flows/conversational-flows", "ar/guides/flows/inputs-id-deprecation" ] }, @@ -23723,6 +23768,7 @@ "pages": [ "ar/guides/flows/first-flow", "ar/guides/flows/mastering-flow-state", + "ar/guides/flows/conversational-flows", "ar/guides/flows/inputs-id-deprecation" ] }, @@ -24196,6 +24242,7 @@ "pages": [ "ar/guides/flows/first-flow", "ar/guides/flows/mastering-flow-state", + "ar/guides/flows/conversational-flows", "ar/guides/flows/inputs-id-deprecation" ] }, @@ -24679,6 +24726,7 @@ "pages": [ "ar/guides/flows/first-flow", "ar/guides/flows/mastering-flow-state", + "ar/guides/flows/conversational-flows", "ar/guides/flows/inputs-id-deprecation" ] }, @@ -25162,6 +25210,7 @@ "pages": [ "ar/guides/flows/first-flow", "ar/guides/flows/mastering-flow-state", + "ar/guides/flows/conversational-flows", "ar/guides/flows/inputs-id-deprecation" ] }, @@ -25645,6 +25694,7 @@ "pages": [ "ar/guides/flows/first-flow", "ar/guides/flows/mastering-flow-state", + "ar/guides/flows/conversational-flows", "ar/guides/flows/inputs-id-deprecation" ] }, @@ -26128,6 +26178,7 @@ "pages": [ "ar/guides/flows/first-flow", "ar/guides/flows/mastering-flow-state", + "ar/guides/flows/conversational-flows", "ar/guides/flows/inputs-id-deprecation" ] }, @@ -26601,6 +26652,7 @@ "pages": [ "ar/guides/flows/first-flow", "ar/guides/flows/mastering-flow-state", + "ar/guides/flows/conversational-flows", "ar/guides/flows/inputs-id-deprecation" ] }, @@ -27074,6 +27126,7 @@ "pages": [ "ar/guides/flows/first-flow", "ar/guides/flows/mastering-flow-state", + "ar/guides/flows/conversational-flows", "ar/guides/flows/inputs-id-deprecation" ] }, @@ -27547,6 +27600,7 @@ "pages": [ "ar/guides/flows/first-flow", "ar/guides/flows/mastering-flow-state", + "ar/guides/flows/conversational-flows", "ar/guides/flows/inputs-id-deprecation" ] }, @@ -28019,6 +28073,7 @@ "pages": [ "ar/guides/flows/first-flow", "ar/guides/flows/mastering-flow-state", + "ar/guides/flows/conversational-flows", "ar/guides/flows/inputs-id-deprecation" ] }, @@ -28491,6 +28546,7 @@ "pages": [ "ar/guides/flows/first-flow", "ar/guides/flows/mastering-flow-state", + "ar/guides/flows/conversational-flows", "ar/guides/flows/inputs-id-deprecation" ] }, diff --git a/docs/en/guides/flows/conversational-flows.mdx b/docs/en/guides/flows/conversational-flows.mdx new file mode 100644 index 0000000000..2aa51195b7 --- /dev/null +++ b/docs/en/guides/flows/conversational-flows.mdx @@ -0,0 +1,437 @@ +--- +title: Conversational Flows +description: Build multi-turn chat apps with kickoff per turn, message history, intent routing, tracing, and WebSocket bridges. +icon: comments +mode: "wide" +--- + +## Overview + +Conversational apps treat each user line as a **new flow run** with the **same session id**. CrewAI adds helpers for message history, optional intent classification, deferred tracing, and UI bridges — without a separate `chat()` API on `Flow`. + +| Concept | Implementation | +|---------|----------------| +| Session id | `kickoff(session_id=...)` → `inputs["id"]` → `state.id` | +| User line | `kickoff(user_message=...)` appends to `state.messages` before the graph runs | +| Turn complete | `FlowFinished` for **this run** only; chat continues on the next `kickoff` | +| Full-session trace | `ConversationalConfig(defer_trace_finalization=True)` + `finalize_session_traces()` | + +## One entry point: `kickoff` + +Use **`flow.kickoff(user_message=..., session_id=...)`** for every user message (REST, WebSocket, CLI). Do not add a custom `chat()` wrapper on `Flow`. + +| API | Use for | +|-----|---------| +| `kickoff(user_message=..., session_id=...)` | Each user message | +| `kickoff_async(...)` | Same parameters; native async entry | +| `ask()` | Blocking prompt **inside** one step (wizard, clarification) | +| `@human_feedback` | Approve/reject **a step output** — not the next chat line | +| `ChatSession.handle_turn(...)` | Transport layer over `kickoff` (SSE / WebSocket) | + +## Quick start + +```python +from uuid import uuid4 + +from crewai.flow import ( + ChatState, + ConversationalConfig, + Flow, + listen, + or_, + persist, + router, + start, +) +from crewai.flow.persistence import SQLiteFlowPersistence + + +class SupportFlow(Flow[ChatState]): + conversational_config = ConversationalConfig( + default_intents=["order", "help", "goodbye"], + intent_llm="gpt-4o-mini", + defer_trace_finalization=True, + ) + + @start() + def bootstrap(self): + if not self.state.session_ready: + self.state.session_ready = True + return "ready" + + @router(bootstrap) + def route(self): + # last_intent set in prepare_conversational_turn when default_intents is set + return self.state.last_intent or "help" + + @listen("order") + def handle_order(self): + reply = "Your order is on the way." + self.append_message("assistant", reply) + return reply + + @listen("help") + def handle_help(self): + reply = "How can I help?" + self.append_message("assistant", reply) + return reply + + @listen("goodbye") + def handle_goodbye(self): + reply = "Goodbye!" + self.append_message("assistant", reply) + return reply + + @persist(SQLiteFlowPersistence("support.db")) + @listen(or_(handle_order, handle_help, handle_goodbye)) + def finalize(self): + return self.state.model_dump() + + +session_id = str(uuid4()) +flow = SupportFlow() + +flow.kickoff(user_message="Where is my order?", session_id=session_id) +flow.kickoff(user_message="What about returns?", session_id=session_id) +flow.finalize_session_traces() # one trace link for the whole chat +``` + +## Turn lifecycle + +Each `kickoff` with `user_message` runs this pipeline: + +1. **`_configure_conversational_kickoff`** — merges `session_id` / `user_message` into `inputs`, applies `ConversationalConfig`, enables deferred tracing when configured. +2. **State restore** — if `inputs["id"]` exists and `@persist` is configured, loads the latest snapshot. +3. **`FlowStarted`** — emitted on the first deferred session turn only. +4. **`prepare_conversational_turn`** — appends the user message to `state.messages`, sets `last_user_message`, clears `last_intent`, optionally classifies when `intents` / `default_intents` + `intent_llm` are set. +5. **Graph execution** — `@start` → `@router` → `@listen` handlers. +6. **End of run** — per-turn `flow_finished` and trace finalization are **skipped** when deferral is enabled; nested `Agent.kickoff()` / crews do not close the parent batch either. + +Handlers should call **`append_message("assistant", reply)`** so the next turn’s `conversation_messages` includes assistant text. The user line is already stored at kickoff — do not append it again in handlers. + +## `ConversationalConfig` (class-level defaults) + +Set on your `Flow` subclass as `conversational_config: ClassVar[ConversationalConfig | None]`. + +| Field | Default | Purpose | +|-------|---------|---------| +| `default_intents` | `None` | Outcome labels for automatic pre-kickoff classification | +| `intent_llm` | `None` | Model for classification (required when intents are used) | +| `interactive_prompt` | `"You: "` | Prompt for `kickoff(interactive=True)` | +| `interactive_timeout` | `None` | Per-line timeout in interactive mode | +| `exit_commands` | `exit`, `quit` | Words that end interactive mode | +| `defer_trace_finalization` | `True` | Keep one trace batch open across turns | + +Override per kickoff with `intents=` and `intent_llm=` keyword arguments. + +## `ChatState` (recommended persisted shape) + +```python +from crewai.flow import ChatState + + +class MyChatState(ChatState): + # Inherited: id, messages, last_user_message, last_intent, session_ready + research_turn_count: int = 0 + custom_flag: bool = False +``` + +| Field | Role | +|-------|------| +| `id` | Session UUID (same as `session_id` / `inputs["id"]`) | +| `messages` | `list` of `{role, content}` for LLM history | +| `last_user_message` | Latest user line for this turn | +| `last_intent` | Route label after classification (if used) | +| `session_ready` | One-time bootstrap flag (permissions, caches, etc.) | + +`ConversationalInputs` is a `TypedDict` for conventional `kickoff(inputs={...})` keys: `id`, `user_message`, `last_intent`. + +## `Flow` conversational API + +### `kickoff` / `kickoff_async` parameters + +| Parameter | Purpose | +|-----------|---------| +| `user_message` | This turn’s text (or `{"role": "user", "content": "..."}`) | +| `session_id` | Conversation UUID → `inputs["id"]` / `state.id` | +| `intents` | Outcome labels for pre-kickoff `classify_intent` | +| `intent_llm` | LLM for classification (required with `intents`) | +| `interactive` | CLI loop via `ask()` (local demos only) | +| `interactive_prompt` | Override prompt in interactive mode | +| `interactive_timeout` | Per-line `ask()` timeout | +| `exit_commands` | Words that end interactive mode | +| `inputs` | Additional state fields (merged with conversational keys) | +| `restore_from_state_id` | Fork hydration from another persisted flow | + +### Instance attributes + +| Attribute | Purpose | +|-----------|---------| +| `conversational_config` | Class-level `ConversationalConfig` defaults | +| `defer_trace_finalization` | Instance flag; set automatically from config on kickoff | +| `suppress_flow_events` | Hides console flow panels; **tracing still records** method/flow events | +| `stream` | Enable streaming; use with `ChatSession.handle_turn(..., stream=True)` | + +### Methods and properties + +| Name | Description | +|------|-------------| +| `append_message(role, content, **extra)` | Append to `state.messages` (roles: `user`, `assistant`, `system`, `tool`) | +| `conversation_messages` | Read-only history for LLM calls | +| `classify_intent(text, outcomes, *, llm, context=None)` | Map text to one outcome (same collapse logic as `@human_feedback`) | +| `receive_user_message(text, *, outcomes=None, llm=None)` | Append user message; optionally set `last_intent` | +| `finalize_session_traces()` | Emit deferred `flow_finished` and finalize the session trace batch | +| `_should_defer_trace_finalization()` | Whether this flow defers per-turn trace finalization | +| `input_history` | Audit trail of `ask()` prompts and responses | + +### Module helpers (`crewai.flow.conversation`) + +Importable for tests or custom orchestration: + +| Function | Description | +|----------|-------------| +| `normalize_kickoff_inputs(inputs, user_message=..., session_id=...)` | Merge conversational kwargs into `inputs` | +| `get_conversation_messages(flow)` | Read messages from state or internal buffer | +| `append_message(flow, role, content, **extra)` | Same as instance method | +| `prepare_conversational_turn(flow, user_message=..., intents=..., intent_llm=..., config=...)` | Turn hydration (usually called by kickoff) | +| `receive_user_message(flow, text, ...)` | Same as instance method | +| `set_state_field(flow, name, value)` | Set a field on dict or Pydantic state | +| `get_conversational_config(flow)` | Read class `conversational_config` | +| `input_history_to_messages(entries)` | Convert `input_history` to LLM message format | + +## Intent routing patterns + +### A. Pre-classify via `ConversationalConfig` (simplest) + +Set `default_intents` and `intent_llm`. Each kickoff runs classification before your `@router`; read `self.state.last_intent` in `route()`. + +### B. Classify inside `@router` (richer prompts) + +Set `default_intents=None` so kickoff only appends the user message. In `route()`, call `classify_intent` with a custom prompt or descriptions: + +```python +@router(bootstrap) +def route(self): + intent = self.classify_intent( + self._routing_prompt(self.state.last_user_message), + ("GREETING", "ORDER", "RESEARCH", "GOODBYE"), + llm=self.conversational_config.intent_llm or "gpt-4o-mini", + ) + self.state.last_intent = intent + return intent +``` + +Use **`@listen("RESEARCH")`** (or similar) for steps that run `Agent.kickoff()` with tools — not bare `LLM.call()` — when you need web research or multi-step tool use. + +## When the flow finishes but the user keeps chatting + +`FlowFinished` means **this graph run** completed. The conversation continues with another `kickoff` and the same `session_id`. `@persist` restores `messages`, flags, and context. + +**Persist pattern:** prefer `@persist` on a **single terminal step** (for example `finalize`) rather than on the whole `Flow` class. Class-level persist saves after every method; `load_state` uses the latest row, which may be a mid-run snapshot (for example right after `bootstrap`) and miss handler updates from the same turn. + +Do **not** use `@human_feedback` for follow-up chat lines unless a human must approve a specific step output before it is shown. + +## High-level `ConversationalFlow` (experimental) + +`crewai.experimental.ConversationalFlow` is an opinionated subclass that handles the per-turn plumbing for you: it ships with a built-in `@start` / `@router` / `converse_turn` / `end_conversation` graph, manages `state.messages`, drives the router LLM, and keeps the trace batch open across turns. You write the **custom routes**; the framework owns the rest. + +Use it when you want a multi-turn chat with an LLM-driven router and per-route handlers without wiring the lifecycle yourself. Drop down to `Flow[ChatState]` (above) when you need full control. + +### Quick example + +```python +from crewai import LLM +from crewai.experimental import ConversationConfig, ConversationalFlow, RouterConfig +from crewai.flow import listen + + +ROUTER_LLM = LLM(model="gpt-4o-mini") + + +@ConversationConfig( + system_prompt="A multi-agent assistant for ordinary chat and tool-backed tasks.", + llm=ROUTER_LLM, + router=RouterConfig(), # routes + descriptions auto-discovered from @listen handlers +) +class SupportFlow(ConversationalFlow): + @listen("INTERNET_SEARCH") + def handle_internet_search(self) -> str: + """Fresh web research, current news, real-time lookups.""" + ... + self.append_assistant_message(reply) + return reply + + @listen("CREWAI_DOCS") + def handle_crewai_docs(self) -> str: + """Look up the CrewAI documentation for framework/API questions.""" + ... + self.append_assistant_message(reply) + return reply + + +flow = SupportFlow() +try: + flow.handle_turn("What can you do?") # routes to converse (built-in) + flow.handle_turn("Search the web for AI news.") # routes to INTERNET_SEARCH + flow.handle_turn("Summarize the first result.") # routes back to converse +finally: + flow.finalize_session_traces() +``` + +### `ConversationConfig` + +Class decorator that attaches per-class chat defaults. + +| Field | Default | Purpose | +|-------|---------|---------| +| `system_prompt` | `slices.conversational_system_prompt` from i18n | System message used by the built-in `converse_turn`. Pass `""` to opt out entirely. | +| `llm` | `None` | Conversation LLM (used by `converse_turn` and as router fallback). | +| `router` | `None` | `RouterConfig` for LLM-driven routing. Without it, the flow always falls through to `converse`. | +| `answer_from_history_prompt` | Framework default | System message for the optional `answer_from_history` route. | +| `answer_from_history_llm` | `None` | Enables the `answer_from_history` short-circuit when set. | +| `intent_llm` | `None` | LLM for legacy `intents=`/`default_intents` pre-classification. | +| `default_intents` | `None` | Outcome labels for legacy pre-classification. | +| `visible_agent_outputs` | `None` | `"all"`, or a list of agent names whose `append_agent_result()` calls should be promoted to public assistant messages. | +| `defer_trace_finalization` | `True` | Keep one trace batch open across `handle_turn()` calls. | + +### `RouterConfig` and the auto-built route catalog + +```python +RouterConfig( + prompt="Optional domain framing (policy, voice, persona).", + response_format=MyRoute, # optional; auto-generated otherwise + llm=ROUTER_LLM, # falls back to ConversationConfig.llm + routes=["INTERNET_SEARCH", "CREWAI_DOCS"], # optional; inferred from listeners + route_descriptions={ + "INTERNET_SEARCH": "Override the docstring for this one route.", + }, + default_intent="converse", # used when LLM call fails or no LLM available + fallback_intent="converse", # used when LLM returns an invalid route + intent_field="intent", +) +``` + +The router prompt that gets sent to the LLM is built automatically. For each route the framework picks a description with this precedence: + +1. `RouterConfig.route_descriptions[label]` — explicit override. +2. `ConversationalFlow.builtin_route_descriptions[label]` — framework-canned text for `converse`, `end`, `answer_from_history` (phrased for the router LLM). +3. First non-empty line of the `@listen(label)` handler's docstring. +4. Empty (the route is listed without a description). + +So in practice, **adding a new route is `@listen("X")` + a one-line docstring**: + +```python +@listen("INTERNET_SEARCH") +def handle_internet_search(self) -> str: + """Fresh web research, current news, real-time lookups.""" + ... +``` + +…and the router LLM sees: + +``` +Routes: +- CREWAI_DOCS: Look up the CrewAI documentation for framework/API questions. +- INTERNET_SEARCH: Fresh web research, current news, real-time lookups. +- converse: Ordinary chat, follow-ups, summaries, clarifications… +- end: User signals the conversation is finished (goodbye, exit, done). +``` + +`RouterConfig.prompt` is for **domain framing** (assistant persona, business rules, voice). The route catalog is auto-built — don't list routes in `prompt`; they'll drift the moment you add a handler. + +### Built-in routes + +| Route | Handler | Purpose | +|-------|---------|---------| +| `converse` | `converse_turn` | Default chat handler. Calls `ConversationConfig.llm` with the system prompt + canonical message history. | +| `end` | `end_conversation` | Sets `state.ended = True` and emits a terminator reply. | +| `answer_from_history` | `answer_from_history_turn` | Optional. Routes here when `ConversationConfig.answer_from_history_llm` is set and the message can be answered from existing history. | + +You can override any of these by defining a same-named handler in your subclass. + +### `handle_turn()` semantics + +`flow.handle_turn(message)` runs one turn: + +1. Resets per-execution tracking (`_completed_methods`, `_method_outputs`) so the graph re-runs — without this, repeated `kickoff` calls on the same flow instance would short-circuit on turn 2+ because `Flow.kickoff_async` treats `inputs={"id": ...}` as a checkpoint restore. +2. Appends the user message to `state.messages`, sets `current_user_message` / `last_user_message`. `last_intent` is **preserved from the prior turn** so the router LLM can use it as a signal. +3. Runs `conversation_start` → `route_conversation` → the chosen `@listen` handler. +4. The router stores its decision in `state.last_intent` (visible to the next turn's router context). +5. If your handler returned a string and didn't already call `append_assistant_message`, `handle_turn` appends it for you. + +You can also call `flow.kickoff(user_message=..., session_id=...)` directly — the same reset/run logic fires. `handle_turn` is the ergonomic wrapper. + +### Custom router behavior + +To run side effects (event bus setup, telemetry) on every routing decision, override `route_turn`: + +```python +class SupportFlow(ConversationalFlow): + def route_turn(self, context: dict[str, Any]) -> str | None: + self.event_bus = MyBus(self) + return super().route_turn(context) +``` + +To bypass the LLM router entirely and pick a route programmatically, return a string from `route_turn`; returning `None` falls back to `_route_with_config(...)`. + +### `append_assistant_message` and `append_agent_result` + +Inside a `@listen(label)` handler, choose: + +- `self.append_assistant_message(text)` — adds a user-visible assistant turn to `state.messages`. The next turn's `converse_turn` sees it. +- `self.append_agent_result(agent_name, result, visibility="private")` — records a structured event in `state.events` and a thread in `state.agent_threads[agent_name]`. Public visibility also calls `append_assistant_message` for you. Use private results for scratch work that shouldn't pollute the canonical history. + +`ConversationConfig.visible_agent_outputs` can promote specific agents' private results to public globally (`"all"`, or a list of agent names). + +## Tracing across turns + +With `defer_trace_finalization=True` (default in `ConversationalConfig`): + +- **One trace batch** for the whole chat session. +- **`flow_started`** on the first turn only; **`flow_finished`** once in `finalize_session_traces()`. +- **Per-turn** `kickoff` does not print “Trace batch finalized”. +- **Nested work** (`Agent.kickoff()`, crews, Exa tools) appends to the **parent** batch; inner `AgentExecutor` flows do not close the session batch early. + +```python +try: + while True: + line = input("You: ").strip() + if not line: + break + flow.kickoff(user_message=line, session_id=session_id) +finally: + flow.finalize_session_traces() +``` + +`ChatSession.close()` calls `finalize_session_traces()` when deferral is enabled. + +`suppress_flow_events=True` only hides Rich console panels; trace and method events still emit for observability. + +### `ConversationalFlow` trace lifecycle + +The experimental [`ConversationalFlow`](#high-level-conversationalflow-experimental) uses the same tracing lifecycle: `defer_trace_finalization` defaults to `True`, so each `handle_turn()` keeps the session trace open. Always finalize at the end of the session — wrap your REPL/loop in `try/finally` and call `flow.finalize_session_traces()` on exit. Without it, the trace batch stays open and the final conversation may never export. + +## Streaming + +Set `stream = True` on the `Flow` class. `kickoff(...)` will then emit `assistant_delta` (and related) events through the standard event bus. + +## Imports + +```python +from crewai.flow import ( + ChatState, + ConversationalConfig, + ConversationalInputs, + Flow, + listen, + persist, + router, + start, +) +``` + +## See also + +- [Mastering Flow State Management](/en/guides/flows/mastering-flow-state) — persistence, Pydantic state, `@persist` +- [Build Your First Flow](/en/guides/flows/first-flow) — flow basics +- Demo: `lib/crewai/runner_conversational_flow_simple.py` — minimal REPL with `RESEARCH` + Exa agent diff --git a/docs/en/guides/flows/first-flow.mdx b/docs/en/guides/flows/first-flow.mdx index a5b8c347ce..f536976d23 100644 --- a/docs/en/guides/flows/first-flow.mdx +++ b/docs/en/guides/flows/first-flow.mdx @@ -617,6 +617,7 @@ Now that you've built your first flow, you can: 3. Explore the `and_` and `or_` functions for more complex parallel execution 4. Connect your flow to external APIs, databases, or user interfaces 5. Combine multiple specialized crews in a single flow +6. Build multi-turn chat apps with [Conversational Flows](/en/guides/flows/conversational-flows) (`kickoff` per message, `ChatSession`, deferred tracing) Congratulations! You've successfully built your first CrewAI Flow that combines regular code, direct LLM calls, and crew-based processing to create a comprehensive guide. These foundational skills enable you to create increasingly sophisticated AI applications that can tackle complex, multi-stage problems through a combination of procedural control and collaborative intelligence. diff --git a/docs/en/guides/flows/mastering-flow-state.mdx b/docs/en/guides/flows/mastering-flow-state.mdx index 68a8212464..648a82dbd9 100644 --- a/docs/en/guides/flows/mastering-flow-state.mdx +++ b/docs/en/guides/flows/mastering-flow-state.mdx @@ -22,6 +22,8 @@ Effective state management enables you to: 5. **Scale your applications** - Support complex workflows with proper data organization 6. **Enable conversational applications** - Store and access conversation history for context-aware AI interactions +For multi-turn chat (`kickoff` per user line, `ChatState`, intent routing, deferred tracing, and `ChatSession`), see [Conversational Flows](/en/guides/flows/conversational-flows). + Let's explore how to leverage these capabilities effectively. ## State Management Fundamentals diff --git a/docs/ko/guides/flows/conversational-flows.mdx b/docs/ko/guides/flows/conversational-flows.mdx new file mode 100644 index 0000000000..b5e9a07236 --- /dev/null +++ b/docs/ko/guides/flows/conversational-flows.mdx @@ -0,0 +1,437 @@ +--- +title: 대화형 Flow +description: 턴마다 kickoff, 메시지 기록, 의도 라우팅, 트레이싱, WebSocket 브리지로 멀티턴 채팅 앱을 만듭니다. +icon: comments +mode: "wide" +--- + +## 개요 + +대화형 앱은 각 사용자 입력을 **동일한 세션 id**로 **새 flow 실행**으로 처리합니다. CrewAI는 메시지 기록, 선택적 의도 분류, 지연 트레이싱, UI 브리지를 제공하며, `Flow`에 별도 `chat()` API는 없습니다. + +| 개념 | 구현 | +|------|------| +| 세션 id | `kickoff(session_id=...)` → `inputs["id"]` → `state.id` | +| 사용자 입력 | `kickoff(user_message=...)`가 그래프 실행 전 `state.messages`에 추가 | +| 턴 완료 | `FlowFinished`는 **이번 실행**만 의미; 다음 `kickoff`로 대화 계속 | +| 세션 전체 트레이스 | `ConversationalConfig(defer_trace_finalization=True)` + `finalize_session_traces()` | + +## 단일 진입점: `kickoff` + +모든 사용자 메시지에 **`flow.kickoff(user_message=..., session_id=...)`**를 사용하세요 (REST, WebSocket, CLI). `Flow`에 커스텀 `chat()` 래퍼를 만들지 마세요. + +| API | 용도 | +|-----|------| +| `kickoff(user_message=..., session_id=...)` | 각 사용자 메시지 | +| `kickoff_async(...)` | 동일 파라미터; 네이티브 async 진입 | +| `ask()` | 한 스텝 **내부** 블로킹 프롬프트 (마법사, 확인) | +| `@human_feedback` | **스텝 출력** 승인/거부 — 다음 채팅 줄이 아님 | +| `ChatSession.handle_turn(...)` | `kickoff` 위의 전송 계층 (SSE / WebSocket) | + +## 빠른 시작 + +```python +from uuid import uuid4 + +from crewai.flow import ( + ChatState, + ConversationalConfig, + Flow, + listen, + or_, + persist, + router, + start, +) +from crewai.flow.persistence import SQLiteFlowPersistence + + +class SupportFlow(Flow[ChatState]): + conversational_config = ConversationalConfig( + default_intents=["order", "help", "goodbye"], + intent_llm="gpt-4o-mini", + defer_trace_finalization=True, + ) + + @start() + def bootstrap(self): + if not self.state.session_ready: + self.state.session_ready = True + return "ready" + + @router(bootstrap) + def route(self): + # default_intents 설정 시 prepare_conversational_turn에서 last_intent 설정 + return self.state.last_intent or "help" + + @listen("order") + def handle_order(self): + reply = "주문이 배송 중입니다." + self.append_message("assistant", reply) + return reply + + @listen("help") + def handle_help(self): + reply = "무엇을 도와드릴까요?" + self.append_message("assistant", reply) + return reply + + @listen("goodbye") + def handle_goodbye(self): + reply = "안녕히 가세요!" + self.append_message("assistant", reply) + return reply + + @persist(SQLiteFlowPersistence("support.db")) + @listen(or_(handle_order, handle_help, handle_goodbye)) + def finalize(self): + return self.state.model_dump() + + +session_id = str(uuid4()) +flow = SupportFlow() + +flow.kickoff(user_message="주문 어디까지 왔나요?", session_id=session_id) +flow.kickoff(user_message="반품은 어떻게 하나요?", session_id=session_id) +flow.finalize_session_traces() # 전체 대화에 대한 단일 trace 링크 +``` + +## 턴 생명주기 + +`user_message`가 있는 각 `kickoff`는 다음 파이프라인을 실행합니다: + +1. **`_configure_conversational_kickoff`** — `session_id` / `user_message`를 `inputs`에 병합, `ConversationalConfig` 적용, 설정 시 지연 트레이싱 활성화. +2. **상태 복원** — `inputs["id"]`가 있고 `@persist`가 설정되면 최신 스냅샷 로드. +3. **`FlowStarted`** — 지연 세션의 첫 턴에서만 발생. +4. **`prepare_conversational_turn`** — 사용자 메시지를 `state.messages`에 추가, `last_user_message` 설정, `last_intent` 초기화, `intents` / `default_intents` + `intent_llm` 설정 시 분류. +5. **그래프 실행** — `@start` → `@router` → `@listen` 핸들러. +6. **실행 종료** — 지연 활성화 시 턴별 `flow_finished` 및 trace 종료 **건너뜀**; 중첩 `Agent.kickoff()` / crew도 부모 batch를 닫지 않음. + +핸들러는 **`append_message("assistant", reply)`**를 호출해 다음 턴의 `conversation_messages`에 어시스턴트 응답이 포함되게 하세요. 사용자 입력은 kickoff 시 이미 저장됩니다 — 핸들러에서 다시 추가하지 마세요. + +## `ConversationalConfig` (클래스 수준 기본값) + +`Flow` 서브클래스에 `conversational_config: ClassVar[ConversationalConfig | None]`로 설정합니다. + +| 필드 | 기본값 | 목적 | +|------|--------|------| +| `default_intents` | `None` | kickoff 전 자동 분류용 outcome 라벨 | +| `intent_llm` | `None` | 분류용 모델 (intent 사용 시 필수) | +| `interactive_prompt` | `"You: "` | `kickoff(interactive=True)` 프롬프트 | +| `interactive_timeout` | `None` | 대화형 모드 줄 단위 타임아웃 | +| `exit_commands` | `exit`, `quit` | 대화형 모드 종료 단어 | +| `defer_trace_finalization` | `True` | 턴 간 하나의 trace batch 유지 | + +`intents=` 및 `intent_llm=` 키워드로 kickoff마다 재정의할 수 있습니다. + +## `ChatState` (권장 persist 형태) + +```python +from crewai.flow import ChatState + + +class MyChatState(ChatState): + # 상속: id, messages, last_user_message, last_intent, session_ready + research_turn_count: int = 0 + custom_flag: bool = False +``` + +| 필드 | 역할 | +|------|------| +| `id` | 세션 UUID (`session_id` / `inputs["id"]`와 동일) | +| `messages` | LLM 기록용 `{role, content}` 리스트 | +| `last_user_message` | 이번 턴의 최신 사용자 입력 | +| `last_intent` | 분류 후 라우트 라벨 (사용 시) | +| `session_ready` | 일회성 bootstrap 플래그 | + +`ConversationalInputs`는 `kickoff(inputs={...})`용 `TypedDict`: `id`, `user_message`, `last_intent`. + +## `Flow` 대화 API + +### `kickoff` / `kickoff_async` 파라미터 + +| 파라미터 | 목적 | +|----------|------| +| `user_message` | 이번 턴 텍스트 (또는 `{"role": "user", "content": "..."}`) | +| `session_id` | 대화 UUID → `inputs["id"]` / `state.id` | +| `intents` | kickoff 전 `classify_intent`용 outcome 라벨 | +| `intent_llm` | 분류 LLM (`intents`와 함께 필수) | +| `interactive` | `ask()` CLI 루프 (로컬 데모 전용) | +| `interactive_prompt` | 대화형 모드 프롬프트 | +| `interactive_timeout` | 줄 단위 `ask()` 타임아웃 | +| `exit_commands` | 대화형 모드 종료 단어 | +| `inputs` | 추가 상태 필드 | +| `restore_from_state_id` | 다른 persist flow에서 fork 복원 | + +### 인스턴스 속성 + +| 속성 | 목적 | +|------|------| +| `conversational_config` | 클래스 수준 `ConversationalConfig` | +| `defer_trace_finalization` | 인스턴스 플래그; kickoff 시 config에서 자동 설정 | +| `suppress_flow_events` | 콘솔 flow 패널 숨김; **트레이싱은 계속 기록** | +| `stream` | 스트리밍; `ChatSession.handle_turn(..., stream=True)`와 함께 | + +### 메서드 및 프로퍼티 + +| 이름 | 설명 | +|------|------| +| `append_message(role, content, **extra)` | `state.messages`에 추가 | +| `conversation_messages` | LLM 호출용 읽기 전용 기록 | +| `classify_intent(text, outcomes, *, llm, context=None)` | outcome 매핑 (`@human_feedback`와 동일 collapse) | +| `receive_user_message(text, *, outcomes=None, llm=None)` | 사용자 메시지 추가; 선택적 `last_intent` | +| `finalize_session_traces()` | 지연 `flow_finished` 발생 및 세션 trace batch 종료 | +| `_should_defer_trace_finalization()` | 턴별 trace 종료 지연 여부 | +| `input_history` | `ask()` 프롬프트/응답 감사 기록 | + +### 모듈 헬퍼 (`crewai.flow.conversation`) + +테스트 또는 커스텀 오케스트레이션용: + +| 함수 | 설명 | +|------|------| +| `normalize_kickoff_inputs(...)` | 대화 kwargs를 `inputs`에 병합 | +| `get_conversation_messages(flow)` | 상태 또는 내부 버퍼에서 메시지 읽기 | +| `append_message(flow, ...)` | 인스턴스 메서드와 동일 | +| `prepare_conversational_turn(flow, ...)` | 턴 수화 (보통 kickoff가 호출) | +| `receive_user_message(flow, ...)` | 인스턴스 메서드와 동일 | +| `set_state_field(flow, name, value)` | dict 또는 Pydantic 상태 필드 설정 | +| `get_conversational_config(flow)` | 클래스 `conversational_config` 읽기 | +| `input_history_to_messages(entries)` | `input_history`를 LLM 메시지 형식으로 | + +## 의도 라우팅 패턴 + +### A. `ConversationalConfig`로 사전 분류 (가장 단순) + +`default_intents`와 `intent_llm` 설정. 각 kickoff가 `@router` 전에 분류; `route()`에서 `self.state.last_intent` 읽기. + +### B. `@router` 내부에서 분류 (풍부한 프롬프트) + +`default_intents=None`으로 kickoff는 메시지만 추가. `route()`에서 커스텀 프롬프트로 `classify_intent` 호출: + +```python +@router(bootstrap) +def route(self): + intent = self.classify_intent( + self._routing_prompt(self.state.last_user_message), + ("GREETING", "ORDER", "RESEARCH", "GOODBYE"), + llm=self.conversational_config.intent_llm or "gpt-4o-mini", + ) + self.state.last_intent = intent + return intent +``` + +웹 리서치나 다단계 tool이 필요하면 **`@listen("RESEARCH")`** 등에서 `Agent.kickoff()`와 tool 사용 — 단순 `LLM.call()` 대신. + +## flow가 끝났지만 사용자는 계속 대화할 때 + +`FlowFinished`는 **이번 그래프 실행**이 완료됨을 의미합니다. 같은 `session_id`로 또 다른 `kickoff`로 대화가 이어집니다. `@persist`가 `messages`, 플래그, 컨텍스트를 복원합니다. + +**Persist 패턴:** 전체 `Flow` 클래스보다 **단일 종료 스텝**(예: `finalize`)에 `@persist`를 두는 것이 좋습니다. 클래스 수준 persist는 매 메서드 후 저장하며, `load_state`는 최신 행을 사용해 같은 턴의 핸들러 업데이트를 놓칠 수 있습니다. + +후속 채팅 줄에 `@human_feedback`를 쓰지 마세요. 특정 스텝 출력을 사람이 승인해야 할 때만 사용하세요. + +## 고수준 `ConversationalFlow` (실험적) + +`crewai.experimental.ConversationalFlow`는 턴 단위의 배관 작업을 대신 처리해 주는 의견 강한(opinionated) 서브클래스입니다. `@start` / `@router` / `converse_turn` / `end_conversation` 그래프가 내장되어 있고, `state.messages`를 관리하며, router LLM을 구동하고, 턴 간 trace 배치를 열린 상태로 유지합니다. 여러분은 **커스텀 라우트**만 작성하면 되고, 나머지는 프레임워크가 담당합니다. + +LLM 기반 라우터와 라우트별 핸들러로 멀티턴 챗을 만들고 싶지만 라이프사이클을 직접 배선하고 싶지 않을 때 사용하세요. 완전한 제어가 필요하면 위의 `Flow[ChatState]`로 내려가세요. + +### 빠른 예제 + +```python +from crewai import LLM +from crewai.experimental import ConversationConfig, ConversationalFlow, RouterConfig +from crewai.flow import listen + + +ROUTER_LLM = LLM(model="gpt-4o-mini") + + +@ConversationConfig( + system_prompt="A multi-agent assistant for ordinary chat and tool-backed tasks.", + llm=ROUTER_LLM, + router=RouterConfig(), # 라우트 + 설명은 @listen 핸들러에서 자동 발견 +) +class SupportFlow(ConversationalFlow): + @listen("INTERNET_SEARCH") + def handle_internet_search(self) -> str: + """Fresh web research, current news, real-time lookups.""" + ... + self.append_assistant_message(reply) + return reply + + @listen("CREWAI_DOCS") + def handle_crewai_docs(self) -> str: + """Look up the CrewAI documentation for framework/API questions.""" + ... + self.append_assistant_message(reply) + return reply + + +flow = SupportFlow() +try: + flow.handle_turn("뭘 할 수 있어?") # converse(빌트인)로 라우팅 + flow.handle_turn("AI 뉴스를 웹에서 찾아줘.") # INTERNET_SEARCH로 라우팅 + flow.handle_turn("첫 번째 결과를 요약해줘.") # 다시 converse로 라우팅 +finally: + flow.finalize_session_traces() +``` + +### `ConversationConfig` + +클래스 단위의 챗 기본값을 부착하는 클래스 데코레이터입니다. + +| 필드 | 기본값 | 목적 | +|------|--------|------| +| `system_prompt` | i18n `slices.conversational_system_prompt` | 빌트인 `converse_turn`이 사용하는 system 메시지. 빈 문자열(`""`)을 전달하면 system 메시지를 끕니다. | +| `llm` | `None` | 대화용 LLM (빌트인 `converse_turn`이 사용하고 router 폴백도 됨). | +| `router` | `None` | LLM 기반 라우팅을 위한 `RouterConfig`. 없으면 항상 `converse`로 떨어집니다. | +| `answer_from_history_prompt` | 프레임워크 기본값 | 선택적인 `answer_from_history` 라우트용 system 메시지. | +| `answer_from_history_llm` | `None` | 설정되면 `answer_from_history` 단축 경로가 활성화됩니다. | +| `intent_llm` | `None` | 레거시 `intents=`/`default_intents` 사전 분류용 LLM. | +| `default_intents` | `None` | 레거시 사전 분류용 outcome 레이블. | +| `visible_agent_outputs` | `None` | `"all"` 또는 `append_agent_result()` 결과를 사용자에게 공개로 승격할 에이전트 이름 목록. | +| `defer_trace_finalization` | `True` | `handle_turn()` 호출들 사이에서 하나의 trace 배치를 열어 둡니다. | + +### `RouterConfig`와 자동 생성되는 라우트 카탈로그 + +```python +RouterConfig( + prompt="선택적인 도메인 프레이밍 (정책, 톤, 페르소나).", + response_format=MyRoute, # 선택; 없으면 자동 생성 + llm=ROUTER_LLM, # ConversationConfig.llm으로 폴백 + routes=["INTERNET_SEARCH", "CREWAI_DOCS"], # 선택; 리스너에서 추론 + route_descriptions={ + "INTERNET_SEARCH": "이 라우트만 docstring 대신 사용할 설명.", + }, + default_intent="converse", # LLM 호출 실패 또는 LLM 없음일 때 사용 + fallback_intent="converse", # LLM이 잘못된 라우트를 반환할 때 사용 + intent_field="intent", +) +``` + +router에 전달되는 프롬프트는 자동으로 만들어집니다. 각 라우트의 설명은 다음 우선순위로 결정됩니다: + +1. `RouterConfig.route_descriptions[label]` — 명시적 오버라이드. +2. `ConversationalFlow.builtin_route_descriptions[label]` — `converse`, `end`, `answer_from_history`용 프레임워크 캐닝 텍스트 (router LLM용으로 다듬어진 문구). +3. `@listen(label)` 핸들러 docstring의 첫 줄(비어있지 않은 줄). +4. 빈 문자열 (라우트만 카탈로그에 등장하고 설명은 없음). + +실제 사용에서 **새 라우트를 추가하는 방법은 `@listen("X")` + 한 줄짜리 docstring**입니다: + +```python +@listen("INTERNET_SEARCH") +def handle_internet_search(self) -> str: + """Fresh web research, current news, real-time lookups.""" + ... +``` + +…그러면 router LLM은 다음을 봅니다: + +``` +Routes: +- CREWAI_DOCS: Look up the CrewAI documentation for framework/API questions. +- INTERNET_SEARCH: Fresh web research, current news, real-time lookups. +- converse: Ordinary chat, follow-ups, summaries, clarifications… +- end: User signals the conversation is finished (goodbye, exit, done). +``` + +`RouterConfig.prompt`는 **도메인 프레이밍** (어시스턴트 페르소나, 비즈니스 규칙, 톤)을 위한 자리입니다. 라우트 카탈로그는 자동 생성되니 `prompt` 안에 라우트 목록을 넣지 마세요. 핸들러를 추가하는 순간 동기화가 깨집니다. + +### 빌트인 라우트 + +| 라우트 | 핸들러 | 목적 | +|--------|--------|------| +| `converse` | `converse_turn` | 기본 챗 핸들러. system prompt + 정식 메시지 히스토리와 함께 `ConversationConfig.llm`을 호출합니다. | +| `end` | `end_conversation` | `state.ended = True`로 설정하고 종료 응답을 보냅니다. | +| `answer_from_history` | `answer_from_history_turn` | 선택적. `ConversationConfig.answer_from_history_llm`이 설정되어 있고 메시지를 히스토리만으로 답할 수 있을 때 라우팅됩니다. | + +서브클래스에 같은 이름의 핸들러를 정의하면 어떤 것이든 오버라이드할 수 있습니다. + +### `handle_turn()` 시맨틱 + +`flow.handle_turn(message)`는 한 턴을 실행합니다: + +1. 그래프가 다시 실행되도록 턴 단위 실행 추적(`_completed_methods`, `_method_outputs`)을 초기화합니다 — 이게 없으면 동일 인스턴스에서 반복 `kickoff` 호출 시 `Flow.kickoff_async`가 `inputs={"id": ...}`를 체크포인트 복원으로 간주해 2번째 턴부터 단락 회로가 발생합니다. +2. 사용자 메시지를 `state.messages`에 추가하고 `current_user_message` / `last_user_message`를 설정합니다. `last_intent`는 **이전 턴 값이 유지**되어 router LLM이 신호로 활용할 수 있습니다. +3. `conversation_start` → `route_conversation` → 선택된 `@listen` 핸들러 순으로 실행됩니다. +4. router는 결정을 `state.last_intent`에 저장합니다 (다음 턴의 router 컨텍스트에서 보입니다). +5. 핸들러가 문자열을 반환했지만 `append_assistant_message`를 직접 호출하지 않았다면, `handle_turn`이 대신 추가해 줍니다. + +`flow.kickoff(user_message=..., session_id=...)`를 직접 호출해도 동일한 reset/run 로직이 동작합니다. `handle_turn`은 그 위에 얹은 편의 래퍼입니다. + +### 커스텀 router 동작 + +매 라우팅 결정마다 사이드 이펙트(이벤트 버스 셋업, 텔레메트리)를 실행하려면 `route_turn`을 오버라이드하세요: + +```python +class SupportFlow(ConversationalFlow): + def route_turn(self, context: dict[str, Any]) -> str | None: + self.event_bus = MyBus(self) + return super().route_turn(context) +``` + +LLM router를 우회해 프로그램적으로 라우트를 선택하려면 `route_turn`에서 문자열을 반환하세요. `None`을 반환하면 `_route_with_config(...)`로 떨어집니다. + +### `append_assistant_message`와 `append_agent_result` + +`@listen(label)` 핸들러 안에서 두 가지 중 선택하세요: + +- `self.append_assistant_message(text)` — 사용자에게 보이는 어시스턴트 턴을 `state.messages`에 추가합니다. 다음 턴의 `converse_turn`이 이 내용을 보게 됩니다. +- `self.append_agent_result(agent_name, result, visibility="private")` — 구조화된 이벤트를 `state.events`에, 스레드를 `state.agent_threads[agent_name]`에 기록합니다. public 가시성은 자동으로 `append_assistant_message`도 호출합니다. 정식 히스토리를 더럽히지 말아야 할 임시 작업에는 private을 쓰세요. + +`ConversationConfig.visible_agent_outputs`로 특정 에이전트의 private 결과를 전역적으로 public으로 승격할 수 있습니다 (`"all"` 또는 이름 리스트). + +## 턴 간 트레이싱 + +`defer_trace_finalization=True` (`ConversationalConfig` 기본값): + +- 채팅 세션 전체에 **하나의 trace batch**. +- 첫 턴에만 **`flow_started`**; `finalize_session_traces()`에서 **`flow_finished`** 한 번. +- 턴별 `kickoff`는 “Trace batch finalized”를 출력하지 않음. +- **중첩 작업** (`Agent.kickoff()`, crew, Exa tool)은 **부모** batch에 추가; 내부 `AgentExecutor` flow가 세션 batch를 조기 종료하지 않음. + +```python +try: + while True: + line = input("You: ").strip() + if not line: + break + flow.kickoff(user_message=line, session_id=session_id) +finally: + flow.finalize_session_traces() +``` + +지연 활성화 시 `ChatSession.close()`가 `finalize_session_traces()`를 호출합니다. + +`suppress_flow_events=True`는 Rich 콘솔 패널만 숨깁니다. trace 및 method 이벤트는 계속 발생합니다. + +### `ConversationalFlow` trace 수명 주기 + +실험적 [`ConversationalFlow`](#고수준-conversationalflow-실험적)는 동일한 tracing 수명 주기를 따릅니다. `defer_trace_finalization` 기본값이 `True`이므로 각 `handle_turn()`이 세션 trace를 열어 둡니다. 세션 끝에서 항상 finalize하세요 — REPL/루프를 `try/finally`로 감싸고 종료 시 `flow.finalize_session_traces()`를 호출하세요. 호출하지 않으면 batch가 열린 채 남아 마지막 대화가 export되지 않을 수 있습니다. + +## 스트리밍 + +`Flow` 클래스에 `stream = True`. `kickoff(...)`가 표준 이벤트 버스를 통해 `assistant_delta` 등 이벤트를 발생시킵니다. + +## import + +```python +from crewai.flow import ( + ChatState, + ConversationalConfig, + ConversationalInputs, + Flow, + listen, + persist, + router, + start, +) +``` + +## 참고 + +- [Flow 상태 관리 마스터하기](/ko/guides/flows/mastering-flow-state) +- [첫 Flow 만들기](/ko/guides/flows/first-flow) +- 데모: `lib/crewai/runner_conversational_flow_simple.py` diff --git a/docs/ko/guides/flows/first-flow.mdx b/docs/ko/guides/flows/first-flow.mdx index 72ed9866a4..8c222f69fa 100644 --- a/docs/ko/guides/flows/first-flow.mdx +++ b/docs/ko/guides/flows/first-flow.mdx @@ -607,6 +607,7 @@ result = ContentCrew().crew().kickoff(inputs={ 3. 더 복잡한 병렬 실행을 위해 `and_` 및 `or_` 함수를 탐색해 보세요. 4. flow를 외부 API, 데이터베이스 또는 사용자 인터페이스에 연결해 보세요. 5. 여러 전문화된 crew를 하나의 flow에서 결합해 보세요. +6. [대화형 Flow](/ko/guides/flows/conversational-flows)로 멀티턴 채팅 앱 구축 (`kickoff` per message, `ChatSession`, 지연 트레이싱) 축하합니다! 정규 코드, 직접적인 LLM 호출, crew 기반 처리를 결합하여 포괄적인 가이드를 생성하는 첫 번째 CrewAI Flow를 성공적으로 구축하셨습니다. 이러한 기초적인 역량을 바탕으로 절차적 제어와 협업적 인텔리전스를 결합하여 복잡하고 다단계의 문제를 해결할 수 있는 점점 더 정교한 AI 애플리케이션을 만들 수 있습니다. diff --git a/docs/ko/guides/flows/mastering-flow-state.mdx b/docs/ko/guides/flows/mastering-flow-state.mdx index eafd24b297..5e7727cb10 100644 --- a/docs/ko/guides/flows/mastering-flow-state.mdx +++ b/docs/ko/guides/flows/mastering-flow-state.mdx @@ -22,6 +22,8 @@ State 관리는 모든 고급 AI 워크플로우의 중추입니다. CrewAI Flow 5. **애플리케이션 확장** - 적절한 데이터 조직을 통해 복잡한 워크플로를 지원할 수 있습니다. 6. **대화형 애플리케이션 활성화** - 컨텍스트 기반 AI 상호작용을 위해 대화 내역을 저장하고 접근할 수 있습니다. +멀티턴 채팅(`kickoff` per user line, `ChatState`, 의도 라우팅, 지연 트레이싱, `ChatSession`)은 [대화형 Flow](/ko/guides/flows/conversational-flows)를 참고하세요. + 이러한 기능을 효과적으로 활용하는 방법을 살펴보겠습니다. ## 상태 관리 기본 사항 diff --git a/docs/pt-BR/guides/flows/conversational-flows.mdx b/docs/pt-BR/guides/flows/conversational-flows.mdx new file mode 100644 index 0000000000..eb000f5288 --- /dev/null +++ b/docs/pt-BR/guides/flows/conversational-flows.mdx @@ -0,0 +1,437 @@ +--- +title: Flows Conversacionais +description: Crie apps de chat multi-turno com kickoff por turno, histórico de mensagens, roteamento de intenção, tracing e pontes WebSocket. +icon: comments +mode: "wide" +--- + +## Visão geral + +Apps conversacionais tratam cada linha do usuário como uma **nova execução do flow** com o **mesmo id de sessão**. A CrewAI oferece helpers para histórico de mensagens, classificação opcional de intenção, tracing adiado e pontes para UI — sem uma API `chat()` separada em `Flow`. + +| Conceito | Implementação | +|---------|----------------| +| Id de sessão | `kickoff(session_id=...)` → `inputs["id"]` → `state.id` | +| Linha do usuário | `kickoff(user_message=...)` acrescenta em `state.messages` antes do grafo rodar | +| Fim do turno | `FlowFinished` só para **esta execução**; o chat segue no próximo `kickoff` | +| Trace da sessão | `ConversationalConfig(defer_trace_finalization=True)` + `finalize_session_traces()` | + +## Um ponto de entrada: `kickoff` + +Use **`flow.kickoff(user_message=..., session_id=...)`** para cada mensagem (REST, WebSocket, CLI). Não crie um wrapper `chat()` customizado em `Flow`. + +| API | Uso | +|-----|-----| +| `kickoff(user_message=..., session_id=...)` | Cada mensagem do usuário | +| `kickoff_async(...)` | Mesmos parâmetros; entrada async nativa | +| `ask()` | Prompt bloqueante **dentro** de um passo (wizard, esclarecimento) | +| `@human_feedback` | Aprovar/rejeitar **saída de um passo** — não a próxima linha do chat | +| `ChatSession.handle_turn(...)` | Camada de transporte sobre `kickoff` (SSE / WebSocket) | + +## Início rápido + +```python +from uuid import uuid4 + +from crewai.flow import ( + ChatState, + ConversationalConfig, + Flow, + listen, + or_, + persist, + router, + start, +) +from crewai.flow.persistence import SQLiteFlowPersistence + + +class SupportFlow(Flow[ChatState]): + conversational_config = ConversationalConfig( + default_intents=["order", "help", "goodbye"], + intent_llm="gpt-4o-mini", + defer_trace_finalization=True, + ) + + @start() + def bootstrap(self): + if not self.state.session_ready: + self.state.session_ready = True + return "ready" + + @router(bootstrap) + def route(self): + # last_intent definido em prepare_conversational_turn quando default_intents está setado + return self.state.last_intent or "help" + + @listen("order") + def handle_order(self): + reply = "Seu pedido está a caminho." + self.append_message("assistant", reply) + return reply + + @listen("help") + def handle_help(self): + reply = "Como posso ajudar?" + self.append_message("assistant", reply) + return reply + + @listen("goodbye") + def handle_goodbye(self): + reply = "Até logo!" + self.append_message("assistant", reply) + return reply + + @persist(SQLiteFlowPersistence("support.db")) + @listen(or_(handle_order, handle_help, handle_goodbye)) + def finalize(self): + return self.state.model_dump() + + +session_id = str(uuid4()) +flow = SupportFlow() + +flow.kickoff(user_message="Onde está meu pedido?", session_id=session_id) +flow.kickoff(user_message="E as devoluções?", session_id=session_id) +flow.finalize_session_traces() # um link de trace para o chat inteiro +``` + +## Ciclo de vida do turno + +Cada `kickoff` com `user_message` executa este pipeline: + +1. **`_configure_conversational_kickoff`** — mescla `session_id` / `user_message` em `inputs`, aplica `ConversationalConfig`, habilita tracing adiado quando configurado. +2. **Restauração de estado** — se `inputs["id"]` existe e `@persist` está configurado, carrega o snapshot mais recente. +3. **`FlowStarted`** — emitido apenas no primeiro turno da sessão adiada. +4. **`prepare_conversational_turn`** — acrescenta a mensagem do usuário em `state.messages`, define `last_user_message`, limpa `last_intent`, classifica opcionalmente quando `intents` / `default_intents` + `intent_llm` estão definidos. +5. **Execução do grafo** — `@start` → `@router` → handlers `@listen`. +6. **Fim da execução** — `flow_finished` por turno e finalização de trace são **ignorados** com adiamento; `Agent.kickoff()` / crews aninhados também não fecham o batch pai. + +Os handlers devem chamar **`append_message("assistant", reply)`** para que o próximo turno inclua a resposta do assistente. A linha do usuário já é salva no kickoff — não acrescente de novo nos handlers. + +## `ConversationalConfig` (padrões em nível de classe) + +Defina na subclasse de `Flow` como `conversational_config: ClassVar[ConversationalConfig | None]`. + +| Campo | Padrão | Propósito | +|-------|---------|-----------| +| `default_intents` | `None` | Rótulos de outcome para classificação automática antes do kickoff | +| `intent_llm` | `None` | Modelo para classificação (obrigatório quando há intents) | +| `interactive_prompt` | `"You: "` | Prompt para `kickoff(interactive=True)` | +| `interactive_timeout` | `None` | Timeout por linha no modo interativo | +| `exit_commands` | `exit`, `quit` | Palavras que encerram o modo interativo | +| `defer_trace_finalization` | `True` | Manter um batch de trace aberto entre turnos | + +Sobrescreva por kickoff com `intents=` e `intent_llm=`. + +## `ChatState` (formato persistido recomendado) + +```python +from crewai.flow import ChatState + + +class MyChatState(ChatState): + # Herdados: id, messages, last_user_message, last_intent, session_ready + research_turn_count: int = 0 + custom_flag: bool = False +``` + +| Campo | Função | +|-------|--------| +| `id` | UUID da sessão (igual a `session_id` / `inputs["id"]`) | +| `messages` | `list` de `{role, content}` para histórico de LLM | +| `last_user_message` | Última linha do usuário neste turno | +| `last_intent` | Rótulo de rota após classificação (se usado) | +| `session_ready` | Flag de bootstrap único (permissões, caches, etc.) | + +`ConversationalInputs` é um `TypedDict` para `kickoff(inputs={...})`: `id`, `user_message`, `last_intent`. + +## API conversacional em `Flow` + +### Parâmetros de `kickoff` / `kickoff_async` + +| Parâmetro | Propósito | +|-----------|-----------| +| `user_message` | Texto deste turno (ou `{"role": "user", "content": "..."}`) | +| `session_id` | UUID da conversa → `inputs["id"]` / `state.id` | +| `intents` | Rótulos de outcome para `classify_intent` antes do kickoff | +| `intent_llm` | LLM para classificação (obrigatório com `intents`) | +| `interactive` | Loop CLI via `ask()` (só demos locais) | +| `interactive_prompt` | Prompt no modo interativo | +| `interactive_timeout` | Timeout de `ask()` por linha | +| `exit_commands` | Palavras que encerram o modo interativo | +| `inputs` | Campos extras de estado (mesclados com chaves conversacionais) | +| `restore_from_state_id` | Hidratação fork de outro flow persistido | + +### Atributos de instância + +| Atributo | Propósito | +|-----------|-----------| +| `conversational_config` | Padrões `ConversationalConfig` em nível de classe | +| `defer_trace_finalization` | Flag de instância; definida automaticamente a partir do config no kickoff | +| `suppress_flow_events` | Oculta painéis Rich no console; **tracing ainda registra** eventos | +| `stream` | Habilita streaming; use com `ChatSession.handle_turn(..., stream=True)` | + +### Métodos e propriedades + +| Nome | Descrição | +|------|-------------| +| `append_message(role, content, **extra)` | Acrescenta em `state.messages` (roles: `user`, `assistant`, `system`, `tool`) | +| `conversation_messages` | Histórico somente leitura para chamadas LLM | +| `classify_intent(text, outcomes, *, llm, context=None)` | Mapeia texto a um outcome (mesma lógica de `@human_feedback`) | +| `receive_user_message(text, *, outcomes=None, llm=None)` | Acrescenta mensagem do usuário; opcionalmente define `last_intent` | +| `finalize_session_traces()` | Emite `flow_finished` adiado e finaliza o batch de trace da sessão | +| `_should_defer_trace_finalization()` | Se este flow adia finalização de trace por turno | +| `input_history` | Trilha de auditoria de prompts e respostas de `ask()` | + +### Helpers do módulo (`crewai.flow.conversation`) + +Importáveis para testes ou orquestração customizada: + +| Função | Descrição | +|----------|-------------| +| `normalize_kickoff_inputs(inputs, user_message=..., session_id=...)` | Mescla kwargs conversacionais em `inputs` | +| `get_conversation_messages(flow)` | Lê mensagens do estado ou buffer interno | +| `append_message(flow, role, content, **extra)` | Igual ao método de instância | +| `prepare_conversational_turn(flow, ...)` | Hidratação do turno (geralmente chamado pelo kickoff) | +| `receive_user_message(flow, text, ...)` | Igual ao método de instância | +| `set_state_field(flow, name, value)` | Define campo em estado dict ou Pydantic | +| `get_conversational_config(flow)` | Lê `conversational_config` da classe | +| `input_history_to_messages(entries)` | Converte `input_history` para formato de mensagens LLM | + +## Padrões de roteamento de intenção + +### A. Pré-classificar via `ConversationalConfig` (mais simples) + +Defina `default_intents` e `intent_llm`. Cada kickoff classifica antes do `@router`; leia `self.state.last_intent` em `route()`. + +### B. Classificar dentro do `@router` (prompts mais ricos) + +Defina `default_intents=None` para o kickoff só acrescentar a mensagem. Em `route()`, chame `classify_intent` com prompt ou descrições customizadas: + +```python +@router(bootstrap) +def route(self): + intent = self.classify_intent( + self._routing_prompt(self.state.last_user_message), + ("GREETING", "ORDER", "RESEARCH", "GOODBYE"), + llm=self.conversational_config.intent_llm or "gpt-4o-mini", + ) + self.state.last_intent = intent + return intent +``` + +Use **`@listen("RESEARCH")`** (ou similar) para passos com `Agent.kickoff()` e ferramentas — não `LLM.call()` puro — quando precisar de pesquisa web ou uso multi-etapa de tools. + +## Quando o flow termina mas o usuário continua conversando + +`FlowFinished` significa que **esta execução do grafo** terminou. A conversa segue com outro `kickoff` e o mesmo `session_id`. `@persist` restaura `messages`, flags e contexto. + +**Padrão de persistência:** prefira `@persist` em um **único passo terminal** (por exemplo `finalize`) em vez de na classe `Flow` inteira. Persist em nível de classe salva após cada método; `load_state` usa a linha mais recente, que pode ser snapshot no meio da execução e perder atualizações dos handlers no mesmo turno. + +Não use `@human_feedback` para linhas de chat de follow-up, a menos que um humano precise aprovar uma saída específica antes de exibi-la. + +## `ConversationalFlow` de alto nível (experimental) + +`crewai.experimental.ConversationalFlow` é uma subclasse opinativa que cuida do encanamento por turno para você: já vem com um grafo embutido `@start` / `@router` / `converse_turn` / `end_conversation`, gerencia `state.messages`, dirige o LLM de roteamento e mantém o batch de trace aberto entre os turnos. Você escreve as **rotas customizadas**; o framework cuida do resto. + +Use-a quando quiser um chat multi-turno com router LLM e handlers por rota sem cablar o ciclo de vida na mão. Desça para `Flow[ChatState]` (acima) quando precisar de controle total. + +### Exemplo rápido + +```python +from crewai import LLM +from crewai.experimental import ConversationConfig, ConversationalFlow, RouterConfig +from crewai.flow import listen + + +ROUTER_LLM = LLM(model="gpt-4o-mini") + + +@ConversationConfig( + system_prompt="A multi-agent assistant for ordinary chat and tool-backed tasks.", + llm=ROUTER_LLM, + router=RouterConfig(), # rotas + descrições auto-descobertas pelos handlers @listen +) +class SupportFlow(ConversationalFlow): + @listen("INTERNET_SEARCH") + def handle_internet_search(self) -> str: + """Fresh web research, current news, real-time lookups.""" + ... + self.append_assistant_message(reply) + return reply + + @listen("CREWAI_DOCS") + def handle_crewai_docs(self) -> str: + """Look up the CrewAI documentation for framework/API questions.""" + ... + self.append_assistant_message(reply) + return reply + + +flow = SupportFlow() +try: + flow.handle_turn("O que você pode fazer?") # roteia para converse (built-in) + flow.handle_turn("Pesquise na web por notícias de IA.") # roteia para INTERNET_SEARCH + flow.handle_turn("Resuma o primeiro resultado.") # volta para converse +finally: + flow.finalize_session_traces() +``` + +### `ConversationConfig` + +Decorador de classe que anexa os defaults de chat por classe. + +| Campo | Padrão | Propósito | +|-------|--------|-----------| +| `system_prompt` | `slices.conversational_system_prompt` (i18n) | System message usado pelo `converse_turn` embutido. Passe `""` para desativar totalmente. | +| `llm` | `None` | LLM de conversa (usado pelo `converse_turn` e como fallback do router). | +| `router` | `None` | `RouterConfig` para roteamento por LLM. Sem ele, o flow sempre cai em `converse`. | +| `answer_from_history_prompt` | padrão do framework | System message para a rota opcional `answer_from_history`. | +| `answer_from_history_llm` | `None` | Habilita o atalho `answer_from_history` quando definido. | +| `intent_llm` | `None` | LLM para o caminho legado `intents=`/`default_intents`. | +| `default_intents` | `None` | Labels de outcome para pré-classificação legada. | +| `visible_agent_outputs` | `None` | `"all"` ou lista de nomes de agentes cujos `append_agent_result()` devem virar mensagens públicas. | +| `defer_trace_finalization` | `True` | Mantém um único batch de trace aberto entre chamadas de `handle_turn()`. | + +### `RouterConfig` e o catálogo de rotas auto-gerado + +```python +RouterConfig( + prompt="Enquadramento de domínio opcional (política, voz, persona).", + response_format=MyRoute, # opcional; auto-gerado caso contrário + llm=ROUTER_LLM, # usa ConversationConfig.llm como fallback + routes=["INTERNET_SEARCH", "CREWAI_DOCS"], # opcional; inferido dos listeners + route_descriptions={ + "INTERNET_SEARCH": "Sobrescreve a docstring só desta rota.", + }, + default_intent="converse", # usado quando a chamada ao LLM falha ou não há LLM + fallback_intent="converse", # usado quando o LLM retorna rota inválida + intent_field="intent", +) +``` + +O prompt do router é montado automaticamente. Para cada rota o framework escolhe a descrição nesta precedência: + +1. `RouterConfig.route_descriptions[label]` — override explícito. +2. `ConversationalFlow.builtin_route_descriptions[label]` — texto canônico do framework para `converse`, `end`, `answer_from_history` (otimizado para o LLM de routing). +3. Primeira linha não vazia da docstring do handler `@listen(label)`. +4. Vazio (a rota aparece no catálogo sem descrição). + +Na prática, **adicionar uma rota é `@listen("X")` + uma docstring de uma linha**: + +```python +@listen("INTERNET_SEARCH") +def handle_internet_search(self) -> str: + """Fresh web research, current news, real-time lookups.""" + ... +``` + +…e o LLM de routing vê: + +``` +Routes: +- CREWAI_DOCS: Look up the CrewAI documentation for framework/API questions. +- INTERNET_SEARCH: Fresh web research, current news, real-time lookups. +- converse: Ordinary chat, follow-ups, summaries, clarifications… +- end: User signals the conversation is finished (goodbye, exit, done). +``` + +`RouterConfig.prompt` é para **enquadramento de domínio** (persona do assistente, regras de negócio, voz). O catálogo de rotas é auto-gerado — não liste rotas em `prompt`; elas vão sair de sincronia assim que você adicionar um handler. + +### Rotas embutidas + +| Rota | Handler | Propósito | +|------|---------|-----------| +| `converse` | `converse_turn` | Handler de chat padrão. Chama `ConversationConfig.llm` com o system prompt + histórico canônico. | +| `end` | `end_conversation` | Define `state.ended = True` e emite uma resposta de encerramento. | +| `answer_from_history` | `answer_from_history_turn` | Opcional. Cai aqui quando `ConversationConfig.answer_from_history_llm` está definido e a mensagem pode ser respondida só pelo histórico. | + +Você pode sobrescrever qualquer uma definindo um handler com o mesmo nome na subclasse. + +### Semântica de `handle_turn()` + +`flow.handle_turn(message)` roda um turno: + +1. Reseta o tracking por execução (`_completed_methods`, `_method_outputs`) para o grafo re-rodar — sem isso, chamadas repetidas de `kickoff` na mesma instância dariam curto-circuito no turno 2+ porque `Flow.kickoff_async` trata `inputs={"id": ...}` como restauração de checkpoint. +2. Anexa a mensagem do usuário em `state.messages`, define `current_user_message` / `last_user_message`. `last_intent` é **preservado do turno anterior** para que o LLM de routing possa usá-lo como sinal. +3. Roda `conversation_start` → `route_conversation` → o handler `@listen` escolhido. +4. O router grava sua decisão em `state.last_intent` (visível para o contexto de routing do próximo turno). +5. Se seu handler retornou uma string e ainda não chamou `append_assistant_message`, `handle_turn` anexa para você. + +Você também pode chamar `flow.kickoff(user_message=..., session_id=...)` diretamente — a mesma lógica de reset/run é acionada. `handle_turn` é o wrapper ergonômico. + +### Comportamento customizado do router + +Para rodar efeitos colaterais (setup de event bus, telemetria) em toda decisão de routing, sobrescreva `route_turn`: + +```python +class SupportFlow(ConversationalFlow): + def route_turn(self, context: dict[str, Any]) -> str | None: + self.event_bus = MyBus(self) + return super().route_turn(context) +``` + +Para ignorar o router LLM e escolher uma rota programaticamente, retorne uma string de `route_turn`; retornar `None` cai no `_route_with_config(...)`. + +### `append_assistant_message` e `append_agent_result` + +Dentro de um handler `@listen(label)`, escolha: + +- `self.append_assistant_message(text)` — adiciona um turno de assistente visível ao usuário em `state.messages`. O `converse_turn` do próximo turno vai vê-lo. +- `self.append_agent_result(agent_name, result, visibility="private")` — registra um evento estruturado em `state.events` e uma thread em `state.agent_threads[agent_name]`. Visibilidade pública também chama `append_assistant_message` automaticamente. Use resultados privados para trabalho de bastidor que não deve poluir o histórico canônico. + +`ConversationConfig.visible_agent_outputs` pode promover globalmente os resultados privados de agentes específicos para públicos (`"all"` ou lista de nomes). + +## Tracing entre turnos + +Com `defer_trace_finalization=True` (padrão em `ConversationalConfig`): + +- **Um batch de trace** para toda a sessão de chat. +- **`flow_started`** só no primeiro turno; **`flow_finished`** uma vez em `finalize_session_traces()`. +- **`kickoff` por turno** não exibe “Trace batch finalized”. +- **Trabalho aninhado** (`Agent.kickoff()`, crews, tools Exa) acrescenta ao batch **pai**; flows internos de `AgentExecutor` não fecham o batch da sessão cedo. + +```python +try: + while True: + line = input("You: ").strip() + if not line: + break + flow.kickoff(user_message=line, session_id=session_id) +finally: + flow.finalize_session_traces() +``` + +`ChatSession.close()` chama `finalize_session_traces()` quando o adiamento está habilitado. + +`suppress_flow_events=True` só oculta painéis do console; eventos de trace e método ainda são emitidos. + +### Ciclo de vida de trace do `ConversationalFlow` + +A [`ConversationalFlow`](#conversationalflow-de-alto-nível-experimental) experimental usa o mesmo ciclo de vida de tracing: `defer_trace_finalization` é `True` por padrão, então cada `handle_turn()` mantém o trace da sessão aberto. Sempre finalize ao fim da sessão — envolva seu loop em `try/finally` e chame `flow.finalize_session_traces()` na saída. Sem isso, o batch fica aberto e a última conversa pode nunca ser exportada. + +## Streaming + +Defina `stream = True` na classe `Flow`. `kickoff(...)` então emitirá `assistant_delta` (e eventos relacionados) pelo event bus padrão. + +## Imports + +```python +from crewai.flow import ( + ChatState, + ConversationalConfig, + ConversationalInputs, + Flow, + listen, + persist, + router, + start, +) +``` + +## Veja também + +- [Dominando o Gerenciamento de Estado em Flows](/pt-BR/guides/flows/mastering-flow-state) — persistência, estado Pydantic, `@persist` +- [Construa Seu Primeiro Flow](/pt-BR/guides/flows/first-flow) — fundamentos de flow +- Demo: `lib/crewai/runner_conversational_flow_simple.py` — REPL mínimo com `RESEARCH` + agente Exa diff --git a/docs/pt-BR/guides/flows/first-flow.mdx b/docs/pt-BR/guides/flows/first-flow.mdx index 07ed7ae934..cb2330a90c 100644 --- a/docs/pt-BR/guides/flows/first-flow.mdx +++ b/docs/pt-BR/guides/flows/first-flow.mdx @@ -614,6 +614,7 @@ Agora que você construiu seu primeiro flow, pode: 3. Explorar as funções `and_` e `or_` para execuções paralelas e mais complexas 4. Conectar seu flow a APIs externas, bancos de dados ou interfaces de usuário 5. Combinar múltiplos crews especializados em um único flow +6. Criar apps de chat multi-turn com [Flows conversacionais](/pt-BR/guides/flows/conversational-flows) (`kickoff` por mensagem, `ChatSession`, tracing adiado) Parabéns! Você construiu seu primeiro CrewAI Flow que combina código regular, chamadas diretas a LLM e processamento baseado em crews para criar um guia abrangente. Essas habilidades fundamentais permitem criar aplicações de IA cada vez mais sofisticadas, capazes de resolver problemas complexos de múltiplas etapas por meio de controle procedural e inteligência colaborativa. diff --git a/docs/pt-BR/guides/flows/mastering-flow-state.mdx b/docs/pt-BR/guides/flows/mastering-flow-state.mdx index 6589b51ada..1d3e6ee42d 100644 --- a/docs/pt-BR/guides/flows/mastering-flow-state.mdx +++ b/docs/pt-BR/guides/flows/mastering-flow-state.mdx @@ -22,6 +22,8 @@ Um gerenciamento de estado efetivo possibilita que você: 5. **Escalone suas aplicações** – Ofereça suporte a workflows complexos com organização apropriada dos dados 6. **Habilite aplicações conversacionais** – Armazene e acesse o histórico da conversa para interações de IA com contexto +Para chat multi-turn (`kickoff` por linha do usuário, `ChatState`, roteamento por intenção, tracing adiado e `ChatSession`), veja [Flows conversacionais](/pt-BR/guides/flows/conversational-flows). + Vamos explorar como aproveitar essas capacidades de forma eficiente. ## Fundamentos do Gerenciamento de Estado diff --git a/lib/crewai/src/crewai/events/event_listener.py b/lib/crewai/src/crewai/events/event_listener.py index 107f85428b..10028cb00f 100644 --- a/lib/crewai/src/crewai/events/event_listener.py +++ b/lib/crewai/src/crewai/events/event_listener.py @@ -306,20 +306,24 @@ def on_flow_started(source: Any, event: FlowStartedEvent) -> None: self._telemetry.flow_execution_span( event.flow_name, list(source._methods.keys()) ) - self.formatter.handle_flow_created(event.flow_name, str(source.flow_id)) - self.formatter.handle_flow_started(event.flow_name, str(source.flow_id)) + if not getattr(source, "suppress_flow_events", False): + self.formatter.handle_flow_created(event.flow_name, str(source.flow_id)) + self.formatter.handle_flow_started(event.flow_name, str(source.flow_id)) @crewai_event_bus.on(FlowFinishedEvent) def on_flow_finished(source: Any, event: FlowFinishedEvent) -> None: - self.formatter.handle_flow_status( - event.flow_name, - source.flow_id, - ) + if not getattr(source, "suppress_flow_events", False): + self.formatter.handle_flow_status( + event.flow_name, + source.flow_id, + ) @crewai_event_bus.on(MethodExecutionStartedEvent) def on_method_execution_started( - _: Any, event: MethodExecutionStartedEvent + source: Any, event: MethodExecutionStartedEvent ) -> None: + if getattr(source, "suppress_flow_events", False): + return self.formatter.handle_method_status( event.method_name, "running", @@ -327,8 +331,10 @@ def on_method_execution_started( @crewai_event_bus.on(MethodExecutionFinishedEvent) def on_method_execution_finished( - _: Any, event: MethodExecutionFinishedEvent + source: Any, event: MethodExecutionFinishedEvent ) -> None: + if getattr(source, "suppress_flow_events", False): + return self.formatter.handle_method_status( event.method_name, "completed", diff --git a/lib/crewai/src/crewai/events/listeners/tracing/first_time_trace_handler.py b/lib/crewai/src/crewai/events/listeners/tracing/first_time_trace_handler.py index 436d50c27d..e6fb4b32e3 100644 --- a/lib/crewai/src/crewai/events/listeners/tracing/first_time_trace_handler.py +++ b/lib/crewai/src/crewai/events/listeners/tracing/first_time_trace_handler.py @@ -222,6 +222,8 @@ def _reset_batch_state(self) -> None: return self.batch_manager.batch_owner_type = None self.batch_manager.batch_owner_id = None + self.batch_manager.defer_session_finalization = False + self.batch_manager._batch_finalized = False self.batch_manager.current_batch = None self.batch_manager.event_buffer.clear() self.batch_manager.trace_batch_id = None diff --git a/lib/crewai/src/crewai/events/listeners/tracing/trace_batch_manager.py b/lib/crewai/src/crewai/events/listeners/tracing/trace_batch_manager.py index a20234a77c..ea1ed5ef00 100644 --- a/lib/crewai/src/crewai/events/listeners/tracing/trace_batch_manager.py +++ b/lib/crewai/src/crewai/events/listeners/tracing/trace_batch_manager.py @@ -70,6 +70,8 @@ def __init__(self) -> None: self.execution_start_times: dict[str, datetime] = {} self.batch_owner_type: str | None = None self.batch_owner_id: str | None = None + self.defer_session_finalization: bool = False + self._batch_finalized: bool = False self.backend_initialized: bool = False self.ephemeral_trace_url: str | None = None try: @@ -101,6 +103,7 @@ def initialize_batch( user_context=user_context, execution_metadata=execution_metadata ) self.is_current_batch_ephemeral = use_ephemeral + self._batch_finalized = False self.record_start_time("execution") @@ -312,6 +315,9 @@ def _send_events_to_backend(self) -> int: def finalize_batch(self) -> TraceBatch | None: """Finalize batch and return it for sending""" + if self._batch_finalized: + return None + if not self.current_batch or not is_tracing_enabled_in_context(): return None @@ -340,10 +346,8 @@ def finalize_batch(self) -> TraceBatch | None: self.current_batch.events = sorted_events events_sent_count = len(sorted_events) if sorted_events: - original_buffer = self.event_buffer self.event_buffer = sorted_events events_sent_to_backend_status = self._send_events_to_backend() - self.event_buffer = original_buffer if events_sent_to_backend_status == 500 and self.trace_batch_id: self._mark_batch_as_failed( self.trace_batch_id, "Error sending events to backend" @@ -360,6 +364,7 @@ def finalize_batch(self) -> TraceBatch | None: self.event_buffer.clear() self.trace_batch_id = None self.is_current_batch_ephemeral = False + self._batch_finalized = True self._cleanup_batch_data() @@ -371,7 +376,7 @@ def _finalize_backend_batch(self, events_count: int = 0) -> None: Args: events_count: Number of events that were successfully sent """ - if not self.plus_api or not self.trace_batch_id: + if self._batch_finalized or not self.plus_api or not self.trace_batch_id: return try: @@ -390,6 +395,7 @@ def _finalize_backend_batch(self, events_count: int = 0) -> None: ) if response.status_code == 200: + self._batch_finalized = True access_code = response.json().get("access_code", None) console = Console() settings = Settings() diff --git a/lib/crewai/src/crewai/events/listeners/tracing/trace_listener.py b/lib/crewai/src/crewai/events/listeners/tracing/trace_listener.py index 23f4524e33..013cb7fff4 100644 --- a/lib/crewai/src/crewai/events/listeners/tracing/trace_listener.py +++ b/lib/crewai/src/crewai/events/listeners/tracing/trace_listener.py @@ -1,5 +1,6 @@ """Trace collection listener for orchestrating trace collection.""" +from datetime import datetime, timezone import os from typing import Any, ClassVar import uuid @@ -230,7 +231,15 @@ def on_flow_created(source: Any, event: FlowCreatedEvent) -> None: @event_bus.on(FlowStartedEvent) def on_flow_started(source: Any, event: FlowStartedEvent) -> None: - # Always call _initialize_flow_batch to claim ownership. + # Nested flows (e.g. AgentExecutor inside a conversational Flow) must + # not re-claim an open session batch owned by the parent kickoff. + if ( + self.batch_manager.defer_session_finalization + and self.batch_manager.is_batch_initialized() + and self.batch_manager.batch_owner_type == "flow" + ): + self._handle_trace_event("flow_started", source, event) + return # If batch was already initialized by a concurrent action event # (race condition), initialize_batch() returns early but # batch_owner_type is still correctly set to "flow". @@ -264,18 +273,20 @@ def _register_context_event_handlers(self, event_bus: CrewAIEventsBus) -> None: @event_bus.on(CrewKickoffStartedEvent) def on_crew_started(source: Any, event: CrewKickoffStartedEvent) -> None: - if self.batch_manager.batch_owner_type != "flow": - # Always call _initialize_crew_batch to claim ownership. - # If batch was already initialized by a concurrent action event - # (e.g. LLM/tool before crew_kickoff_started), initialize_batch() - # returns early but batch_owner_type is still correctly set to "crew". - # Skip only when a parent flow already owns the batch. + # Nested crew inside Flow.kickoff: never claim an existing flow session batch. + if not self._nested_in_flow_execution() and ( + not self.batch_manager.is_batch_initialized() + ): self._initialize_crew_batch(source, event) self._handle_trace_event("crew_kickoff_started", source, event) @event_bus.on(CrewKickoffCompletedEvent) def on_crew_completed(source: Any, event: CrewKickoffCompletedEvent) -> None: self._handle_trace_event("crew_kickoff_completed", source, event) + if self.batch_manager.defer_session_finalization: + return + if self._nested_in_flow_execution(): + return if self.batch_manager.batch_owner_type == "crew": if self.first_time_handler.is_first_time: self.first_time_handler.mark_events_collected() @@ -286,10 +297,14 @@ def on_crew_completed(source: Any, event: CrewKickoffCompletedEvent) -> None: @event_bus.on(CrewKickoffFailedEvent) def on_crew_failed(source: Any, event: CrewKickoffFailedEvent) -> None: self._handle_trace_event("crew_kickoff_failed", source, event) + if self.batch_manager.defer_session_finalization: + return + if self._nested_in_flow_execution(): + return if self.first_time_handler.is_first_time: self.first_time_handler.mark_events_collected() self.first_time_handler.handle_execution_completion() - else: + elif self.batch_manager.batch_owner_type == "crew": self.batch_manager.finalize_batch() @event_bus.on(TaskStartedEvent) @@ -707,8 +722,32 @@ def _register_system_event_handlers(self, event_bus: CrewAIEventsBus) -> None: @on_signal def handle_signal(source: Any, event: SignalEvent) -> None: """Flush trace batch on system signals to prevent data loss.""" - if self.batch_manager.is_batch_initialized(): - self.batch_manager.finalize_batch() + if not self.batch_manager.is_batch_initialized(): + return + # Multi-turn flows defer batch finalization to finalize_session_traces(). + if self.batch_manager.defer_session_finalization: + return + self.batch_manager.finalize_batch() + + @staticmethod + def _is_inside_active_flow_context() -> bool: + """True when ``kickoff_async`` has set ``current_flow_id`` (nested crew).""" + from crewai.flow.flow_context import current_flow_id + + return current_flow_id.get() is not None + + def _flow_owns_trace_batch(self) -> bool: + """True when an in-flight conversational flow already owns the trace batch.""" + if self.batch_manager.batch_owner_type == "flow": + return True + batch = self.batch_manager.current_batch + if batch is not None: + return batch.execution_metadata.get("execution_type") == "flow" + return False + + def _nested_in_flow_execution(self) -> bool: + """True when a crew runs inside a flow session (context or batch ownership).""" + return self._is_inside_active_flow_context() or self._flow_owns_trace_batch() def _initialize_crew_batch(self, source: Any, event: BaseEvent) -> None: """Initialize trace batch. @@ -729,6 +768,33 @@ def _initialize_crew_batch(self, source: Any, event: BaseEvent) -> None: self._initialize_batch(user_context, execution_metadata) + def _try_initialize_flow_batch_from_context(self, event: Any) -> bool: + """Claim a flow trace batch when an action event fires inside kickoff. + + When ``suppress_flow_events=True``, console panels are hidden but + ``FlowStartedEvent`` and method lifecycle events still emit; if no + batch exists yet, LLM/tool events must not fall back to implicit crew + batches. + """ + from crewai.flow.flow_context import current_flow_id, current_flow_name + + flow_id = current_flow_id.get() + if flow_id is None: + return False + + started_at = getattr(event, "timestamp", None) or datetime.now(timezone.utc) + user_context = self._get_user_context() + execution_metadata = { + "flow_name": current_flow_name.get() or "Unknown Flow", + "execution_start": started_at, + "crewai_version": get_crewai_version(), + "execution_type": "flow", + } + self.batch_manager.batch_owner_type = "flow" + self.batch_manager.batch_owner_id = flow_id + self._initialize_batch(user_context, execution_metadata) + return True + def _initialize_flow_batch(self, source: Any, event: BaseEvent) -> None: """Initialize trace batch for Flow execution. @@ -793,12 +859,19 @@ def _handle_action_event(self, event_type: str, source: Any, event: Any) -> None event: Event object. """ if not self.batch_manager.is_batch_initialized(): - user_context = self._get_user_context() - execution_metadata = { - "crew_name": getattr(source, "name", "Unknown Crew"), - "crewai_version": get_crewai_version(), - } - self._initialize_batch(user_context, execution_metadata) + if self._try_initialize_flow_batch_from_context(event): + pass + elif not self._nested_in_flow_execution(): + user_context = self._get_user_context() + execution_metadata = { + "crew_name": getattr(source, "name", "Unknown Crew"), + "crewai_version": get_crewai_version(), + } + self.batch_manager.batch_owner_type = "crew" + self.batch_manager.batch_owner_id = getattr( + source, "id", str(uuid.uuid4()) + ) + self._initialize_batch(user_context, execution_metadata) self.batch_manager.begin_event_processing() try: diff --git a/lib/crewai/src/crewai/experimental/__init__.py b/lib/crewai/src/crewai/experimental/__init__.py index 662a722f32..d9fd6b2263 100644 --- a/lib/crewai/src/crewai/experimental/__init__.py +++ b/lib/crewai/src/crewai/experimental/__init__.py @@ -1,4 +1,13 @@ from crewai.experimental.agent_executor import AgentExecutor, CrewAgentExecutorFlow +from crewai.experimental.conversational_flow import ( + AgentMessage, + ConversationConfig, + ConversationEvent, + ConversationMessage, + ConversationState, + ConversationalFlow, + RouterConfig, +) from crewai.experimental.evaluation import ( AgentEvaluationResult, AgentEvaluator, @@ -24,7 +33,13 @@ "AgentEvaluationResult", "AgentEvaluator", "AgentExecutor", + "AgentMessage", "BaseEvaluator", + "ConversationConfig", + "ConversationEvent", + "ConversationMessage", + "ConversationState", + "ConversationalFlow", "CrewAgentExecutorFlow", # Deprecated alias for AgentExecutor "EvaluationScore", "EvaluationTraceCallback", @@ -35,6 +50,7 @@ "MetricCategory", "ParameterExtractionEvaluator", "ReasoningEfficiencyEvaluator", + "RouterConfig", "SemanticQualityEvaluator", "ToolInvocationEvaluator", "ToolSelectionEvaluator", diff --git a/lib/crewai/src/crewai/experimental/conversational_flow.py b/lib/crewai/src/crewai/experimental/conversational_flow.py new file mode 100644 index 0000000000..b9d56ed3f8 --- /dev/null +++ b/lib/crewai/src/crewai/experimental/conversational_flow.py @@ -0,0 +1,759 @@ +"""Experimental higher-level conversational Flow abstraction.""" + +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from dataclasses import dataclass +from enum import Enum +import json +from typing import Any, ClassVar, Literal, cast +from uuid import uuid4 + +from pydantic import BaseModel, ConfigDict, Field, create_model + +from crewai.flow.conversation import get_conversation_messages +from crewai.flow.flow import Flow, listen, router, start +from crewai.llms.base_llm import BaseLLM +from crewai.utilities.i18n import I18N_DEFAULT +from crewai.utilities.types import LLMMessage + + +# Pydantic rebuilds inherited Flow annotations in this module's namespace. +# Keep the forward reference resolvable without importing crewai.context here, +# which would create a cycle while crewai.experimental is importing. +ExecutionContext = Any + +ConversationMessageRole = Literal["user", "assistant", "system", "tool"] +ConversationEventVisibility = Literal["private", "public"] + + +@dataclass +class RouterConfig: + """Class-level LLM router configuration for ``ConversationalFlow``. + + ``route_descriptions`` overrides the per-route descriptions used to build + the router LLM's "available routes" catalog. Routes without an entry fall + back to the handler's docstring first line (or, for built-in routes, the + framework's canned description). ``prompt`` is reserved for domain + policy/voice, not the route catalog — that's auto-built. + """ + + prompt: str | None = None + response_format: type[BaseModel] | None = None + llm: Any | None = None + routes: Sequence[str] | None = None + route_descriptions: dict[str, str] | None = None + default_intent: str | None = "converse" + fallback_intent: str | None = "converse" + intent_field: str = "intent" + + +@dataclass +class ConversationConfig: + """Class-level configuration for experimental conversational flows. + + ``system_prompt`` defaults to the ``slices.conversational_system_prompt`` + translation when left as ``None``. Pass an empty string to opt out of any + system prompt for ``converse_turn``. ``answer_from_history_prompt`` falls + back to ``slices.conversational_answer_from_history_prompt`` when ``None``. + """ + + system_prompt: str | None = None + llm: Any | None = None + router: RouterConfig | None = None + answer_from_history_prompt: str | None = None + default_intents: Sequence[str] | None = None + intent_llm: Any | None = None + answer_from_history_llm: Any | None = None + visible_agent_outputs: Sequence[str] | Literal["all"] | None = None + defer_trace_finalization: bool = True + + def __call__(self, flow_cls: type[Any]) -> type[Any]: + """Use this config as a class decorator.""" + flow_cls.conversational_config = self + return flow_cls + + +class ConversationMessage(BaseModel): + """Canonical user-facing message shared across conversational turns.""" + + model_config = ConfigDict(arbitrary_types_allowed=True) + + role: ConversationMessageRole + content: str | list[dict[str, Any]] | None + name: str | None = None + tool_call_id: str | None = None + tool_calls: list[dict[str, Any]] | None = None + files: dict[str, Any] | None = None + metadata: dict[str, Any] = Field(default_factory=dict) + + +class AgentMessage(BaseModel): + """Private per-agent message or scratch result.""" + + role: ConversationMessageRole | str = "assistant" + content: Any + metadata: dict[str, Any] = Field(default_factory=dict) + + +class ConversationEvent(BaseModel): + """Structured trace/event that is separate from user-visible messages.""" + + type: str + payload: dict[str, Any] = Field(default_factory=dict) + agent_name: str | None = None + visibility: ConversationEventVisibility = "private" + + +class ConversationState(BaseModel): + """Structured state for ``ConversationalFlow``. + + ``messages`` is the canonical user-facing history. Agent/tool scratch work + belongs in ``events`` or ``agent_threads`` unless explicitly made public. + """ + + id: str = Field(default_factory=lambda: str(uuid4())) + messages: list[ConversationMessage] = Field(default_factory=list) + current_user_message: str | None = None + last_user_message: str | None = None + last_intent: str | None = None + ended: bool = False + events: list[ConversationEvent] = Field(default_factory=list) + agent_threads: dict[str, list[AgentMessage]] = Field(default_factory=dict) + session_ready: bool = False + + +def _message_to_llm_dict(message: Any) -> LLMMessage: + if isinstance(message, BaseModel): + data = message.model_dump(exclude_none=True) + elif isinstance(message, dict): + data = dict(message) + else: + data = {"role": "user", "content": str(message)} + + return cast( + LLMMessage, + {key: value for key, value in data.items() if key != "metadata"}, + ) + + +class ConversationalFlow(Flow[ConversationState]): + """Flow base class for turn-oriented conversational applications. + + Subclasses define normal ``@listen("intent")`` handlers. The inherited + start/router methods turn each ``handle_turn()`` call into one Flow run. + """ + + conversational_config: ClassVar[ConversationConfig | None] = None + builtin_routes: ClassVar[tuple[str, ...]] = ("converse", "end") + internal_routes: ClassVar[tuple[str, ...]] = ( + "answer_from_history", + "conversation_start", + ) + builtin_route_descriptions: ClassVar[dict[str, str]] = { + "converse": ( + "Ordinary chat, follow-ups, summaries, clarifications, and " + "questions answerable from prior conversation history." + ), + "end": ("User signals the conversation is finished (goodbye, exit, done)."), + "answer_from_history": ( + "Answer directly from prior conversation history without invoking " + "tools, agents, or custom routes." + ), + } + + @start() + def conversation_start(self) -> str | None: + """Internal Flow entrypoint for a single chat turn.""" + return self.state.current_user_message + + @router(conversation_start) + def route_conversation(self) -> str: + """Route the current turn to a listener label.""" + context = self.build_router_context() + configured_route = self.route_turn(context) + if configured_route: + self.state.last_intent = configured_route + return configured_route + + if self.state.last_intent: + return self.state.last_intent + + if self.can_answer_from_history(context): + self.state.last_intent = "answer_from_history" + return "answer_from_history" + + self.state.last_intent = "converse" + return "converse" + + @listen("converse") + def converse_turn(self) -> str: + """Built-in chat handler over canonical conversation history.""" + llm = self._default_conversation_llm() + if llm is None: + content = "I can continue the conversation once an LLM is configured." + self.append_assistant_message(content) + return content + + messages: list[LLMMessage] = [] + system_prompt = self._resolve_system_prompt() + if system_prompt: + messages.append({"role": "system", "content": system_prompt}) + messages.extend(self.conversation_messages) + + response = self._coerce_llm(llm).call(messages=messages) + content = self._stringify_result(response) + self.append_assistant_message(content) + return content + + @listen("end") + def end_conversation(self) -> str: + """Built-in conversation terminator.""" + self.state.ended = True + content = "Conversation ended." + self.append_assistant_message(content) + return content + + @listen("answer_from_history") + def answer_from_history_turn(self) -> str | None: + """Answer directly from canonical conversation history when configured.""" + config = self._conversation_config + if config is None: + return None + llm = config.answer_from_history_llm + if llm is None: + return None + + llm_instance = self._coerce_llm(llm) + messages: list[LLMMessage] = [ + { + "role": "system", + "content": self._resolve_answer_from_history_prompt(), + }, + *self.build_agent_context("answer_from_history"), + ] + response = llm_instance.call(messages=messages) + content = self._stringify_result(response) + self.append_assistant_message(content) + return content + + def kickoff(self, *args: Any, **kwargs: Any) -> Any: + """Run one conversational turn. + + Every call into ``ConversationalFlow`` is a turn, so reset the graph + execution tracking before delegating. Without this, calls after the + first see ``_completed_methods`` populated, ``Flow.kickoff_async`` + flips ``_is_execution_resuming = True``, every method short-circuits, + and the prior turn's output is returned. The persisted Pydantic state + (messages, current_user_message, etc.) is preserved on ``self._state``. + + Checkpoint restores deliberately keep ``_completed_methods`` populated + so paused work resumes; skip the reset in that path. + """ + is_checkpoint_restore = kwargs.get("from_checkpoint") is not None or ( + len(args) >= 3 and args[2] is not None + ) + if not is_checkpoint_restore: + self._reset_turn_execution_state() + return super().kickoff(*args, **kwargs) + + def handle_turn( + self, + message: str, + *, + session_id: str | None = None, + intents: Sequence[str] | None = None, + intent_llm: str | BaseLLM | None = None, + **kickoff_kwargs: Any, + ) -> Any: + """Append a user message, run one conversational turn, and return output.""" + assistant_count = self._assistant_message_count() + result = self.kickoff( + user_message=message, + session_id=session_id or self.state.id, + intents=intents, + intent_llm=intent_llm, + **kickoff_kwargs, + ) + if ( + result is not None + and self._assistant_message_count() == assistant_count + and self._is_public_turn_result(result) + ): + self.append_assistant_message(self._stringify_result(result)) + return result + + def build_router_context(self) -> dict[str, Any]: + """Build context used by the routing policy for the current turn.""" + return { + "system_prompt": self._resolve_system_prompt(), + "current_user_message": self.state.current_user_message, + "message_history": self.conversation_messages, + "events": [event.model_dump() for event in self.state.events], + "last_intent": self.state.last_intent, + } + + def build_agent_context(self, agent_name: str) -> list[LLMMessage]: + """Build canonical message context for an agent or direct LLM call.""" + messages = list(self.conversation_messages) + thread = self.state.agent_threads.get(agent_name, []) + messages.extend( + cast( + LLMMessage, + {"role": msg.role, "content": self._stringify_result(msg.content)}, + ) + for msg in thread + ) + return messages + + def route_turn(self, context: dict[str, Any]) -> str | None: + """Route with ``ConversationConfig.router`` when configured.""" + config = self._conversation_config + if config is None or config.router is None: + return None + return self._route_with_config(config.router, context) + + def can_answer_from_history(self, context: dict[str, Any]) -> bool: + """Return whether this turn can be answered from message history.""" + config = self._conversation_config + if config is None or config.answer_from_history_llm is None: + return False + if len(self.conversation_messages) < 2: + return False + + feedback = ( + f"{self._resolve_answer_from_history_prompt()}\n\n" + f"Current user message: {context.get('current_user_message')}\n\n" + f"Message history:\n{self._format_messages(self.conversation_messages)}" + ) + outcome = self._collapse_to_outcome( + feedback, + ("answer_from_history", "route_to_flow"), + config.answer_from_history_llm, + ) + return outcome == "answer_from_history" + + def append_agent_result( + self, + agent_name: str, + result: Any, + *, + visibility: Literal["private", "public"] = "private", + metadata: dict[str, Any] | None = None, + ) -> None: + """Record an agent result, optionally making it visible to the user.""" + content = self._stringify_result(result) + event_visibility = self._resolve_visibility(agent_name, visibility) + event = ConversationEvent( + type="agent_result", + agent_name=agent_name, + visibility=event_visibility, + payload={"content": content, **(metadata or {})}, + ) + self.state.events.append(event) + self.state.agent_threads.setdefault(agent_name, []).append( + AgentMessage(content=content, metadata=metadata or {}) + ) + if event_visibility == "public": + self.append_assistant_message(content) + + def append_assistant_message( + self, + content: str, + *, + metadata: dict[str, Any] | None = None, + ) -> None: + """Append a final user-visible assistant message.""" + self.state.messages.append( + ConversationMessage( + role="assistant", + content=content, + metadata=metadata or {}, + ) + ) + + @property + def conversation_messages(self) -> list[LLMMessage]: + """Canonical user-facing message history as LLM-compatible dicts.""" + return [ + _message_to_llm_dict(message) for message in get_conversation_messages(self) + ] + + @property + def _conversation_config(self) -> ConversationConfig | None: + return getattr(type(self), "conversational_config", None) + + def _reset_turn_execution_state(self) -> None: + """Clear per-execution tracking so the next turn re-runs the graph. + + Mirrors what ``Flow.kickoff_async`` does on a non-restoring run: drops + completed-method tracking, per-method call counts, and pending listener + bookkeeping. ``self._state`` (messages, current_user_message, etc.) is + deliberately untouched so the conversation continues uninterrupted. + """ + self._completed_methods.clear() + self._method_outputs.clear() + self._pending_and_listeners.clear() + self._method_call_counts.clear() + self._clear_or_listeners() + self._is_execution_resuming = False + + def _resolve_system_prompt(self) -> str | None: + """Return the effective conversational system prompt. + + ``None`` on the config (the default) resolves to the i18n base prompt; + an empty string is treated as an explicit opt-out. + """ + config = self._conversation_config + if config is None or config.system_prompt is None: + return I18N_DEFAULT.slice("conversational_system_prompt") + return config.system_prompt or None + + def _resolve_answer_from_history_prompt(self) -> str: + """Return the effective ``answer_from_history`` prompt. + + ``None`` (the default) falls back to the i18n slice. Unlike + ``system_prompt``, this prompt is always needed when the route runs, + so it does not support an empty-string opt-out. + """ + config = self._conversation_config + if config is None or not config.answer_from_history_prompt: + return I18N_DEFAULT.slice("conversational_answer_from_history_prompt") + return config.answer_from_history_prompt + + def receive_user_message( + self, + text: str, + *, + outcomes: Sequence[str] | None = None, + llm: str | BaseLLM | None = None, + ) -> str: + """Append a user turn and optionally classify its intent. + + ``last_intent`` is preserved across turns so the router prompt can use + the prior turn's route as a signal (e.g., follow-up after RESEARCH + should usually route to ``converse``). The legacy intent-classification + path below still overwrites it when outcomes are provided, and + ``route_conversation`` reassigns it on every router decision. + """ + self.state.messages.append(ConversationMessage(role="user", content=text)) + self.state.current_user_message = text + self.state.last_user_message = text + + if outcomes and llm is not None: + intent = self.classify_intent( + text, + outcomes, + llm=llm, + context=self.conversation_messages, + ) + self.state.last_intent = intent + return intent + + return text + + def _apply_pending_conversational_turn(self) -> None: + if self._pending_user_message is None: + return + + text = self._coerce_user_message_text(self._pending_user_message) + if not text.strip(): + return + + config = self._conversation_config + outcomes = self._pending_intents + if outcomes is None and config is not None: + outcomes = config.default_intents + + llm = self._pending_intent_llm + if llm is None and config is not None: + llm = config.intent_llm + + if outcomes: + if llm is None: + raise ValueError("intent_llm is required when intents are provided") + self.receive_user_message(text, outcomes=outcomes, llm=llm) + else: + self.receive_user_message(text) + + def _route_with_config( + self, + router_config: RouterConfig, + context: dict[str, Any], + ) -> str | None: + router_llm = self._default_router_llm(router_config) + if router_llm is None: + return router_config.default_intent + + try: + llm = self._coerce_llm(router_llm) + response = self._call_router_llm( + llm, + messages=self._build_router_messages(router_config, context), + response_format=self._router_response_format(router_config), + ) + intent = self._extract_router_intent(response, router_config.intent_field) + except Exception: + return router_config.fallback_intent or router_config.default_intent + + if intent is None: + return router_config.fallback_intent or router_config.default_intent + + valid_labels = self._effective_routes(router_config) + if valid_labels and intent not in valid_labels: + return router_config.fallback_intent or router_config.default_intent + + return intent + + def _default_router_llm(self, router_config: RouterConfig) -> Any | None: + config = self._conversation_config + return ( + router_config.llm + or (config.intent_llm if config else None) + or (config.llm if config else None) + ) + + def _router_response_format( + self, + router_config: RouterConfig, + ) -> type[BaseModel]: + if router_config.response_format is not None: + return router_config.response_format + + routes = sorted(self._effective_routes(router_config)) + field_definitions: dict[str, Any] = { + router_config.intent_field: ( + str, + Field(description=f"One of: {', '.join(routes)}"), + ) + } + return cast( + type[BaseModel], + create_model( + "ConversationRoute", + **field_definitions, + ), + ) + + def _call_router_llm( + self, + llm: Any, + *, + messages: list[LLMMessage], + response_format: type[BaseModel], + ) -> Any: + """Call the router LLM with CrewAI's response_format naming. + + Older local LLM implementations may still expose ``response_model``; + keep the compatibility fallback isolated from the public config shape. + """ + try: + return llm.call(messages=messages, response_format=response_format) + except TypeError as exc: + if "response_format" not in str(exc): + raise + return llm.call(messages=messages, response_model=response_format) + + def _build_router_messages( + self, + router_config: RouterConfig, + context: dict[str, Any], + ) -> list[LLMMessage]: + catalog = self._build_route_catalog(router_config) + context = { + **context, + "available_routes": sorted(catalog.keys()), + } + domain_prompt = f"{router_config.prompt}\n\n" if router_config.prompt else "" + routes_section = "Routes:\n" + "\n".join( + f"- {label}: {description}" if description else f"- {label}" + for label, description in sorted(catalog.items()) + ) + routing_prompt = ( + domain_prompt + + routes_section + + "\n\nChoose exactly one route from the list above. Prefer " + "'converse' for follow-ups, summaries, and clarifications about " + "prior turns — even if they touch on a topic the user previously " + "invoked a custom route for. Use a custom route only when the user " + "is making a fresh request for that tool or workflow." + ) + return [ + {"role": "system", "content": routing_prompt}, + { + "role": "user", + "content": json.dumps(context, default=str), + }, + ] + + def _build_route_catalog( + self, + router_config: RouterConfig | None, + ) -> dict[str, str]: + """Build a ``{label: description}`` catalog for the router prompt. + + Priority per route: + 1. ``router_config.route_descriptions`` override (user-provided). + 2. ``builtin_route_descriptions`` (framework-canned for converse/end/ + answer_from_history — phrased for LLM routing). + 3. First non-empty line of the ``@listen`` handler's docstring. + 4. Empty (route appears in the catalog without a description). + """ + label_to_method: dict[str, str] = {} + for listener_name, condition in self._listeners.items(): + if isinstance(condition, tuple): + _, trigger_labels = condition + for trigger_label in trigger_labels: + label_to_method.setdefault(str(trigger_label), str(listener_name)) + + routes = self._effective_routes(router_config) + overrides = ( + router_config.route_descriptions + if router_config and router_config.route_descriptions + else {} + ) + + catalog: dict[str, str] = {} + for route_label in routes: + if route_label in overrides: + catalog[route_label] = overrides[route_label] + continue + if route_label in self.builtin_route_descriptions: + catalog[route_label] = self.builtin_route_descriptions[route_label] + continue + + handler_name = label_to_method.get(route_label) + description = "" + if handler_name: + method = getattr(type(self), handler_name, None) + doc = getattr(method, "__doc__", None) + if doc: + description = doc.strip().split("\n", 1)[0].strip() + catalog[route_label] = description + + return catalog + + def _extract_router_intent(self, response: Any, intent_field: str) -> str | None: + if isinstance(response, BaseModel): + value = getattr(response, intent_field, None) + elif isinstance(response, dict): + value = response.get(intent_field) + elif isinstance(response, str): + try: + parsed = json.loads(response) + except json.JSONDecodeError: + value = response.strip() + else: + value = parsed.get(intent_field) + else: + value = getattr(response, intent_field, None) + + if value is None: + return None + if isinstance(value, Enum): + return str(value.value) + return str(value) + + def _valid_route_labels(self) -> set[str]: + labels: set[str] = set() + for condition in self._listeners.values(): + if isinstance(condition, tuple): + _, methods = condition + labels.update(str(method) for method in methods) + return labels + + def _effective_routes(self, router_config: RouterConfig | None = None) -> set[str]: + custom_routes = set(router_config.routes or ()) if router_config else set() + if not custom_routes: + custom_routes = ( + self._valid_route_labels() + - set(self.builtin_routes) + - set(self.internal_routes) + ) + return custom_routes | set(self.builtin_routes) + + def _default_conversation_llm(self) -> Any | None: + config = self._conversation_config + if config is None: + return None + if config.llm is not None: + return config.llm + if config.answer_from_history_llm is not None: + return config.answer_from_history_llm + if config.router is not None: + return config.router.llm + return config.intent_llm + + def _resolve_visibility( + self, + agent_name: str, + visibility: Literal["private", "public"], + ) -> Literal["private", "public"]: + if visibility == "public": + return "public" + config = self._conversation_config + visible = config.visible_agent_outputs if config else None + if visible == "all" or (visible is not None and agent_name in visible): + return "public" + return "private" + + def _is_public_turn_result(self, result: Any) -> bool: + if not isinstance(result, str): + return False + if result in { + "conversation", + "converse", + "end", + "answer_from_history", + "route_to_flow", + }: + return False + return result != self.state.last_intent + + def _assistant_message_count(self) -> int: + return sum(1 for message in self.state.messages if message.role == "assistant") + + @staticmethod + def _coerce_user_message_text(user_message: str | dict[str, Any] | Any) -> str: + if isinstance(user_message, str): + return user_message + if isinstance(user_message, dict) and user_message.get("content") is not None: + return str(user_message["content"]) + return str(user_message) + + @staticmethod + def _stringify_result(result: Any) -> str: + if hasattr(result, "raw"): + return str(result.raw) + if isinstance(result, BaseModel): + return result.model_dump_json() + return str(result) + + @staticmethod + def _format_messages(messages: Sequence[Mapping[str, Any]]) -> str: + return "\n".join( + f"{message.get('role', 'user')}: {message.get('content', '')}" + for message in messages + ) + + @staticmethod + def _coerce_llm(llm: str | BaseLLM | Any) -> Any: + from crewai.llm import LLM + from crewai.llms.base_llm import BaseLLM as BaseLLMClass + + if isinstance(llm, str): + return LLM(model=llm) + if isinstance(llm, BaseLLMClass) or callable(getattr(llm, "call", None)): + return llm + raise ValueError(f"Invalid llm type: {type(llm)}. Expected str or BaseLLM.") + + +__all__ = [ + "AgentMessage", + "ConversationConfig", + "ConversationEvent", + "ConversationMessage", + "ConversationState", + "ConversationalFlow", + "RouterConfig", +] diff --git a/lib/crewai/src/crewai/flow/__init__.py b/lib/crewai/src/crewai/flow/__init__.py index 6922725fa5..7142403adb 100644 --- a/lib/crewai/src/crewai/flow/__init__.py +++ b/lib/crewai/src/crewai/flow/__init__.py @@ -4,6 +4,11 @@ HumanFeedbackProvider, PendingFeedbackContext, ) +from crewai.flow.conversation import ( + ChatState, + ConversationalConfig, + ConversationalInputs, +) from crewai.flow.flow import Flow, and_, listen, or_, router, start from crewai.flow.flow_config import flow_config from crewai.flow.flow_serializer import flow_structure @@ -18,7 +23,10 @@ __all__ = [ + "ChatState", "ConsoleProvider", + "ConversationalConfig", + "ConversationalInputs", "Flow", "FlowStructure", "HumanFeedbackPending", diff --git a/lib/crewai/src/crewai/flow/conversation.py b/lib/crewai/src/crewai/flow/conversation.py new file mode 100644 index 0000000000..98a519b657 --- /dev/null +++ b/lib/crewai/src/crewai/flow/conversation.py @@ -0,0 +1,246 @@ +"""Conversational turn helpers for CrewAI Flows. + +Provides message history utilities, kickoff input normalization, and optional +class-level defaults via ``ConversationalConfig``. Session identity is ``state.id`` +(``inputs["id"]`` / ``kickoff(session_id=...)``), not a separate Flow field. +""" + +from __future__ import annotations + +from collections.abc import Sequence +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Any, Literal, TypedDict, cast +from uuid import uuid4 + +from pydantic import BaseModel, Field + +from crewai.utilities.types import LLMMessage + + +if TYPE_CHECKING: + from crewai.flow.flow import Flow + from crewai.llms.base_llm import BaseLLM + + +_EXIT_COMMANDS_DEFAULT: tuple[str, ...] = ("exit", "quit") + + +class ConversationalInputs(TypedDict, total=False): + """Conventional ``kickoff(inputs=...)`` keys for chat turns.""" + + id: str + user_message: str | dict[str, Any] + last_intent: str + + +@dataclass +class ConversationalConfig: + """Optional class-level defaults for conversational flows. + + Override per kickoff via ``user_message``, ``session_id``, ``intents``, etc. + """ + + default_intents: Sequence[str] | None = None + intent_llm: str | None = None + interactive_prompt: str = "You: " + interactive_timeout: float | None = None + exit_commands: Sequence[str] = field(default_factory=lambda: _EXIT_COMMANDS_DEFAULT) + defer_trace_finalization: bool = True + + +class ChatState(BaseModel): + """Recommended persisted state shape for multi-turn flows.""" + + id: str = Field(default_factory=lambda: str(uuid4())) + messages: list[LLMMessage] = Field(default_factory=list) + last_user_message: str | None = None + last_intent: str | None = None + session_ready: bool = False + + +def _coerce_user_message_text(user_message: str | dict[str, Any] | Any) -> str: + if isinstance(user_message, str): + return user_message + if isinstance(user_message, dict): + content = user_message.get("content") + if content is not None: + return str(content) + return str(user_message) + + +def normalize_kickoff_inputs( + inputs: dict[str, Any] | None, + *, + user_message: str | dict[str, Any] | None = None, + session_id: str | None = None, +) -> dict[str, Any] | None: + """Merge conversational kickoff kwargs into the inputs dict. + + Returns ``None`` when the caller passed no inputs and no conversational + kwargs — so ``FlowStartedEvent.inputs`` stays ``None`` for stateless flows + instead of being materialized as an empty dict. + """ + if inputs is None and user_message is None and session_id is None: + return None + + merged: dict[str, Any] = dict(inputs or {}) + + if session_id is not None: + merged["id"] = session_id + + if user_message is not None: + merged["user_message"] = user_message + + return merged + + +def get_conversation_messages(flow: Flow[Any]) -> list[LLMMessage]: + """Read message history from flow state or the internal fallback buffer.""" + buffer: list[LLMMessage] = getattr(flow, "_conversation_messages", []) + state = getattr(flow, "_state", None) + if state is None: + return list(buffer) + + if isinstance(state, dict): + messages = state.get("messages") + if isinstance(messages, list): + return cast(list[LLMMessage], messages) + elif isinstance(state, BaseModel) and hasattr(state, "messages"): + messages = getattr(state, "messages", None) + if isinstance(messages, list): + return cast(list[LLMMessage], messages) + + return list(buffer) + + +def append_message( + flow: Flow[Any], + role: Literal["user", "assistant", "system", "tool"], + content: str, + **extra: Any, +) -> None: + """Append a message to ``state.messages`` or the flow fallback buffer.""" + message: LLMMessage = {"role": role, "content": content} + for key, value in extra.items(): + if key in ("tool_call_id", "name", "tool_calls", "files"): + message[key] = value # type: ignore[literal-required] + + state = getattr(flow, "_state", None) + if state is not None: + if isinstance(state, dict): + messages = state.get("messages") + if isinstance(messages, list): + messages.append(message) + return + elif isinstance(state, BaseModel) and hasattr(state, "messages"): + messages = getattr(state, "messages", None) + if messages is None: + object.__setattr__(state, "messages", []) + messages = state.messages + if isinstance(messages, list): + messages.append(message) + return + + if not hasattr(flow, "_conversation_messages"): + object.__setattr__(flow, "_conversation_messages", []) + flow._conversation_messages.append(message) + + +def set_state_field(flow: Flow[Any], name: str, value: Any) -> None: + """Set a field on structured or dict flow state when present.""" + state = getattr(flow, "_state", None) + if state is None: + return + if isinstance(state, dict): + state[name] = value + elif isinstance(state, BaseModel) and hasattr(state, name): + object.__setattr__(state, name, value) + + +def receive_user_message( + flow: Flow[Any], + text: str, + *, + outcomes: Sequence[str] | None = None, + llm: str | BaseLLM | None = None, +) -> str: + """Record a user turn: append message and optionally classify intent.""" + append_message(flow, "user", text) + set_state_field(flow, "last_user_message", text) + + if outcomes and llm is not None: + intent = flow.classify_intent( + text, + outcomes, + llm=llm, + context=get_conversation_messages(flow), + ) + set_state_field(flow, "last_intent", intent) + return intent + + return text + + +def prepare_conversational_turn( + flow: Flow[Any], + *, + user_message: str | dict[str, Any] | None = None, + intents: Sequence[str] | None = None, + intent_llm: str | BaseLLM | None = None, + config: ConversationalConfig | None = None, +) -> None: + """Hydrate conversation state after inputs are merged into flow state.""" + if user_message is None: + state = getattr(flow, "_state", None) + if isinstance(state, dict) and "user_message" in state: + user_message = state["user_message"] + elif isinstance(state, BaseModel) and hasattr(state, "user_message"): + user_message = getattr(state, "user_message", None) + + if user_message is None: + return + + text = _coerce_user_message_text(user_message) + if not text.strip(): + return + + # Fresh classification each turn (do not reuse prior turn's route label). + set_state_field(flow, "last_intent", None) + + resolved_intents = intents + if resolved_intents is None and config is not None: + resolved_intents = config.default_intents + + resolved_llm = intent_llm + if resolved_llm is None and config is not None: + resolved_llm = config.intent_llm + + if resolved_intents: + if resolved_llm is None: + raise ValueError("intent_llm is required when intents are provided") + receive_user_message( + flow, + text, + outcomes=resolved_intents, + llm=resolved_llm, + ) + else: + receive_user_message(flow, text) + + +def input_history_to_messages(entries: Sequence[Any]) -> list[LLMMessage]: + """Convert ``Flow.input_history`` entries to LLM message format.""" + messages: list[LLMMessage] = [] + for entry in entries: + prompt = entry.get("message") if isinstance(entry, dict) else None + response = entry.get("response") if isinstance(entry, dict) else None + if prompt: + messages.append({"role": "assistant", "content": str(prompt)}) + if response: + messages.append({"role": "user", "content": str(response)}) + return messages + + +def get_conversational_config(flow: Flow[Any]) -> ConversationalConfig | None: + """Return class-level ``conversational_config`` if defined.""" + return getattr(type(flow), "conversational_config", None) diff --git a/lib/crewai/src/crewai/flow/flow.py b/lib/crewai/src/crewai/flow/flow.py index 1ac8b9fdef..312cb4c9a2 100644 --- a/lib/crewai/src/crewai/flow/flow.py +++ b/lib/crewai/src/crewai/flow/flow.py @@ -13,6 +13,7 @@ Iterable, Iterator, KeysView, + Mapping, Sequence, ValuesView, ) @@ -54,12 +55,18 @@ from rich.console import Console from rich.panel import Panel -from crewai.events.base_events import reset_emission_counter +from crewai.events.base_events import ( + get_emission_sequence, + reset_emission_counter, + set_emission_counter, +) from crewai.events.event_bus import crewai_event_bus from crewai.events.event_context import ( get_current_parent_id, + get_last_event_id, reset_last_event_id, restore_event_scope, + set_last_event_id, triggered_by_scope, ) from crewai.events.listeners.tracing.trace_listener import ( @@ -83,7 +90,19 @@ MethodExecutionStartedEvent, ) 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.conversation import ( + append_message as _append_conversation_message, + get_conversation_messages, + get_conversational_config, + normalize_kickoff_inputs, + prepare_conversational_turn, + receive_user_message as _receive_user_message, +) +from crewai.flow.flow_context import ( + current_flow_id, + current_flow_name, + current_flow_request_id, +) from crewai.flow.flow_wrappers import ( FlowCondition, FlowConditions, @@ -141,6 +160,7 @@ signal_end, signal_error, ) +from crewai.utilities.types import LLMMessage logger = logging.getLogger(__name__) @@ -942,6 +962,27 @@ def __new__( else: router_paths[attr_name] = [] + for base in bases: + for method_name in getattr(base, "_start_methods", []): + current_method = getattr(cls, method_name, None) + if is_flow_method(current_method) and method_name not in start_methods: + start_methods.append(method_name) + + for method_name, condition in getattr(base, "_listeners", {}).items(): + current_method = getattr(cls, method_name, None) + if is_flow_method(current_method) and method_name not in listeners: + listeners[method_name] = condition + + for method_name in getattr(base, "_routers", set()): + current_method = getattr(cls, method_name, None) + if is_flow_method(current_method): + routers.add(method_name) + + for method_name, paths in getattr(base, "_router_paths", {}).items(): + current_method = getattr(cls, method_name, None) + if is_flow_method(current_method) and method_name not in router_paths: + router_paths[method_name] = paths + cls._start_methods = start_methods # type: ignore[attr-defined] cls._listeners = listeners # type: ignore[attr-defined] cls._routers = routers # type: ignore[attr-defined] @@ -992,6 +1033,13 @@ class Flow(BaseModel, Generic[T], metaclass=FlowMeta): ), ] = Field(default=None) suppress_flow_events: bool = Field(default=False) + defer_trace_finalization: bool = Field( + default=False, + description=( + "When True, do not finalize the trace batch at the end of each kickoff. " + "Call finalize_session_traces() when the chat session ends." + ), + ) human_feedback_history: list[HumanFeedbackResult] = Field(default_factory=list) last_human_feedback: HumanFeedbackResult | None = Field(default=None) @@ -1121,8 +1169,14 @@ def _restore_from_checkpoint(self) -> None: _pending_feedback_context: PendingFeedbackContext | None = PrivateAttr(default=None) _human_feedback_method_outputs: dict[str, Any] = PrivateAttr(default_factory=dict) _input_history: list[InputHistoryEntry] = PrivateAttr(default_factory=list) + _conversation_messages: list[LLMMessage] = PrivateAttr(default_factory=list) + _pending_user_message: str | dict[str, Any] | None = PrivateAttr(default=None) + _pending_intents: Sequence[str] | None = PrivateAttr(default=None) + _pending_intent_llm: str | BaseLLM | None = PrivateAttr(default=None) _state: Any = PrivateAttr(default=None) + conversational_config: ClassVar[Any | None] = None + def __class_getitem__(cls: type[Flow[T]], item: type[T]) -> type[Flow[T]]: # type: ignore[override] class _FlowGeneric(cls): # type: ignore[valid-type,misc] pass @@ -1245,6 +1299,116 @@ def extract_memories(self, content: str) -> list[str]: result: list[str] = self.memory.extract_memories(content) return result + @property + def conversation_messages(self) -> list[LLMMessage]: + """Message history from state or the internal conversation buffer.""" + return get_conversation_messages(self) + + @property + def input_history(self) -> list[InputHistoryEntry]: + """Read-only view of prompts and responses from ``ask()``.""" + return list(self._input_history) + + def append_message( + self, + role: Literal["user", "assistant", "system", "tool"], + content: str, + **extra: Any, + ) -> None: + """Append a message to conversation history on state or the fallback buffer.""" + _append_conversation_message(self, role, content, **extra) + + def classify_intent( + self, + text: str, + outcomes: Sequence[str], + *, + llm: str | BaseLLM, + context: Sequence[Mapping[str, Any]] | None = None, + ) -> str: + """Map user text to one of the given outcomes using an LLM.""" + if context: + context_blob = "\n".join( + f"{m.get('role', 'user')}: {m.get('content', '')}" for m in context + ) + feedback = f"{context_blob}\n\nLatest user message: {text}" + else: + feedback = text + return self._collapse_to_outcome(feedback, outcomes, llm) + + def receive_user_message( + self, + text: str, + *, + outcomes: Sequence[str] | None = None, + llm: str | BaseLLM | None = None, + ) -> str: + """Append a user message and optionally set ``last_intent`` on state.""" + return _receive_user_message( + self, + text, + outcomes=outcomes, + llm=llm, + ) + + def _configure_conversational_kickoff( + self, + *, + inputs: dict[str, Any] | None = None, + user_message: str | dict[str, Any] | None = None, + session_id: str | None = None, + intents: Sequence[str] | None = None, + intent_llm: str | BaseLLM | None = None, + ) -> dict[str, Any] | None: + """Store pending conversational turn options for ``kickoff_async``.""" + config = get_conversational_config(self) + resolved_intents = intents + resolved_llm = intent_llm + if config is not None: + if resolved_intents is None: + resolved_intents = config.default_intents + if resolved_llm is None: + resolved_llm = config.intent_llm + + resolved_message = user_message + if resolved_message is None and inputs and "user_message" in inputs: + resolved_message = inputs["user_message"] + + self._pending_user_message = resolved_message + self._pending_intents = list(resolved_intents) if resolved_intents else None + self._pending_intent_llm = resolved_llm + + if config is not None and config.defer_trace_finalization: + self.defer_trace_finalization = True + from crewai.events.listeners.tracing.trace_listener import ( + TraceCollectionListener, + ) + + TraceCollectionListener().batch_manager.defer_session_finalization = True + + return normalize_kickoff_inputs( + inputs, + user_message=resolved_message, + session_id=session_id, + ) + + def _clear_conversational_kickoff(self) -> None: + self._pending_user_message = None + self._pending_intents = None + self._pending_intent_llm = None + + def _apply_pending_conversational_turn(self) -> None: + if self._pending_user_message is None: + return + config = get_conversational_config(self) + prepare_conversational_turn( + self, + user_message=self._pending_user_message, + intents=self._pending_intents, + intent_llm=self._pending_intent_llm, + config=config, + ) + def _mark_or_listener_fired(self, listener_name: FlowMethodName) -> bool: """Mark an OR listener as fired atomically. @@ -1570,20 +1734,19 @@ async def resume_async(self, feedback: str = "") -> Any: reset_emission_counter() reset_last_event_id() - if not self.suppress_flow_events: - future = crewai_event_bus.emit( - self, - FlowStartedEvent( - type="flow_started", - flow_name=self.name or self.__class__.__name__, - inputs=None, - ), - ) - if future and isinstance(future, Future): - try: - await asyncio.wrap_future(future) - except Exception: - logger.warning("FlowStartedEvent handler failed", exc_info=True) + future = crewai_event_bus.emit( + self, + FlowStartedEvent( + type="flow_started", + flow_name=self._flow_display_name(), + inputs=None, + ), + ) + if future and isinstance(future, Future): + try: + await asyncio.wrap_future(future) + except Exception: + logger.warning("FlowStartedEvent handler failed", exc_info=True) get_env_context() @@ -1727,29 +1890,10 @@ async def resume_async(self, feedback: str = "") -> Any: ) self._event_futures.clear() - if not self.suppress_flow_events: - future = crewai_event_bus.emit( - self, - FlowFinishedEvent( - type="flow_finished", - flow_name=self.name or self.__class__.__name__, - result=final_result, - state=self._copy_and_serialize_state(), - ), - ) - if future and isinstance(future, Future): - try: - await asyncio.wrap_future(future) - except Exception: - logger.warning("FlowFinishedEvent handler failed", exc_info=True) + if not self._should_defer_trace_finalization(): + await self._emit_flow_finished_async(final_result) - trace_listener = TraceCollectionListener() - if trace_listener.batch_manager.batch_owner_type == "flow": - if trace_listener.first_time_handler.is_first_time: - trace_listener.first_time_handler.mark_events_collected() - trace_listener.first_time_handler.handle_execution_completion() - else: - trace_listener.batch_manager.finalize_batch() + self._finalize_flow_trace_batch() return final_result @@ -2045,6 +2189,15 @@ def kickoff( input_files: dict[str, FileInput] | None = None, from_checkpoint: CheckpointConfig | None = None, restore_from_state_id: str | None = None, + *, + user_message: str | dict[str, Any] | None = None, + session_id: str | None = None, + intents: Sequence[str] | None = None, + intent_llm: str | BaseLLM | None = None, + interactive: bool = False, + interactive_prompt: str | None = None, + interactive_timeout: float | None = None, + exit_commands: Sequence[str] | None = None, ) -> Any | FlowStreamingOutput: """Start the flow execution in a synchronous context. @@ -2064,10 +2217,57 @@ def kickoff( If the referenced state is not found, the kickoff falls back silently to baseline behavior. Cannot be combined with ``from_checkpoint``; passing both raises ``ValueError``. + user_message: Text or ``{"role": "user", "content": "..."}`` for this + chat turn. Appended to ``state.messages`` before the graph runs. + session_id: Conversation session UUID; merged into ``inputs["id"]`` + for ``@persist`` restoration. + intents: Optional outcome labels for pre-kickoff intent classification. + intent_llm: LLM used when ``intents`` is set. + interactive: If True, run a CLI loop (``ask`` per line) until exit or + timeout. For local demos only; APIs should pass ``user_message``. + interactive_prompt: Prompt shown by ``ask()`` in interactive mode. + interactive_timeout: Per-line timeout for interactive ``ask()``. + exit_commands: Words that end interactive mode (default exit, quit). Returns: The final output from the flow or FlowStreamingOutput if streaming. """ + if interactive: + if user_message is not None: + raise ValueError( + "Cannot pass user_message with interactive=True; " + "messages are collected via ask()." + ) + if self.stream: + raise ValueError("interactive=True is not supported with stream=True") + return self._kickoff_interactive( + inputs=inputs, + input_files=input_files, + session_id=session_id, + intents=intents, + intent_llm=intent_llm, + interactive_prompt=interactive_prompt, + interactive_timeout=interactive_timeout, + exit_commands=exit_commands, + restore_from_state_id=restore_from_state_id, + ) + + original_inputs = inputs + has_conversational_kwargs = ( + user_message is not None + or session_id is not None + or intents is not None + or intent_llm is not None + or (inputs is not None and "user_message" in inputs) + ) + inputs = self._configure_conversational_kickoff( + inputs=inputs, + user_message=user_message, + session_id=session_id, + intents=intents, + intent_llm=intent_llm, + ) + if from_checkpoint is not None and restore_from_state_id is not None: raise ValueError( "Cannot combine `from_checkpoint` and `restore_from_state_id`. " @@ -2076,7 +2276,19 @@ def kickoff( ) restored = apply_checkpoint(self, from_checkpoint) if restored is not None: - return restored.kickoff(inputs=inputs, input_files=input_files) + restored_kwargs: dict[str, Any] = { + "inputs": inputs if has_conversational_kwargs else original_inputs, + "input_files": input_files, + } + if user_message is not None: + restored_kwargs["user_message"] = user_message + if session_id is not None: + restored_kwargs["session_id"] = session_id + if intents is not None: + restored_kwargs["intents"] = intents + if intent_llm is not None: + restored_kwargs["intent_llm"] = intent_llm + return restored.kickoff(**restored_kwargs) if self.stream: result_holder: list[Any] = [] current_task_info: TaskInfo = { @@ -2099,6 +2311,10 @@ def run_flow() -> None: inputs=inputs, input_files=input_files, restore_from_state_id=restore_from_state_id, + user_message=self._pending_user_message, + session_id=inputs.get("id") if inputs else None, + intents=self._pending_intents, + intent_llm=self._pending_intent_llm, ) result_holder.append(result) except Exception as e: @@ -2122,11 +2338,18 @@ def run_flow() -> None: return streaming_output async def _run_flow() -> Any: - return await self.kickoff_async( - inputs, - input_files, - restore_from_state_id=restore_from_state_id, - ) + try: + return await self.kickoff_async( + inputs, + input_files, + restore_from_state_id=restore_from_state_id, + user_message=self._pending_user_message, + session_id=inputs.get("id") if inputs else None, + intents=self._pending_intents, + intent_llm=self._pending_intent_llm, + ) + finally: + self._clear_conversational_kickoff() try: asyncio.get_running_loop() @@ -2136,12 +2359,75 @@ async def _run_flow() -> Any: except RuntimeError: return asyncio.run(_run_flow()) + def _kickoff_interactive( + self, + *, + inputs: dict[str, Any] | None, + input_files: dict[str, FileInput] | None, + session_id: str | None, + intents: Sequence[str] | None, + intent_llm: str | BaseLLM | None, + interactive_prompt: str | None, + interactive_timeout: float | None, + exit_commands: Sequence[str] | None, + restore_from_state_id: str | None, + ) -> Any: + config = get_conversational_config(self) + prompt = cast( + str, + interactive_prompt + or (getattr(config, "interactive_prompt", "You: ") if config else "You: "), + ) + timeout = ( + interactive_timeout + if interactive_timeout is not None + else (getattr(config, "interactive_timeout", None) if config else None) + ) + exit_values = exit_commands or cast( + Sequence[str], + getattr(config, "exit_commands", ("exit", "quit")) + if config + else ("exit", "quit"), + ) + exits = {c.strip().lower() for c in exit_values} + sid = session_id + if sid is None and inputs and "id" in inputs: + sid = str(inputs["id"]) + if sid is None: + sid = str(uuid4()) + + last_result: Any = None + while True: + line = self.ask(prompt, timeout=timeout) + if line is None or line.strip().lower() in exits: + break + turn_inputs = self._configure_conversational_kickoff( + inputs=inputs, + user_message=line, + session_id=sid, + intents=intents, + intent_llm=intent_llm, + ) + last_result = self.kickoff( + inputs=turn_inputs, + input_files=input_files, + restore_from_state_id=restore_from_state_id, + ) + restore_from_state_id = None + self._clear_conversational_kickoff() + return last_result + async def kickoff_async( self, inputs: dict[str, Any] | None = None, input_files: dict[str, FileInput] | None = None, from_checkpoint: CheckpointConfig | None = None, restore_from_state_id: str | None = None, + *, + user_message: str | dict[str, Any] | None = None, + session_id: str | None = None, + intents: Sequence[str] | None = None, + intent_llm: str | BaseLLM | None = None, ) -> Any | FlowStreamingOutput: """Start the flow execution asynchronously. @@ -2162,10 +2448,30 @@ async def kickoff_async( separate persistence key. If the referenced state is not found, falls back silently to baseline. Cannot be combined with ``from_checkpoint``; passing both raises ``ValueError``. + user_message: User text for this conversational turn. + session_id: Session UUID (``inputs["id"]``). + intents: Optional labels for pre-kickoff classification. + intent_llm: LLM for classification when ``intents`` is set. Returns: The final output from the flow, which is the result of the last executed method. """ + original_inputs = inputs + has_conversational_kwargs = ( + user_message is not None + or session_id is not None + or intents is not None + or intent_llm is not None + or (inputs is not None and "user_message" in inputs) + ) + inputs = self._configure_conversational_kickoff( + inputs=inputs, + user_message=user_message, + session_id=session_id, + intents=intents, + intent_llm=intent_llm, + ) + if from_checkpoint is not None and restore_from_state_id is not None: raise ValueError( "Cannot combine `from_checkpoint` and `restore_from_state_id`. " @@ -2174,7 +2480,19 @@ async def kickoff_async( ) restored = apply_checkpoint(self, from_checkpoint) if restored is not None: - return await restored.kickoff_async(inputs=inputs, input_files=input_files) + restored_kwargs: dict[str, Any] = { + "inputs": inputs if has_conversational_kwargs else original_inputs, + "input_files": input_files, + } + if user_message is not None: + restored_kwargs["user_message"] = user_message + if session_id is not None: + restored_kwargs["session_id"] = session_id + if intents is not None: + restored_kwargs["intents"] = intents + if intent_llm is not None: + restored_kwargs["intent_llm"] = intent_llm + return await restored.kickoff_async(**restored_kwargs) if self.stream: result_holder: list[Any] = [] current_task_info: TaskInfo = { @@ -2227,10 +2545,13 @@ async def run_flow() -> None: flow_id_token = None request_id_token = None + flow_name_token = None if current_flow_id.get() is None: flow_id_token = current_flow_id.set(self.flow_id) if current_flow_request_id.get() is None: request_id_token = current_flow_request_id.set(self.flow_id) + if current_flow_name.get() is None: + flow_name_token = current_flow_name.set(self._flow_display_name()) try: # Reset flow state for fresh execution unless restoring from persistence @@ -2313,39 +2634,68 @@ async def run_flow() -> None: f"No flow state found for UUID: {restore_uuid}", color="red" ) - # Update state with any additional inputs (ignoring the 'id' key) - filtered_inputs = {k: v for k, v in inputs.items() if k != "id"} + # Update state with any additional inputs (ignoring conversational keys) + filtered_inputs = { + k: v + for k, v in inputs.items() + if k not in ("id", "user_message", "last_intent") + } if filtered_inputs: self._initialize_state(filtered_inputs) - if get_current_parent_id() is None: + continuing_deferred_session_trace = ( + self._should_defer_trace_finalization() + and getattr(self, "_conversation_trace_started", False) + ) + if get_current_parent_id() is None and continuing_deferred_session_trace: + set_emission_counter( + getattr(self, "_conversation_trace_emission_sequence", 0) + ) + last_event_id = getattr(self, "_conversation_trace_last_event_id", None) + if last_event_id: + set_last_event_id(last_event_id) + started_id = getattr(self, "_conversation_flow_started_event_id", None) + if started_id: + restore_event_scope(((started_id, "flow_started"),)) + elif get_current_parent_id() is None: reset_emission_counter() reset_last_event_id() - if not self.suppress_flow_events: - future = crewai_event_bus.emit( - self, - FlowStartedEvent( - type="flow_started", - flow_name=self.name or self.__class__.__name__, - inputs=inputs, - ), + skip_flow_started = continuing_deferred_session_trace + if not skip_flow_started: + started_event = FlowStartedEvent( + type="flow_started", + flow_name=self._flow_display_name(), + inputs=inputs, ) + future = crewai_event_bus.emit(self, started_event) if future: try: await asyncio.wrap_future(future) except Exception: logger.warning("FlowStartedEvent handler failed", exc_info=True) + if self._should_defer_trace_finalization(): + object.__setattr__(self, "_conversation_trace_started", True) + object.__setattr__( + self, + "_conversation_flow_started_event_id", + started_event.event_id, + ) + from crewai.events.listeners.tracing.trace_listener import ( + TraceCollectionListener, + ) + + TraceCollectionListener().batch_manager.defer_session_finalization = True + if not self.suppress_flow_events: self._log_flow_event( f"Flow started with ID: {self.flow_id}", color="bold magenta" ) - # After FlowStarted (when not suppressed): env events must not pre-empt - # trace batch init with implicit "crew" execution_type. + # After FlowStarted: env events must not pre-empt trace batch init + # with implicit "crew" execution_type. get_env_context() - if inputs is not None and "id" not in inputs: - self._initialize_state(inputs) + self._apply_pending_conversational_turn() if self._is_execution_resuming: await self._replay_recorded_events() @@ -2440,35 +2790,49 @@ async def run_flow() -> None: ) self._event_futures.clear() - if not self.suppress_flow_events: - future = crewai_event_bus.emit( + if not self._should_defer_trace_finalization(): + await self._emit_flow_finished_async(final_output) + + self._finalize_flow_trace_batch() + if self._should_defer_trace_finalization(): + event_record = ( + crewai_event_bus._runtime_state.event_record + if crewai_event_bus._runtime_state is not None + else None + ) + recorded_events = ( + [node.event for node in event_record.nodes.values()] + if event_record is not None + else [] + ) + latest_event = max( + recorded_events, + key=lambda event: event.emission_sequence or 0, + default=None, + ) + latest_sequence = ( + latest_event.emission_sequence + if latest_event is not None + and latest_event.emission_sequence is not None + else 0 + ) + object.__setattr__( self, - FlowFinishedEvent( - type="flow_finished", - flow_name=self.name or self.__class__.__name__, - result=final_output, - state=self._copy_and_serialize_state(), + "_conversation_trace_emission_sequence", + max( + get_emission_sequence(), + latest_sequence, ), ) - if future: - try: - await asyncio.wrap_future(future) - except Exception: - logger.warning( - "FlowFinishedEvent handler failed", exc_info=True - ) - - if not self.suppress_flow_events: - trace_listener = TraceCollectionListener() - if trace_listener.batch_manager.batch_owner_type == "flow": - if trace_listener.first_time_handler.is_first_time: - trace_listener.first_time_handler.mark_events_collected() - trace_listener.first_time_handler.handle_execution_completion() - else: - trace_listener.batch_manager.finalize_batch() + object.__setattr__( + self, + "_conversation_trace_last_event_id", + latest_event.event_id if latest_event else get_last_event_id(), + ) return final_output finally: + self._clear_conversational_kickoff() # Ensure all background memory saves complete before returning if self.memory is not None and hasattr(self.memory, "drain_writes"): self.memory.drain_writes() @@ -2476,6 +2840,8 @@ async def run_flow() -> None: current_flow_request_id.reset(request_id_token) if flow_id_token is not None: current_flow_id.reset(flow_id_token) + if flow_name_token is not None: + current_flow_name.reset(flow_name_token) detach(flow_token) async def akickoff( @@ -2484,6 +2850,11 @@ async def akickoff( input_files: dict[str, FileInput] | None = None, from_checkpoint: CheckpointConfig | None = None, restore_from_state_id: str | None = None, + *, + user_message: str | dict[str, Any] | None = None, + session_id: str | None = None, + intents: Sequence[str] | None = None, + intent_llm: str | BaseLLM | None = None, ) -> Any | FlowStreamingOutput: """Native async method to start the flow execution. Alias for kickoff_async. @@ -2495,6 +2866,10 @@ async def akickoff( restore_from_state_id: Optional UUID of a previously-persisted flow whose latest snapshot should hydrate this run's state. See ``kickoff_async`` for full semantics. + user_message: User text for this conversational turn. + session_id: Session UUID (``inputs["id"]``). + intents: Optional labels for pre-kickoff classification. + intent_llm: LLM for classification when ``intents`` is set. Returns: The final output from the flow, which is the result of the last executed method. @@ -2504,6 +2879,10 @@ async def akickoff( input_files, from_checkpoint, restore_from_state_id=restore_from_state_id, + user_message=user_message, + session_id=session_id, + intents=intents, + intent_llm=intent_llm, ) async def _replay_recorded_events(self) -> None: @@ -2642,27 +3021,26 @@ async def _execute_method( Returns: A tuple of (result, finished_event_id) where finished_event_id is - the event_id of the MethodExecutionFinishedEvent, or None if events - are suppressed. + the event_id of the MethodExecutionFinishedEvent. """ try: dumped_params = {f"_{i}": arg for i, arg in enumerate(args)} | ( kwargs or {} ) - if not self.suppress_flow_events: - future = crewai_event_bus.emit( - self, - MethodExecutionStartedEvent( - type="method_execution_started", - method_name=method_name, - flow_name=self.name or self.__class__.__name__, - params=dumped_params, - state=self._copy_and_serialize_state(), - ), - ) - if future: - self._event_futures.append(future) + # Always emit for tracing; suppress_flow_events only hides console panels. + future = crewai_event_bus.emit( + self, + MethodExecutionStartedEvent( + type="method_execution_started", + method_name=method_name, + flow_name=self.name or self.__class__.__name__, + params=dumped_params, + state=self._copy_and_serialize_state(), + ), + ) + if future: + self._event_futures.append(future) # Set method name in context so ask() can read it without # stack inspection. Must happen before copy_context() so the @@ -2704,18 +3082,17 @@ async def _execute_method( self._completed_methods.add(method_name) finished_event_id: str | None = None - if not self.suppress_flow_events: - finished_event = MethodExecutionFinishedEvent( - type="method_execution_finished", - method_name=method_name, - flow_name=self.name or self.__class__.__name__, - state=self._copy_and_serialize_state(), - result=result, - ) - finished_event_id = finished_event.event_id - future = crewai_event_bus.emit(self, finished_event) - if future: - self._event_futures.append(future) + finished_event = MethodExecutionFinishedEvent( + type="method_execution_finished", + method_name=method_name, + flow_name=self.name or self.__class__.__name__, + state=self._copy_and_serialize_state(), + result=result, + ) + finished_event_id = finished_event.event_id + future = crewai_event_bus.emit(self, finished_event) + if future: + self._event_futures.append(future) return result, finished_event_id except Exception as e: @@ -2730,24 +3107,23 @@ async def _execute_method( self.persistence = SQLiteFlowPersistence() - # Emit paused event (not failed) - if not self.suppress_flow_events: - future = crewai_event_bus.emit( - self, - MethodExecutionPausedEvent( - type="method_execution_paused", - method_name=method_name, - flow_name=self.name or self.__class__.__name__, - state=self._copy_and_serialize_state(), - flow_id=e.context.flow_id, - message=e.context.message, - emit=e.context.emit, - ), - ) - if future: - self._event_futures.append(future) - elif not self.suppress_flow_events: - # Regular failure - emit failed event + # Emit paused event (not failed); always emit for tracing. + future = crewai_event_bus.emit( + self, + MethodExecutionPausedEvent( + type="method_execution_paused", + method_name=method_name, + flow_name=self.name or self.__class__.__name__, + state=self._copy_and_serialize_state(), + flow_id=e.context.flow_id, + message=e.context.message, + emit=e.context.emit, + ), + ) + if future: + self._event_futures.append(future) + else: + # Regular failure - always emit for tracing. future = crewai_event_bus.emit( self, MethodExecutionFailedEvent( @@ -3354,6 +3730,9 @@ def gather_info(self): ), ) + if response: + _append_conversation_message(self, "user", response) + return response def _request_human_feedback( @@ -3543,6 +3922,106 @@ class FeedbackOutcome(BaseModel): ) return outcomes[0] + def _flow_display_name(self) -> str: + return self.name or self.__class__.__name__ + + def _should_defer_trace_finalization(self) -> bool: + if self.defer_trace_finalization: + return True + config = get_conversational_config(self) + return bool(config and config.defer_trace_finalization) + + async def _emit_flow_finished_async(self, result: Any) -> None: + """Emit ``FlowFinishedEvent`` and await handlers.""" + future = crewai_event_bus.emit( + self, + FlowFinishedEvent( + type="flow_finished", + flow_name=self._flow_display_name(), + result=result, + state=self._copy_and_serialize_state(), + ), + ) + if not future: + return + try: + if isinstance(future, Future): + await asyncio.wrap_future(future) + else: + await future + except Exception: + logger.warning("FlowFinishedEvent handler failed", exc_info=True) + + def _emit_flow_finished_sync(self, result: Any) -> None: + """Emit ``FlowFinishedEvent`` from synchronous session teardown.""" + try: + asyncio.get_running_loop() + except RuntimeError: + asyncio.run(self._emit_flow_finished_async(result)) + else: + raise RuntimeError( + "Cannot emit flow_finished synchronously while an event loop is running" + ) + + def finalize_session_traces(self) -> None: + """Finalize the trace batch after a multi-turn conversational session.""" + from crewai.events.event_context import restore_event_scope + from crewai.events.listeners.tracing.trace_listener import ( + TraceCollectionListener, + ) + + trace_listener = TraceCollectionListener() + batch_manager = trace_listener.batch_manager + + if batch_manager._batch_finalized or not batch_manager.is_batch_initialized(): + batch_manager.defer_session_finalization = False + object.__setattr__(self, "_conversation_trace_started", False) + object.__setattr__(self, "_conversation_flow_started_event_id", None) + object.__setattr__(self, "_conversation_trace_emission_sequence", 0) + object.__setattr__(self, "_conversation_trace_last_event_id", None) + return + + result = self._method_outputs[-1] if self._method_outputs else None + if self._should_defer_trace_finalization() and getattr( + self, "_conversation_trace_started", False + ): + started_id = getattr(self, "_conversation_flow_started_event_id", None) + if started_id: + restore_event_scope(((started_id, "flow_started"),)) + try: + self._emit_flow_finished_sync(result) + finally: + restore_event_scope(()) + object.__setattr__(self, "_conversation_flow_started_event_id", None) + + self._finalize_flow_trace_batch(force=True) + object.__setattr__(self, "_conversation_trace_started", False) + object.__setattr__(self, "_conversation_trace_emission_sequence", 0) + object.__setattr__(self, "_conversation_trace_last_event_id", None) + batch_manager.defer_session_finalization = False + + def _finalize_flow_trace_batch(self, *, force: bool = False) -> None: + """Finalize the active trace batch when this flow owns it.""" + from crewai.events.listeners.tracing.trace_listener import ( + TraceCollectionListener, + ) + + trace_listener = TraceCollectionListener() + batch_manager = trace_listener.batch_manager + if not force and ( + self._should_defer_trace_finalization() + or batch_manager.defer_session_finalization + ): + return + + if batch_manager.batch_owner_type != "flow": + return + if trace_listener.first_time_handler.is_first_time: + trace_listener.first_time_handler.mark_events_collected() + trace_listener.first_time_handler.handle_execution_completion() + else: + trace_listener.batch_manager.finalize_batch() + def _log_flow_event( self, message: str, diff --git a/lib/crewai/src/crewai/flow/flow_context.py b/lib/crewai/src/crewai/flow/flow_context.py index 0ff6cf9739..474360aa36 100644 --- a/lib/crewai/src/crewai/flow/flow_context.py +++ b/lib/crewai/src/crewai/flow/flow_context.py @@ -18,3 +18,7 @@ current_flow_method_name: contextvars.ContextVar[str] = contextvars.ContextVar( "flow_method_name", default="unknown" ) + +current_flow_name: contextvars.ContextVar[str | None] = contextvars.ContextVar( + "flow_name", default=None +) diff --git a/lib/crewai/src/crewai/translations/en.json b/lib/crewai/src/crewai/translations/en.json index 51a862026f..9e5c17d41c 100644 --- a/lib/crewai/src/crewai/translations/en.json +++ b/lib/crewai/src/crewai/translations/en.json @@ -35,6 +35,8 @@ "knowledge_search_query": "The original query is: {task_prompt}.", "knowledge_search_query_system_prompt": "Your goal is to rewrite the user query so that it is optimized for retrieval from a vector database. Consider how the query will be used to find relevant documents, and aim to make it more specific and context-aware. \n\n Do not include any other text than the rewritten query, especially any preamble or postamble and only add expected output format if its relevant to the rewritten query. \n\n Focus on the key words of the intended task and to retrieve the most relevant information. \n\n There will be some extra context provided that might need to be removed such as expected_output formats structured_outputs and other instructions.", "human_feedback_collapse": "Based on the following human feedback, determine which outcome best matches their intent.\n\nFeedback: {feedback}\n\nPossible outcomes: {outcomes}\n\nRespond with ONLY one of the exact outcome values listed above, nothing else.", + "conversational_system_prompt": "You are a helpful conversational assistant. Maintain context across turns, answer using the canonical message history when possible, and respond clearly and concisely. Ask for clarification when the user's intent is ambiguous.", + "conversational_answer_from_history_prompt": "Given the current user message and the canonical message history, decide whether the assistant can answer from message_history without running additional tools or agents. If so, answer clearly using only that context.", "hitl_pre_review_system": "You are reviewing content before a human sees it. Apply the lessons from past human feedback to improve the output. Preserve the original meaning and structure, but incorporate the corrections and preferences indicated by the lessons.", "hitl_pre_review_user": "Output to review:\n{output}\n\nLessons from past human feedback:\n{lessons}\n\nApply the lessons to improve the output.", "hitl_distill_system": "You extract generalizable lessons from human feedback on system outputs. A lesson should be a reusable rule or preference that applies to future similar outputs -- not a one-time correction specific to this exact content.\n\nExamples of good lessons:\n- Always include source citations when making factual claims\n- Use bullet points instead of long paragraphs for action items\n- Avoid technical jargon when the audience is non-technical\n\nIf the feedback is just approval (e.g. looks good, approved) or contains no generalizable guidance, return an empty list.", diff --git a/lib/crewai/tests/test_flow_conversation.py b/lib/crewai/tests/test_flow_conversation.py new file mode 100644 index 0000000000..ae817ab426 --- /dev/null +++ b/lib/crewai/tests/test_flow_conversation.py @@ -0,0 +1,1106 @@ +"""Tests for conversational Flow helpers and kickoff parameters.""" + +from __future__ import annotations + +from typing import Any, Literal +from unittest.mock import MagicMock, patch +from uuid import uuid4 + +import pytest +from pydantic import BaseModel, Field + +from crewai.events.event_bus import crewai_event_bus +from crewai.events.listeners.tracing.trace_listener import TraceCollectionListener +from crewai.events.types.flow_events import ( + FlowStartedEvent, + MethodExecutionFinishedEvent, + MethodExecutionStartedEvent, +) +from crewai.events.types.llm_events import LLMCallStartedEvent +from crewai.experimental import ( + ConversationConfig, + ConversationMessage, + ConversationState, + ConversationalFlow, + RouterConfig, +) +from crewai.flow import Flow, ChatState, listen, start +from crewai.flow.flow_context import current_flow_id, current_flow_name +from crewai.flow.conversation import ( + ConversationalConfig, + append_message, + get_conversation_messages, + normalize_kickoff_inputs, + prepare_conversational_turn, +) +from crewai.state import CheckpointConfig +from crewai.utilities.types import LLMMessage + + +class SimpleChatFlow(Flow[ChatState]): + @start() + def begin(self): + return "done" + + +class DictChatFlow(Flow): + @start() + def begin(self): + return self.state.get("marker", "ok") + + +class TestNormalizeKickoffInputs: + def test_merges_session_and_user_message(self) -> None: + merged = normalize_kickoff_inputs( + {"foo": 1}, + user_message="hello", + session_id="sess-1", + ) + assert merged["id"] == "sess-1" + assert merged["user_message"] == "hello" + assert merged["foo"] == 1 + + +class TestMessageHelpers: + def test_append_message_on_pydantic_state(self) -> None: + flow = SimpleChatFlow() + flow._state = ChatState() + append_message(flow, "user", "hi") + assert get_conversation_messages(flow) == [{"role": "user", "content": "hi"}] + + def test_append_message_fallback_buffer(self) -> None: + flow = DictChatFlow() + + class _State: + id = str(uuid4()) + + flow._state = _State() + append_message(flow, "assistant", "reply") + assert get_conversation_messages(flow) == [ + {"role": "assistant", "content": "reply"} + ] + assert flow._conversation_messages == [ + {"role": "assistant", "content": "reply"} + ] + + +class TestIntentPerTurn: + def test_prepare_clears_stale_last_intent(self) -> None: + flow = SimpleChatFlow() + flow._state = ChatState(last_intent="ORDER", messages=[]) + prepare_conversational_turn(flow, user_message="hello") + assert flow.state.last_intent is None + + +class TestKickoffConversational: + def test_kickoff_user_message_hydrates_state(self) -> None: + flow = SimpleChatFlow() + flow.kickoff(user_message="track my order", session_id="session-abc") + + assert flow.state.last_user_message == "track my order" + assert any( + m.get("role") == "user" and m.get("content") == "track my order" + for m in flow.state.messages + ) + assert flow.state.id == "session-abc" + + def test_kickoff_classifies_intent_when_configured(self) -> None: + flow = SimpleChatFlow() + + with patch.object( + flow, + "_collapse_to_outcome", + return_value="order", + ) as mock_collapse: + flow.kickoff( + user_message="where is my package", + session_id="s1", + intents=["order", "help"], + intent_llm="gpt-4o-mini", + ) + + mock_collapse.assert_called_once() + assert flow.state.last_intent == "order" + + def test_ask_appends_to_messages(self) -> None: + class AskFlow(Flow[ChatState]): + input_provider = MagicMock() + input_provider.request_input = MagicMock(return_value="user reply") + + @start() + def begin(self): + self.ask("Prompt:") + return "ok" + + flow = AskFlow() + flow._state = ChatState() + flow.kickoff() + + assert any( + m.get("role") == "user" and m.get("content") == "user reply" + for m in flow.state.messages + ) + + +class TestClassifyIntent: + def test_uses_collapse_with_context(self) -> None: + flow = SimpleChatFlow() + flow._state = ChatState( + messages=[{"role": "user", "content": "prior"}], + ) + + with patch.object(flow, "_collapse_to_outcome", return_value="help") as mock: + outcome = flow.classify_intent( + "I need help", + ["order", "help"], + llm="gpt-4o-mini", + context=flow.conversation_messages, + ) + + assert outcome == "help" + assert "I need help" in mock.call_args[0][0] + + +class TestConversationalFlow: + def test_deferred_multi_turn_trace_keeps_event_sequence_continuous( + self, + ) -> None: + @ConversationConfig() + class TraceFlow(ConversationalFlow): + def route_turn(self, context: dict[str, Any]) -> str | None: + return "work" + + @listen("work") + def do_work(self) -> str: + reply = f"worked: {self.state.current_user_message}" + self.append_assistant_message(reply) + return reply + + events: list[Any] = [] + with crewai_event_bus.scoped_handlers(): + + @crewai_event_bus.on(FlowStartedEvent) + def capture_flow_started(source: Any, event: Any) -> None: + events.append(event) + + @crewai_event_bus.on(MethodExecutionStartedEvent) + def capture_method_started(source: Any, event: Any) -> None: + events.append(event) + + @crewai_event_bus.on(MethodExecutionFinishedEvent) + def capture_method_finished(source: Any, event: Any) -> None: + events.append(event) + + flow = TraceFlow() + flow.handle_turn("research apple stock") + flow.handle_turn("research google stock") + flow.finalize_session_traces() + crewai_event_bus.flush() + + flow_started_events = [ + event for event in events if isinstance(event, FlowStartedEvent) + ] + method_events = [ + event + for event in events + if isinstance( + event, MethodExecutionStartedEvent | MethodExecutionFinishedEvent + ) + ] + sequences = [ + event.emission_sequence + for event in events + if event.emission_sequence is not None + ] + assert len(flow_started_events) == 1 + assert len(sequences) == len(set(sequences)) + assert all( + event.parent_event_id == flow_started_events[0].event_id + for event in method_events + ) + + def test_handle_turn_defers_trace_until_session_finalize(self) -> None: + from crewai.events.listeners.tracing.trace_batch_manager import TraceBatch + + @ConversationConfig() + class TraceFlow(ConversationalFlow): + def route_turn(self, context: dict[str, Any]) -> str | None: + return "work" + + @listen("work") + def do_work(self) -> str: + self.append_assistant_message("done") + return "done" + + flow = TraceFlow() + listener = TraceCollectionListener() + listener.batch_manager.current_batch = TraceBatch() + listener.batch_manager.batch_owner_type = "flow" + listener.batch_manager._batch_finalized = False + try: + with patch.object(flow, "_finalize_flow_trace_batch") as mock_finalize: + flow.handle_turn("hello") + + assert flow.defer_trace_finalization is True + assert flow._should_defer_trace_finalization() is True + mock_finalize.assert_called_once_with() + + flow.finalize_session_traces() + + assert mock_finalize.call_args_list[-1] == ((), {"force": True}) + finally: + listener.batch_manager.current_batch = None + listener.batch_manager.batch_owner_type = None + listener.batch_manager.defer_session_finalization = False + listener.batch_manager._batch_finalized = False + + def test_handle_turn_delegates_to_restored_checkpoint_flow(self) -> None: + class CheckpointFlow(ConversationalFlow): + pass + + flow = CheckpointFlow() + mock_restored = MagicMock(spec=CheckpointFlow) + mock_restored.kickoff.return_value = "restored reply" + + cfg = CheckpointConfig(restore_from="/path/to/conversation_cp.json") + with patch.object(CheckpointFlow, "from_checkpoint", return_value=mock_restored): + result = flow.handle_turn("resume this chat", from_checkpoint=cfg) + + mock_restored.kickoff.assert_called_once_with( + inputs={ + "id": flow.state.id, + "user_message": "resume this chat", + }, + input_files=None, + user_message="resume this chat", + session_id=flow.state.id, + ) + assert mock_restored.checkpoint.restore_from is None + assert result == "restored reply" + + def test_handle_turn_routes_to_listener_and_records_public_result(self) -> None: + @ConversationConfig(default_intents=["research"], intent_llm="gpt-4o-mini") + class ResearchFlow(ConversationalFlow): + @listen("research") + def run_research(self) -> str: + self.append_agent_result( + "researcher", + "researched answer", + visibility="public", + ) + return "researched answer" + + flow = ResearchFlow() + + with patch.object(flow, "_collapse_to_outcome", return_value="research"): + result = flow.handle_turn("research CrewAI") + + assert result == "researched answer" + assert "conversation_start" in ResearchFlow._start_methods + assert flow.state.current_user_message == "research CrewAI" + assert flow.state.last_intent == "research" + assert [message.role for message in flow.state.messages] == [ + "user", + "assistant", + ] + assert flow.state.messages[-1].content == "researched answer" + assert flow.state.events[0].agent_name == "researcher" + assert flow.state.events[0].visibility == "public" + + def test_private_agent_results_stay_out_of_shared_history(self) -> None: + class PrivateFlow(ConversationalFlow): + def route_turn(self, context: dict[str, Any]) -> str | None: + return "work" + + @listen("work") + def do_work(self) -> None: + self.append_agent_result("planner", "private scratch") + + flow = PrivateFlow() + flow.handle_turn("plan quietly") + + assert [message.role for message in flow.state.messages] == ["user"] + assert flow.state.events[0].visibility == "private" + assert flow.state.agent_threads["planner"][0].content == "private scratch" + + def test_answer_from_history_uses_configured_llm_and_appends_reply(self) -> None: + @ConversationConfig(answer_from_history_llm="gpt-4o-mini") + class HistoryFlow(ConversationalFlow): + pass + + flow = HistoryFlow() + flow._state = ConversationState( + messages=[ + ConversationMessage(role="user", content="research topic"), + ConversationMessage(role="assistant", content="prior findings"), + ] + ) + llm = MagicMock() + llm.call.return_value = "summary from history" + + with ( + patch.object( + flow, + "_collapse_to_outcome", + return_value="answer_from_history", + ), + patch.object(flow, "_coerce_llm", return_value=llm), + ): + result = flow.handle_turn("summarize this") + + assert result == "summary from history" + assert flow.state.messages[-1].role == "assistant" + assert flow.state.messages[-1].content == "summary from history" + llm.call.assert_called_once() + + def test_router_config_uses_structured_intent_response(self) -> None: + class ResearchRoute(BaseModel): + intent: Literal["research", "clarify"] + + llm = MagicMock() + llm.call.return_value = ResearchRoute(intent="research") + + @ConversationConfig( + router=RouterConfig( + prompt="Classify the next action.", + response_format=ResearchRoute, + llm=llm, + routes=["research", "clarify"], + default_intent="clarify", + fallback_intent="clarify", + ) + ) + class RoutedFlow(ConversationalFlow): + @listen("research") + def run_research(self) -> str: + self.append_assistant_message("researched") + return "researched" + + @listen("clarify") + def ask_clarification(self) -> str: + self.append_assistant_message("clarify") + return "clarify" + + flow = RoutedFlow() + result = flow.handle_turn("research CrewAI") + + assert result == "researched" + llm.call.assert_called_once() + assert llm.call.call_args.kwargs["response_format"] is ResearchRoute + assert flow.state.messages[-1].content == "researched" + + def test_router_config_falls_back_for_invalid_intent(self) -> None: + class ResearchRoute(BaseModel): + intent: str + + llm = MagicMock() + llm.call.return_value = ResearchRoute(intent="unknown") + + @ConversationConfig( + router=RouterConfig( + prompt="Classify the next action.", + response_format=ResearchRoute, + llm=llm, + routes=["research", "clarify"], + default_intent="clarify", + fallback_intent="clarify", + ) + ) + class RoutedFlow(ConversationalFlow): + @listen("research") + def run_research(self) -> str: + self.append_assistant_message("researched") + return "researched" + + @listen("clarify") + def ask_clarification(self) -> str: + self.append_assistant_message("clarify") + return "clarify" + + flow = RoutedFlow() + result = flow.handle_turn("something vague") + + assert result == "clarify" + assert flow.state.messages[-1].content == "clarify" + + def test_router_effective_routes_include_builtins(self) -> None: + class ResearchRoute(BaseModel): + intent: Literal["research", "converse", "end"] + + @ConversationConfig( + router=RouterConfig( + prompt="Classify.", + response_format=ResearchRoute, + routes=["research"], + ) + ) + class RoutedFlow(ConversationalFlow): + @listen("research") + def run_research(self) -> str: + return "researched" + + flow = RoutedFlow() + + assert flow._effective_routes(flow.conversational_config.router) == { + "research", + "converse", + "end", + } + + def test_router_infers_custom_routes_without_internal_routes(self) -> None: + class ResearchRoute(BaseModel): + intent: Literal["research", "converse", "end"] + + @ConversationConfig( + router=RouterConfig( + prompt="Classify.", + response_format=ResearchRoute, + ) + ) + class RoutedFlow(ConversationalFlow): + @listen("research") + def run_research(self) -> str: + return "researched" + + flow = RoutedFlow() + + assert flow._effective_routes(flow.conversational_config.router) == { + "research", + "converse", + "end", + } + + def test_router_config_uses_conversational_defaults(self) -> None: + llm = MagicMock() + + @ConversationConfig( + llm=llm, + router=RouterConfig(), + ) + class RoutedFlow(ConversationalFlow): + @listen("research") + def run_research(self) -> str: + self.append_assistant_message("researched") + return "researched" + + flow = RoutedFlow() + response_format = flow._router_response_format(flow.conversational_config.router) + llm.call.return_value = response_format(intent="research") + + result = flow.handle_turn("research CrewAI") + + assert result == "researched" + llm.call.assert_called_once() + assert llm.call.call_args.kwargs["response_format"].__name__ == ( + "ConversationRoute" + ) + assert flow.state.messages[-1].content == "researched" + + def test_builtin_converse_appends_assistant_message_and_uses_history(self) -> None: + class ResearchRoute(BaseModel): + intent: Literal["research", "converse", "end"] + + router_llm = MagicMock() + router_llm.call.return_value = ResearchRoute(intent="converse") + chat_llm = MagicMock() + chat_llm.call.return_value = "summary from built-in converse" + + @ConversationConfig( + system_prompt="You are a helpful research assistant.", + llm=chat_llm, + router=RouterConfig( + prompt="Classify.", + response_format=ResearchRoute, + llm=router_llm, + routes=["research"], + default_intent="converse", + ), + ) + class RoutedFlow(ConversationalFlow): + @listen("research") + def run_research(self) -> str: + self.append_agent_result( + "researcher", + "prior findings", + visibility="public", + ) + return "prior findings" + + flow = RoutedFlow() + flow.state.messages = [ + ConversationMessage(role="user", content="research CrewAI"), + ConversationMessage(role="assistant", content="prior findings"), + ] + result = flow.handle_turn("summarize findings") + + assert result == "summary from built-in converse" + assert flow.state.messages[-1].content == "summary from built-in converse" + messages = chat_llm.call.call_args.kwargs["messages"] + assert messages[0] == { + "role": "system", + "content": "You are a helpful research assistant.", + } + assert any(message["content"] == "prior findings" for message in messages) + assert any(message["content"] == "summarize findings" for message in messages) + + def test_builtin_end_marks_conversation_ended(self) -> None: + class ResearchRoute(BaseModel): + intent: Literal["research", "converse", "end"] + + router_llm = MagicMock() + router_llm.call.return_value = ResearchRoute(intent="end") + + @ConversationConfig( + router=RouterConfig( + prompt="Classify.", + response_format=ResearchRoute, + llm=router_llm, + routes=["research"], + default_intent="converse", + ) + ) + class RoutedFlow(ConversationalFlow): + @listen("research") + def run_research(self) -> str: + return "researched" + + flow = RoutedFlow() + result = flow.handle_turn("bye") + + assert result == "Conversation ended." + assert flow.state.ended is True + assert flow.state.messages[-1].content == "Conversation ended." + + def test_handle_turn_reruns_graph_after_prior_turn_completed(self) -> None: + """Multi-turn must not flip ``_is_execution_resuming`` and short-circuit. + + ``Flow.kickoff`` with persistence enabled treats ``inputs={"id": ...}`` + as a checkpoint restore, so it skips clearing ``_completed_methods``. + Without ``ConversationalFlow.kickoff`` resetting that state, turn 2+ + sees every method as already-completed, short-circuits to + ``_method_outputs[-1]``, and returns the previous turn's output. + """ + + class Route(BaseModel): + intent: Literal["RESEARCH", "converse", "end"] + + router_llm = MagicMock() + router_llm.call.side_effect = [ + Route(intent="converse"), + Route(intent="RESEARCH"), + ] + chat_llm = MagicMock() + chat_llm.call.return_value = "general help" + + @ConversationConfig( + llm=chat_llm, + router=RouterConfig( + response_format=Route, + llm=router_llm, + routes=["RESEARCH"], + ), + ) + class DemoFlow(ConversationalFlow): + @listen("RESEARCH") + def handle_research(self) -> str: + self.append_assistant_message("fresh research") + return "fresh research" + + flow = DemoFlow() + from crewai.flow.persistence import SQLiteFlowPersistence + + import tempfile + from pathlib import Path + + flow.persistence = SQLiteFlowPersistence( + str(Path(tempfile.mkdtemp()) / "regression.db") + ) + + out1 = flow.handle_turn("tell me what you can do") + out2 = flow.handle_turn("now do research") + + assert out1 == "general help" + assert out2 == "fresh research" + assert chat_llm.call.call_count == 1 + assert router_llm.call.call_count == 2 + assert flow.state.messages[-1].content == "fresh research" + assert flow._is_execution_resuming is False + + def test_route_catalog_combines_docstrings_builtins_and_overrides(self) -> None: + """Catalog precedence: route_descriptions > built-in > docstring.""" + + @ConversationConfig( + router=RouterConfig( + routes=["RESEARCH", "ORDER"], + route_descriptions={"ORDER": "explicit override for order route"}, + ) + ) + class CatalogFlow(ConversationalFlow): + @listen("RESEARCH") + def handle_research(self) -> str: + """Fresh web research, current news, real-time lookups.""" + return "researched" + + @listen("ORDER") + def handle_order(self) -> str: + """This docstring should NOT win — override takes priority.""" + return "ordered" + + flow = CatalogFlow() + catalog = flow._build_route_catalog(flow.conversational_config.router) + + assert catalog["RESEARCH"] == ( + "Fresh web research, current news, real-time lookups." + ) + assert catalog["ORDER"] == "explicit override for order route" + # Built-in routes get framework-canned descriptions. + assert "Ordinary chat" in catalog["converse"] + assert "finished" in catalog["end"] + + def test_route_catalog_falls_back_to_empty_when_no_docstring(self) -> None: + @ConversationConfig(router=RouterConfig(routes=["BARE"])) + class BareFlow(ConversationalFlow): + @listen("BARE") + def handle_bare(self) -> str: + return "bare" + + flow = BareFlow() + catalog = flow._build_route_catalog(flow.conversational_config.router) + + assert catalog["BARE"] == "" + + def test_router_messages_include_route_catalog(self) -> None: + """The router system prompt must enumerate routes with descriptions.""" + + class Route(BaseModel): + intent: Literal["RESEARCH", "converse", "end"] + + router_llm = MagicMock() + router_llm.call.return_value = Route(intent="RESEARCH") + + @ConversationConfig( + router=RouterConfig( + prompt="A research-focused assistant.", + response_format=Route, + llm=router_llm, + routes=["RESEARCH"], + ) + ) + class RoutedFlow(ConversationalFlow): + @listen("RESEARCH") + def handle_research(self) -> str: + """Fresh web research and current news.""" + self.append_assistant_message("researched") + return "researched" + + flow = RoutedFlow() + flow.handle_turn("research today's AI news") + + system_message = router_llm.call.call_args.kwargs["messages"][0]["content"] + assert "Routes:" in system_message + assert "- RESEARCH: Fresh web research and current news." in system_message + assert "- converse: Ordinary chat" in system_message + assert system_message.startswith("A research-focused assistant.") + + def test_router_decision_persists_last_intent_and_passes_it_next_turn( + self, + ) -> None: + """Router must record its decision so the next turn's router LLM sees it.""" + + class Route(BaseModel): + intent: Literal["research", "converse", "end"] + + router_llm = MagicMock() + router_llm.call.side_effect = [ + Route(intent="research"), + Route(intent="converse"), + ] + chat_llm = MagicMock() + chat_llm.call.return_value = "follow-up reply" + + @ConversationConfig( + llm=chat_llm, + router=RouterConfig( + response_format=Route, + llm=router_llm, + routes=["research"], + ), + ) + class RoutedFlow(ConversationalFlow): + @listen("research") + def run_research(self) -> str: + self.append_assistant_message("researched") + return "researched" + + flow = RoutedFlow() + + flow.handle_turn("research CrewAI") + assert flow.state.last_intent == "research" + + flow.handle_turn("tell me more about that") + assert flow.state.last_intent == "converse" + + # Turn 2's router LLM must have seen last_intent='research' in its context. + second_call_user_content = router_llm.call.call_args_list[1].kwargs["messages"][1][ + "content" + ] + assert '"last_intent": "research"' in second_call_user_content + + def test_custom_route_still_runs_with_builtin_routes(self) -> None: + class ResearchRoute(BaseModel): + intent: Literal["research", "converse", "end"] + + router_llm = MagicMock() + router_llm.call.return_value = ResearchRoute(intent="research") + + @ConversationConfig( + router=RouterConfig( + prompt="Classify.", + response_format=ResearchRoute, + llm=router_llm, + routes=["research"], + default_intent="converse", + ) + ) + class RoutedFlow(ConversationalFlow): + @listen("research") + def run_research(self) -> str: + self.append_agent_result("researcher", "researched", visibility="public") + return "researched" + + flow = RoutedFlow() + result = flow.handle_turn("research CrewAI") + + assert result == "researched" + assert flow.state.messages[-1].content == "researched" + + +class TestFlowTracingWhenSuppressed: + def test_flow_started_emitted_when_panel_events_suppressed(self) -> None: + class QuietFlow(Flow[ChatState]): + suppress_flow_events = True + + @start() + def begin(self) -> str: + return "ok" + + started: list[str] = [] + original_emit = crewai_event_bus.emit + + def track_emit(source: Any, event: Any, *args: Any, **kwargs: Any) -> Any: + if isinstance(event, FlowStartedEvent): + started.append(event.flow_name) + return original_emit(source, event, *args, **kwargs) + + with patch.object(crewai_event_bus, "emit", side_effect=track_emit): + QuietFlow().kickoff() + + assert started == ["QuietFlow"] + + def test_method_execution_emitted_when_panel_events_suppressed(self) -> None: + class QuietFlow(Flow[ChatState]): + suppress_flow_events = True + + @start() + def begin(self) -> str: + return "ok" + + started: list[str] = [] + finished: list[str] = [] + original_emit = crewai_event_bus.emit + + def track_emit(source: Any, event: Any, *args: Any, **kwargs: Any) -> Any: + if isinstance(event, MethodExecutionStartedEvent): + started.append(event.method_name) + if isinstance(event, MethodExecutionFinishedEvent): + finished.append(event.method_name) + return original_emit(source, event, *args, **kwargs) + + with patch.object(crewai_event_bus, "emit", side_effect=track_emit): + QuietFlow().kickoff() + + assert started == ["begin"] + assert finished == ["begin"] + + def test_llm_action_inside_flow_claims_flow_trace_batch(self) -> None: + listener = TraceCollectionListener() + listener.batch_manager.current_batch = None + listener.batch_manager.batch_owner_type = None + listener.batch_manager.batch_owner_id = None + + flow_id_token = current_flow_id.set("flow-test-id") + flow_name_token = current_flow_name.set("DemoSupportFlow") + try: + event = LLMCallStartedEvent( + model="gpt-4o-mini", + messages=[], + call_id="call-test", + ) + listener._handle_action_event("llm_call_started", object(), event) + finally: + current_flow_id.reset(flow_id_token) + current_flow_name.reset(flow_name_token) + + assert listener.batch_manager.batch_owner_type == "flow" + assert listener.batch_manager.batch_owner_id == "flow-test-id" + assert ( + listener.batch_manager.current_batch.execution_metadata["execution_type"] + == "flow" + ) + assert ( + listener.batch_manager.current_batch.execution_metadata["flow_name"] + == "DemoSupportFlow" + ) + + +class TestDeferTraceFinalization: + def test_conversational_kickoff_enables_defer_flag(self) -> None: + class ChatFlow(Flow[ChatState]): + conversational_config = ConversationalConfig( + defer_trace_finalization=True + ) + + @start() + def begin(self) -> str: + return "ok" + + flow = ChatFlow() + flow._configure_conversational_kickoff( + user_message="hi", + session_id="sess-trace", + ) + assert flow.defer_trace_finalization is True + assert flow._should_defer_trace_finalization() is True + + def test_finalize_skipped_until_forced(self) -> None: + flow = SimpleChatFlow() + flow.defer_trace_finalization = True + + listener = TraceCollectionListener() + listener.batch_manager.batch_owner_type = "flow" + listener.first_time_handler.is_first_time = False + + with patch.object(listener.batch_manager, "finalize_batch") as mock_finalize: + flow._finalize_flow_trace_batch() + mock_finalize.assert_not_called() + + flow._finalize_flow_trace_batch(force=True) + mock_finalize.assert_called_once() + + +class TestDeferredFlowLifecycleEvents: + def test_deferred_kickoff_skips_per_turn_flow_finished(self) -> None: + class ChatFlow(Flow[ChatState]): + conversational_config = ConversationalConfig( + defer_trace_finalization=True + ) + + @start() + def begin(self) -> str: + return "ok" + + flow = ChatFlow() + with patch.object(flow, "_emit_flow_finished_async") as mock_finished: + flow.kickoff(user_message="hi", session_id="sess-lifecycle") + mock_finished.assert_not_called() + + def test_flow_finished_without_flow_started_warns(self, capsys) -> None: + from crewai.events.event_bus import crewai_event_bus + from crewai.events.event_context import restore_event_scope + from crewai.events.types.flow_events import FlowFinishedEvent + + class BareFlow(Flow[ChatState]): + @start() + def begin(self) -> str: + return "ok" + + restore_event_scope(()) + flow = BareFlow() + crewai_event_bus.emit( + flow, + FlowFinishedEvent( + type="flow_finished", + flow_name="BareFlow", + result="ok", + state={}, + ), + ) + captured = capsys.readouterr().out + assert "flow_finished" in captured + assert "Missing starting event" in captured + + def test_finalize_session_restores_flow_started_scope(self, capsys) -> None: + from crewai.events.listeners.tracing.trace_batch_manager import TraceBatch + + class ChatFlow(Flow[ChatState]): + conversational_config = ConversationalConfig( + defer_trace_finalization=True + ) + + @start() + def begin(self) -> str: + return "ok" + + flow = ChatFlow() + flow.defer_trace_finalization = True + object.__setattr__(flow, "_conversation_trace_started", True) + object.__setattr__(flow, "_conversation_flow_started_event_id", "start-evt-1") + flow._method_outputs.append("ok") + + listener = TraceCollectionListener() + listener.batch_manager.batch_owner_type = "flow" + listener.batch_manager.current_batch = TraceBatch( + execution_metadata={"execution_type": "flow", "flow_name": "ChatFlow"}, + ) + listener.batch_manager.defer_session_finalization = True + listener.batch_manager._batch_finalized = False + + with patch.object(flow, "_finalize_flow_trace_batch") as mock_finalize: + flow.finalize_session_traces() + + captured = capsys.readouterr().out + assert "Missing starting event" not in captured + mock_finalize.assert_called_once_with(force=True) + assert listener.batch_manager.defer_session_finalization is False + + def test_finalize_batch_is_idempotent(self) -> None: + from crewai.events.listeners.tracing.trace_batch_manager import TraceBatchManager + + with patch( + "crewai.events.listeners.tracing.trace_batch_manager.is_tracing_enabled_in_context", + return_value=True, + ): + bm = TraceBatchManager() + bm.current_batch = bm.initialize_batch( + user_context={"privacy_level": "standard"}, + execution_metadata={"execution_type": "flow", "flow_name": "ChatFlow"}, + ) + bm.trace_batch_id = "batch-idempotent" + bm.backend_initialized = True + + with ( + patch.object( + bm.plus_api, + "send_trace_events", + return_value=MagicMock(status_code=200), + ), + patch.object( + bm.plus_api, + "finalize_trace_batch", + return_value=MagicMock(status_code=200, json=MagicMock(return_value={})), + ) as mock_finalize_api, + ): + bm.finalize_batch() + bm.finalize_batch() + + assert mock_finalize_api.call_count == 1 + assert bm._batch_finalized is True + + def test_finalize_session_is_idempotent_after_batch_cleared(self) -> None: + class ChatFlow(Flow[ChatState]): + @start() + def begin(self) -> str: + return "ok" + + flow = ChatFlow() + flow.defer_trace_finalization = True + object.__setattr__(flow, "_conversation_trace_started", True) + + listener = TraceCollectionListener() + listener.batch_manager.current_batch = None + listener.batch_manager.batch_owner_type = None + listener.batch_manager.trace_batch_id = None + listener.batch_manager._batch_finalized = True + + with patch.object(flow, "_emit_flow_finished_sync") as mock_finished: + with patch.object(flow, "_finalize_flow_trace_batch") as mock_finalize: + flow.finalize_session_traces() + flow.finalize_session_traces() + + mock_finished.assert_not_called() + mock_finalize.assert_not_called() + + def test_sigint_skips_deferred_session_batch(self) -> None: + from crewai.events.listeners.tracing.trace_batch_manager import TraceBatch + + listener = TraceCollectionListener() + listener.batch_manager.current_batch = TraceBatch() + listener.batch_manager.defer_session_finalization = True + + with patch.object(listener.batch_manager, "finalize_batch") as mock_finalize: + if listener.batch_manager.is_batch_initialized(): + if not listener.batch_manager.defer_session_finalization: + listener.batch_manager.finalize_batch() + mock_finalize.assert_not_called() + + +class TestNestedCrewTracing: + def test_is_inside_active_flow_context_when_kickoff_running(self) -> None: + from crewai.events.listeners.tracing.trace_listener import ( + TraceCollectionListener, + ) + from crewai.flow.flow_context import current_flow_id + + assert TraceCollectionListener._is_inside_active_flow_context() is False + token = current_flow_id.set("parent-flow-id") + try: + assert TraceCollectionListener._is_inside_active_flow_context() is True + finally: + current_flow_id.reset(token) + + def test_nested_crew_completion_skips_finalize(self) -> None: + from crewai.events.listeners.tracing.trace_listener import ( + TraceCollectionListener, + ) + from crewai.flow.flow_context import current_flow_id + + listener = TraceCollectionListener() + listener.batch_manager.batch_owner_type = "crew" + + token = current_flow_id.set("parent-flow-id") + try: + with patch.object(listener.batch_manager, "finalize_batch") as mock_finalize: + if listener._nested_in_flow_execution(): + pass + elif listener.batch_manager.batch_owner_type == "crew": + listener.batch_manager.finalize_batch() + mock_finalize.assert_not_called() + finally: + current_flow_id.reset(token) + + def test_finalize_flow_trace_batch_respects_defer_session_flag(self) -> None: + """Nested Flow kickoffs (e.g. AgentExecutor) must not finalize a deferred session batch.""" + + class InnerFlow(Flow[ChatState]): + @start() + def begin(self) -> str: + return "ok" + + listener = TraceCollectionListener() + listener.batch_manager.batch_owner_type = "flow" + listener.batch_manager.defer_session_finalization = True + listener.first_time_handler.is_first_time = False + + inner = InnerFlow() + with patch.object(listener.batch_manager, "finalize_batch") as mock_finalize: + inner._finalize_flow_trace_batch() + mock_finalize.assert_not_called() + + def test_flow_owned_batch_skips_finalize_without_flow_context(self) -> None: + from crewai.events.listeners.tracing.trace_listener import ( + TraceCollectionListener, + ) + from crewai.events.listeners.tracing.trace_batch_manager import TraceBatch + + listener = TraceCollectionListener() + listener.batch_manager.batch_owner_type = "flow" + listener.batch_manager.current_batch = TraceBatch( + execution_metadata={"execution_type": "flow", "flow_name": "Demo"}, + ) + + with patch.object(listener.batch_manager, "finalize_batch") as mock_finalize: + if listener._nested_in_flow_execution(): + pass + elif listener.batch_manager.batch_owner_type == "crew": + listener.batch_manager.finalize_batch() + mock_finalize.assert_not_called() diff --git a/lib/crewai/tests/tracing/test_tracing.py b/lib/crewai/tests/tracing/test_tracing.py index 28f2d4c7ed..11bfc4e270 100644 --- a/lib/crewai/tests/tracing/test_tracing.py +++ b/lib/crewai/tests/tracing/test_tracing.py @@ -868,6 +868,49 @@ def test_trace_batch_marked_as_failed_on_finalize_error(self): "test_batch_id_12345", "Internal Server Error" ) + def test_finalize_batch_clears_buffer_after_successful_send(self) -> None: + """Successful send must not restore a stale event buffer (duplicate events).""" + from crewai.events.listeners.tracing.types import TraceEvent + + with patch( + "crewai.events.listeners.tracing.trace_batch_manager.is_tracing_enabled_in_context", + return_value=True, + ): + batch_manager = TraceBatchManager() + batch_manager.current_batch = batch_manager.initialize_batch( + user_context={"privacy_level": "standard"}, + execution_metadata={ + "execution_type": "flow", + "flow_name": "TestFlow", + }, + ) + batch_manager.trace_batch_id = "batch-clear-test" + batch_manager.backend_initialized = True + batch_manager.event_buffer = [ + TraceEvent( + type="llm_call_started", + timestamp="2026-01-01T00:00:00", + event_id="evt-1", + emission_sequence=1, + ) + ] + + with ( + patch.object( + batch_manager.plus_api, + "send_trace_events", + return_value=MagicMock(status_code=200), + ), + patch.object( + batch_manager.plus_api, + "finalize_trace_batch", + return_value=MagicMock(status_code=200, json=MagicMock(return_value={})), + ), + ): + batch_manager.finalize_batch() + + assert batch_manager.event_buffer == [] + def test_ephemeral_batch_includes_anon_id(self): """Test that ephemeral batch initialization sends anon_id from get_user_id()""" fake_user_id = "abc123def456"