From 69e7bc01f4ca881af76972cd7bd66a3acea43cc1 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 31 Aug 2026 06:43:59 +0000 Subject: [PATCH] feat(experimental): add /btw commands to steer conversational flows Opt-in add-on that intercepts /btw lines before handle_turn so users can inject steering notes or force a route without recording a user turn. Co-authored-by: Lorenze Jay --- .../ar/guides/flows/conversational-flows.mdx | 38 ++++ .../en/guides/flows/conversational-flows.mdx | 38 ++++ .../ko/guides/flows/conversational-flows.mdx | 38 ++++ .../guides/flows/conversational-flows.mdx | 38 ++++ .../src/crewai/experimental/__init__.py | 28 +++ .../conversation_commands/__init__.py | 54 +++++ .../conversation_commands/addon.py | 192 ++++++++++++++++ .../conversation_commands/parser.py | 109 +++++++++ .../conversation_commands/steering.py | 159 +++++++++++++ .../test_btw_commands.py | 209 ++++++++++++++++++ 10 files changed, 903 insertions(+) create mode 100644 lib/crewai/src/crewai/experimental/conversation_commands/__init__.py create mode 100644 lib/crewai/src/crewai/experimental/conversation_commands/addon.py create mode 100644 lib/crewai/src/crewai/experimental/conversation_commands/parser.py create mode 100644 lib/crewai/src/crewai/experimental/conversation_commands/steering.py create mode 100644 lib/crewai/tests/experimental/conversation_commands/test_btw_commands.py diff --git a/docs/edge/ar/guides/flows/conversational-flows.mdx b/docs/edge/ar/guides/flows/conversational-flows.mdx index e7d4253b0..b0786414c 100644 --- a/docs/edge/ar/guides/flows/conversational-flows.mdx +++ b/docs/edge/ar/guides/flows/conversational-flows.mdx @@ -612,6 +612,44 @@ from crewai.flow import ( ) ``` +## تجريبي: أوامر `/btw` + + +`/btw` إضافة تجريبية. تتجاهل التدفقات المحادثية أسطر الأوامر حتى تفعّلها صراحة، وقد يتغيّر الـ API. + + +يعامل كل `handle_turn()` سطر المستخدم كتشغيل جديد للرسم. `/btw` قناة جانبية تتيح لك إدخال ملاحظة توجيه — أو فرض مسار — **دون** إلحاق رسالة مستخدم أو تشغيل الرسم. + +```python +from crewai.experimental.conversation_commands import btw_commands +from crewai.flow import ConversationConfig, ConversationState, Flow, listen + + +@btw_commands +@ConversationConfig() +class SupportFlow(Flow[ConversationState]): + @listen("RESEARCH") + def handle_research(self) -> str: + """Fresh web research.""" + reply = "I would research that." + self.append_assistant_message(reply) + return reply +``` + +| السطر | الأثر | +|------|--------| +| `/btw keep answers under 20 words` | يحفظ ملاحظة توجيه. لا تُسجَّل جولة مستخدم. تظهر الملاحظة لاحقاً في مطالبات `converse` والموجّه. | +| `/btw route RESEARCH` | يفرض المسار على **الجولة التالية** ثم يعود للتوجيه العادي. | +| `/btw stay RESEARCH` أو `/btw route RESEARCH persist` | يواصل فرض ذلك المسار حتى `/btw clear`. | +| `/btw clear` | يحذف الملاحظات والمسارات المفروضة. | +| `/btw` أو `/btw show` | يعرض التوجيه الحالي. | +| `/help` | يسرد الأوامر. | +| `What's the weather /btw keep it to one sentence` | يطبّق الأمر ثم يشغّل النص المتبقي كجولة مستخدم. | + +لتدفق أنشأته مسبقاً (بما في ذلك `Flow.from_declaration(...)`)، استدعِ `enable_btw_commands(flow)` بدل المزيّن. + +أسطر `/btw` المستقلة تعيد إقراراً ولا تستدعي `kickoff()`. ولأن `chat()` وواجهة CLI TUI تغلفان `handle_turn()`، تلتقطان الإضافة تلقائياً بعد تثبيتها. التدفقات بدون الإضافة ما زالت تعامل `/btw …` كرسالة مستخدم عادية. + ## مراجع - [إتقان إدارة حالة Flow](/ar/guides/flows/mastering-flow-state) diff --git a/docs/edge/en/guides/flows/conversational-flows.mdx b/docs/edge/en/guides/flows/conversational-flows.mdx index c0ae83d74..7d4ba2e8f 100644 --- a/docs/edge/en/guides/flows/conversational-flows.mdx +++ b/docs/edge/en/guides/flows/conversational-flows.mdx @@ -613,6 +613,44 @@ from crewai.flow import ( ) ``` +## Experimental: `/btw` commands + + +`/btw` is an experimental add-on. Conversational flows ignore slash lines until you opt in, and the API may change. + + +Each `handle_turn()` treats the user line as a new graph run. `/btw` is a side-channel so you can interject a steering note — or force a route — **without** appending a user message or running the graph. + +```python +from crewai.experimental.conversation_commands import btw_commands +from crewai.flow import ConversationConfig, ConversationState, Flow, listen + + +@btw_commands +@ConversationConfig() +class SupportFlow(Flow[ConversationState]): + @listen("RESEARCH") + def handle_research(self) -> str: + """Fresh web research.""" + reply = "I would research that." + self.append_assistant_message(reply) + return reply +``` + +| Line | Effect | +|------|--------| +| `/btw keep answers under 20 words` | Persist a steering note. No user turn is recorded. Later `converse` and router prompts include the note. | +| `/btw route RESEARCH` | Force the **next** turn onto that route, then resume normal routing. | +| `/btw stay RESEARCH` or `/btw route RESEARCH persist` | Keep forcing that route until `/btw clear`. | +| `/btw clear` | Drop notes and forced routes. | +| `/btw` or `/btw show` | Print the current steering. | +| `/help` | List commands. | +| `What's the weather /btw keep it to one sentence` | Apply the command, then run the leftover text as the user turn. | + +For a flow you already constructed (including `Flow.from_declaration(...)`), call `enable_btw_commands(flow)` instead of the decorator. + +Standalone `/btw` lines return an acknowledgement and do not call `kickoff()`. Because `chat()` and the CLI TUI wrap `handle_turn()`, they pick the add-on up automatically once it is installed. Flows without the add-on still treat `/btw …` as a normal user message. + ## See also - [Mastering Flow State Management](/en/guides/flows/mastering-flow-state) — persistence, Pydantic state, `@persist` diff --git a/docs/edge/ko/guides/flows/conversational-flows.mdx b/docs/edge/ko/guides/flows/conversational-flows.mdx index d18ba3ec8..ed2a2b6eb 100644 --- a/docs/edge/ko/guides/flows/conversational-flows.mdx +++ b/docs/edge/ko/guides/flows/conversational-flows.mdx @@ -608,6 +608,44 @@ from crewai.flow import ( ) ``` +## 실험적: `/btw` 명령 + + +`/btw`는 실험적 애드온입니다. 대화형 Flow는 명시적으로 켜기 전까지 슬래시 줄을 무시하며, API는 바뀔 수 있습니다. + + +각 `handle_turn()`은 사용자 줄을 새로운 그래프 실행으로 취급합니다. `/btw`는 사이드 채널이므로 사용자 메시지를 추가하거나 그래프를 실행하지 **않고** 스티어링 노트(또는 강제 라우트)를 끼워 넣을 수 있습니다. + +```python +from crewai.experimental.conversation_commands import btw_commands +from crewai.flow import ConversationConfig, ConversationState, Flow, listen + + +@btw_commands +@ConversationConfig() +class SupportFlow(Flow[ConversationState]): + @listen("RESEARCH") + def handle_research(self) -> str: + """Fresh web research.""" + reply = "I would research that." + self.append_assistant_message(reply) + return reply +``` + +| 줄 | 효과 | +|------|--------| +| `/btw keep answers under 20 words` | 스티어링 노트를 유지합니다. 사용자 턴은 기록되지 않습니다. 이후 `converse`와 라우터 프롬프트에 노트가 포함됩니다. | +| `/btw route RESEARCH` | **다음** 턴만 해당 라우트로 강제하고 이후에는 일반 라우팅으로 돌아갑니다. | +| `/btw stay RESEARCH` 또는 `/btw route RESEARCH persist` | `/btw clear` 전까지 그 라우트를 계속 강제합니다. | +| `/btw clear` | 노트와 강제 라우트를 제거합니다. | +| `/btw` 또는 `/btw show` | 현재 스티어링을 보여 줍니다. | +| `/help` | 명령 목록을 출력합니다. | +| `What's the weather /btw keep it to one sentence` | 명령을 적용한 뒤 남은 텍스트를 사용자 턴으로 실행합니다. | + +이미 만든 Flow( `Flow.from_declaration(...)` 포함)에는 데코레이터 대신 `enable_btw_commands(flow)`를 호출하세요. + +단독 `/btw` 줄은 확인 메시지를 반환하며 `kickoff()`를 호출하지 않습니다. `chat()`과 CLI TUI는 `handle_turn()`을 감싸므로, 애드온이 설치된 뒤에는 자동으로 동작합니다. 애드온이 없는 Flow는 `/btw …`를 일반 사용자 메시지로 취급합니다. + ## 참고 - [Flow 상태 관리 마스터하기](/ko/guides/flows/mastering-flow-state) diff --git a/docs/edge/pt-BR/guides/flows/conversational-flows.mdx b/docs/edge/pt-BR/guides/flows/conversational-flows.mdx index 38bf02d4c..b10a91c18 100644 --- a/docs/edge/pt-BR/guides/flows/conversational-flows.mdx +++ b/docs/edge/pt-BR/guides/flows/conversational-flows.mdx @@ -613,6 +613,44 @@ from crewai.flow import ( ) ``` +## Experimental: comandos `/btw` + + +`/btw` é um extra experimental. Flows conversacionais ignoram linhas com barra até você ativar o extra, e a API pode mudar. + + +Cada `handle_turn()` trata a linha do usuário como uma nova execução do grafo. `/btw` é um canal lateral para intercalar uma nota de direção — ou forçar uma rota — **sem** anexar uma mensagem de usuário nem rodar o grafo. + +```python +from crewai.experimental.conversation_commands import btw_commands +from crewai.flow import ConversationConfig, ConversationState, Flow, listen + + +@btw_commands +@ConversationConfig() +class SupportFlow(Flow[ConversationState]): + @listen("RESEARCH") + def handle_research(self) -> str: + """Fresh web research.""" + reply = "I would research that." + self.append_assistant_message(reply) + return reply +``` + +| Linha | Efeito | +|------|--------| +| `/btw keep answers under 20 words` | Persiste uma nota de direção. Nenhum turno de usuário é gravado. As notas entram depois nos prompts de `converse` e do router. | +| `/btw route RESEARCH` | Força o **próximo** turno para essa rota e depois volta ao roteamento normal. | +| `/btw stay RESEARCH` ou `/btw route RESEARCH persist` | Continua forçando essa rota até `/btw clear`. | +| `/btw clear` | Remove notas e rotas forçadas. | +| `/btw` ou `/btw show` | Mostra a direção atual. | +| `/help` | Lista os comandos. | +| `What's the weather /btw keep it to one sentence` | Aplica o comando e executa o texto restante como turno do usuário. | + +Para um flow já construído (incluindo `Flow.from_declaration(...)`), chame `enable_btw_commands(flow)` em vez do decorator. + +Linhas `/btw` isoladas devolvem um acknowledgement e não chamam `kickoff()`. Como `chat()` e o TUI da CLI encapsulam `handle_turn()`, o extra passa a valer automaticamente depois de instalado. Flows sem o extra ainda tratam `/btw …` como mensagem normal de usuário. + ## Veja também - [Dominando o Gerenciamento de Estado em Flows](/pt-BR/guides/flows/mastering-flow-state) — persistência, estado Pydantic, `@persist` diff --git a/lib/crewai/src/crewai/experimental/__init__.py b/lib/crewai/src/crewai/experimental/__init__.py index ce2082f05..802b88875 100644 --- a/lib/crewai/src/crewai/experimental/__init__.py +++ b/lib/crewai/src/crewai/experimental/__init__.py @@ -20,6 +20,18 @@ from crewai.flow.conversational import ( _LAZY_FROM_AGENT_EXECUTOR = {"AgentExecutor", "CrewAgentExecutorFlow"} +_LAZY_FROM_CONVERSATION_COMMANDS = { + "BtwAction", + "BtwKind", + "BtwSteering", + "HELP_TEXT", + "ParsedBtwLine", + "btw_commands", + "enable_btw_commands", + "get_btw_steering", + "parse_btw_line", +} + _LAZY_FROM_EVALUATION = { "AgentEvaluationResult", "AgentEvaluator", @@ -65,6 +77,13 @@ def __getattr__(name: str) -> Any: globals()[attr] = getattr(_evaluation_mod, attr) return globals()[name] + if name in _LAZY_FROM_CONVERSATION_COMMANDS: + from crewai.experimental import conversation_commands as _btw_mod + + for attr in _LAZY_FROM_CONVERSATION_COMMANDS: + globals()[attr] = getattr(_btw_mod, attr) + return globals()[name] + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") @@ -73,7 +92,12 @@ __all__ = [ "AgentEvaluator", "AgentExecutor", "AgentMessage", + "BtwAction", + "BtwKind", + "BtwSteering", "BaseEvaluator", + "HELP_TEXT", + "ParsedBtwLine", "ConversationConfig", "ConversationEvent", "ConversationMessage", @@ -92,6 +116,10 @@ __all__ = [ "SemanticQualityEvaluator", "ToolInvocationEvaluator", "ToolSelectionEvaluator", + "btw_commands", "create_default_evaluator", "create_evaluation_callbacks", + "enable_btw_commands", + "get_btw_steering", + "parse_btw_line", ] diff --git a/lib/crewai/src/crewai/experimental/conversation_commands/__init__.py b/lib/crewai/src/crewai/experimental/conversation_commands/__init__.py new file mode 100644 index 000000000..a33af860f --- /dev/null +++ b/lib/crewai/src/crewai/experimental/conversation_commands/__init__.py @@ -0,0 +1,54 @@ +"""Experimental ``/btw`` commands for conversational Flows. + +Conversational turns treat every user line as a new graph run. This add-on +lets you interject a side instruction that steers routing and replies +without appending a user message. It is opt-in and not part of the stable +``handle_turn`` / ``chat`` contract. + +Example:: + + from crewai.experimental.conversation_commands import btw_commands + from crewai.flow import ConversationConfig, ConversationState, Flow, listen + + + @btw_commands + @ConversationConfig() + class SupportFlow(Flow[ConversationState]): + @listen("RESEARCH") + def handle_research(self) -> str: + return "researching" + + flow = SupportFlow() + flow.handle_turn("/btw keep answers under 20 words") + flow.handle_turn("/btw route RESEARCH") + flow.handle_turn("latest AI news") +""" + +from crewai.experimental.conversation_commands.addon import ( + btw_commands, + enable_btw_commands, +) +from crewai.experimental.conversation_commands.parser import ( + HELP_TEXT, + BtwAction, + BtwKind, + ParsedBtwLine, + parse_btw_line, +) +from crewai.experimental.conversation_commands.steering import ( + BtwSteering, + get_btw_steering, +) + + +__all__ = [ + "HELP_TEXT", + "BtwAction", + "BtwKind", + "BtwSteering", + "ParsedBtwLine", + "btw_commands", + "enable_btw_commands", + "get_btw_steering", + "parse_btw_line", +] diff --git a/lib/crewai/src/crewai/experimental/conversation_commands/addon.py b/lib/crewai/src/crewai/experimental/conversation_commands/addon.py new file mode 100644 index 000000000..e50aacbbc --- /dev/null +++ b/lib/crewai/src/crewai/experimental/conversation_commands/addon.py @@ -0,0 +1,192 @@ +"""Opt-in installer for experimental ``/btw`` conversational commands.""" + +from __future__ import annotations + +from collections.abc import Callable, Iterator +from functools import wraps +from typing import Any, TypeVar, cast, overload + +from crewai.experimental.conversation_commands.parser import parse_btw_line +from crewai.experimental.conversation_commands.steering import ( + ENABLED_ATTR, + apply_btw_action, + get_btw_steering, +) +from crewai.types.streaming import StreamFrame, StreamSession + + +T = TypeVar("T") + + +class _Intercept: + __slots__ = ("consumed", "reply", "user_message") + + def __init__( + self, + *, + consumed: bool, + user_message: str | None, + reply: str | None = None, + ) -> None: + self.consumed = consumed + self.user_message = user_message + self.reply = reply + + +def _intercept(flow: Any, message: str) -> _Intercept: + parsed = parse_btw_line(message) + if parsed.action is None: + return _Intercept(consumed=False, user_message=parsed.user_message) + + reply = apply_btw_action(flow, parsed.action) + if parsed.user_message: + return _Intercept( + consumed=False, + user_message=parsed.user_message, + reply=reply, + ) + return _Intercept(consumed=True, user_message=None, reply=reply) + + +def _ack_stream(reply: str) -> StreamSession[str]: + def frames() -> Iterator[StreamFrame]: + return iter(()) + + session: StreamSession[str] = StreamSession(sync_iterator=frames()) + session._set_result(reply) + return session + + +def _already_enabled(target: Any) -> bool: + if getattr(target, ENABLED_ATTR, False): + return True + if not isinstance(target, type) and getattr(type(target), ENABLED_ATTR, False): + return True + return False + + +def _wrap_handle_turn(original: Callable[..., Any]) -> Callable[..., Any]: + @wraps(original) + def handle_turn(self: Any, message: str, *args: Any, **kwargs: Any) -> Any: + decision = _intercept(self, message) + if decision.consumed: + return decision.reply + return original(self, decision.user_message, *args, **kwargs) + + return handle_turn + + +def _wrap_stream_turn(original: Callable[..., Any]) -> Callable[..., Any]: + @wraps(original) + def stream_turn(self: Any, message: str, *args: Any, **kwargs: Any) -> Any: + decision = _intercept(self, message) + if decision.consumed: + return _ack_stream(decision.reply or "") + return original(self, decision.user_message, *args, **kwargs) + + return stream_turn + + +def _wrap_route_turn(original: Callable[..., Any]) -> Callable[..., Any]: + @wraps(original) + def route_turn(self: Any, context: dict[str, Any]) -> str | None: + forced = get_btw_steering(self).consume_forced_route() + if forced: + return forced + return original(self, context) + + return route_turn + + +def _wrap_build_router_context(original: Callable[..., Any]) -> Callable[..., Any]: + @wraps(original) + def build_router_context(self: Any) -> dict[str, Any]: + return get_btw_steering(self).apply_to_router_context(original(self)) + + return build_router_context + + +def _wrap_resolve_system_prompt(original: Callable[..., Any]) -> Callable[..., Any]: + @wraps(original) + def _resolve_system_prompt(self: Any) -> str | None: + return get_btw_steering(self).apply_to_system_prompt(original(self)) + + return _resolve_system_prompt + + +def _wrap_class(cls: type[T]) -> type[T]: + cls.handle_turn = _wrap_handle_turn(cls.handle_turn) # type: ignore[attr-defined] + cls.stream_turn = _wrap_stream_turn(cls.stream_turn) # type: ignore[attr-defined] + cls.route_turn = _wrap_route_turn(cls.route_turn) # type: ignore[attr-defined] + cls.build_router_context = _wrap_build_router_context( # type: ignore[attr-defined] + cls.build_router_context + ) + cls._resolve_system_prompt = _wrap_resolve_system_prompt( # type: ignore[attr-defined] + cls._resolve_system_prompt + ) + setattr(cls, ENABLED_ATTR, True) + return cls + + +def _bind_and_wrap( + flow: Any, + name: str, + wrapper: Callable[[Callable[..., Any]], Callable[..., Any]], +) -> None: + original = getattr(type(flow), name) + wrapped = wrapper(original) + + @wraps(original) + def bound(*args: Any, **kwargs: Any) -> Any: + return wrapped(flow, *args, **kwargs) + + object.__setattr__(flow, name, bound) + + +def _wrap_instance(flow: T) -> T: + target = cast(Any, flow) + _bind_and_wrap(target, "handle_turn", _wrap_handle_turn) + _bind_and_wrap(target, "stream_turn", _wrap_stream_turn) + _bind_and_wrap(target, "route_turn", _wrap_route_turn) + _bind_and_wrap(target, "build_router_context", _wrap_build_router_context) + _bind_and_wrap(target, "_resolve_system_prompt", _wrap_resolve_system_prompt) + object.__setattr__(target, ENABLED_ATTR, True) + return flow + + +def enable_btw_commands(target: T) -> T: + """Install ``/btw`` interjections on a Flow class or instance. + + Opt-in only: conversational flows ignore slash lines until this is + applied. Safe to call more than once. + """ + if _already_enabled(target): + return target + if isinstance(target, type): + return _wrap_class(cast(type[T], target)) + return _wrap_instance(target) + + +@overload +def btw_commands(flow_cls: type[T]) -> type[T]: ... + + +@overload +def btw_commands(flow_cls: None = None) -> Callable[[type[T]], type[T]]: ... + + +def btw_commands( + flow_cls: type[T] | None = None, +) -> type[T] | Callable[[type[T]], type[T]]: + """Class decorator that enables experimental ``/btw`` commands. + + Use as ``@btw_commands`` or ``@btw_commands()`` above + ``@ConversationConfig(...)``. + """ + + def decorate(cls: type[T]) -> type[T]: + return enable_btw_commands(cls) + + if flow_cls is not None: + return decorate(flow_cls) + return decorate diff --git a/lib/crewai/src/crewai/experimental/conversation_commands/parser.py b/lib/crewai/src/crewai/experimental/conversation_commands/parser.py new file mode 100644 index 000000000..cd6574247 --- /dev/null +++ b/lib/crewai/src/crewai/experimental/conversation_commands/parser.py @@ -0,0 +1,109 @@ +"""Parse ``/btw`` interjections from a conversational user line. + +A leading ``/btw`` is a side-channel command: it steers later turns and +does not become the user utterance. Appending `` /btw …`` to a normal +message applies the command and still runs the remaining text as a turn. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import Enum +import re + + +_LEADING_BTW = re.compile(r"^/btw(?:\s+|$)", re.IGNORECASE) +_INLINE_BTW = re.compile(r"\s+/btw(?:\s+|$)", re.IGNORECASE) +_HELP_LINES = frozenset({"/help", "/?"}) + +HELP_TEXT = ( + "Experimental /btw commands (side-channel; they do not become a user turn):\n" + " /btw Persist a steering note for later turns\n" + " /btw route