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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +445 -0
- data/lib/copilotkit/a2ui.rb +334 -0
- data/lib/copilotkit/agent.rb +46 -0
- data/lib/copilotkit/inspector_metadata.rb +82 -0
- data/lib/copilotkit/intelligence.rb +371 -0
- data/lib/copilotkit/mcp_apps.rb +199 -0
- data/lib/copilotkit/runner.rb +353 -0
- data/lib/copilotkit/runtime.rb +386 -0
- data/lib/copilotkit/runtime_entitlements.rb +53 -0
- data/lib/copilotkit/telemetry.rb +186 -0
- data/lib/copilotkit/ui_agent.rb +38 -0
- data/lib/copilotkit/websocket.rb +84 -0
- metadata +83 -0
|
@@ -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
|