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
105 changes: 94 additions & 11 deletions codeframe/core/react_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,8 +78,31 @@ def _assistant_user_pairs(messages: list[dict], cutoff: int):

# Reason string emitted when a stall timeout triggers a blocker — used to
# set the correct BlockerOrigin ("system") vs agent-generated blockers.

def _iteration_budget_override() -> Optional[int]:
"""The iteration budget forced by CODEFRAME_MAX_ITERATIONS, if any (#1117).

Returns None when unset or unusable, so the caller falls back to the
configured/adaptive budget. A bad value warns rather than crashing — this
sits on the Golden Path and a typo must not abort the run.
"""
raw = os.getenv("CODEFRAME_MAX_ITERATIONS")
if not raw:
return None
try:
value = int(raw)
except ValueError:
logger.warning("CODEFRAME_MAX_ITERATIONS=%r is not an integer; ignoring", raw)
return None
if value <= 0:
logger.warning("CODEFRAME_MAX_ITERATIONS=%d must be positive; ignoring", value)
return None
return value


_REASON_STALL_DETECTED = "stall_detected"
_REASON_COST_CAP_EXCEEDED = "cost_cap_exceeded"
_REASON_ITERATION_BUDGET_EXHAUSTED = "iteration_budget_exhausted"

#: Verification-failure reasons that mean "a blocker was created", so the run is
#: BLOCKED rather than FAILED. Anything not listed falls through to FAILED —
Expand All @@ -88,6 +111,17 @@ def _assistant_user_pairs(messages: list[dict], cutoff: int):
"escalated_to_blocker",
_REASON_STALL_DETECTED,
_REASON_COST_CAP_EXCEEDED,
# Running out of iterations is not an error (#1117). The run that surfaced
# this had a nearly complete implementation on disk and told the user only
# "Task execution failed".
_REASON_ITERATION_BUDGET_EXHAUSTED,
})

#: Reasons the *runtime* stopped the run, as opposed to a blocker the agent
#: raised about the work itself. These get BlockerOrigin "system".
_SYSTEM_DETECTED_REASONS = frozenset({
_REASON_STALL_DETECTED,
_REASON_ITERATION_BUDGET_EXHAUSTED,
})

# Map tool names to agent phases for progress reporting.
Expand Down Expand Up @@ -241,7 +275,9 @@ def run(self, task_id: str) -> AgentStatus:
Returns:
AgentStatus.COMPLETED — task finished successfully.
AgentStatus.BLOCKED — a blocker was created (check self.blocker_id).
AgentStatus.FAILED — max iterations or verification exhausted.
AgentStatus.FAILED — an error, or verification exhausted. NOT
iteration exhaustion, which returns BLOCKED with a blocker
since #1117.
"""
self._current_task_id = task_id
self._early_termination_reason = None
Expand Down Expand Up @@ -288,7 +324,12 @@ def run(self, task_id: str) -> AgentStatus:
elif self._stall_triggered.is_set():
reason = _REASON_STALL_DETECTED
else:
reason = "max_iterations_reached"
# Unreachable today: natural exhaustion returns BLOCKED
# (#1117) and the remaining FAILED paths — stall with
# --stall-action fail, loop detection — both set one of
# the flags above. Kept as a defensive default, but no
# longer mislabelled as a budget stop.
reason = "unknown"
self._emit(EventType.AGENT_FAILED, {
"task_id": task_id,
"reason": reason,
Expand Down Expand Up @@ -477,8 +518,11 @@ def _react_loop(self, system_prompt: str) -> AgentStatus:
"""Core ReAct loop: iterate LLM calls until text-only or max iterations.

Returns AgentStatus.COMPLETED when the LLM responds with text only.
Returns AgentStatus.BLOCKED when a blocker pattern is detected.
Returns AgentStatus.FAILED when max_iterations is reached.
Returns AgentStatus.BLOCKED when a blocker pattern is detected, and also
when the iteration budget is exhausted — that is a resumable stop with a
blocker, not a failure (#1117).
Returns AgentStatus.FAILED on a stall with --stall-action fail, or when
loop detection ends the run early.
"""
messages: list[dict] = [
{
Expand Down Expand Up @@ -766,8 +810,29 @@ def _tool_sig(tc) -> str:
"task_id": self._current_task_id,
})

# Exhausted iterations
return AgentStatus.FAILED
# Exhausted iterations (#1117). Same shape as the cost cap above: the
# work is not wrong, it ran out of budget, and the partial result is
# already on disk. FAILED threw that away and told the user nothing.
# Kept under _create_text_blocker's 500-char context cap so the
# `cf work diagnose` pointer is not truncated away.
short_id = (self._current_task_id or "")[:8]
self._create_text_blocker(
(
f"Used the full budget of {self.max_iterations} iteration(s) "
f"without declaring the task complete.\n\n"
f"This is not an error. Partial work from this run is already in "
f"your working tree — review it with `git status` before "
f"re-running.\n\n"
f"To continue, raise the budget and re-run:\n"
f" CODEFRAME_MAX_ITERATIONS={self.max_iterations * 2} "
f"cf work start {short_id} --execute\n"
f"(or set agent.max_iterations in .codeframe/config.yaml), "
f"or split the task up.\n\n"
f"What the run did: `cf work diagnose {short_id}`"
),
_REASON_ITERATION_BUDGET_EXHAUSTED,
)
return AgentStatus.BLOCKED

# ------------------------------------------------------------------
# Final verification
Expand Down Expand Up @@ -1162,6 +1227,15 @@ def _calculate_adaptive_budget(self, context: TaskContext) -> int:
else:
budget_config = AgentBudgetConfig()

# CODEFRAME_MAX_ITERATIONS beats the config file (#1117), matching the
# LLM-provider precedence chain. Raising the cap is what the exhaustion
# blocker tells the user to do, so it has to work without editing a file
# inside the repo — and it has to be an *exact* budget, not a ceiling the
# complexity multiplier then scales away from.
override = _iteration_budget_override()
if override is not None:
return override

base = budget_config.base_iterations
# Default to medium complexity (2) when score is absent.
score = getattr(context.task, "complexity_score", None) or 2
Expand Down Expand Up @@ -1378,11 +1452,20 @@ def _create_text_blocker(self, text: str, reason: str) -> None:
the run record to the blocker. If creation fails the exception
propagates — callers in ``run()`` catch it and return FAILED.
"""
question = (
f"Agent detected a blocker: {reason}\n\n"
f"Context:\n{text[:500]}"
)
origin = "system" if reason == _REASON_STALL_DETECTED else "agent"
# Budget/stall stops are detected by the runtime, not reported by the
# agent, so neither the wording nor the origin should claim otherwise
# (#1117) — "Agent detected a blocker: iteration_budget_exhausted" reads
# like the agent found a problem in the work, which is the opposite of
# what happened.
if reason in _SYSTEM_DETECTED_REASONS:
question = f"Run stopped: {reason}\n\n{text[:500]}"
origin = "system"
else:
question = (
f"Agent detected a blocker: {reason}\n\n"
f"Context:\n{text[:500]}"
)
origin = "agent"
blocker = blockers.create(
workspace=self.workspace,
question=question,
Expand Down
202 changes: 202 additions & 0 deletions tests/core/test_iteration_budget_outcome_1117.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
"""#1117 — exhausting the iteration budget read as a bare "Task execution failed".

The cold-start run produced a substantially complete implementation (15
create_file, 8 edit_file, 12 run_command, 6 run_tests) and then hit
`Iteration 45/45` while adding a Dockerfile and example clients. The user saw:

Task execution failed

and a FAILED task — with no indication that most of the deliverable was sitting
in their working tree, what the cap was, or how to continue.

Budget exhaustion is not an error. It is the same shape as the cost cap, which
already blocks rather than fails: the work is not wrong, it needs a human
decision. So it now produces a blocker and BLOCKED, which is resumable and
reads differently from a genuine failure in `cf tasks list`.
"""

from unittest.mock import MagicMock

import pytest

from codeframe.adapters.llm.base import LLMResponse, ToolCall
from codeframe.core import blockers
from codeframe.core.react_agent import (
_BLOCKED_REASONS,
_REASON_ITERATION_BUDGET_EXHAUSTED,
ReactAgent,
)
from codeframe.core.agent import AgentStatus
from codeframe.core.state_machine import TaskStatus
from codeframe.core import tasks, workspace as workspace_mod

pytestmark = pytest.mark.v2


@pytest.fixture
def ws(tmp_path):
return workspace_mod.create_or_load_workspace(tmp_path)


def _always_uses_a_tool_provider():
"""A provider that never finishes, so the loop can only end by exhaustion.

Real LLMResponse/ToolCall rather than mocks — the loop JSON-serialises tool
calls into the transcript, which a MagicMock cannot survive.
"""
provider = MagicMock()
calls = {"n": 0}

def complete(**kwargs):
# Each call is distinct, or the repetition detector ends the loop first
# and we would be testing that instead of the budget.
calls["n"] += 1
n = calls["n"]
return LLMResponse(
content="still working",
tool_calls=[
ToolCall(id=f"t{n}", name="run_command", input={"command": f"echo step-{n}"})
],
stop_reason="tool_use",
input_tokens=10,
output_tokens=10,
)

provider.complete.side_effect = complete
return provider


def _ok_tool_result(tc):
"""Every tool call succeeds, so only the budget can end the loop."""
from codeframe.core.tools import ToolResult

return ToolResult(tool_call_id=tc.id, content="ok")


def _agent(ws, task_id, max_iterations=3):
agent = ReactAgent(
workspace=ws,
llm_provider=_always_uses_a_tool_provider(),
max_iterations=max_iterations,
)
agent._current_task_id = task_id
return agent


class TestBudgetExhaustionIsItsOwnOutcome:
"""AC: a distinct, named outcome — not a bare `Task execution failed`."""

def test_the_reason_is_a_blocked_reason_not_a_failure(self):
assert _REASON_ITERATION_BUDGET_EXHAUSTED in _BLOCKED_REASONS

def test_the_loop_returns_blocked_rather_than_failed(self, ws, monkeypatch):
task = tasks.create(ws, title="Build the thing", status=TaskStatus.READY)
agent = _agent(ws, task.id)
monkeypatch.setattr(agent, "_execute_tool_with_lint", _ok_tool_result)

status = agent._react_loop("system prompt")

assert status == AgentStatus.BLOCKED, (
"budget exhaustion must not read the same as an error"
)

def test_a_blocker_is_created_so_the_run_is_resumable(self, ws, monkeypatch):
"""AC: a blocker is created so the run is resumable rather than terminal."""
task = tasks.create(ws, title="Build the thing", status=TaskStatus.READY)
agent = _agent(ws, task.id)
monkeypatch.setattr(agent, "_execute_tool_with_lint", _ok_tool_result)

agent._react_loop("system prompt")

open_blockers = blockers.list_open(ws)
assert len(open_blockers) == 1
assert agent.blocker_id == open_blockers[0].id


class TestTheMessageTellsTheUserWhatHappened:
"""AC: states the cap, the flag/env var that raises it, and that partial work exists."""

def _question(self, ws, monkeypatch, max_iterations=3) -> str:
task = tasks.create(ws, title="Build the thing", status=TaskStatus.READY)
agent = _agent(ws, task.id, max_iterations=max_iterations)
monkeypatch.setattr(agent, "_execute_tool_with_lint", _ok_tool_result)
agent._react_loop("system prompt")
return blockers.list_open(ws)[0].question

def test_it_states_the_cap_that_was_hit(self, ws, monkeypatch):
assert "3" in self._question(ws, monkeypatch, max_iterations=3)

def test_it_names_how_to_raise_the_cap(self, ws, monkeypatch):
question = self._question(ws, monkeypatch)
assert "CODEFRAME_MAX_ITERATIONS" in question
assert "config.yaml" in question

def test_it_says_partial_work_is_in_the_tree(self, ws, monkeypatch):
question = self._question(ws, monkeypatch).lower()
assert "partial" in question or "working tree" in question

def test_it_points_at_the_diagnostic_commands(self, ws, monkeypatch):
"""AC: points at `cf work diagnose` / `cf events tail`."""
question = self._question(ws, monkeypatch)
assert "cf work diagnose" in question

def test_it_does_not_read_as_a_generic_failure(self, ws, monkeypatch):
assert "Task execution failed" not in self._question(ws, monkeypatch)


class TestItIsDistinctFromARealFailure:
"""AC: a test asserts the message is distinct from the generic failure message."""

def test_a_genuine_error_still_fails(self, ws, monkeypatch):
"""An exploding provider is a real failure and must stay FAILED."""
task = tasks.create(ws, title="Build the thing", status=TaskStatus.READY)
agent = _agent(ws, task.id)

def boom(**kwargs):
raise RuntimeError("provider exploded")

agent.llm_provider.complete.side_effect = boom

with pytest.raises(RuntimeError):
agent._react_loop("system prompt")

assert blockers.list_open(ws) == [], (
"a genuine error must not be dressed up as a resumable blocker"
)


class TestTheEscapeHatchTheMessageNamesActuallyWorks:
"""The message is only useful if CODEFRAME_MAX_ITERATIONS really raises the cap.

There was no such env var and no `--max-iterations` flag when this issue was
written, so the obvious message would have pointed the user at nothing.
"""

def _budget(self, ws, complexity=None):
from codeframe.core.context import TaskContext

task = tasks.create(
ws, title="t", status=TaskStatus.READY, complexity_score=complexity
)
agent = _agent(ws, task.id)
context = MagicMock(spec=TaskContext)
context.task = task
return agent._calculate_adaptive_budget(context)

def test_the_env_var_sets_the_budget(self, ws, monkeypatch):
monkeypatch.setenv("CODEFRAME_MAX_ITERATIONS", "77")
assert self._budget(ws) == 77

def test_it_wins_over_the_complexity_multiplier(self, ws, monkeypatch):
"""An explicit budget is exact, not a base the multiplier scales away from."""
monkeypatch.setenv("CODEFRAME_MAX_ITERATIONS", "40")
assert self._budget(ws, complexity=5) == 40

@pytest.mark.parametrize("bad", ["not-a-number", "0", "-3"])
def test_a_nonsense_value_is_ignored_rather_than_crashing(self, ws, monkeypatch, bad):
monkeypatch.setenv("CODEFRAME_MAX_ITERATIONS", bad)
assert self._budget(ws) == 45 # the adaptive default

def test_unset_leaves_the_adaptive_budget_alone(self, ws, monkeypatch):
monkeypatch.delenv("CODEFRAME_MAX_ITERATIONS", raising=False)
assert self._budget(ws) == 45
12 changes: 10 additions & 2 deletions tests/core/test_react_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -1578,7 +1578,15 @@ def test_error_event_and_stream_close_on_max_iterations(
self, mock_ctx_loader, mock_exec_tool, mock_gates, mock_events,
workspace, provider, mock_context,
):
"""On max_iterations failure, ReactAgent publishes ErrorEvent and closes stream."""
"""On max_iterations, ReactAgent publishes ErrorEvent and closes stream.

Since #1117 exhaustion tries to create a blocker and return BLOCKED.
This fixture's workspace has no database, so that write fails and the
run degrades to FAILED — which is the point worth pinning here: the
stream is still closed exactly once and the caller still learns the run
ended. The BLOCKED contract itself is covered against a real workspace
in tests/core/test_iteration_budget_outcome_1117.py.
"""
from codeframe.core.react_agent import ReactAgent
from codeframe.core.models import ErrorEvent

Expand Down Expand Up @@ -1612,7 +1620,7 @@ def test_error_event_and_stream_close_on_max_iterations(
e for _, e in publisher.events if isinstance(e, ErrorEvent)
]
assert len(error_events) == 1
assert error_events[0].error == "max_iterations_reached"
assert error_events[0].error

assert "task-1" in publisher.completed_tasks

Expand Down