Skip to content
Closed
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
6 changes: 5 additions & 1 deletion agent/turn_finalizer.py
Original file line number Diff line number Diff line change
Expand Up @@ -122,10 +122,14 @@ def finalize_turn(
)

# Determine if conversation completed successfully
normal_text_response = str(_turn_exit_reason).startswith("text_response(")
completed = (
final_response is not None
and api_call_count < agent.max_iterations
and not failed
and (
api_call_count < agent.max_iterations
or normal_text_response
)
)

# Post-loop cleanup must never lose the response. Trajectory save,
Expand Down
18 changes: 16 additions & 2 deletions cron/scheduler.py
Original file line number Diff line number Diff line change
Expand Up @@ -2189,13 +2189,27 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]:
# would otherwise be delivered as if it were the agent's reply and the
# job's `last_status` set to "ok". Raise so the except handler below
# builds the proper failure tuple. (issue #17855)
if result.get("failed") is True or result.get("completed") is False:
turn_exit_reason = str(result.get("turn_exit_reason") or "")
final_response_text = (result.get("final_response") or "").strip()
max_iteration_summary = (
result.get("failed") is not True
and result.get("completed") is False
and turn_exit_reason.startswith("max_iterations_reached(")
and bool(final_response_text)
)
if result.get("failed") is True or (result.get("completed") is False and not max_iteration_summary):
_err_text = (
result.get("error")
or (result.get("final_response") or "").strip()
or final_response_text
or "agent reported failure"
)
raise RuntimeError(_err_text)
if max_iteration_summary:
logger.warning(
"Job '%s' reached the iteration limit but produced a final fallback response; "
"delivering the response instead of failing the cron run",
job_name,
)

final_response = result.get("final_response", "") or ""
# Strip leaked placeholder text that upstream may inject on empty completions.
Expand Down
27 changes: 23 additions & 4 deletions tests/agent/test_turn_finalizer_cleanup_guard.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,13 @@ def _sync_external_memory_for_turn(self, **k):
pass


def _run(agent):
def _run(
agent,
*,
final_response=None,
api_call_count=3,
turn_exit_reason="unknown",
):
messages = [
{"role": "user", "content": "do a thing"},
{
Expand All @@ -114,8 +120,8 @@ def _run(agent):
]
return finalize_turn(
agent,
final_response=None, # forces the max-iterations summary path
api_call_count=3,
final_response=final_response,
api_call_count=api_call_count,
interrupted=False,
failed=False,
messages=messages,
Expand All @@ -125,7 +131,7 @@ def _run(agent):
user_message="do a thing",
original_user_message="do a thing",
_should_review_memory=False,
_turn_exit_reason="unknown",
_turn_exit_reason=turn_exit_reason,
)


Expand Down Expand Up @@ -162,4 +168,17 @@ def test_clean_turn_has_no_cleanup_errors_key():
agent = _StubAgent(raise_in=())
result = _run(agent)
assert result["final_response"] == "PARTIAL SUMMARY FROM MODEL"
assert result["completed"] is False
assert "cleanup_errors" not in result


def test_text_response_on_last_allowed_call_is_completed():
agent = _StubAgent(raise_in=())
result = _run(
agent,
final_response="final report",
api_call_count=agent.max_iterations,
turn_exit_reason="text_response(finish_reason=stop)",
)
assert result["final_response"] == "final report"
assert result["completed"] is True
46 changes: 46 additions & 0 deletions tests/cron/test_scheduler.py
Original file line number Diff line number Diff line change
Expand Up @@ -1394,6 +1394,52 @@ def test_run_job_completed_true_without_failed_flag_succeeds(self, tmp_path):
assert error is None
assert final_response == "all good"

def test_run_job_delivers_max_iteration_fallback_summary(self, tmp_path):
"""Cron should deliver a usable max-iteration fallback summary.

A cron run can exhaust the iteration budget, get a final text summary
from the no-tools fallback call, and still have ``completed=False`` in
the generic agent result. That should not make cron raise the report
text as a RuntimeError.
"""
job = {
"id": "summary-job",
"name": "summary",
"prompt": "finish the report",
}
fake_db = MagicMock()

with patch("cron.scheduler._hermes_home", tmp_path), \
patch("cron.scheduler._resolve_origin", return_value=None), \
patch("dotenv.load_dotenv"), \
patch("hermes_state.SessionDB", return_value=fake_db), \
patch(
"hermes_cli.runtime_provider.resolve_runtime_provider",
return_value={
"api_key": "***",
"base_url": "https://example.invalid/v1",
"provider": "openrouter",
"api_mode": "chat_completions",
},
), \
patch("run_agent.AIAgent") as mock_agent_cls:
mock_agent = MagicMock()
mock_agent.run_conversation.return_value = {
"final_response": "final fallback report",
"completed": False,
"failed": False,
"turn_exit_reason": "max_iterations_reached(60/60)",
}
mock_agent_cls.return_value = mock_agent

success, output, final_response, error = run_job(job)

assert success is True
assert error is None
assert final_response == "final fallback report"
assert "final fallback report" in output
assert "(FAILED)" not in output

def test_tick_marks_empty_response_as_error(self, tmp_path):
"""When run_job returns success=True but final_response is empty,
tick() should mark the job as error so last_status != 'ok'.
Expand Down
Loading