agentcap-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
agentcap/loop.py ADDED
@@ -0,0 +1,118 @@
1
+ """Loop detection and simple spend-velocity guards."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import threading
8
+ import time
9
+ from collections import deque
10
+ from decimal import Decimal
11
+ from typing import Any, Callable, Mapping
12
+
13
+ _EXCLUDED_FIELDS = frozenset({"stream", "user", "metadata", "stream_options"})
14
+
15
+
16
+ def canonical_body(body: Mapping[str, Any]) -> str:
17
+ """Canonical JSON for hashing, with volatile request fields removed."""
18
+
19
+ if not isinstance(body, Mapping):
20
+ raise TypeError("request body must be a mapping")
21
+ filtered = {key: value for key, value in body.items() if key not in _EXCLUDED_FIELDS}
22
+ return json.dumps(
23
+ filtered, sort_keys=True, separators=(",", ":"), ensure_ascii=False
24
+ )
25
+
26
+
27
+ def request_hash(body: Mapping[str, Any]) -> str:
28
+ return hashlib.sha256(canonical_body(body).encode("utf-8")).hexdigest()
29
+
30
+
31
+ _hash_body = request_hash
32
+
33
+
34
+ class LoopDetector:
35
+ """Detect N identical requests inside a rolling time window."""
36
+
37
+ def __init__(
38
+ self,
39
+ threshold: int = 5,
40
+ window_s: float = 120.0,
41
+ *,
42
+ clock: Callable[[], float] | None = None,
43
+ ) -> None:
44
+ if threshold < 1:
45
+ raise ValueError("threshold must be at least 1")
46
+ if window_s <= 0:
47
+ raise ValueError("window_s must be positive")
48
+ self.threshold = int(threshold)
49
+ self.window_s = float(window_s)
50
+ self._clock = clock or time.time
51
+ self._hits: dict[str, deque[float]] = {}
52
+ self._lock = threading.Lock()
53
+
54
+ def record(self, request_hash: str | Mapping[str, Any]) -> bool:
55
+ """Record a hash (or a body); True when the threshold is reached."""
56
+
57
+ if isinstance(request_hash, Mapping):
58
+ request_hash = _hash_body(request_hash)
59
+ if not isinstance(request_hash, str) or not request_hash:
60
+ raise ValueError("request_hash must be a non-empty string")
61
+ now = self._clock()
62
+ cutoff = now - self.window_s
63
+ with self._lock:
64
+ hits = self._hits.setdefault(request_hash, deque())
65
+ while hits and hits[0] < cutoff:
66
+ hits.popleft()
67
+ hits.append(now)
68
+ detected = len(hits) >= self.threshold
69
+ if detected:
70
+ # Keep the window intact for reporting, but avoid unbounded
71
+ # growth on a hot hash.
72
+ while len(hits) > self.threshold:
73
+ hits.popleft()
74
+ return detected
75
+
76
+ def record_request(self, body: Mapping[str, Any]) -> bool:
77
+ return self.record(request_hash(body))
78
+
79
+ def reset(self, request_hash: str | None = None) -> None:
80
+ with self._lock:
81
+ if request_hash is None:
82
+ self._hits.clear()
83
+ else:
84
+ self._hits.pop(request_hash, None)
85
+
86
+
87
+ class VelocityGuard:
88
+ """Guard against spending more than a budget inside a rolling window."""
89
+
90
+ def __init__(
91
+ self,
92
+ max_usd_per_window: Decimal | str | int,
93
+ window_s: float,
94
+ ledger: Any,
95
+ ) -> None:
96
+ if isinstance(max_usd_per_window, float):
97
+ raise TypeError("max_usd_per_window must be Decimal/str/int, not float")
98
+ self.max_usd_per_window = Decimal(str(max_usd_per_window))
99
+ if self.max_usd_per_window < 0:
100
+ raise ValueError("max_usd_per_window must be non-negative")
101
+ if window_s <= 0:
102
+ raise ValueError("window_s must be positive")
103
+ self.window_s = float(window_s)
104
+ self.ledger = ledger
105
+
106
+ def current_usd(self) -> Decimal:
107
+ return self.ledger.window_spent(self.window_s)
108
+
109
+ def would_exceed(self, additional_usd: Decimal | str | int = Decimal("0")) -> bool:
110
+ if isinstance(additional_usd, float):
111
+ raise TypeError("additional_usd must be Decimal/str/int, not float")
112
+ additional = Decimal(str(additional_usd))
113
+ if additional < 0:
114
+ raise ValueError("additional_usd must be non-negative")
115
+ return self.current_usd() + additional > self.max_usd_per_window
116
+
117
+ def exceeded(self) -> bool:
118
+ return self.current_usd() > self.max_usd_per_window