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