backloop-sdk 0.1.2__tar.gz → 0.1.4__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.4}/PKG-INFO +3 -2
  2. {backloop_sdk-0.1.2 → backloop_sdk-0.1.4}/README.md +2 -1
  3. {backloop_sdk-0.1.2 → backloop_sdk-0.1.4}/pyproject.toml +1 -1
  4. {backloop_sdk-0.1.2 → backloop_sdk-0.1.4}/src/backloop/__init__.py +1 -1
  5. {backloop_sdk-0.1.2 → backloop_sdk-0.1.4}/src/backloop/_generated.py +1 -1
  6. {backloop_sdk-0.1.2 → backloop_sdk-0.1.4}/src/backloop/forward.py +69 -2
  7. {backloop_sdk-0.1.2 → backloop_sdk-0.1.4}/tests/test_forward.py +47 -0
  8. {backloop_sdk-0.1.2 → backloop_sdk-0.1.4}/.gitignore +0 -0
  9. {backloop_sdk-0.1.2 → backloop_sdk-0.1.4}/LICENSE +0 -0
  10. {backloop_sdk-0.1.2 → backloop_sdk-0.1.4}/src/backloop/_http.py +0 -0
  11. {backloop_sdk-0.1.2 → backloop_sdk-0.1.4}/src/backloop/client.py +0 -0
  12. {backloop_sdk-0.1.2 → backloop_sdk-0.1.4}/src/backloop/ids.py +0 -0
  13. {backloop_sdk-0.1.2 → backloop_sdk-0.1.4}/src/backloop/integrations.py +0 -0
  14. {backloop_sdk-0.1.2 → backloop_sdk-0.1.4}/src/backloop/py.typed +0 -0
  15. {backloop_sdk-0.1.2 → backloop_sdk-0.1.4}/src/backloop/redact.py +0 -0
  16. {backloop_sdk-0.1.2 → backloop_sdk-0.1.4}/src/backloop/server.py +0 -0
  17. {backloop_sdk-0.1.2 → backloop_sdk-0.1.4}/src/backloop/tool.py +0 -0
  18. {backloop_sdk-0.1.2 → backloop_sdk-0.1.4}/src/backloop/types.py +0 -0
  19. {backloop_sdk-0.1.2 → backloop_sdk-0.1.4}/src/backloop/validate.py +0 -0
  20. {backloop_sdk-0.1.2 → backloop_sdk-0.1.4}/tests/__init__.py +0 -0
  21. {backloop_sdk-0.1.2 → backloop_sdk-0.1.4}/tests/helpers.py +0 -0
  22. {backloop_sdk-0.1.2 → backloop_sdk-0.1.4}/tests/test_client.py +0 -0
  23. {backloop_sdk-0.1.2 → backloop_sdk-0.1.4}/tests/test_ids.py +0 -0
  24. {backloop_sdk-0.1.2 → backloop_sdk-0.1.4}/tests/test_integrations.py +0 -0
  25. {backloop_sdk-0.1.2 → backloop_sdk-0.1.4}/tests/test_redact.py +0 -0
  26. {backloop_sdk-0.1.2 → backloop_sdk-0.1.4}/tests/test_server.py +0 -0
  27. {backloop_sdk-0.1.2 → backloop_sdk-0.1.4}/tests/test_tool.py +0 -0
  28. {backloop_sdk-0.1.2 → backloop_sdk-0.1.4}/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.4
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.4"
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.4"
48
48
 
49
49
  __all__ = [
50
50
  "ACK_SCHEMA",
@@ -7,6 +7,6 @@ RECORD_SCHEMA = json.loads("{\"$schema\":\"https://json-schema.org/draft/2020-12
7
7
 
8
8
  ACK_SCHEMA = json.loads("{\"$schema\":\"https://json-schema.org/draft/2020-12/schema\",\"$id\":\"https://trybackloop.com/schemas/agent-feedback/0.1/ack.schema.json\",\"title\":\"Agent Feedback Acknowledgement\",\"description\":\"Body of a 202 response to POST /feedback.\",\"type\":\"object\",\"required\":[\"id\",\"status\",\"received_at\"],\"properties\":{\"id\":{\"type\":\"string\"},\"status\":{\"type\":\"string\",\"const\":\"accepted\"},\"received_at\":{\"type\":\"string\",\"format\":\"date-time\"},\"known_issue\":{\"description\":\"Present when the service already tracks this problem. Agents can use it to stop retrying or to apply a known workaround.\",\"type\":\"object\",\"required\":[\"id\",\"title\",\"status\"],\"properties\":{\"id\":{\"type\":\"string\"},\"title\":{\"type\":\"string\"},\"status\":{\"type\":\"string\",\"enum\":[\"acknowledged\",\"planned\",\"in_progress\",\"fixed\",\"wont_fix\"]},\"url\":{\"type\":\"string\"},\"workaround\":{\"type\":\"string\"}}}}}")
9
9
 
10
- AGENT_FEEDBACK_INSTRUCTIONS = "## Reporting feedback to the services you use\n\nThe services you call accept structured feedback through `POST /feedback` (or the `submit_feedback` tool). Your reports go to the engineers who build the service and are used to fix what got in your way. Use it.\n\n**Submit feedback when something prevented or complicated your task:**\n- a capability you needed does not exist (a filter, field, endpoint or bulk operation);\n- the documentation was missing, ambiguous or wrong, and you had to guess;\n- a response was surprising or inconsistent with the docs;\n- an error message did not tell you how to fix the request;\n- the service behaved incorrectly;\n- latency, rate limits or pagination made the task impractical.\n\n**Don't submit feedback** for your own mistakes that the service reported clearly, for things you have not tried, or more than once for the same problem in the same task.\n\n**Write it like this:**\n- `goal`: what you or your user were trying to accomplish, in plain language. \"Find companies currently hiring GTM engineers\", not \"call /companies/search\".\n- `message`: what got in the way, specifically. \"No hiring-role filter is available; `q` only matches company names.\"\n- `type`: `missing_capability`, `bug`, `unclear_documentation`, `unexpected_response`, `unhelpful_error`, `performance` or `other`.\n- Fill `endpoint`, `outcome` (`blocked`, `degraded` or `completed`), `workaround`, `expected` and `suggestion` when you know them. A concrete suggestion is the most useful thing you can give.\n- Reuse the same `session_id` for every report in one task.\n\n**Never include** credentials, API keys, tokens, passwords, personal data, or your user's private content. Describe the shape of a request, not its secrets.\n\n**Keep going.** Submitting feedback is best effort. Send it, then continue with the task or a workaround. If the response contains `known_issue`, the problem is already tracked: tell your user if it matters, and use the workaround if one is given."
10
+ AGENT_FEEDBACK_INSTRUCTIONS = "## Reporting feedback to the services you use\n\nThe services you call accept structured feedback through `POST /feedback` (or the `submit_feedback` tool). Your reports go to the engineers who build the service and are used to fix what got in your way. Use it.\n\n**Submit feedback when something prevented or complicated your task, even if you found a workaround:**\n- a capability you needed does not exist (a filter, field, endpoint or bulk operation), so you made extra calls, filtered results yourself, or gave up;\n- the documentation was missing, ambiguous or wrong, and you had to guess;\n- a response was surprising or inconsistent with the docs;\n- an error message did not tell you how to fix the request;\n- the service behaved incorrectly;\n- latency, rate limits or pagination made the task impractical.\n\n**Before your final answer,** check whether any of these happened. If one did and you have not reported it, report it now, then answer.\n\n**Don't submit feedback** for your own mistakes that the service reported clearly, for things you have not tried, or more than once for the same problem in the same task.\n\n**Write it like this:**\n- `goal`: what you or your user were trying to accomplish, in plain language. \"Find companies currently hiring GTM engineers\", not \"call /companies/search\".\n- `message`: what got in the way, specifically. \"No hiring-role filter is available; `q` only matches company names.\"\n- `type`: `missing_capability`, `bug`, `unclear_documentation`, `unexpected_response`, `unhelpful_error`, `performance` or `other`.\n- Fill `endpoint`, `outcome` (`blocked`, `degraded` or `completed`), `workaround`, `expected` and `suggestion` when you know them. A concrete suggestion is the most useful thing you can give.\n- Reuse the same `session_id` for every report in one task.\n\n**Never include** credentials, API keys, tokens, passwords, personal data, or your user's private content. Describe the shape of a request, not its secrets.\n\n**Keep going.** Submitting feedback is best effort. Send it, then continue with the task or a workaround. If the response contains `known_issue`, the problem is already tracked: tell your user if it matters, and use the workaround if one is given."
11
11
 
12
12
  FEEDBACK_TOOL_DESCRIPTION = "Report a problem with this service to its engineering team. Call this when something prevented or complicated your task: a missing capability (filter, field, endpoint), unclear or wrong documentation, a surprising response, an unhelpful error, a bug, or impractical performance. Describe your goal in plain language, say specifically what got in the way, and include a concrete suggestion if you have one. Report each distinct problem once per task. Never include credentials, personal data or your user's private content. This is best effort: after calling it, continue with your task."
@@ -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