6060from src .utils .language_detector import detect_language , get_language_name
6161from src .utils .prompt_config_loader import PromptConfigurationLoader
6262from src .utils .query_validator import validate_query_basic
63+ from src .utils .sse_utils import extract_content_from_sse
64+ from src .utils .conversation_history_store import should_save_history , save_history_round
65+ from src .utils .conversation_history_helpers import get_conversation_history
6366from src .guardrails import NeMoRailsAdapter , GuardrailCheckResult
6467from src .contextual_retrieval import ContextualRetriever
6568from src .contextual_retrieval .bm25_search import SmartBM25Search
6871 ContextualRetrievalFailureError ,
6972)
7073from src .llm_orchestrator_config .feature_flags import FeatureFlags
71- from src .tool_classifier import ToolClassifier
74+ from src .tool_classifier import ToolClassifier , WorkflowType
7275from src .tool_classifier .constants import SERVICE_STEP_PREFIXES
7376from src .tool_classifier .workflows .service_workflow import ServiceWorkflowExecutor
7477
7578# Initialize Loki logger for orchestration service
7679logger = LokiLogger (service_name = "llm-orchestration-service" )
7780
81+ # Set of content strings that must NOT be persisted in conversation history.
82+ # Covers all multilingual error / OOS / guardrail-violation messages so that
83+ # failed or blocked exchanges are never written to Redis.
84+ _HISTORY_EXCLUDED_MESSAGES : frozenset [str ] = frozenset (
85+ {
86+ * OUT_OF_SCOPE_MESSAGES .values (),
87+ * TECHNICAL_ISSUE_MESSAGES .values (),
88+ * INPUT_GUARDRAIL_VIOLATION_MESSAGES .values (),
89+ * OUTPUT_GUARDRAIL_VIOLATION_MESSAGES .values (),
90+ * QUERY_VALIDATION_FAILED_MESSAGES .values (),
91+ STREAM_TOKEN_LIMIT_MESSAGE ,
92+ }
93+ )
94+
7895
7996class LangfuseConfig :
8097 """Configuration for Langfuse integration."""
@@ -152,6 +169,11 @@ def __init__(self) -> None:
152169 # Workflow executors access it via self.orchestration_service.session_store.
153170 self .session_store : Any = None
154171
172+ # Redis-backed conversation history store.
173+ # Set to None here; the FastAPI lifespan injects the live store after
174+ # Redis initialises (app.state.orchestration_service.conversation_history_store = ...).
175+ self .conversation_history_store : Any = None
176+
155177 # Shared BM25 search index pre-warmed at startup.
156178 # Populated by _prewarm_shared_bm25() which is called from the FastAPI
157179 # lifespan so it runs inside the async event loop. Until then it is None
@@ -503,6 +525,18 @@ async def process_orchestration_request(
503525 },
504526 )
505527 langfuse .flush ()
528+
529+ # Persist successful exchange to conversation history (non-streaming)
530+ if should_save_history (
531+ self .conversation_history_store , response , _HISTORY_EXCLUDED_MESSAGES
532+ ):
533+ await save_history_round (
534+ self .conversation_history_store ,
535+ request .chatId ,
536+ request .message ,
537+ response .content ,
538+ )
539+
506540 return response
507541
508542 except Exception as e :
@@ -731,14 +765,51 @@ async def stream_orchestration_response(
731765 )
732766 time_metric ["classifier.route" ] = time .time () - start_time
733767
768+ # Accumulate content for history only on non-RAG workflows;
769+ # RAG routes through _stream_rag_pipeline which has its own hook.
770+ _save_classifier_history = (
771+ self .conversation_history_store is not None
772+ and classification .workflow != WorkflowType .RAG
773+ )
774+ _classifier_accumulated : list [str ] = []
775+ # Tracks whether an excluded marker (OOS / guardrail violation /
776+ # error) was observed at any point during the stream. When True
777+ # the entire accumulated buffer is discarded so no partial content
778+ # from before the blocked marker is ever written to Redis.
779+ _history_blocked = False
780+
734781 async for sse_chunk in stream_result :
735782 yield sse_chunk
783+ if _save_classifier_history and not _history_blocked :
784+ extracted = extract_content_from_sse (sse_chunk )
785+ if extracted is not None and extracted != "END" :
786+ if extracted in _HISTORY_EXCLUDED_MESSAGES :
787+ # Excluded marker observed — discard any partial
788+ # content accumulated before this point and stop
789+ # accumulating for the rest of the stream.
790+ _classifier_accumulated .clear ()
791+ _history_blocked = True
792+ else :
793+ _classifier_accumulated .append (extracted )
736794
737795 # Successfully completed streaming through classifier
738796 logger .info (
739797 f"[{ request .chatId } ] [{ stream_ctx .stream_id } ] Tool classifier streaming completed"
740798 )
741799
800+ # Persist conversation history (classifier streaming, non-RAG workflows)
801+ if (
802+ _save_classifier_history
803+ and not _history_blocked
804+ and _classifier_accumulated
805+ ):
806+ await save_history_round (
807+ self .conversation_history_store ,
808+ request .chatId ,
809+ request .message ,
810+ "" .join (_classifier_accumulated ),
811+ )
812+
742813 # Log costs and timings
743814 self .log_costs (costs_metric )
744815 log_step_timings (time_metric , request .chatId )
@@ -868,10 +939,16 @@ async def _stream_rag_pipeline(
868939 )
869940
870941 start_time = time .time ()
942+ conversation_history , conversation_summary = await get_conversation_history (
943+ chat_id = request .chatId ,
944+ store = self .conversation_history_store ,
945+ fallback = request .conversationHistory ,
946+ )
871947 refined_output , refiner_usage = self ._refine_user_prompt (
872948 llm_manager = components ["llm_manager" ],
873949 original_message = request .message ,
874- conversation_history = request .conversationHistory ,
950+ conversation_history = conversation_history ,
951+ conversation_summary = conversation_summary ,
875952 )
876953 time_metric ["prompt_refiner" ] = time .time () - start_time
877954 costs_metric ["prompt_refiner" ] = refiner_usage
@@ -1173,6 +1250,17 @@ async def bot_response_generator() -> AsyncIterator[str]:
11731250 f"Storage failed for chat_id: { request .chatId } , environment: { request .environment } - { str (storage_error )} "
11741251 )
11751252
1253+ # Persist conversation history (RAG streaming)
1254+ if self .conversation_history_store is not None :
1255+ _rag_bot_message = "" .join (accumulated_response )
1256+ if _rag_bot_message not in _HISTORY_EXCLUDED_MESSAGES :
1257+ await save_history_round (
1258+ self .conversation_history_store ,
1259+ request .chatId ,
1260+ request .message ,
1261+ _rag_bot_message ,
1262+ )
1263+
11761264 # Mark stream as completed successfully
11771265 stream_ctx .mark_completed ()
11781266
@@ -1422,10 +1510,16 @@ async def _execute_orchestration_pipeline(
14221510
14231511 # Step 1: Refine user prompt
14241512 start_time = time .time ()
1513+ conversation_history , conversation_summary = await get_conversation_history (
1514+ chat_id = request .chatId ,
1515+ store = self .conversation_history_store ,
1516+ fallback = request .conversationHistory ,
1517+ )
14251518 refined_output , refiner_usage = self ._refine_user_prompt (
14261519 llm_manager = components ["llm_manager" ],
14271520 original_message = request .message ,
1428- conversation_history = request .conversationHistory ,
1521+ conversation_history = conversation_history ,
1522+ conversation_summary = conversation_summary ,
14291523 )
14301524 timing_key = f"{ prefix } .prompt_refiner" if prefix else "prompt_refiner"
14311525 time_metric [timing_key ] = time .time () - start_time
@@ -2304,6 +2398,7 @@ def _refine_user_prompt(
23042398 llm_manager : LLMManager ,
23052399 original_message : str ,
23062400 conversation_history : List [ConversationItem ],
2401+ conversation_summary : Optional [str ] = None ,
23072402 ) -> tuple [PromptRefinerOutput , Dict [str , Any ]]:
23082403 """
23092404 Refine user prompt using loaded LLM configuration and return usage info.
@@ -2312,6 +2407,10 @@ def _refine_user_prompt(
23122407 llm_manager: The LLM manager instance to use
23132408 original_message: The original user message to refine
23142409 conversation_history: Previous conversation context
2410+ conversation_summary: Optional summary of earlier conversation rounds
2411+ that were evicted from Redis. When provided it is prepended to the
2412+ DSPy history as a ``system`` turn so the refiner can use it for
2413+ context without re-summarising via an LLM call.
23152414
23162415 Returns:
23172416 Tuple of (PromptRefinerOutput, usage_dict): The refined prompt output and usage info
@@ -2324,8 +2423,16 @@ def _refine_user_prompt(
23242423 logger .info ("Starting prompt refinement process" )
23252424
23262425 try :
2327- # Convert conversation history to DSPy format
2426+ # Convert conversation history to DSPy format, optionally prepending
2427+ # a pre-computed summary of earlier (evicted) conversation rounds.
23282428 history : List [Dict [str , str ]] = []
2429+ if conversation_summary :
2430+ history .append (
2431+ {
2432+ "role" : "system" ,
2433+ "content" : f"Summary of earlier conversation: { conversation_summary } " ,
2434+ }
2435+ )
23292436 for item in conversation_history :
23302437 role = "assistant" if item .authorRole == "bot" else item .authorRole
23312438 history .append ({"role" : role , "content" : item .message })
0 commit comments