
    sj|)                    .   d Z ddlmZ ddlZddlmZ ddlmZ ddlm	Z	m
Z
mZmZ  ej        e          ZdZdZ ed	          Zd
ZdZdZdZdZ ed
           G d d                      Zd+dZd,dZd-dZd.dZd/d Zd0d"Zd1d&Zd,d'Z d2d)Z!d3d*Z"dS )4u  Deterministic-empty detection and cost-aware retry budgets (NS-503).

When a provider returns an empty completion, the agent loop retries up to
3 times and then walks the fallback chain. Every attempt re-sends the full
conversation input — at large context on paid routes this bills the user
repeatedly for a turn that produces no text (the "charged ~$2.33 for an
empty answer" incident class).

Signaled refusals (``finish_reason="content_filter"``, Anthropic
``stop_reason="refusal"``, Bedrock guardrails) are already terminal and
never reach the empty-retry loop. This module addresses the *unsignaled*
empties: the provider reports a successful completion with zero output
tokens and a generic finish reason (portal-proxied refusals commonly look
like this).

Two independent guards, both failing OPEN to today's behaviour:

1. **Deterministic-empty detection** — two consecutive empty attempts,
   both with usage present and ``output_tokens == 0``, from the same
   (model, provider, finish_reason), are treated as deterministic: the
   same prompt will keep producing the same empty. Remaining retries are
   skipped and the loop proceeds straight to the fallback chain (a
   different model may behave differently). Attempts with missing usage
   or ``output_tokens > 0`` (model generated *something* — think-block
   stripping, whitespace, flaky decoding) never classify as deterministic
   and keep the full retry budget.

2. **Cost-aware retry budget** — when the estimated input cost of a
   single empty attempt exceeds the configured threshold (default
   $0.25), the empty-retry budget for this streak drops from 3 to 1.
   Unknown pricing, missing usage, or included/subscription routes
   leave the budget untouched.

Configured via the additive ``agent.empty_response_guard`` section in
``config.yaml`` (resolved once at agent init by ``agent_init``)::

    agent:
      empty_response_guard:
        enabled: true            # false = legacy fixed 3-retry behaviour
        cost_threshold_usd: 0.25 # per-attempt cost that halves the budget

Per project policy, no ``HERMES_*`` environment variables are involved —
``.env`` is reserved for credentials; behavioural settings live in
``config.yaml``.
    )annotationsN)	dataclass)Decimal)AnyListOptionalTuple      z0.25T_empty_attempt_history_empty_streak_cost_usd_empty_guard_enabled_empty_guard_cost_threshold_usd)frozenc                  ^    e Zd ZU dZded<   ded<   ded<   ded<   ded<   edd            ZdS )EmptyAttemptz8One observed empty completion within the current streak.strmodelproviderfinish_reasonboolusage_presentzero_outputreturntuplec                *    | j         | j        | j        fS N)r   r   r   )selfs    >/home/agent/.hermes/hermes-agent/agent/empty_response_guard.py	signaturezEmptyAttempt.signatureR   s    
DM4+=>>    N)r   r   )__name__
__module____qualname____doc____annotations__propertyr     r!   r   r   r   H   sp         BBJJJMMM? ? ? X? ? ?r!   r   sectionr   r   Tuple[bool, Decimal]c                H   t          | t                    st          t          fS |                     dt                    }t          |t
                    r|}nEt          |t                    r)|                                                                dv}nt          }t          }|                     d          }|ft          |t
                    sQ	 t          t          |                    }|dk    r|}n+# t          $ r t                              d|           Y nw xY w||fS )aa  Resolve ``agent.empty_response_guard`` config into (enabled, threshold).

    Tolerant of malformed input: anything that isn't a well-formed dict
    (or well-formed values within it) falls back to the schema defaults.
    Called once per agent at init; the resolved values are stashed on the
    agent object so the hot loop never re-reads config.
    enabled)0falsenooffcost_threshold_usdNr   z9empty-guard: invalid cost_threshold_usd %r, using default)
isinstancedictDEFAULT_GUARD_ENABLEDDEFAULT_COST_THRESHOLD_USDgetr   r   striplowerr   	Exceptionloggerdebug)r)   enabled_rawr,   	thresholdthreshold_raw	candidates         r   resolve_guard_settingsr@   W   s/    gt$$ C%'ABB++i)>??K+t$$ (	K	%	% (##%%++--5PP'*IKK 455M M4)H)H 	M 2 233I1}}%	 	 	 	LLK    	
 Ys   $C5 5%DDagentr   c                t    t          | t          t                    }t          |t                    r|nt          S )zWhether the guard is enabled for this agent (config-resolved).

    Agents built before the config was threaded through (tests, embedded
    callers) simply get the default: enabled.
    )getattr_ENABLED_ATTRr4   r2   r   rA   values     r   guard_enabledrG   z   s0     E=*?@@Eud++F551FFr!   r   c                v    t          | t          d           }t          |t                    r|dk    r|S t          S )Nr   )rC   _THRESHOLD_ATTRr2   r   r5   rE   s     r   _cost_threshold_usdrJ      s8    E?D11E%!! eaii%%r!   List[EmptyAttempt]c                f    t          | t          d           }|g }t          | t          |           |S r   )rC   _ATTEMPTS_ATTRsetattr)rA   attemptss     r   	_attemptsrP      s5    und33H~x000Or!   responseOptional[Decimal]c                   t          |dd          }|sdS 	 ddlm}m}  ||t          | dd          t          | dd                    } |t          | dd	          pd	|t          | dd          t          | d
d          t          | dd                    }n-# t          $ r  t
                              dd           Y dS w xY wt          |dd          S )z<Best-effort USD estimate for one attempt. None when unknown.usageNr   )estimate_usage_costnormalize_usager   api_moder   rW   r    base_urlapi_key)r   rZ   r[   z#empty-guard: cost estimation failedTexc_info
amount_usd)rC   agent.usage_pricingrU   rV   r9   r:   r;   )rA   rQ   	raw_usagerU   rV   	canonicalresults          r   _estimate_attempt_costrc      s"   '400I tLLLLLLLL#OUJ55UJ55
 
 
	
 %$E7B''-2UJ55UJ55E9d33
 
 
    :TJJJtt 6<...s   BB &CCr   c           	        t          |dd          }|sdS 	 ddlm}  ||t          | dd          t          | dd                    }n-# t          $ r  t                              d	d
           Y dS w xY wt          |dd          }|dS t          |dd          dk    rdS t          |dd          pd}d
||z   dk    fS )zAReturn (usage_present, zero_output) for a response, failing open.rT   N)FFr   )rV   r   rW   rX   z'empty-guard: usage normalization failedTr\   output_tokensprompt_tokensreasoning_tokens)rC   r_   rV   r9   r:   r;   )rA   rQ   r`   rV   ra   output	reasonings          r   _zero_outputrj      s   '400I ~
777777#OUJ55UJ55
 
 
		
    >NNN~~ Y66F~~ y/1--22~ 	#5q99>QI6I%!+,,s   2A
 
&A43A4r   r   Nonec                  t          |           }t          | dd          dk    r7|                                 t          | t          t          d                     t          | |          \  }}|                    t          t          t          | dd          pd          t          t          | dd          pd          t          |pd          ||                     t          | |          }|S|dk    rOt          | t          t          d                    pt          d          }t          | t          ||z              dS dS dS )	a"  Record one empty completion in the current streak.

    Must be called before ``_empty_content_retries`` is incremented for
    this attempt: a counter of 0 marks the start of a new streak and
    clears prior history (this transparently follows every existing
    counter-reset site).
    _empty_content_retriesr   r-   r   rY   r   )r   r   r   r   r   N)rP   rC   clearrN   _STREAK_COST_ATTRr   rj   appendr   r   rc   )rA   r   rQ   rO   r   r   costpriors           r   record_empty_attemptrs      sG    Hu.22a77('#,,777!-eX!>!>M;OOgeWb117R88
B77=2>>m1r22'#	
 	
 	
   "%22DD1HH0'#,,??O73<<(%$,77777 HHr!   c                    t          |           sdS t          | t          d          pg }t          |          dk     rdS |d         t	          fd|D                       S )uI  True when the current streak looks deterministic.

    Requires >= 2 consecutive attempts, ALL with usage present, zero
    output tokens, and an identical (model, provider, finish_reason)
    signature. Any attempt with missing usage or non-zero output keeps
    this False (fail open — transients deserve their retries).
    FN   r   c              3  T   K   | ]"}|j         o|j        o|j        j        k    V  #d S r   )r   r   r    ).0afirsts     r   	<genexpr>z&deterministic_empty.<locals>.<genexpr>   sN         	
LAMLakU_.L     r!   )rG   rC   rM   lenall)rA   rO   ry   s     @r   deterministic_emptyr}      s      uund339rH
8}}quQKE         r!   intc                    t          |           st          S t          | |          }|t          S |t          |           k    rt          S t          S )zEmpty-retry budget for the current streak (3, or 1 when a single
    attempt is estimated to cost more than the configured threshold).)rG   DEFAULT_EMPTY_RETRY_BUDGETrc   rJ   REDUCED_EMPTY_RETRY_BUDGET)rA   rQ   rq   s      r   empty_retry_budgetr      sT      *))!%22D|))"5))))))%%r!   c                F    t          | t          d          }||dk    rdS |S )zAAccumulated estimated cost of the current empty streak, if known.Nr   )rC   ro   )rA   rq   s     r   streak_cost_usdr     s+    5+T22D|tqyytKr!   )r)   r   r   r*   )rA   r   r   r   )rA   r   r   r   )rA   r   r   rK   )rA   r   rQ   r   r   rR   )rA   r   rQ   r   r   r   )rA   r   r   r   rQ   r   r   rk   )rA   r   rQ   r   r   r~   )rA   r   r   rR   )#r%   
__future__r   loggingdataclassesr   decimalr   typingr   r   r   r	   	getLoggerr"   r:   r   r   r5   r4   rM   ro   rD   rI   r   r@   rG   rJ   rP   rc   rj   rs   r}   r   r   r(   r!   r   <module>r      s  , ,\ # " " " " "  ! ! ! ! ! !       - - - - - - - - - - - -		8	$	$  $WV__   *, &3 $? ? ? ? ? ? ? ?           FG G G G& & & &   / / / /4- - - ->8 8 8 8<   (
& 
& 
& 
&     r!   