Skip to content
Open
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
39 changes: 39 additions & 0 deletions openhack/agents/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Comment on lines +247 to +248

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Coordinator calls bypass the budget

The session-wide cost cap only protects calls made through _agent_loop. CoordinatorAgent.run bypasses that loop, and _extract_high_risk_features and _write_app_specific_researchers call 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
This is a comment left during a code review.
Path: openhack/agents/base.py
Line: 247-248

Comment:
**Coordinator calls bypass the budget**

The session-wide cost cap only protects calls made through `_agent_loop`. `CoordinatorAgent.run` bypasses that loop, and `_extract_high_risk_features` and `_write_app_specific_researchers` call 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.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Codex Fix in Claude Code Fix in Cursor Fix in Conductor

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)
Expand Down Expand Up @@ -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
Expand All @@ -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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Prompt To Fix With AI
This 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.

Fix in Codex Fix in Claude Code Fix in Cursor Fix in Conductor

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",
Expand Down
9 changes: 9 additions & 0 deletions openhack/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,15 @@ class Settings(BaseSettings):
agent_max_iterations: int = 0
agent_stale_turn_limit: int = 8

# Spend / wall-clock backstops. With no iteration cutoff, the stale-turn
# stop only catches thrash; a run that keeps producing novel output has no
# other bound. These are opt-in (0 = unlimited, matching the no-cutoff
# philosophy) and are checked before every model call.
# agent_max_cost_usd: session-wide spend (all agents share session.total_cost)
# agent_max_runtime_s: wall-clock seconds for a single agent loop
agent_max_cost_usd: float = 0.0
agent_max_runtime_s: float = 0.0

# Scan scoping — exclude paths that are never production web attack surface
scan_exclude_patterns: list[str] = [
"**/test/**", "**/tests/**", "**/__tests__/**", "**/spec/**",
Expand Down
102 changes: 102 additions & 0 deletions tests/test_agent_budget.py
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Prompt To Fix With AI
This 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.

Fix in Codex Fix in Claude Code Fix in Cursor Fix in Conductor



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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Prompt To Fix With AI
This 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.

Fix in Codex Fix in Claude Code Fix in Cursor Fix in Conductor



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"