From 7aa008f34a4a324aefbb8af059c90889a6f42e6e Mon Sep 17 00:00:00 2001 From: zhanghanduo Date: Fri, 25 Sep 2026 15:37:03 +0800 Subject: [PATCH 1/2] fix(loop): only mention the expanded retry in runaway guidance when it ran With expansion disabled by a lowered RUNAWAY_MAX_RETRIES, or skipped because it could not fit the context, the first runaway retry goes straight to the reduced phase, yet its reminder said "The expanded-thinking retry still produced no visible answer" -- describing an attempt the model never saw. call_llm now tracks whether an expanded retry actually ran and the reduced phase picks its reminder accordingly. Retry ladder, caps and thinking overrides are unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) --- agent_core/runtime/loop/_call.py | 14 +++- agent_core/runtime/loop/_runaway.py | 19 ++++- .../runaway-guidance-without-expansion.fix.md | 1 + tests/test_runaway_retry_guidance.py | 69 +++++++++++++++++++ 4 files changed, 99 insertions(+), 4 deletions(-) create mode 100644 changes/runaway-guidance-without-expansion.fix.md create mode 100644 tests/test_runaway_retry_guidance.py diff --git a/agent_core/runtime/loop/_call.py b/agent_core/runtime/loop/_call.py index 702b219..ee145f7 100644 --- a/agent_core/runtime/loop/_call.py +++ b/agent_core/runtime/loop/_call.py @@ -375,6 +375,8 @@ def _response_attempt_fields(response: LLMResponse) -> dict[str, Any]: # Phase index and retry spend diverge when expansion is skipped. runaway_retries = 0 runaway_attempts = 0 + # Whether an expanded-thinking retry actually ran (drives the guidance). + runaway_expanded = False last_runaway_reason = "" stream_stall_count = 0 if runaway_state is not None: @@ -732,6 +734,7 @@ async def _finish_attempt( ) if expanded_llm is not None: runaway_retries = 1 + runaway_expanded = True llm_active = expanded_llm else: runaway_retries = 2 @@ -752,7 +755,10 @@ async def _finish_attempt( max_tokens=reasoning_only_max_tokens, ) retry_thinking, recovery_guidance, recovery_action = ( - _runaway_retry_policy(runaway_retries, next_cap) + _runaway_retry_policy( + runaway_retries, next_cap, + expanded_attempted=runaway_expanded, + ) ) messages_active = [*messages, user_msg(recovery_guidance)] await _finish_attempt( @@ -850,6 +856,7 @@ async def _finish_attempt( ) if expanded_llm is not None: runaway_retries = 1 + runaway_expanded = True llm_active = expanded_llm else: runaway_retries = 2 @@ -870,7 +877,10 @@ async def _finish_attempt( max_tokens=reasoning_only_max_tokens, ) retry_thinking, recovery_guidance, recovery_action = ( - _runaway_retry_policy(runaway_retries, next_cap) + _runaway_retry_policy( + runaway_retries, next_cap, + expanded_attempted=runaway_expanded, + ) ) messages_active = [*messages, user_msg(recovery_guidance)] await _finish_attempt( diff --git a/agent_core/runtime/loop/_runaway.py b/agent_core/runtime/loop/_runaway.py index 2141c33..46057e7 100644 --- a/agent_core/runtime/loop/_runaway.py +++ b/agent_core/runtime/loop/_runaway.py @@ -55,6 +55,15 @@ "a short, bounded reasoning pass now, then promptly emit either one valid " "tool call or visible answer text. Do not re-derive the full plan." ) +# Same instruction as above for a reduced retry that was NOT preceded by an +# expanded one (expansion disabled by a lowered retry budget, or no room to +# expand); naming a retry the model never saw would misdescribe its history. +_RUNAWAY_REDUCED_GUIDANCE = ( + "[system reminder] The previous attempt used its full private-reasoning " + "budget without producing a visible answer or tool call. Use only a short, " + "bounded reasoning pass now, then promptly emit either one valid tool call " + "or visible answer text. Do not re-derive the full plan." +) _RUNAWAY_DIRECT_RECOVERY_GUIDANCE = ( "[system reminder] Three consecutive attempts spent their budgets in " "private reasoning without producing a visible answer or tool call. " @@ -310,8 +319,14 @@ def _phase_reasoning_guard( def _runaway_retry_policy( retry_number: int, next_cap: Any, + *, + expanded_attempted: bool = True, ) -> tuple[ThinkingRetryOverride, str, str]: - """Choose the next retry's task-local thinking policy and guidance.""" + """Choose the next retry's task-local thinking policy and guidance. + + ``expanded_attempted`` says whether an expanded-thinking retry actually ran + before this reduced one; the reduced guidance only refers to it if so. + """ if retry_number == 1: try: cap = max(int(next_cap), 1) @@ -353,6 +368,6 @@ def _runaway_retry_policy( thinking_budget=budget, reasoning_effort="low", ), - _RUNAWAY_RECOVERY_GUIDANCE, + _RUNAWAY_RECOVERY_GUIDANCE if expanded_attempted else _RUNAWAY_REDUCED_GUIDANCE, "retry_reduced_cap_and_thinking", ) diff --git a/changes/runaway-guidance-without-expansion.fix.md b/changes/runaway-guidance-without-expansion.fix.md new file mode 100644 index 0000000..605020c --- /dev/null +++ b/changes/runaway-guidance-without-expansion.fix.md @@ -0,0 +1 @@ +Runaway recovery no longer tells the model that an "expanded-thinking retry" failed when none ran. With expansion disabled by a lowered `RUNAWAY_MAX_RETRIES`, or skipped because it could not fit the context, the reduced retry now gets a reminder that only references the previous attempt. The retry ladder, caps and thinking overrides are unchanged; only the injected reminder text differs in that case. diff --git a/tests/test_runaway_retry_guidance.py b/tests/test_runaway_retry_guidance.py new file mode 100644 index 0000000..d296f2b --- /dev/null +++ b/tests/test_runaway_retry_guidance.py @@ -0,0 +1,69 @@ +"""Runaway-retry guidance must describe the retries that actually ran. + +With expansion disabled (a lowered ``RUNAWAY_MAX_RETRIES``) or impossible, the +first retry goes straight to the reduced phase. Its reminder used to say "The +expanded-thinking retry still produced no visible answer", describing an +attempt the model never saw. +""" + +from __future__ import annotations + +import asyncio +from typing import Any + +import pytest + +from agent_core.llm import LLMResponse +from agent_core.runtime.loop import _call +from agent_core.runtime.loop._runaway import _runaway_retry_policy + + +def test_reduced_guidance_names_the_expanded_retry_only_when_it_ran() -> None: + _, after_expanded, _ = _runaway_retry_policy(2, 4096, expanded_attempted=True) + _, without_expanded, _ = _runaway_retry_policy(2, 4096, expanded_attempted=False) + + assert "expanded-thinking retry" in after_expanded + assert "expanded" not in without_expanded + assert "short, bounded reasoning pass" in without_expanded + + +class _RunawayThenAnswer: + """First reply spends the whole budget on reasoning; the retry answers.""" + + model = "fake" + + def __init__(self) -> None: + self.requests: list[list[Any]] = [] + + async def chat(self, messages: list[Any], **_kwargs: Any) -> LLMResponse: + self.requests.append(list(messages)) + if len(self.requests) == 1: + return LLMResponse( + content="", + reasoning_content="x" * 5_000, + finish_reason="length", + usage={"prompt_tokens": 10, "completion_tokens": 2_048}, + ) + return LLMResponse(content="done", finish_reason="stop") + + +def _retry_reminder(llm: _RunawayThenAnswer) -> str: + assert len(llm.requests) == 2 + last = llm.requests[1][-1] + assert last["role"] == "user" + return str(last["content"]) + + +def test_skipped_expansion_does_not_mention_it(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(_call, "_RUNAWAY_EXPAND_ENABLED", False) + monkeypatch.setattr(_call, "_RUNAWAY_BACKOFF_S", 0.0) + llm = _RunawayThenAnswer() + + response = asyncio.run( + _call.call_llm(llm, [{"role": "user", "content": "q"}], timeout=30, max_retries=3, turn=1) + ) + + assert response is not None and response.content == "done" + reminder = _retry_reminder(llm) + assert reminder.startswith("[system reminder]") + assert "expanded" not in reminder From 432efa7b699a7b2c6d58e2ca08fdd83bd7b322af Mon Sep 17 00:00:00 2001 From: zhanghanduo Date: Sat, 26 Sep 2026 17:01:30 +0800 Subject: [PATCH 2/2] fix(loop): keep reduced guidance accurate after early stop --- agent_core/runtime/loop/_runaway.py | 4 +-- tests/test_runaway_retry_guidance.py | 46 +++++++++++++++++++++++++++- 2 files changed, 47 insertions(+), 3 deletions(-) diff --git a/agent_core/runtime/loop/_runaway.py b/agent_core/runtime/loop/_runaway.py index 46057e7..703f545 100644 --- a/agent_core/runtime/loop/_runaway.py +++ b/agent_core/runtime/loop/_runaway.py @@ -59,8 +59,8 @@ # expanded one (expansion disabled by a lowered retry budget, or no room to # expand); naming a retry the model never saw would misdescribe its history. _RUNAWAY_REDUCED_GUIDANCE = ( - "[system reminder] The previous attempt used its full private-reasoning " - "budget without producing a visible answer or tool call. Use only a short, " + "[system reminder] The previous attempt stopped without producing a visible " + "answer or tool call. Use only a short, " "bounded reasoning pass now, then promptly emit either one valid tool call " "or visible answer text. Do not re-derive the full plan." ) diff --git a/tests/test_runaway_retry_guidance.py b/tests/test_runaway_retry_guidance.py index d296f2b..2c5e522 100644 --- a/tests/test_runaway_retry_guidance.py +++ b/tests/test_runaway_retry_guidance.py @@ -13,8 +13,9 @@ import pytest -from agent_core.llm import LLMResponse +from agent_core.llm import LLMResponse, StreamDelta from agent_core.runtime.loop import _call +from agent_core.runtime.loop._bind import bind_max_tokens from agent_core.runtime.loop._runaway import _runaway_retry_policy @@ -67,3 +68,46 @@ def test_skipped_expansion_does_not_mention_it(monkeypatch: pytest.MonkeyPatch) reminder = _retry_reminder(llm) assert reminder.startswith("[system reminder]") assert "expanded" not in reminder + + +def test_early_stopped_stream_does_not_claim_full_budget( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(_call, "_RUNAWAY_MAX_RETRIES", 2) + monkeypatch.setattr(_call, "_RUNAWAY_EXPAND_ENABLED", False) + monkeypatch.setattr(_call, "_RUNAWAY_BACKOFF_S", 0.0) + + class _EarlyStoppedThenAnswer: + model = "fake" + + def __init__(self) -> None: + self.requests: list[list[Any]] = [] + + async def stream(self, messages: list[Any], **_kwargs: Any) -> Any: + self.requests.append(list(messages)) + if len(self.requests) == 1: + yield StreamDelta(reasoning_content="x" * 400) + else: + yield StreamDelta(content="done", finish_reason="stop") + + async def on_delta(*_args: Any, **_kwargs: Any) -> None: + pass + + llm = _EarlyStoppedThenAnswer() + response = asyncio.run( + _call.call_llm( + bind_max_tokens(llm, 2048), + [{"role": "user", "content": "q"}], + timeout=30, + max_retries=2, + turn=1, + on_delta=on_delta, + reasoning_only_max_tokens=100, + ) + ) + + assert response is not None and response.content == "done" + assert len(llm.requests) == 2 + reminder = llm.requests[1][-1]["content"] + assert "previous attempt stopped" in reminder + assert "full private-reasoning budget" not in reminder