copilotkit-runtime 0.1.0.rc.1

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,386 @@
1
+ # frozen_string_literal: true
2
+ require 'json'
3
+ require 'net/http'
4
+ require 'uri'
5
+ require 'securerandom'
6
+ require 'thread'
7
+ require_relative 'telemetry'
8
+ require_relative 'intelligence'
9
+
10
+ module CopilotKit
11
+ # Rack endpoint. Mount directly in Rails routes with `mount runtime => '/copilotkit'`.
12
+ class Runtime
13
+ attr_reader :intelligence
14
+
15
+ def initialize(api_key: nil, identify_user:, intelligence: nil, api_url: nil,
16
+ runner_url: nil, client_url: nil, agents: {},
17
+ base_path: '', memory_access: nil, telemetry: nil, cors_origins: [],
18
+ learning_container: nil, a2ui: nil, mcp_apps: nil, on_error: nil,
19
+ lock_heartbeat_interval: 15, lock_ttl: 20, license_token: nil)
20
+ api_key ||= intelligence&.api_key
21
+ api_url ||= intelligence&.api_url || Intelligence::API_URL
22
+ runner_url ||= intelligence&.runner_url || Intelligence::RUNNER_URL
23
+ client_url ||= intelligence&.client_url || Intelligence::CLIENT_URL
24
+ if intelligence && [api_key, api_url.sub(%r{/$}, ''), runner_url, client_url] != [intelligence.api_key, intelligence.api_url, intelligence.runner_url, intelligence.client_url]
25
+ raise ArgumentError, 'Runtime transport configuration must match intelligence'
26
+ end
27
+ raise ArgumentError, 'api_key is required' if api_key.to_s.strip.empty?
28
+ raise ArgumentError, 'identify_user must be callable' unless identify_user.respond_to?(:call)
29
+ @intelligence = intelligence || Intelligence.new(api_key: api_key, api_url: api_url, runner_url: runner_url, client_url: client_url)
30
+ @platform = @intelligence
31
+ @api_key = api_key
32
+ @identify_user, @agents, @base_path = identify_user, agents, base_path.sub(%r{/$}, '')
33
+ @a2ui = a2ui == true ? {} : a2ui
34
+ @mcp_servers = (mcp_apps || {}).fetch('servers', [])
35
+ @runner_url, @client_url = runner_url, client_url
36
+ @memory_access = memory_access
37
+ @telemetry = telemetry || Telemetry.new(license_token: license_token)
38
+ @on_error = on_error
39
+ raise ArgumentError, 'Lock heartbeat must be positive and shorter than TTL' unless lock_heartbeat_interval.is_a?(Numeric) && lock_ttl.is_a?(Numeric) && lock_heartbeat_interval.positive? && lock_ttl > lock_heartbeat_interval
40
+ @lock_heartbeat_interval, @lock_ttl = lock_heartbeat_interval, lock_ttl
41
+ @cors_origins, @learning_container = cors_origins.freeze, learning_container
42
+ @runs, @mutex, @closed = {}, Mutex.new, false
43
+ @startups = {}
44
+ @telemetry.emit('oss.runtime.instance_created', 'agentsAmount' => @agents.length)
45
+ end
46
+
47
+ # Handles one Rack request. Customer authentication receives the real Rack environment.
48
+ def call(env)
49
+ path = env.fetch('PATH_INFO', '')
50
+ path = path.delete_prefix(@base_path) if path == @base_path || path.start_with?(@base_path + '/')
51
+ method = env.fetch('REQUEST_METHOD', 'GET')
52
+ status, result = dispatch(method, path, env)
53
+ response = [status, { 'content-type' => 'application/json', 'cache-control' => 'no-store' }, status == 204 ? [] : [JSON.generate(result)]]
54
+ rescue Error => error
55
+ report_error(error) if error.status >= 500
56
+ status = error.status
57
+ response = [status, { 'content-type' => 'application/json' }, [JSON.generate('error' => error.message)]]
58
+ rescue JSON::ParserError, ArgumentError
59
+ status = 400
60
+ response = [400, { 'content-type' => 'application/json' }, [JSON.generate('error' => 'Invalid request body')]]
61
+ rescue StandardError => error
62
+ report_error(error)
63
+ status = 502
64
+ response = [502, { 'content-type' => 'application/json' }, [JSON.generate('error' => 'Runtime dependency failed')]]
65
+ ensure
66
+ if response
67
+ response[1].delete('content-type') if response[0] == 204
68
+ if path == '/inspector-metadata'
69
+ response[1]['cache-control'] = 'no-store, private'
70
+ response[1]['allow'] = 'GET' if response[0] == 405
71
+ end
72
+ origin = env['HTTP_ORIGIN']
73
+ if origin && @cors_origins.include?(origin)
74
+ response[1].merge!('access-control-allow-origin' => origin, 'vary' => 'Origin',
75
+ 'access-control-allow-credentials' => 'true',
76
+ 'access-control-allow-methods' => 'GET, POST, PATCH, DELETE, OPTIONS',
77
+ 'access-control-allow-headers' => 'Content-Type, Authorization')
78
+ end
79
+ return response
80
+ end
81
+ end
82
+
83
+ # Waits for active work, cancels remaining agents, and flushes the exporter.
84
+ def close(timeout: 10)
85
+ runs, startups = @mutex.synchronize { @closed = true; [@runs.values.dup, @startups.values.dup] }
86
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
87
+ startups.each(&:kill)
88
+ runs.each(&:request_stop)
89
+ startups.each { |thread| thread.join([deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC), 0].max) }
90
+ runs.each { |run| run.join([deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC), 0].max) }
91
+ runs.each { |run| run.stop(timeout: 0) }
92
+ @telemetry.close(timeout: [deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC), 0].max)
93
+ end
94
+
95
+ private
96
+
97
+ def dispatch(method, path, env)
98
+ return [204, nil] if method == 'OPTIONS'
99
+ if path == '/info'
100
+ raise Error.new(405, 'Method not allowed') unless method == 'GET'
101
+ return [200, info]
102
+ end
103
+ if path == '/inspector-metadata'
104
+ raise Error.new(405, 'Method not allowed') unless method == 'GET'
105
+ return inspector_metadata
106
+ end
107
+ user = normalize_callback_keys(@identify_user.call(env), %w[id name])
108
+ raise Error.new(401, 'Authenticated application user is required') unless user.is_a?(Hash) && user['id'].is_a?(String) && !user['id'].strip.empty?
109
+ query = URI.decode_www_form(env.fetch('QUERY_STRING', '')).to_h
110
+ raw = env['rack.input']&.read(1_048_577).to_s
111
+ raise Error.new(413, 'Request body too large') if raw.bytesize > 1_048_576
112
+ body = raw.empty? ? {} : JSON.parse(raw)
113
+ raise Error.new(400, 'JSON object is required') unless body.is_a?(Hash)
114
+ if (match = %r{\A/agent/([^/]+)/stop/([^/]+)\z}.match(path))
115
+ raise Error.new(405, 'Method not allowed') unless method == 'POST'
116
+ return stop_run(match[1], match[2], body, user)
117
+ end
118
+ if (match = %r{\A/agent/([^/]+)/(run|connect)\z}.match(path))
119
+ raise Error.new(405, 'Method not allowed') unless method == 'POST'
120
+ agent_id, action = match.captures
121
+ @telemetry.emit('oss.runtime.copilot_request_created', 'requestType' => action)
122
+ raise Error.new(404, 'Agent not found') unless @agents.key?(agent_id)
123
+ identifier!(body['threadId'])
124
+ return connect(body['threadId'], user, agent_id) if action == 'connect'
125
+ return run(body, user, agent_id)
126
+ end
127
+ return threads(method, path, query, body, user) if path.start_with?('/threads')
128
+ return memories(method, path, query, body, user, env) if path.start_with?('/memories')
129
+ if path == '/annotate' && method == 'POST'
130
+ %w[type threadId].each { |field| identifier!(body[field]) }
131
+ id = body['clientEventId'] || SecureRandom.uuid
132
+ identifier!(id)
133
+ payload = body.select { |key, _| %w[type threadId payload occurredAt].include?(key) }.merge('userId' => user['id'])
134
+ result = @platform.request('PUT', "/connector/annotate/#{escaped(id)}", payload)
135
+ raise Error.new(502, 'Empty annotation response') unless result.is_a?(Hash)
136
+ return [200, result]
137
+ end
138
+ raise Error.new(404, 'Route not found')
139
+ end
140
+
141
+ def identifier!(value)
142
+ raise Error.new(400, 'Valid identifier is required') unless value.is_a?(String) && !value.strip.empty? && value.length <= 512
143
+ end
144
+
145
+ # Normalize only documented callback keys. Explicit string values win,
146
+ # including nil and false, so aliases cannot bypass value validation.
147
+ def normalize_callback_keys(value, keys)
148
+ return value unless value.is_a?(Hash)
149
+ result = value.dup
150
+ keys.each do |key|
151
+ symbol = key.to_sym
152
+ result[key] = value[symbol] if !value.key?(key) && value.key?(symbol)
153
+ result.delete(symbol)
154
+ end
155
+ result
156
+ end
157
+
158
+ def stop_run(agent_id, requested_thread, body, user)
159
+ identifier!(body['runId']) if body.key?('runId')
160
+ begin
161
+ thread = @platform.request('GET', "/api/threads/#{requested_thread}?userId=#{escaped(user['id'])}").fetch('thread')
162
+ rescue Error => error
163
+ raise Error.new(error.status >= 500 ? 502 : error.status, 'Thread access denied')
164
+ end
165
+ raise Error.new(502, 'Invalid thread response') unless thread.is_a?(Hash) && thread['id'].is_a?(String) && !thread['id'].strip.empty?
166
+ raise Error.new(403, 'Thread access denied') if thread.key?('agentId') && thread['agentId'] != agent_id
167
+ raise Error.new(404, 'Agent not found') unless @agents.key?(agent_id)
168
+ active = @mutex.synchronize { @runs.values.find { |run| run.thread_id == thread['id'] && (!body['runId'] || run.run_id == body['runId']) } }
169
+ stopped = active ? active.request_stop : false
170
+ result = { 'stopped' => stopped }
171
+ result['interrupt'] = { 'type' => 'RUN_ERROR', 'message' => 'Run stopped by user', 'code' => 'STOPPED' } if stopped
172
+ [200, result]
173
+ end
174
+
175
+ def report_error(error)
176
+ @on_error&.call(error)
177
+ rescue StandardError
178
+ nil
179
+ end
180
+
181
+ def escaped(value)
182
+ URI.encode_www_form_component(value).gsub('+', '%20')
183
+ end
184
+
185
+ # Return project display data without forwarding browser credentials or provider failures.
186
+ def inspector_metadata
187
+ metadata = InspectorMetadata.parse(@intelligence.get_inspector_metadata)
188
+ metadata ? [200, metadata] : [204, nil]
189
+ rescue StandardError => error
190
+ report_error(error)
191
+ [204, nil]
192
+ end
193
+
194
+ def info
195
+ entitlement = begin
196
+ @intelligence.get_runtime_entitlements
197
+ rescue StandardError => error
198
+ retryable = !error.is_a?(RuntimeEntitlementError) || error.retryable
199
+ { 'status' => retryable ? 'unavailable' : 'misconfigured', 'error' => {
200
+ 'code' => retryable ? 'runtime_entitlements_unavailable' : 'runtime_entitlements_misconfigured',
201
+ 'message' => retryable ? 'Runtime entitlement lookup failed' : 'Runtime entitlement lookup is misconfigured',
202
+ 'retryable' => retryable } }
203
+ end
204
+ license_status = if entitlement['status'] == 'ready'
205
+ entitlement['entitlement']['active'] ? 'valid' : 'none'
206
+ else
207
+ entitlement['error']['retryable'] ? 'unknown' : 'none'
208
+ end
209
+ result = { 'version' => '0.1.0.rc.1', 'mode' => 'intelligence', 'agents' => @agents.to_h { |id, agent| [id, { 'name' => id, 'description' => agent.description, 'className' => agent.class.name }] },
210
+ 'intelligence' => { 'wsUrl' => @client_url }, 'runtimeEntitlements' => entitlement,
211
+ 'licenseStatus' => license_status,
212
+ 'threadEndpoints' => { 'list' => true, 'inspect' => true, 'mutations' => true, 'realtimeMetadata' => true },
213
+ 'audioFileTranscriptionEnabled' => false, 'a2uiEnabled' => !!@a2ui && @a2ui['enabled'] != false, 'openGenerativeUIEnabled' => false,
214
+ 'suggestions' => false, 'telemetryDisabled' => @telemetry.disabled? }
215
+ result['a2ui'] = { 'enabled' => true }.merge(@a2ui.slice('agents')) if result['a2uiEnabled']
216
+ result['inspectorMetadata'] = true
217
+ result
218
+ end
219
+
220
+ def credentials(result)
221
+ result.slice('threadId', 'runId', 'joinToken').merge('realtime' => { 'clientUrl' => @client_url, 'topic' => "thread:#{result['threadId']}" })
222
+ end
223
+
224
+ def connect(thread_id, user, agent_id)
225
+ result = @platform.request('POST', "/api/threads/#{escaped(thread_id)}/connect", 'userId' => user['id'], 'agentId' => agent_id)
226
+ [result ? 200 : 204, result && credentials(result).reject { |key, _| key == 'runId' }]
227
+ end
228
+
229
+ def threads(method, path, query, body, user)
230
+ if path == '/threads' && method == 'GET'
231
+ identifier!(query['agentId'])
232
+ params = query.slice('agentId', 'includeArchived', 'limit', 'cursor').merge('userId' => user['id'])
233
+ return [200, @platform.request('GET', '/api/threads?' + URI.encode_www_form(params))]
234
+ end
235
+ if path == '/threads/subscribe' && method == 'POST'
236
+ return [200, @platform.request('POST', '/api/threads/subscribe', 'userId' => user['id'])]
237
+ end
238
+ match = %r{\A/threads/([^/]+)(?:/(messages|events|state|archive))?\z}.match(path)
239
+ raise Error.new(404, 'Route not found') unless match
240
+ id, action = match.captures
241
+ base = "/api/threads/#{id}"
242
+ scope = '?userId=' + escaped(user['id'])
243
+ if method == 'GET' && %w[messages events state].include?(action)
244
+ @platform.request('GET', base + scope) unless action == 'messages'
245
+ target = action == 'messages' ? base + '/messages' + scope : "/api/_inspect/threads/#{id}/#{action}"
246
+ return [200, @platform.request('GET', target)]
247
+ end
248
+ identifier!(body['agentId'])
249
+ updates = body.slice('agentId', 'name', 'archived', 'reason').merge('userId' => user['id'])
250
+ if action == 'archive' && method == 'POST'
251
+ @platform.request('PATCH', base, updates.merge('archived' => true))
252
+ return [200, { 'threadId' => URI.decode_www_form_component(id), 'archived' => true }]
253
+ end
254
+ if action.nil? && method == 'PATCH'
255
+ return [200, @platform.request('PATCH', base, updates).fetch('thread')]
256
+ end
257
+ if action.nil? && method == 'DELETE'
258
+ @platform.request('DELETE', base, updates.slice('userId', 'agentId').merge('reason' => 'Deleted via CopilotKit runtime'))
259
+ return [200, { 'threadId' => URI.decode_www_form_component(id), 'deleted' => true }]
260
+ end
261
+ raise Error.new(405, 'Method not allowed')
262
+ end
263
+
264
+ def memories(method, path, query, body, user, env)
265
+ raise Error.new(405, 'Method not allowed') unless %w[GET POST PATCH DELETE].include?(method)
266
+ raise Error.new(404, 'Route not found') unless path.match?(%r{\A/memories(?:/[^/]+)?\z})
267
+ headers = { 'x-cpki-user-id' => user['id'] }
268
+ unless @memory_access.nil?
269
+ begin
270
+ grant = normalize_callback_keys(@memory_access.call(user, env), %w[user project])
271
+ rescue StandardError
272
+ raise Error.new(500, 'Memory policy failed')
273
+ end
274
+ raise Error.new(403, 'Memory access is not granted') if grant.nil?
275
+ raise Error.new(500, 'Invalid memory grant') unless grant.is_a?(Hash) && grant.length == 2 && grant.key?('user') && grant.key?('project') && grant.values.all? { |value| %w[none read read-write].include?(value) }
276
+ raise Error.new(403, 'Memory access is not granted') unless grant.values.any? { |value| %w[read read-write].include?(value) }
277
+ raise Error.new(403, 'Memory write access is not granted') if %w[POST PATCH DELETE].include?(method) && !%w[/memories/subscribe /memories/recall].include?(path) && !grant.values.include?('read-write')
278
+ if path == '/memories' && method == 'POST'
279
+ raise Error.new(403, 'Memory scope is not writable') unless grant[body.fetch('scope', 'user')] == 'read-write'
280
+ end
281
+ headers['x-cpki-memory-grant'] = JSON.generate(grant)
282
+ end
283
+ payload = body.slice('content', 'kind', 'scope', 'sourceThreadIds', 'query', 'limit')
284
+ if path == '/memories/recall'
285
+ raise Error.new(400, 'Recall query is required') unless payload['query'].is_a?(String) && !payload['query'].strip.empty?
286
+ payload['query'] = payload['query'].strip
287
+ raise Error.new(400, 'Positive integer limit is required') if payload.key?('limit') && (!payload['limit'].is_a?(Integer) || payload['limit'] <= 0)
288
+ elsif %w[POST PATCH].include?(method) && path != '/memories/subscribe'
289
+ raise Error.new(400, 'Memory content and kind are required') unless payload['content'].is_a?(String) && %w[topical episodic operational].include?(payload['kind'])
290
+ end
291
+ raise Error.new(400, 'Invalid memory scope') if payload.key?('scope') && !%w[user project].include?(payload['scope'])
292
+ if payload.key?('sourceThreadIds') && (!payload['sourceThreadIds'].is_a?(Array) || !payload['sourceThreadIds'].all? { |id| id.is_a?(String) })
293
+ raise Error.new(400, 'Invalid sourceThreadIds')
294
+ end
295
+ target = '/api' + path
296
+ target += '?' + URI.encode_www_form(query.slice('scope', 'kind', 'limit', 'cursor')) if method == 'GET' && !query.empty?
297
+ begin
298
+ result = @platform.request(method, target, method == 'GET' || method == 'DELETE' || path == '/memories/subscribe' ? nil : payload, headers)
299
+ rescue Error => error
300
+ raise Error.new(error.status >= 500 ? 502 : error.status, error.message)
301
+ end
302
+ [method == 'DELETE' ? 204 : (method == 'POST' && path == '/memories' ? 201 : 200), result]
303
+ end
304
+
305
+ def run(input, user, agent_id)
306
+ token = Object.new
307
+ worker = @mutex.synchronize do
308
+ raise Error.new(503, 'Runtime is shutting down') if @closed
309
+ @startups[token] = Thread.new do
310
+ begin
311
+ perform_run(input, user, agent_id)
312
+ ensure
313
+ @mutex.synchronize { @startups.delete(token) }
314
+ end
315
+ end
316
+ end
317
+ worker.report_on_exception = false
318
+ worker.value || raise(Error.new(503, 'Runtime is shutting down'))
319
+ end
320
+
321
+ def perform_run(input, user, agent_id)
322
+ identifier!(input['runId'])
323
+ raise Error.new(400, 'messages must be an array') unless input['messages'].is_a?(Array)
324
+ raise Error.new(503, 'Runtime is shutting down') if @closed
325
+ thread_id = input['threadId']
326
+ creation = { 'threadId' => thread_id, 'userId' => user['id'], 'agentId' => agent_id }
327
+ container = @learning_container&.call(user, input)
328
+ creation['learningContainerId'] = container if container
329
+ begin
330
+ @platform.request('GET', "/api/threads/#{escaped(thread_id)}?userId=#{escaped(user['id'])}")
331
+ rescue Error => error
332
+ raise unless error.status == 404
333
+ begin
334
+ @platform.request('POST', '/api/threads', creation)
335
+ rescue Error => race
336
+ raise unless race.status == 409
337
+ @platform.request('GET', "/api/threads/#{escaped(thread_id)}?userId=#{escaped(user['id'])}")
338
+ end
339
+ end
340
+ runner, lock, started, lock_rejected = nil, nil, false, false
341
+ begin
342
+ begin
343
+ lock = @platform.request('POST', "/api/threads/#{escaped(thread_id)}/lock", creation.reject { |key, _| key == 'threadId' }.merge('runId' => input['runId'], 'ttlSeconds' => @lock_ttl))
344
+ rescue Error => error
345
+ lock_rejected = error.status.between?(400, 499)
346
+ raise
347
+ end
348
+ %w[threadId runId joinToken].each { |field| raise Error.new(502, 'Invalid platform lock response') unless lock[field].is_a?(String) && !lock[field].empty? }
349
+ canonical = input.merge('threadId' => lock['threadId'], 'runId' => lock['runId'])
350
+ a2ui = @a2ui if @a2ui && @a2ui['enabled'] != false && (!@a2ui['agents'] || @a2ui['agents'].include?(agent_id))
351
+ servers = @mcp_servers.select { |server| !server['agentId'] || server['agentId'] == agent_id }
352
+ agent = UIAgent.new(agent: @agents.fetch(agent_id), a2ui: a2ui, mcp_servers: servers)
353
+ runner = Runner.new(platform: @platform, url: @runner_url, auth_token: @api_key, lock: lock, agent: agent, input: canonical, messages: [], telemetry: @telemetry, on_error: @on_error, heartbeat_interval: @lock_heartbeat_interval, lock_ttl: @lock_ttl)
354
+ runner.start_lease
355
+ history = @platform.request('GET', "/api/threads/#{escaped(lock['threadId'])}/messages?userId=#{escaped(user['id'])}").fetch('messages')
356
+ prior_ids = history.map { |message| message['id'] }
357
+ fresh = input['messages'].reject { |message| prior_ids.include?(message['id']) }
358
+ # Stored messages are projection DTOs, not AG-UI model input. Use their
359
+ # IDs only to avoid persisting messages twice; preserve the client input.
360
+ runner.prepare_input(canonical, fresh)
361
+ runner.join_gateway
362
+ @mutex.synchronize do
363
+ raise Error.new(503, 'Runtime is shutting down') if @closed
364
+ @runs[lock['runId']] = runner
365
+ runner.start { @mutex.synchronize { @runs.delete(lock['runId']) } }
366
+ started = true
367
+ end
368
+ [200, credentials(lock)]
369
+ ensure
370
+ unless started || lock_rejected
371
+ runner&.stop
372
+ begin
373
+ Timeout.timeout(3) { @platform.request('DELETE', "/api/threads/#{escaped(lock&.dig('threadId') || thread_id)}/lock", 'runId' => lock&.dig('runId') || input['runId']) }
374
+ rescue StandardError => error
375
+ report_error(error)
376
+ end
377
+ end
378
+ end
379
+ end
380
+ end
381
+
382
+ end
383
+
384
+ require_relative 'agent'
385
+ require_relative 'ui_agent'
386
+ require_relative 'runner'
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+ require 'json'
3
+
4
+ module CopilotKit
5
+ # Strict normalization of current and legacy Runtime entitlement responses.
6
+ module RuntimeEntitlements
7
+ SOURCES = %w[managedOrgSubscription selfHostedDeploymentLicense awsMarketplaceDeploymentLicense].freeze
8
+ module_function
9
+
10
+ # Return a separate JSON-compatible value for each caller and cache entry.
11
+ def copy(value)
12
+ JSON.parse(JSON.generate(value))
13
+ end
14
+
15
+ def keys?(value, required, optional = [])
16
+ value.is_a?(Hash) && (required - value.keys).empty? && (value.keys - required - optional).empty?
17
+ end
18
+
19
+ def boolean?(value)
20
+ value == true || value == false
21
+ end
22
+
23
+ def grant?(value)
24
+ return false unless keys?(value, %w[active source features limits], %w[planCode entitlementSource])
25
+ return false unless boolean?(value['active']) && SOURCES.include?(value['source'])
26
+ return false unless value['features'].is_a?(Hash) && value['features'].all? { |key, flag| key.is_a?(String) && boolean?(flag) }
27
+ return false unless value['limits'].is_a?(Hash) && value['limits'].all? do |key, number|
28
+ key.is_a?(String) && (number.is_a?(Integer) || number.is_a?(Float)) && number.to_f.finite?
29
+ end
30
+ %w[planCode entitlementSource].all? { |key| !value.key?(key) || value[key].is_a?(String) }
31
+ end
32
+
33
+ # @return [Hash, nil] A published response union, or nil for invalid authority.
34
+ def parse(value)
35
+ return nil unless value.is_a?(Hash)
36
+ if value['status'] == 'ready' && keys?(value, %w[status entitlement]) && grant?(value['entitlement'])
37
+ return copy(value)
38
+ end
39
+ if %w[degraded misconfigured unavailable].include?(value['status']) && keys?(value, %w[status error])
40
+ error = value['error']
41
+ return nil unless keys?(error, %w[code message retryable], %w[requestId traceId])
42
+ return nil unless error['code'].is_a?(String) && error['message'].is_a?(String) && boolean?(error['retryable'])
43
+ return nil unless %w[requestId traceId].all? { |key| !error.key?(key) || error[key].is_a?(String) }
44
+ return copy(value)
45
+ end
46
+ if value['organizationId'].is_a?(String)
47
+ grant = value.reject { |key, _| key == 'organizationId' }
48
+ return copy('status' => 'ready', 'entitlement' => grant) if grant?(grant)
49
+ end
50
+ nil
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,186 @@
1
+ # frozen_string_literal: true
2
+ require 'timeout'
3
+ require 'base64'
4
+
5
+ module CopilotKit
6
+ # Bounded asynchronous analytics exporter with TypeScript-compatible envelopes.
7
+ # Every attribute is constructed here; application content is never copied.
8
+ class Telemetry
9
+ PREFIX = 'oss.runtime.'
10
+ ENDPOINT = 'https://telemetry.copilotkit.ai/ingest'
11
+ EVENTS = %w[instance_created copilot_request_created agent_execution_stream_started agent_execution_stream_ended agent_execution_stream_errored].freeze
12
+
13
+ # Unsampled by default: the sink is ours, so a real count beats one
14
+ # extrapolated from a fraction of the population. +sample_rate+ and
15
+ # COPILOTKIT_TELEMETRY_SAMPLE_RATE still dial it down.
16
+ def initialize(exporter: nil, disabled: false, sample_rate: 1.0, telemetry_id: nil, license_token: nil,
17
+ url: nil, queue_capacity: 256, random: -> { Random.rand }, env: ENV)
18
+ @disabled = disabled || %w[DO_NOT_TRACK COPILOTKIT_TELEMETRY_DISABLED].any? { |key| %w[true 1].include?(env[key].to_s.downcase) }
19
+ configured_rate = env.key?('COPILOTKIT_TELEMETRY_SAMPLE_RATE') ? env['COPILOTKIT_TELEMETRY_SAMPLE_RATE'] : sample_rate
20
+ begin
21
+ @rate = Float(configured_rate)
22
+ rescue ArgumentError, TypeError
23
+ @rate = 1.0
24
+ end
25
+ @rate = 1.0 unless @rate.finite? && @rate.between?(0, 1)
26
+ @id = [telemetry_id, env['CPK_TELEMETRY_ID']].filter_map do |value|
27
+ next unless value.is_a?(String)
28
+ normalized = value.gsub(/\A[ \t]+|[ \t]+\z/, '')
29
+ normalized if normalized.match?(/\A[A-Za-z0-9_-]{1,128}\z/)
30
+ end.first unless @disabled
31
+ @identified = false
32
+ unless @disabled || @id
33
+ token = [license_token, env['COPILOTKIT_LICENSE_TOKEN']].find do |value|
34
+ value.is_a?(String) && value.match?(/[^\u0009-\u000D\u0020\u00A0\u1680\u2000-\u200A\u2028\u2029\u202F\u205F\u3000\uFEFF]/)
35
+ end
36
+ @id = license_telemetry_id(token)
37
+ @identified = !@id.nil?
38
+ @rate = 1.0 if @identified
39
+ end
40
+ @url = url || env['COPILOTKIT_TELEMETRY_URL'] || ENDPOINT
41
+ @exporter, @random = exporter, random
42
+ raise ArgumentError, 'queue_capacity must be positive' unless queue_capacity.is_a?(Integer) && queue_capacity.positive?
43
+ @queue, @mutex, @condition = SizedQueue.new(queue_capacity), Mutex.new, ConditionVariable.new
44
+ @pending, @closed, @worker = 0, false, nil
45
+ end
46
+
47
+ def disabled?
48
+ @disabled
49
+ end
50
+
51
+ # Queue one sampled event without waiting for the network. A full queue drops it.
52
+ def emit(name, attributes = {})
53
+ return if @disabled || (!@identified && (@rate.zero? || @random.call >= @rate))
54
+ event_name = name.delete_prefix(PREFIX)
55
+ return unless name.start_with?(PREFIX) && EVENTS.include?(event_name)
56
+ properties = case event_name
57
+ when 'instance_created'
58
+ count = attributes['agentsAmount']
59
+ { 'actionsAmount' => 0, 'endpointTypes' => [], 'endpointsAmount' => 0,
60
+ 'agentsAmount' => count.is_a?(Integer) && count >= 0 ? count : 0, 'cloud.api_key_provided' => false }
61
+ when 'copilot_request_created'
62
+ return unless %w[run connect].include?(attributes['requestType'])
63
+ { 'requestType' => attributes['requestType'], 'cloud.guardrails.enabled' => false, 'cloud.api_key_provided' => false }
64
+ when 'agent_execution_stream_errored'
65
+ { 'error' => 'AGENT_RUN_FAILED' }
66
+ else
67
+ {}
68
+ end
69
+ event = { 'event' => name, 'properties' => properties, 'ts' => Time.now.to_i,
70
+ 'package' => { 'name' => 'copilotkit-runtime-ruby', 'version' => '0.1.0.rc.1' },
71
+ 'global_properties' => { 'sampleRate' => @rate, 'sampleRateAdjustmentFactor' => 1 - @rate,
72
+ 'sampleWeight' => 1 / @rate, 'telemetry_identified' => @identified,
73
+ 'telemetry_emitter' => 'runtime-ruby', 'telemetry_surface' => 'v2',
74
+ 'telemetry_transport' => 'lambda' } }
75
+ @mutex.synchronize do
76
+ return if @closed
77
+ begin
78
+ @queue.push(event, true)
79
+ rescue ThreadError
80
+ return
81
+ end
82
+ @pending += 1
83
+ @worker ||= Thread.new { consume }
84
+ end
85
+ nil
86
+ rescue StandardError
87
+ nil
88
+ end
89
+
90
+ # Wait for already queued events, up to the caller's deadline.
91
+ def flush(timeout: 3)
92
+ deadline = monotonic + timeout
93
+ @mutex.synchronize do
94
+ while @pending.positive?
95
+ remaining = deadline - monotonic
96
+ return false unless remaining.positive?
97
+ @condition.wait(@mutex, remaining)
98
+ end
99
+ end
100
+ true
101
+ end
102
+
103
+ # Stop accepting work, drain within a deadline, then cancel a stalled exporter.
104
+ def close(timeout: 3)
105
+ worker = @mutex.synchronize do
106
+ return if @closed
107
+ @closed = true
108
+ begin
109
+ @queue.push(nil, true) if @worker
110
+ rescue ThreadError
111
+ # A full queue drains naturally; the worker stops when closed and empty.
112
+ end
113
+ @worker
114
+ end
115
+ worker&.join(timeout)
116
+ if worker&.alive?
117
+ worker.kill
118
+ worker.join(0.1)
119
+ end
120
+ nil
121
+ rescue StandardError
122
+ nil
123
+ end
124
+
125
+ private
126
+
127
+ # Claims provide analytics attribution only, never license verification.
128
+ def license_telemetry_id(token)
129
+ return unless token.is_a?(String)
130
+ parts = token.split('.', -1)
131
+ return unless parts.length == 3
132
+ payload = parts[1]
133
+ return unless payload.match?(/\A[A-Za-z0-9_-]+\z/) && payload.length % 4 != 1
134
+ decoded = JSON.parse(Base64.urlsafe_decode64(payload))
135
+ return unless decoded.is_a?(Hash) && decoded['telemetry_id'].is_a?(String)
136
+ id = decoded['telemetry_id'].gsub(/\A[ \t]+|[ \t]+\z/, '')
137
+ id if id.match?(/\A[A-Za-z0-9_-]{1,128}\z/)
138
+ rescue ArgumentError, JSON::ParserError
139
+ nil
140
+ end
141
+
142
+ def monotonic
143
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
144
+ end
145
+
146
+ def consume
147
+ loop do
148
+ event = @queue.pop
149
+ break unless event
150
+ begin
151
+ Timeout.timeout(3) { @exporter ? @exporter.call(event) : send_http(event) }
152
+ rescue StandardError
153
+ # Analytics failure never changes runtime behavior or emits raw diagnostics.
154
+ ensure
155
+ @mutex.synchronize { @pending -= 1; @condition.broadcast }
156
+ end
157
+ break if @mutex.synchronize { @closed && @queue.empty? }
158
+ end
159
+ ensure
160
+ begin
161
+ Timeout.timeout(3) { @exporter.close } if @exporter.respond_to?(:close)
162
+ rescue StandardError
163
+ nil
164
+ end
165
+ end
166
+
167
+ def send_http(event)
168
+ uri = URI(@url)
169
+ return unless uri.is_a?(URI::HTTP) && !uri.userinfo
170
+ headers = { 'content-type' => 'application/json' }
171
+ headers['X-CopilotKit-Telemetry-Id'] = @id if @id
172
+ request = Net::HTTP::Post.new(uri.request_uri, headers)
173
+ request.body = JSON.generate(event)
174
+ Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https', open_timeout: 3, read_timeout: 3) do |http|
175
+ http.max_retries = 0
176
+ http.request(request) do |response| # Net::HTTP never follows redirects.
177
+ received = 0
178
+ response.read_body do |chunk|
179
+ received += chunk.bytesize
180
+ raise IOError, 'Telemetry response exceeded size limit' if received > 65_536
181
+ end
182
+ end
183
+ end
184
+ end
185
+ end
186
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+ require_relative 'a2ui'
3
+ require_relative 'mcp_apps'
4
+ module CopilotKit
5
+ class UIAgent < Agent
6
+ def initialize(agent:, a2ui: nil, mcp_servers: [])
7
+ @agent, @a2ui, @mcp_servers = agent, a2ui, mcp_servers
8
+ super(description: agent.description)
9
+ end
10
+ def each_event(input, &block)
11
+ input = input.merge('tools' => input['tools'] || [], 'context' => input['context'] || [],
12
+ 'messages' => input.fetch('messages').map { |message| message.key?('toolCalls') && message['toolCalls'].nil? ? message.merge('toolCalls' => []) : message })
13
+ mcp = MCPApps.new(@mcp_servers)
14
+ if (request = input.dig('forwardedProps', '__proxiedMCPRequest'))
15
+ block.call('type' => 'RUN_FINISHED', 'result' => mcp.proxy(request))
16
+ return
17
+ end
18
+ middleware = @a2ui && A2UI.new(@a2ui)
19
+ prepared = middleware ? middleware.prepare(input) : input
20
+ prepared = mcp.prepare(prepared)
21
+ terminal = nil
22
+ @agent.each_event(prepared) do |event|
23
+ if event['type'] == 'RUN_FINISHED'
24
+ terminal = event
25
+ else
26
+ mcp.accept(event)
27
+ generated = middleware ? middleware.accept(event) : []
28
+ generated.each(&block) unless event['type'] == 'TOOL_CALL_RESULT'
29
+ block.call(event)
30
+ generated.each(&block) if event['type'] == 'TOOL_CALL_RESULT'
31
+ end
32
+ end
33
+ middleware&.finish&.each(&block) if terminal
34
+ mcp.finish(&block) if terminal
35
+ block.call(terminal) if terminal
36
+ end
37
+ end
38
+ end