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 +7 -0
- data/Gemfile +7 -0
- data/README.md +273 -0
- data/Rakefile +11 -0
- data/lib/mirrors_sdk/agent_context.rb +51 -0
- data/lib/mirrors_sdk/capture.rb +236 -0
- data/lib/mirrors_sdk/circuit.rb +51 -0
- data/lib/mirrors_sdk/client.rb +261 -0
- data/lib/mirrors_sdk/configuration.rb +74 -0
- data/lib/mirrors_sdk/context.rb +281 -0
- data/lib/mirrors_sdk/delivery.rb +74 -0
- data/lib/mirrors_sdk/executor.rb +226 -0
- data/lib/mirrors_sdk/models.rb +104 -0
- data/lib/mirrors_sdk/snapshot.rb +129 -0
- data/lib/mirrors_sdk/transport.rb +223 -0
- data/lib/mirrors_sdk/version.rb +5 -0
- data/lib/mirrors_sdk/wire.rb +238 -0
- data/lib/mirrors_sdk/worker.rb +114 -0
- data/lib/mirrors_sdk.rb +13 -0
- data/mirrors-sdk.gemspec +23 -0
- data/skills/install-mirrors-ruby-sdk/SKILL.md +152 -0
- data/test/capture_test.rb +303 -0
- data/test/configuration_test.rb +64 -0
- data/test/context_test.rb +295 -0
- data/test/executor_test.rb +93 -0
- data/test/fixtures/canonical-events.json +38 -0
- data/test/integration_test.rb +154 -0
- data/test/lifecycle_test.rb +128 -0
- data/test/snapshot_test.rb +88 -0
- data/test/support/runtime_server.rb +101 -0
- data/test/test_helper.rb +30 -0
- data/test/transport_test.rb +182 -0
- data/test/wire_test.rb +128 -0
- metadata +72 -0
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
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,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
|