Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions agent_core/runtime/loop/_call.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand All @@ -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(
Expand Down Expand Up @@ -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
Expand All @@ -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(
Expand Down
19 changes: 17 additions & 2 deletions agent_core/runtime/loop/_runaway.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 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."
)
_RUNAWAY_DIRECT_RECOVERY_GUIDANCE = (
"[system reminder] Three consecutive attempts spent their budgets in "
"private reasoning without producing a visible answer or tool call. "
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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",
)
1 change: 1 addition & 0 deletions changes/runaway-guidance-without-expansion.fix.md
Original file line number Diff line number Diff line change
@@ -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.
113 changes: 113 additions & 0 deletions tests/test_runaway_retry_guidance.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
"""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, 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


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


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
Loading