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,334 @@
1
+ # frozen_string_literal: true
2
+ require 'set'
3
+ module CopilotKit
4
+ # A2UI 0.9 transform compatible with middleware 0.0.10. State belongs to one run.
5
+ class A2UI
6
+ SCHEMA_CONTEXT = 'A2UI Component Schema — available components for generating UI surfaces. Use these component names and properties when creating A2UI operations.'
7
+ BASIC_CATALOG = 'https://a2ui.org/specification/v0_9/basic_catalog.json'
8
+
9
+ def initialize(config)
10
+ @config = config
11
+ @names = Set.new(config.fetch('a2uiToolNames', ['render_a2ui']))
12
+ @tool_name = config['injectA2UITool'].is_a?(String) ? config['injectA2UITool'] : 'render_a2ui'
13
+ @names << @tool_name if config['injectA2UITool']
14
+ @calls, @painted, @outer, @attempts = {}, Set.new, nil, Hash.new(0)
15
+ @retrying = Set.new
16
+ end
17
+
18
+ # Adds server-owned schema and tool context without mutating browser input.
19
+ def prepare(input)
20
+ result = Marshal.load(Marshal.dump(input))
21
+ entry = result.fetch('context', []).find { |context| context['description'] == SCHEMA_CONTEXT }
22
+ if entry && entry['value'].is_a?(String)
23
+ frontend_schema = JSON.parse(entry['value'])
24
+ @frontend_catalog = frontend_schema['catalogId'] if frontend_schema.is_a?(Hash)
25
+ end
26
+ action = result.dig('forwardedProps', 'a2uiAction', 'userAction')
27
+ if action.is_a?(Hash)
28
+ id = SecureRandom.uuid
29
+ result['messages'] ||= []
30
+ result['messages'] << { 'id' => SecureRandom.uuid, 'role' => 'assistant', 'content' => '', 'toolCalls' => [
31
+ { 'id' => id, 'type' => 'function', 'function' => { 'name' => 'log_a2ui_event', 'arguments' => JSON.generate(action) } }
32
+ ] }
33
+ text = "User performed action \"#{action.fetch('name', 'unknown_action')}\" on surface \"#{action.fetch('surfaceId', 'unknown_surface')}\""
34
+ text += " (component: #{action['sourceComponentId']})" if action['sourceComponentId']
35
+ text += '. Context: ' + JSON.generate(action.fetch('context', {}))
36
+ result['messages'] << { 'id' => SecureRandom.uuid, 'role' => 'tool', 'toolCallId' => id, 'content' => text }
37
+ end
38
+ result['context'] ||= []
39
+ if @config['schema'] && !@config['schema'].empty?
40
+ result['context'].reject! { |context| context['description'] == SCHEMA_CONTEXT }
41
+ result['context'] << { 'description' => SCHEMA_CONTEXT, 'value' => JSON.generate(@config['schema']) }
42
+ end
43
+ if @config['injectA2UITool']
44
+ result['tools'] = result.fetch('tools', []).reject { |tool| tool['name'] == @tool_name } + [tool]
45
+ result['forwardedProps'] = result.fetch('forwardedProps', {}).merge('injectA2UITool' => @config['injectA2UITool'])
46
+ description = "A2UI render tool usage guide — how to call #{@tool_name} with valid arguments."
47
+ result['context'].reject! { |context| context['description'] == description }
48
+ result['context'] << { 'description' => description, 'value' => "Call #{@tool_name} with surfaceId, components, and optional data. Use flat v0.9 components with unique id and component fields. Include id root. Reference child IDs, never nest components or create cycles. Only use catalog types and required properties. Bind with {\"path\":\"/key\"}. Repeat via children:{componentId,path}. The host owns catalogId; do not choose it." }
49
+ end
50
+ result
51
+ rescue JSON::ParserError
52
+ @frontend_catalog = nil
53
+ # Invalid frontend schema is ignored, as in the reference middleware.
54
+ sanitized = input.merge('context' => input.fetch('context', []).reject { |context| context['description'] == SCHEMA_CONTEXT })
55
+ prepare(sanitized)
56
+ end
57
+
58
+ # Returns only generated events; the caller preserves original AG-UI events.
59
+ def accept(event)
60
+ events = []
61
+ id = event['toolCallId']
62
+ case event['type']
63
+ when 'TOOL_CALL_START'
64
+ if @names.include?(event['toolCallName'])
65
+ key = @outer || id
66
+ @attempts[key] += 1
67
+ @calls[id] = { args: '', key: key, painted: false, resolved: false, count: 0, tokens: 0 }
68
+ events << activity(key, 'status' => 'building') unless @retrying.include?(key)
69
+ elsif !%w[log_a2ui_event].include?(event['toolCallName'])
70
+ @outer = id
71
+ end
72
+ when 'TOOL_CALL_ARGS'
73
+ call = @calls[id]
74
+ return events unless call
75
+ call[:args] += event.fetch('delta', '')
76
+ raise Error.new(502, 'A2UI arguments exceeded size limit') if call[:args].bytesize > 1_048_576
77
+ tokens = (call[:args].length / 4.0).round
78
+ if @config.dig('recovery', 'showProgressTokens') != false && !call[:painted] && !call[:rejected] && !@retrying.include?(call[:key]) && tokens - call[:tokens] >= 20
79
+ call[:tokens] = tokens
80
+ events << activity(call[:key], 'status' => 'building', 'progressTokens' => tokens)
81
+ end
82
+ events.concat(progress(call))
83
+ when 'TOOL_CALL_RESULT'
84
+ @calls[id][:resolved] = true if @calls[id]
85
+ parsed = parse_result(event['content'])
86
+ if parsed.is_a?(Hash) && parsed['a2ui_operations'].is_a?(Array)
87
+ ops = parsed['a2ui_operations'].select { |operation| operation.is_a?(Hash) && !@painted.include?(surface_id(operation)) }
88
+ groups = ops.group_by { |operation| surface_id(operation) || 'default' }
89
+ groups.each do |surface, group|
90
+ key = @outer || id
91
+ key = "#{surface}-#{key}" if groups.length > 1
92
+ events << activity(key, 'a2ui_operations' => group)
93
+ end
94
+ elsif parsed.is_a?(Hash) && parsed['code'] == 'a2ui_recovery_exhausted'
95
+ events << activity(@outer || id, 'status' => 'failed', 'error' => parsed.fetch('error', 'A2UI generation failed'),
96
+ 'attempts' => parsed.fetch('attempts', []), 'maxAttempts' => parsed.fetch('attempts', []).length)
97
+ end
98
+ @outer = nil if @outer == id
99
+ end
100
+ events
101
+ end
102
+
103
+ # Completes only render calls that do not already have an agent result.
104
+ def finish
105
+ @calls.filter_map do |id, call|
106
+ next if call[:resolved]
107
+ { 'type' => 'TOOL_CALL_RESULT', 'messageId' => SecureRandom.uuid, 'toolCallId' => id, 'content' => JSON.generate('status' => 'rendered') }
108
+ end
109
+ end
110
+
111
+ private
112
+
113
+ def tool
114
+ { 'name' => @tool_name, 'description' => 'Render a dynamic A2UI v0.9 surface with structured parameters. Follow the A2UI render tool usage guide provided in context.',
115
+ 'parameters' => { 'type' => 'object', 'properties' => { 'surfaceId' => { 'type' => 'string' },
116
+ 'components' => { 'type' => 'array', 'items' => { 'type' => 'object' } }, 'data' => { 'type' => 'object' } }, 'required' => %w[surfaceId components] } }
117
+ end
118
+
119
+ def activity(key, content)
120
+ exposure = @config.dig('recovery', 'debugExposure')
121
+ content = content.merge('debugExposure' => exposure) if exposure && content['status']
122
+ { 'type' => 'ACTIVITY_SNAPSHOT', 'messageId' => "a2ui-surface-#{key}", 'activityType' => 'a2ui-surface', 'content' => content, 'replace' => true }
123
+ end
124
+
125
+ def progress(call)
126
+ return [] if call[:rejected]
127
+ surface = field(call[:args], 'surfaceId')
128
+ return [] unless surface.is_a?(String) && !surface.empty?
129
+ components = field(call[:args], 'components')
130
+ events = []
131
+ if components.is_a?(Array) && !call[:painted]
132
+ errors = validate(components)
133
+ unless errors.empty?
134
+ call[:rejected] = true
135
+ @retrying << call[:key]
136
+ maximum = @config.dig('recovery', 'maxAttempts') || 3
137
+ return [activity(call[:key], 'status' => 'retrying', 'attempt' => [@attempts[call[:key]] + 1, maximum].min, 'maxAttempts' => maximum, 'errors' => errors)]
138
+ end
139
+ call[:components] = components
140
+ call[:surface] = surface
141
+ streamed_catalog = field(call[:args], 'catalogId')
142
+ streamed_catalog = nil if streamed_catalog == 'basic'
143
+ call[:catalog] = [@config['defaultCatalogId'], @frontend_catalog, streamed_catalog].find { |id| id.is_a?(String) && !id.empty? } || BASIC_CATALOG
144
+ call[:painted] = true
145
+ @retrying.delete(call[:key])
146
+ @painted << surface
147
+ repeated = components.find { |component| component['children'].is_a?(Hash) && component['children']['path'].is_a?(String) }
148
+ call[:data_key] = repeated ? repeated['children']['path'].sub(%r{\A/}, '') : 'items'
149
+ events << snapshot(call)
150
+ end
151
+ return events unless call[:painted] && !call[:data_complete]
152
+ data = field(call[:args], 'data')
153
+ if data.is_a?(Hash)
154
+ call[:data_complete] = true
155
+ events << snapshot(call, data)
156
+ else
157
+ data_start = field_start(call[:args], 'data')
158
+ items = data_start && partial_array(call[:args][data_start..-1], call[:data_key])
159
+ if items && items.length > call[:count]
160
+ call[:count] = items.length
161
+ events << snapshot(call, call[:data_key] => items)
162
+ end
163
+ end
164
+ events
165
+ end
166
+
167
+ def snapshot(call, data = nil)
168
+ surface = call[:surface]
169
+ operations = [
170
+ { 'version' => 'v0.9', 'createSurface' => { 'surfaceId' => surface, 'catalogId' => call[:catalog] } },
171
+ { 'version' => 'v0.9', 'updateComponents' => { 'surfaceId' => surface, 'components' => call[:components] } }
172
+ ]
173
+ operations << { 'version' => 'v0.9', 'updateDataModel' => { 'surfaceId' => surface, 'path' => '/', 'value' => data } } if data
174
+ activity(call[:key], 'a2ui_operations' => operations)
175
+ end
176
+
177
+ def parse_result(content)
178
+ parsed = JSON.parse(content)
179
+ parsed = JSON.parse(parsed) if parsed.is_a?(String)
180
+ parsed
181
+ rescue JSON::ParserError, TypeError
182
+ nil
183
+ end
184
+
185
+ def surface_id(operation)
186
+ %w[createSurface updateComponents updateDataModel deleteSurface].each do |key|
187
+ return operation[key]['surfaceId'] if operation[key].is_a?(Hash) && operation[key]['surfaceId']
188
+ end
189
+ nil
190
+ end
191
+
192
+ # Finds field boundaries lexically so quoted JSON inside strings is not mistaken for structure.
193
+ def field_start(text, name)
194
+ index = 0
195
+ while index < text.length
196
+ if text[index] == '"'
197
+ ending = string_end(text, index)
198
+ return nil unless ending
199
+ key = JSON.parse(text[index..ending])
200
+ next_index = ending + 1
201
+ next_index += 1 while text[next_index]&.match?(/\s/)
202
+ if key == name && text[next_index] == ':'
203
+ next_index += 1
204
+ next_index += 1 while text[next_index]&.match?(/\s/)
205
+ return next_index
206
+ end
207
+ index = ending
208
+ end
209
+ index += 1
210
+ end
211
+ nil
212
+ end
213
+
214
+ def string_end(text, start)
215
+ escaped = false
216
+ ((start + 1)...text.length).each do |index|
217
+ char = text[index]
218
+ return index if char == '"' && !escaped
219
+ escaped = char == '\\' && !escaped
220
+ end
221
+ nil
222
+ end
223
+
224
+ def value_end(text, start)
225
+ return string_end(text, start) if text[start] == '"'
226
+ unless ['{', '['].include?(text[start])
227
+ ending = text.index(/[\s,\]}]/, start)
228
+ return ending && ending > start ? ending - 1 : nil
229
+ end
230
+ stack, index = [], start
231
+ while index < text.length
232
+ char = text[index]
233
+ if char == '"'
234
+ index = string_end(text, index)
235
+ return nil unless index
236
+ elsif ['{', '['].include?(char)
237
+ stack << char
238
+ elsif ['}', ']'].include?(char)
239
+ stack.pop
240
+ return index if stack.empty?
241
+ end
242
+ index += 1
243
+ end
244
+ nil
245
+ end
246
+
247
+ def field(text, name)
248
+ start = field_start(text, name)
249
+ ending = start && value_end(text, start)
250
+ ending && JSON.parse(text[start..ending])
251
+ rescue JSON::ParserError
252
+ nil
253
+ end
254
+
255
+ def partial_array(text, name)
256
+ start = field_start(text, name)
257
+ return nil unless start && text[start] == '['
258
+ index, items = start + 1, []
259
+ loop do
260
+ index += 1 while text[index]&.match?(/[\s,]/)
261
+ ending = value_end(text, index)
262
+ break unless ending
263
+ items << JSON.parse(text[index..ending])
264
+ index = ending + 1
265
+ end
266
+ items
267
+ rescue JSON::ParserError
268
+ nil
269
+ end
270
+
271
+ # Semantic gate: IDs, root, catalog, required properties, refs, and cycles.
272
+ def validate(components)
273
+ errors = []
274
+ add = ->(code, path) { errors << { 'code' => code, 'path' => path, 'message' => code.tr('_', ' ') } }
275
+ return [{ 'code' => 'empty_components', 'path' => 'components', 'message' => 'A2UI components must be a non-empty array' }] if components.empty?
276
+ ids = components.filter_map { |component| component['id'] if component.is_a?(Hash) }
277
+ ids.group_by(&:itself).each { |id, matches| add.call('duplicate_id', "components[id=#{id}]") if matches.length > 1 }
278
+ add.call('no_root', 'components') unless ids.include?('root')
279
+ catalog = @config['schema'].is_a?(Hash) ? @config['schema']['components'] : nil
280
+ edges = {}
281
+ components.each_with_index do |component, index|
282
+ unless component.is_a?(Hash)
283
+ add.call('missing_id', "components[#{index}].id")
284
+ next
285
+ end
286
+ id, type = component.values_at('id', 'component')
287
+ add.call('missing_id', "components[#{index}].id") unless id.is_a?(String) && !id.empty?
288
+ add.call('missing_component_type', "components[#{index}].component") unless type.is_a?(String) && !type.empty?
289
+ schema = catalog && catalog[type]
290
+ if catalog && !catalog.empty?
291
+ add.call('unknown_component', "components[#{index}].component") unless schema
292
+ (schema || {}).fetch('required', []).each { |prop| add.call('missing_required_prop', "components[#{index}].#{prop}") unless component.key?(prop) }
293
+ end
294
+ refs = references(component, schema)
295
+ refs.each { |ref| add.call('unresolved_child', "components[#{index}]") unless ids.include?(ref) }
296
+ edges[id] = refs
297
+ end
298
+ visited, visiting = Set.new, Set.new
299
+ visit = lambda do |id|
300
+ if visiting.include?(id)
301
+ add.call('child_cycle', "components[id=#{id}]")
302
+ return
303
+ end
304
+ return if visited.include?(id)
305
+ visiting << id
306
+ (edges[id] || []).each { |child| visit.call(child) }
307
+ visiting.delete(id)
308
+ visited << id
309
+ end
310
+ edges.each_key { |id| visit.call(id) }
311
+ errors
312
+ end
313
+
314
+ def references(component, schema)
315
+ collect = lambda do |value|
316
+ values = value.is_a?(Array) ? value : [value]
317
+ values.filter_map { |entry| entry.is_a?(String) ? entry : (entry.is_a?(Hash) ? entry['componentId'] : nil) }
318
+ end
319
+ refs = collect.call(component['child']) + collect.call(component['children'])
320
+ (schema || {}).fetch('properties', {}).each do |field, property|
321
+ next unless property.is_a?(Hash) && !%w[child children].include?(field)
322
+ if %w[componentRef componentRefList].include?(property['format'])
323
+ refs.concat(collect.call(component[field]))
324
+ elsif property['type'] == 'array' && component[field].is_a?(Array)
325
+ property.fetch('items', {}).fetch('properties', {}).each do |sub, sub_schema|
326
+ next unless %w[componentRef componentRefList].include?(sub_schema['format'])
327
+ component[field].each { |item| refs.concat(collect.call(item[sub])) if item.is_a?(Hash) }
328
+ end
329
+ end
330
+ end
331
+ refs
332
+ end
333
+ end
334
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+ module CopilotKit
3
+ # Native agents implement each_event(input) and yield AG-UI event hashes.
4
+ # Each call must keep its mutable run state local for concurrent requests.
5
+ class Agent
6
+ attr_reader :description
7
+ def initialize(description: '')
8
+ @description = description
9
+ end
10
+ def each_event(_input)
11
+ raise NotImplementedError, 'Implement each_event(input) and yield AG-UI events'
12
+ end
13
+ end
14
+
15
+ # AG-UI HTTP adapter. Parses SSE incrementally with bounded event size.
16
+ class HttpAgent < Agent
17
+ def initialize(url:, headers: {}, description: '')
18
+ super(description: description)
19
+ @uri, @headers = URI(url), headers.freeze
20
+ raise ArgumentError, 'Agent URL must use HTTP(S)' unless @uri.is_a?(URI::HTTP)
21
+ end
22
+
23
+ def each_event(input)
24
+ request = Net::HTTP::Post.new(@uri.request_uri, { 'content-type' => 'application/json', 'accept' => 'text/event-stream' }.merge(@headers))
25
+ request.body = JSON.generate(input)
26
+ Net::HTTP.start(@uri.host, @uri.port, use_ssl: @uri.scheme == 'https', open_timeout: 5, read_timeout: 120) do |http|
27
+ http.request(request) do |response|
28
+ raise Error.new(502, 'Agent request failed') unless response.code.to_i.between?(200, 299)
29
+ buffer = +''
30
+ response.read_body do |chunk|
31
+ buffer << chunk
32
+ raise Error.new(502, 'Agent event exceeded size limit') if buffer.bytesize > 1_048_576
33
+ while (separator = /\r?\n\r?\n/.match(buffer))
34
+ frame = buffer.slice!(0, separator.end(0))
35
+ data = frame.lines.select { |line| line.start_with?('data:') }.map { |line| line.delete_prefix('data:').sub(/\A /, '').strip }.join("\n")
36
+ next if data.empty? || data == '[DONE]'
37
+ event = JSON.parse(data)
38
+ raise Error.new(502, 'Malformed AG-UI event') unless event.is_a?(Hash) && event['type'].is_a?(String)
39
+ yield event
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+ require 'uri'
3
+ require 'ipaddr'
4
+
5
+ module CopilotKit
6
+ # Sanitized project display metadata. This data does not grant resource access.
7
+ module InspectorMetadata
8
+ # Copy supported V1 fields into native Hash values, preserving optional modules.
9
+ # @return [Hash, nil] A V1 hash, or nil for an unsupported schema.
10
+ def self.parse(value)
11
+ return nil unless value.is_a?(Hash) && [Integer, Float].any? { |type| value['schemaVersion'].is_a?(type) } && value['schemaVersion'] == 1
12
+ result = { 'schemaVersion' => 1 }
13
+ identity = object(value['identity'])
14
+ organization, project = text(identity['organizationName']), text(identity['projectName'])
15
+ result['identity'] = { 'organizationName' => organization, 'projectName' => project } if organization && project
16
+ plan = object(value['plan'])
17
+ code, label = text(plan['code']), text(plan['label'])
18
+ result['plan'] = { 'code' => code, 'label' => label } if code && label
19
+ state = object(value['license'])['state']
20
+ result['license'] = { 'state' => state.dup } if %w[valid none expired unknown].include?(state)
21
+ action = object(value['action'])
22
+ action_url = safe_url(action['url'])
23
+ if %w[manage_plan renew enable_intelligence].include?(action['kind']) && action_url
24
+ result['action'] = { 'kind' => action['kind'].dup, 'url' => action_url }
25
+ end
26
+ usage = object(value['usage'])
27
+ used = integer(usage['used'])
28
+ limit = object(usage['limit'])
29
+ parsed_limit = case limit['kind']
30
+ when 'finite'
31
+ count = integer(limit['value'], 1)
32
+ { 'kind' => 'finite', 'value' => count } if count
33
+ when 'unlimited' then { 'kind' => 'unlimited' }
34
+ when 'unknown' then { 'kind' => 'unknown' }
35
+ end
36
+ if used && parsed_limit
37
+ result['usage'] = { 'used' => used, 'limit' => parsed_limit }
38
+ expiring = integer(usage['expiringSoonCount'])
39
+ result['usage']['expiringSoonCount'] = expiring if expiring
40
+ end
41
+ result
42
+ end
43
+
44
+ def self.object(value)
45
+ value.is_a?(Hash) ? value : {}
46
+ end
47
+
48
+ def self.text(value)
49
+ return nil unless value.is_a?(String) && value.valid_encoding?
50
+ # Match the whitespace set used by the TypeScript parser, including BOM but not NEL.
51
+ cleaned = value.encode(Encoding::UTF_8).gsub(/\A[\u0009-\u000d\u0020\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000\ufeff]+|[\u0009-\u000d\u0020\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000\ufeff]+\z/, '')
52
+ cleaned.empty? ? nil : cleaned
53
+ rescue EncodingError
54
+ nil
55
+ end
56
+
57
+ def self.integer(value, minimum = 0)
58
+ return nil unless value.is_a?(Integer) || value.is_a?(Float)
59
+ return nil unless value.finite? && value >= minimum && value <= 9_007_199_254_740_991 && value == value.to_i
60
+ value.to_i
61
+ end
62
+
63
+ def self.safe_url(value)
64
+ raw = text(value)
65
+ return nil unless raw && !raw.include?('?') && !raw.include?('#') && raw.include?('://')
66
+ authority = raw.split('://', 2).last.split('/', 2).first.to_s
67
+ return nil if authority.include?('@')
68
+ parsed = URI(raw.tr('\\', '/'))
69
+ return nil unless parsed.is_a?(URI::HTTP) && parsed.host && !parsed.host.empty? && !parsed.userinfo && parsed.port.between?(0, 65_535)
70
+ host = URI::DEFAULT_PARSER.unescape(parsed.hostname).downcase
71
+ return nil if host.each_char.any? { |character| character.ord <= 32 || '%#/<>?@[]\\^|'.include?(character) }
72
+ return raw if parsed.scheme == 'https'
73
+ loopback = %w[localhost 127.0.0.1].include?(host)
74
+ loopback ||= host.include?(':') && IPAddr.new(host) == IPAddr.new('::1')
75
+ loopback ? raw : nil
76
+ rescue URI::InvalidURIError, IPAddr::InvalidAddressError, ArgumentError
77
+ nil
78
+ end
79
+
80
+ private_class_method :object, :text, :integer, :safe_url
81
+ end
82
+ end