agenttwin 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.
Files changed (51) hide show
  1. agenttwin/__init__.py +73 -0
  2. agenttwin/__main__.py +14 -0
  3. agenttwin/__pycache__/__init__.cpython-312.pyc +0 -0
  4. agenttwin/__pycache__/__main__.cpython-312.pyc +0 -0
  5. agenttwin/__pycache__/asserts.cpython-312.pyc +0 -0
  6. agenttwin/__pycache__/cli.cpython-312.pyc +0 -0
  7. agenttwin/__pycache__/diff.cpython-312.pyc +0 -0
  8. agenttwin/__pycache__/drift.cpython-312.pyc +0 -0
  9. agenttwin/__pycache__/env.cpython-312.pyc +0 -0
  10. agenttwin/__pycache__/fingerprint.cpython-312.pyc +0 -0
  11. agenttwin/__pycache__/gallery.cpython-312.pyc +0 -0
  12. agenttwin/__pycache__/proxy.cpython-312.pyc +0 -0
  13. agenttwin/__pycache__/redact.cpython-312.pyc +0 -0
  14. agenttwin/__pycache__/report.cpython-312.pyc +0 -0
  15. agenttwin/__pycache__/trace.cpython-312.pyc +0 -0
  16. agenttwin/adapters/__init__.py +22 -0
  17. agenttwin/adapters/__pycache__/__init__.cpython-312.pyc +0 -0
  18. agenttwin/adapters/__pycache__/base.cpython-312.pyc +0 -0
  19. agenttwin/adapters/__pycache__/openai_compatible.cpython-312.pyc +0 -0
  20. agenttwin/adapters/base.py +96 -0
  21. agenttwin/adapters/openai_compatible.py +329 -0
  22. agenttwin/asserts.py +872 -0
  23. agenttwin/cli.py +655 -0
  24. agenttwin/diff.py +218 -0
  25. agenttwin/drift.py +94 -0
  26. agenttwin/env.py +363 -0
  27. agenttwin/fingerprint.py +112 -0
  28. agenttwin/fixtures/cleanup.jsonl +7 -0
  29. agenttwin/fixtures/cleanup_guarded.jsonl +5 -0
  30. agenttwin/fixtures/correct_shipping.jsonl +5 -0
  31. agenttwin/fixtures/deathloop.jsonl +22 -0
  32. agenttwin/fixtures/double_shipping.jsonl +7 -0
  33. agenttwin/fixtures/full_receipt.jsonl +5 -0
  34. agenttwin/fixtures/partial_receipt.jsonl +5 -0
  35. agenttwin/fixtures/repaired.jsonl +5 -0
  36. agenttwin/fixtures/search_first.jsonl +7 -0
  37. agenttwin/fixtures/write_first.jsonl +7 -0
  38. agenttwin/gallery.py +591 -0
  39. agenttwin/html/__init__.py +19 -0
  40. agenttwin/html/replayer.html +1467 -0
  41. agenttwin/proxy.py +431 -0
  42. agenttwin/record.py +104 -0
  43. agenttwin/redact.py +167 -0
  44. agenttwin/report.py +304 -0
  45. agenttwin/trace.py +391 -0
  46. agenttwin-0.1.0.dist-info/LICENSE +202 -0
  47. agenttwin-0.1.0.dist-info/METADATA +308 -0
  48. agenttwin-0.1.0.dist-info/RECORD +51 -0
  49. agenttwin-0.1.0.dist-info/WHEEL +5 -0
  50. agenttwin-0.1.0.dist-info/entry_points.txt +2 -0
  51. agenttwin-0.1.0.dist-info/top_level.txt +1 -0
agenttwin/__init__.py ADDED
@@ -0,0 +1,73 @@
1
+ """AgentTwin -- record one agent run, replay it forever (CONTRACTS section 3).
2
+
3
+ The tape layer (Trace, Event, Header, canonical_json, sha256_hex) is imported
4
+ eagerly. Everything that reaches the replayer is resolved by name on first
5
+ access, so importing agenttwin never depends on the replay side of the library
6
+ being importable -- in particular not on agenttwin.diff.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from importlib import import_module
12
+ from typing import Any
13
+
14
+ from .drift import ArgsDrift, DriftError, FingerprintDrift, PromptDrift, TapeMiss
15
+ from .redact import Redactor
16
+ from .trace import (
17
+ CLOCK_STEP,
18
+ EVENT_KINDS,
19
+ SCHEMA_VERSION,
20
+ Event,
21
+ Header,
22
+ SchemaError,
23
+ Trace,
24
+ canonical_json,
25
+ sha256_hex,
26
+ )
27
+
28
+ __version__: str = "0.1.0"
29
+
30
+ _LAZY: dict[str, str] = {
31
+ "Twin": ".env",
32
+ "TapeRecorder": ".env",
33
+ "ReplaySession": ".env",
34
+ "Report": ".report",
35
+ "TraceDiff": ".diff",
36
+ }
37
+
38
+ __all__ = [
39
+ "ArgsDrift",
40
+ "CLOCK_STEP",
41
+ "DriftError",
42
+ "EVENT_KINDS",
43
+ "Event",
44
+ "FingerprintDrift",
45
+ "Header",
46
+ "PromptDrift",
47
+ "Redactor",
48
+ "Report",
49
+ "ReplaySession",
50
+ "SCHEMA_VERSION",
51
+ "SchemaError",
52
+ "TapeMiss",
53
+ "TapeRecorder",
54
+ "Trace",
55
+ "TraceDiff",
56
+ "Twin",
57
+ "canonical_json",
58
+ "sha256_hex",
59
+ ]
60
+
61
+
62
+ def __getattr__(name: str) -> Any:
63
+ """PEP 562: resolve the replay-side names on first access, then cache them."""
64
+ module = _LAZY.get(name)
65
+ if module is None:
66
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
67
+ value = getattr(import_module(module, __name__), name)
68
+ globals()[name] = value
69
+ return value
70
+
71
+
72
+ def __dir__() -> list[str]:
73
+ return sorted(__all__)
agenttwin/__main__.py ADDED
@@ -0,0 +1,14 @@
1
+ """Module entry point so "python -m agenttwin" reaches the CLI (CONTRACTS section 6).
2
+
3
+ The console script installed by pyproject.toml points at agenttwin.cli:main; this
4
+ module exists so the same surface works from a source checkout without an
5
+ install. Keep it to the delegation and the exit status -- everything else,
6
+ including status mapping, belongs to agenttwin.cli.main.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from .cli import main
12
+
13
+ if __name__ == "__main__":
14
+ raise SystemExit(main())
@@ -0,0 +1,22 @@
1
+ """The adapter layer: record an agent you did not write (design doc 4.2 L1 + L2).
2
+
3
+ ``wrap`` and ``install`` speak to any object shaped like the OpenAI SDK client,
4
+ and ``invoke`` calls a controller the way CONTRACTS section 3 defines. No SDK is
5
+ imported anywhere here -- the adapter is duck-typed, so importing the package
6
+ stays free of optional dependencies.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from .base import AgentCallable, AgentProtocol, AgentRunner, invoke, is_agent
12
+ from .openai_compatible import install, wrap
13
+
14
+ __all__ = [
15
+ "AgentCallable",
16
+ "AgentProtocol",
17
+ "AgentRunner",
18
+ "install",
19
+ "invoke",
20
+ "is_agent",
21
+ "wrap",
22
+ ]
@@ -0,0 +1,96 @@
1
+ """The frozen Agent Protocol (CONTRACTS section 3) -- one call shape for Twin.
2
+
3
+ A controller is either ``agent(llm, tools)`` or an object exposing
4
+ ``agent.run(llm, tools)``; when its signature also accepts ``policy`` -- by name,
5
+ as the third positional parameter, or through ``*args`` -- Twin passes the policy
6
+ through. ``env.py`` keeps a private copy of that decision (its surface is frozen);
7
+ this module is the standalone version, so an adapter can call a controller without
8
+ importing the record/replay facade.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import inspect
14
+ from collections.abc import Mapping
15
+ from typing import Any, Protocol, runtime_checkable
16
+
17
+ __all__ = [
18
+ "AgentCallable",
19
+ "AgentProtocol",
20
+ "AgentRunner",
21
+ "is_agent",
22
+ "invoke",
23
+ ]
24
+
25
+ _POSITIONAL_KINDS = (
26
+ inspect.Parameter.POSITIONAL_ONLY,
27
+ inspect.Parameter.POSITIONAL_OR_KEYWORD,
28
+ )
29
+
30
+
31
+ @runtime_checkable
32
+ class AgentCallable(Protocol):
33
+ """CONTRACTS section 3 -- the callable form of a controller: agent(llm, tools)."""
34
+
35
+ def __call__(self, llm: Any, tools: Mapping[str, Any]) -> Any:
36
+ ...
37
+
38
+
39
+ @runtime_checkable
40
+ class AgentRunner(Protocol):
41
+ """CONTRACTS section 3 -- the object form of a controller: agent.run(llm, tools)."""
42
+
43
+ def run(self, llm: Any, tools: Mapping[str, Any]) -> Any:
44
+ ...
45
+
46
+
47
+ # CONTRACTS section 3 allows either form, so the protocol is their union: a single
48
+ # Protocol cannot express it, because every member of a Protocol is required.
49
+ AgentProtocol = AgentCallable | AgentRunner
50
+
51
+
52
+ def is_agent(agent: Any) -> bool:
53
+ """True when invoke() can call agent: the callable form or the .run form (CONTRACTS section 3)."""
54
+ return callable(getattr(agent, "run", None)) or callable(agent)
55
+
56
+
57
+ def invoke(agent: Any, llm: Any, tools: Mapping[str, Any], policy: Any = None) -> Any:
58
+ """Call agent the way CONTRACTS section 3 defines it and return its result.
59
+
60
+ ``run`` wins over ``__call__`` when an object offers both (env.py decides the
61
+ same way). A signature that accepts ``policy`` always receives it, including
62
+ ``None``: a controller whose third parameter is required stays callable when
63
+ no policy was supplied.
64
+ """
65
+ runner = getattr(agent, "run", None)
66
+ fn = runner if callable(runner) else agent
67
+ if not callable(fn):
68
+ raise TypeError(
69
+ "agent must be callable(llm, tools) or expose run(llm, tools); "
70
+ f"got {type(agent).__name__}"
71
+ )
72
+ style = _policy_style(fn)
73
+ if style == "keyword":
74
+ return fn(llm, tools, policy=policy)
75
+ if style == "positional":
76
+ return fn(llm, tools, policy)
77
+ return fn(llm, tools)
78
+
79
+
80
+ def _policy_style(fn: Any) -> str | None:
81
+ """How fn wants the policy: "keyword", "positional", or None when it takes none."""
82
+ try:
83
+ parameters = inspect.signature(fn).parameters
84
+ except (TypeError, ValueError):
85
+ # Not introspectable (a C builtin): the documented (llm, tools) form is all we can promise.
86
+ return None
87
+ named = parameters.get("policy")
88
+ if named is not None:
89
+ if named.kind is inspect.Parameter.POSITIONAL_ONLY:
90
+ return "positional"
91
+ return "keyword"
92
+ kinds = [parameter.kind for parameter in parameters.values()]
93
+ if inspect.Parameter.VAR_POSITIONAL in kinds:
94
+ return "positional"
95
+ positional = sum(1 for kind in kinds if kind in _POSITIONAL_KINDS)
96
+ return "positional" if positional >= 3 else None
@@ -0,0 +1,329 @@
1
+ """The openai-compatible adapter (design doc section 4.2, L1 + L2).
2
+
3
+ Duck-typed on purpose: nothing here imports the SDK, so importing the package
4
+ never pulls one in. ``wrap`` records into a tape the caller already opened;
5
+ ``install`` opens its own tape from the environment and writes it when the
6
+ interpreter exits.
7
+
8
+ Recording follows CONTRACTS section 1.4 and 9.20, which replay depends on: the
9
+ request the caller passed is hashed *before* redaction, the preview is the
10
+ redacted and truncated request, and the response is carried as JSON. Nothing
11
+ beyond ``chat.completions.create(**kwargs)`` is modelled: the adapter is not an
12
+ SDK shim.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import atexit
18
+ import json
19
+ import os
20
+ from collections.abc import Mapping
21
+ from pathlib import Path
22
+ from typing import Any
23
+
24
+ from ..env import TapeRecorder, Twin
25
+ from ..proxy import LLM_ACTOR, PREVIEW_LIMIT
26
+ from ..redact import Redactor
27
+ from ..trace import Trace, canonical_json, sha256_hex
28
+
29
+ __all__ = ["RECORD_OUT_ENV", "RECORD_TRACE_ID_ENV", "install", "wrap"]
30
+
31
+ # The L2 zero-config pair. An unset AGENTTWIN_RECORD_OUT is a configuration
32
+ # error, never a silent no-op: recording nowhere is how a tape goes missing.
33
+ RECORD_OUT_ENV: str = "AGENTTWIN_RECORD_OUT"
34
+ RECORD_TRACE_ID_ENV: str = "AGENTTWIN_TRACE_ID"
35
+
36
+ _UNSAFE: Any = object()
37
+
38
+
39
+ def wrap(client: Any, tape: Any = None, *, model: str | None = None) -> Any:
40
+ """CONTRACTS section 3 -- route client.chat.completions.create through a tape.
41
+
42
+ tape is a TapeRecorder from Twin.record() or a Trace; None keeps the proxy
43
+ transparent. model names the model for calls whose kwargs omit it.
44
+
45
+ A TapeRecorder is told the substitutions this proxy makes, so its header
46
+ counts them (CONTRACTS 9.28). A bare Trace's header is already final and is
47
+ never retroactively recounted: with no recorder there is nothing to rebuild it.
48
+ """
49
+ _require_client(client)
50
+ return _RecordingClient(
51
+ client, _trace_of(tape), _redactor_of(tape), model, _note_of(tape)
52
+ )
53
+
54
+
55
+ def install(
56
+ client: Any,
57
+ *,
58
+ out: str | Path | None = None,
59
+ trace_id: str | None = None,
60
+ model: str | None = None,
61
+ ) -> Any:
62
+ """Design doc section 4.2 L2 -- zero-config recording for an existing client.
63
+
64
+ out falls back to AGENTTWIN_RECORD_OUT and trace_id to AGENTTWIN_TRACE_ID
65
+ (then to the tape filename). The tape is written when the interpreter
66
+ exits; call flush() on the returned proxy to write it sooner.
67
+ """
68
+ _require_client(client)
69
+ target = _target(out)
70
+ trace_id = trace_id or _env(RECORD_TRACE_ID_ENV) or target.stem or "trace"
71
+ # The adapter scrubs every preview, so the tape must say so: a header policy of
72
+ # "no patterns" printed next to a scrubbed preview would be a false statement.
73
+ recorder = Twin.record(trace_id, out=target, model=model, redactor=Redactor())
74
+ proxy = _InstalledClient(client, recorder, model)
75
+ atexit.register(proxy.flush)
76
+ return proxy
77
+
78
+
79
+ # --- client shape ----------------------------------------------------------
80
+
81
+
82
+ def _require_client(client: Any) -> Any:
83
+ """Fail at wrap time on a shape no create() call could ever reach."""
84
+ try:
85
+ create = client.chat.completions.create
86
+ except AttributeError:
87
+ raise TypeError(
88
+ "client must look like the OpenAI SDK -- a callable create(**kwargs) on "
89
+ f"client.chat.completions; got {type(client).__name__}"
90
+ ) from None
91
+ if not callable(create):
92
+ raise TypeError(f"client.chat.completions.create must be callable; got {type(create).__name__}")
93
+ return client
94
+
95
+
96
+ def _target(out: str | Path | None) -> Path:
97
+ """The tape path from out or the environment; refuse to record nowhere."""
98
+ raw = out if out is not None else _env(RECORD_OUT_ENV)
99
+ if not raw:
100
+ raise RuntimeError(
101
+ "install() has no tape to write: pass out=... or set "
102
+ f"{RECORD_OUT_ENV} (optionally {RECORD_TRACE_ID_ENV} for the trace id); "
103
+ "refusing to run unrecorded"
104
+ )
105
+ target = Path(raw)
106
+ target.parent.mkdir(parents=True, exist_ok=True)
107
+ return target
108
+
109
+
110
+ def _env(name: str) -> str | None:
111
+ """An environment variable, with the empty string treated as unset."""
112
+ return os.environ.get(name) or None
113
+
114
+
115
+ def _trace_of(tape: Any) -> Trace | None:
116
+ """The Trace behind a TapeRecorder (or a Trace itself); None records nothing."""
117
+ if tape is None:
118
+ return None
119
+ trace = getattr(tape, "trace", tape)
120
+ if not callable(getattr(trace, "append", None)):
121
+ raise TypeError(f"tape must be a TapeRecorder or a Trace; got {type(tape).__name__}")
122
+ return trace
123
+
124
+
125
+ def _redactor_of(tape: Any) -> Any:
126
+ """Reuse the recorder's policy; a bare Trace or wrap(tape=None) scrubs by default.
127
+
128
+ The default is the full pattern set rather than "no redaction": the preview is
129
+ the only place a real request is stored, so it must never carry a secret
130
+ (CONTRACTS 9.20).
131
+ """
132
+ redactor = getattr(tape, "redactor", None)
133
+ if callable(getattr(redactor, "payload", None)):
134
+ return redactor
135
+ return Redactor()
136
+
137
+
138
+ def _note_of(tape: Any) -> Any:
139
+ """The recorder's redaction counter, or None for a bare Trace (CONTRACTS 9.28)."""
140
+ note = getattr(tape, "note_redactions", None)
141
+ return note if callable(note) else None
142
+
143
+
144
+ # --- the proxies -----------------------------------------------------------
145
+
146
+
147
+ class _RecordingClient:
148
+ """A proxy over an openai-compatible client: create() records, the rest delegates."""
149
+
150
+ def __init__(
151
+ self,
152
+ client: Any,
153
+ trace: Trace | None,
154
+ redactor: Any,
155
+ model: str | None,
156
+ note: Any = None,
157
+ ) -> None:
158
+ self._client = client
159
+ self._trace = trace
160
+ self._redactor = redactor
161
+ self._model = model
162
+ self._note = note
163
+ self._chat = _ChatProxy(self)
164
+
165
+ @property
166
+ def chat(self) -> "_ChatProxy":
167
+ """The client's chat namespace, whose completions.create records the call."""
168
+ return self._chat
169
+
170
+ def __getattr__(self, name: str) -> Any:
171
+ """CONTRACTS section 3 -- everything that is not create() is the real client's."""
172
+ try:
173
+ client = object.__getattribute__(self, "_client")
174
+ except AttributeError:
175
+ raise AttributeError(name) from None
176
+ return getattr(client, name)
177
+
178
+ def __repr__(self) -> str:
179
+ return f"<agenttwin adapter for {type(self._client).__name__}>"
180
+
181
+ def _create(self, kwargs: dict) -> Any:
182
+ """One create() in, one llm_call event out; the real response is returned.
183
+
184
+ The event is appended only after the real call returns, so a call that
185
+ raises records nothing -- the same rule proxy.RecordingLLM follows.
186
+ """
187
+ response = self._client.chat.completions.create(**kwargs)
188
+ if self._trace is not None:
189
+ self._trace.append("llm_call", LLM_ACTOR, self._payload(kwargs, response))
190
+ return response
191
+
192
+ def _payload(self, request: dict, response: Any) -> dict:
193
+ """CONTRACTS section 1.4 -- the llm_call payload, hashed before redaction."""
194
+ payload: dict[str, Any] = {
195
+ "prompt_hash": sha256_hex(canonical_json(request)),
196
+ "response": _json_safe(response),
197
+ "prompt_preview": canonical_json(request),
198
+ }
199
+ model = request.get("model")
200
+ if model is None:
201
+ model = self._model
202
+ if model is not None:
203
+ payload["model"] = model
204
+ return _scrub("llm_call", payload, self._redactor, self._note)
205
+
206
+
207
+ class _ChatProxy:
208
+ """client.chat: completions records, every other attribute is the real namespace's."""
209
+
210
+ def __init__(self, owner: _RecordingClient) -> None:
211
+ self._owner = owner
212
+ self._completions = _CompletionsProxy(owner)
213
+
214
+ @property
215
+ def completions(self) -> "_CompletionsProxy":
216
+ """The recording completions namespace."""
217
+ return self._completions
218
+
219
+ def __getattr__(self, name: str) -> Any:
220
+ return getattr(self._owner._client.chat, name)
221
+
222
+
223
+ class _CompletionsProxy:
224
+ """client.chat.completions: create() records, every other attribute delegates."""
225
+
226
+ def __init__(self, owner: _RecordingClient) -> None:
227
+ self._owner = owner
228
+
229
+ def create(self, **kwargs: Any) -> Any:
230
+ """CONTRACTS section 1.4 -- one call in, one llm_call event out."""
231
+ return self._owner._create(kwargs)
232
+
233
+ def __getattr__(self, name: str) -> Any:
234
+ return getattr(self._owner._client.chat.completions, name)
235
+
236
+
237
+ class _InstalledClient(_RecordingClient):
238
+ """install()'s proxy: it owns the tape, so it decides when the tape is written."""
239
+
240
+ def __init__(self, client: Any, recorder: TapeRecorder, model: str | None) -> None:
241
+ self._recorder = recorder
242
+ super().__init__(
243
+ client, recorder.trace, _redactor_of(recorder), model, _note_of(recorder)
244
+ )
245
+
246
+ def flush(self) -> None:
247
+ """Write the tape now, terminal event included; calling it twice is harmless."""
248
+ self._recorder.__exit__(None, None, None)
249
+
250
+
251
+ # --- payload helpers -------------------------------------------------------
252
+
253
+
254
+ def _scrub(kind: str, payload: dict, redactor: Any, note: Any = None) -> dict:
255
+ """CONTRACTS 9.20 -- hashes stay verbatim, previews are scrubbed then truncated.
256
+
257
+ A sha256 is itself a 32+ hex run, so redaction would otherwise rewrite the
258
+ very value replay compares; proxy.py splits the two for the same reason.
259
+
260
+ note, when given, receives the substitution count so the tape header can
261
+ report it (CONTRACTS 9.28); a bare Trace has no recorder to tell.
262
+ """
263
+ hashes = {key: value for key, value in payload.items() if key.endswith("_hash")}
264
+ scrub = {key: value for key, value in payload.items() if not key.endswith("_hash")}
265
+ scrubbed, count = redactor.payload(kind, scrub)
266
+ if note is not None:
267
+ note(count)
268
+ scrubbed.update(hashes)
269
+ for key, value in scrubbed.items():
270
+ if key.endswith("_preview") and isinstance(value, str) and len(value) > PREVIEW_LIMIT:
271
+ scrubbed[key] = value[:PREVIEW_LIMIT]
272
+ return scrubbed
273
+
274
+
275
+ def _json_safe(response: Any) -> Any:
276
+ """CONTRACTS section 1.4 -- the response as something the tape can carry.
277
+
278
+ model_dump(), to_dict(), a mapping, the JSON-safe entries of __dict__, then
279
+ {"repr": ...}: a tape line always names what came back instead of failing
280
+ the recording on an SDK object.
281
+ """
282
+ for name in ("model_dump", "to_dict"):
283
+ value = _called_json(response, name)
284
+ if value is not _UNSAFE:
285
+ return value
286
+ if isinstance(response, Mapping):
287
+ value = _round_trip(dict(response))
288
+ if value is not _UNSAFE:
289
+ return value
290
+ value = _attributes_json(response)
291
+ if value is not _UNSAFE:
292
+ return value
293
+ return {"repr": repr(response)}
294
+
295
+
296
+ def _called_json(response: Any, name: str) -> Any:
297
+ """response.name() round-tripped, or _UNSAFE when absent, raising or not JSON."""
298
+ try:
299
+ method = getattr(response, name, None)
300
+ except Exception:
301
+ return _UNSAFE
302
+ if not callable(method):
303
+ return _UNSAFE
304
+ try:
305
+ return _round_trip(method())
306
+ except Exception:
307
+ return _UNSAFE
308
+
309
+
310
+ def _attributes_json(response: Any) -> Any:
311
+ """The __dict__ entries JSON can represent, or _UNSAFE when there are none."""
312
+ try:
313
+ data = vars(response)
314
+ except TypeError:
315
+ return _UNSAFE
316
+ picked = {
317
+ key: value
318
+ for key, value in data.items()
319
+ if isinstance(key, str) and _round_trip(value) is not _UNSAFE
320
+ }
321
+ return _round_trip(picked) if picked else _UNSAFE
322
+
323
+
324
+ def _round_trip(value: Any) -> Any:
325
+ """value through json and back, or _UNSAFE when json cannot represent it."""
326
+ try:
327
+ return json.loads(json.dumps(value, sort_keys=True))
328
+ except (TypeError, ValueError):
329
+ return _UNSAFE