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
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
require "uri"
|
|
5
|
+
require_relative "snapshot"
|
|
6
|
+
require_relative "agent_context"
|
|
7
|
+
|
|
8
|
+
module MirrorsSDK
|
|
9
|
+
# Canonical evidence and receipt bindings shared by capture, retrieval and heartbeat.
|
|
10
|
+
class Wire
|
|
11
|
+
MODES = %w[active shadow off].freeze
|
|
12
|
+
BASE64 = /\A(?:[A-Za-z0-9+\/]{4})*(?:[A-Za-z0-9+\/]{2}==|[A-Za-z0-9+\/]{3}=)?\z/.freeze
|
|
13
|
+
|
|
14
|
+
def self.encode(value, limit: 1_000_000, deadline: nil)
|
|
15
|
+
Snapshot.encode(value, limit: limit, deadline: deadline)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def self.id(value)
|
|
19
|
+
unless value.instance_of?(String) && value.match?(/\A[A-Za-z0-9_.-]{1,128}\z/)
|
|
20
|
+
raise ArgumentError, "Invalid Mirrors identifier"
|
|
21
|
+
end
|
|
22
|
+
value
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def self.utc(value)
|
|
26
|
+
unless value.instance_of?(String) && value.match?(/\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z\z/)
|
|
27
|
+
raise ArgumentError, "Canonical UTC timestamp required"
|
|
28
|
+
end
|
|
29
|
+
parsed = Time.iso8601(value).utc
|
|
30
|
+
raise ArgumentError, "Invalid UTC date" unless parsed.iso8601(3) == value
|
|
31
|
+
|
|
32
|
+
parsed.to_f
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def self.snapshot(trace, final: false, terminal: nil, deadline: nil)
|
|
36
|
+
Snapshot.check_deadline(deadline)
|
|
37
|
+
unless (true.equal?(final) || false.equal?(final)) &&
|
|
38
|
+
(nil.equal?(terminal) || (final && Snapshot::CLASS.bind(terminal).call == String &&
|
|
39
|
+
%w[completed failed canceled].include?(String.new(terminal))))
|
|
40
|
+
raise ArgumentError, "Invalid execution ending"
|
|
41
|
+
end
|
|
42
|
+
value = JSON.parse(encode(trace, deadline: deadline))
|
|
43
|
+
Snapshot.check_deadline(deadline)
|
|
44
|
+
fields(value, %w[id messages], %w[conversationId conversationRevision model entryPoint completeness user turnLatencyMs agentContext])
|
|
45
|
+
id(value["id"])
|
|
46
|
+
messages(value["messages"], deadline)
|
|
47
|
+
AgentContextWire.validate(value["agentContext"]) if value.key?("agentContext")
|
|
48
|
+
if value.key?("conversationId") || value.key?("conversationRevision")
|
|
49
|
+
id(value["conversationId"])
|
|
50
|
+
revision = value["conversationRevision"]
|
|
51
|
+
unless revision.instance_of?(Integer) && revision.between?(1, Snapshot::SAFE_INTEGER)
|
|
52
|
+
raise ArgumentError, "Conversation identity requires a positive safe integer revision"
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
{ "model" => 200, "entryPoint" => 256 }.each do |field, limit|
|
|
56
|
+
text(value[field], limit) if value.key?(field)
|
|
57
|
+
end
|
|
58
|
+
completeness(value["completeness"]) if value.key?("completeness")
|
|
59
|
+
end_user(value["user"]) if value.key?("user")
|
|
60
|
+
if value.key?("turnLatencyMs")
|
|
61
|
+
latency = value["turnLatencyMs"]
|
|
62
|
+
unless final && [Integer, Float].include?(latency.class) && latency.finite? && latency >= 0
|
|
63
|
+
raise ArgumentError, "Turn latency requires a final snapshot and finite nonnegative value"
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
Snapshot.check_deadline(deadline)
|
|
67
|
+
value
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def self.lifetime(value)
|
|
71
|
+
((utc(value["expiresAt"]) - utc(value["issuedAt"])) * 1000).round / 1000.0
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def self.mode_response(value, agent_id)
|
|
75
|
+
value.instance_of?(Hash) && value["protocol"].instance_of?(Integer) &&
|
|
76
|
+
value["protocol"] == 1 && value["agentId"] == agent_id && MODES.include?(value["mode"]) &&
|
|
77
|
+
!!id(value["version"]) && dates(value, 3)
|
|
78
|
+
rescue ArgumentError, TypeError
|
|
79
|
+
false
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def self.context_response(value, request)
|
|
83
|
+
return false unless response_binding(value, request, 1) && echo(value, request, "shadowOnly") &&
|
|
84
|
+
!!id(value["modeVersion"]) && !!text(value["reason"], 128) &&
|
|
85
|
+
dates(value, 5) && value.key?("context")
|
|
86
|
+
|
|
87
|
+
context = value["context"]
|
|
88
|
+
context.nil? || (context.instance_of?(String) && context.bytesize <= 32_768 &&
|
|
89
|
+
value["mode"] == "active" && !request["shadowOnly"])
|
|
90
|
+
rescue ArgumentError, TypeError, KeyError
|
|
91
|
+
false
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def self.trace_response(value, request)
|
|
95
|
+
return false unless response_binding(value, request, 3) &&
|
|
96
|
+
%w[ordinal final terminal captureOnly].all? { |field| echo(value, request, field) } &&
|
|
97
|
+
value.key?("context") && value.key?("ingestion")
|
|
98
|
+
|
|
99
|
+
utc(value["expiresAt"])
|
|
100
|
+
context = value["context"]
|
|
101
|
+
if !context.nil? && (!context.instance_of?(String) || context.bytesize > 32_768 ||
|
|
102
|
+
value["mode"] != "active" || request["final"] || request["captureOnly"])
|
|
103
|
+
return false
|
|
104
|
+
end
|
|
105
|
+
ingestion = value["ingestion"]
|
|
106
|
+
return !request["final"] if ingestion.nil?
|
|
107
|
+
return false unless request["final"]
|
|
108
|
+
|
|
109
|
+
fields(ingestion, %w[status jobId])
|
|
110
|
+
(ingestion["status"] == "queued" && !!id(ingestion["jobId"])) ||
|
|
111
|
+
(ingestion["status"] == "unsupported_history" && ingestion["jobId"].nil?)
|
|
112
|
+
rescue ArgumentError, TypeError, KeyError
|
|
113
|
+
false
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def self.fields(value, required, optional = [])
|
|
117
|
+
unless value.instance_of?(Hash) && (required - value.keys).empty? &&
|
|
118
|
+
(value.keys - required - optional).empty?
|
|
119
|
+
raise ArgumentError, "Invalid canonical fields"
|
|
120
|
+
end
|
|
121
|
+
value
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def self.text(value, limit = nil)
|
|
125
|
+
unless value.instance_of?(String) && (!limit || AgentContextWire.length(value).between?(1, limit))
|
|
126
|
+
raise ArgumentError, "Invalid canonical text"
|
|
127
|
+
end
|
|
128
|
+
value
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def self.messages(value, deadline)
|
|
132
|
+
unless value.instance_of?(Array) && value.length <= 10_000
|
|
133
|
+
raise ArgumentError, "Invalid message collection"
|
|
134
|
+
end
|
|
135
|
+
value.each do |message|
|
|
136
|
+
Snapshot.check_deadline(deadline)
|
|
137
|
+
fields(message, %w[role content], %w[metadata])
|
|
138
|
+
unless %w[system user assistant tool].include?(message["role"])
|
|
139
|
+
raise ArgumentError, "Invalid message role"
|
|
140
|
+
end
|
|
141
|
+
content = message["content"]
|
|
142
|
+
next if content.instance_of?(String)
|
|
143
|
+
unless content.instance_of?(Array) && content.length <= 1000
|
|
144
|
+
raise ArgumentError, "Invalid message content"
|
|
145
|
+
end
|
|
146
|
+
content.each do |item|
|
|
147
|
+
Snapshot.check_deadline(deadline)
|
|
148
|
+
block(item)
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def self.block(value)
|
|
154
|
+
raise ArgumentError, "Invalid canonical block" unless value.instance_of?(Hash)
|
|
155
|
+
|
|
156
|
+
case value["type"]
|
|
157
|
+
when "text", "reasoning"
|
|
158
|
+
fields(value, %w[type text], %w[metadata])
|
|
159
|
+
text(value["text"])
|
|
160
|
+
when "tool-call", "tool-result"
|
|
161
|
+
call = value["type"] == "tool-call"
|
|
162
|
+
required = call ? %w[type callId name arguments] : %w[type callId name value error]
|
|
163
|
+
fields(value, required, call ? %w[metadata providerExecuted] : %w[metadata])
|
|
164
|
+
id(value["callId"])
|
|
165
|
+
text(value["name"], 128)
|
|
166
|
+
flag = call ? "providerExecuted" : "error"
|
|
167
|
+
if value.key?(flag) && value[flag] != true && value[flag] != false
|
|
168
|
+
raise ArgumentError, "Invalid tool flag"
|
|
169
|
+
end
|
|
170
|
+
when "media"
|
|
171
|
+
media(value)
|
|
172
|
+
else
|
|
173
|
+
raise ArgumentError, "Unsupported canonical block"
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def self.media(value)
|
|
178
|
+
fields(value, %w[type kind source], %w[metadata mediaType filename])
|
|
179
|
+
raise ArgumentError, "Invalid media kind" unless %w[image file].include?(value["kind"])
|
|
180
|
+
|
|
181
|
+
source = fields(value["source"], %w[encoding value])
|
|
182
|
+
contents = text(source["value"])
|
|
183
|
+
if source["encoding"] == "url"
|
|
184
|
+
uri = URI.parse(contents)
|
|
185
|
+
unless %w[http https].include?(uri.scheme) && uri.hostname && !uri.hostname.empty?
|
|
186
|
+
raise ArgumentError, "Invalid media URL"
|
|
187
|
+
end
|
|
188
|
+
elsif source["encoding"] != "base64" || !contents.match?(BASE64)
|
|
189
|
+
raise ArgumentError, "Invalid media encoding"
|
|
190
|
+
end
|
|
191
|
+
%w[mediaType filename].each { |field| text(value[field]) if value.key?(field) }
|
|
192
|
+
rescue URI::InvalidURIError
|
|
193
|
+
raise ArgumentError, "Invalid media URL"
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def self.completeness(value)
|
|
197
|
+
fields(value, %w[history original_content ended])
|
|
198
|
+
unless %w[complete truncated].include?(value["history"]) &&
|
|
199
|
+
%w[complete partial].include?(value["original_content"]) &&
|
|
200
|
+
%w[completed abandoned unknown].include?(value["ended"])
|
|
201
|
+
raise ArgumentError, "Invalid source completeness"
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def self.end_user(value)
|
|
206
|
+
fields(value, %w[id], %w[segment])
|
|
207
|
+
{ "id" => 256, "segment" => 64 }.each do |field, limit|
|
|
208
|
+
next unless value.key?(field)
|
|
209
|
+
|
|
210
|
+
trimmed = text(value[field]).gsub(AgentContextWire::TRIM, "")
|
|
211
|
+
unless AgentContextWire.length(trimmed).between?(1, limit) && !trimmed.match?(/\p{Cc}/)
|
|
212
|
+
raise ArgumentError, "Invalid end user"
|
|
213
|
+
end
|
|
214
|
+
value[field] = trimmed
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def self.dates(value, maximum)
|
|
219
|
+
duration = lifetime(value)
|
|
220
|
+
duration > 0 && duration <= maximum && utc(value["issuedAt"]) <= Time.now.to_f + 0.1
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def self.echo(value, request, field)
|
|
224
|
+
value.key?(field) == request.key?(field) && value[field].class == request[field].class &&
|
|
225
|
+
value[field] == request[field]
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def self.response_binding(value, request, protocol)
|
|
229
|
+
value.instance_of?(Hash) && value["protocol"].instance_of?(Integer) && value["protocol"] == protocol &&
|
|
230
|
+
value["agentId"] == request["agentId"] && value["traceId"] == request.fetch("trace").fetch("id") &&
|
|
231
|
+
value["requestId"] == request["requestId"] && value["role"] == "system" &&
|
|
232
|
+
MODES.include?(value["mode"]) && !!id(value["decisionId"])
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
private_class_method :fields, :text, :messages, :block, :media, :completeness,
|
|
236
|
+
:end_user, :dates, :echo, :response_binding
|
|
237
|
+
end
|
|
238
|
+
end
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "monitor"
|
|
4
|
+
|
|
5
|
+
module MirrorsSDK
|
|
6
|
+
# Independent bounded pools keep optional capture traffic out of the context path.
|
|
7
|
+
class Worker
|
|
8
|
+
attr_reader :transport, :context_executor
|
|
9
|
+
|
|
10
|
+
def initialize(config)
|
|
11
|
+
@config, @lock = config, Monitor.new
|
|
12
|
+
@closed, @enabled, @generation = false, config.enabled, 0
|
|
13
|
+
@wake = @lock.new_cond
|
|
14
|
+
@transport = Transport.new(config)
|
|
15
|
+
@capture = CaptureRuntime.new(@transport, config)
|
|
16
|
+
@prefix = Executor.new(size: 2, capacity: 16, name: "mirrors-capture")
|
|
17
|
+
@final = Executor.new(size: 2, capacity: 16, name: "mirrors-final")
|
|
18
|
+
@context_executor = Executor.new(size: 2, capacity: 16, name: "mirrors-context")
|
|
19
|
+
rescue StandardError
|
|
20
|
+
dispose
|
|
21
|
+
raise
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def start(context)
|
|
25
|
+
@heartbeat = Thread.new { heartbeat(context) }
|
|
26
|
+
@heartbeat.name = "mirrors-mode"
|
|
27
|
+
@heartbeat.report_on_exception = false
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def capture(value, deadline:, sequence:)
|
|
31
|
+
@lock.synchronize do
|
|
32
|
+
return nil if @closed || !@enabled
|
|
33
|
+
|
|
34
|
+
runtime = @capture
|
|
35
|
+
pool = value["final"] ? @final : @prefix
|
|
36
|
+
pool.submit { |cancel| runtime.send(value, deadline: deadline, cancel: cancel, sequence: sequence) }
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def release(trace_id)
|
|
41
|
+
@lock.synchronize { @capture.release(trace_id) }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def set_enabled(enabled)
|
|
45
|
+
@lock.synchronize do
|
|
46
|
+
return if @closed || @enabled == enabled
|
|
47
|
+
|
|
48
|
+
@enabled = enabled
|
|
49
|
+
@generation += 1
|
|
50
|
+
@heartbeat_cancel&.cancel("disabled")
|
|
51
|
+
@transport.set_enabled(enabled)
|
|
52
|
+
if enabled
|
|
53
|
+
@capture = CaptureRuntime.new(@transport, @config)
|
|
54
|
+
else
|
|
55
|
+
@capture.clear
|
|
56
|
+
end
|
|
57
|
+
@wake.broadcast
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def dispose
|
|
62
|
+
@lock.synchronize do
|
|
63
|
+
return if @closed
|
|
64
|
+
|
|
65
|
+
@closed = true
|
|
66
|
+
@heartbeat_cancel&.cancel("closed")
|
|
67
|
+
@capture&.clear
|
|
68
|
+
@transport&.close
|
|
69
|
+
[@prefix, @final, @context_executor].compact.each(&:shutdown)
|
|
70
|
+
@wake.broadcast
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def diagnostics
|
|
75
|
+
{ capture: @prefix.diagnostics, final: @final.diagnostics, context: @context_executor.diagnostics }
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
private
|
|
79
|
+
|
|
80
|
+
def heartbeat(context)
|
|
81
|
+
loop do
|
|
82
|
+
attempt = @lock.synchronize do
|
|
83
|
+
break if @closed
|
|
84
|
+
|
|
85
|
+
if @enabled
|
|
86
|
+
@heartbeat_cancel = Cancellation.new
|
|
87
|
+
[@generation, @heartbeat_cancel]
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
break if @closed
|
|
91
|
+
|
|
92
|
+
refresh(context, *attempt) if attempt
|
|
93
|
+
@lock.synchronize do
|
|
94
|
+
break if @closed
|
|
95
|
+
|
|
96
|
+
@wake.wait(@config.heartbeat_interval_ms / 1000.0 * (0.9 + rand * 0.2))
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def refresh(context, generation, cancel)
|
|
102
|
+
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
103
|
+
value = @transport.request("mode", nil, deadline: started + 0.75, cancel: cancel) do |response|
|
|
104
|
+
Wire.mode_response(response, @config.agent_id)
|
|
105
|
+
end
|
|
106
|
+
@lock.synchronize do
|
|
107
|
+
context.observe_mode(value, started) if @enabled && !@closed && @generation == generation
|
|
108
|
+
end
|
|
109
|
+
rescue StandardError
|
|
110
|
+
# A failed refresh never extends the previous lease.
|
|
111
|
+
nil
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
data/lib/mirrors_sdk.rb
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "mirrors_sdk/version"
|
|
4
|
+
require_relative "mirrors_sdk/configuration"
|
|
5
|
+
require_relative "mirrors_sdk/models"
|
|
6
|
+
require_relative "mirrors_sdk/wire"
|
|
7
|
+
require_relative "mirrors_sdk/executor"
|
|
8
|
+
require_relative "mirrors_sdk/transport"
|
|
9
|
+
require_relative "mirrors_sdk/delivery"
|
|
10
|
+
require_relative "mirrors_sdk/capture"
|
|
11
|
+
require_relative "mirrors_sdk/context"
|
|
12
|
+
require_relative "mirrors_sdk/worker"
|
|
13
|
+
require_relative "mirrors_sdk/client"
|
data/mirrors-sdk.gemspec
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "lib/mirrors_sdk/version"
|
|
4
|
+
|
|
5
|
+
Gem::Specification.new do |spec|
|
|
6
|
+
spec.name = "mirrors-sdk"
|
|
7
|
+
spec.version = MirrorsSDK::VERSION
|
|
8
|
+
spec.authors = ["Mirrors"]
|
|
9
|
+
spec.summary = "Background trace capture and optional context for Ruby agents"
|
|
10
|
+
spec.description = "Capture canonical conversation history and retrieve bounded, " \
|
|
11
|
+
"server-selected context while your application owns model calls and tools."
|
|
12
|
+
spec.homepage = "https://mirrors.dev/docs"
|
|
13
|
+
spec.required_ruby_version = ">= 3.3"
|
|
14
|
+
spec.files = Dir.chdir(__dir__) do
|
|
15
|
+
Dir["lib/**/*.rb", "test/**/*", "skills/**/*.md", "README.md", "Gemfile", "Rakefile", "mirrors-sdk.gemspec"]
|
|
16
|
+
.select { |path| File.file?(path) }.sort
|
|
17
|
+
end
|
|
18
|
+
spec.require_paths = ["lib"]
|
|
19
|
+
spec.metadata = {
|
|
20
|
+
"source_code_uri" => "https://github.com/runmirrors/sidecar/tree/main/packages/ruby-sdk",
|
|
21
|
+
"documentation_uri" => "https://github.com/runmirrors/sidecar/blob/main/packages/ruby-sdk/README.md"
|
|
22
|
+
}
|
|
23
|
+
end
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: install-mirrors-ruby-sdk
|
|
3
|
+
description: Install or upgrade the Mirrors Ruby SDK and integrate original trace capture and optional context into an existing Ruby agent.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Install Mirrors for Ruby
|
|
7
|
+
|
|
8
|
+
Use `MirrorsSDK::Mirrors` from `require "mirrors_sdk"`. Preserve the application's
|
|
9
|
+
prompt assembly, provider calls, tools, streams, errors and cancellation. Network
|
|
10
|
+
workers, heartbeats, protocol handling and retries belong inside the SDK. Read the
|
|
11
|
+
installed package's matching README before adapting existing hooks.
|
|
12
|
+
|
|
13
|
+
## Select the package
|
|
14
|
+
|
|
15
|
+
Use Ruby 3.3+ and the application's existing dependency manager. The gem is named
|
|
16
|
+
`mirrors-sdk`; the require path is `mirrors_sdk`. Ruby, Python and npm package
|
|
17
|
+
versions are independent.
|
|
18
|
+
|
|
19
|
+
Verify that the intended official Mirrors release is published on the trusted gem
|
|
20
|
+
registry before using a registry install. This source version does not establish
|
|
21
|
+
RubyGems publication. Confirm ownership and source provenance, not merely a matching
|
|
22
|
+
name. When available, add the verified stable version through the existing Gemfile
|
|
23
|
+
and record its resolved version in the application's lockfile.
|
|
24
|
+
|
|
25
|
+
If no official release exists, use an available reviewed checkout and record its
|
|
26
|
+
revision. Replace this placeholder with the actual source directory:
|
|
27
|
+
|
|
28
|
+
```ruby
|
|
29
|
+
gem "mirrors-sdk", path: "/path/to/sidecar/packages/ruby-sdk", require: "mirrors_sdk"
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Then run `bundle install` and verify:
|
|
33
|
+
|
|
34
|
+
```sh
|
|
35
|
+
bundle exec ruby -rmirrors_sdk -e 'puts MirrorsSDK::VERSION'
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Respect constraints and lockfiles; explain conflicts instead of claiming an upgrade
|
|
39
|
+
succeeded. Do not invent a release, substitute another package or publish the gem
|
|
40
|
+
as part of installing it. The built gem includes this skill; find its directory via
|
|
41
|
+
`Gem::Specification.find_by_name("mirrors-sdk").full_gem_path`.
|
|
42
|
+
|
|
43
|
+
## Resolve the connection
|
|
44
|
+
|
|
45
|
+
Reuse verified server configuration for `api_url`, `agent_id` and `api_key`. Resolve
|
|
46
|
+
missing nonsecret settings from the intended agent's **Agent setup → SDK connection
|
|
47
|
+
settings** in the signed-in dashboard. Use its API origin and generated agent ID
|
|
48
|
+
together; never derive the ID from a display name or key. Load the matching agent's
|
|
49
|
+
key with runtime read/write permissions from server secrets. Never print, commit or
|
|
50
|
+
request a key in chat. Preserve explicit local, staging and self-hosted settings.
|
|
51
|
+
|
|
52
|
+
Before real trace delivery, make one bounded, read-only
|
|
53
|
+
`GET <api_url>/v1/agents/<agent_id>/runtime/mode` using `Authorization: Bearer ...`
|
|
54
|
+
and `X-Mirrors-Protocol: 1`, refusing redirects. The response must identify the same
|
|
55
|
+
agent. Shadow/off are valid modes. Recheck the environment, generated ID and key
|
|
56
|
+
scope on 404. Do not upload customer history merely to test credentials.
|
|
57
|
+
|
|
58
|
+
## Integrate the existing harness
|
|
59
|
+
|
|
60
|
+
Create one reusable client per agent configuration in each worker **after forking**:
|
|
61
|
+
|
|
62
|
+
```ruby
|
|
63
|
+
require "mirrors_sdk"
|
|
64
|
+
require "securerandom"
|
|
65
|
+
|
|
66
|
+
mirrors = MirrorsSDK::Mirrors.new(
|
|
67
|
+
api_url: ENV.fetch("MIRRORS_API_URL"),
|
|
68
|
+
api_key: ENV.fetch("MIRRORS_API_KEY"),
|
|
69
|
+
agent_id: ENV.fetch("MIRRORS_AGENT_ID")
|
|
70
|
+
)
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Find the original-history logger, user-turn prompt assembly and existing execution
|
|
74
|
+
ending hooks. Preserve complete original canonical messages, roles, reasoning,
|
|
75
|
+
block order and tool associations. Use builtin JSON values and string-key hashes;
|
|
76
|
+
canonical names remain camelCase (`conversationId`, `callId`, `turnLatencyMs`).
|
|
77
|
+
Exclude Mirrors-added context, and never invent associations or truncate evidence.
|
|
78
|
+
|
|
79
|
+
At user-message acceptance, create a fresh execution ID and optional turn timer.
|
|
80
|
+
Keep them through the full model/tool loop and every retry:
|
|
81
|
+
|
|
82
|
+
```ruby
|
|
83
|
+
turn = mirrors.start_turn
|
|
84
|
+
trace = { "id" => SecureRandom.uuid, "messages" => full_original_history }
|
|
85
|
+
mirrors.capture_trace(trace)
|
|
86
|
+
selected = mirrors.get_context(trace, timeout_ms: 1000, cancel: cancellation)
|
|
87
|
+
# Preserve the host's cancellation check here before calling its provider.
|
|
88
|
+
system = selected.context.empty? ? base_system : "#{base_system}\n\n#{selected.context}"
|
|
89
|
+
response = existing_model_call(system: system, messages: original_messages)
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
The fragment's history, prompt, model and cancellation variables belong to the
|
|
93
|
+
application. `cancellation` can be a `MirrorsSDK::Cancellation` token; call `cancel`
|
|
94
|
+
at the host's cancellation hook and check `canceled?` before its provider call.
|
|
95
|
+
Do not turn a canceled operation into a provider invocation.
|
|
96
|
+
|
|
97
|
+
`get_context` returns an object with attributes. It synchronously waits within one
|
|
98
|
+
total budget of at most 1,000 ms from entry; use a shorter `timeout_ms` to reduce
|
|
99
|
+
waiting. It does not wait for capture. Event-loop applications should use their
|
|
100
|
+
existing background execution facility. Empty context leaves the prompt unchanged.
|
|
101
|
+
Use returned text promptly for that request, check `freshness.expires_at` if delayed,
|
|
102
|
+
and retrieve again when history changes.
|
|
103
|
+
|
|
104
|
+
Capture updated original snapshots at the logger without awaiting delivery. Only
|
|
105
|
+
after the entire response stream, tools and retries finish, finalize once through
|
|
106
|
+
the existing completion/failure/cancellation hook:
|
|
107
|
+
|
|
108
|
+
```ruby
|
|
109
|
+
ticket = mirrors.capture_trace(
|
|
110
|
+
trace.merge("messages" => completed_original_history, "turnLatencyMs" => turn.finish),
|
|
111
|
+
final: true,
|
|
112
|
+
terminal: "completed" # Use "failed" or "canceled" at those hooks.
|
|
113
|
+
)
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Terminal status describes execution ending, not task success. Put latency only on
|
|
117
|
+
final capture, measure the whole turn and exclude capture delivery. Omit unknown
|
|
118
|
+
duration. With no final evidence, `release_trace(trace["id"])` only cleans up local
|
|
119
|
+
state. Never reuse execution IDs after completion, release, restart or disable.
|
|
120
|
+
|
|
121
|
+
Continuing chats supply both `conversationId` and `conversationRevision` or neither.
|
|
122
|
+
Allocate a positive safe-integer revision atomically in durable storage per
|
|
123
|
+
execution, persist the pair and keep it fixed across snapshots/retries. Timestamps,
|
|
124
|
+
message counts and process counters are invalid. Branches need distinct chat IDs.
|
|
125
|
+
Optional `user` contains an opaque internal `id` and optional cohort `segment`.
|
|
126
|
+
|
|
127
|
+
## Optional original instructions
|
|
128
|
+
|
|
129
|
+
When the installed SDK and deployed API/worker readers support it, supply original
|
|
130
|
+
instructions on `trace["agentContext"]`: `systemPrompt` and/or `skills`, where each
|
|
131
|
+
skill has `name` and `content`. Read the application's prompt before adding Mirrors
|
|
132
|
+
context; do not load arbitrary files or skills automatically. Include the object on
|
|
133
|
+
the final snapshot: previous values are never inherited. Omit unknown fields; empty
|
|
134
|
+
prompt/skills explicitly mean empty. Respect the installed README's character,
|
|
135
|
+
count and byte limits. Preserve existing system messages without synthesizing copies.
|
|
136
|
+
|
|
137
|
+
## Verify and shut down
|
|
138
|
+
|
|
139
|
+
Inspect `ticket.receipt` outside the model path; `ticket.wait(2)` waits up to two
|
|
140
|
+
seconds. Other timeout arguments use milliseconds. Accepted ingestion `queued`
|
|
141
|
+
does not mean analysis completed; an unconfirmed write may have committed.
|
|
142
|
+
|
|
143
|
+
At shutdown, stop admitting turns, let active turns finish and call
|
|
144
|
+
`mirrors.close(timeout_ms: 2000)`. `flush` covers only tickets pending at entry;
|
|
145
|
+
inspect accepted/dropped/unconfirmed/pending counts. Background work is best effort
|
|
146
|
+
and no durable queue exists.
|
|
147
|
+
|
|
148
|
+
Verify locally before real delivery: active context appends to a copied prompt;
|
|
149
|
+
original evidence remains unchanged; empty/shadow/off/error/cancellation paths
|
|
150
|
+
preserve host behavior; tools and streams finalize only at their true ending;
|
|
151
|
+
shutdown is bounded. Report the installed version and observed receipt outcomes,
|
|
152
|
+
distinguishing local tests from publication, hosted acceptance and model quality.
|