security agent cleanup

This commit is contained in:
Roger Oriol
2026-07-26 21:07:47 +02:00
parent 0bb3b1c601
commit 7ff6bf858d
21 changed files with 206 additions and 195 deletions

View File

@@ -1,14 +1,14 @@
"""Loop & resource controls (checklist §4).
"""Loop & resource controls.
This module centralises every limit that prevents the agent from
running away with itself:
§4.1 IterationCaps hard caps on turns and tool calls.
§4.2 ContextBudget token budget with automatic context trimming.
§4.3 (timeouts live in sandbox.py / agent.py wired here via a small
IterationCaps: hard caps on turns and tool calls.
ContextBudget: token budget with automatic context trimming.
(timeouts live in sandbox.py / agent.py, wired here via a small
ToolTimeout helper that classifies timeout events for the audit
log).
§4.4 CostTracker cumulative API spend circuit breaker.
CostTracker: cumulative API spend circuit breaker.
All of these are *host-side* controls: the model never gets to vote on
them. They are evaluated between tool calls and before each LLM call.
@@ -22,7 +22,7 @@ from typing import Any
# ---------------------------------------------------------------------------
# 4.1 Hard iteration caps
# Hard iteration caps
# ---------------------------------------------------------------------------
# Defaults are chosen to be generous enough for real coding tasks but
@@ -33,9 +33,9 @@ DEFAULT_MAX_TOOL_CALLS_PER_SESSION = 200
@dataclass
class IterationCaps:
"""Counters that enforce hard iteration caps (§4.1).
"""Counters that enforce hard iteration caps.
The harness never the model owns these limits. Two counters
The harness, never the model, owns these limits. Two counters
are tracked:
- ``turns``: incremented once per LLM response within a single
@@ -82,11 +82,11 @@ class IterationCaps:
@property
def breached(self) -> bool:
return self.turns > self.max_turns_per_user_msg or \
self.tool_calls > self.max_tool_calls_per_session
self.tool_calls > self.max_tool_calls_per_session
# ---------------------------------------------------------------------------
# 4.2 Token budget enforcement
# Token budget enforcement
# ---------------------------------------------------------------------------
# Above this fraction of the model's context window we trim older
@@ -102,7 +102,7 @@ def estimate_tokens(text: str) -> int:
"""Cheap token estimate: ~4 chars per token.
Good enough for budget decisions; the real BPE count is only
needed for billing, which §4.4 handles via the API's own
needed for billing, which handles via the API's own
``usage`` field.
"""
if not text:
@@ -112,7 +112,8 @@ def estimate_tokens(text: str) -> int:
def _message_token_count(msg: Any) -> int:
"""Estimate tokens in a single chat message."""
content = msg.get("content") if isinstance(msg, dict) else getattr(msg, "content", None)
content = msg.get("content") if isinstance(
msg, dict) else getattr(msg, "content", None)
if content is None:
return 0
if isinstance(content, str):
@@ -124,7 +125,7 @@ def _message_token_count(msg: Any) -> int:
@dataclass
class ContextBudget:
"""Track cumulative tokens and trim the message history (§4.2).
"""Track cumulative tokens and trim the message history.
``check_and_trim`` is called before each LLM request. If the
estimated token count exceeds ``max_tokens * trim_threshold`` it
@@ -187,10 +188,12 @@ class ContextBudget:
tool_calls: list[str] = []
total_chars = 0
for m in dropped:
content = m.get("content") if isinstance(m, dict) else getattr(m, "content", "")
content = m.get("content") if isinstance(
m, dict) else getattr(m, "content", "")
if content:
total_chars += len(str(content))
tcs = m.get("tool_calls") if isinstance(m, dict) else getattr(m, "tool_calls", None)
tcs = m.get("tool_calls") if isinstance(
m, dict) else getattr(m, "tool_calls", None)
if tcs:
for tc in tcs:
name = getattr(getattr(tc, "function", None), "name", None)
@@ -201,12 +204,14 @@ class ContextBudget:
blob = str([
(m.get("role") if isinstance(m, dict) else getattr(m, "role", "")) for m in dropped
])
digest = hashlib.sha256(blob.encode("utf-8", "replace")).hexdigest()[:16]
digest = hashlib.sha256(blob.encode(
"utf-8", "replace")).hexdigest()[:16]
lines = [
"[context-trim summary]",
f"Earlier conversation dropped to stay within the token budget.",
f"Dropped messages: {len(dropped)} ({total_chars} chars).",
f"Tools called in dropped section: {', '.join(tool_calls) or 'none'}.",
f"Tools called in dropped section: {
', '.join(tool_calls) or 'none'}.",
f"Conversation hash (first 16 hex): {digest}.",
"Re-read any files you need rather than relying on the dropped context.",
]
@@ -214,7 +219,7 @@ class ContextBudget:
def cap_tool_result(result: str, limit: int = DEFAULT_MAX_TOOL_RESULT_CHARS) -> str:
"""Cap a tool result before it is inserted into messages (§4.2).
"""Cap a tool result before it is inserted into messages.
Long results (e.g. reading a 50k-line file) are truncated to
``limit`` chars with a notice appended so the model knows there is
@@ -230,7 +235,7 @@ def cap_tool_result(result: str, limit: int = DEFAULT_MAX_TOOL_RESULT_CHARS) ->
# ---------------------------------------------------------------------------
# 4.4 Cost circuit breakers
# Cost circuit breakers
# ---------------------------------------------------------------------------
# Default per-session spend cap in USD. Generous for local Ollama
@@ -246,7 +251,7 @@ DEFAULT_PRICE_PER_1K_OUT = 0.000600
@dataclass
class CostTracker:
"""Accumulate API spend and abort when the cap is hit (§4.4).
"""Accumulate API spend and abort when the cap is hit.
After each ``chat.completions.create`` call, the caller invokes
``record_usage`` with the ``response.usage`` object (or None for
@@ -292,7 +297,8 @@ class CostTracker:
def summary(self) -> str:
return (
f"calls={self.calls} tokens_in={self.total_tokens_in} "
f"tokens_out={self.total_tokens_out} cost=${self.total_cost_usd:.4f}"
f"tokens_out={self.total_tokens_out} cost=${
self.total_cost_usd:.4f}"
)