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.
- {backloop_sdk-0.1.1 → backloop_sdk-0.1.3}/PKG-INFO +3 -2
- {backloop_sdk-0.1.1 → backloop_sdk-0.1.3}/README.md +2 -1
- {backloop_sdk-0.1.1 → backloop_sdk-0.1.3}/pyproject.toml +1 -1
- {backloop_sdk-0.1.1 → backloop_sdk-0.1.3}/src/backloop/__init__.py +1 -1
- {backloop_sdk-0.1.1 → backloop_sdk-0.1.3}/src/backloop/forward.py +69 -2
- {backloop_sdk-0.1.1 → backloop_sdk-0.1.3}/src/backloop/server.py +8 -0
- {backloop_sdk-0.1.1 → backloop_sdk-0.1.3}/tests/test_forward.py +47 -0
- {backloop_sdk-0.1.1 → backloop_sdk-0.1.3}/tests/test_server.py +13 -0
- {backloop_sdk-0.1.1 → backloop_sdk-0.1.3}/.gitignore +0 -0
- {backloop_sdk-0.1.1 → backloop_sdk-0.1.3}/LICENSE +0 -0
- {backloop_sdk-0.1.1 → backloop_sdk-0.1.3}/src/backloop/_generated.py +0 -0
- {backloop_sdk-0.1.1 → backloop_sdk-0.1.3}/src/backloop/_http.py +0 -0
- {backloop_sdk-0.1.1 → backloop_sdk-0.1.3}/src/backloop/client.py +0 -0
- {backloop_sdk-0.1.1 → backloop_sdk-0.1.3}/src/backloop/ids.py +0 -0
- {backloop_sdk-0.1.1 → backloop_sdk-0.1.3}/src/backloop/integrations.py +0 -0
- {backloop_sdk-0.1.1 → backloop_sdk-0.1.3}/src/backloop/py.typed +0 -0
- {backloop_sdk-0.1.1 → backloop_sdk-0.1.3}/src/backloop/redact.py +0 -0
- {backloop_sdk-0.1.1 → backloop_sdk-0.1.3}/src/backloop/tool.py +0 -0
- {backloop_sdk-0.1.1 → backloop_sdk-0.1.3}/src/backloop/types.py +0 -0
- {backloop_sdk-0.1.1 → backloop_sdk-0.1.3}/src/backloop/validate.py +0 -0
- {backloop_sdk-0.1.1 → backloop_sdk-0.1.3}/tests/__init__.py +0 -0
- {backloop_sdk-0.1.1 → backloop_sdk-0.1.3}/tests/helpers.py +0 -0
- {backloop_sdk-0.1.1 → backloop_sdk-0.1.3}/tests/test_client.py +0 -0
- {backloop_sdk-0.1.1 → backloop_sdk-0.1.3}/tests/test_ids.py +0 -0
- {backloop_sdk-0.1.1 → backloop_sdk-0.1.3}/tests/test_integrations.py +0 -0
- {backloop_sdk-0.1.1 → backloop_sdk-0.1.3}/tests/test_redact.py +0 -0
- {backloop_sdk-0.1.1 → backloop_sdk-0.1.3}/tests/test_tool.py +0 -0
- {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.
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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"
|
|
@@ -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(
|
|
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
|
-
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|