Repository navigation
Add opt-in cost and runtime caps to agent loop #29
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -237,6 +237,29 @@ def _record_response_usage(self, response) -> None: | |
| self.session.total_output_tokens += response.usage.get("output_tokens", 0) | ||
| self.context_manager.update_usage(response.usage.get("input_tokens", 0)) | ||
|
|
||
| def _budget_exceeded(self, loop_started: float) -> Optional[str]: | ||
| """Return a human-readable reason if a spend/runtime cap is hit. | ||
|
|
||
| Both caps are opt-in: a value <= 0 disables that check. Cost is | ||
| session-wide (agents share ``session.total_cost``); runtime is measured | ||
| from the start of this agent loop. | ||
| """ | ||
| max_cost = getattr(settings, "agent_max_cost_usd", 0.0) or 0.0 | ||
| if max_cost > 0 and self.session.total_cost >= max_cost: | ||
| return ( | ||
| f"cost cap reached (${self.session.total_cost:.4f} >= " | ||
| f"${max_cost:.4f}; agent_max_cost_usd)" | ||
| ) | ||
| max_runtime = getattr(settings, "agent_max_runtime_s", 0.0) or 0.0 | ||
| if max_runtime > 0: | ||
| elapsed = time.monotonic() - loop_started | ||
| if elapsed >= max_runtime: | ||
| return ( | ||
| f"runtime cap reached ({elapsed:.0f}s >= " | ||
| f"{max_runtime:.0f}s; agent_max_runtime_s)" | ||
| ) | ||
| return None | ||
|
|
||
| def _estimate_tokens(self, messages: list[Message], system: str) -> int: | ||
| """Rough token estimate: ~4 chars per token for English text.""" | ||
| total_chars = len(system) | ||
|
|
@@ -403,6 +426,7 @@ async def _agent_loop(self) -> dict: | |
| max_iterations = getattr(settings, "agent_max_iterations", 0) or 0 | ||
| stale_limit = getattr(settings, "agent_stale_turn_limit", 8) | ||
| iteration = 0 | ||
| loop_started = time.monotonic() | ||
| # Natural prose returned immediately before the continuation guard. | ||
| # Some providers obey the guard by sending finish_task in a separate | ||
| # response; retain the prose so a no-op greeting does not acquire a | ||
|
|
@@ -419,6 +443,21 @@ async def _agent_loop(self) -> dict: | |
| await self.session.wait_if_paused() | ||
| if self.session.cancelled: | ||
| break | ||
| budget_reason = self._budget_exceeded(loop_started) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The budget check runs before the first model call, but not before retries after a context-length error. If the first request takes longer than Prompt To Fix With AIThis is a comment left during a code review.
Path: openhack/agents/base.py
Line: 446
Comment:
**Retries ignore the runtime limit**
The budget check runs before the first model call, but not before retries after a context-length error. If the first request takes longer than `agent_max_runtime_s` before failing, the loop still sends another request after the deadline. Check the budget before each retry and return the same budget-stop result when it is reached.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly. |
||
| if budget_reason: | ||
| logger.warning(f"[{self.name}] Stopping: {budget_reason}") | ||
| result = { | ||
| "error": "Budget exceeded", | ||
| "detail": budget_reason, | ||
| "partial_result": self._last_assistant_content(), | ||
| } | ||
| self.session.record_event( | ||
| "agent_loop_stopped", | ||
| {"reason": "budget_exceeded", **result}, | ||
| agent=self.name, | ||
| ) | ||
| self.session.finish_turn("budget_exceeded", result, agent=self.name) | ||
| return result | ||
| iteration += 1 | ||
| self.session.record_event( | ||
| "agent_iteration_started", | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,102 @@ | ||
| """Opt-in spend / wall-clock backstops for the agent loop. | ||
|
|
||
| With the iteration cutoff removed, a run that keeps producing novel output has | ||
| no bound other than the stale-turn stop. `agent_max_cost_usd` and | ||
| `agent_max_runtime_s` give operators a hard ceiling. Both default to 0 | ||
| (unlimited) so existing behaviour is unchanged. | ||
| """ | ||
|
|
||
| import asyncio | ||
|
|
||
| from openhack.agents.llm import LLMResponse, ToolCall | ||
| from openhack.agents.session import Session | ||
| from openhack.config import settings | ||
| from openhack.tui import OpenHackApp | ||
|
|
||
| from tests.test_no_iteration_cutoff import _ProbeAgent, _UniqueResultTools | ||
|
|
||
|
|
||
| class _CostlyLLM: | ||
| """Productive forever (never finish_task); every call costs `cost_per_call`.""" | ||
|
|
||
| model = "test-model" | ||
| provider = "test-provider" | ||
|
|
||
| def __init__(self, cost_per_call: float = 0.0, delay: float = 0.0): | ||
| self.cost_per_call = cost_per_call | ||
| self.delay = delay | ||
| self.calls = 0 | ||
| self.event_callback = None | ||
|
|
||
| async def chat(self, **kwargs): | ||
| self.calls += 1 | ||
| if self.delay: | ||
| await asyncio.sleep(self.delay) | ||
| return LLMResponse( | ||
| tool_calls=[ | ||
| ToolCall(id=f"p{self.calls}", name="probe", arguments={"n": self.calls}) | ||
| ], | ||
| finish_reason="tool_calls", | ||
| model_call_id=f"m{self.calls}", | ||
| cost=self.cost_per_call, | ||
| ) | ||
|
|
||
|
|
||
| def _run(tmp_path, llm, monkeypatch, max_calls=50): | ||
| session = Session(str(tmp_path), persist_events=False) | ||
| tools = _UniqueResultTools(tmp_path, include_agent_tools=True, session=session) | ||
| agent = _ProbeAgent(llm, tools, session) | ||
| # Safety net so a regression in the cap can't hang the suite. | ||
| monkeypatch.setattr(settings, "agent_max_iterations", max_calls) | ||
| return session, asyncio.run(agent.run("go")) | ||
|
|
||
|
|
||
| def test_defaults_are_unlimited(): | ||
| assert settings.agent_max_cost_usd == 0.0 | ||
| assert settings.agent_max_runtime_s == 0.0 | ||
|
Comment on lines
+54
to
+56
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
These tests read the user's loaded Prompt To Fix With AIThis is a comment left during a code review.
Path: tests/test_agent_budget.py
Line: 54-56
Comment:
**Local budgets change test results**
These tests read the user's loaded `settings` instead of isolating the new limits. If either limit is set through the environment or saved config, `test_defaults_are_unlimited` fails even though the declared defaults are correct. A configured runtime limit can also stop `test_cost_cap_stops_the_loop` early. Check defaults through `Settings.model_fields` and explicitly set both limits in each behavioral test.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly. |
||
|
|
||
|
|
||
| def test_cost_cap_stops_the_loop(tmp_path, monkeypatch): | ||
| monkeypatch.setattr(settings, "agent_max_cost_usd", 0.25) | ||
| llm = _CostlyLLM(cost_per_call=0.10) | ||
|
|
||
| session, result = _run(tmp_path, llm, monkeypatch) | ||
|
|
||
| # 3 calls -> $0.30 >= $0.25, checked before the 4th call. | ||
| assert llm.calls == 3 | ||
| assert result["error"] == "Budget exceeded" | ||
| assert "agent_max_cost_usd" in result["detail"] | ||
| assert any( | ||
| e.event_type == "agent_loop_stopped" | ||
| and (e.data or {}).get("reason") == "budget_exceeded" | ||
| for e in session.events | ||
| ) | ||
|
|
||
|
|
||
| def test_runtime_cap_stops_the_loop(tmp_path, monkeypatch): | ||
| monkeypatch.setattr(settings, "agent_max_cost_usd", 0.0) | ||
| monkeypatch.setattr(settings, "agent_max_runtime_s", 0.2) | ||
| llm = _CostlyLLM(delay=0.08) | ||
|
|
||
| session, result = _run(tmp_path, llm, monkeypatch) | ||
|
|
||
| assert result["error"] == "Budget exceeded" | ||
| assert "agent_max_runtime_s" in result["detail"] | ||
| assert 2 <= llm.calls <= 6 | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This test depends on real scheduling time. On a busy runner, one Prompt To Fix With AIThis is a comment left during a code review.
Path: tests/test_agent_budget.py
Line: 85
Comment:
**Runtime test fails under load**
This test depends on real scheduling time. On a busy runner, one `asyncio.sleep(0.08)` can resume after the full 0.2-second limit. Correct code can then stop after one call and fail the `llm.calls >= 2` assertion. Use a controlled clock instead of real sleeps so runner load does not cause false failures.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly. |
||
|
|
||
|
|
||
| def test_caps_disabled_do_not_interfere(tmp_path, monkeypatch): | ||
| monkeypatch.setattr(settings, "agent_max_cost_usd", 0.0) | ||
| monkeypatch.setattr(settings, "agent_max_runtime_s", 0.0) | ||
| llm = _CostlyLLM(cost_per_call=100.0) | ||
|
|
||
| _, result = _run(tmp_path, llm, monkeypatch, max_calls=7) | ||
|
|
||
| # Only the iteration backstop (7) stops it, not the budget. | ||
| assert llm.calls == 7 | ||
| assert result["error"] == "Max iterations reached" | ||
|
|
||
|
|
||
| def test_budget_stop_is_reported_incomplete_not_completed(): | ||
| result = {"error": "Budget exceeded", "detail": "x", "partial_result": ""} | ||
| assert OpenHackApp._agent_result_status(result) == "incomplete" | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The session-wide cost cap only protects calls made through
_agent_loop.CoordinatorAgent.runbypasses that loop, and_extract_high_risk_featuresand_write_app_specific_researcherscall the model directly. If recon finishes below the cap and hunting reaches it, feature extraction still sends a paid request. Apply the cost check to these calls too, or stop the coordinator when the session budget is reached.Prompt To Fix With AI