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 +98 -0
- backloop/_generated.py +12 -0
- backloop/_http.py +50 -0
- backloop/client.py +205 -0
- backloop/forward.py +88 -0
- backloop/ids.py +35 -0
- backloop/integrations.py +106 -0
- backloop/py.typed +0 -0
- backloop/redact.py +108 -0
- backloop/server.py +206 -0
- backloop/tool.py +40 -0
- backloop/types.py +165 -0
- backloop/validate.py +175 -0
- backloop_sdk-0.1.0.dist-info/METADATA +120 -0
- backloop_sdk-0.1.0.dist-info/RECORD +17 -0
- backloop_sdk-0.1.0.dist-info/WHEEL +4 -0
- backloop_sdk-0.1.0.dist-info/licenses/LICENSE +55 -0
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()
|
backloop/integrations.py
ADDED
|
@@ -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
|