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.
- mirrors_sdk-0.1.0/MANIFEST.in +2 -0
- mirrors_sdk-0.1.0/PKG-INFO +180 -0
- mirrors_sdk-0.1.0/README.md +166 -0
- mirrors_sdk-0.1.0/pyproject.toml +25 -0
- mirrors_sdk-0.1.0/setup.cfg +4 -0
- mirrors_sdk-0.1.0/src/mirrors_sdk/__init__.py +33 -0
- mirrors_sdk-0.1.0/src/mirrors_sdk/capture.py +254 -0
- mirrors_sdk-0.1.0/src/mirrors_sdk/circuit.py +47 -0
- mirrors_sdk-0.1.0/src/mirrors_sdk/client.py +339 -0
- mirrors_sdk-0.1.0/src/mirrors_sdk/configuration.py +75 -0
- mirrors_sdk-0.1.0/src/mirrors_sdk/context.py +254 -0
- mirrors_sdk-0.1.0/src/mirrors_sdk/delivery.py +96 -0
- mirrors_sdk-0.1.0/src/mirrors_sdk/models.py +107 -0
- mirrors_sdk-0.1.0/src/mirrors_sdk/py.typed +0 -0
- mirrors_sdk-0.1.0/src/mirrors_sdk/resolver.py +69 -0
- mirrors_sdk-0.1.0/src/mirrors_sdk/snapshot.py +95 -0
- mirrors_sdk-0.1.0/src/mirrors_sdk/transport.py +184 -0
- mirrors_sdk-0.1.0/src/mirrors_sdk/types.py +87 -0
- mirrors_sdk-0.1.0/src/mirrors_sdk/wire.py +310 -0
- mirrors_sdk-0.1.0/src/mirrors_sdk/worker.py +138 -0
- mirrors_sdk-0.1.0/src/mirrors_sdk.egg-info/PKG-INFO +180 -0
- mirrors_sdk-0.1.0/src/mirrors_sdk.egg-info/SOURCES.txt +31 -0
- mirrors_sdk-0.1.0/src/mirrors_sdk.egg-info/dependency_links.txt +1 -0
- mirrors_sdk-0.1.0/src/mirrors_sdk.egg-info/requires.txt +1 -0
- mirrors_sdk-0.1.0/src/mirrors_sdk.egg-info/top_level.txt +1 -0
- mirrors_sdk-0.1.0/tests/fixtures/canonical-events.json +38 -0
- mirrors_sdk-0.1.0/tests/http_fixture.py +159 -0
- mirrors_sdk-0.1.0/tests/test_capture.py +459 -0
- mirrors_sdk-0.1.0/tests/test_host_safety.py +260 -0
- mirrors_sdk-0.1.0/tests/test_integration.py +311 -0
- mirrors_sdk-0.1.0/tests/test_resolver.py +83 -0
- mirrors_sdk-0.1.0/tests/test_transport.py +287 -0
- mirrors_sdk-0.1.0/tests/test_wire.py +380 -0
|
@@ -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,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")
|