webagent-protocol 0.2.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.
@@ -0,0 +1,231 @@
1
+ """Conversation guards: stop two agents from talking in circles.
2
+
3
+ When an LLM-driven user agent negotiates with an LLM-driven business agent,
4
+ neither side is guaranteed to converge. Typical failure modes are:
5
+
6
+ * **stuck repetition**: the same question gets the same answer again and again
7
+ ("do you have croissants?" → "yes, 24" → "do you have croissants?" …);
8
+ * **ping-pong cycles**: the agents alternate between two or three states
9
+ (offer A → counter B → offer A → counter B …);
10
+ * **stalls**: the same request over and over while only a counter or timestamp
11
+ in the answer changes;
12
+ * **runaway sessions**: an unbounded number of turns, each costing tokens.
13
+
14
+ :class:`ConversationGuard` detects all three from *fingerprints* of each
15
+ exchange (request plus reply). Fingerprints ignore message ids, timestamps,
16
+ signatures, proof-of-work and trivial formatting (case, whitespace,
17
+ punctuation), so cosmetic rephrasing does not hide a loop. A repeated request
18
+ that gets a *different* answer (for example, polling stock that is changing) is
19
+ progress, not a loop.
20
+
21
+ The same guard runs on both sides:
22
+
23
+ * the business agent refuses a request that would continue a detected loop,
24
+ *before* running any tool (``loop_detected`` / ``conversation_limit`` errors);
25
+ * the user agent (``WAPSession``, the MCP bridge) stops itself and tells the
26
+ model to report back to the user instead of retrying.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import hashlib
32
+ import re
33
+ import time
34
+ from collections import deque
35
+ from dataclasses import dataclass, field
36
+ from typing import Any
37
+
38
+ from .crypto import canonical_json
39
+
40
+ _PUNCT = re.compile(r"[^\w]+", re.UNICODE)
41
+
42
+
43
+ def normalize_text(text: str) -> str:
44
+ """Case-, whitespace- and punctuation-insensitive form of ``text``."""
45
+ return " ".join(_PUNCT.sub(" ", text.lower()).split())
46
+
47
+
48
+ def fingerprint(*parts: Any) -> str:
49
+ """Stable short hash of JSON-compatible parts (strings are normalised)."""
50
+ normalised = [normalize_text(p) if isinstance(p, str) else p for p in parts]
51
+ return hashlib.sha256(canonical_json(normalised)).hexdigest()[:16]
52
+
53
+
54
+ def request_fingerprint(capability_id: str | None, structured_data: dict[str, Any] | None, content: str) -> str:
55
+ return fingerprint("req", capability_id or "", structured_data or {}, content or "")
56
+
57
+
58
+ def reply_fingerprint(content: str, structured_data: dict[str, Any] | None) -> str:
59
+ return fingerprint("rep", content or "", structured_data or {})
60
+
61
+
62
+ class ConversationLimitError(Exception):
63
+ """Raised when a conversation must stop. ``reason`` is ``"loop_detected"`` or ``"conversation_limit"``."""
64
+
65
+ def __init__(self, reason: str, message: str, details: dict[str, Any] | None = None) -> None:
66
+ super().__init__(message)
67
+ self.reason = reason
68
+ self.message = message
69
+ self.details = details or {}
70
+
71
+
72
+ @dataclass(frozen=True)
73
+ class ConversationPolicy:
74
+ """Loop-protection limits. Published by business agents in ``conversation_policy``."""
75
+
76
+ max_turns: int = 100
77
+ """Hard cap on turns in one session."""
78
+ max_repeats: int = 3
79
+ """How many times the same exchange (or cycle of exchanges) may occur in a row."""
80
+ max_cycle_length: int = 3
81
+ """Longest cycle (in turns) that is detected, e.g. 2 catches A-B-A-B ping-pong."""
82
+ window_seconds: float = 300.0
83
+ """Only exchanges this recent count towards repetition; older ones are forgotten."""
84
+ max_identical_requests: int = 5
85
+ """The same request may be sent at most this many times in a row, even if answers differ
86
+ (catches stalls where only counters or timestamps change). Raise it for deliberate polling."""
87
+ max_duration_seconds: float | None = None
88
+ """Optional wall-clock limit for one session, measured from its first turn."""
89
+ max_repeats_across_sessions: int = 10
90
+ """Looser threshold for loops that hop across fresh session ids under one agent key
91
+ (one key may legitimately serve several end users)."""
92
+
93
+ def as_dict(self) -> dict[str, Any]:
94
+ return {
95
+ "max_turns": self.max_turns,
96
+ "max_repeats": self.max_repeats,
97
+ "max_cycle_length": self.max_cycle_length,
98
+ "window_seconds": self.window_seconds,
99
+ "max_identical_requests": self.max_identical_requests,
100
+ "max_repeats_across_sessions": self.max_repeats_across_sessions,
101
+ "max_duration_seconds": self.max_duration_seconds,
102
+ }
103
+
104
+ def across_sessions(self) -> ConversationPolicy:
105
+ """The policy applied per principal across sessions: repetition only, no turn cap."""
106
+ return ConversationPolicy(
107
+ max_turns=2**31,
108
+ max_repeats=self.max_repeats_across_sessions,
109
+ max_cycle_length=self.max_cycle_length,
110
+ window_seconds=self.window_seconds,
111
+ max_identical_requests=self.max_repeats_across_sessions,
112
+ max_repeats_across_sessions=self.max_repeats_across_sessions,
113
+ )
114
+
115
+
116
+ @dataclass
117
+ class _Exchange:
118
+ request: str
119
+ reply: str
120
+ at: float
121
+
122
+
123
+ @dataclass
124
+ class ConversationGuard:
125
+ """Tracks one conversation and decides whether the next request may proceed."""
126
+
127
+ policy: ConversationPolicy = field(default_factory=ConversationPolicy)
128
+ turns: int = 0
129
+ started_at: float | None = None
130
+ _history: deque[_Exchange] = field(default_factory=deque)
131
+
132
+ def to_dict(self) -> dict[str, Any]:
133
+ """JSON form, so guards can live in a shared store between requests and workers."""
134
+ return {
135
+ "turns": self.turns,
136
+ "started_at": self.started_at,
137
+ "history": [[e.request, e.reply, e.at] for e in self._history],
138
+ }
139
+
140
+ @classmethod
141
+ def from_dict(cls, policy: ConversationPolicy, data: dict[str, Any] | None) -> ConversationGuard:
142
+ guard = cls(policy)
143
+ if data:
144
+ guard.turns = int(data.get("turns", 0))
145
+ guard.started_at = data.get("started_at")
146
+ guard._history = deque(_Exchange(r, p, float(t)) for r, p, t in data.get("history", []))
147
+ return guard
148
+
149
+ def _recent(self, now: float) -> list[_Exchange]:
150
+ cutoff = now - self.policy.window_seconds
151
+ while self._history and self._history[0].at < cutoff:
152
+ self._history.popleft()
153
+ return list(self._history)
154
+
155
+ def check(self, request_fp: str, now: float | None = None) -> None:
156
+ """Raise :class:`ConversationLimitError` if sending ``request_fp`` would continue a loop.
157
+
158
+ The request is refused if the conversation is out of turns, or if the
159
+ last ``max_repeats`` repetitions of some cycle of length ≤ ``max_cycle_length``
160
+ would be extended by this request with no new information (every
161
+ exchange in the cycle got the same reply each time).
162
+ """
163
+ now = time.time() if now is None else now
164
+ limit = self.policy.max_duration_seconds
165
+ if limit is not None and self.started_at is not None and now - self.started_at > limit:
166
+ raise ConversationLimitError(
167
+ "conversation_limit",
168
+ f"this conversation exceeded its time limit of {limit:g}s",
169
+ {"max_duration_seconds": limit},
170
+ )
171
+ if self.turns >= self.policy.max_turns:
172
+ raise ConversationLimitError(
173
+ "conversation_limit",
174
+ f"this conversation reached its limit of {self.policy.max_turns} turns",
175
+ {"max_turns": self.policy.max_turns},
176
+ )
177
+ recent = self._recent(now)
178
+ identical = 0
179
+ for exchange in reversed(recent):
180
+ if exchange.request != request_fp:
181
+ break
182
+ identical += 1
183
+ if identical >= self.policy.max_identical_requests:
184
+ raise ConversationLimitError(
185
+ "loop_detected",
186
+ f"loop detected: the same request was sent {identical} times in a row without converging; "
187
+ "change the request or stop and report back to the user",
188
+ {"identical_requests": identical},
189
+ )
190
+ repeats = self.policy.max_repeats
191
+ for period in range(1, self.policy.max_cycle_length + 1):
192
+ needed = period * repeats
193
+ if len(recent) < needed:
194
+ break
195
+ tail = recent[-needed:]
196
+ block = tail[:period]
197
+ if (
198
+ all(
199
+ tail[i].request == block[i % period].request and tail[i].reply == block[i % period].reply
200
+ for i in range(needed)
201
+ )
202
+ and request_fp == block[0].request
203
+ ):
204
+ kind = "the same request" if period == 1 else f"a cycle of {period} requests"
205
+ raise ConversationLimitError(
206
+ "loop_detected",
207
+ f"loop detected: {kind} got the same answer {repeats} times in a row; "
208
+ "change the request or stop and report back to the user",
209
+ {"cycle_length": period, "repeats": repeats},
210
+ )
211
+
212
+ def record(self, request_fp: str, reply_fp: str, now: float | None = None) -> None:
213
+ now = time.time() if now is None else now
214
+ if self.started_at is None:
215
+ self.started_at = now
216
+ self.turns += 1
217
+ self._history.append(_Exchange(request_fp, reply_fp, now))
218
+ max_len = max(self.policy.max_cycle_length * self.policy.max_repeats, self.policy.max_identical_requests)
219
+ while len(self._history) > max_len:
220
+ self._history.popleft()
221
+
222
+
223
+ __all__ = [
224
+ "ConversationGuard",
225
+ "ConversationLimitError",
226
+ "ConversationPolicy",
227
+ "fingerprint",
228
+ "normalize_text",
229
+ "reply_fingerprint",
230
+ "request_fingerprint",
231
+ ]
wap/spec/crypto.py ADDED
@@ -0,0 +1,216 @@
1
+ """Ed25519 signing, verification and key management for WAP.
2
+
3
+ Signatures are computed over the RFC 8785 (JSON Canonicalization Scheme) form
4
+ of a document with its ``signature`` member removed (see :mod:`wap.spec.jcs`),
5
+ so any implementation in any language reproduces the exact signed bytes.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import hashlib
11
+ import os
12
+ from dataclasses import dataclass
13
+ from pathlib import Path
14
+ from typing import Any, TypeVar
15
+
16
+ from cryptography.exceptions import InvalidSignature
17
+ from cryptography.hazmat.primitives import serialization
18
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey, Ed25519PublicKey
19
+ from pydantic import BaseModel
20
+
21
+ from .jcs import canonicalize
22
+
23
+ SignedModelT = TypeVar("SignedModelT", bound=BaseModel)
24
+
25
+ PRIVATE_KEY_ENV = "WAP_PRIVATE_KEY"
26
+
27
+
28
+ class KeyFormatError(ValueError):
29
+ """Raised when key material is not a valid hex-encoded Ed25519 key."""
30
+
31
+
32
+ def canonical_json(obj: Any) -> bytes:
33
+ """Serialise ``obj`` into its RFC 8785 (JCS) canonical bytes, the form WAP signs."""
34
+ if isinstance(obj, BaseModel):
35
+ obj = obj.model_dump(mode="json")
36
+ return canonicalize(obj)
37
+
38
+
39
+ def signing_payload(model: BaseModel) -> bytes:
40
+ """Canonical bytes of ``model`` with its ``signature`` field excluded."""
41
+ return canonical_json(model.model_dump(mode="json", exclude={"signature"}))
42
+
43
+
44
+ def _decode_hex(value: str, length: int, what: str) -> bytes:
45
+ try:
46
+ raw = bytes.fromhex(value.strip())
47
+ except (ValueError, AttributeError) as exc:
48
+ raise KeyFormatError(f"{what} is not valid hex") from exc
49
+ if len(raw) != length:
50
+ raise KeyFormatError(f"{what} must be {length} bytes ({length * 2} hex chars), got {len(raw)}")
51
+ return raw
52
+
53
+
54
+ def load_private_key(private_key_hex: str) -> Ed25519PrivateKey:
55
+ """Load a raw 32-byte Ed25519 private key (seed) from hex."""
56
+ return Ed25519PrivateKey.from_private_bytes(_decode_hex(private_key_hex, 32, "private key"))
57
+
58
+
59
+ def load_public_key(public_key_hex: str) -> Ed25519PublicKey:
60
+ """Load a raw 32-byte Ed25519 public key from hex."""
61
+ return Ed25519PublicKey.from_public_bytes(_decode_hex(public_key_hex, 32, "public key"))
62
+
63
+
64
+ def private_key_to_hex(key: Ed25519PrivateKey) -> str:
65
+ return key.private_bytes(
66
+ encoding=serialization.Encoding.Raw,
67
+ format=serialization.PrivateFormat.Raw,
68
+ encryption_algorithm=serialization.NoEncryption(),
69
+ ).hex()
70
+
71
+
72
+ def public_key_to_hex(key: Ed25519PublicKey) -> str:
73
+ return key.public_bytes(encoding=serialization.Encoding.Raw, format=serialization.PublicFormat.Raw).hex()
74
+
75
+
76
+ def fingerprint(public_key_hex: str) -> str:
77
+ """Short, human-comparable fingerprint of a public key (``SHA256:<16 hex>``)."""
78
+ raw = _decode_hex(public_key_hex, 32, "public key")
79
+ return "SHA256:" + hashlib.sha256(raw).hexdigest()[:32]
80
+
81
+
82
+ @dataclass(frozen=True)
83
+ class KeyPair:
84
+ private_key: str
85
+ public_key: str
86
+
87
+ @property
88
+ def fingerprint(self) -> str:
89
+ return fingerprint(self.public_key)
90
+
91
+
92
+ def generate_keypair() -> KeyPair:
93
+ """Generate a fresh Ed25519 key pair, both halves hex-encoded."""
94
+ key = Ed25519PrivateKey.generate()
95
+ return KeyPair(private_key=private_key_to_hex(key), public_key=public_key_to_hex(key.public_key()))
96
+
97
+
98
+ def sign_bytes(private_key: Ed25519PrivateKey | str, data: bytes) -> str:
99
+ if isinstance(private_key, str):
100
+ private_key = load_private_key(private_key)
101
+ return private_key.sign(data).hex()
102
+
103
+
104
+ def verify_bytes(public_key: Ed25519PublicKey | str, data: bytes, signature_hex: str) -> bool:
105
+ """Return ``True`` iff ``signature_hex`` is a valid signature of ``data``."""
106
+ try:
107
+ if isinstance(public_key, str):
108
+ public_key = load_public_key(public_key)
109
+ signature = _decode_hex(signature_hex, 64, "signature")
110
+ public_key.verify(signature, data)
111
+ except (InvalidSignature, KeyFormatError, ValueError):
112
+ return False
113
+ return True
114
+
115
+
116
+ def endorsement_payload(manifest: BaseModel) -> bytes:
117
+ """Bytes a previous key signs to endorse a rotated manifest: everything but the signatures."""
118
+ return canonical_json(manifest.model_dump(mode="json", exclude={"signature", "key_endorsements"}))
119
+
120
+
121
+ def verify_endorsement(manifest: Any, previous_key: str) -> bool:
122
+ """True iff ``previous_key`` is listed in the manifest and endorsed it."""
123
+ signature = manifest.key_endorsements.get(previous_key)
124
+ if previous_key not in manifest.previous_keys or not signature:
125
+ return False
126
+ return verify_bytes(previous_key, endorsement_payload(manifest), signature)
127
+
128
+
129
+ def sign_model(model: SignedModelT, private_key: Ed25519PrivateKey | str) -> SignedModelT:
130
+ """Return a copy of ``model`` with its ``signature`` field populated."""
131
+ if "signature" not in type(model).model_fields:
132
+ raise TypeError(f"{type(model).__name__} has no signature field")
133
+ signature = sign_bytes(private_key, signing_payload(model))
134
+ return model.model_copy(update={"signature": signature})
135
+
136
+
137
+ def verify_model(model: BaseModel, public_key: Ed25519PublicKey | str) -> bool:
138
+ """Verify the embedded ``signature`` of ``model`` against ``public_key``."""
139
+ signature = getattr(model, "signature", "")
140
+ if not signature:
141
+ return False
142
+ try:
143
+ payload = signing_payload(model)
144
+ except ValueError: # not canonicalisable, so it cannot carry a valid signature
145
+ return False
146
+ return verify_bytes(public_key, payload, signature)
147
+
148
+
149
+ class Signer:
150
+ """Holds a private key and signs WAP documents on behalf of an agent."""
151
+
152
+ def __init__(self, private_key: Ed25519PrivateKey | str | None = None) -> None:
153
+ if private_key is None:
154
+ private_key = Ed25519PrivateKey.generate()
155
+ elif isinstance(private_key, str):
156
+ private_key = load_private_key(private_key)
157
+ self._key = private_key
158
+ self.public_key = public_key_to_hex(private_key.public_key())
159
+
160
+ @classmethod
161
+ def from_env(cls, variable: str = PRIVATE_KEY_ENV) -> Signer:
162
+ value = os.environ.get(variable)
163
+ if not value:
164
+ raise KeyFormatError(f"environment variable {variable} is not set")
165
+ return cls(value)
166
+
167
+ @classmethod
168
+ def from_file(cls, path: str | Path) -> Signer:
169
+ return cls(Path(path).read_text(encoding="utf-8").strip())
170
+
171
+ @property
172
+ def fingerprint(self) -> str:
173
+ return fingerprint(self.public_key)
174
+
175
+ def export_private_key(self) -> str:
176
+ return private_key_to_hex(self._key)
177
+
178
+ def sign(self, data: bytes) -> str:
179
+ return self._key.sign(data).hex()
180
+
181
+ def sign_model(self, model: SignedModelT) -> SignedModelT:
182
+ return sign_model(model, self._key)
183
+
184
+ def derive_secret(self, purpose: str, length: int = 32) -> bytes:
185
+ """Derive a symmetric secret bound to this key (HKDF-SHA256), e.g. for proof-of-work HMACs.
186
+
187
+ Every worker holding the same private key derives the same secret, so no extra
188
+ secret has to be distributed across a deployment.
189
+ """
190
+ from cryptography.hazmat.primitives import hashes
191
+ from cryptography.hazmat.primitives.kdf.hkdf import HKDF
192
+
193
+ seed = bytes.fromhex(self.export_private_key())
194
+ return HKDF(algorithm=hashes.SHA256(), length=length, salt=None, info=purpose.encode()).derive(seed)
195
+
196
+
197
+ __all__ = [
198
+ "KeyFormatError",
199
+ "KeyPair",
200
+ "PRIVATE_KEY_ENV",
201
+ "Signer",
202
+ "canonical_json",
203
+ "fingerprint",
204
+ "generate_keypair",
205
+ "load_private_key",
206
+ "load_public_key",
207
+ "private_key_to_hex",
208
+ "public_key_to_hex",
209
+ "sign_bytes",
210
+ "endorsement_payload",
211
+ "sign_model",
212
+ "verify_endorsement",
213
+ "signing_payload",
214
+ "verify_bytes",
215
+ "verify_model",
216
+ ]
wap/spec/jcs.py ADDED
@@ -0,0 +1,132 @@
1
+ """JSON Canonicalization Scheme (RFC 8785).
2
+
3
+ WAP signs the canonical form of JSON documents so that any implementation, in
4
+ any language, reproduces the exact signed bytes. JCS defines that form:
5
+
6
+ * objects: members sorted by the UTF-16 code units of their names;
7
+ * no insignificant whitespace;
8
+ * strings: the minimal ECMAScript ``JSON.stringify`` escaping, UTF-8 encoded;
9
+ * numbers: IEEE-754 doubles printed exactly as ECMAScript ``Number.prototype.toString``
10
+ prints them (shortest round-trip digits; ``1e+21`` style exponents; ``-0`` as ``0``).
11
+
12
+ Integers outside ±2**53 cannot be represented exactly by a double and are
13
+ rejected, as are NaN and infinities (I-JSON, RFC 7493).
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import math
19
+ from typing import Any
20
+
21
+ MAX_SAFE_INTEGER = 2**53
22
+
23
+ _ESCAPES = {'"': '\\"', "\\": "\\\\", "\b": "\\b", "\f": "\\f", "\n": "\\n", "\r": "\\r", "\t": "\\t"}
24
+
25
+
26
+ class CanonicalizationError(ValueError):
27
+ """The value cannot be represented in canonical JSON."""
28
+
29
+
30
+ def _string(value: str) -> str:
31
+ out = ['"']
32
+ for ch in value:
33
+ escaped = _ESCAPES.get(ch)
34
+ if escaped is not None:
35
+ out.append(escaped)
36
+ elif ch < " ":
37
+ out.append(f"\\u{ord(ch):04x}")
38
+ else:
39
+ out.append(ch)
40
+ out.append('"')
41
+ return "".join(out)
42
+
43
+
44
+ def _number(value: float | int) -> str:
45
+ if isinstance(value, bool): # bool is an int subclass; handled by the caller
46
+ raise CanonicalizationError("booleans are not numbers")
47
+ if isinstance(value, int):
48
+ if abs(value) > MAX_SAFE_INTEGER:
49
+ raise CanonicalizationError(f"integer {value} is outside the IEEE-754 safe range (±2**53)")
50
+ value = float(value)
51
+ if not math.isfinite(value):
52
+ raise CanonicalizationError("NaN and infinities are not valid JSON")
53
+ if value == 0:
54
+ return "0" # also -0
55
+ sign = "-" if value < 0 else ""
56
+ # Python's repr is the shortest round-trip representation, like ECMAScript; only the
57
+ # layout (where the decimal point goes, exponent style) differs, so re-layout the digits.
58
+ text = repr(abs(value))
59
+ mantissa, _, exponent = text.partition("e")
60
+ int_part, _, frac_part = mantissa.partition(".")
61
+ digits = (int_part + frac_part).lstrip("0")
62
+ leading_zeros = len(int_part + frac_part) - len((int_part + frac_part).lstrip("0"))
63
+ point = len(int_part) - leading_zeros + (int(exponent) if exponent else 0)
64
+ digits = digits.rstrip("0")
65
+ k, n = len(digits), point
66
+ if k <= n <= 21:
67
+ body = digits + "0" * (n - k)
68
+ elif 0 < n <= 21:
69
+ body = digits[:n] + "." + digits[n:]
70
+ elif -6 < n <= 0:
71
+ body = "0." + "0" * (-n) + digits
72
+ else:
73
+ e = n - 1
74
+ exp = f"e{'+' if e >= 0 else '-'}{abs(e)}"
75
+ body = (digits + exp) if k == 1 else (digits[0] + "." + digits[1:] + exp)
76
+ return sign + body
77
+
78
+
79
+ def _utf16_key(name: str) -> bytes:
80
+ return name.encode("utf-16-be", "surrogatepass")
81
+
82
+
83
+ def _serialize(value: Any, out: list[str]) -> None:
84
+ if value is None:
85
+ out.append("null")
86
+ elif value is True:
87
+ out.append("true")
88
+ elif value is False:
89
+ out.append("false")
90
+ elif isinstance(value, str):
91
+ out.append(_string(value))
92
+ elif isinstance(value, (int, float)):
93
+ out.append(_number(value))
94
+ elif isinstance(value, dict):
95
+ out.append("{")
96
+ for i, key in enumerate(sorted(value, key=lambda k: _utf16_key(_check_key(k)))):
97
+ if i:
98
+ out.append(",")
99
+ out.append(_string(key))
100
+ out.append(":")
101
+ _serialize(value[key], out)
102
+ out.append("}")
103
+ elif isinstance(value, (list, tuple)):
104
+ out.append("[")
105
+ for i, item in enumerate(value):
106
+ if i:
107
+ out.append(",")
108
+ _serialize(item, out)
109
+ out.append("]")
110
+ else:
111
+ raise CanonicalizationError(f"{type(value).__name__} is not a JSON type")
112
+
113
+
114
+ def _check_key(key: Any) -> str:
115
+ if not isinstance(key, str):
116
+ raise CanonicalizationError("object member names must be strings")
117
+ return key
118
+
119
+
120
+ def canonicalize(value: Any) -> bytes:
121
+ """Return the RFC 8785 canonical UTF-8 bytes of a JSON-compatible Python value."""
122
+ out: list[str] = []
123
+ _serialize(value, out)
124
+ return "".join(out).encode("utf-8", "surrogatepass")
125
+
126
+
127
+ def format_number(value: float | int) -> str:
128
+ """ECMAScript ``Number.prototype.toString`` for a finite double (exposed for tests)."""
129
+ return _number(value)
130
+
131
+
132
+ __all__ = ["CanonicalizationError", "MAX_SAFE_INTEGER", "canonicalize", "format_number"]