mirrors-sdk 0.1.0

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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 8031ec5dde9188cd42bfdad78edf5b7f94d09a7db3258f2b25c94e1acaa7ed49
4
+ data.tar.gz: c138edebf9c993de0264c19110774d49e283c7a91237054e17f23bc33b222896
5
+ SHA512:
6
+ metadata.gz: 6c7b2b4237c20df46c699b4b9e6857b31da3d3a03982340719fef7c45ab3d4ef1a5c79342016b465a338c76543665d45d66a6c40611a478862da78dcc85b67ce
7
+ data.tar.gz: 206264fe2b5ed439310c831743aff066007e31279743f063c866061ae8aacc55162ccc3e88295aa4bfe78f76995e86a4a9e321fd81e4286f850859021342523b
data/Gemfile ADDED
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ source "https://rubygems.org"
4
+ gemspec
5
+
6
+ gem "minitest", "~> 5.25"
7
+ gem "rake", "~> 13.2"
data/README.md ADDED
@@ -0,0 +1,273 @@
1
+ # Mirrors Ruby SDK
2
+
3
+ Capture original conversation history in the background and retrieve optional
4
+ context before a model request. Your application owns its system prompt, model
5
+ calls, tools, streaming, errors and cancellation.
6
+
7
+ Requires Ruby 3.3 or later. Install a released version from RubyGems:
8
+
9
+ ```sh
10
+ gem install mirrors-sdk -v 0.1.0
11
+ ```
12
+
13
+ For an application using Bundler, add this to its Gemfile and run `bundle install`:
14
+
15
+ ```ruby
16
+ gem "mirrors-sdk", "~> 0.1.0", require: "mirrors_sdk"
17
+ ```
18
+
19
+ To test an unreleased version, build and install from a reviewed checkout:
20
+
21
+ ```sh
22
+ cd packages/ruby-sdk
23
+ gem build mirrors-sdk.gemspec
24
+ gem install --local mirrors-sdk-0.1.0.gem --no-document
25
+ ruby -rmirrors_sdk -e 'puts MirrorsSDK::VERSION'
26
+ ```
27
+
28
+ For Bundler development, use the checkout's actual location:
29
+
30
+ ```ruby
31
+ gem "mirrors-sdk", path: "/path/to/sidecar/packages/ruby-sdk", require: "mirrors_sdk"
32
+ ```
33
+
34
+ The gem has no runtime dependencies beyond Ruby's standard libraries. Its version
35
+ is independent of the Python and TypeScript packages. The packaged
36
+ [installation skill](skills/install-mirrors-ruby-sdk/SKILL.md) describes integration
37
+ and verifies official publication before suggesting a registry install.
38
+
39
+ ## Integrate with the existing model loop
40
+
41
+ Create one reusable client per agent configuration **after worker processes fork**.
42
+ Get the API URL and agent ID from that agent's SDK connection settings, and load
43
+ the corresponding key from server secrets with runtime read/write permissions.
44
+
45
+ ```ruby
46
+ require "mirrors_sdk"
47
+ require "securerandom"
48
+
49
+ mirrors = MirrorsSDK::Mirrors.new(
50
+ api_url: ENV.fetch("MIRRORS_API_URL"),
51
+ api_key: ENV.fetch("MIRRORS_API_KEY"),
52
+ agent_id: ENV.fetch("MIRRORS_AGENT_ID")
53
+ )
54
+
55
+ turn = mirrors.start_turn
56
+ trace = {
57
+ "id" => SecureRandom.uuid,
58
+ "messages" => [{ "role" => "user", "content" => "Help with my account" }]
59
+ }
60
+ mirrors.capture_trace(trace) # Copy and queue; no network wait.
61
+ selected = mirrors.get_context(trace, timeout_ms: 1000)
62
+
63
+ # Use your application's existing prompt, model call and stream/tool handling.
64
+ system = original_system_prompt
65
+ system = "#{system}\n\n#{selected.context}" unless selected.context.empty?
66
+ response = existing_model_call(system: system, messages: original_messages)
67
+
68
+ # Only after all model calls, tools, retries and response streaming have ended:
69
+ ticket = mirrors.capture_trace(
70
+ trace.merge("messages" => completed_original_messages, "turnLatencyMs" => turn.finish),
71
+ final: true,
72
+ terminal: "completed" # Or "failed" / "canceled" at those ending hooks.
73
+ )
74
+ ```
75
+
76
+ `get_context` may wait up to one total **1,000 ms budget from call entry**, including
77
+ copying and scheduling. Set `timeout_ms` from 1–1000 to shorten it. This is a best
78
+ effort deadline under Ruby and operating-system scheduling, not a hard real-time
79
+ guarantee. It never waits for capture delivery. This synchronous method blocks its
80
+ calling thread; use the application's existing background execution facility when
81
+ integrating with an event loop.
82
+
83
+ Results expose `context`, `mode`, `delivery`, `reason`, `elapsed_ms` and nullable
84
+ `freshness`. Use returned text promptly for the same model request and inspect
85
+ `freshness.expires_at` if dispatch is delayed. Changed history requires a new call.
86
+ The client rechecks freshness before returning, including mode changes, lease
87
+ expiry, supersession, release and finalization. It cannot recall text already used
88
+ by the application.
89
+
90
+ Mode refresh runs independently in the background. Unknown/expired mode immediately
91
+ returns empty local shadow fallback. Confirmed shadow returns empty while scheduling
92
+ bounded background evaluation; off does not retrieve. Capture continues in every
93
+ server mode. The SDK requires HTTPS except loopback development addresses, refuses
94
+ redirects and never logs keys, requests or conversation bodies.
95
+
96
+ ## Canonical evidence
97
+
98
+ Ruby methods use snake_case; trace hashes use **string keys with the existing JSON
99
+ names**, such as `conversationId`, `callId` and `turnLatencyMs`. Symbols, custom
100
+ objects, cycles and oversized inputs are rejected rather than coerced or truncated.
101
+ Accepted input is copied so caller mutations cannot alter admitted evidence.
102
+
103
+ Preserve original roles (`system`, `user`, `assistant`, `tool`), text, reasoning,
104
+ block order and tool call/result IDs. Exclude Mirrors-added context. See the
105
+ [canonical message contract](../../docs/contracts/remote-sdk.md#event-stream) for
106
+ text, reasoning, tool and media blocks. Never invent missing tool associations.
107
+
108
+ Use a fresh execution ID for each user turn, including after restart, finalization,
109
+ local release or disable/re-enable. If final evidence is unavailable,
110
+ `mirrors.release_trace(trace["id"])` performs local cleanup without claiming server
111
+ completion. Terminal status describes how execution ended, not task success.
112
+
113
+ For continuing chats, supply both `conversationId` and `conversationRevision` or
114
+ neither. Persist an atomically allocated positive safe-integer revision per
115
+ execution; keep both fields and source completeness metadata fixed across that
116
+ execution's captures/retries. Send the full cumulative original history. Revisions
117
+ are not timestamps, message counts or process-local counters. Branches need distinct
118
+ conversation IDs. Optional `user` is `{ "id" => opaque_internal_id, "segment" => cohort }`.
119
+
120
+ `start_turn` returns a monotonic timer whose first `finish` freezes elapsed
121
+ milliseconds. Measure the whole user turn, including context waiting, models,
122
+ tools, retries and streaming. Put `turnLatencyMs` on final capture only, excluding
123
+ capture delivery; omit unknown duration.
124
+
125
+ ## Optional original instructions
126
+
127
+ Attach application-supplied instructions once to the turn's trace:
128
+
129
+ ```ruby
130
+ trace["agentContext"] = {
131
+ "systemPrompt" => original_system_prompt,
132
+ "skills" => [{ "name" => "account-policy", "content" => application_skill_text }]
133
+ }
134
+ ```
135
+
136
+ Either field may be omitted; the object must contain at least one. Capture the
137
+ original prompt before appending Mirrors context. Include `agentContext` explicitly
138
+ on the final capture to retain it: values are never inherited from an earlier
139
+ snapshot or another turn. Empty prompt or skills values mean explicitly empty;
140
+ omit unknown values. The SDK does not read files, discover skills or assert that
141
+ supplied skills ran. Preserve existing system messages without synthesizing copies.
142
+ Compatible API and worker readers are required; installing a gem does not deploy
143
+ the service. Ruby supports both prompt and skill fields from the TypeScript
144
+ contract; the current Python SDK accepts only the prompt field.
145
+
146
+ ## Receipts, cancellation and shutdown
147
+
148
+ `ticket.receipt.status` is `pending`, `accepted`, `dropped` or `unconfirmed`.
149
+ `ticket.wait(2)` waits at most **two seconds** outside the model path.
150
+ Other SDK timeout arguments use milliseconds. `accepted` with ingestion status
151
+ `queued` confirms delivery, not analysis completion. An `unconfirmed` write may
152
+ already have committed; immutable retries preserve its request identity and bytes.
153
+
154
+ Pass a `MirrorsSDK::Cancellation` token to `get_context(..., cancel: token)` and
155
+ call `token.cancel` from the application's cancellation hook. A canceled request
156
+ returns empty context with a distinct reason. Preserve the host's cancellation
157
+ check before calling its provider; SDK cancellation never invokes a provider.
158
+
159
+ ```ruby
160
+ # At shutdown, stop admitting turns and let active turns finish first.
161
+ summary = mirrors.close(timeout_ms: 2000)
162
+ # Or mirrors.flush(timeout_ms: 2000), then mirrors.dispose.
163
+ ```
164
+
165
+ `flush` waits only for captures pending at entry and reports `accepted`, `dropped`,
166
+ `unconfirmed` and `pending` counts. `close` combines bounded flush and disposal;
167
+ `dispose` cancels optional work without waiting for network completion. Background
168
+ threads do not keep a finished Ruby process alive. There is no durable queue, so
169
+ flush explicitly when confirmation matters.
170
+
171
+ `set_enabled(false)` cancels optional work; re-enabling requires fresh execution
172
+ IDs. Invalid configuration stays inert with explicit fallback results. A client
173
+ inherited across a fork stays inert; create a new client in the child process.
174
+
175
+ ## Limits and tradeoffs
176
+
177
+ | Boundary | Limit/default |
178
+ | --- | --- |
179
+ | Encoded snapshot | 1,000,000 bytes; 10,000 messages; 50,000 JSON nodes; depth 32 |
180
+ | Agent context | 262,144 UTF-8 JSON bytes within the snapshot budget |
181
+ | Prompt / each skill content | 100,000 UTF-16 code units |
182
+ | Skills | 64 unique names; names at most 128 UTF-16 code units |
183
+ | Pending captures | 16 prefixes and 16 finals, independently |
184
+ | Active executions / recent final receipts | 64 / 128 |
185
+ | Pending retrievals | 16, independent of capture |
186
+ | Capture timeout | 1 second prefix; 6 seconds final; explicit `timeout_ms` 1–30,000 |
187
+ | Context / response | 32,768 / 65,536 bytes |
188
+ | Circuit | 3 failures, 30-second cooldown, one recovery probe per endpoint |
189
+ | Mode heartbeat / lease | 1 second with jitter / at most 3 seconds |
190
+
191
+ Fixed pools use two prefix, two final and two retrieval workers, plus one heartbeat
192
+ thread, per client. Separate admission keeps network work bounded. The benefit is
193
+ straightforward synchronous integration with
194
+ no external HTTP library; the cost is per-client threads, fresh HTTP connections
195
+ and best-effort delivery at process exit. Native DNS stalls can occupy bounded
196
+ worker capacity until resolution returns. Endpoint circuits are independent; final
197
+ retries share the original deadline. No automatic infinite retry/restart loop exists.
198
+
199
+ This gem implements the modern `captureTrace` / `getContext` boundary shared by
200
+ TypeScript and Python. It contains no private evaluator, provider adapter, legacy
201
+ session API or ready-only prompt injection helper.
202
+
203
+ ## Development
204
+
205
+ From this directory:
206
+
207
+ ```sh
208
+ bundle install
209
+ bundle exec rake test
210
+ gem build mirrors-sdk.gemspec
211
+ ```
212
+
213
+ Dedicated CI runs on Ruby 3.3, 3.4 and 4.0, exercises scripted and loopback HTTP
214
+ behavior, builds the gem, checks a clean installation and runs packaged tests.
215
+ These checks establish package behavior, not registry publication, hosted service
216
+ acceptance, production latency or model quality. Tests call no real provider or
217
+ hosted Mirrors service.
218
+
219
+ ## Publishing through GitHub Actions
220
+
221
+ The `Ruby SDK` workflow publishes tags named `ruby-sdk-v<VERSION>`, such as
222
+ `ruby-sdk-v0.1.0`. All three Ruby versions must pass verification before the
223
+ `publish` job can upload the exact gem built and tested by the Ruby 3.4 job.
224
+ Pull requests and manual workflow runs only verify; they never publish.
225
+
226
+ Authentication uses [RubyGems trusted publishing](https://guides.rubygems.org/trusted-publishing/).
227
+ The benefit is no long-lived API key to create, store or rotate. The cost is a
228
+ one-time setup in RubyGems and a matching GitHub environment.
229
+
230
+ Before the first release, create a RubyGems account and register a
231
+ [pending trusted publisher](https://rubygems.org/profile/oidc/pending_trusted_publishers)
232
+ with these exact values:
233
+
234
+ | RubyGems field | Value |
235
+ | --- | --- |
236
+ | Gem name | `mirrors-sdk` |
237
+ | Repository owner | `runmirrors` |
238
+ | Repository name | `sidecar` |
239
+ | Workflow filename | `ruby-sdk.yml` |
240
+ | Environment | `rubygems` |
241
+ | Workflow Repository Owner / Name | Leave blank; this workflow is in the same repository |
242
+
243
+ Create the `rubygems` environment in the repository's GitHub Settings →
244
+ Environments. Select the repository's release protection rules there; the workflow
245
+ uses those configured rules and does not create them. No RubyGems API-key secret
246
+ is required. The pending publisher supports the first release and becomes a
247
+ normal publisher after that push succeeds. These account/environment settings
248
+ must be configured separately; this repository change does not create them.
249
+
250
+ The first token exchange must identify the pending publisher's account. The
251
+ workflow passes the verified RubyGems account ID `256014` as the string `owner`.
252
+ It uses the documented OIDC exchange directly because the current official
253
+ credentials action does not yet support that field. Tokens are masked and the
254
+ short-lived push key is available only within the publish job.
255
+
256
+ The gemspec leaves licensing unspecified, matching the current Python and
257
+ TypeScript packages. Merge the reviewed SDK and workflow, then tag the intended
258
+ merged commit after its checks pass:
259
+
260
+ ```sh
261
+ git tag ruby-sdk-v0.1.0 <verified-merged-commit>
262
+ git push origin refs/tags/ruby-sdk-v0.1.0
263
+ ```
264
+
265
+ The tag must exactly match `MirrorsSDK::VERSION`; the publish job also checks the
266
+ downloaded gem's name and version before requesting credentials. Only that job
267
+ has permission to request a short-lived GitHub identity token. It uploads the
268
+ retained artifact without rebuilding it or granting GitHub repository write access.
269
+
270
+ If authentication or publication fails before a successful push, fix the setup and
271
+ rerun the failed publish job within the artifact's 30-day retention period. If the
272
+ push may already have succeeded, inspect the RubyGems version first; do not assume
273
+ rerunning a public package upload is idempotent.
data/Rakefile ADDED
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rake/testtask"
4
+
5
+ Rake::TestTask.new(:test) do |test|
6
+ test.libs << "lib"
7
+ test.test_files = FileList["test/**/*_test.rb"]
8
+ test.warning = true
9
+ end
10
+
11
+ task default: :test
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module MirrorsSDK
6
+ # Original instructions are optional evidence, never modifications to model input.
7
+ class AgentContextWire
8
+ TRIM = /\A[\s\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000\ufeff]+|[\s\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000\ufeff]+\z/.freeze
9
+
10
+ def self.length(value)
11
+ value.encode(Encoding::UTF_16LE).bytesize / 2
12
+ end
13
+
14
+ def self.validate(value)
15
+ unless value.instance_of?(Hash) && !value.empty? && (value.keys - %w[systemPrompt skills]).empty?
16
+ raise ArgumentError, "Invalid agent context"
17
+ end
18
+ if value.key?("systemPrompt")
19
+ prompt = value["systemPrompt"]
20
+ unless prompt.instance_of?(String) && length(prompt) <= 100_000
21
+ raise ArgumentError, "Invalid system prompt"
22
+ end
23
+ end
24
+ validate_skills(value["skills"]) if value.key?("skills")
25
+ if JSON.generate(value).bytesize > 262_144
26
+ raise ArgumentError, "Agent context exceeds the encoded size limit"
27
+ end
28
+ value
29
+ end
30
+
31
+ def self.validate_skills(skills)
32
+ unless skills.instance_of?(Array) && skills.length <= 64
33
+ raise ArgumentError, "Invalid agent skills"
34
+ end
35
+ names = {}
36
+ skills.each do |skill|
37
+ unless skill.instance_of?(Hash) && skill.keys.sort == %w[content name]
38
+ raise ArgumentError, "Invalid agent skill"
39
+ end
40
+ name, content = skill.values_at("name", "content")
41
+ unless name.instance_of?(String) && !name.gsub(TRIM, "").empty? &&
42
+ length(name) <= 128 && !name.match?(/\p{Cc}/) && !names[name] &&
43
+ content.instance_of?(String) && !content.empty? && length(content) <= 100_000
44
+ raise ArgumentError, "Invalid agent skill"
45
+ end
46
+ names[name] = true
47
+ end
48
+ end
49
+ private_class_method :validate_skills
50
+ end
51
+ end
@@ -0,0 +1,236 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "json"
5
+ require "securerandom"
6
+ require "thread"
7
+
8
+ module MirrorsSDK
9
+ # Serializes each execution's ordinal stream, retaining uncertain writes verbatim.
10
+ class CaptureRuntime
11
+ Pending = Struct.new(:digest, :body, :request)
12
+ State = Struct.new(:metadata, :ordinal, :pending, :sealed, :retired, :busy,
13
+ :changed, :cancellation, :final_digest, :final_owner, :latest_sequence)
14
+
15
+ def initialize(transport, config)
16
+ @transport, @config = transport, config
17
+ @lock = Mutex.new
18
+ @active, @terminal, @recent = {}, {}, {}
19
+ @closed = false
20
+ end
21
+
22
+ def send(input, deadline:, cancel: nil, sequence: nil)
23
+ owner = Object.new
24
+ final = input.instance_of?(Hash) && input["final"] == true
25
+ trace_id = input.dig("trace", "id") if input.instance_of?(Hash)
26
+ receipt = perform(input, deadline, cancel, owner, sequence)
27
+ receipt
28
+ rescue StandardError
29
+ receipt = DeliveryReceipt.new("unconfirmed", "transport")
30
+ ensure
31
+ finish_final(trace_id, owner, receipt) if final
32
+ end
33
+
34
+ # Local release abandons prefixes without canceling an admitted final.
35
+ def release(trace_id)
36
+ state = @lock.synchronize do
37
+ removed = @active.delete(trace_id)
38
+ retire(removed) if removed
39
+ removed
40
+ end
41
+ state.cancellation.cancel("canceled") if state
42
+ nil
43
+ end
44
+
45
+ def clear
46
+ states = @lock.synchronize do
47
+ @closed = true
48
+ values = @active.values + @terminal.values
49
+ values.each { |state| retire(state) }
50
+ @active.clear
51
+ @terminal.clear
52
+ @recent.clear
53
+ values
54
+ end
55
+ states.each { |state| state.cancellation.cancel("closed") }
56
+ nil
57
+ end
58
+
59
+ def diagnostics
60
+ @lock.synchronize do
61
+ { active: @active.length, terminal: @terminal.length, recent: @recent.length }
62
+ end
63
+ end
64
+
65
+ private
66
+
67
+ def now
68
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
69
+ end
70
+
71
+ def perform(input, deadline, cancel, owner, sequence)
72
+ final = input["final"] == true
73
+ deadline = [deadline, now + (@config.timeout_ms || (final ? 6000 : 1000)) / 1000.0].min
74
+ begin
75
+ encoded = Wire.encode(input, deadline: deadline)
76
+ snapshot = JSON.parse(encoded)
77
+ trace_id = Wire.id(snapshot.fetch("trace").fetch("id"))
78
+ digest = Digest::SHA256.digest(encoded)
79
+ metadata = metadata(snapshot["trace"])
80
+ rescue StandardError
81
+ return now >= deadline ? DeliveryReceipt.new("unconfirmed", "timeout") : DeliveryReceipt.new("dropped", "invalid_evidence")
82
+ end
83
+ state = @lock.synchronize { admit(trace_id, metadata, digest, final, owner, sequence) }
84
+ return state if state.is_a?(DeliveryReceipt)
85
+
86
+ local = Cancellation.new
87
+ unlink_state = state.cancellation.on_cancel { local.cancel("canceled") }
88
+ unlink_caller = cancel.on_cancel { local.cancel(cancel.reason) } if cancel
89
+ acquired = false
90
+ begin
91
+ @lock.synchronize do
92
+ while state.busy
93
+ check_available(state, deadline, local)
94
+ state.changed.wait(@lock, [deadline - now, 0.01].min)
95
+ end
96
+ check_available(state, deadline, local)
97
+ return DeliveryReceipt.new("dropped", "superseded") if state.sealed && !final
98
+ return DeliveryReceipt.new("dropped", "superseded") if stale?(state, sequence)
99
+ previous = recent(trace_id, digest)
100
+ return previous if previous
101
+ state.busy = acquired = true
102
+ end
103
+ if state.pending && state.pending.digest != digest
104
+ return DeliveryReceipt.new("dropped", "terminal_conflict") if state.pending.request["final"]
105
+ deliver(state, trace_id, deadline, local, retry_final: final)
106
+ end
107
+ unless state.pending
108
+ return DeliveryReceipt.new("dropped", "capacity") if state.ordinal >= 10_000
109
+ request = snapshot.merge("protocol" => 3, "agentId" => @config.agent_id,
110
+ "requestId" => SecureRandom.uuid, "ordinal" => state.ordinal + 1)
111
+ state.pending = Pending.new(digest, Wire.encode(request, limit: 1_048_576, deadline: deadline), request)
112
+ end
113
+ deliver(state, trace_id, deadline, local, retry_final: final)
114
+ rescue TransportError => error
115
+ DeliveryReceipt.new("unconfirmed", error.reason == "timeout" ? "timeout" : "transport")
116
+ rescue StandardError
117
+ DeliveryReceipt.new("unconfirmed", now >= deadline ? "timeout" : "transport")
118
+ ensure
119
+ if acquired
120
+ @lock.synchronize do
121
+ state.busy = false
122
+ state.changed.broadcast
123
+ end
124
+ end
125
+ unlink_caller.call if unlink_caller
126
+ unlink_state.call if unlink_state
127
+ end
128
+ end
129
+
130
+ def admit(trace_id, metadata, digest, final, owner, sequence)
131
+ return DeliveryReceipt.new("dropped", "closed") if @closed
132
+ previous = recent(trace_id, digest)
133
+ return previous if previous
134
+ state = @terminal[trace_id] || @active[trace_id]
135
+ return DeliveryReceipt.new("dropped", "superseded") if state && stale?(state, sequence)
136
+ return DeliveryReceipt.new("dropped", "invalid_evidence") if state && state.metadata != metadata
137
+ if state && state.sealed && (!final || state.final_digest != digest)
138
+ return DeliveryReceipt.new("dropped", "terminal_conflict")
139
+ end
140
+ unless state
141
+ pool = final ? @terminal : @active
142
+ return DeliveryReceipt.new("dropped", "capacity") if pool.length >= (final ? 16 : 64)
143
+ state = State.new(metadata, 0, nil, false, false, false,
144
+ ConditionVariable.new, Cancellation.new, nil, nil, nil)
145
+ pool[trace_id] = state
146
+ end
147
+ if final && !state.sealed
148
+ return DeliveryReceipt.new("dropped", "capacity") if !@terminal.key?(trace_id) && @terminal.length >= 16
149
+ state.sealed, state.final_digest, state.final_owner = true, digest, owner
150
+ @active.delete(trace_id)
151
+ @terminal[trace_id] = state
152
+ end
153
+ state.latest_sequence = sequence if sequence
154
+ state
155
+ end
156
+
157
+ def stale?(state, sequence)
158
+ sequence && state.latest_sequence && sequence < state.latest_sequence
159
+ end
160
+
161
+ def deliver(state, trace_id, deadline, cancel, retry_final:)
162
+ pending = state.pending
163
+ response = nil
164
+ (retry_final ? 2 : 1).times do |attempt|
165
+ @lock.synchronize { check_available(state, deadline, cancel) }
166
+ begin
167
+ response = @transport.request("trace", pending.body, deadline: deadline,
168
+ cancel: cancel, final_recovery: retry_final) do |value|
169
+ Wire.trace_response(value, pending.request)
170
+ end
171
+ break
172
+ rescue TransportError => error
173
+ raise if error.permanent || !retry_final || attempt == 1 || now >= deadline || cancel.canceled?
174
+ end
175
+ end
176
+ @lock.synchronize do
177
+ check_available(state, deadline, cancel)
178
+ raise TransportError.new("transport", permanent: true) unless Wire.trace_response(response, pending.request)
179
+ state.ordinal = pending.request["ordinal"]
180
+ state.pending = nil
181
+ receipt = DeliveryReceipt.new("accepted", ingestion: response["ingestion"])
182
+ if pending.request["final"]
183
+ @active.delete(trace_id)
184
+ @terminal.delete(trace_id)
185
+ remember(trace_id, pending.digest, receipt)
186
+ end
187
+ receipt
188
+ end
189
+ end
190
+
191
+ def check_available(state, deadline, cancel)
192
+ raise TransportError.new("timeout") if now >= deadline
193
+ raise TransportError.new("canceled") if @closed || state.retired || cancel.canceled?
194
+ end
195
+
196
+ def finish_final(trace_id, owner, receipt)
197
+ release(trace_id)
198
+ state = @lock.synchronize do
199
+ value = @terminal[trace_id]
200
+ if value && value.final_owner.equal?(owner)
201
+ @terminal.delete(trace_id)
202
+ remember(trace_id, value.final_digest, receipt)
203
+ retire(value)
204
+ value
205
+ end
206
+ end
207
+ state.cancellation.cancel("canceled") if state
208
+ end
209
+
210
+ def retire(state)
211
+ state.retired = true
212
+ state.changed.broadcast
213
+ end
214
+
215
+ def recent(trace_id, digest)
216
+ value = @recent[trace_id]
217
+ value && (value[0] == digest ? value[1] : DeliveryReceipt.new("dropped", "terminal_conflict"))
218
+ end
219
+
220
+ def remember(trace_id, digest, receipt)
221
+ @recent.delete(trace_id)
222
+ @recent[trace_id] = [digest, receipt]
223
+ @recent.shift if @recent.length > 128
224
+ end
225
+
226
+ def metadata(trace)
227
+ # These are source facts; final-only user, prompt, and duration may differ.
228
+ fields = %w[model entryPoint completeness conversationId conversationRevision]
229
+ value = fields.each_with_object({}) do |key, result|
230
+ result[key] = trace[key] if trace.key?(key)
231
+ end
232
+ value["completeness"] = value["completeness"].sort.to_h if value["completeness"].is_a?(Hash)
233
+ Wire.encode(value)
234
+ end
235
+ end
236
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MirrorsSDK
4
+ # Independent admission for each endpoint, including one recovery probe.
5
+ class Circuit
6
+ def initialize(threshold = 3, cooldown_ms = 30_000, clock: nil)
7
+ raise ArgumentError, "Invalid threshold" unless threshold.instance_of?(Integer) && (1..100).cover?(threshold)
8
+ raise ArgumentError, "Invalid cooldown" unless cooldown_ms.instance_of?(Integer) && (1..300_000).cover?(cooldown_ms)
9
+
10
+ @threshold = threshold
11
+ @cooldown = cooldown_ms / 1000.0
12
+ @clock = clock || -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
13
+ @lock = Mutex.new
14
+ @failures = 0
15
+ @until = 0
16
+ @probing = false
17
+ @generation = 0
18
+ end
19
+
20
+ def acquire
21
+ @lock.synchronize do
22
+ return nil if @until.positive? && (@clock.call < @until || @probing)
23
+
24
+ probe = @until.positive?
25
+ @probing = true if probe
26
+ generation = @generation
27
+ finished = false
28
+ lambda do |success|
29
+ @lock.synchronize do
30
+ next if finished || generation != @generation
31
+
32
+ finished = true
33
+ @probing = false if probe
34
+ next if success.nil?
35
+
36
+ if success
37
+ @failures = 0
38
+ @until = 0
39
+ else
40
+ @failures += 1
41
+ if probe || @failures >= @threshold
42
+ @until = @clock.call + @cooldown
43
+ @generation += 1
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end