from __future__ import annotations from pipecat.frames.frames import ( CancelFrame, Frame, InterruptionFrame, LLMFullResponseEndFrame, LLMFullResponseStartFrame, LLMTextFrame, OutputTransportMessageUrgentFrame, ) from pipecat.processors.frame_processor import FrameDirection, FrameProcessor from .config import ResponseStateConfig class StateTagResponseProcessor(FrameProcessor): """Extract a leading state tag from LLM text before text streaming and TTS. Expected model output: some statespoken response The extracted state is emitted as a product protocol event, while only the spoken response text is forwarded downstream. If the model does not produce the tag, the original text is forwarded unchanged. """ def __init__(self, config: ResponseStateConfig) -> None: super().__init__() self._tag = config.tag self._event_type = config.event_type self._max_prefix_chars = config.max_prefix_chars self._opening_tag = f"<{self._tag}>" self._closing_tag = f"" self._start_frame: LLMFullResponseStartFrame | None = None self._buffer = "" self._decided = False self._in_llm_response = False async def process_frame(self, frame: Frame, direction: FrameDirection) -> None: await super().process_frame(frame, direction) if isinstance(frame, LLMFullResponseStartFrame): self._start_frame = frame self._buffer = "" self._decided = False self._in_llm_response = True return if isinstance(frame, LLMTextFrame) and self._in_llm_response and not self._decided: await self._process_initial_text(frame.text or "", direction) return if isinstance(frame, LLMFullResponseEndFrame): if self._in_llm_response: await self._flush_buffer(direction) await self.push_frame(frame, direction) self._reset() return if isinstance(frame, (InterruptionFrame, CancelFrame)): if self._in_llm_response: await self._flush_buffer(direction) self._reset() await self.push_frame(frame, direction) return await self.push_frame(frame, direction) async def _process_initial_text(self, text: str, direction: FrameDirection) -> None: if not text: return self._buffer += text decision = self._parse_buffer() if decision is None: return self._decided = True state, response_text = decision if state is not None: await self._emit_state(state) await self._push_start(direction) if response_text: await self.push_frame(LLMTextFrame(response_text), direction) self._buffer = "" def _parse_buffer(self) -> tuple[str | None, str] | None: stripped = self._buffer.lstrip() if not stripped: return None if stripped.startswith(self._opening_tag): state_start = len(self._opening_tag) state_end = stripped.find(self._closing_tag, state_start) if state_end >= 0: response_start = state_end + len(self._closing_tag) return stripped[state_start:state_end].strip(), stripped[response_start:] if len(self._buffer) < self._max_prefix_chars: return None return None, self._buffer if self._opening_tag.startswith(stripped) and len(self._buffer) < self._max_prefix_chars: return None return None, self._buffer async def _flush_buffer(self, direction: FrameDirection) -> None: await self._push_start(direction) if self._buffer: await self.push_frame(LLMTextFrame(self._buffer), direction) self._buffer = "" self._decided = True async def _push_start(self, direction: FrameDirection) -> None: if self._start_frame: await self.push_frame(self._start_frame, direction) self._start_frame = None async def _emit_state(self, state: str) -> None: await self.push_frame( OutputTransportMessageUrgentFrame( message={ "type": self._event_type, "state": state, } ), FrameDirection.DOWNSTREAM, ) def _reset(self) -> None: self._start_frame = None self._buffer = "" self._decided = False self._in_llm_response = False