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/redact.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Best-effort removal of secrets and personal data from feedback before it
|
|
3
|
+
leaves the agent. The protocol forbids sending them; this catches mistakes.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import re
|
|
9
|
+
from typing import Iterable, Optional, TypeVar, Union
|
|
10
|
+
|
|
11
|
+
T = TypeVar("T")
|
|
12
|
+
|
|
13
|
+
REDACTED = "[REDACTED]"
|
|
14
|
+
|
|
15
|
+
# Patterns use re.ASCII so \b and \d behave like the (non-unicode) JS regexes
|
|
16
|
+
# in redact.ts. JS's \s is Unicode-aware, so it is spelled out where it matters.
|
|
17
|
+
_A = re.ASCII
|
|
18
|
+
_WS = r"\s -
"
|
|
19
|
+
|
|
20
|
+
_SECRET_PATTERNS = [
|
|
21
|
+
re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----", _A),
|
|
22
|
+
re.compile(rf"\bBearer[{_WS}]+[A-Za-z0-9\-._~+/]{{8,}}=*", _A | re.I),
|
|
23
|
+
re.compile(rf"\bBasic[{_WS}]+[A-Za-z0-9+/]{{8,}}=*", _A),
|
|
24
|
+
re.compile(r"\beyJ[A-Za-z0-9_-]{5,}\.[A-Za-z0-9_-]{5,}\.[A-Za-z0-9_-]{5,}\b", _A), # JWT
|
|
25
|
+
re.compile(r"\bsk-(?:ant-|proj-)?[A-Za-z0-9_-]{16,}\b", _A), # Anthropic / OpenAI style
|
|
26
|
+
re.compile(r"\b(?:sk|pk|rk)_(?:live|test)_[A-Za-z0-9]{10,}\b", _A), # Stripe
|
|
27
|
+
re.compile(r"\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{20,}\b", _A), # GitHub
|
|
28
|
+
re.compile(r"\bgithub_pat_[A-Za-z0-9_]{20,}\b", _A),
|
|
29
|
+
re.compile(r"\bxox[abprs]-[A-Za-z0-9-]{10,}\b", _A), # Slack
|
|
30
|
+
re.compile(r"\bAKIA[0-9A-Z]{16}\b", _A), # AWS access key id
|
|
31
|
+
re.compile(r"\bAIza[0-9A-Za-z_-]{35}\b", _A), # Google API key
|
|
32
|
+
re.compile(
|
|
33
|
+
rf"([?&](?:api_?key|access_token|token|secret|password|signature)=)[^&{_WS}\"']+",
|
|
34
|
+
_A | re.I,
|
|
35
|
+
),
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
_EMAIL = re.compile(r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b", _A)
|
|
39
|
+
_CARD_CANDIDATE = re.compile(r"\b(?:\d[ -]?){12,18}\d\b", _A)
|
|
40
|
+
|
|
41
|
+
_SENSITIVE_KEY = re.compile(
|
|
42
|
+
r"authorization|cookie|set-cookie|password|passwd|secret|client_secret|api[_-]?key|x-api-key"
|
|
43
|
+
r"|access[_-]?token|refresh[_-]?token|token|private[_-]?key|ssn|card[_-]?number|cvv",
|
|
44
|
+
_A | re.I,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
PatternLike = Union[str, "re.Pattern[str]"]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _luhn(digits: str) -> bool:
|
|
51
|
+
total = 0
|
|
52
|
+
double = False
|
|
53
|
+
for ch in reversed(digits):
|
|
54
|
+
d = ord(ch) - 48
|
|
55
|
+
if double:
|
|
56
|
+
d *= 2
|
|
57
|
+
if d > 9:
|
|
58
|
+
d -= 9
|
|
59
|
+
total += d
|
|
60
|
+
double = not double
|
|
61
|
+
return total % 10 == 0
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _replace_secret(match: re.Match[str]) -> str:
|
|
65
|
+
# Keep a leading capture group (e.g. "?api_key=") and redact the rest.
|
|
66
|
+
prefix = match.group(1) if match.re.groups else None
|
|
67
|
+
if isinstance(prefix, str) and match.group(0).startswith(prefix):
|
|
68
|
+
return prefix + REDACTED
|
|
69
|
+
return REDACTED
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _replace_card(match: re.Match[str]) -> str:
|
|
73
|
+
digits = re.sub(r"[ -]", "", match.group(0))
|
|
74
|
+
return REDACTED if 13 <= len(digits) <= 19 and _luhn(digits) else match.group(0)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def redact_string(
|
|
78
|
+
value: str, emails: bool = True, patterns: Optional[Iterable[PatternLike]] = None
|
|
79
|
+
) -> str:
|
|
80
|
+
"""Redact secrets, card numbers and (unless ``emails=False``) email addresses in a string."""
|
|
81
|
+
out = value
|
|
82
|
+
extra = [re.compile(p) if isinstance(p, str) else p for p in (patterns or ())]
|
|
83
|
+
for pattern in [*_SECRET_PATTERNS, *extra]:
|
|
84
|
+
out = pattern.sub(_replace_secret, out)
|
|
85
|
+
out = _CARD_CANDIDATE.sub(_replace_card, out)
|
|
86
|
+
if emails:
|
|
87
|
+
out = _EMAIL.sub(REDACTED, out)
|
|
88
|
+
return out
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def redact(value: T, emails: bool = True, patterns: Optional[Iterable[PatternLike]] = None) -> T:
|
|
92
|
+
"""Recursively redact every string in a JSON value, and the values of sensitive keys.
|
|
93
|
+
|
|
94
|
+
Returns a new value; the input is not modified.
|
|
95
|
+
"""
|
|
96
|
+
extra = list(patterns or ())
|
|
97
|
+
if isinstance(value, str):
|
|
98
|
+
return redact_string(value, emails, extra) # type: ignore[return-value]
|
|
99
|
+
if isinstance(value, (list, tuple)):
|
|
100
|
+
return [redact(v, emails, extra) for v in value] # type: ignore[return-value]
|
|
101
|
+
if isinstance(value, dict):
|
|
102
|
+
return { # type: ignore[return-value]
|
|
103
|
+
key: REDACTED
|
|
104
|
+
if isinstance(key, str) and _SENSITIVE_KEY.fullmatch(key) and v is not None
|
|
105
|
+
else redact(v, emails, extra)
|
|
106
|
+
for key, v in value.items()
|
|
107
|
+
}
|
|
108
|
+
return value
|
backloop/server.py
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"""Framework-agnostic handler for ``POST /feedback`` and the discovery document."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import inspect
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
import math
|
|
9
|
+
import re
|
|
10
|
+
import threading
|
|
11
|
+
from datetime import datetime, timezone
|
|
12
|
+
from typing import Any, Callable, Dict, List, Mapping, Optional, Tuple, Union
|
|
13
|
+
|
|
14
|
+
from .ids import new_id
|
|
15
|
+
from .types import (
|
|
16
|
+
DEFAULT_MAX_BYTES,
|
|
17
|
+
FEEDBACK_TYPES,
|
|
18
|
+
LINK_REL,
|
|
19
|
+
SPEC_VERSION,
|
|
20
|
+
AuthMode,
|
|
21
|
+
DiscoveryDocument,
|
|
22
|
+
FeedbackRecord,
|
|
23
|
+
FeedbackSource,
|
|
24
|
+
ValidationIssue,
|
|
25
|
+
)
|
|
26
|
+
from .validate import format_issues, validate_submission
|
|
27
|
+
|
|
28
|
+
logger = logging.getLogger("backloop")
|
|
29
|
+
|
|
30
|
+
#: ``(status, json_body, headers)``, ready to hand to any web framework.
|
|
31
|
+
HandlerResponse = Tuple[int, Dict[str, Any], Dict[str, str]]
|
|
32
|
+
#: What ``on_record`` may return: ``{"known_issue": {...}}`` to pass back to the agent, or None.
|
|
33
|
+
OnRecordResult = Optional[Mapping[str, Any]]
|
|
34
|
+
OnRecord = Callable[[FeedbackRecord], OnRecordResult]
|
|
35
|
+
|
|
36
|
+
JSON_HEADERS = {"content-type": "application/json; charset=utf-8"}
|
|
37
|
+
_JSON_CONTENT_TYPE = re.compile(r"application/(?:[\w.+-]+\+)?json\b", re.I | re.ASCII)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def error_response(
|
|
41
|
+
status: int,
|
|
42
|
+
code: str,
|
|
43
|
+
message: str,
|
|
44
|
+
details: Optional[List[ValidationIssue]] = None,
|
|
45
|
+
headers: Optional[Dict[str, str]] = None,
|
|
46
|
+
) -> HandlerResponse:
|
|
47
|
+
"""Build a protocol error: ``{"error": {"code", "message", "details"?}}``."""
|
|
48
|
+
error: Dict[str, Any] = {"code": code, "message": message}
|
|
49
|
+
if details is not None:
|
|
50
|
+
error["details"] = details
|
|
51
|
+
return status, {"error": error}, {**JSON_HEADERS, **(headers or {})}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def feedback_link_header(path: str = "/feedback") -> str:
|
|
55
|
+
"""``Link`` header value advertising the feedback endpoint. Add it to your API's error responses."""
|
|
56
|
+
return f'<{path}>; rel="{LINK_REL}"'
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class _RateLimiter:
|
|
60
|
+
def __init__(self, limit: int, window: float) -> None:
|
|
61
|
+
self._limit = limit
|
|
62
|
+
self._window = window
|
|
63
|
+
self._hits: Dict[str, List[float]] = {} # key -> [count, reset_at]
|
|
64
|
+
self._lock = threading.Lock()
|
|
65
|
+
|
|
66
|
+
def take(self, key: str, now: float) -> int:
|
|
67
|
+
"""Returns seconds to wait, or 0 if allowed."""
|
|
68
|
+
with self._lock:
|
|
69
|
+
entry = self._hits.get(key)
|
|
70
|
+
if entry is None or entry[1] <= now:
|
|
71
|
+
if len(self._hits) > 10_000:
|
|
72
|
+
self._hits.clear()
|
|
73
|
+
self._hits[key] = [1, now + self._window]
|
|
74
|
+
return 0
|
|
75
|
+
entry[0] += 1
|
|
76
|
+
return math.ceil(entry[1] - now) if entry[0] > self._limit else 0
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _reject_constant(name: str) -> Any:
|
|
80
|
+
raise ValueError(f"{name} is not valid JSON")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class FeedbackHandler:
|
|
84
|
+
"""Validates submissions, builds records and hands them to ``on_record``.
|
|
85
|
+
|
|
86
|
+
Framework-agnostic: your route reads the body and the caller's account,
|
|
87
|
+
calls ``handle_submit`` and returns the ``(status, body, headers)`` it gets.
|
|
88
|
+
|
|
89
|
+
- ``on_record(record)`` stores or forwards each accepted record. It may
|
|
90
|
+
return ``{"known_issue": {...}}`` to pass back to the agent. If it raises,
|
|
91
|
+
the agent gets ``503 unavailable``.
|
|
92
|
+
- ``identify(request)`` resolves the (pseudonymous, see ``hash_account``)
|
|
93
|
+
account behind a framework request; used by the integrations.
|
|
94
|
+
- ``rate_limit`` is ``(max, window_seconds)`` per account (or per IP), or
|
|
95
|
+
None to disable it.
|
|
96
|
+
"""
|
|
97
|
+
|
|
98
|
+
def __init__(
|
|
99
|
+
self,
|
|
100
|
+
on_record: OnRecord,
|
|
101
|
+
*,
|
|
102
|
+
service: Optional[str] = None,
|
|
103
|
+
identify: Optional[Callable[[Any], Any]] = None,
|
|
104
|
+
auth: AuthMode = "optional",
|
|
105
|
+
max_bytes: int = DEFAULT_MAX_BYTES,
|
|
106
|
+
public_endpoint: Optional[str] = None,
|
|
107
|
+
rate_limit: Optional[Tuple[int, float]] = (60, 60.0),
|
|
108
|
+
source: FeedbackSource = "http",
|
|
109
|
+
generate_id: Optional[Callable[[], str]] = None,
|
|
110
|
+
now: Optional[Callable[[], datetime]] = None,
|
|
111
|
+
) -> None:
|
|
112
|
+
if inspect.iscoroutinefunction(on_record):
|
|
113
|
+
raise TypeError("on_record must be a regular (synchronous) function")
|
|
114
|
+
self.on_record = on_record
|
|
115
|
+
self.service = service
|
|
116
|
+
self.identify = identify
|
|
117
|
+
self.auth = auth
|
|
118
|
+
self.max_bytes = max_bytes
|
|
119
|
+
self.public_endpoint = public_endpoint
|
|
120
|
+
self.source = source
|
|
121
|
+
self._limiter = _RateLimiter(*rate_limit) if rate_limit else None
|
|
122
|
+
self._generate_id = generate_id or (lambda: new_id("fb"))
|
|
123
|
+
self._now = now or (lambda: datetime.now(timezone.utc))
|
|
124
|
+
|
|
125
|
+
def handle_submit(
|
|
126
|
+
self,
|
|
127
|
+
body: Union[bytes, str],
|
|
128
|
+
content_type: Optional[str],
|
|
129
|
+
account: Optional[str] = None,
|
|
130
|
+
client_ip: Optional[str] = None,
|
|
131
|
+
) -> HandlerResponse:
|
|
132
|
+
"""Handle ``POST /feedback``. Returns ``(status, json_body, headers)``."""
|
|
133
|
+
raw = body.encode("utf-8") if isinstance(body, str) else bytes(body)
|
|
134
|
+
if not _JSON_CONTENT_TYPE.match(content_type or ""):
|
|
135
|
+
return error_response(415, "unsupported_media_type", "Content-Type must be application/json")
|
|
136
|
+
if len(raw) > self.max_bytes:
|
|
137
|
+
return error_response(413, "payload_too_large", f"Feedback is limited to {self.max_bytes} bytes")
|
|
138
|
+
if self.auth == "required" and not account:
|
|
139
|
+
return error_response(
|
|
140
|
+
401, "unauthorized", "Authenticate with the same credentials as the rest of the API"
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
if self._limiter is not None:
|
|
144
|
+
key = account if account is not None else client_ip if client_ip is not None else "anonymous"
|
|
145
|
+
wait = self._limiter.take(key, self._utc_now().timestamp())
|
|
146
|
+
if wait > 0:
|
|
147
|
+
return error_response(
|
|
148
|
+
429, "rate_limited", "Too many feedback submissions", headers={"retry-after": str(wait)}
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
try:
|
|
152
|
+
# Decode like fetch's Request.text(): UTF-8, BOM stripped, invalid bytes replaced.
|
|
153
|
+
parsed = json.loads(raw.decode("utf-8-sig", "replace"), parse_constant=_reject_constant)
|
|
154
|
+
except ValueError:
|
|
155
|
+
return error_response(400, "invalid_json", "Body is not valid JSON")
|
|
156
|
+
result = validate_submission(parsed)
|
|
157
|
+
if not result.valid:
|
|
158
|
+
return error_response(400, "invalid_feedback", format_issues(result.issues), result.issues)
|
|
159
|
+
|
|
160
|
+
record: FeedbackRecord = {"id": self._generate_id(), "received_at": _iso(self._utc_now())} # type: ignore[typeddict-item]
|
|
161
|
+
if self.service:
|
|
162
|
+
record["service"] = self.service
|
|
163
|
+
if account:
|
|
164
|
+
record["account"] = account
|
|
165
|
+
record["source"] = self.source or "http"
|
|
166
|
+
record["feedback"] = parsed
|
|
167
|
+
|
|
168
|
+
try:
|
|
169
|
+
outcome = self.on_record(record)
|
|
170
|
+
except Exception:
|
|
171
|
+
logger.exception("[backloop] failed to store feedback record")
|
|
172
|
+
return error_response(
|
|
173
|
+
503, "unavailable", "Feedback could not be stored. Retry once later.", headers={"retry-after": "5"}
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
ack: Dict[str, Any] = {"id": record["id"], "status": "accepted", "received_at": record["received_at"]}
|
|
177
|
+
known_issue = outcome.get("known_issue") if isinstance(outcome, Mapping) else None
|
|
178
|
+
if known_issue:
|
|
179
|
+
ack["known_issue"] = known_issue
|
|
180
|
+
return 202, ack, dict(JSON_HEADERS)
|
|
181
|
+
|
|
182
|
+
def discovery(self, base_url: Optional[str] = None) -> DiscoveryDocument:
|
|
183
|
+
"""The document for ``GET /.well-known/agent-feedback``.
|
|
184
|
+
|
|
185
|
+
The endpoint is ``public_endpoint`` if set, else ``{base_url}/feedback``.
|
|
186
|
+
"""
|
|
187
|
+
endpoint = self.public_endpoint
|
|
188
|
+
if endpoint is None:
|
|
189
|
+
endpoint = (base_url or "").rstrip("/") + "/feedback"
|
|
190
|
+
return {
|
|
191
|
+
"spec_version": SPEC_VERSION,
|
|
192
|
+
"endpoint": endpoint,
|
|
193
|
+
"types": list(FEEDBACK_TYPES), # type: ignore[typeddict-item]
|
|
194
|
+
"max_bytes": self.max_bytes,
|
|
195
|
+
"auth": self.auth,
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
def _utc_now(self) -> datetime:
|
|
199
|
+
now = self._now()
|
|
200
|
+
return now.replace(tzinfo=timezone.utc) if now.tzinfo is None else now
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _iso(dt: datetime) -> str:
|
|
204
|
+
"""Format like ``Date.toISOString()``: UTC, millisecond precision."""
|
|
205
|
+
dt = dt.astimezone(timezone.utc)
|
|
206
|
+
return dt.strftime("%Y-%m-%dT%H:%M:%S.") + f"{dt.microsecond // 1000:03d}Z"
|
backloop/tool.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""The ``submit_feedback`` tool for LLM tool-calling APIs (spec §7)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import copy
|
|
6
|
+
from typing import Any, Dict, Optional
|
|
7
|
+
|
|
8
|
+
from ._generated import FEEDBACK_SCHEMA, FEEDBACK_TOOL_DESCRIPTION
|
|
9
|
+
|
|
10
|
+
FEEDBACK_TOOL_NAME = "submit_feedback"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def feedback_tool_input_schema() -> Dict[str, Any]:
|
|
14
|
+
"""
|
|
15
|
+
JSON Schema for the tool's input: the submission schema without
|
|
16
|
+
``spec_version`` and without JSON-Schema meta keys, ready for any LLM
|
|
17
|
+
tool-calling API.
|
|
18
|
+
"""
|
|
19
|
+
schema = copy.deepcopy(FEEDBACK_SCHEMA)
|
|
20
|
+
properties = schema["properties"]
|
|
21
|
+
properties.pop("spec_version", None)
|
|
22
|
+
return {
|
|
23
|
+
"type": "object",
|
|
24
|
+
"properties": properties,
|
|
25
|
+
"required": schema["required"],
|
|
26
|
+
"additionalProperties": schema["additionalProperties"],
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def feedback_tool(name: Optional[str] = None, description: Optional[str] = None) -> Dict[str, Any]:
|
|
31
|
+
"""
|
|
32
|
+
The feedback tool in the shape the Claude Messages API expects
|
|
33
|
+
(``{"name", "description", "input_schema"}``). Add it to your agent's tools
|
|
34
|
+
and send its input with ``FeedbackClient.submit``.
|
|
35
|
+
"""
|
|
36
|
+
return {
|
|
37
|
+
"name": name if name is not None else FEEDBACK_TOOL_NAME,
|
|
38
|
+
"description": description if description is not None else FEEDBACK_TOOL_DESCRIPTION,
|
|
39
|
+
"input_schema": feedback_tool_input_schema(),
|
|
40
|
+
}
|
backloop/types.py
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""Protocol types and constants. See spec/*.schema.json."""
|
|
2
|
+
|
|
3
|
+
from typing import Any, Dict, List, Literal, Tuple, TypedDict
|
|
4
|
+
|
|
5
|
+
SPEC_VERSION = "0.1"
|
|
6
|
+
|
|
7
|
+
FEEDBACK_TYPES: Tuple[str, ...] = (
|
|
8
|
+
"missing_capability",
|
|
9
|
+
"bug",
|
|
10
|
+
"unclear_documentation",
|
|
11
|
+
"unexpected_response",
|
|
12
|
+
"unhelpful_error",
|
|
13
|
+
"performance",
|
|
14
|
+
"other",
|
|
15
|
+
)
|
|
16
|
+
OUTCOMES: Tuple[str, ...] = ("blocked", "degraded", "completed")
|
|
17
|
+
|
|
18
|
+
DEFAULT_MAX_BYTES = 16_384
|
|
19
|
+
WELL_KNOWN_PATH = "/.well-known/agent-feedback"
|
|
20
|
+
LINK_REL = "agent-feedback"
|
|
21
|
+
|
|
22
|
+
FeedbackType = Literal[
|
|
23
|
+
"missing_capability",
|
|
24
|
+
"bug",
|
|
25
|
+
"unclear_documentation",
|
|
26
|
+
"unexpected_response",
|
|
27
|
+
"unhelpful_error",
|
|
28
|
+
"performance",
|
|
29
|
+
"other",
|
|
30
|
+
]
|
|
31
|
+
Outcome = Literal["blocked", "degraded", "completed"]
|
|
32
|
+
HttpMethod = Literal["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"]
|
|
33
|
+
FeedbackSource = Literal["http", "mcp", "sdk", "other"]
|
|
34
|
+
KnownIssueStatus = Literal["acknowledged", "planned", "in_progress", "fixed", "wont_fix"]
|
|
35
|
+
AuthMode = Literal["none", "optional", "required"]
|
|
36
|
+
FeedbackErrorCode = Literal[
|
|
37
|
+
"invalid_json",
|
|
38
|
+
"invalid_feedback",
|
|
39
|
+
"unauthorized",
|
|
40
|
+
"payload_too_large",
|
|
41
|
+
"unsupported_media_type",
|
|
42
|
+
"rate_limited",
|
|
43
|
+
"method_not_allowed",
|
|
44
|
+
"unavailable",
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class AgentInfo(TypedDict, total=False):
|
|
49
|
+
name: str
|
|
50
|
+
version: str
|
|
51
|
+
model: str
|
|
52
|
+
framework: str
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class Evidence(TypedDict, total=False):
|
|
56
|
+
status_code: int
|
|
57
|
+
request: Dict[str, Any]
|
|
58
|
+
response_excerpt: str
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class _FeedbackSubmissionRequired(TypedDict):
|
|
62
|
+
type: FeedbackType
|
|
63
|
+
goal: str
|
|
64
|
+
message: str
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class FeedbackSubmission(_FeedbackSubmissionRequired, total=False):
|
|
68
|
+
"""What an agent sends to ``POST /feedback``. See spec/feedback.schema.json."""
|
|
69
|
+
|
|
70
|
+
spec_version: Literal["0.1"]
|
|
71
|
+
endpoint: str
|
|
72
|
+
method: HttpMethod
|
|
73
|
+
outcome: Outcome
|
|
74
|
+
workaround: bool
|
|
75
|
+
workaround_description: str
|
|
76
|
+
expected: str
|
|
77
|
+
suggestion: str
|
|
78
|
+
request_id: str
|
|
79
|
+
session_id: str
|
|
80
|
+
agent: AgentInfo
|
|
81
|
+
evidence: Evidence
|
|
82
|
+
metadata: Dict[str, Any]
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class _FeedbackRecordRequired(TypedDict):
|
|
86
|
+
id: str
|
|
87
|
+
received_at: str
|
|
88
|
+
feedback: FeedbackSubmission
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class FeedbackRecord(_FeedbackRecordRequired, total=False):
|
|
92
|
+
"""A submission after a service accepted it. See spec/record.schema.json."""
|
|
93
|
+
|
|
94
|
+
service: str
|
|
95
|
+
account: str
|
|
96
|
+
source: FeedbackSource
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class _KnownIssueRequired(TypedDict):
|
|
100
|
+
id: str
|
|
101
|
+
title: str
|
|
102
|
+
status: KnownIssueStatus
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class KnownIssue(_KnownIssueRequired, total=False):
|
|
106
|
+
url: str
|
|
107
|
+
workaround: str
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class _FeedbackAckRequired(TypedDict):
|
|
111
|
+
id: str
|
|
112
|
+
status: Literal["accepted"]
|
|
113
|
+
received_at: str
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class FeedbackAck(_FeedbackAckRequired, total=False):
|
|
117
|
+
"""Body of a ``202`` response. See spec/ack.schema.json."""
|
|
118
|
+
|
|
119
|
+
known_issue: KnownIssue
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class ValidationIssue(TypedDict):
|
|
123
|
+
"""``path`` is a dot path to the offending field; ``""`` for the root object."""
|
|
124
|
+
|
|
125
|
+
path: str
|
|
126
|
+
message: str
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class _ErrorInfoRequired(TypedDict):
|
|
130
|
+
code: str
|
|
131
|
+
message: str
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class ErrorInfo(_ErrorInfoRequired, total=False):
|
|
135
|
+
details: List[ValidationIssue]
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class ErrorBody(TypedDict):
|
|
139
|
+
error: ErrorInfo
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
class DiscoveryDocument(TypedDict):
|
|
143
|
+
"""Served at ``GET /.well-known/agent-feedback``."""
|
|
144
|
+
|
|
145
|
+
spec_version: Literal["0.1"]
|
|
146
|
+
endpoint: str
|
|
147
|
+
types: List[FeedbackType]
|
|
148
|
+
max_bytes: int
|
|
149
|
+
auth: AuthMode
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class _IngestResultRequired(TypedDict):
|
|
153
|
+
id: str
|
|
154
|
+
status: Literal["accepted", "duplicate", "rejected"]
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
class IngestResult(_IngestResultRequired, total=False):
|
|
158
|
+
known_issue: KnownIssue
|
|
159
|
+
error: ErrorInfo
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
class IngestResponse(TypedDict):
|
|
163
|
+
"""Collector response to ``POST /v1/records``."""
|
|
164
|
+
|
|
165
|
+
results: List[IngestResult]
|
backloop/validate.py
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""
|
|
2
|
+
A small JSON Schema interpreter covering exactly the keywords the protocol
|
|
3
|
+
schemas use, so the SDK validates against spec/*.schema.json with zero
|
|
4
|
+
dependencies. Mirrors validate.ts in the TypeScript SDK.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import math
|
|
11
|
+
import re
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from typing import Any, Dict, Generic, List, Optional, TypeVar
|
|
14
|
+
|
|
15
|
+
from ._generated import ACK_SCHEMA, FEEDBACK_SCHEMA, RECORD_SCHEMA
|
|
16
|
+
from .types import FeedbackAck, FeedbackRecord, FeedbackSubmission, ValidationIssue
|
|
17
|
+
|
|
18
|
+
T = TypeVar("T")
|
|
19
|
+
Schema = Dict[str, Any]
|
|
20
|
+
|
|
21
|
+
_REFS: Dict[str, Schema] = {"feedback.schema.json": FEEDBACK_SCHEMA}
|
|
22
|
+
|
|
23
|
+
_RFC3339 = re.compile(
|
|
24
|
+
r"\d{4}-\d{2}-\d{2}[Tt]\d{2}:\d{2}:\d{2}(\.\d+)?([Zz]|[+-]\d{2}:\d{2})", re.ASCII
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _type_of(value: Any) -> str:
|
|
29
|
+
"""The JSON type of a Python value, as ``typeof`` would report it in validate.ts."""
|
|
30
|
+
if value is None:
|
|
31
|
+
return "null"
|
|
32
|
+
if isinstance(value, bool):
|
|
33
|
+
return "boolean"
|
|
34
|
+
if isinstance(value, (int, float)):
|
|
35
|
+
return "number"
|
|
36
|
+
if isinstance(value, str):
|
|
37
|
+
return "string"
|
|
38
|
+
if isinstance(value, (list, tuple)):
|
|
39
|
+
return "array"
|
|
40
|
+
if isinstance(value, dict):
|
|
41
|
+
return "object"
|
|
42
|
+
return type(value).__name__
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _is_integer(value: Any) -> bool:
|
|
46
|
+
# Like Number.isInteger: 400 and 400.0 are integers, 400.5 and True are not.
|
|
47
|
+
if isinstance(value, bool):
|
|
48
|
+
return False
|
|
49
|
+
if isinstance(value, int):
|
|
50
|
+
return True
|
|
51
|
+
return isinstance(value, float) and math.isfinite(value) and value.is_integer()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _same(a: Any, b: Any) -> bool:
|
|
55
|
+
"""JSON equality: unlike ``==``, ``True`` never equals ``1``."""
|
|
56
|
+
if isinstance(a, bool) or isinstance(b, bool):
|
|
57
|
+
return type(a) is type(b) and a == b
|
|
58
|
+
return a == b
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _num(n: Any) -> str:
|
|
62
|
+
return str(int(n)) if isinstance(n, float) and n.is_integer() else str(n)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _check(schema: Schema, value: Any, path: str, issues: List[ValidationIssue]) -> None:
|
|
66
|
+
ref = schema.get("$ref")
|
|
67
|
+
if ref:
|
|
68
|
+
target = _REFS.get(ref)
|
|
69
|
+
if target is None:
|
|
70
|
+
raise ValueError(f"Unresolvable $ref {ref}")
|
|
71
|
+
_check(target, value, path, issues)
|
|
72
|
+
return
|
|
73
|
+
|
|
74
|
+
actual = _type_of(value)
|
|
75
|
+
expected = schema.get("type")
|
|
76
|
+
if expected == "object" and actual != "object":
|
|
77
|
+
issues.append({"path": path, "message": "must be an object"})
|
|
78
|
+
return
|
|
79
|
+
if expected == "string" and actual != "string":
|
|
80
|
+
issues.append({"path": path, "message": "must be a string"})
|
|
81
|
+
return
|
|
82
|
+
if expected == "boolean" and actual != "boolean":
|
|
83
|
+
issues.append({"path": path, "message": "must be a boolean"})
|
|
84
|
+
return
|
|
85
|
+
if expected == "integer" and not _is_integer(value):
|
|
86
|
+
issues.append({"path": path, "message": "must be an integer"})
|
|
87
|
+
return
|
|
88
|
+
if expected == "number" and not (actual == "number" and math.isfinite(value)):
|
|
89
|
+
issues.append({"path": path, "message": "must be a number"})
|
|
90
|
+
return
|
|
91
|
+
|
|
92
|
+
if "const" in schema and not _same(value, schema["const"]):
|
|
93
|
+
const = json.dumps(schema["const"], ensure_ascii=False)
|
|
94
|
+
issues.append({"path": path, "message": f"must be {const}"})
|
|
95
|
+
enum = schema.get("enum")
|
|
96
|
+
if enum is not None and not any(_same(value, option) for option in enum):
|
|
97
|
+
issues.append({"path": path, "message": "must be one of: " + ", ".join(map(str, enum))})
|
|
98
|
+
|
|
99
|
+
if actual == "string":
|
|
100
|
+
length = len(value) # code points, as JSON Schema specifies
|
|
101
|
+
min_length = schema.get("minLength")
|
|
102
|
+
if min_length is not None and length < min_length:
|
|
103
|
+
message = (
|
|
104
|
+
"must not be empty" if min_length == 1 else f"must be at least {min_length} characters"
|
|
105
|
+
)
|
|
106
|
+
issues.append({"path": path, "message": message})
|
|
107
|
+
max_length = schema.get("maxLength")
|
|
108
|
+
if max_length is not None and length > max_length:
|
|
109
|
+
issues.append({"path": path, "message": f"must be at most {max_length} characters"})
|
|
110
|
+
if schema.get("format") == "date-time" and not _RFC3339.fullmatch(value):
|
|
111
|
+
issues.append({"path": path, "message": "must be an RFC 3339 date-time"})
|
|
112
|
+
|
|
113
|
+
if actual == "number":
|
|
114
|
+
minimum = schema.get("minimum")
|
|
115
|
+
if minimum is not None and value < minimum:
|
|
116
|
+
issues.append({"path": path, "message": f"must be >= {_num(minimum)}"})
|
|
117
|
+
maximum = schema.get("maximum")
|
|
118
|
+
if maximum is not None and value > maximum:
|
|
119
|
+
issues.append({"path": path, "message": f"must be <= {_num(maximum)}"})
|
|
120
|
+
|
|
121
|
+
if actual == "object" and ("properties" in schema or "required" in schema):
|
|
122
|
+
properties: Schema = schema.get("properties") or {}
|
|
123
|
+
|
|
124
|
+
def join(key: Any) -> str:
|
|
125
|
+
return f"{path}.{key}" if path else str(key)
|
|
126
|
+
|
|
127
|
+
for key in schema.get("required", ()):
|
|
128
|
+
if key not in value:
|
|
129
|
+
issues.append({"path": join(key), "message": "is required"})
|
|
130
|
+
for key, child in value.items():
|
|
131
|
+
child_schema = properties.get(key)
|
|
132
|
+
if child_schema is not None:
|
|
133
|
+
_check(child_schema, child, join(key), issues)
|
|
134
|
+
elif schema.get("additionalProperties") is False:
|
|
135
|
+
issues.append({"path": join(key), "message": "is not a recognised field"})
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@dataclass
|
|
139
|
+
class ValidationResult(Generic[T]):
|
|
140
|
+
"""``valid`` is True when ``issues`` is empty; ``value`` is then the validated input."""
|
|
141
|
+
|
|
142
|
+
valid: bool
|
|
143
|
+
issues: List[ValidationIssue] = field(default_factory=list)
|
|
144
|
+
value: Optional[T] = None
|
|
145
|
+
|
|
146
|
+
def __bool__(self) -> bool:
|
|
147
|
+
return self.valid
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _run(schema: Schema, value: Any) -> ValidationResult[Any]:
|
|
151
|
+
issues: List[ValidationIssue] = []
|
|
152
|
+
_check(schema, value, "", issues)
|
|
153
|
+
if issues:
|
|
154
|
+
return ValidationResult(valid=False, issues=issues)
|
|
155
|
+
return ValidationResult(valid=True, issues=[], value=value)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def validate_submission(value: Any) -> ValidationResult[FeedbackSubmission]:
|
|
159
|
+
"""Validate an agent's submission against spec/feedback.schema.json."""
|
|
160
|
+
return _run(FEEDBACK_SCHEMA, value)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def validate_record(value: Any) -> ValidationResult[FeedbackRecord]:
|
|
164
|
+
"""Validate a stored/forwarded record against spec/record.schema.json."""
|
|
165
|
+
return _run(RECORD_SCHEMA, value)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def validate_ack(value: Any) -> ValidationResult[FeedbackAck]:
|
|
169
|
+
"""Validate a ``202`` acknowledgement against spec/ack.schema.json."""
|
|
170
|
+
return _run(ACK_SCHEMA, value)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def format_issues(issues: List[ValidationIssue]) -> str:
|
|
174
|
+
"""``"goal: must not be empty; type: is required"``."""
|
|
175
|
+
return "; ".join(f"{i['path']}: {i['message']}" if i["path"] else i["message"] for i in issues)
|