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.
@@ -0,0 +1,182 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "test_helper"
4
+ require "socket"
5
+ require "stringio"
6
+ require_relative "../lib/mirrors_sdk/configuration"
7
+ require_relative "../lib/mirrors_sdk/transport"
8
+
9
+ class TransportTest < Minitest::Test
10
+ def setup
11
+ @requests = Queue.new
12
+ @connections, @handlers = [], []
13
+ @behavior = ->(socket, _request) { respond(socket) }
14
+ @server = TCPServer.new("127.0.0.1", 0)
15
+ @config = MirrorsSDK::Config.new(api_url: "http://127.0.0.1:#{@server.addr[1]}/prefix",
16
+ api_key: "private-token", agent_id: "agent", failure_threshold: 1)
17
+ @transport = MirrorsSDK::Transport.new(@config)
18
+ @acceptor = Thread.new do
19
+ loop do
20
+ socket = @server.accept
21
+ @connections << socket
22
+ @handlers << Thread.new(socket) { |connection| handle(connection) }
23
+ end
24
+ rescue IOError, Errno::EBADF
25
+ nil
26
+ end
27
+ end
28
+
29
+ def teardown
30
+ @transport.close if @transport
31
+ @server.close if @server
32
+ @connections.each { |socket| socket.close unless socket.closed? }
33
+ @acceptor.join(0.2) if @acceptor
34
+ @handlers.each { |thread| thread.join(0.2) || thread.kill }
35
+ end
36
+
37
+ def test_exact_wire_binding_and_mode_get
38
+ body = '{"final":false,"text":"original"}'
39
+ assert_equal({ "ok" => true }, post(body))
40
+ request = @requests.pop
41
+ assert_equal "POST /prefix/v1/agents/agent/runtime/trace HTTP/1.1", request[:line]
42
+ assert_equal body, request[:body]
43
+ assert_equal "Bearer private-token", request[:headers]["authorization"]
44
+ assert_equal "3", request[:headers]["x-mirrors-protocol"]
45
+ assert_equal Digest::SHA256.hexdigest("trace\n#{body}"), request[:headers]["idempotency-key"]
46
+ @transport.request("mode", nil, deadline: monotonic + 1) { true }
47
+ request = @requests.pop
48
+ assert request[:line].start_with?("GET ")
49
+ assert_equal "", request[:body]
50
+ assert_equal "1", request[:headers]["x-mirrors-protocol"]
51
+ end
52
+
53
+ def test_total_deadline_bounds_trickling_response
54
+ @behavior = lambda do |socket, _request|
55
+ socket.write("HTTP/1.1 200 OK\r\nContent-Length: 1000\r\nConnection: close\r\n\r\n")
56
+ 1000.times { socket.write(" "); sleep 0.005 }
57
+ end
58
+ started = monotonic
59
+ error = assert_raises(MirrorsSDK::TransportError) { post(timeout: 0.05) }
60
+ assert_equal "timeout", error.reason
61
+ assert_operator monotonic - started, :<, 0.3
62
+ end
63
+
64
+ def test_redirect_does_not_forward_credentials
65
+ @behavior = ->(socket, _) { respond(socket, status: 302, headers: "Location: /credential-target\r\n") }
66
+ assert_raises(MirrorsSDK::TransportError) { post }
67
+ assert_equal 1, @requests.size
68
+ end
69
+
70
+ def test_raw_and_decompressed_limits_and_valid_gzip
71
+ data = '{"data":"' + ("x" * 70_000) + '"}'
72
+ [nil, "gzip", "deflate"].each do |encoding|
73
+ reset_transport
74
+ payload = encode(data, encoding)
75
+ headers = encoding ? "Content-Encoding: #{encoding}\r\n" : ""
76
+ @behavior = ->(socket, _) { respond(socket, body: payload, headers: headers) }
77
+ error = assert_raises(MirrorsSDK::TransportError) { post }
78
+ assert_equal "response_too_large", error.reason
79
+ end
80
+ reset_transport
81
+ payload = encode('{"ok":true}', "gzip")
82
+ @behavior = ->(socket, _) { respond(socket, body: payload, headers: "Content-Encoding: gzip\r\n") }
83
+ assert_equal({ "ok" => true }, post)
84
+ end
85
+
86
+ def test_cancellation_releases_socket_without_opening_circuit
87
+ entered = Queue.new
88
+ @behavior = ->(socket, _) { entered << true; socket.read }
89
+ token = MirrorsSDK::Cancellation.new
90
+ job = Thread.new do
91
+ post(cancel: token)
92
+ rescue MirrorsSDK::TransportError => error
93
+ error
94
+ end
95
+ entered.pop
96
+ token.cancel
97
+ assert job.join(0.5), "Cancellation must interrupt a connected request"
98
+ assert_equal "canceled", job.value.reason
99
+ @behavior = ->(socket, _) { respond(socket) }
100
+ assert_equal({ "ok" => true }, post)
101
+ ensure
102
+ job.kill if job && job.alive?
103
+ end
104
+
105
+ def test_final_circuit_reconciles_exact_prefix_independently
106
+ body = '{"final":false,"requestId":"immutable-prefix"}'
107
+ @behavior = ->(socket, _) { respond(socket, status: 503) }
108
+ assert_raises(MirrorsSDK::TransportError) { post(body) }
109
+ first = @requests.pop
110
+ @behavior = ->(socket, _) { respond(socket) }
111
+ value = @transport.request("trace", body, deadline: monotonic + 1, final_recovery: true) { |v| v["ok"] }
112
+ assert_equal({ "ok" => true }, value)
113
+ second = @requests.pop
114
+ assert_equal first[:body], second[:body]
115
+ assert_equal first[:headers]["idempotency-key"], second[:headers]["idempotency-key"]
116
+ assert_equal "circuit_open", assert_raises(MirrorsSDK::TransportError) { post }.reason
117
+ assert_equal({ "ok" => true }, post('{"final":true}'))
118
+ end
119
+
120
+ def test_invalid_json_and_bindings_fail_closed
121
+ ['{"ok":true,"ok":false}', '{"value":NaN}', '{"ok":false}', "\xff".b].each do |body|
122
+ reset_transport
123
+ @behavior = ->(socket, _) { respond(socket, body: body) }
124
+ error = assert_raises(MirrorsSDK::TransportError) { post }
125
+ assert_equal "invalid_response", error.reason
126
+ assert_nil error.cause
127
+ end
128
+ end
129
+
130
+ def test_permanent_rejection_has_sanitized_message
131
+ @behavior = ->(socket, _) { respond(socket, status: 400, body: "private-token original history") }
132
+ error = assert_raises(MirrorsSDK::TransportError) { post }
133
+ assert error.permanent
134
+ assert_equal "rejected", error.message
135
+ end
136
+
137
+ private
138
+
139
+ def monotonic
140
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
141
+ end
142
+
143
+ def post(body = '{"final":false}', timeout: 1, cancel: nil)
144
+ @transport.request("trace", body, deadline: monotonic + timeout, cancel: cancel) { |value| value["ok"] == true }
145
+ end
146
+
147
+ def reset_transport
148
+ @transport.close
149
+ @transport = MirrorsSDK::Transport.new(@config)
150
+ end
151
+
152
+ def handle(socket)
153
+ line = socket.gets("\r\n").strip
154
+ headers = {}
155
+ while (header = socket.gets("\r\n")) && header != "\r\n"
156
+ key, value = header.strip.split(":", 2)
157
+ headers[key.downcase] = value.strip
158
+ end
159
+ request = { line: line, headers: headers, body: socket.read(headers.fetch("content-length", "0").to_i) }
160
+ @requests << request
161
+ @behavior.call(socket, request)
162
+ rescue IOError, SystemCallError
163
+ nil
164
+ ensure
165
+ socket.close unless socket.closed?
166
+ end
167
+
168
+ def respond(socket, body: '{"ok":true}', status: 200, headers: "")
169
+ socket.write("HTTP/1.1 #{status} Result\r\nContent-Length: #{body.bytesize}\r\nConnection: close\r\n#{headers}\r\n#{body}")
170
+ end
171
+
172
+ def encode(data, encoding)
173
+ return data unless encoding
174
+ return Zlib::Deflate.deflate(data) if encoding == "deflate"
175
+
176
+ output = StringIO.new
177
+ writer = Zlib::GzipWriter.new(output)
178
+ writer.write(data)
179
+ writer.close
180
+ output.string
181
+ end
182
+ end
data/test/wire_test.rb ADDED
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "test_helper"
4
+ require "mirrors_sdk/wire"
5
+
6
+ class WireTest < Minitest::Test
7
+ def trace(**extra)
8
+ { "id" => "run-1", "messages" => [{ "role" => "user", "content" => "Hello" }] }.merge(extra.transform_keys(&:to_s))
9
+ end
10
+
11
+ def utc(offset = 0)
12
+ (Time.now + offset).utc.iso8601(3)
13
+ end
14
+
15
+ def capture(final = false)
16
+ request = { "protocol" => 3, "agentId" => "agent", "requestId" => "request", "ordinal" => 1,
17
+ "trace" => trace, "final" => final, "captureOnly" => true }
18
+ response = { "protocol" => 3, "agentId" => "agent", "traceId" => "run-1", "requestId" => "request",
19
+ "ordinal" => 1, "final" => final, "captureOnly" => true, "decisionId" => "decision",
20
+ "expiresAt" => utc(1), "mode" => "active", "role" => "system", "context" => nil,
21
+ "ingestion" => final ? { "status" => "queued", "jobId" => "job" } : nil }
22
+ [request, response]
23
+ end
24
+
25
+ def context
26
+ request = { "protocol" => 1, "agentId" => "agent", "requestId" => "request", "trace" => trace }
27
+ response = { "protocol" => 1, "agentId" => "agent", "requestId" => "request", "traceId" => "run-1",
28
+ "decisionId" => "decision", "issuedAt" => utc(-0.1), "expiresAt" => utc(1), "mode" => "active",
29
+ "modeVersion" => "version", "role" => "system", "context" => "fix", "reason" => "selected" }
30
+ [request, response]
31
+ end
32
+
33
+ def test_shared_canonical_fixture_is_preserved
34
+ fixture = JSON.parse(File.read(File.join(__dir__, "fixtures/canonical-events.json")))
35
+ ["../../python-sdk/tests/fixtures/canonical-events.json", "../../remote-sdk/test/fixtures/canonical-events.json"].each do |path|
36
+ source = File.expand_path(path, __dir__)
37
+ assert_equal JSON.parse(File.read(source)), fixture if File.exist?(source)
38
+ end
39
+ messages = fixture["events"][0]["data"]
40
+ source = trace(messages: messages)
41
+ assert_equal source, MirrorsSDK::Wire.snapshot(source)
42
+ end
43
+
44
+ def test_conversation_user_and_latency_rules
45
+ source = trace(conversationId: "chat", conversationRevision: 7,
46
+ user: { "id" => "\ufeffuser\ufeff", "segment" => " founders " }, turnLatencyMs: 0.25)
47
+ copied = MirrorsSDK::Wire.snapshot(source, final: true, terminal: "failed")
48
+ assert_equal({ "id" => "user", "segment" => "founders" }, copied["user"])
49
+ assert_equal "\ufeffuser\ufeff", source["user"]["id"]
50
+ assert_equal 0.25, copied["turnLatencyMs"]
51
+ [0, -1, 1.5, true, 9_007_199_254_740_992].each do |revision|
52
+ assert_raises(ArgumentError) { MirrorsSDK::Wire.snapshot(trace(conversationId: "chat", conversationRevision: revision)) }
53
+ end
54
+ assert_raises(ArgumentError) { MirrorsSDK::Wire.snapshot(trace(conversationId: "chat")) }
55
+ assert_raises(ArgumentError) { MirrorsSDK::Wire.snapshot(source) }
56
+ assert_raises(ArgumentError) { MirrorsSDK::Wire.snapshot(trace, terminal: "completed") }
57
+ [true, -1, Float::INFINITY].each do |latency|
58
+ assert_raises(ArgumentError) { MirrorsSDK::Wire.snapshot(trace(turnLatencyMs: latency), final: true) }
59
+ end
60
+ end
61
+
62
+ def test_agent_context_matches_prompt_and_skill_limits
63
+ ["", " Original prompt\n", "💡" * 50_000].each do |prompt|
64
+ source = trace(agentContext: { "systemPrompt" => prompt, "skills" => [{ "name" => "search", "content" => "Find evidence" }] })
65
+ assert_equal source, MirrorsSDK::Wire.snapshot(source)
66
+ end
67
+ assert_equal({ "skills" => [] }, MirrorsSDK::Wire.snapshot(trace(agentContext: { "skills" => [] }))["agentContext"])
68
+ [{}, nil, { "systemPrompt" => nil }, { "systemPrompt" => "💡" * 50_001 },
69
+ { "systemPrompt" => "界" * 87_376 }, { "skills" => [{ "name" => " ", "content" => "x" }] },
70
+ { "skills" => [{ "name" => "a", "content" => "" }] },
71
+ { "skills" => Array.new(2) { { "name" => "same", "content" => "x" } } },
72
+ { "skills" => [{ "name" => "a\n", "content" => "x" }] },
73
+ { "systemPrompt" => "x", "unknown" => true }].each do |agent_context|
74
+ assert_raises(ArgumentError) { MirrorsSDK::Wire.snapshot(trace(agentContext: agent_context)) }
75
+ end
76
+ boundary = trace(agentContext: { "systemPrompt" => "界" * 87_375 })
77
+ assert_equal 262_144, MirrorsSDK::Wire.encode(boundary["agentContext"]).bytesize
78
+ assert_equal boundary, MirrorsSDK::Wire.snapshot(boundary)
79
+ end
80
+
81
+ def test_invalid_fields_and_unicode_limits_do_not_silently_change_evidence
82
+ [trace(extra: 1), trace(id: "résumé"), trace(entryPoint: "💡" * 129),
83
+ trace(user: { "id" => "a\u001cb" }), trace(user: { "id" => "💡" * 129 }),
84
+ trace(messages: [{ "role" => "assistant", "content" => [{ "type" => "tool-call", "callId" => "c", "name" => "t" }] }]),
85
+ trace(messages: [{ "role" => "tool", "content" => [{ "type" => "tool-result", "callId" => "c", "name" => "t", "value" => nil, "error" => 1 }] }])].each do |source|
86
+ assert_raises(ArgumentError) { MirrorsSDK::Wire.snapshot(source) }
87
+ end
88
+ end
89
+
90
+ def test_capture_receipts_require_exact_bindings_and_flags
91
+ [false, true].each do |final|
92
+ request, response = capture(final)
93
+ assert MirrorsSDK::Wire.trace_response(response, request)
94
+ { "ordinal" => 1.0, "final" => 1, "agentId" => "other", "traceId" => "other", "protocol" => true,
95
+ "context" => "injection", "captureOnly" => 1, "expiresAt" => "2026-02-30T00:00:00.000Z" }.each do |field, invalid|
96
+ refute MirrorsSDK::Wire.trace_response(response.merge(field => invalid), request), field
97
+ end
98
+ %w[context ingestion captureOnly].each do |field|
99
+ refute MirrorsSDK::Wire.trace_response(response.reject { |key, _| key == field }, request)
100
+ end
101
+ bad_ingestion = final ? nil : { "status" => "queued", "jobId" => "job" }
102
+ refute MirrorsSDK::Wire.trace_response(response.merge("ingestion" => bad_ingestion), request)
103
+ end
104
+ end
105
+
106
+ def test_context_bindings_shadow_and_mode_lease_limits
107
+ request, response = context
108
+ assert MirrorsSDK::Wire.context_response(response, request)
109
+ { "expiresAt" => utc(6), "issuedAt" => utc(2), "context" => "é" * 16_385,
110
+ "mode" => "shadow", "protocol" => true, "modeVersion" => "bad/value", "shadowOnly" => false }.each do |field, invalid|
111
+ refute MirrorsSDK::Wire.context_response(response.merge(field => invalid), request), field
112
+ end
113
+ assert MirrorsSDK::Wire.context_response(response.merge("shadowOnly" => true, "mode" => "shadow", "context" => nil),
114
+ request.merge("shadowOnly" => true))
115
+ mode = { "protocol" => 1, "agentId" => "agent", "mode" => "active", "version" => "v",
116
+ "issuedAt" => utc(-0.1), "expiresAt" => utc(1) }
117
+ assert MirrorsSDK::Wire.mode_response(mode, "agent")
118
+ refute MirrorsSDK::Wire.mode_response(mode.merge("expiresAt" => utc(4)), "agent")
119
+ refute MirrorsSDK::Wire.mode_response(mode.merge("agentId" => "other"), "agent")
120
+ end
121
+
122
+ def test_dates_are_canonical_calendar_dates_with_milliseconds
123
+ assert_equal 1_767_225_600, MirrorsSDK::Wire.utc("2026-01-01T00:00:00.000Z")
124
+ ["2026-01-01T00:00:00Z", "2026-01-01T00:00:00.000+00:00", "2026-02-30T00:00:00.000Z", nil].each do |invalid|
125
+ assert_raises(ArgumentError) { MirrorsSDK::Wire.utc(invalid) }
126
+ end
127
+ end
128
+ end
metadata ADDED
@@ -0,0 +1,72 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: mirrors-sdk
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Mirrors
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies: []
12
+ description: Capture canonical conversation history and retrieve bounded, server-selected
13
+ context while your application owns model calls and tools.
14
+ executables: []
15
+ extensions: []
16
+ extra_rdoc_files: []
17
+ files:
18
+ - Gemfile
19
+ - README.md
20
+ - Rakefile
21
+ - lib/mirrors_sdk.rb
22
+ - lib/mirrors_sdk/agent_context.rb
23
+ - lib/mirrors_sdk/capture.rb
24
+ - lib/mirrors_sdk/circuit.rb
25
+ - lib/mirrors_sdk/client.rb
26
+ - lib/mirrors_sdk/configuration.rb
27
+ - lib/mirrors_sdk/context.rb
28
+ - lib/mirrors_sdk/delivery.rb
29
+ - lib/mirrors_sdk/executor.rb
30
+ - lib/mirrors_sdk/models.rb
31
+ - lib/mirrors_sdk/snapshot.rb
32
+ - lib/mirrors_sdk/transport.rb
33
+ - lib/mirrors_sdk/version.rb
34
+ - lib/mirrors_sdk/wire.rb
35
+ - lib/mirrors_sdk/worker.rb
36
+ - mirrors-sdk.gemspec
37
+ - skills/install-mirrors-ruby-sdk/SKILL.md
38
+ - test/capture_test.rb
39
+ - test/configuration_test.rb
40
+ - test/context_test.rb
41
+ - test/executor_test.rb
42
+ - test/fixtures/canonical-events.json
43
+ - test/integration_test.rb
44
+ - test/lifecycle_test.rb
45
+ - test/snapshot_test.rb
46
+ - test/support/runtime_server.rb
47
+ - test/test_helper.rb
48
+ - test/transport_test.rb
49
+ - test/wire_test.rb
50
+ homepage: https://mirrors.dev/docs
51
+ licenses: []
52
+ metadata:
53
+ source_code_uri: https://github.com/runmirrors/sidecar/tree/main/packages/ruby-sdk
54
+ documentation_uri: https://github.com/runmirrors/sidecar/blob/main/packages/ruby-sdk/README.md
55
+ rdoc_options: []
56
+ require_paths:
57
+ - lib
58
+ required_ruby_version: !ruby/object:Gem::Requirement
59
+ requirements:
60
+ - - ">="
61
+ - !ruby/object:Gem::Version
62
+ version: '3.3'
63
+ required_rubygems_version: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - ">="
66
+ - !ruby/object:Gem::Version
67
+ version: '0'
68
+ requirements: []
69
+ rubygems_version: 3.6.9
70
+ specification_version: 4
71
+ summary: Background trace capture and optional context for Ruby agents
72
+ test_files: []