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
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
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