backloop-sdk 0.1.0__py3-none-any.whl

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/__init__.py ADDED
@@ -0,0 +1,98 @@
1
+ """Python SDK for the Agent Feedback Protocol: validate, send and receive structured feedback from AI agents."""
2
+
3
+ from ._generated import (
4
+ ACK_SCHEMA,
5
+ AGENT_FEEDBACK_INSTRUCTIONS,
6
+ FEEDBACK_SCHEMA,
7
+ FEEDBACK_TOOL_DESCRIPTION,
8
+ RECORD_SCHEMA,
9
+ )
10
+ from .client import FeedbackClient, FeedbackError, SubmitResult, feedback_url_from_link
11
+ from .forward import ForwardError, MemorySink, fan_out, forward_to, memory_sink
12
+ from .ids import hash_account, new_id
13
+ from .integrations import fastapi_router, flask_blueprint
14
+ from .redact import REDACTED, redact, redact_string
15
+ from .server import (
16
+ FeedbackHandler,
17
+ HandlerResponse,
18
+ OnRecordResult,
19
+ error_response,
20
+ feedback_link_header,
21
+ )
22
+ from .tool import FEEDBACK_TOOL_NAME, feedback_tool, feedback_tool_input_schema
23
+ from .types import (
24
+ DEFAULT_MAX_BYTES,
25
+ FEEDBACK_TYPES,
26
+ LINK_REL,
27
+ OUTCOMES,
28
+ SPEC_VERSION,
29
+ WELL_KNOWN_PATH,
30
+ AgentInfo,
31
+ DiscoveryDocument,
32
+ Evidence,
33
+ FeedbackAck,
34
+ FeedbackRecord,
35
+ FeedbackSubmission,
36
+ KnownIssue,
37
+ ValidationIssue,
38
+ )
39
+ from .validate import (
40
+ ValidationResult,
41
+ format_issues,
42
+ validate_ack,
43
+ validate_record,
44
+ validate_submission,
45
+ )
46
+
47
+ __version__ = "0.1.0"
48
+
49
+ __all__ = [
50
+ "ACK_SCHEMA",
51
+ "AGENT_FEEDBACK_INSTRUCTIONS",
52
+ "DEFAULT_MAX_BYTES",
53
+ "FEEDBACK_SCHEMA",
54
+ "FEEDBACK_TOOL_DESCRIPTION",
55
+ "FEEDBACK_TOOL_NAME",
56
+ "FEEDBACK_TYPES",
57
+ "LINK_REL",
58
+ "OUTCOMES",
59
+ "RECORD_SCHEMA",
60
+ "REDACTED",
61
+ "SPEC_VERSION",
62
+ "WELL_KNOWN_PATH",
63
+ "AgentInfo",
64
+ "DiscoveryDocument",
65
+ "Evidence",
66
+ "FeedbackAck",
67
+ "FeedbackClient",
68
+ "FeedbackError",
69
+ "FeedbackHandler",
70
+ "FeedbackRecord",
71
+ "FeedbackSubmission",
72
+ "ForwardError",
73
+ "HandlerResponse",
74
+ "KnownIssue",
75
+ "MemorySink",
76
+ "OnRecordResult",
77
+ "SubmitResult",
78
+ "ValidationIssue",
79
+ "ValidationResult",
80
+ "error_response",
81
+ "fan_out",
82
+ "fastapi_router",
83
+ "feedback_link_header",
84
+ "feedback_tool",
85
+ "feedback_tool_input_schema",
86
+ "feedback_url_from_link",
87
+ "flask_blueprint",
88
+ "format_issues",
89
+ "forward_to",
90
+ "hash_account",
91
+ "memory_sink",
92
+ "new_id",
93
+ "redact",
94
+ "redact_string",
95
+ "validate_ack",
96
+ "validate_record",
97
+ "validate_submission",
98
+ ]
backloop/_generated.py ADDED
@@ -0,0 +1,12 @@
1
+ # Generated by scripts/sync-schema.mjs from spec/. Do not edit by hand.
2
+ import json
3
+
4
+ FEEDBACK_SCHEMA = json.loads("{\"$schema\":\"https://json-schema.org/draft/2020-12/schema\",\"$id\":\"https://trybackloop.com/schemas/agent-feedback/0.1/feedback.schema.json\",\"title\":\"Agent Feedback Submission\",\"description\":\"Structured feedback an AI agent sends to a service when something prevents or complicates its task. Agent Feedback Protocol v0.1.\",\"type\":\"object\",\"required\":[\"type\",\"goal\",\"message\"],\"additionalProperties\":false,\"properties\":{\"spec_version\":{\"description\":\"Protocol version the agent is speaking. Defaults to \\\"0.1\\\" when omitted.\",\"type\":\"string\",\"const\":\"0.1\"},\"type\":{\"description\":\"Category of the problem.\",\"type\":\"string\",\"enum\":[\"missing_capability\",\"bug\",\"unclear_documentation\",\"unexpected_response\",\"unhelpful_error\",\"performance\",\"other\"]},\"goal\":{\"description\":\"What the agent (or its user) was trying to accomplish, in plain language. Describe the task, not the API call.\",\"type\":\"string\",\"minLength\":1,\"maxLength\":1000},\"message\":{\"description\":\"What prevented or complicated the task.\",\"type\":\"string\",\"minLength\":1,\"maxLength\":4000},\"endpoint\":{\"description\":\"The operation involved: an HTTP path template (\\\"/companies/search\\\") or a tool name (\\\"search_companies\\\").\",\"type\":\"string\",\"minLength\":1,\"maxLength\":512},\"method\":{\"description\":\"HTTP method, when the endpoint is an HTTP path.\",\"type\":\"string\",\"enum\":[\"GET\",\"POST\",\"PUT\",\"PATCH\",\"DELETE\",\"HEAD\",\"OPTIONS\"]},\"outcome\":{\"description\":\"What happened to the task. blocked: the task could not be completed. degraded: completed, but worse or slower than it should have been. completed: completed fine; the feedback is a suggestion.\",\"type\":\"string\",\"enum\":[\"blocked\",\"degraded\",\"completed\"]},\"workaround\":{\"description\":\"Whether the agent found a workaround.\",\"type\":\"boolean\"},\"workaround_description\":{\"description\":\"The workaround the agent used, if any.\",\"type\":\"string\",\"maxLength\":2000},\"expected\":{\"description\":\"What the agent expected to find or happen.\",\"type\":\"string\",\"maxLength\":2000},\"suggestion\":{\"description\":\"A concrete change that would have let the agent succeed.\",\"type\":\"string\",\"maxLength\":2000},\"request_id\":{\"description\":\"The service's request ID for a related call, to join feedback with logs.\",\"type\":\"string\",\"maxLength\":256},\"session_id\":{\"description\":\"Opaque ID shared by all feedback from one agent task run. Used to de-duplicate retries.\",\"type\":\"string\",\"maxLength\":256},\"agent\":{\"description\":\"Self-reported information about the agent.\",\"type\":\"object\",\"additionalProperties\":false,\"properties\":{\"name\":{\"type\":\"string\",\"maxLength\":128},\"version\":{\"type\":\"string\",\"maxLength\":64},\"model\":{\"type\":\"string\",\"maxLength\":128},\"framework\":{\"type\":\"string\",\"maxLength\":128}}},\"evidence\":{\"description\":\"Sanitized details of the failing interaction. Never include credentials or personal data.\",\"type\":\"object\",\"additionalProperties\":false,\"properties\":{\"status_code\":{\"type\":\"integer\",\"minimum\":100,\"maximum\":599},\"request\":{\"description\":\"Parameters or body the agent sent, with secrets removed.\",\"type\":\"object\"},\"response_excerpt\":{\"description\":\"The relevant part of the response.\",\"type\":\"string\",\"maxLength\":4000}}},\"metadata\":{\"description\":\"Free-form extra data. Services may ignore it.\",\"type\":\"object\"}}}")
5
+
6
+ RECORD_SCHEMA = json.loads("{\"$schema\":\"https://json-schema.org/draft/2020-12/schema\",\"$id\":\"https://trybackloop.com/schemas/agent-feedback/0.1/record.schema.json\",\"title\":\"Agent Feedback Record\",\"description\":\"A feedback submission after the receiving service has accepted it: the agent's submission plus server-side context. This is what services store and forward to collectors.\",\"type\":\"object\",\"required\":[\"id\",\"received_at\",\"feedback\"],\"additionalProperties\":false,\"properties\":{\"id\":{\"description\":\"Unique ID assigned by the receiving service.\",\"type\":\"string\",\"minLength\":1,\"maxLength\":128},\"received_at\":{\"description\":\"RFC 3339 timestamp of when the service accepted the submission.\",\"type\":\"string\",\"format\":\"date-time\"},\"service\":{\"description\":\"Name of the service that received the feedback.\",\"type\":\"string\",\"maxLength\":128},\"account\":{\"description\":\"Opaque, stable identifier of the customer account the agent authenticated as. Services should hash or pseudonymize it.\",\"type\":\"string\",\"maxLength\":256},\"source\":{\"description\":\"Channel the feedback arrived through.\",\"type\":\"string\",\"enum\":[\"http\",\"mcp\",\"sdk\",\"other\"]},\"feedback\":{\"$ref\":\"feedback.schema.json\"}}}")
7
+
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
+
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."
11
+
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."
backloop/_http.py ADDED
@@ -0,0 +1,50 @@
1
+ """Minimal urllib wrapper: returns every HTTP status instead of raising on 4xx/5xx."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import urllib.error
7
+ import urllib.parse
8
+ import urllib.request
9
+ from typing import Any, Dict, Mapping, Optional, Tuple
10
+
11
+ Response = Tuple[int, Mapping[str, str], bytes]
12
+
13
+
14
+ def request(
15
+ url: str,
16
+ method: str = "GET",
17
+ body: Optional[bytes] = None,
18
+ headers: Optional[Dict[str, str]] = None,
19
+ timeout: float = 10.0,
20
+ ) -> Response:
21
+ """Send a request and return ``(status, headers, body)``.
22
+
23
+ Network failures (connection refused, timeouts, bad URLs) raise. Like
24
+ fetch, only http(s) URLs are allowed: urllib would also open file:// URLs.
25
+ """
26
+ if urllib.parse.urlsplit(url).scheme.lower() not in ("http", "https"):
27
+ raise ValueError(f"Unsupported URL: {url}")
28
+ req = urllib.request.Request(url, data=body, method=method, headers=headers or {})
29
+ try:
30
+ with urllib.request.urlopen(req, timeout=timeout) as res:
31
+ return res.status, res.headers, res.read()
32
+ except urllib.error.HTTPError as e:
33
+ try:
34
+ raw = e.read() # reading to the end also releases the connection
35
+ except Exception:
36
+ raw = b""
37
+ return e.code, e.headers, raw
38
+
39
+
40
+ def parse_json(raw: bytes) -> Any:
41
+ """Parse a JSON body, or return None when it is not JSON."""
42
+ try:
43
+ return json.loads(raw)
44
+ except ValueError:
45
+ return None
46
+
47
+
48
+ def dumps(value: Any) -> bytes:
49
+ """Compact UTF-8 JSON, like ``JSON.stringify``."""
50
+ return json.dumps(value, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
backloop/client.py ADDED
@@ -0,0 +1,205 @@
1
+ """Send feedback to a service's ``/feedback`` endpoint."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import email.utils
6
+ import math
7
+ import re
8
+ import time
9
+ import urllib.parse
10
+ from dataclasses import dataclass
11
+ from datetime import datetime, timezone
12
+ from typing import Any, Dict, List, Mapping, Optional, Union
13
+
14
+ from . import _http
15
+ from .redact import redact as _redact
16
+ from .types import (
17
+ SPEC_VERSION,
18
+ WELL_KNOWN_PATH,
19
+ AgentInfo,
20
+ DiscoveryDocument,
21
+ FeedbackAck,
22
+ FeedbackSubmission,
23
+ ValidationIssue,
24
+ )
25
+ from .validate import format_issues, validate_submission
26
+
27
+ RETRYABLE = frozenset({429, 500, 502, 503, 504})
28
+ USER_AGENT = f"backloop-sdk-python/{SPEC_VERSION}"
29
+
30
+
31
+ class FeedbackError(Exception):
32
+ """Raised on validation, network or HTTP errors. ``code`` is the protocol error code."""
33
+
34
+ def __init__(
35
+ self,
36
+ message: str,
37
+ code: str,
38
+ status: Optional[int] = None,
39
+ details: Optional[List[ValidationIssue]] = None,
40
+ ) -> None:
41
+ super().__init__(message)
42
+ self.message = message
43
+ self.code = code
44
+ self.status = status
45
+ self.details = details
46
+
47
+
48
+ @dataclass
49
+ class SubmitResult:
50
+ """Result of ``try_submit``: ``ack`` when ``ok``, otherwise ``error``."""
51
+
52
+ ok: bool
53
+ ack: Optional[FeedbackAck] = None
54
+ error: Optional[FeedbackError] = None
55
+
56
+
57
+ class FeedbackClient:
58
+ """Client for a service's feedback endpoint.
59
+
60
+ ``redact`` may be ``True`` (default), ``False``, or a dict of ``redact()``
61
+ options such as ``{"emails": False}``.
62
+ """
63
+
64
+ def __init__(
65
+ self,
66
+ base_url: Optional[str] = None,
67
+ *,
68
+ endpoint: Optional[str] = None,
69
+ api_key: Optional[str] = None,
70
+ headers: Optional[Dict[str, str]] = None,
71
+ agent: Optional[AgentInfo] = None,
72
+ session_id: Optional[str] = None,
73
+ redact: Union[bool, Mapping[str, Any]] = True,
74
+ timeout: float = 10.0,
75
+ ) -> None:
76
+ if endpoint is None and base_url:
77
+ endpoint = base_url.rstrip("/") + "/feedback"
78
+ if not endpoint:
79
+ raise ValueError("FeedbackClient needs `endpoint` or `base_url`")
80
+ self.endpoint = endpoint
81
+ self.api_key = api_key
82
+ self.headers = dict(headers or {})
83
+ self.agent = agent
84
+ self.session_id = session_id
85
+ self.redact = redact
86
+ self.timeout = timeout
87
+
88
+ def prepare(self, feedback: FeedbackSubmission) -> FeedbackSubmission:
89
+ """Build the submission that would be sent: defaults applied, redacted and validated."""
90
+ body: Any = dict(feedback) if isinstance(feedback, dict) else feedback
91
+ if isinstance(body, dict):
92
+ if self.agent and not body.get("agent"):
93
+ body["agent"] = self.agent
94
+ if self.session_id and not body.get("session_id"):
95
+ body["session_id"] = self.session_id
96
+ if self.redact is not False:
97
+ options = self.redact if isinstance(self.redact, Mapping) else {}
98
+ body = _redact(body, **options)
99
+ result = validate_submission(body)
100
+ if not result.valid:
101
+ raise FeedbackError(format_issues(result.issues), "invalid_feedback", None, result.issues)
102
+ return body
103
+
104
+ def submit(self, feedback: FeedbackSubmission) -> FeedbackAck:
105
+ """Send feedback. Raises ``FeedbackError`` on validation, network or HTTP errors.
106
+
107
+ Retries once on 429 and 5xx, after ``Retry-After`` (default 1s, capped at 10s).
108
+ """
109
+ body = _http.dumps(self.prepare(feedback))
110
+ status, headers, raw = self._post(body)
111
+ if status in RETRYABLE:
112
+ delay = _retry_after_seconds(headers.get("retry-after"))
113
+ time.sleep(max(0.0, min(1.0 if delay is None else delay, 10.0)))
114
+ status, headers, raw = self._post(body)
115
+ payload = _http.parse_json(raw)
116
+ if status in (200, 202):
117
+ return payload
118
+ error = payload.get("error") if isinstance(payload, dict) else None
119
+ if not isinstance(error, dict):
120
+ error = {}
121
+ message = error.get("message")
122
+ code = error.get("code")
123
+ raise FeedbackError(
124
+ message if message is not None else f"Feedback endpoint returned HTTP {status}",
125
+ code if code is not None else "http_error",
126
+ status,
127
+ error.get("details"),
128
+ )
129
+
130
+ def try_submit(self, feedback: FeedbackSubmission) -> SubmitResult:
131
+ """Like ``submit``, but never raises: feedback must never break the agent's task."""
132
+ try:
133
+ return SubmitResult(ok=True, ack=self.submit(feedback))
134
+ except FeedbackError as e:
135
+ return SubmitResult(ok=False, error=e)
136
+ except Exception as e: # noqa: BLE001 - best effort by design
137
+ return SubmitResult(ok=False, error=FeedbackError(str(e), "network_error"))
138
+
139
+ def _post(self, body: bytes) -> _http.Response:
140
+ headers = {"content-type": "application/json", "user-agent": USER_AGENT, **self.headers}
141
+ if self.api_key:
142
+ headers["authorization"] = f"Bearer {self.api_key}"
143
+ try:
144
+ return _http.request(self.endpoint, "POST", body, headers, self.timeout)
145
+ except Exception as e:
146
+ reason = getattr(e, "reason", e)
147
+ raise FeedbackError(f"Could not reach {self.endpoint}: {reason}", "network_error") from e
148
+
149
+ @staticmethod
150
+ def discover(base_url: str, timeout: float = 5.0) -> Optional[DiscoveryDocument]:
151
+ """Fetch a service's discovery document at ``/.well-known/agent-feedback``.
152
+
153
+ Returns None when the service does not implement the protocol.
154
+ """
155
+ try:
156
+ url = urllib.parse.urljoin(base_url, WELL_KNOWN_PATH)
157
+ status, _, raw = _http.request(url, headers={"user-agent": USER_AGENT}, timeout=timeout)
158
+ except Exception:
159
+ return None
160
+ if not 200 <= status < 300:
161
+ return None
162
+ doc = _http.parse_json(raw)
163
+ return doc if isinstance(doc, dict) and isinstance(doc.get("endpoint"), str) else None
164
+
165
+ @classmethod
166
+ def from_discovery(cls, base_url: str, **options: Any) -> Optional[FeedbackClient]:
167
+ """Create a client from a service's discovery document, if it has one."""
168
+ doc = cls.discover(base_url)
169
+ if doc is None:
170
+ return None
171
+ return cls(endpoint=urllib.parse.urljoin(base_url, doc["endpoint"]), **options)
172
+
173
+
174
+ _LINK_PART = re.compile(r"<([^>]+)>\s*;(.*)")
175
+ _LINK_REL = re.compile(r'rel="?[^";]*\bagent-feedback\b', re.ASCII)
176
+
177
+
178
+ def feedback_url_from_link(link: Optional[str], base: str) -> Optional[str]:
179
+ """Parse a ``Link`` header and return the agent-feedback URL, if any."""
180
+ if not link:
181
+ return None
182
+ for part in link.split(","):
183
+ m = _LINK_PART.search(part)
184
+ if m and _LINK_REL.search(m.group(2)):
185
+ return urllib.parse.urljoin(base, m.group(1))
186
+ return None
187
+
188
+
189
+ def _retry_after_seconds(header: Optional[str]) -> Optional[float]:
190
+ """Seconds to wait from a ``Retry-After`` header (delta-seconds or HTTP-date)."""
191
+ if not header:
192
+ return None
193
+ try:
194
+ seconds = float(header)
195
+ if math.isfinite(seconds):
196
+ return seconds
197
+ except ValueError:
198
+ pass
199
+ try:
200
+ when = email.utils.parsedate_to_datetime(header)
201
+ except (TypeError, ValueError, IndexError, OverflowError):
202
+ return None
203
+ if when.tzinfo is None:
204
+ when = when.replace(tzinfo=timezone.utc)
205
+ return max(0.0, (when - datetime.now(timezone.utc)).total_seconds())
backloop/forward.py ADDED
@@ -0,0 +1,88 @@
1
+ """``on_record`` sinks: forward records to a collector, or keep them in memory."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Callable, List, Optional
6
+
7
+ from . import _http
8
+ from .server import OnRecord, OnRecordResult
9
+ from .types import FeedbackRecord
10
+
11
+
12
+ class ForwardError(Exception):
13
+ """The collector could not be reached, returned an error, or rejected the record."""
14
+
15
+ def __init__(self, message: str, status: Optional[int] = None) -> None:
16
+ super().__init__(message)
17
+ self.status = status
18
+
19
+
20
+ def forward_to(url: str, ingest_key: Optional[str] = None, timeout: float = 5.0) -> OnRecord:
21
+ """
22
+ An ``on_record`` sink that forwards records to a collector's
23
+ ``POST /v1/records`` and passes any ``known_issue`` back to the agent.
24
+
25
+ Retries once on network errors, 429 and 5xx; raises ``ForwardError`` on
26
+ failure, which the handler turns into ``503 unavailable``.
27
+ """
28
+ endpoint = url.rstrip("/") + "/v1/records"
29
+ headers = {"content-type": "application/json"}
30
+ if ingest_key:
31
+ headers["authorization"] = f"Bearer {ingest_key}"
32
+
33
+ def sink(record: FeedbackRecord) -> OnRecordResult:
34
+ body = _http.dumps({"records": [record]})
35
+ last_error = ForwardError(f"Could not forward to {endpoint}")
36
+ for _attempt in range(2):
37
+ try:
38
+ status, _, raw = _http.request(endpoint, "POST", body, headers, timeout)
39
+ except Exception as e:
40
+ last_error = ForwardError(f"Could not reach {endpoint}: {getattr(e, 'reason', e)}")
41
+ continue
42
+ if 200 <= status < 300:
43
+ return _result(raw)
44
+ last_error = ForwardError(f"Collector returned HTTP {status}", status)
45
+ if status < 500 and status != 429:
46
+ break
47
+ raise last_error
48
+
49
+ return sink
50
+
51
+
52
+ def _result(raw: bytes) -> OnRecordResult:
53
+ data = _http.parse_json(raw)
54
+ if not isinstance(data, dict):
55
+ raise ForwardError("Collector returned an invalid response")
56
+ results = data.get("results")
57
+ result = results[0] if isinstance(results, list) and results and isinstance(results[0], dict) else {}
58
+ if result.get("status") == "rejected":
59
+ error = result.get("error")
60
+ message = error.get("message") if isinstance(error, dict) else None
61
+ raise ForwardError(f"Collector rejected record: {message or 'unknown error'}")
62
+ known_issue = result.get("known_issue")
63
+ return {"known_issue": known_issue} if known_issue else None
64
+
65
+
66
+ def fan_out(*sinks: Callable[[FeedbackRecord], OnRecordResult]) -> OnRecord:
67
+ """Run several sinks for each record; the first ``known_issue`` wins."""
68
+
69
+ def sink(record: FeedbackRecord) -> OnRecordResult:
70
+ results = [s(record) for s in sinks]
71
+ return next((r for r in results if r and r.get("known_issue")), None)
72
+
73
+ return sink
74
+
75
+
76
+ class MemorySink:
77
+ """Keeps records in ``records``. Useful for tests and prototypes."""
78
+
79
+ def __init__(self) -> None:
80
+ self.records: List[FeedbackRecord] = []
81
+
82
+ def __call__(self, record: FeedbackRecord) -> None:
83
+ self.records.append(record)
84
+
85
+
86
+ def memory_sink() -> MemorySink:
87
+ """An ``on_record`` sink that keeps records in memory (``sink.records``)."""
88
+ return MemorySink()
backloop/ids.py ADDED
@@ -0,0 +1,35 @@
1
+ """Record IDs and account pseudonymization."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import hmac
7
+ import os
8
+ import time
9
+
10
+ _ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ" # Crockford base32
11
+
12
+
13
+ def new_id(prefix: str = "fb") -> str:
14
+ """Time-sortable unique ID (ULID layout) with a prefix, e.g. ``fb_01JAX3ZK7Q8W2M4N6P9RTV5B``."""
15
+ ms = time.time_ns() // 1_000_000
16
+ ts = ""
17
+ for _ in range(10):
18
+ ts = _ALPHABET[ms % 32] + ts
19
+ ms //= 32
20
+ rand = "".join(_ALPHABET[b % 32] for b in os.urandom(16))
21
+ return f"{prefix}_{ts}{rand}"
22
+
23
+
24
+ def _utf8(value: str) -> bytes:
25
+ # Encode like the WHATWG TextEncoder: lone surrogates become U+FFFD.
26
+ return value.encode("utf-16", "surrogatepass").decode("utf-16", "replace").encode("utf-8")
27
+
28
+
29
+ def hash_account(account_id: str, secret: str) -> str:
30
+ """
31
+ Pseudonymize an account ID with HMAC-SHA256, so collectors can count
32
+ affected customers without learning who they are.
33
+ """
34
+ digest = hmac.new(_utf8(secret), _utf8(account_id), hashlib.sha256).digest()
35
+ return "acct_" + digest[:12].hex()
@@ -0,0 +1,106 @@
1
+ """Optional FastAPI and Flask helpers. Each imports its framework lazily.
2
+
3
+ No ``from __future__ import annotations`` here: FastAPI must be able to
4
+ resolve the locally imported ``Request`` annotation.
5
+ """
6
+
7
+ import inspect
8
+ import json
9
+ from typing import Any, Callable, Optional
10
+
11
+ from .server import JSON_HEADERS, FeedbackHandler
12
+ from .types import WELL_KNOWN_PATH
13
+
14
+ Identify = Callable[[Any], Any]
15
+
16
+ _DISCOVERY_HEADERS = {**JSON_HEADERS, "cache-control": "public, max-age=3600"}
17
+
18
+
19
+ def _client_ip(forwarded_for: Optional[str], remote: Optional[str]) -> Optional[str]:
20
+ if forwarded_for:
21
+ first = forwarded_for.split(",")[0].strip()
22
+ if first:
23
+ return first
24
+ return remote
25
+
26
+
27
+ def _read_capped(stream: Any, limit: int) -> bytes:
28
+ """Read at most ``limit + 1`` bytes: enough for the handler to reject an oversized body."""
29
+ chunks = []
30
+ size = 0
31
+ while size <= limit:
32
+ chunk = stream.read(limit + 1 - size)
33
+ if not chunk:
34
+ break
35
+ chunks.append(chunk)
36
+ size += len(chunk)
37
+ return b"".join(chunks)
38
+
39
+
40
+ def fastapi_router(handler: FeedbackHandler, identify: Optional[Identify] = None) -> Any:
41
+ """A FastAPI ``APIRouter`` serving ``POST /feedback`` and ``GET /.well-known/agent-feedback``.
42
+
43
+ ``identify(request)`` (sync or async; defaults to ``handler.identify``)
44
+ returns the caller's pseudonymous account, or None. Include it at the app
45
+ root: ``app.include_router(fastapi_router(handler))``.
46
+ """
47
+ from fastapi import APIRouter, Request
48
+ from fastapi.responses import JSONResponse
49
+ from starlette.concurrency import run_in_threadpool
50
+
51
+ resolve = identify or handler.identify
52
+ router = APIRouter()
53
+
54
+ @router.post("/feedback")
55
+ async def submit_feedback(request: Request): # type: ignore[no-untyped-def]
56
+ body = bytearray()
57
+ async for chunk in request.stream():
58
+ body += chunk
59
+ if len(body) > handler.max_bytes:
60
+ break
61
+ account = resolve(request) if resolve else None
62
+ if inspect.isawaitable(account):
63
+ account = await account
64
+ client_ip = _client_ip(
65
+ request.headers.get("x-forwarded-for"), request.client.host if request.client else None
66
+ )
67
+ status, payload, headers = await run_in_threadpool(
68
+ handler.handle_submit, bytes(body), request.headers.get("content-type"), account, client_ip
69
+ )
70
+ return JSONResponse(payload, status_code=status, headers=headers)
71
+
72
+ @router.get(WELL_KNOWN_PATH)
73
+ async def agent_feedback_discovery(request: Request): # type: ignore[no-untyped-def]
74
+ return JSONResponse(handler.discovery(str(request.base_url)), headers=_DISCOVERY_HEADERS)
75
+
76
+ return router
77
+
78
+
79
+ def flask_blueprint(handler: FeedbackHandler, identify: Optional[Identify] = None) -> Any:
80
+ """A Flask ``Blueprint`` serving ``POST /feedback`` and ``GET /.well-known/agent-feedback``.
81
+
82
+ ``identify(request)`` (defaults to ``handler.identify``) returns the
83
+ caller's pseudonymous account, or None. Register it at the app root:
84
+ ``app.register_blueprint(flask_blueprint(handler))``.
85
+ """
86
+ from flask import Blueprint, Response, request
87
+
88
+ resolve = identify or handler.identify
89
+ blueprint = Blueprint("backloop", __name__)
90
+
91
+ @blueprint.route("/feedback", methods=["POST"])
92
+ def submit_feedback() -> Any:
93
+ body = _read_capped(request.stream, handler.max_bytes)
94
+ account = resolve(request) if resolve else None
95
+ client_ip = _client_ip(request.headers.get("X-Forwarded-For"), request.remote_addr)
96
+ status, payload, headers = handler.handle_submit(
97
+ body, request.headers.get("Content-Type"), account, client_ip
98
+ )
99
+ return Response(json.dumps(payload, ensure_ascii=False), status=status, headers=headers)
100
+
101
+ @blueprint.route(WELL_KNOWN_PATH, methods=["GET"])
102
+ def agent_feedback_discovery() -> Any:
103
+ doc = handler.discovery(request.url_root)
104
+ return Response(json.dumps(doc), headers=_DISCOVERY_HEADERS)
105
+
106
+ return blueprint
backloop/py.typed ADDED
File without changes