mirrors-sdk 0.1.0__tar.gz

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 (33) hide show
  1. mirrors_sdk-0.1.0/MANIFEST.in +2 -0
  2. mirrors_sdk-0.1.0/PKG-INFO +180 -0
  3. mirrors_sdk-0.1.0/README.md +166 -0
  4. mirrors_sdk-0.1.0/pyproject.toml +25 -0
  5. mirrors_sdk-0.1.0/setup.cfg +4 -0
  6. mirrors_sdk-0.1.0/src/mirrors_sdk/__init__.py +33 -0
  7. mirrors_sdk-0.1.0/src/mirrors_sdk/capture.py +254 -0
  8. mirrors_sdk-0.1.0/src/mirrors_sdk/circuit.py +47 -0
  9. mirrors_sdk-0.1.0/src/mirrors_sdk/client.py +339 -0
  10. mirrors_sdk-0.1.0/src/mirrors_sdk/configuration.py +75 -0
  11. mirrors_sdk-0.1.0/src/mirrors_sdk/context.py +254 -0
  12. mirrors_sdk-0.1.0/src/mirrors_sdk/delivery.py +96 -0
  13. mirrors_sdk-0.1.0/src/mirrors_sdk/models.py +107 -0
  14. mirrors_sdk-0.1.0/src/mirrors_sdk/py.typed +0 -0
  15. mirrors_sdk-0.1.0/src/mirrors_sdk/resolver.py +69 -0
  16. mirrors_sdk-0.1.0/src/mirrors_sdk/snapshot.py +95 -0
  17. mirrors_sdk-0.1.0/src/mirrors_sdk/transport.py +184 -0
  18. mirrors_sdk-0.1.0/src/mirrors_sdk/types.py +87 -0
  19. mirrors_sdk-0.1.0/src/mirrors_sdk/wire.py +310 -0
  20. mirrors_sdk-0.1.0/src/mirrors_sdk/worker.py +138 -0
  21. mirrors_sdk-0.1.0/src/mirrors_sdk.egg-info/PKG-INFO +180 -0
  22. mirrors_sdk-0.1.0/src/mirrors_sdk.egg-info/SOURCES.txt +31 -0
  23. mirrors_sdk-0.1.0/src/mirrors_sdk.egg-info/dependency_links.txt +1 -0
  24. mirrors_sdk-0.1.0/src/mirrors_sdk.egg-info/requires.txt +1 -0
  25. mirrors_sdk-0.1.0/src/mirrors_sdk.egg-info/top_level.txt +1 -0
  26. mirrors_sdk-0.1.0/tests/fixtures/canonical-events.json +38 -0
  27. mirrors_sdk-0.1.0/tests/http_fixture.py +159 -0
  28. mirrors_sdk-0.1.0/tests/test_capture.py +459 -0
  29. mirrors_sdk-0.1.0/tests/test_host_safety.py +260 -0
  30. mirrors_sdk-0.1.0/tests/test_integration.py +311 -0
  31. mirrors_sdk-0.1.0/tests/test_resolver.py +83 -0
  32. mirrors_sdk-0.1.0/tests/test_transport.py +287 -0
  33. mirrors_sdk-0.1.0/tests/test_wire.py +380 -0
@@ -0,0 +1,2 @@
1
+ recursive-include tests *.py
2
+ recursive-include tests *.json
@@ -0,0 +1,180 @@
1
+ Metadata-Version: 2.4
2
+ Name: mirrors-sdk
3
+ Version: 0.1.0
4
+ Summary: Background trace capture and bounded optional context for Python agents
5
+ Classifier: Programming Language :: Python :: 3
6
+ Classifier: Programming Language :: Python :: 3 :: Only
7
+ Classifier: Programming Language :: Python :: 3.11
8
+ Classifier: Programming Language :: Python :: 3.12
9
+ Classifier: Programming Language :: Python :: 3.13
10
+ Classifier: Programming Language :: Python :: 3.14
11
+ Requires-Python: >=3.11
12
+ Description-Content-Type: text/markdown
13
+ Requires-Dist: httpx<0.29,>=0.28.1
14
+
15
+ # Mirrors Python SDK
16
+
17
+ Capture original conversation history in the background and retrieve optional
18
+ context before assembling a model request. Your application keeps control of its
19
+ system prompt, model calls, tools, streaming, errors and cancellation.
20
+
21
+ Python 3.11 or later. This source package is **not yet published to PyPI**.
22
+ Install the reviewed source locally:
23
+
24
+ ```sh
25
+ python -m pip install ./packages/python-sdk
26
+ ```
27
+
28
+ ## Synchronous integration
29
+
30
+ ```python
31
+ import os
32
+ from uuid import uuid4
33
+
34
+ from mirrors_sdk import Mirrors
35
+
36
+ mirrors = Mirrors(
37
+ api_url=os.environ["MIRRORS_API_URL"],
38
+ api_key=os.environ["MIRRORS_API_KEY"],
39
+ agent_id=os.environ["MIRRORS_AGENT_ID"],
40
+ )
41
+ execution_id = str(uuid4())
42
+
43
+ # Map the application's complete original history to canonical messages.
44
+ trace = {
45
+ "id": execution_id,
46
+ "messages": [{"role": "user", "content": "Help with my account"}],
47
+ }
48
+ mirrors.capture_trace(trace) # Returns immediately; do not wait before a model call.
49
+
50
+ selected = mirrors.get_context(trace, timeout_ms=1000)
51
+ system = original_system_prompt
52
+ if selected.context:
53
+ system += "\n\n" + selected.context
54
+
55
+ # The application's existing model call and error/stream handling stay here.
56
+ response = existing_model_call(system=system, messages=original_messages)
57
+ ```
58
+
59
+ Appending the returned text is synchronous. `get_context` is also a synchronous
60
+ Python method, but it may wait for optional network context for **at most 1,000 ms
61
+ from call entry**, including snapshot copying and scheduling. Use a shorter
62
+ `timeout_ms` from 1–1000 to reduce that wait. Timing is bounded best effort, not a
63
+ hard real-time guarantee while Python or the operating system cannot schedule work.
64
+ It does not wait for capture delivery. No user event loop or async framework is
65
+ required. Async applications may use `await mirrors.aget_context(trace)` to avoid
66
+ blocking their event loop.
67
+
68
+ The SDK starts its mode heartbeat in the background. Until a valid mode lease is
69
+ available, or after it expires, retrieval immediately returns empty local shadow
70
+ fallback. Confirmed shadow returns empty while evaluating in the background; off
71
+ does not retrieve context. Capture continues in every server mode. A successful
72
+ response contains `context`, `mode`, `delivery`, `reason`, `elapsed_ms` and nullable
73
+ `freshness`; `delivery == "accepted"` means receipt, not application or model success.
74
+ Use text promptly and check its `freshness` expiry if dispatch is delayed.
75
+
76
+ Copy the URL and agent ID from the agent's SDK connection settings. Keep keys in
77
+ server secrets with runtime read/write permissions. The SDK refuses redirects and
78
+ requires HTTPS, except for literal loopback development addresses. It never logs
79
+ keys, requests or conversation contents.
80
+
81
+ ## Original evidence and final capture
82
+
83
+ Canonical messages preserve original roles (`system`, `user`, `assistant`, `tool`),
84
+ text, reasoning, block order and tool call/result IDs. Preserve original history;
85
+ do not include SDK context in evidence or invent missing associations. Capture
86
+ copies accepted input so later caller mutations cannot change an admitted request.
87
+ Oversized, cyclic, non-JSON or custom-object inputs are rejected without truncation.
88
+
89
+ After the execution and its response stream have ended, capture the full final
90
+ history:
91
+
92
+ ```python
93
+ ticket = mirrors.capture_trace(
94
+ completed_original_trace,
95
+ final=True,
96
+ terminal="completed", # Or "failed" / "canceled"; not a task-success label.
97
+ )
98
+ # Inspect outside the model path. wait() takes seconds and never waits indefinitely.
99
+ receipt = ticket.wait(timeout=2)
100
+ ```
101
+
102
+ `ticket.receipt.status` distinguishes `pending`, `accepted`, `dropped` and
103
+ `unconfirmed`. A timeout or connection loss may follow a server commit, so
104
+ `unconfirmed` does not mean rejected. Accepted ingestion with `status == "queued"`
105
+ does not mean analysis has finished. Retries retain the same immutable request
106
+ identity; there is no infinite retry loop or durable crash queue.
107
+
108
+ Use a fresh execution ID after restart, finalization or `release_trace(id)`. Release
109
+ only performs local cleanup; it does not claim server completion. For a conversation
110
+ spanning executions, supply both canonical fields `conversationId` and
111
+ `conversationRevision`, with a durable, atomically allocated increasing revision.
112
+ Keep that pair and source metadata fixed throughout one execution. Optional
113
+ `user: {"id": ..., "segment": ...}` uses opaque internal IDs, never email addresses.
114
+ These canonical JSON field names match the TypeScript SDK and wire protocol.
115
+
116
+ For whole-turn latency, call `turn = mirrors.start_turn()` at user-message acceptance
117
+ and `turn.finish()` after the final response, including tools, streaming and retries.
118
+ The first finish freezes monotonic milliseconds. Supply it as `turnLatencyMs` on the
119
+ final trace only; omit unknown duration. Timing does not imply success.
120
+
121
+ ## Shutdown and emergency bypass
122
+
123
+ ```python
124
+ # At shutdown, outside the agent's model path:
125
+ summary = mirrors.flush(timeout_ms=2000)
126
+ mirrors.dispose()
127
+ ```
128
+
129
+ `flush` waits only for capture tickets pending when called and reports accepted,
130
+ dropped, unconfirmed and pending counts. Its `complete` flag is true only when every
131
+ selected ticket was accepted. `close(timeout_ms=2000)` combines a bounded flush
132
+ and disposal. `dispose()` immediately disables optional work without waiting for
133
+ network completion. Background threads do not keep the Python process alive, so
134
+ process exit may abandon captures; flush explicitly when confirmation matters.
135
+
136
+ `set_enabled(False)` cancels optional work and clears active state. Re-enabling
137
+ requires fresh execution IDs. Invalid configuration stays inert and yields explicit
138
+ fallback receipts. SDK failures do not invoke, retry or replace application model
139
+ calls. Cancellation passed as `threading.Event` to `get_context(..., cancel=event)`
140
+ returns a distinct canceled result; preserve the application's own cancellation
141
+ check before invoking its provider. Async task cancellation propagates normally.
142
+
143
+ ## Limits and design tradeoffs
144
+
145
+ | Limit | Value |
146
+ | --- | --- |
147
+ | Encoded snapshot | 1,000,000 bytes; 10,000 messages; 50,000 JSON nodes; depth 32 |
148
+ | Pending captures | 16 prefixes and 16 finals, separately |
149
+ | Active execution state / recent final receipts | 64 / 128 |
150
+ | Pending context retrievals | 16, independent of capture |
151
+ | Capture timeout defaults | 1 second prefix; 6 seconds final |
152
+ | Explicit capture `timeout_ms` | 1–30,000 ms |
153
+ | Context / response bytes | 32,768 / 65,536 |
154
+ | Circuit default | 3 failures, 30-second cooldown, one recovery probe per endpoint |
155
+ | Mode heartbeat / lease | 1 second with jitter / at most 3 seconds |
156
+
157
+ One daemon event-loop thread and one HTTPX client own network work; at most two
158
+ daemon DNS lookups isolate stalled system resolution. The benefit is cancellable,
159
+ pooled HTTP for synchronous callers without a thread per upload. The cost is the
160
+ HTTPX dependency and best-effort background delivery. Endpoint circuits
161
+ are independent, so a successful capture cannot hide context failures. Uncertain
162
+ captures reconcile before newer ordinals; final retries share their original budget.
163
+
164
+ The package mirrors the current TypeScript `captureTrace` / `getContext` boundary.
165
+ It does not port the legacy session adapters or ready-only injection helpers, and
166
+ it contains no private policy engine or provider integration. Tests cover local
167
+ protocol behavior and package installation; they do not establish PyPI publication,
168
+ hosted service compatibility, production latency or model-quality improvement.
169
+
170
+ ## Development
171
+
172
+ ```sh
173
+ python -m pip install ./packages/python-sdk build
174
+ python -m unittest discover -s packages/python-sdk/tests -v
175
+ python -m build packages/python-sdk
176
+ ```
177
+
178
+ CI runs behavioral and real loopback HTTP tests on Python 3.11, 3.13 and 3.14, then
179
+ builds and installs the wheel in a clean environment. No test calls a real provider
180
+ or a hosted Mirrors service.
@@ -0,0 +1,166 @@
1
+ # Mirrors Python SDK
2
+
3
+ Capture original conversation history in the background and retrieve optional
4
+ context before assembling a model request. Your application keeps control of its
5
+ system prompt, model calls, tools, streaming, errors and cancellation.
6
+
7
+ Python 3.11 or later. This source package is **not yet published to PyPI**.
8
+ Install the reviewed source locally:
9
+
10
+ ```sh
11
+ python -m pip install ./packages/python-sdk
12
+ ```
13
+
14
+ ## Synchronous integration
15
+
16
+ ```python
17
+ import os
18
+ from uuid import uuid4
19
+
20
+ from mirrors_sdk import Mirrors
21
+
22
+ mirrors = Mirrors(
23
+ api_url=os.environ["MIRRORS_API_URL"],
24
+ api_key=os.environ["MIRRORS_API_KEY"],
25
+ agent_id=os.environ["MIRRORS_AGENT_ID"],
26
+ )
27
+ execution_id = str(uuid4())
28
+
29
+ # Map the application's complete original history to canonical messages.
30
+ trace = {
31
+ "id": execution_id,
32
+ "messages": [{"role": "user", "content": "Help with my account"}],
33
+ }
34
+ mirrors.capture_trace(trace) # Returns immediately; do not wait before a model call.
35
+
36
+ selected = mirrors.get_context(trace, timeout_ms=1000)
37
+ system = original_system_prompt
38
+ if selected.context:
39
+ system += "\n\n" + selected.context
40
+
41
+ # The application's existing model call and error/stream handling stay here.
42
+ response = existing_model_call(system=system, messages=original_messages)
43
+ ```
44
+
45
+ Appending the returned text is synchronous. `get_context` is also a synchronous
46
+ Python method, but it may wait for optional network context for **at most 1,000 ms
47
+ from call entry**, including snapshot copying and scheduling. Use a shorter
48
+ `timeout_ms` from 1–1000 to reduce that wait. Timing is bounded best effort, not a
49
+ hard real-time guarantee while Python or the operating system cannot schedule work.
50
+ It does not wait for capture delivery. No user event loop or async framework is
51
+ required. Async applications may use `await mirrors.aget_context(trace)` to avoid
52
+ blocking their event loop.
53
+
54
+ The SDK starts its mode heartbeat in the background. Until a valid mode lease is
55
+ available, or after it expires, retrieval immediately returns empty local shadow
56
+ fallback. Confirmed shadow returns empty while evaluating in the background; off
57
+ does not retrieve context. Capture continues in every server mode. A successful
58
+ response contains `context`, `mode`, `delivery`, `reason`, `elapsed_ms` and nullable
59
+ `freshness`; `delivery == "accepted"` means receipt, not application or model success.
60
+ Use text promptly and check its `freshness` expiry if dispatch is delayed.
61
+
62
+ Copy the URL and agent ID from the agent's SDK connection settings. Keep keys in
63
+ server secrets with runtime read/write permissions. The SDK refuses redirects and
64
+ requires HTTPS, except for literal loopback development addresses. It never logs
65
+ keys, requests or conversation contents.
66
+
67
+ ## Original evidence and final capture
68
+
69
+ Canonical messages preserve original roles (`system`, `user`, `assistant`, `tool`),
70
+ text, reasoning, block order and tool call/result IDs. Preserve original history;
71
+ do not include SDK context in evidence or invent missing associations. Capture
72
+ copies accepted input so later caller mutations cannot change an admitted request.
73
+ Oversized, cyclic, non-JSON or custom-object inputs are rejected without truncation.
74
+
75
+ After the execution and its response stream have ended, capture the full final
76
+ history:
77
+
78
+ ```python
79
+ ticket = mirrors.capture_trace(
80
+ completed_original_trace,
81
+ final=True,
82
+ terminal="completed", # Or "failed" / "canceled"; not a task-success label.
83
+ )
84
+ # Inspect outside the model path. wait() takes seconds and never waits indefinitely.
85
+ receipt = ticket.wait(timeout=2)
86
+ ```
87
+
88
+ `ticket.receipt.status` distinguishes `pending`, `accepted`, `dropped` and
89
+ `unconfirmed`. A timeout or connection loss may follow a server commit, so
90
+ `unconfirmed` does not mean rejected. Accepted ingestion with `status == "queued"`
91
+ does not mean analysis has finished. Retries retain the same immutable request
92
+ identity; there is no infinite retry loop or durable crash queue.
93
+
94
+ Use a fresh execution ID after restart, finalization or `release_trace(id)`. Release
95
+ only performs local cleanup; it does not claim server completion. For a conversation
96
+ spanning executions, supply both canonical fields `conversationId` and
97
+ `conversationRevision`, with a durable, atomically allocated increasing revision.
98
+ Keep that pair and source metadata fixed throughout one execution. Optional
99
+ `user: {"id": ..., "segment": ...}` uses opaque internal IDs, never email addresses.
100
+ These canonical JSON field names match the TypeScript SDK and wire protocol.
101
+
102
+ For whole-turn latency, call `turn = mirrors.start_turn()` at user-message acceptance
103
+ and `turn.finish()` after the final response, including tools, streaming and retries.
104
+ The first finish freezes monotonic milliseconds. Supply it as `turnLatencyMs` on the
105
+ final trace only; omit unknown duration. Timing does not imply success.
106
+
107
+ ## Shutdown and emergency bypass
108
+
109
+ ```python
110
+ # At shutdown, outside the agent's model path:
111
+ summary = mirrors.flush(timeout_ms=2000)
112
+ mirrors.dispose()
113
+ ```
114
+
115
+ `flush` waits only for capture tickets pending when called and reports accepted,
116
+ dropped, unconfirmed and pending counts. Its `complete` flag is true only when every
117
+ selected ticket was accepted. `close(timeout_ms=2000)` combines a bounded flush
118
+ and disposal. `dispose()` immediately disables optional work without waiting for
119
+ network completion. Background threads do not keep the Python process alive, so
120
+ process exit may abandon captures; flush explicitly when confirmation matters.
121
+
122
+ `set_enabled(False)` cancels optional work and clears active state. Re-enabling
123
+ requires fresh execution IDs. Invalid configuration stays inert and yields explicit
124
+ fallback receipts. SDK failures do not invoke, retry or replace application model
125
+ calls. Cancellation passed as `threading.Event` to `get_context(..., cancel=event)`
126
+ returns a distinct canceled result; preserve the application's own cancellation
127
+ check before invoking its provider. Async task cancellation propagates normally.
128
+
129
+ ## Limits and design tradeoffs
130
+
131
+ | Limit | Value |
132
+ | --- | --- |
133
+ | Encoded snapshot | 1,000,000 bytes; 10,000 messages; 50,000 JSON nodes; depth 32 |
134
+ | Pending captures | 16 prefixes and 16 finals, separately |
135
+ | Active execution state / recent final receipts | 64 / 128 |
136
+ | Pending context retrievals | 16, independent of capture |
137
+ | Capture timeout defaults | 1 second prefix; 6 seconds final |
138
+ | Explicit capture `timeout_ms` | 1–30,000 ms |
139
+ | Context / response bytes | 32,768 / 65,536 |
140
+ | Circuit default | 3 failures, 30-second cooldown, one recovery probe per endpoint |
141
+ | Mode heartbeat / lease | 1 second with jitter / at most 3 seconds |
142
+
143
+ One daemon event-loop thread and one HTTPX client own network work; at most two
144
+ daemon DNS lookups isolate stalled system resolution. The benefit is cancellable,
145
+ pooled HTTP for synchronous callers without a thread per upload. The cost is the
146
+ HTTPX dependency and best-effort background delivery. Endpoint circuits
147
+ are independent, so a successful capture cannot hide context failures. Uncertain
148
+ captures reconcile before newer ordinals; final retries share their original budget.
149
+
150
+ The package mirrors the current TypeScript `captureTrace` / `getContext` boundary.
151
+ It does not port the legacy session adapters or ready-only injection helpers, and
152
+ it contains no private policy engine or provider integration. Tests cover local
153
+ protocol behavior and package installation; they do not establish PyPI publication,
154
+ hosted service compatibility, production latency or model-quality improvement.
155
+
156
+ ## Development
157
+
158
+ ```sh
159
+ python -m pip install ./packages/python-sdk build
160
+ python -m unittest discover -s packages/python-sdk/tests -v
161
+ python -m build packages/python-sdk
162
+ ```
163
+
164
+ CI runs behavioral and real loopback HTTP tests on Python 3.11, 3.13 and 3.14, then
165
+ builds and installs the wheel in a clean environment. No test calls a real provider
166
+ or a hosted Mirrors service.
@@ -0,0 +1,25 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "mirrors-sdk"
7
+ version = "0.1.0"
8
+ description = "Background trace capture and bounded optional context for Python agents"
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ dependencies = ["httpx>=0.28.1,<0.29"]
12
+ classifiers = [
13
+ "Programming Language :: Python :: 3",
14
+ "Programming Language :: Python :: 3 :: Only",
15
+ "Programming Language :: Python :: 3.11",
16
+ "Programming Language :: Python :: 3.12",
17
+ "Programming Language :: Python :: 3.13",
18
+ "Programming Language :: Python :: 3.14",
19
+ ]
20
+
21
+ [tool.setuptools.packages.find]
22
+ where = ["src"]
23
+
24
+ [tool.setuptools.package-data]
25
+ mirrors_sdk = ["py.typed"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,33 @@
1
+ """Mirrors: optional context and best-effort original trace capture."""
2
+
3
+ from .client import Mirrors
4
+ from .models import (
5
+ ContextFreshness,
6
+ ContextResult,
7
+ DeliveryReceipt,
8
+ DeliveryTicket,
9
+ FlushResult,
10
+ TurnTimer,
11
+ )
12
+ from .types import (
13
+ CanonicalMessage,
14
+ CanonicalTrace,
15
+ EndUser,
16
+ MessageBlock,
17
+ SourceCompleteness,
18
+ )
19
+
20
+ __all__ = [
21
+ "Mirrors",
22
+ "ContextFreshness",
23
+ "ContextResult",
24
+ "DeliveryReceipt",
25
+ "DeliveryTicket",
26
+ "FlushResult",
27
+ "TurnTimer",
28
+ "CanonicalMessage",
29
+ "CanonicalTrace",
30
+ "EndUser",
31
+ "MessageBlock",
32
+ "SourceCompleteness",
33
+ ]
@@ -0,0 +1,254 @@
1
+ """Ordered capture delivery with immutable reconciliation of uncertain writes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import hashlib
7
+ import json
8
+ import time
9
+ import uuid
10
+ from collections import OrderedDict
11
+ from dataclasses import dataclass, field
12
+ from typing import Any
13
+
14
+ from .models import DeliveryReceipt
15
+ from .transport import TransportError
16
+ from .wire import Wire
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class _Pending:
21
+ digest: bytes
22
+ body: bytes
23
+ request: dict[str, Any]
24
+
25
+
26
+ @dataclass
27
+ class _Trace:
28
+ metadata: bytes
29
+ ordinal: int = 0
30
+ pending: _Pending | None = None
31
+ sealed: bool = False
32
+ retired: bool = False
33
+ lock: asyncio.Lock = field(default_factory=asyncio.Lock)
34
+ running: asyncio.Task[Any] | None = None
35
+ final_digest: bytes | None = None
36
+ final_owner: asyncio.Task[Any] | None = None
37
+
38
+
39
+ class CaptureRuntime:
40
+ """Loop-owned state; application threads communicate through the SDK worker."""
41
+
42
+ def __init__(self, transport: Any, config: Any) -> None:
43
+ self._transport = transport
44
+ self._config = config
45
+ self._active: dict[str, _Trace] = {}
46
+ self._terminal: dict[str, _Trace] = {}
47
+ self._recent: OrderedDict[str, tuple[bytes, DeliveryReceipt]] = OrderedDict()
48
+ self._closed = False
49
+
50
+ async def send(self, input: dict[str, Any], deadline: float) -> DeliveryReceipt:
51
+ """Send once; finals may retry once within the same absolute deadline."""
52
+ receipt = await self._send(input, deadline)
53
+ if input.get("final") is True:
54
+ trace_id = input.get("trace", {}).get("id")
55
+ # A rejected final still ends the host execution. Release any prefix state
56
+ # left behind by validation/capacity rejection, preserving admitted finals.
57
+ self.release(trace_id)
58
+ state = self._terminal.get(trace_id)
59
+ if state is not None and state.final_owner is asyncio.current_task():
60
+ # Settled uncertain finals do not consume scarce terminal capacity forever.
61
+ # Their immutable outcome is retained in the same bounded receipt cache.
62
+ self._terminal.pop(trace_id, None)
63
+ if state.final_digest is not None:
64
+ self._remember(trace_id, state.final_digest, receipt)
65
+ self._retire(state)
66
+ return receipt
67
+
68
+ async def _send(self, input: dict[str, Any], deadline: float) -> DeliveryReceipt:
69
+ if self._closed:
70
+ return DeliveryReceipt("dropped", "closed")
71
+ final = input.get("final") is True
72
+ timeout_ms = self._config.timeout_ms
73
+ deadline = min(
74
+ deadline,
75
+ time.monotonic() + (timeout_ms or (6000 if final else 1000)) / 1000,
76
+ )
77
+ try:
78
+ encoded = Wire.encode(input)
79
+ snapshot = json.loads(encoded)
80
+ trace_id = snapshot["trace"]["id"]
81
+ digest = hashlib.sha256(encoded).digest()
82
+ metadata = self._metadata(snapshot["trace"])
83
+ except (TypeError, ValueError, KeyError, RecursionError):
84
+ return DeliveryReceipt("dropped", "invalid_evidence")
85
+ previous = self._recent.get(trace_id)
86
+ if previous is not None:
87
+ return (
88
+ previous[1]
89
+ if previous[0] == digest
90
+ else DeliveryReceipt("dropped", "terminal_conflict")
91
+ )
92
+ state = self._terminal.get(trace_id) or self._active.get(trace_id)
93
+ if state is not None and state.metadata != metadata:
94
+ return DeliveryReceipt("dropped", "invalid_evidence")
95
+ if state is not None and state.sealed and not final:
96
+ return DeliveryReceipt("dropped", "terminal_conflict")
97
+ if state is not None and state.sealed and state.final_digest != digest:
98
+ return DeliveryReceipt("dropped", "terminal_conflict")
99
+ if state is None:
100
+ pool = self._terminal if final else self._active
101
+ if len(pool) >= (16 if final else 64):
102
+ return DeliveryReceipt("dropped", "capacity")
103
+ state = _Trace(metadata, sealed=final)
104
+ pool[trace_id] = state
105
+ if final:
106
+ state.final_digest = digest
107
+ state.final_owner = asyncio.current_task()
108
+ elif final and not state.sealed:
109
+ if len(self._terminal) >= 16:
110
+ return DeliveryReceipt("dropped", "capacity")
111
+ state.sealed = True
112
+ state.final_digest = digest
113
+ state.final_owner = asyncio.current_task()
114
+ self._active.pop(trace_id, None)
115
+ self._terminal[trace_id] = state
116
+ acquired = False
117
+ try:
118
+ remaining = deadline - time.monotonic()
119
+ if remaining <= 0:
120
+ return DeliveryReceipt("unconfirmed", "timeout")
121
+ await asyncio.wait_for(state.lock.acquire(), remaining)
122
+ acquired = True
123
+ state.running = asyncio.current_task()
124
+ if state.retired or self._closed:
125
+ return DeliveryReceipt("dropped", "closed")
126
+ if state.sealed and not final:
127
+ return DeliveryReceipt("dropped", "superseded")
128
+ # Another queued final may have completed while this call waited.
129
+ previous = self._recent.get(trace_id)
130
+ if previous is not None:
131
+ return (
132
+ previous[1]
133
+ if previous[0] == digest
134
+ else DeliveryReceipt("dropped", "terminal_conflict")
135
+ )
136
+ if state.pending is not None and state.pending.digest != digest:
137
+ # An uncertain terminal is immutable. A different final cannot replace it.
138
+ if state.pending.request["final"]:
139
+ return DeliveryReceipt("dropped", "terminal_conflict")
140
+ await self._deliver(state, trace_id, deadline, retry=final)
141
+ if state.pending is None:
142
+ if state.ordinal >= 10_000:
143
+ return DeliveryReceipt("dropped", "capacity")
144
+ request = {
145
+ **snapshot,
146
+ "protocol": 3,
147
+ "agentId": self._config.agent_id,
148
+ "requestId": str(uuid.uuid4()),
149
+ "ordinal": state.ordinal + 1,
150
+ }
151
+ state.pending = _Pending(
152
+ digest, Wire.encode(request, 1_048_576), request
153
+ )
154
+ return await self._deliver(state, trace_id, deadline, retry=final)
155
+ except asyncio.TimeoutError:
156
+ return DeliveryReceipt("unconfirmed", "timeout")
157
+ except asyncio.CancelledError:
158
+ return DeliveryReceipt("unconfirmed", "transport")
159
+ except TransportError as error:
160
+ return DeliveryReceipt(
161
+ "unconfirmed", "timeout" if error.reason == "timeout" else "transport"
162
+ )
163
+ except Exception:
164
+ # Optional capture must not leak protocol or transport failures into the host.
165
+ return DeliveryReceipt("unconfirmed", "transport")
166
+ finally:
167
+ if acquired:
168
+ state.running = None
169
+ state.lock.release()
170
+
171
+ async def _deliver(
172
+ self, state: _Trace, trace_id: str, deadline: float, *, retry: bool
173
+ ) -> DeliveryReceipt:
174
+ pending = state.pending
175
+ assert pending is not None
176
+ for attempt in range(2 if retry else 1):
177
+ if time.monotonic() >= deadline:
178
+ raise asyncio.TimeoutError
179
+ if state.retired or self._closed:
180
+ raise asyncio.CancelledError
181
+ try:
182
+ response = await self._transport.request(
183
+ "trace",
184
+ pending.body,
185
+ deadline,
186
+ lambda value: Wire.trace_response(value, pending.request),
187
+ final_recovery=retry,
188
+ )
189
+ break
190
+ except TransportError as error:
191
+ if (
192
+ error.permanent
193
+ or not retry
194
+ or attempt == 1
195
+ or time.monotonic() >= deadline
196
+ ):
197
+ raise
198
+ if state.retired or self._closed:
199
+ raise asyncio.CancelledError
200
+ if time.monotonic() >= deadline:
201
+ raise asyncio.TimeoutError
202
+ if not Wire.trace_response(response, pending.request):
203
+ raise ValueError("Invalid capture response")
204
+ state.ordinal = pending.request["ordinal"]
205
+ state.pending = None
206
+ receipt = DeliveryReceipt("accepted", ingestion=response["ingestion"])
207
+ if pending.request["final"]:
208
+ self._active.pop(trace_id, None)
209
+ self._terminal.pop(trace_id, None)
210
+ self._remember(trace_id, pending.digest, receipt)
211
+ return receipt
212
+
213
+ def _remember(self, trace_id: str, digest: bytes, receipt: DeliveryReceipt) -> None:
214
+ self._recent[trace_id] = (digest, receipt)
215
+ if len(self._recent) > 128:
216
+ self._recent.popitem(last=False)
217
+
218
+ def release(self, trace_id: str) -> None:
219
+ """Retire prefixes locally; an admitted final retains its delivery opportunity."""
220
+ state = self._active.pop(trace_id, None)
221
+ if state is not None:
222
+ self._retire(state)
223
+
224
+ def clear(self) -> None:
225
+ """Permanently stop capture and abandon all outstanding work."""
226
+ self._closed = True
227
+ for state in (*self._active.values(), *self._terminal.values()):
228
+ self._retire(state)
229
+ self._active.clear()
230
+ self._terminal.clear()
231
+ self._recent.clear()
232
+
233
+ @staticmethod
234
+ def _retire(state: _Trace) -> None:
235
+ state.retired = True
236
+ if state.running is not None:
237
+ state.running.cancel()
238
+
239
+ @staticmethod
240
+ def _metadata(trace: dict[str, Any]) -> bytes:
241
+ # End-user and measured duration intentionally belong to the final snapshot.
242
+ fields = (
243
+ "model",
244
+ "entryPoint",
245
+ "completeness",
246
+ "conversationId",
247
+ "conversationRevision",
248
+ )
249
+ return json.dumps(
250
+ {key: trace[key] for key in fields if key in trace},
251
+ sort_keys=True,
252
+ separators=(",", ":"),
253
+ ensure_ascii=False,
254
+ ).encode("utf-8")