loopgain 0.4.2__tar.gz → 0.4.3__tar.gz

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.
Files changed (34) hide show
  1. {loopgain-0.4.2 → loopgain-0.4.3}/PKG-INFO +2 -1
  2. {loopgain-0.4.2 → loopgain-0.4.3}/README.md +1 -0
  3. {loopgain-0.4.2 → loopgain-0.4.3}/loopgain/_version.py +1 -1
  4. {loopgain-0.4.2 → loopgain-0.4.3}/loopgain/core.py +15 -1
  5. {loopgain-0.4.2 → loopgain-0.4.3}/loopgain/telemetry.py +62 -20
  6. {loopgain-0.4.2 → loopgain-0.4.3}/loopgain.egg-info/PKG-INFO +2 -1
  7. {loopgain-0.4.2 → loopgain-0.4.3}/tests/test_telemetry.py +114 -0
  8. {loopgain-0.4.2 → loopgain-0.4.3}/LICENSE +0 -0
  9. {loopgain-0.4.2 → loopgain-0.4.3}/loopgain/__init__.py +0 -0
  10. {loopgain-0.4.2 → loopgain-0.4.3}/loopgain/__main__.py +0 -0
  11. {loopgain-0.4.2 → loopgain-0.4.3}/loopgain/classifier.py +0 -0
  12. {loopgain-0.4.2 → loopgain-0.4.3}/loopgain/cli.py +0 -0
  13. {loopgain-0.4.2 → loopgain-0.4.3}/loopgain/funnel.py +0 -0
  14. {loopgain-0.4.2 → loopgain-0.4.3}/loopgain/integrations/__init__.py +0 -0
  15. {loopgain-0.4.2 → loopgain-0.4.3}/loopgain/integrations/autogen.py +0 -0
  16. {loopgain-0.4.2 → loopgain-0.4.3}/loopgain/integrations/claude_agent_sdk.py +0 -0
  17. {loopgain-0.4.2 → loopgain-0.4.3}/loopgain/integrations/crewai.py +0 -0
  18. {loopgain-0.4.2 → loopgain-0.4.3}/loopgain/integrations/langchain.py +0 -0
  19. {loopgain-0.4.2 → loopgain-0.4.3}/loopgain/integrations/langgraph.py +0 -0
  20. {loopgain-0.4.2 → loopgain-0.4.3}/loopgain/integrations/openai_agents.py +0 -0
  21. {loopgain-0.4.2 → loopgain-0.4.3}/loopgain.egg-info/SOURCES.txt +0 -0
  22. {loopgain-0.4.2 → loopgain-0.4.3}/loopgain.egg-info/dependency_links.txt +0 -0
  23. {loopgain-0.4.2 → loopgain-0.4.3}/loopgain.egg-info/entry_points.txt +0 -0
  24. {loopgain-0.4.2 → loopgain-0.4.3}/loopgain.egg-info/requires.txt +0 -0
  25. {loopgain-0.4.2 → loopgain-0.4.3}/loopgain.egg-info/top_level.txt +0 -0
  26. {loopgain-0.4.2 → loopgain-0.4.3}/pyproject.toml +0 -0
  27. {loopgain-0.4.2 → loopgain-0.4.3}/setup.cfg +0 -0
  28. {loopgain-0.4.2 → loopgain-0.4.3}/tests/test_classifier_mock_validation.py +0 -0
  29. {loopgain-0.4.2 → loopgain-0.4.3}/tests/test_classifier_synthetic.py +0 -0
  30. {loopgain-0.4.2 → loopgain-0.4.3}/tests/test_core.py +0 -0
  31. {loopgain-0.4.2 → loopgain-0.4.3}/tests/test_funnel.py +0 -0
  32. {loopgain-0.4.2 → loopgain-0.4.3}/tests/test_integrations.py +0 -0
  33. {loopgain-0.4.2 → loopgain-0.4.3}/tests/test_stress.py +0 -0
  34. {loopgain-0.4.2 → loopgain-0.4.3}/tests/test_termination_safety.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: loopgain
3
- Version: 0.4.2
3
+ Version: 0.4.3
4
4
  Summary: An open-source cost controller for AI agent loops. Stops a loop when it has actually converged and rolls back before it degrades — replacing the max_iterations guess with a real-time loop-gain (Aβ) monitor with five named threshold bands and best-so-far rollback.
5
5
  Author-email: Dave Fitzsimmons <hello@loopgain.ai>
6
6
  License: Apache-2.0
@@ -183,6 +183,7 @@ LoopGain saves money by stopping a loop once it stops improving — fewer iterat
183
183
 
184
184
  - **Savings depend on your workload.** Loops that usually succeed fast save the most (~96%); adversarial, failure-prone loops save less (~78–84%). The headline is a blend — run the benchmark on your own loops before quoting a number.
185
185
  - **LoopGain detects convergence, not correctness.** It stops when your error signal stops improving — which means more iterations won't help, *not* that the loop succeeded. On the benchmark this preserved quality (it rarely stopped early on a worse output; false-stop rate ≤4.5%), but a loop can stall with the error still above zero — a plateau at, say, 2 failing tests. So check `result.best_error` (or your own pass/fail) before you trust the output: if it plateaued short of your target, that's a quality gap LoopGain can't see, and a false stop that forces a rerun is the one way it eats into the savings. LoopGain decides *when to stop*; you decide *whether the answer is good enough*.
186
+ - **LoopGain is only as right as your verifier.** It acts on the error signal you give it. If your verifier reports zero errors, LoopGain trusts that and stops — so a verifier with blind spots can report success on an answer that is still wrong, and LoopGain will confidently stop there. This is not the plateau case above: the error reads zero and the loop looks like a clean success, so neither LoopGain nor its convergence signal can flag it. The quality of the stop is bounded by the quality of the check behind your error signal. Pair LoopGain with the strongest verifier you can afford at the stop — executable tests over a sampled subset, a schema or type check over a vibe, a held-out check the loop didn't optimize against.
186
187
 
187
188
  ---
188
189
 
@@ -134,6 +134,7 @@ LoopGain saves money by stopping a loop once it stops improving — fewer iterat
134
134
 
135
135
  - **Savings depend on your workload.** Loops that usually succeed fast save the most (~96%); adversarial, failure-prone loops save less (~78–84%). The headline is a blend — run the benchmark on your own loops before quoting a number.
136
136
  - **LoopGain detects convergence, not correctness.** It stops when your error signal stops improving — which means more iterations won't help, *not* that the loop succeeded. On the benchmark this preserved quality (it rarely stopped early on a worse output; false-stop rate ≤4.5%), but a loop can stall with the error still above zero — a plateau at, say, 2 failing tests. So check `result.best_error` (or your own pass/fail) before you trust the output: if it plateaued short of your target, that's a quality gap LoopGain can't see, and a false stop that forces a rerun is the one way it eats into the savings. LoopGain decides *when to stop*; you decide *whether the answer is good enough*.
137
+ - **LoopGain is only as right as your verifier.** It acts on the error signal you give it. If your verifier reports zero errors, LoopGain trusts that and stops — so a verifier with blind spots can report success on an answer that is still wrong, and LoopGain will confidently stop there. This is not the plateau case above: the error reads zero and the loop looks like a clean success, so neither LoopGain nor its convergence signal can flag it. The quality of the stop is bounded by the quality of the check behind your error signal. Pair LoopGain with the strongest verifier you can afford at the stop — executable tests over a sampled subset, a schema or type check over a vibe, a held-out check the loop didn't optimize against.
137
138
 
138
139
  ---
139
140
 
@@ -7,4 +7,4 @@ from here so the value never drifts between ``__version__`` and the
7
7
  ``pyproject.toml``) for each release.
8
8
  """
9
9
 
10
- __version__ = "0.4.2"
10
+ __version__ = "0.4.3"
@@ -514,6 +514,8 @@ class LoopGain:
514
514
  loop_type: Optional[str] = None,
515
515
  team: Optional[str] = None,
516
516
  include_per_iteration: bool = True,
517
+ retries: int = 2,
518
+ retry_backoff: float = 0.25,
517
519
  ) -> bool:
518
520
  """Send anonymized telemetry to a receiver endpoint.
519
521
 
@@ -544,6 +546,12 @@ class LoopGain:
544
546
  per-iteration Aβ + error trajectories (capped) so the
545
547
  dashboard's Loop Detail scrubber works. Set ``False`` to
546
548
  send only aggregate summary stats.
549
+ retries: Additional attempts if a send fails *transiently*
550
+ (timeout, connection error, 5xx/429). Default 2 (up to 3
551
+ attempts). Set to 0 for single-shot. Deterministic failures
552
+ (bad token, etc.) are never retried.
553
+ retry_backoff: Base seconds between attempts; the nth retry waits
554
+ ``retry_backoff * n``. Default 0.25.
547
555
 
548
556
  Returns:
549
557
  ``True`` on 2xx response, ``False`` otherwise.
@@ -572,5 +580,11 @@ class LoopGain:
572
580
  include_per_iteration=include_per_iteration,
573
581
  )
574
582
  return send_payload(
575
- endpoint, token, payload, timeout=timeout, allow_insecure=allow_insecure
583
+ endpoint,
584
+ token,
585
+ payload,
586
+ timeout=timeout,
587
+ allow_insecure=allow_insecure,
588
+ retries=retries,
589
+ retry_backoff=retry_backoff,
576
590
  )
@@ -22,7 +22,9 @@ from __future__ import annotations
22
22
 
23
23
  import json
24
24
  import math
25
+ import socket
25
26
  import statistics
27
+ import time
26
28
  import urllib.error
27
29
  import urllib.request
28
30
  from datetime import datetime, timezone
@@ -218,18 +220,43 @@ def build_payload(
218
220
  return payload
219
221
 
220
222
 
223
+ def _is_transient(exc: BaseException) -> bool:
224
+ """Is this send failure worth retrying?
225
+
226
+ Transient = timeout, connection/DNS error, or a 5xx/429 from the server —
227
+ a later attempt might succeed. Deterministic failures (4xx other than 429,
228
+ a refused redirect) will never succeed on retry, so they are *not*
229
+ transient and we give up immediately.
230
+ """
231
+ if isinstance(exc, urllib.error.HTTPError): # subclass of URLError — check first
232
+ return exc.code >= 500 or exc.code == 429
233
+ return isinstance(exc, (TimeoutError, socket.timeout, urllib.error.URLError, OSError))
234
+
235
+
221
236
  def send_payload(
222
237
  endpoint: str,
223
238
  token: str,
224
239
  payload: dict[str, Any],
225
240
  timeout: float = 2.0,
226
241
  allow_insecure: bool = False,
242
+ retries: int = 2,
243
+ retry_backoff: float = 0.25,
227
244
  ) -> bool:
228
245
  """POST a telemetry payload to the given endpoint.
229
246
 
230
247
  Best-effort: errors are swallowed; never raises. Returns ``True`` if
231
248
  the server returned a 2xx status, ``False`` otherwise.
232
249
 
250
+ A single send is one HTTP POST with a ``timeout``-second deadline. The
251
+ warm round-trip to the hosted receiver is ~150 ms, so the default 2 s
252
+ timeout has wide headroom; the failure mode in practice is a *transient*
253
+ outlier (a cold database first-write, a momentary network blip) that
254
+ blows past it. Because a low-frequency caller may send only one aggregate
255
+ per run, a single dropped send loses that whole run's data — so a transient
256
+ failure is retried up to ``retries`` times with a short linear backoff.
257
+ Deterministic failures (bad token, malformed payload, refused redirect)
258
+ are *not* retried. Still best-effort throughout: the loop never raises.
259
+
233
260
  Args:
234
261
  endpoint: Telemetry receiver URL (e.g.,
235
262
  ``https://telemetry.loopgain.ai/v1/aggregate``). Must use
@@ -240,13 +267,18 @@ def send_payload(
240
267
  token: Bearer token issued by the receiver. Identifies the customer
241
268
  account; rotatable; not linked to any production secrets.
242
269
  payload: Dict from ``build_payload``.
243
- timeout: Per-request timeout in seconds. Default 2.0.
270
+ timeout: Per-attempt timeout in seconds. Default 2.0.
244
271
  allow_insecure: If ``True``, permit ``http://`` endpoints. Intended
245
272
  for local development against a self-hosted receiver on
246
273
  ``http://localhost``. Default ``False``.
274
+ retries: Number of *additional* attempts after the first if the send
275
+ fails transiently. Default 2 (so up to 3 attempts total). Set to
276
+ 0 to restore single-shot behavior.
277
+ retry_backoff: Base seconds to sleep between attempts; the nth retry
278
+ waits ``retry_backoff * n`` (0.25 s, 0.50 s, …). Default 0.25.
247
279
 
248
280
  Returns:
249
- ``True`` on 2xx response, ``False`` otherwise.
281
+ ``True`` on a 2xx response, ``False`` otherwise.
250
282
  """
251
283
  # Refuse to attach the bearer token to anything but http(s); silently
252
284
  # best-effort so a misconfigured endpoint can't break the user's loop.
@@ -263,23 +295,33 @@ def send_payload(
263
295
 
264
296
  try:
265
297
  body = json.dumps(payload).encode("utf-8")
266
- req = urllib.request.Request(
267
- endpoint,
268
- data=body,
269
- method="POST",
270
- headers={
271
- "Content-Type": "application/json",
272
- "Authorization": f"Bearer {token}",
273
- "User-Agent": f"loopgain/{LIBRARY_VERSION}",
274
- },
275
- )
276
- # Use the no-redirect seam so a malicious or misconfigured
277
- # endpoint can't 302 the bearer token to a different host.
278
- with _open_request(req, timeout) as resp:
279
- return 200 <= resp.status < 300
280
298
  except Exception:
281
- # Best-effort: never break the user's loop because telemetry failed.
282
- # Catches URLError, HTTPError, TimeoutError, OSError, plus the
283
- # ValueError that urllib raises for malformed URLs (e.g., missing scheme),
284
- # plus any JSON-encoding edge case in the payload.
299
+ # A payload that won't JSON-encode will never send don't retry.
285
300
  return False
301
+
302
+ req = urllib.request.Request(
303
+ endpoint,
304
+ data=body,
305
+ method="POST",
306
+ headers={
307
+ "Content-Type": "application/json",
308
+ "Authorization": f"Bearer {token}",
309
+ "User-Agent": f"loopgain/{LIBRARY_VERSION}",
310
+ },
311
+ )
312
+
313
+ attempts = max(1, retries + 1)
314
+ for i in range(attempts):
315
+ try:
316
+ # Use the no-redirect seam so a malicious or misconfigured
317
+ # endpoint can't 302 the bearer token to a different host.
318
+ with _open_request(req, timeout) as resp:
319
+ return 200 <= resp.status < 300
320
+ except Exception as exc:
321
+ # Best-effort: never break the user's loop because telemetry failed.
322
+ # Retry only transient failures, and only if attempts remain.
323
+ last = i == attempts - 1
324
+ if last or not _is_transient(exc):
325
+ return False
326
+ time.sleep(retry_backoff * (i + 1))
327
+ return False
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: loopgain
3
- Version: 0.4.2
3
+ Version: 0.4.3
4
4
  Summary: An open-source cost controller for AI agent loops. Stops a loop when it has actually converged and rolls back before it degrades — replacing the max_iterations guess with a real-time loop-gain (Aβ) monitor with five named threshold bands and best-so-far rollback.
5
5
  Author-email: Dave Fitzsimmons <hello@loopgain.ai>
6
6
  License: Apache-2.0
@@ -183,6 +183,7 @@ LoopGain saves money by stopping a loop once it stops improving — fewer iterat
183
183
 
184
184
  - **Savings depend on your workload.** Loops that usually succeed fast save the most (~96%); adversarial, failure-prone loops save less (~78–84%). The headline is a blend — run the benchmark on your own loops before quoting a number.
185
185
  - **LoopGain detects convergence, not correctness.** It stops when your error signal stops improving — which means more iterations won't help, *not* that the loop succeeded. On the benchmark this preserved quality (it rarely stopped early on a worse output; false-stop rate ≤4.5%), but a loop can stall with the error still above zero — a plateau at, say, 2 failing tests. So check `result.best_error` (or your own pass/fail) before you trust the output: if it plateaued short of your target, that's a quality gap LoopGain can't see, and a false stop that forces a rerun is the one way it eats into the savings. LoopGain decides *when to stop*; you decide *whether the answer is good enough*.
186
+ - **LoopGain is only as right as your verifier.** It acts on the error signal you give it. If your verifier reports zero errors, LoopGain trusts that and stops — so a verifier with blind spots can report success on an answer that is still wrong, and LoopGain will confidently stop there. This is not the plateau case above: the error reads zero and the loop looks like a clean success, so neither LoopGain nor its convergence signal can flag it. The quality of the stop is bounded by the quality of the check behind your error signal. Pair LoopGain with the strongest verifier you can afford at the stop — executable tests over a sampled subset, a schema or type check over a vibe, a held-out check the loop didn't optimize against.
186
187
 
187
188
  ---
188
189
 
@@ -660,3 +660,117 @@ def test_send_payload_refuses_redirects():
660
660
  req = urllib.request.Request("https://example.com/")
661
661
  with pytest.raises(urllib.error.HTTPError):
662
662
  method(req, io.BytesIO(b""), 302, "Found", {})
663
+
664
+
665
+ # ----- send_payload retry behavior (transient failures) -----
666
+
667
+ import socket as _socket
668
+ import urllib.error as _uerr
669
+
670
+ from loopgain import telemetry as _tele
671
+
672
+
673
+ class _OkResp:
674
+ status = 202
675
+
676
+ def __enter__(self):
677
+ return self
678
+
679
+ def __exit__(self, *args):
680
+ pass
681
+
682
+
683
+ def _retry_payload():
684
+ return build_payload(_make_terminated_loop(), workload_id="retry-test")
685
+
686
+
687
+ def test_send_payload_retries_transient_then_succeeds(monkeypatch):
688
+ """A transient failure (timeout) is retried; a later success returns True."""
689
+ calls = {"n": 0}
690
+
691
+ def flaky(req, timeout=None):
692
+ calls["n"] += 1
693
+ if calls["n"] < 3:
694
+ raise _socket.timeout("slow first attempts")
695
+ return _OkResp()
696
+
697
+ sleeps: list[float] = []
698
+ monkeypatch.setattr("loopgain.telemetry._open_request", flaky)
699
+ monkeypatch.setattr("loopgain.telemetry.time.sleep", lambda s: sleeps.append(s))
700
+
701
+ ok = send_payload("https://t.example/v1/aggregate", token="t", payload=_retry_payload())
702
+ assert ok is True
703
+ assert calls["n"] == 3 # two transient failures, third succeeds
704
+ assert sleeps == [0.25, 0.5] # linear backoff between attempts
705
+
706
+
707
+ def test_send_payload_gives_up_after_retries_on_persistent_5xx(monkeypatch):
708
+ """A persistent transient (503) exhausts retries and returns False."""
709
+ calls = {"n": 0}
710
+
711
+ def always_503(req, timeout=None):
712
+ calls["n"] += 1
713
+ raise _uerr.HTTPError("https://t.example", 503, "unavailable", {}, None)
714
+
715
+ monkeypatch.setattr("loopgain.telemetry._open_request", always_503)
716
+ monkeypatch.setattr("loopgain.telemetry.time.sleep", lambda s: None)
717
+
718
+ ok = send_payload("https://t.example/v1/aggregate", token="t", payload=_retry_payload(), retries=2)
719
+ assert ok is False
720
+ assert calls["n"] == 3 # 1 initial + 2 retries
721
+
722
+
723
+ def test_send_payload_does_not_retry_deterministic_4xx(monkeypatch):
724
+ """A 401 will never succeed on retry — fail fast, no backoff."""
725
+ calls = {"n": 0}
726
+ slept = {"n": 0}
727
+
728
+ def unauthorized(req, timeout=None):
729
+ calls["n"] += 1
730
+ raise _uerr.HTTPError("https://t.example", 401, "unauthorized", {}, None)
731
+
732
+ monkeypatch.setattr("loopgain.telemetry._open_request", unauthorized)
733
+ monkeypatch.setattr("loopgain.telemetry.time.sleep", lambda s: slept.__setitem__("n", slept["n"] + 1))
734
+
735
+ ok = send_payload("https://t.example/v1/aggregate", token="bad", payload=_retry_payload())
736
+ assert ok is False
737
+ assert calls["n"] == 1 # no retry on a deterministic 4xx
738
+ assert slept["n"] == 0
739
+
740
+
741
+ def test_send_payload_retries_zero_is_single_shot(monkeypatch):
742
+ """retries=0 restores the original single-attempt behavior."""
743
+ calls = {"n": 0}
744
+
745
+ def timeout(req, timeout=None):
746
+ calls["n"] += 1
747
+ raise TimeoutError()
748
+
749
+ monkeypatch.setattr("loopgain.telemetry._open_request", timeout)
750
+ monkeypatch.setattr("loopgain.telemetry.time.sleep", lambda s: None)
751
+
752
+ ok = send_payload("https://t.example/v1/aggregate", token="t", payload=_retry_payload(), retries=0)
753
+ assert ok is False
754
+ assert calls["n"] == 1
755
+
756
+
757
+ def test_send_payload_never_raises_on_unexpected_error(monkeypatch):
758
+ """A non-transient, unexpected error is swallowed (best-effort), no retry."""
759
+ def boom(req, timeout=None):
760
+ raise RuntimeError("unexpected")
761
+
762
+ monkeypatch.setattr("loopgain.telemetry._open_request", boom)
763
+ monkeypatch.setattr("loopgain.telemetry.time.sleep", lambda s: None)
764
+
765
+ assert send_payload("https://t.example/v1/aggregate", token="t", payload=_retry_payload()) is False
766
+
767
+
768
+ def test_is_transient_classification():
769
+ assert _tele._is_transient(TimeoutError()) is True
770
+ assert _tele._is_transient(_socket.timeout()) is True
771
+ assert _tele._is_transient(_uerr.URLError("dns")) is True
772
+ assert _tele._is_transient(_uerr.HTTPError("u", 503, "x", {}, None)) is True
773
+ assert _tele._is_transient(_uerr.HTTPError("u", 429, "x", {}, None)) is True
774
+ assert _tele._is_transient(_uerr.HTTPError("u", 400, "x", {}, None)) is False
775
+ assert _tele._is_transient(_uerr.HTTPError("u", 401, "x", {}, None)) is False
776
+ assert _tele._is_transient(RuntimeError("x")) is False
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes