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.
Files changed (28) hide show
  1. {backloop_sdk-0.1.2 → backloop_sdk-0.1.3}/PKG-INFO +3 -2
  2. {backloop_sdk-0.1.2 → backloop_sdk-0.1.3}/README.md +2 -1
  3. {backloop_sdk-0.1.2 → backloop_sdk-0.1.3}/pyproject.toml +1 -1
  4. {backloop_sdk-0.1.2 → backloop_sdk-0.1.3}/src/backloop/__init__.py +1 -1
  5. {backloop_sdk-0.1.2 → backloop_sdk-0.1.3}/src/backloop/forward.py +69 -2
  6. {backloop_sdk-0.1.2 → backloop_sdk-0.1.3}/tests/test_forward.py +47 -0
  7. {backloop_sdk-0.1.2 → backloop_sdk-0.1.3}/.gitignore +0 -0
  8. {backloop_sdk-0.1.2 → backloop_sdk-0.1.3}/LICENSE +0 -0
  9. {backloop_sdk-0.1.2 → backloop_sdk-0.1.3}/src/backloop/_generated.py +0 -0
  10. {backloop_sdk-0.1.2 → backloop_sdk-0.1.3}/src/backloop/_http.py +0 -0
  11. {backloop_sdk-0.1.2 → backloop_sdk-0.1.3}/src/backloop/client.py +0 -0
  12. {backloop_sdk-0.1.2 → backloop_sdk-0.1.3}/src/backloop/ids.py +0 -0
  13. {backloop_sdk-0.1.2 → backloop_sdk-0.1.3}/src/backloop/integrations.py +0 -0
  14. {backloop_sdk-0.1.2 → backloop_sdk-0.1.3}/src/backloop/py.typed +0 -0
  15. {backloop_sdk-0.1.2 → backloop_sdk-0.1.3}/src/backloop/redact.py +0 -0
  16. {backloop_sdk-0.1.2 → backloop_sdk-0.1.3}/src/backloop/server.py +0 -0
  17. {backloop_sdk-0.1.2 → backloop_sdk-0.1.3}/src/backloop/tool.py +0 -0
  18. {backloop_sdk-0.1.2 → backloop_sdk-0.1.3}/src/backloop/types.py +0 -0
  19. {backloop_sdk-0.1.2 → backloop_sdk-0.1.3}/src/backloop/validate.py +0 -0
  20. {backloop_sdk-0.1.2 → backloop_sdk-0.1.3}/tests/__init__.py +0 -0
  21. {backloop_sdk-0.1.2 → backloop_sdk-0.1.3}/tests/helpers.py +0 -0
  22. {backloop_sdk-0.1.2 → backloop_sdk-0.1.3}/tests/test_client.py +0 -0
  23. {backloop_sdk-0.1.2 → backloop_sdk-0.1.3}/tests/test_ids.py +0 -0
  24. {backloop_sdk-0.1.2 → backloop_sdk-0.1.3}/tests/test_integrations.py +0 -0
  25. {backloop_sdk-0.1.2 → backloop_sdk-0.1.3}/tests/test_redact.py +0 -0
  26. {backloop_sdk-0.1.2 → backloop_sdk-0.1.3}/tests/test_server.py +0 -0
  27. {backloop_sdk-0.1.2 → backloop_sdk-0.1.3}/tests/test_tool.py +0 -0
  28. {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.2
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.2"
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.2"
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:
@@ -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