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