fix(agent): stop re-billing deterministic empty responses (NS-503)

Every empty-response retry re-sends the full conversation input at full
price. On large contexts a single turn that produces no visible output
could bill the user several dollars across the 3-retry + fallback-chain
walk (reported: ~$2.33 for one empty answer on a ~26K-token session).

Signaled refusals (finish_reason=content_filter, Anthropic refusal
stop_reason, guardrail interventions) are already terminal today and
never reach this loop. The uncovered class is *unsignaled* refusals:
the provider returns 200 with zero output tokens and a generic finish
reason. Those are deterministic — resending the identical prompt
reproduces the same empty — so burning the remaining retry budget only
multiplies the charge.

New agent/empty_response_guard.py, two independent guards, both failing
OPEN to today's behaviour:

- Deterministic-empty detection: two consecutive empty attempts with
  usage present, output_tokens == 0 (reasoning tokens count as output),
  and identical (model, provider, finish_reason) skip the remaining
  retries and go straight to the fallback chain — a different model may
  well answer. Missing usage, nonzero output, or any signature change
  keeps the full budget.
- Cost-aware retry budget: when one attempt's estimated input cost
  exceeds HERMES_EMPTY_RETRY_COST_THRESHOLD_USD (default $0.25), the
  empty-retry budget drops 3 -> 1 for that streak. Unknown pricing or
  included/subscription routes are untouched.

At exhaustion the status trace now includes the estimated cost of the
empty attempts so the charge is at least explained in-session.

Streak state lives on the agent and self-clears whenever
_empty_content_retries resets to 0, transparently honouring every
existing reset site (turn start, tool success, compaction, fallback
activation) without touching them.

Set HERMES_DETERMINISTIC_EMPTY_GUARD=0 to disable both guards.

Tests: tests/agent/test_empty_response_guard.py (26 unit tests) plus
two loop-level integration tests in tests/run_agent/test_run_agent.py
proving the api_call reduction and the fail-open path.

Refs NS-503.
This commit is contained in:
Shannon Sands
2026-07-31 10:37:26 +10:00
committed by Teknium
parent 204302bd64
commit ac06c2ff8b
4 changed files with 624 additions and 6 deletions
+68 -6
View File
@@ -93,6 +93,7 @@ from agent.trajectory import has_incomplete_scratchpad
# finalizer at turn end.
from agent.turn_finalizer import finalize_turn
from agent.usage_pricing import estimate_usage_cost, normalize_usage
from agent import empty_response_guard as _empty_guard
from hermes_constants import PARTIAL_STREAM_STUB_ID
from hermes_logging import set_session_context
from tools.skill_provenance import set_current_write_origin
@@ -7603,7 +7604,36 @@ def run_conversation(
_has_structured
and agent._thinking_prefill_retries >= 2
)
if _truly_empty and (not _has_structured or _prefill_exhausted) and agent._empty_content_retries < 3:
_empty_candidate = _truly_empty and (
not _has_structured or _prefill_exhausted
)
if _empty_candidate:
# NS-503: every empty attempt re-sends the full
# conversation input at full price. Record the
# attempt (usage/finish_reason signature) so
# deterministic empties — e.g. unsignaled
# provider refusals with zero output tokens —
# stop burning paid retries reproducing the
# same empty. Fails open: missing usage or
# any generated tokens keep the full budget.
_empty_guard.record_empty_attempt(
agent,
finish_reason=finish_reason,
response=response,
)
_empty_retry_budget = (
_empty_guard.empty_retry_budget(agent, response)
if _empty_candidate
else _empty_guard.DEFAULT_EMPTY_RETRY_BUDGET
)
_deterministic_empty = _empty_candidate and (
_empty_guard.deterministic_empty(agent)
)
if (
_empty_candidate
and agent._empty_content_retries < _empty_retry_budget
and not _deterministic_empty
):
agent._empty_content_retries += 1
wait_time = jittered_backoff(
agent._empty_content_retries,
@@ -7612,12 +7642,19 @@ def run_conversation(
)
logger.warning(
"Empty response (no content or reasoning) — "
"retry %d/3 in %.1fs (model=%s)",
agent._empty_content_retries, wait_time, agent.model,
"retry %d/%d in %.1fs (model=%s)",
agent._empty_content_retries,
_empty_retry_budget, wait_time, agent.model,
)
_budget_note = (
" — high-cost request, reduced retry budget"
if _empty_retry_budget < _empty_guard.DEFAULT_EMPTY_RETRY_BUDGET
else ""
)
agent._buffer_status(
f"⚠️ Empty response from model — retrying "
f"({agent._empty_content_retries}/3) in {wait_time:.0f}s"
f"({agent._empty_content_retries}/{_empty_retry_budget}) "
f"in {wait_time:.0f}s{_budget_note}"
)
# Sleep in small increments to stay responsive to interrupts
sleep_end = time.time() + wait_time
@@ -7627,7 +7664,7 @@ def run_conversation(
agent._vprint(f"{agent.log_prefix}⚡ Interrupt detected during empty-response retry wait, aborting.", force=True)
_interrupt_text = (
f"Operation interrupted: retrying empty response from model "
f"(retry {agent._empty_content_retries}/3)."
f"(retry {agent._empty_content_retries}/{_empty_retry_budget})."
)
close_interrupted_tool_sequence(messages, _interrupt_text)
agent._persist_session(messages, conversation_history)
@@ -7643,11 +7680,25 @@ def run_conversation(
_backoff_touch_counter += 1
if _backoff_touch_counter % 150 == 0: # 150 × 0.2s = 30s
agent._touch_activity(
f"empty response retry backoff ({agent._empty_content_retries}/3), "
f"empty response retry backoff ({agent._empty_content_retries}/{_empty_retry_budget}), "
f"{int(sleep_end - time.time())}s remaining"
)
continue
if _truly_empty and _deterministic_empty:
logger.warning(
"Deterministic empty response detected "
"(consecutive zero-output completions, "
"model=%s provider=%s finish_reason=%s) — "
"skipping remaining retries",
agent.model, agent.provider, finish_reason,
)
agent._buffer_status(
"⚠️ Model is deterministically returning empty "
"(zero output tokens) — skipping further retries "
"to avoid repeat charges"
)
# ── Exhausted retries — try fallback provider ──
# Before giving up with "(empty)", attempt to
# switch to the next provider in the fallback
@@ -7694,6 +7745,17 @@ def run_conversation(
# "(empty)" terminal.
# Surface the buffered retry/fallback trace so the
# user can see what was attempted before "(empty)".
# NS-503: if we know roughly what the empty streak
# cost (each attempt re-billed the full input), say
# so — an unexplained charge for "no answer" is the
# core of the complaint.
_streak_cost = _empty_guard.streak_cost_usd(agent)
if _streak_cost is not None:
agent._buffer_status(
f"ℹ️ Estimated cost of these empty attempts: "
f"~${_streak_cost:.2f} (input tokens are billed "
f"per attempt even when no answer is produced)"
)
agent._flush_status_buffer()
_turn_exit_reason = "empty_response_exhausted"
reasoning_text = agent._extract_reasoning(assistant_message)
+230
View File
@@ -0,0 +1,230 @@
"""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 ``HERMES_EMPTY_RETRY_COST_THRESHOLD_USD``
(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.
Set ``HERMES_DETERMINISTIC_EMPTY_GUARD=0`` to disable both guards.
"""
from __future__ import annotations
import logging
import os
from dataclasses import dataclass
from decimal import Decimal
from typing import Any, List, Optional
logger = logging.getLogger(__name__)
DEFAULT_EMPTY_RETRY_BUDGET = 3
REDUCED_EMPTY_RETRY_BUDGET = 1
DEFAULT_COST_THRESHOLD_USD = Decimal("0.25")
# Attribute names stashed on the agent object. State is scoped to one
# consecutive empty streak: it is cleared whenever a streak starts
# (``_empty_content_retries == 0`` at record time), which transparently
# honours every existing reset site (turn start, compaction, tool
# success, fallback activation) without touching them.
_ATTEMPTS_ATTR = "_empty_attempt_history"
_STREAK_COST_ATTR = "_empty_streak_cost_usd"
@dataclass(frozen=True)
class EmptyAttempt:
"""One observed empty completion within the current streak."""
model: str
provider: str
finish_reason: str
usage_present: bool
zero_output: bool
@property
def signature(self) -> tuple:
return (self.model, self.provider, self.finish_reason)
def guard_enabled() -> bool:
return os.environ.get("HERMES_DETERMINISTIC_EMPTY_GUARD", "1").strip().lower() not in (
"0",
"false",
"no",
"off",
)
def _cost_threshold_usd() -> Decimal:
raw = os.environ.get("HERMES_EMPTY_RETRY_COST_THRESHOLD_USD", "").strip()
if not raw:
return DEFAULT_COST_THRESHOLD_USD
try:
value = Decimal(raw)
except Exception:
return DEFAULT_COST_THRESHOLD_USD
if value <= 0:
return DEFAULT_COST_THRESHOLD_USD
return value
def _attempts(agent: Any) -> List[EmptyAttempt]:
attempts = getattr(agent, _ATTEMPTS_ATTR, None)
if attempts is None:
attempts = []
setattr(agent, _ATTEMPTS_ATTR, attempts)
return attempts
def _estimate_attempt_cost(agent: Any, response: Any) -> Optional[Decimal]:
"""Best-effort USD estimate for one attempt. None when unknown."""
raw_usage = getattr(response, "usage", None)
if not raw_usage:
return None
try:
from agent.usage_pricing import estimate_usage_cost, normalize_usage
canonical = normalize_usage(
raw_usage,
provider=getattr(agent, "provider", None),
api_mode=getattr(agent, "api_mode", None),
)
result = estimate_usage_cost(
getattr(agent, "model", "") or "",
canonical,
provider=getattr(agent, "provider", None),
base_url=getattr(agent, "base_url", None),
api_key=getattr(agent, "api_key", None),
)
except Exception: # noqa: BLE001 — pricing must never break the loop
logger.debug("empty-guard: cost estimation failed", exc_info=True)
return None
return getattr(result, "amount_usd", None)
def _zero_output(agent: Any, response: Any) -> tuple:
"""Return (usage_present, zero_output) for a response, failing open."""
raw_usage = getattr(response, "usage", None)
if not raw_usage:
return (False, False)
try:
from agent.usage_pricing import normalize_usage
canonical = normalize_usage(
raw_usage,
provider=getattr(agent, "provider", None),
api_mode=getattr(agent, "api_mode", None),
)
except Exception: # noqa: BLE001
logger.debug("empty-guard: usage normalization failed", exc_info=True)
return (False, False)
output = getattr(canonical, "output_tokens", None)
if output is None:
return (False, False)
# A present-but-empty usage object (some proxies emit usage with no
# fields) normalizes to all zeros. A genuine completion always has
# input tokens — without them the usage is not evidence, fail open.
if getattr(canonical, "prompt_tokens", 0) <= 0:
return (False, False)
# Reasoning tokens count as real generation — a reasoning-only
# response is NOT a deterministic empty (the prefill-continuation
# path upstream owns that case).
reasoning = getattr(canonical, "reasoning_tokens", 0) or 0
return (True, (output + reasoning) == 0)
def record_empty_attempt(agent: Any, *, finish_reason: str, response: Any) -> None:
"""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).
"""
attempts = _attempts(agent)
if getattr(agent, "_empty_content_retries", 0) == 0:
attempts.clear()
setattr(agent, _STREAK_COST_ATTR, Decimal("0"))
usage_present, zero_output = _zero_output(agent, response)
attempts.append(
EmptyAttempt(
model=str(getattr(agent, "model", "") or ""),
provider=str(getattr(agent, "provider", "") or ""),
finish_reason=str(finish_reason or ""),
usage_present=usage_present,
zero_output=zero_output,
)
)
cost = _estimate_attempt_cost(agent, response)
if cost is not None and cost > 0:
prior = getattr(agent, _STREAK_COST_ATTR, Decimal("0")) or Decimal("0")
setattr(agent, _STREAK_COST_ATTR, prior + cost)
def deterministic_empty(agent: Any) -> bool:
"""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).
"""
if not guard_enabled():
return False
attempts = getattr(agent, _ATTEMPTS_ATTR, None) or []
if len(attempts) < 2:
return False
first = attempts[0]
return all(
a.usage_present and a.zero_output and a.signature == first.signature
for a in attempts
)
def empty_retry_budget(agent: Any, response: Any) -> int:
"""Empty-retry budget for the current streak (3, or 1 when a single
attempt is estimated to cost more than the configured threshold)."""
if not guard_enabled():
return DEFAULT_EMPTY_RETRY_BUDGET
cost = _estimate_attempt_cost(agent, response)
if cost is None:
return DEFAULT_EMPTY_RETRY_BUDGET
if cost >= _cost_threshold_usd():
return REDUCED_EMPTY_RETRY_BUDGET
return DEFAULT_EMPTY_RETRY_BUDGET
def streak_cost_usd(agent: Any) -> Optional[Decimal]:
"""Accumulated estimated cost of the current empty streak, if known."""
cost = getattr(agent, _STREAK_COST_ATTR, None)
if cost is None or cost <= 0:
return None
return cost
+280
View File
@@ -0,0 +1,280 @@
"""Tests for agent/empty_response_guard.py (NS-503).
The guard exists to stop the empty-retry loop re-billing large inputs for
deterministic empties (unsignaled provider refusals with zero output
tokens) while never tightening behaviour on ambiguous evidence.
Fail-open contract under test:
- Missing usage -> never deterministic, default budget.
- Any generated tokens (output or reasoning) -> never deterministic.
- Different model/provider/finish_reason across attempts -> not deterministic.
- Guard disabled via env -> everything falls back to defaults.
"""
from decimal import Decimal
from types import SimpleNamespace
import pytest
from agent import empty_response_guard as guard
def _agent(**overrides):
base = dict(
model="anthropic/claude-fable-5",
provider="nous",
api_mode="chat_completions",
base_url=None,
api_key=None,
_empty_content_retries=0,
)
base.update(overrides)
return SimpleNamespace(**base)
def _response(prompt_tokens=25_900, completion_tokens=0, usage_present=True):
if not usage_present:
return SimpleNamespace(usage=None)
usage = SimpleNamespace(
prompt_tokens=prompt_tokens,
completion_tokens=completion_tokens,
total_tokens=prompt_tokens + completion_tokens,
)
return SimpleNamespace(usage=usage)
def _record_streak(agent, responses, finish_reasons=None):
"""Record attempts the way the loop does: record, then increment."""
finish_reasons = finish_reasons or ["stop"] * len(responses)
for resp, reason in zip(responses, finish_reasons):
guard.record_empty_attempt(agent, finish_reason=reason, response=resp)
agent._empty_content_retries += 1
class TestDeterministicEmpty:
def test_two_zero_output_attempts_same_signature_is_deterministic(self):
agent = _agent()
_record_streak(agent, [_response(), _response()])
assert guard.deterministic_empty(agent) is True
def test_single_attempt_is_never_deterministic(self):
"""One empty could be a transient blip — retry #1 must always run."""
agent = _agent()
_record_streak(agent, [_response()])
assert guard.deterministic_empty(agent) is False
def test_missing_usage_fails_open(self):
agent = _agent()
_record_streak(
agent,
[_response(usage_present=False), _response(usage_present=False)],
)
assert guard.deterministic_empty(agent) is False
def test_mixed_usage_presence_fails_open(self):
agent = _agent()
_record_streak(agent, [_response(), _response(usage_present=False)])
assert guard.deterministic_empty(agent) is False
def test_nonzero_output_tokens_fails_open(self):
"""Model generated something (whitespace, stripped think-blocks) —
that's the flaky-but-recoverable class, keep full retries."""
agent = _agent()
_record_streak(
agent,
[_response(completion_tokens=42), _response(completion_tokens=42)],
)
assert guard.deterministic_empty(agent) is False
def test_zero_then_nonzero_fails_open(self):
agent = _agent()
_record_streak(
agent,
[_response(completion_tokens=0), _response(completion_tokens=7)],
)
assert guard.deterministic_empty(agent) is False
def test_signature_change_resets_determinism(self):
"""Fallback switched model mid-streak — new model deserves retries."""
agent = _agent()
guard.record_empty_attempt(agent, finish_reason="stop", response=_response())
agent._empty_content_retries += 1
agent.model = "other/model"
guard.record_empty_attempt(agent, finish_reason="stop", response=_response())
agent._empty_content_retries += 1
assert guard.deterministic_empty(agent) is False
def test_finish_reason_change_fails_open(self):
agent = _agent()
_record_streak(
agent,
[_response(), _response()],
finish_reasons=["stop", "length"],
)
assert guard.deterministic_empty(agent) is False
def test_new_streak_clears_history(self):
"""Counter reset to 0 (turn start / tool success / compaction /
fallback) starts a fresh streak — prior attempts must not leak."""
agent = _agent()
_record_streak(agent, [_response(), _response()])
assert guard.deterministic_empty(agent) is True
agent._empty_content_retries = 0 # any existing reset site
guard.record_empty_attempt(agent, finish_reason="stop", response=_response())
agent._empty_content_retries += 1
assert guard.deterministic_empty(agent) is False
def test_guard_disabled_via_env(self, monkeypatch):
monkeypatch.setenv("HERMES_DETERMINISTIC_EMPTY_GUARD", "0")
agent = _agent()
_record_streak(agent, [_response(), _response()])
assert guard.deterministic_empty(agent) is False
def test_reasoning_tokens_count_as_generation(self, monkeypatch):
"""Reasoning-only responses are owned by the prefill path; the
guard must not classify them as deterministic empties."""
canonical = SimpleNamespace(output_tokens=0, reasoning_tokens=128)
monkeypatch.setattr(
guard, "_zero_output", lambda a, r: (True, (0 + 128) == 0)
)
agent = _agent()
_record_streak(agent, [_response(), _response()])
assert guard.deterministic_empty(agent) is False
class TestEmptyRetryBudget:
def test_default_budget_when_cost_unknown(self, monkeypatch):
monkeypatch.setattr(guard, "_estimate_attempt_cost", lambda a, r: None)
assert (
guard.empty_retry_budget(_agent(), _response())
== guard.DEFAULT_EMPTY_RETRY_BUDGET
)
def test_reduced_budget_above_threshold(self, monkeypatch):
monkeypatch.setattr(
guard, "_estimate_attempt_cost", lambda a, r: Decimal("0.80")
)
assert (
guard.empty_retry_budget(_agent(), _response())
== guard.REDUCED_EMPTY_RETRY_BUDGET
)
def test_default_budget_below_threshold(self, monkeypatch):
monkeypatch.setattr(
guard, "_estimate_attempt_cost", lambda a, r: Decimal("0.01")
)
assert (
guard.empty_retry_budget(_agent(), _response())
== guard.DEFAULT_EMPTY_RETRY_BUDGET
)
def test_custom_threshold_env(self, monkeypatch):
monkeypatch.setenv("HERMES_EMPTY_RETRY_COST_THRESHOLD_USD", "5.00")
monkeypatch.setattr(
guard, "_estimate_attempt_cost", lambda a, r: Decimal("0.80")
)
assert (
guard.empty_retry_budget(_agent(), _response())
== guard.DEFAULT_EMPTY_RETRY_BUDGET
)
def test_bad_threshold_env_falls_back(self, monkeypatch):
monkeypatch.setenv("HERMES_EMPTY_RETRY_COST_THRESHOLD_USD", "banana")
assert guard._cost_threshold_usd() == guard.DEFAULT_COST_THRESHOLD_USD
monkeypatch.setenv("HERMES_EMPTY_RETRY_COST_THRESHOLD_USD", "-1")
assert guard._cost_threshold_usd() == guard.DEFAULT_COST_THRESHOLD_USD
def test_guard_disabled_keeps_default_budget(self, monkeypatch):
monkeypatch.setenv("HERMES_DETERMINISTIC_EMPTY_GUARD", "0")
monkeypatch.setattr(
guard, "_estimate_attempt_cost", lambda a, r: Decimal("9.99")
)
assert (
guard.empty_retry_budget(_agent(), _response())
== guard.DEFAULT_EMPTY_RETRY_BUDGET
)
def test_pricing_exception_fails_open(self, monkeypatch):
def _boom(a, r):
raise RuntimeError("pricing exploded")
# _estimate_attempt_cost itself catches internally; simulate at
# the normalize layer by feeding garbage usage.
agent = _agent(model=None, provider=None)
resp = SimpleNamespace(usage=object())
assert (
guard.empty_retry_budget(agent, resp)
== guard.DEFAULT_EMPTY_RETRY_BUDGET
)
class TestStreakCost:
def test_streak_cost_accumulates(self, monkeypatch):
costs = iter([Decimal("1.10"), Decimal("1.23")])
monkeypatch.setattr(
guard, "_estimate_attempt_cost", lambda a, r: next(costs)
)
agent = _agent()
_record_streak(agent, [_response(), _response()])
assert guard.streak_cost_usd(agent) == Decimal("2.33")
def test_streak_cost_none_when_unknown(self, monkeypatch):
monkeypatch.setattr(guard, "_estimate_attempt_cost", lambda a, r: None)
agent = _agent()
_record_streak(agent, [_response(), _response()])
assert guard.streak_cost_usd(agent) is None
def test_streak_cost_resets_on_new_streak(self, monkeypatch):
monkeypatch.setattr(
guard, "_estimate_attempt_cost", lambda a, r: Decimal("1.00")
)
agent = _agent()
_record_streak(agent, [_response(), _response()])
assert guard.streak_cost_usd(agent) == Decimal("2.00")
agent._empty_content_retries = 0
guard.record_empty_attempt(agent, finish_reason="stop", response=_response())
assert guard.streak_cost_usd(agent) == Decimal("1.00")
class TestZeroOutputExtraction:
"""_zero_output goes through the real normalize_usage path."""
def test_openai_shape_zero_completion(self):
agent = _agent()
present, zero = guard._zero_output(agent, _response(completion_tokens=0))
assert present is True
assert zero is True
def test_openai_shape_with_completion(self):
agent = _agent()
present, zero = guard._zero_output(agent, _response(completion_tokens=9))
assert present is True
assert zero is False
def test_no_usage(self):
agent = _agent()
present, zero = guard._zero_output(agent, _response(usage_present=False))
assert present is False
assert zero is False
def test_anthropic_shape_zero_output(self):
agent = _agent(api_mode="anthropic_messages")
usage = SimpleNamespace(
input_tokens=25_900,
output_tokens=0,
cache_read_input_tokens=0,
cache_creation_input_tokens=0,
)
present, zero = guard._zero_output(agent, SimpleNamespace(usage=usage))
assert present is True
assert zero is True
def test_all_zero_usage_object_fails_open(self):
"""Proxies that emit an empty usage object (all fields absent →
normalized to zeros) provide no evidence — must not classify."""
agent = _agent()
usage = SimpleNamespace() # no token fields at all
present, zero = guard._zero_output(agent, SimpleNamespace(usage=usage))
assert present is False
assert zero is False
+46
View File
@@ -3264,6 +3264,52 @@ class TestRunConversation:
assert "No reply:" in result["final_response"]
assert result["api_calls"] == 4 # 1 original + 3 retries
def test_deterministic_empty_stops_retries_early(self, agent):
"""NS-503: consecutive zero-output-token empties with identical
model/provider/finish_reason are deterministic (unsignaled refusal)
— the loop must stop re-billing the full input after the second
attempt instead of burning the whole retry budget."""
self._setup_agent(agent)
agent.base_url = "http://127.0.0.1:1234/v1"
zero_usage = {
"prompt_tokens": 25_900,
"completion_tokens": 0,
"total_tokens": 25_900,
}
empty_resp = _mock_response(
content=None, finish_reason="stop", usage=zero_usage
)
# Provide plenty of responses; guard should stop consuming early.
agent.client.chat.completions.create.side_effect = [empty_resp] * 6
with (
patch.object(agent, "_persist_session"),
patch.object(agent, "_save_trajectory"),
patch.object(agent, "_cleanup_task_resources"),
):
result = agent.run_conversation("answer me")
assert result["completed"] is True
assert result["final_response"] != "(empty)"
# 1 original + 1 retry: the second identical zero-output empty
# proves determinism, remaining retries are skipped.
assert result["api_calls"] == 2
def test_empty_without_usage_keeps_full_retry_budget(self, agent):
"""NS-503 fail-open: no usage data means no evidence of a
deterministic empty — legacy 3-retry behaviour must be preserved
(this is the flaky-provider case retries exist for)."""
self._setup_agent(agent)
agent.base_url = "http://127.0.0.1:1234/v1"
empty_resp = _mock_response(content=None, finish_reason="stop")
agent.client.chat.completions.create.side_effect = [empty_resp] * 4
with (
patch.object(agent, "_persist_session"),
patch.object(agent, "_save_trajectory"),
patch.object(agent, "_cleanup_task_resources"),
):
result = agent.run_conversation("answer me")
assert result["completed"] is True
assert result["api_calls"] == 4 # unchanged: 1 original + 3 retries
def test_truly_empty_response_succeeds_on_nudge(self, agent):
"""Model produces content after being nudged for empty response."""
self._setup_agent(agent)