
    `gj>                    :    d Z ddlmZ ddlmZ dgZ G d d      Zy)u  Stateful scrubber for reasoning/thinking blocks in streamed assistant text.

``run_agent._strip_think_blocks`` is regex-based and correct for a complete
string, but when it runs *per-delta* in ``_fire_stream_delta`` it destroys
the state that downstream consumers (CLI ``_stream_delta``, gateway
``GatewayStreamConsumer._filter_and_accumulate``) rely on.

Concretely, when MiniMax-M2.7 streams

    delta1 = "<think>"
    delta2 = "Let me check their config"
    delta3 = "</think>"

the per-delta regex erases delta1 entirely (case 2: unterminated-open at
boundary matches ``^<think>...``), so the downstream state machine never
sees the open tag, treats delta2 as regular content, and leaks reasoning
to the user.  Consumers that don't run their own state machine (ACP,
api_server, TTS) never had any defence at all — they just emitted
whatever survived the upstream regex.

This module centralises the tag-suppression state machine at the
upstream layer so every stream_delta_callback sees text that has
already had reasoning blocks removed.  Partial tags at delta
boundaries are held back until the next delta resolves them, and
end-of-stream flushing surfaces any held-back prose that turned out
not to be a real tag.

Usage::

    scrubber = StreamingThinkScrubber()
    for delta in stream:
        visible = scrubber.feed(delta)
        if visible:
            emit(visible)
    tail = scrubber.flush()  # at end of stream
    if tail:
        emit(tail)

The scrubber is re-entrant per agent instance.  Call ``reset()`` at
the top of each new turn so a hung block from an interrupted prior
stream cannot taint the next turn's output.

Tag variants handled (case-insensitive):
  ``<think>``, ``<thinking>``, ``<reasoning>``, ``<thought>``,
  ``<REASONING_SCRATCHPAD>``.

Block-boundary rule for opens: an opening tag is only treated as a
reasoning-block opener when it appears at the start of the stream,
after a newline (optionally followed by whitespace), or when only
whitespace has been emitted on the current line.  This prevents prose
that *mentions* the tag name (e.g. ``"use <think> tags here"``) from
being incorrectly suppressed.  Closed pairs (``<think>X</think>``) are
always suppressed regardless of boundary; a closed pair is an
intentional, bounded construct.
    )annotations)TupleStreamingThinkScrubberc                  @   e Zd ZU dZdZded<    ed eD              Zded<    ed eD              Zded<    e	d	 eez   D              Z
d
ed<   ddZddZddZddZe	 	 	 	 	 	 dd       ZddZ	 	 	 	 	 	 ddZ	 	 	 	 	 	 	 	 ddZe	 	 	 	 	 	 dd       Zedd       Zy)r   an  Stateful scrubber for streaming reasoning/thinking blocks.

    State machine:
      - ``_in_block``: True while inside an opened block, waiting for
        a close tag.  All text inside is discarded.
      - ``_buf``: held-back partial-tag tail.  Emitted / discarded on
        the next ``feed()`` call or by ``flush()``.
      - ``_last_emitted_ended_newline``: True iff the most recent
        emission to the consumer ended with ``\n``, or nothing has
        been emitted yet (start-of-stream counts as a boundary).  Used
        to decide whether an open tag at buffer position 0 is at a
        block boundary.
    )thinkthinking	reasoningthoughtREASONING_SCRATCHPADTuple[str, ...]_OPEN_TAG_NAMESc              #  (   K   | ]
  }d | d  yw)<>N .0names     G/root/.hermes/venv/lib/python3.12/site-packages/agent/think_scrubber.py	<genexpr>z StreamingThinkScrubber.<genexpr>Y   s     'P!D6'P   
_OPEN_TAGSc              #  (   K   | ]
  }d | d  yw)</r   Nr   r   s     r   r   z StreamingThinkScrubber.<genexpr>Z   s     (R$2dV1(Rr   _CLOSE_TAGSc              #  2   K   | ]  }t        |        y w)N)len)r   tags     r   r   z StreamingThinkScrubber.<genexpr>]   s     ICHIs   int_MAX_TAG_LENc                .    d| _         d| _        d| _        y )NF T	_in_block_buf_last_emitted_ended_newlineselfs    r   __init__zStreamingThinkScrubber.__init___   s    $	15(    c                .    d| _         d| _        d| _        y)z4Reset all state.  Call at the top of every new turn.Fr"   TNr#   r'   s    r   resetzStreamingThinkScrubber.resetd   s    	+/(r*   c                p   |sy| j                   |z   }d| _         g }|r| j                  rq| j                  || j                        \  }}|dk(  r<| j	                  || j                        }|r|| d nd| _         dj                  |      S |||z   d }d| _        n| j                  |      }| j                  ||      \  }}	|Z|dk(  s|d   |k  rM|\  }
}|d|
 }|r:| j                  |      }|r'|j                  |       |j                  d      | _        ||d }|dk7  rR|d| }|r:| j                  |      }|r'|j                  |       |j                  d      | _        d| _        |||	z   d }Y| j	                  || j                        }| j	                  || j                        }t        ||      }|r|d|  }|| d | _         n	|}d| _         |r:| j                  |      }|r'|j                  |       |j                  d      | _        dj                  |      S |rdj                  |      S )zFeed one delta; return the scrubbed visible portion.

        May return an empty string when the entire delta is reasoning
        content or is being held back pending resolution of a partial
        tag at the boundary.
        r"   NFr   
T)r%   r$   _find_first_tagr   _max_partial_suffixjoin_find_earliest_closed_pair_find_open_at_boundary_strip_orphan_close_tagsappendendswithr&   r   max)r(   textbufout	close_idx	close_lenheldpairopen_idxopen_len	start_idxend_idx	preceding
held_close	emit_texts                  r   feedzStreamingThinkScrubber.feedj   s    ii$	~~'+';';))($	9 ?  33C9I9IJD/3TEFDI773<')i/01!& 66s; &*%@%@&"(
 #Nd1g&9)-&Iw #JYI $($A$A)$L	$JJy1 ) 2 24 8 !< gh-Cr> !$IXI $($A$A)$L	$JJy1 ) 2 24 8 !< &*DNh123C
 //T__E!55))
 4, #FdUI #TEFDI #I "DI $ = =i HI 

9-%..t4 8 wws|#c f wws|r*   c                    | j                   rd| _        d| _         d| _        y| j                  }d| _        d| _        |sy| j                  |      S )u  End-of-stream flush.

        If still inside an unterminated block, held-back content is
        discarded — leaking partial reasoning is worse than a
        truncated answer.  Otherwise the held-back partial-tag tail is
        emitted verbatim (it turned out not to be a real tag prefix).

        Always treats the next ``feed()`` as a fresh stream boundary.
        Intra-turn retries (thinking-only prefill, empty-response
        retry) flush then stream again without calling ``reset()``;
        leaving ``_last_emitted_ended_newline`` False made a new
        stream's opening ``<think>`` look mid-line and leak into the
        visible reply.
        r"   FT)r$   r%   r&   r5   )r(   tails     r   flushzStreamingThinkScrubber.flush   sU     >>DI"DN/3D,yy	 ,0(,,T22r*   c                    | j                         }d}d}|D ]?  }|j                  |j                               }|dk7  s(|dk(  s||k  s3|}t        |      }A ||fS )zfReturn (earliest_index, tag_length) over *tags*, or (-1, 0).

        Case-insensitive match.
        r.   r   )lowerfindr   )r:   tags	buf_lowerbest_idxbest_lenr   idxs          r   r0   z&StreamingThinkScrubber._find_first_tag   sj     IIK	 	$C..-Cbyh"nhs8		$
 !!r*   c                j   |j                         }d}t        | j                  | j                        D ]}  \  }}|j                         }|j                         }|j	                  |      }|dk(  r=|j	                  ||t        |      z         }	|	dk(  ra|	t        |      z   }
|	||d   k  sz||
f} |S )a  Return (start_idx, end_idx) of the earliest closed pair, else None.

        A closed pair is ``<tag>...</tag>`` of any variant.  Matches are
        case-insensitive and non-greedy (the closest close tag after
        an open tag wins), matching the regex ``<tag>.*?</tag>``
        semantics of ``_strip_think_blocks`` case 1.  When two tag
        variants could both match, the one whose open tag appears
        earlier wins.
        Nr.   r   )rL   zipr   r   rM   r   )r(   r:   rO   bestopen_tag	close_tag
open_lowerclose_lowerr@   r<   rC   s              r   r3   z1StreamingThinkScrubber._find_earliest_closed_pair   s     IIK	)-#&t8H8H#I 	+Hi!)J#//+K ~~j1H2~!XJ7I B#k"22G|x$q'1 '*	+ r*   c                
   |j                         }d}d}| j                  D ]]  }|j                         }d}	 |j                  ||      }	|	dk(  r.| j                  ||	|      r|dk(  s|	|k  r|	}t	        |      }Y|	dz   }I ||fS )zReturn the earliest block-boundary open-tag (idx, len).

        Returns (-1, 0) if no boundary-legal opener is present.
        r.   r      )rL   r   rM   _is_block_boundaryr   )
r(   r:   already_emittedrO   rP   rQ   r   	tag_lowersearch_startrR   s
             r   r4   z-StreamingThinkScrubber._find_open_at_boundary  s     IIK	?? 	'C		ILnnY="9**3_E2~x#&#&s8"Qw 	' !!r*   c                ,   |dk(  r"|r|d   j                  d      S | j                  S |d| }|j                  d      }|dk(  r:|r|d   j                  d      }n| j                  }|xr |j                         dk(  S ||dz   d j                         dk(  S )a  True iff position *idx* in *buf* is a block boundary.

        A block boundary is:
          - buf position 0 AND the most recent emission ended with
            a newline (or nothing has been emitted yet)
          - any position whose preceding text on the current line
            (since the last newline in buf) is whitespace-only, AND
            if there is no newline in the preceding buf portion, the
            most recent prior emission ended with a newline
        r   r.   r/   Nr"   r[   )r7   r&   rfindstrip)r(   r:   rR   r]   rD   last_nlprior_newlines          r   r\   z)StreamingThinkScrubber._is_block_boundary4  s     !8 &r*33D99333I	//$'b=  / 3 < <T B $ @ @ <Y__%6"%<< 1&,,."44r*   c                   |sy|j                         }t        t        |      | j                  dz
        }t	        |dd      D ]E  }|| d }|D ]8  }|j                         }t        |      |kD  s"|j                  |      s4|c c S  G y)zReturn the longest buf-suffix that is a prefix of any tag.

        Only prefixes strictly shorter than the tag itself count
        (full-length suffixes are the tag and are handled as matches,
        not held-back partials).  Case-insensitive.
        r   r[   r.   N)rL   minr   r    range
startswith)	clsr:   rN   rO   	max_checkisuffixr   r^   s	            r   r1   z*StreamingThinkScrubber._max_partial_suffixW  s     IIK	I(8(81(<=	y!R( 	Ars^F IIK	y>A%)*>*>v*FH	 r*   c                   d|vr|S |j                         }g }d}|t        |      k  rd}|||dz    dk(  rr| j                  D ]c  }|j                         }t        |      }||||z    |k(  s*||z   }	|	t        |      k  r"||	   dv r|	dz  }	|	t        |      k  r||	   dv r|	}d} n |s|j                  ||          |dz  }|t        |      k  rdj	                  |      S )	a  Remove any close tags from *text* (orphan-close handling).

        An orphan close tag has no matching open in the current
        scrubber state; it's always noise, stripped with any trailing
        whitespace so the surrounding prose flows naturally.
        r   r   F   z 	
r[   Tr"   )rL   r   r   r6   r2   )
ri   r9   
text_lowerr;   rk   matchedr   r^   tag_lenjs
             r   r5   z/StreamingThinkScrubber._strip_orphan_close_tagsm  s    tKZZ\
#d)mG!AE"d*?? C #		I!)nG!!AK0I= K#d)mQ90DFA  #d)mQ90D"& 

47#Q# #d)m$ wws|r*   N)returnNone)r9   strrs   ru   )rs   ru   )r:   ru   rN   r   rs   Tuple[int, int])r:   ru   )r:   ru   r]   	list[str]rs   rv   )r:   ru   rR   r   r]   rw   rs   bool)r:   ru   rN   r   rs   r   )__name__
__module____qualname____doc__r   __annotations__tupler   r   r8   r    r)   r,   rG   rJ   staticmethodr0   r3   r4   r\   classmethodr1   r5   r   r*   r   r   r   @   s(   (O_  #('P'P"PJP#((R/(R#RKR I
[0HIIL#I6
0`D3B ""'"	" ""8"")2"	"2!5!5 !53<!5	!5F ,	 *  r*   N)r|   
__future__r   typingr   __all__r   r   r*   r   <module>r      s'   6p # #
$L Lr*   