backloop-sdk 0.1.1__tar.gz → 0.1.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 (28) hide show
  1. {backloop_sdk-0.1.1 → backloop_sdk-0.1.3}/PKG-INFO +3 -2
  2. {backloop_sdk-0.1.1 → backloop_sdk-0.1.3}/README.md +2 -1
  3. {backloop_sdk-0.1.1 → backloop_sdk-0.1.3}/pyproject.toml +1 -1
  4. {backloop_sdk-0.1.1 → backloop_sdk-0.1.3}/src/backloop/__init__.py +1 -1
  5. {backloop_sdk-0.1.1 → backloop_sdk-0.1.3}/src/backloop/forward.py +69 -2
  6. {backloop_sdk-0.1.1 → backloop_sdk-0.1.3}/src/backloop/server.py +8 -0
  7. {backloop_sdk-0.1.1 → backloop_sdk-0.1.3}/tests/test_forward.py +47 -0
  8. {backloop_sdk-0.1.1 → backloop_sdk-0.1.3}/tests/test_server.py +13 -0
  9. {backloop_sdk-0.1.1 → backloop_sdk-0.1.3}/.gitignore +0 -0
  10. {backloop_sdk-0.1.1 → backloop_sdk-0.1.3}/LICENSE +0 -0
  11. {backloop_sdk-0.1.1 → backloop_sdk-0.1.3}/src/backloop/_generated.py +0 -0
  12. {backloop_sdk-0.1.1 → backloop_sdk-0.1.3}/src/backloop/_http.py +0 -0
  13. {backloop_sdk-0.1.1 → backloop_sdk-0.1.3}/src/backloop/client.py +0 -0
  14. {backloop_sdk-0.1.1 → backloop_sdk-0.1.3}/src/backloop/ids.py +0 -0
  15. {backloop_sdk-0.1.1 → backloop_sdk-0.1.3}/src/backloop/integrations.py +0 -0
  16. {backloop_sdk-0.1.1 → backloop_sdk-0.1.3}/src/backloop/py.typed +0 -0
  17. {backloop_sdk-0.1.1 → backloop_sdk-0.1.3}/src/backloop/redact.py +0 -0
  18. {backloop_sdk-0.1.1 → backloop_sdk-0.1.3}/src/backloop/tool.py +0 -0
  19. {backloop_sdk-0.1.1 → backloop_sdk-0.1.3}/src/backloop/types.py +0 -0
  20. {backloop_sdk-0.1.1 → backloop_sdk-0.1.3}/src/backloop/validate.py +0 -0
  21. {backloop_sdk-0.1.1 → backloop_sdk-0.1.3}/tests/__init__.py +0 -0
  22. {backloop_sdk-0.1.1 → backloop_sdk-0.1.3}/tests/helpers.py +0 -0
  23. {backloop_sdk-0.1.1 → backloop_sdk-0.1.3}/tests/test_client.py +0 -0
  24. {backloop_sdk-0.1.1 → backloop_sdk-0.1.3}/tests/test_ids.py +0 -0
  25. {backloop_sdk-0.1.1 → backloop_sdk-0.1.3}/tests/test_integrations.py +0 -0
  26. {backloop_sdk-0.1.1 → backloop_sdk-0.1.3}/tests/test_redact.py +0 -0
  27. {backloop_sdk-0.1.1 → backloop_sdk-0.1.3}/tests/test_tool.py +0 -0
  28. {backloop_sdk-0.1.1 → backloop_sdk-0.1.3}/tests/test_validate.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: backloop-sdk
3
- Version: 0.1.1
3
+ Version: 0.1.3
4
4
  Summary: Python SDK for the Agent Feedback Protocol: validate, send and receive structured feedback from AI agents.
5
5
  Project-URL: Homepage, https://trybackloop.com
6
6
  Project-URL: Specification, https://trybackloop.com/spec/
@@ -72,7 +72,8 @@ tools = [feedback_tool()] # {"name": "submit_feedback", "description", "input_s
72
72
  from backloop import FeedbackHandler, forward_to, hash_account
73
73
 
74
74
  handler = FeedbackHandler(
75
- on_record=forward_to("https://collector.example.com", ingest_key=INGEST_KEY), # or your own callable
75
+ # wait: answer the agent after at most 0.5 s and go on forwarding in a background thread.
76
+ on_record=forward_to("https://collector.example.com", ingest_key=INGEST_KEY, wait=0.5), # or your own callable
76
77
  service="acme-companies-api",
77
78
  identify=lambda request: hash_account(current_account_id(request), SECRET) if authenticated(request) else None,
78
79
  )
@@ -48,7 +48,8 @@ tools = [feedback_tool()] # {"name": "submit_feedback", "description", "input_s
48
48
  from backloop import FeedbackHandler, forward_to, hash_account
49
49
 
50
50
  handler = FeedbackHandler(
51
- on_record=forward_to("https://collector.example.com", ingest_key=INGEST_KEY), # or your own callable
51
+ # wait: answer the agent after at most 0.5 s and go on forwarding in a background thread.
52
+ on_record=forward_to("https://collector.example.com", ingest_key=INGEST_KEY, wait=0.5), # or your own callable
52
53
  service="acme-companies-api",
53
54
  identify=lambda request: hash_account(current_account_id(request), SECRET) if authenticated(request) else None,
54
55
  )
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "backloop-sdk"
7
- version = "0.1.1"
7
+ version = "0.1.3"
8
8
  description = "Python SDK for the Agent Feedback Protocol: validate, send and receive structured feedback from AI agents."
9
9
  readme = "README.md"
10
10
  license = "Apache-2.0"
@@ -44,7 +44,7 @@ from .validate import (
44
44
  validate_submission,
45
45
  )
46
46
 
47
- __version__ = "0.1.1"
47
+ __version__ = "0.1.3"
48
48
 
49
49
  __all__ = [
50
50
  "ACK_SCHEMA",
@@ -2,6 +2,9 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import logging
6
+ import threading
7
+ from concurrent.futures import ThreadPoolExecutor, TimeoutError as _Timeout
5
8
  from typing import Callable, List, Optional
6
9
 
7
10
  from . import _http
@@ -9,6 +12,14 @@ from .server import OnRecord, OnRecordResult
9
12
  from .types import FeedbackRecord
10
13
 
11
14
 
15
+ logger = logging.getLogger("backloop")
16
+
17
+ # Forwards that outlive their request. Few threads: they wait on the network, and a
18
+ # collector that is down must not take the service's threads with it.
19
+ _BACKGROUND_THREADS = 4
20
+ _BACKGROUND_LIMIT = 200
21
+
22
+
12
23
  class ForwardError(Exception):
13
24
  """The collector could not be reached, returned an error, or rejected the record."""
14
25
 
@@ -17,13 +28,26 @@ class ForwardError(Exception):
17
28
  self.status = status
18
29
 
19
30
 
20
- def forward_to(url: str, ingest_key: Optional[str] = None, timeout: float = 5.0) -> OnRecord:
31
+ def forward_to(
32
+ url: str,
33
+ ingest_key: Optional[str] = None,
34
+ timeout: float = 5.0,
35
+ wait: Optional[float] = None,
36
+ on_error: Optional[Callable[[Exception, FeedbackRecord], None]] = None,
37
+ ) -> OnRecord:
21
38
  """
22
39
  An ``on_record`` sink that forwards records to a collector's
23
40
  ``POST /v1/records`` and passes any ``known_issue`` back to the agent.
24
41
 
25
42
  Retries once on network errors, 429 and 5xx; raises ``ForwardError`` on
26
43
  failure, which the handler turns into ``503 unavailable``.
44
+
45
+ ``wait``: how long the agent's request waits for the collector's answer, in
46
+ seconds. None: until it answers (or fails). Set: at most that long, then
47
+ forwarding goes on in a background thread, so a slow collector never holds
48
+ a worker. An answer that came in time still carries the known issue back.
49
+ ``on_error`` is told about a forward that failed after the request was
50
+ answered (default: logged).
27
51
  """
28
52
  endpoint = url.rstrip("/") + "/v1/records"
29
53
  headers = {"content-type": "application/json"}
@@ -46,7 +70,50 @@ def forward_to(url: str, ingest_key: Optional[str] = None, timeout: float = 5.0)
46
70
  break
47
71
  raise last_error
48
72
 
49
- return sink
73
+ if wait is None:
74
+ return sink
75
+
76
+ pool = ThreadPoolExecutor(max_workers=_BACKGROUND_THREADS, thread_name_prefix="backloop-forward")
77
+ pending = threading.BoundedSemaphore(_BACKGROUND_LIMIT)
78
+
79
+ def late(error: Exception, record: FeedbackRecord) -> None:
80
+ if on_error:
81
+ on_error(error, record)
82
+ else:
83
+ logger.error("[backloop] feedback record %s not forwarded: %s", record.get("id"), error)
84
+
85
+ def background(record: FeedbackRecord) -> OnRecordResult:
86
+ # More waiting than a collector that is down can take: this one is dropped, and said so.
87
+ if not pending.acquire(blocking=False):
88
+ late(ForwardError(f"Too many records waiting for {endpoint}"), record)
89
+ return None
90
+ answered = threading.Event()
91
+ lock = threading.Lock()
92
+
93
+ def work() -> OnRecordResult:
94
+ try:
95
+ return sink(record)
96
+ except Exception as e:
97
+ with lock:
98
+ if not answered.is_set():
99
+ raise
100
+ late(e, record)
101
+ return None
102
+ finally:
103
+ pending.release()
104
+
105
+ future = pool.submit(work)
106
+ try:
107
+ return future.result(timeout=wait)
108
+ except _Timeout:
109
+ with lock:
110
+ answered.set()
111
+ # It failed between the timeout and the lock: nobody was told yet.
112
+ if future.done() and future.exception() is not None:
113
+ late(future.exception(), record) # type: ignore[arg-type]
114
+ return None
115
+
116
+ return background
50
117
 
51
118
 
52
119
  def _result(raw: bytes) -> OnRecordResult:
@@ -12,6 +12,7 @@ from datetime import datetime, timezone
12
12
  from typing import Any, Callable, Dict, List, Mapping, Optional, Tuple, Union
13
13
 
14
14
  from .ids import new_id
15
+ from .redact import redact as _redact
15
16
  from .types import (
16
17
  DEFAULT_MAX_BYTES,
17
18
  FEEDBACK_TYPES,
@@ -93,6 +94,9 @@ class FeedbackHandler:
93
94
  account behind a framework request; used by the integrations.
94
95
  - ``rate_limit`` is ``(max, window_seconds)`` per account (or per IP), or
95
96
  None to disable it.
97
+ - ``redact`` removes secrets, card numbers and email addresses from what
98
+ agents send, before ``on_record`` sees it. True (default), False, or a
99
+ dict of ``redact()`` options. Not every agent uses a client that does.
96
100
  """
97
101
 
98
102
  def __init__(
@@ -106,6 +110,7 @@ class FeedbackHandler:
106
110
  public_endpoint: Optional[str] = None,
107
111
  rate_limit: Optional[Tuple[int, float]] = (60, 60.0),
108
112
  source: FeedbackSource = "http",
113
+ redact: Union[bool, Mapping[str, Any]] = True,
109
114
  generate_id: Optional[Callable[[], str]] = None,
110
115
  now: Optional[Callable[[], datetime]] = None,
111
116
  ) -> None:
@@ -118,6 +123,7 @@ class FeedbackHandler:
118
123
  self.max_bytes = max_bytes
119
124
  self.public_endpoint = public_endpoint
120
125
  self.source = source
126
+ self.redact = redact
121
127
  self._limiter = _RateLimiter(*rate_limit) if rate_limit else None
122
128
  self._generate_id = generate_id or (lambda: new_id("fb"))
123
129
  self._now = now or (lambda: datetime.now(timezone.utc))
@@ -163,6 +169,8 @@ class FeedbackHandler:
163
169
  if account:
164
170
  record["account"] = account
165
171
  record["source"] = self.source or "http"
172
+ if self.redact is not False:
173
+ parsed = _redact(parsed, **(self.redact if isinstance(self.redact, Mapping) else {}))
166
174
  record["feedback"] = parsed
167
175
 
168
176
  try:
@@ -1,4 +1,6 @@
1
1
  import json
2
+ import threading
3
+ import time
2
4
  import unittest
3
5
 
4
6
  from backloop import FeedbackHandler, ForwardError, fan_out, forward_to, memory_sink
@@ -68,6 +70,51 @@ class ForwardToTest(unittest.TestCase):
68
70
  forward_to(refused_url(), timeout=2)(RECORD)
69
71
 
70
72
 
73
+ class BackgroundForwardTest(unittest.TestCase):
74
+ def test_answers_without_waiting_for_a_slow_collector_and_still_forwards(self):
75
+ release = threading.Event()
76
+
77
+ def slow(_request):
78
+ release.wait(5)
79
+ return (200, DUPLICATE, {})
80
+
81
+ with LocalServer(slow) as collector:
82
+ started = time.monotonic()
83
+ result = forward_to(collector.url, wait=0.05)(RECORD)
84
+ self.assertIsNone(result)
85
+ self.assertLess(time.monotonic() - started, 1.0)
86
+ release.set()
87
+ deadline = time.monotonic() + 5
88
+ while not collector.requests and time.monotonic() < deadline:
89
+ time.sleep(0.01)
90
+ self.assertEqual(json.loads(collector.requests[0]["body"]), {"records": [RECORD]})
91
+
92
+ def test_a_quick_answer_still_reaches_the_agent(self):
93
+ with LocalServer((200, DUPLICATE, {})) as collector:
94
+ self.assertEqual(forward_to(collector.url, wait=2)(RECORD), {"known_issue": KNOWN_ISSUE})
95
+
96
+ def test_a_quick_failure_is_raised(self):
97
+ with self.assertRaises(ForwardError):
98
+ forward_to(refused_url(), wait=5)(RECORD)
99
+
100
+ def test_a_late_failure_is_reported_not_raised(self):
101
+ failed = []
102
+ done = threading.Event()
103
+
104
+ def late(_request):
105
+ time.sleep(0.2)
106
+ return (400, {"error": {"message": "no"}}, {})
107
+
108
+ def on_error(error, record):
109
+ failed.append((type(error).__name__, record["id"]))
110
+ done.set()
111
+
112
+ with LocalServer(late) as collector:
113
+ self.assertIsNone(forward_to(collector.url, wait=0.02, on_error=on_error)(RECORD))
114
+ self.assertTrue(done.wait(5))
115
+ self.assertEqual(failed, [("ForwardError", "fb_1")])
116
+
117
+
71
118
  class HandlerForwardingTest(unittest.TestCase):
72
119
  BODY = json.dumps({"type": "bug", "goal": "g", "message": "m"})
73
120
 
@@ -51,6 +51,19 @@ class HappyPathTest(unittest.TestCase):
51
51
  [{"id": "fb_test", "received_at": "2026-09-28T10:04:11.123Z", "source": "http", "feedback": SUBMISSION}],
52
52
  )
53
53
 
54
+ def test_removes_secrets_and_emails_whatever_client_sent_them(self):
55
+ leaky = dict(SUBMISSION, message="401 with Bearer 9f8e7d6c5b4a3f2e for jane@acme.test", metadata={"password": "hunter2", "plan": "team"})
56
+ handler, sink = make()
57
+ status, _, _ = handler.handle_submit(json.dumps(leaky).encode(), JSON)
58
+ self.assertEqual(status, 202)
59
+ feedback = sink.records[0]["feedback"]
60
+ self.assertEqual(feedback["message"], "401 with [REDACTED] for [REDACTED]")
61
+ self.assertEqual(feedback["metadata"], {"password": "[REDACTED]", "plan": "team"})
62
+ # Asked not to: as sent.
63
+ handler, sink = make(redact=False)
64
+ handler.handle_submit(json.dumps(leaky).encode(), JSON)
65
+ self.assertEqual(sink.records[0]["feedback"]["message"], leaky["message"])
66
+
54
67
  def test_record_carries_service_account_and_source(self):
55
68
  handler, sink = make(service="acme-api", source="mcp")
56
69
  handler.handle_submit(BODY, JSON, "acct_abc")
File without changes
File without changes