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/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)