fix(adaptive_router): enforce satisfaction gate, stop false-flagging empty tool output

- SessionState now carries clean_credit_awarded + last_processed_turn (matching
  the DB schema). Satisfaction only fires once per session AND only after
  MIN_TURNS_FOR_CLEAN_CREDIT turns of context — early "thanks" no longer
  inflates alpha.
- _detect_failure no longer treats empty content as failure. Many tools
  legitimately return empty output (zero-result searches, silent bash);
  penalizing those corrupted the bandit posterior. Only is_error fires now.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Krrish Dholakia 2026-04-20 17:47:25 -07:00
parent e99955ac52
commit bcc093d8c5
4 changed files with 151 additions and 7 deletions

View file

@ -18,6 +18,7 @@ from typing import Any, Dict, List, Optional, Set
from litellm.router_strategy.adaptive_router.config import (
LOOP_REPEAT_THRESHOLD,
MIN_TURNS_FOR_CLEAN_CREDIT,
MISALIGNMENT_JACCARD_THRESHOLD,
STAGNATION_JACCARD_NEAR_DUP,
TOOL_CALL_HISTORY_MAX,
@ -80,6 +81,8 @@ class SessionState:
pending_tool_calls: Dict[str, str] = field(default_factory=dict)
turn_count: int = 0
last_processed_turn: int = -1
clean_credit_awarded: bool = False
terminal_status: Optional[int] = None
@ -161,13 +164,15 @@ def _detect_satisfaction(curr_user: Optional[str]) -> bool:
def _detect_failure(tool_results: List[Dict[str, Any]]) -> bool:
"""Any tool result that's an error or empty content."""
"""Any tool result explicitly flagged as an error.
We do NOT treat empty content as failure — many tools legitimately return
empty output (zero-result searches, silent bash commands, void writes) and
penalizing the model for those would corrupt the bandit posterior.
"""
for r in tool_results:
if r.get("is_error"):
return True
content = r.get("content")
if content is None or content == "" or content == [] or content == {}:
return True
return False
@ -238,7 +243,16 @@ def apply_turn(state: SessionState, turn: Turn) -> SignalDelta:
if _detect_disengagement(turn.user_content):
delta.disengagement = 1
if _detect_satisfaction(turn.user_content):
delta.satisfaction = 1
# Gate: only award satisfaction credit once per session, and only
# after MIN_TURNS_FOR_CLEAN_CREDIT turns of context. Early "thanks"
# on turn 1-2 is noise, not a validated quality signal.
current_turn_index = state.turn_count + 1
if (
not state.clean_credit_awarded
and current_turn_index >= MIN_TURNS_FOR_CLEAN_CREDIT
):
delta.satisfaction = 1
state.clean_credit_awarded = True
if _detect_failure(turn.tool_results):
delta.failure = 1
if _detect_loop(state.tool_call_history, turn.tool_calls):
@ -268,5 +282,6 @@ def apply_turn(state: SessionState, turn: Turn) -> SignalDelta:
state.terminal_status = turn.response_status
state.turn_count += 1
state.last_processed_turn = state.turn_count
return delta

View file

@ -124,6 +124,16 @@ def test_claim_or_check_owner_expired_owner_reclaims_for_new_model(monkeypatch):
@pytest.mark.asyncio
async def test_record_turn_pushes_to_queue():
r = _make_router()
# Prime with 2 prior turns so satisfaction gate (MIN_TURNS_FOR_CLEAN_CREDIT=3)
# is satisfied when the "thanks" turn arrives.
for _ in range(2):
await r.record_turn(
session_id="s1",
model_name="fast",
request_type=RequestType.GENERAL,
turn=Turn(user_content="hi", assistant_content="hello"),
)
r.queue.add_session_state = AsyncMock()
r.queue.add_state_delta = AsyncMock()
@ -143,6 +153,24 @@ async def test_record_turn_pushes_to_queue():
@pytest.mark.asyncio
async def test_record_turn_satisfaction_increments_alpha():
r = _make_router()
# Prime with 2 prior turns to clear the MIN_TURNS_FOR_CLEAN_CREDIT gate.
# Use distinct content to avoid incidentally firing stagnation/misalignment.
priming_turns = [
Turn(
user_content="alpha bravo charlie", assistant_content="delta echo foxtrot"
),
Turn(
user_content="golf hotel india juliet",
assistant_content="kilo lima mike november",
),
]
for t in priming_turns:
await r.record_turn(
session_id="sX",
model_name="fast",
request_type=RequestType.GENERAL,
turn=t,
)
cell_before = r._cells[(RequestType.GENERAL, "fast")]
turn = Turn(user_content="that worked, thanks!")
await r.record_turn(
@ -224,7 +252,6 @@ async def test_load_state_from_db_handles_unknown_request_type():
assert r._cells[(RequestType.WRITING, "fast")] == cold or True
# ---- Session state eviction ---------------------------------------------
@ -267,4 +294,3 @@ def test_session_state_expiry_is_refreshed_on_access():
second_exp = r._session_states_expiry[("sess-A", "fast")]
assert second_exp > first_exp

View file

@ -67,6 +67,24 @@ async def test_pick_record_flush_full_cycle():
chosen = await router.pick_model(RequestType.CODE_GENERATION)
assert chosen in router.config.available_models
# Prime 2 prior turns (distinct content so no other signals fire) so the
# MIN_TURNS_FOR_CLEAN_CREDIT satisfaction gate is satisfied on turn 3.
priming = [
Turn(
user_content="alpha bravo charlie", assistant_content="delta echo foxtrot"
),
Turn(
user_content="golf hotel india juliet",
assistant_content="kilo lima mike november",
),
]
for t in priming:
await router.record_turn(
session_id="s1",
model_name=chosen,
request_type=RequestType.CODE_GENERATION,
turn=t,
)
await router.record_turn(
session_id="s1",
model_name=chosen,
@ -226,6 +244,15 @@ async def test_load_state_from_db_handles_unknown_request_type():
@pytest.mark.asyncio
async def test_flush_isolates_writes_per_router_session_model():
router = _make_router()
# Prime 2 prior turns per session to clear the MIN_TURNS_FOR_CLEAN_CREDIT gate.
for sid, model in (("s1", "gpt-4o"), ("s2", "gpt-4o-mini")):
for _ in range(2):
await router.record_turn(
sid,
model,
RequestType.GENERAL,
Turn(user_content="hi", assistant_content="hello"),
)
await router.record_turn(
"s1", "gpt-4o", RequestType.GENERAL, Turn(user_content="thanks!")
)
@ -248,6 +275,14 @@ async def test_repeated_flush_drains_queue_and_subsequent_flush_is_noop():
"""Verifies the queue is fully drained on flush -- a second flush writes nothing."""
router = _make_router()
chosen = await router.pick_model(RequestType.GENERAL)
# Prime 2 prior turns so satisfaction can fire on the third turn.
for _ in range(2):
await router.record_turn(
"drain-1",
chosen,
RequestType.GENERAL,
Turn(user_content="hi", assistant_content="hello"),
)
await router.record_turn(
"drain-1", chosen, RequestType.GENERAL, Turn(user_content="thanks!")
)

View file

@ -97,6 +97,74 @@ def test_mixed_failure_then_satisfaction():
assert state.satisfaction_count >= 1
def test_satisfaction_gated_by_min_turns_for_clean_credit():
"""'thanks' on turn 1 is noise, not a validated quality signal."""
state = SessionState(
session_id="s", router_name="r", model_name="m", classified_type="general"
)
apply_turn(state, Turn(user_content="thanks!"))
assert state.satisfaction_count == 0
assert state.clean_credit_awarded is False
assert state.last_processed_turn == 1
def test_satisfaction_credit_awarded_once_per_session():
"""Even multiple satisfaction turns only award +1 alpha across the session."""
state = SessionState(
session_id="s", router_name="r", model_name="m", classified_type="general"
)
apply_turn(state, Turn(user_content="hi", assistant_content="hello"))
apply_turn(state, Turn(user_content="help me", assistant_content="sure"))
apply_turn(state, Turn(user_content="perfect, thanks"))
assert state.satisfaction_count == 1
assert state.clean_credit_awarded is True
apply_turn(state, Turn(user_content="great, thank you"))
assert state.satisfaction_count == 1
def test_empty_tool_content_does_not_fire_failure():
"""Zero-result searches / silent commands return empty but valid output."""
state = SessionState(
session_id="s", router_name="r", model_name="m", classified_type="general"
)
apply_turn(
state,
Turn(
tool_calls=[{"name": "grep", "arguments": {"q": "x"}}],
tool_results=[{"tool_call_id": "c1", "content": ""}],
),
)
apply_turn(
state,
Turn(
tool_calls=[{"name": "list", "arguments": {}}],
tool_results=[{"tool_call_id": "c2", "content": []}],
),
)
apply_turn(
state,
Turn(
tool_calls=[{"name": "noop", "arguments": {}}],
tool_results=[{"tool_call_id": "c3", "content": None}],
),
)
assert state.failure_count == 0
def test_is_error_still_fires_failure():
state = SessionState(
session_id="s", router_name="r", model_name="m", classified_type="general"
)
apply_turn(
state,
Turn(
tool_calls=[{"name": "read", "arguments": {"p": "x"}}],
tool_results=[{"tool_call_id": "c1", "content": "boom", "is_error": True}],
),
)
assert state.failure_count == 1
def test_apply_turn_is_o1_does_not_grow_history_unbounded():
state = SessionState(
session_id="s",