fix(complexity_router): keep the opening tool reads when a failed read comes first

An overflowing tool loop now fills half its budget with the oldest entries after the ask and the rest with
the newest, so a failed first read no longer leaves the classifier without the read that described the task
This commit is contained in:
mateo-berri 2026-10-29 00:04:19 -07:00
parent 237de4ab15
commit 4d9c6aa09b
3 changed files with 46 additions and 17 deletions

View file

@ -1063,14 +1063,16 @@ def _extract_agent_progress(
An agent loop learns what the ask really involves from its tools, so these are what the classifier
needs on a continuation turn. Only what follows the newest human ask counts, since tool traffic
before it served an earlier ask. Newest first is what the budget keeps, so the latest result always
reaches the classifier. Harness reminders are stripped from tool output the same way they are from asks.
before it served an earlier ask; a follow-up approving that work ("yes, go ahead") is what
classifier_context_include_assistant_turns is for. The latest result always reaches the classifier.
Harness reminders are stripped from tool output the same way they are from asks.
A loop that overflows the budget has each entry cut to a quarter of it first, since one large file read
would otherwise fill the block alone. Its first tool result is kept even then, ahead of the newest entries
the rest of the budget buys, because that is usually the read that told the agent what the task is, and a
long loop of later reads would otherwise push it out and leave the classifier with routine output alone. A
loop that fits is quoted whole.
would otherwise fill the block alone. The oldest entries then fill half the budget and the newest the
rest, because the reads a loop opens with usually told the agent what the task is, and a long loop of
later reads would otherwise push them out and leave the classifier with routine output alone. Half
rather than the first result alone, since that one can be a failed read that says nothing about the
task. A loop that fits is quoted whole.
"""
from litellm.litellm_core_utils.prompt_templates.factory import tool_exchange_texts
@ -1094,12 +1096,13 @@ def _extract_agent_progress(
return tuple(reversed(clamped))
share: Final = budget_chars // _TOOL_ENTRY_SHARES_OF_BUDGET
oldest_first: Final = tuple((kind, _truncate(text, share)) for kind, text in reversed(clamped))
first_result: Final = next((index for index, (kind, _) in enumerate(oldest_first) if kind == "tool result"), None)
if first_result is None or share < _MIN_QUOTED_TURN_CHARS:
if share < _MIN_QUOTED_TURN_CHARS or sum(len(text) for _, text in oldest_first) <= budget_chars:
return tuple(reversed(_turns_within_budget(tuple(reversed(oldest_first)), budget_chars)))
anchor: Final = oldest_first[first_result]
newer: Final = tuple(reversed(oldest_first[first_result + 1 :]))
return (anchor, *reversed(_turns_within_budget(newer, budget_chars - len(anchor[1]))))
opening: Final = _turns_within_budget(oldest_first, budget_chars // 2)
latest: Final = _turns_within_budget(
tuple(reversed(oldest_first[len(opening) :])), budget_chars - sum(len(text) for _, text in opening)
)
return (*opening, *reversed(latest))
def _decision_is_pinnable(decision: StandardLoggingRoutingDecision | None) -> bool:

View file

@ -1385,8 +1385,11 @@ class ComplexityRouterConfig(LiteLLMBaseModel):
"always goes to the classifier model, since the local scorer reads only the ask. The block is bounded "
"on its own by classifier_context_budget_chars and classifier_context_per_turn_chars, "
"newest first, and once a loop overflows that budget each entry is cut to a quarter of it, so "
"one large file read cannot push out the rest, and the first tool result after the ask, usually "
"the read that described the task, is kept ahead of the newest ones. It is not bounded by "
"one large file read cannot push out the rest, and the oldest entries after the ask, usually "
"the reads that described the task, fill half of it ahead of the newest ones. Tool traffic "
"before the newest ask is left out, since it served an earlier ask, so a follow-up such as "
"'yes, go ahead' that approves that work needs classifier_context_include_assistant_turns "
"to be classified on it. It is not bounded by "
"classifier_context_window_size, since one ask can run dozens "
"of tool calls; setting the window size to 0 still turns all context off. Tool output "
"such as file contents is sent to the classifier model, which may be a different "

View file

@ -11734,12 +11734,35 @@ class TestAgentProgressReachesClassifier:
assert "balance = 0" in client.states[-1]
@pytest.mark.asyncio
@pytest.mark.parametrize("reads,read_chars", [(4, 3000), (6, 1500), (20, 400)])
async def test_long_loop_keeps_the_read_that_described_the_task(self, reads: int, read_chars: int) -> None:
@pytest.mark.parametrize("failed_first_read", [False, True])
@pytest.mark.parametrize("reads,read_chars", [(4, 3000), (6, 1500), (20, 400), (4, 20000)])
async def test_long_loop_keeps_the_read_that_described_the_task(
self, reads: int, read_chars: int, failed_first_read: bool
) -> None:
"""Every later read in a long loop is routine on its own, so once they overflow the budget the classifier
must still see the task file the loop opened with, or every_request drops the loop to the cheap tier."""
must still see the task file the loop opened with, or every_request drops the loop to the cheap tier. That
holds when a failed read comes before the task file too."""
client: Final = _TaskAwareJevClient()
router: Final = self._jev_router(client, classifier_context_include_tool_turns=True)
ask, *task_read = _tool_loop_continuation("chat_completions")
failed_read: Final = (
(
{
"role": "assistant",
"content": None,
"tool_calls": [
{
"id": "call_0",
"type": "function",
"function": {"name": "read_file", "arguments": '{"path": "docs/TASK.md"}'},
}
],
},
{"role": "tool", "tool_call_id": "call_0", "content": "Error: sandbox denied the read (exit code 1)"},
)
if failed_first_read
else ()
)
later_reads: Final = tuple(
turn
for i in range(reads)
@ -11762,7 +11785,7 @@ class TestAgentProgressReachesClassifier:
response: Final = await router.async_pre_routing_hook(
model="test-router",
request_kwargs={},
messages=[*_tool_loop_continuation("chat_completions"), *later_reads],
messages=[ask, *failed_read, *task_read, *later_reads],
)
assert response is not None