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,353 @@
1
+ # frozen_string_literal: true
2
+ require 'base64'
3
+ require 'timeout'
4
+ require_relative 'websocket'
5
+
6
+ module CopilotKit
7
+ class RetryableGatewayError < Error; end
8
+ class PermanentGatewayError < Error; end
9
+ # Phoenix V2 transport. Each synchronous push waits for its durability ACK.
10
+ class Gateway
11
+ attr_reader :supports_batch
12
+ def initialize(url:, token:, thread_id:, run_id:, on_stop: nil)
13
+ @url, @token, @thread_id, @run_id = url, token, thread_id, run_id
14
+ @reference = 0
15
+ @messages = Queue.new
16
+ @mutex = Mutex.new
17
+ @on_stop = on_stop
18
+ end
19
+
20
+ def connect
21
+ @messages = SizedQueue.new(64)
22
+ queue = @messages
23
+ @socket = WebSocketTransport.new(@url.sub(%r{/$}, '') + '/websocket?vsn=2.0.0', {
24
+ 'Sec-WebSocket-Protocol' => "phoenix, base64url.bearer.phx.#{Base64.strict_encode64(@token).delete('=')}"
25
+ }) do |message|
26
+ if message.is_a?(Array) && message[2] == "ingestion:#{@run_id}" && message[3] == 'ag-ui' && message[4].is_a?(Hash) && message[4]['type'] == 'CUSTOM' && message[4]['name'] == 'stop'
27
+ @on_stop&.call
28
+ else
29
+ begin
30
+ queue.push(message, true)
31
+ rescue ThreadError, ClosedQueueError
32
+ queue.close
33
+ end
34
+ end
35
+ end
36
+ @join_ref = next_ref
37
+ reply = exchange(@join_ref, 'phx_join', { 'thread_id' => @thread_id, 'run_id' => @run_id })
38
+ raise RetryableGatewayError.new(502, 'Gateway is draining') if reply['status'] != 'ok' && reply.dig('response', 'retryable') == true
39
+ raise Error.new(502, 'Gateway rejected channel join') unless reply['status'] == 'ok'
40
+ @supports_batch = Array(reply.dig('response', 'capabilities')).include?('runner_event_batch_v1')
41
+ true
42
+ rescue Timeout::Error, RetryableGatewayError
43
+ close
44
+ attempts = (attempts || 0) + 1
45
+ if attempts < 4
46
+ sleep(0.1 * 2**(attempts - 1))
47
+ retry
48
+ end
49
+ raise Error.new(502, 'Gateway join timed out')
50
+ end
51
+
52
+ def publish(events)
53
+ @mutex.synchronize do
54
+ groups = @supports_batch ? [events] : events.map { |event| [event] }
55
+ groups.each do |group|
56
+ reply = exchange(next_ref, @supports_batch ? 'events' : 'event', @supports_batch ? { 'events' => group } : group.first)
57
+ if reply['status'] != 'ok'
58
+ type = reply.dig('response', 'retryable') == false ? PermanentGatewayError : Error
59
+ raise type.new(502, 'Gateway rejected event')
60
+ end
61
+ end
62
+ end
63
+ end
64
+
65
+ # Recover idle transport failures without cancelling a valid platform lease.
66
+ def heartbeat
67
+ @mutex.synchronize do
68
+ 4.times do |attempt|
69
+ begin
70
+ if attempt.positive?
71
+ close
72
+ connect
73
+ end
74
+ return exchange(next_ref, 'heartbeat', {}, topic: 'phoenix')
75
+ rescue StandardError
76
+ raise if attempt == 3
77
+ sleep(0.1 * 2**attempt)
78
+ end
79
+ end
80
+ end
81
+ end
82
+
83
+ # Serialize reconnects with event ACKs and heartbeat recovery.
84
+ def reconnect
85
+ @mutex.synchronize do
86
+ close
87
+ connect
88
+ end
89
+ end
90
+
91
+ def close
92
+ @socket&.close
93
+ rescue StandardError
94
+ nil
95
+ end
96
+
97
+ private
98
+
99
+ def next_ref
100
+ @reference += 1
101
+ @reference.to_s
102
+ end
103
+
104
+ def exchange(ref, event, payload, topic: "ingestion:#{@run_id}")
105
+ @socket.send(JSON.generate([@join_ref, ref, topic, event, payload]))
106
+ Timeout.timeout(5) do
107
+ loop do
108
+ message = @messages.pop
109
+ raise Error.new(502, 'Gateway connection closed') if message.nil? || message == [:closed]
110
+ next unless message.is_a?(Array) && message[1] == ref && message[3] == 'phx_reply'
111
+ return message[4]
112
+ end
113
+ end
114
+ end
115
+ end
116
+
117
+ # Owns a single run, including gateway durability, lease renewal, and cleanup.
118
+ class Runner
119
+ attr_reader :thread_id, :run_id
120
+
121
+ def initialize(platform:, url:, auth_token:, lock:, agent:, input:, messages:, telemetry:, on_error: nil,
122
+ heartbeat_interval: 15, lock_ttl: 20, queue_capacity: 32)
123
+ @platform, @lock, @agent, @input, @messages, @telemetry = platform, lock, agent, input, messages, telemetry
124
+ @on_error = on_error
125
+ @thread_id, @run_id = lock.fetch('threadId'), lock.fetch('runId')
126
+ @heartbeat_interval, @lock_ttl = heartbeat_interval, lock_ttl
127
+ @events, @state_mutex = SizedQueue.new(queue_capacity), Mutex.new
128
+ @cancel_code, @producer, @pending_event = nil, nil, nil
129
+ @durable_terminal = false
130
+ @terminal_sent = false
131
+ @open_messages, @open_tools = {}, {}
132
+ @gateway = Gateway.new(url: url, token: auth_token, thread_id: @thread_id, run_id: @run_id, on_stop: -> { request_stop })
133
+ @sequence, @stopped = 0, false
134
+ @lock_path = '/api/threads/' + URI.encode_www_form_component(lock.fetch('threadId')) + '/lock'
135
+ end
136
+
137
+ def join_gateway
138
+ raise Error.new(503, 'Run startup was cancelled') if @cancel_code || @stopped
139
+ @gateway.connect
140
+ raise Error.new(503, 'Run startup was cancelled') if @cancel_code || @stopped
141
+ end
142
+
143
+ def prepare_input(input, messages)
144
+ @input, @messages = input, messages
145
+ end
146
+
147
+ # Lease renewal does not wait for history, channel join, or event ACKs.
148
+ def start_lease
149
+ return if @lease_thread
150
+ @lease_thread = Thread.new do
151
+ loop do
152
+ sleep @heartbeat_interval
153
+ break if @stopped
154
+ @platform.request('PATCH', @lock_path, 'runId' => @lock['runId'], 'ttlSeconds' => @lock_ttl)
155
+ rescue StandardError => error
156
+ report_error(error)
157
+ request_stop(code: 'LOCK_RENEWAL_FAILED')
158
+ break
159
+ end
160
+ end
161
+ end
162
+
163
+ def start(&finished)
164
+ start_lease
165
+ @thread = Thread.new do
166
+ @telemetry.emit('oss.runtime.agent_execution_stream_started')
167
+ heartbeat = Thread.new do
168
+ loop do
169
+ sleep @heartbeat_interval
170
+ break if @stopped
171
+ @gateway.heartbeat
172
+ rescue StandardError => error
173
+ report_error(error)
174
+ request_stop(code: 'GATEWAY_UNAVAILABLE')
175
+ break
176
+ end
177
+ end
178
+ outcome = 'completed'
179
+ stream_completed = false
180
+ begin
181
+ emit('type' => 'RUN_STARTED', 'input' => @input.merge('messages' => @messages))
182
+ @state_mutex.synchronize do
183
+ @producer = Thread.new { produce } unless @cancel_code
184
+ end
185
+ terminal = false
186
+ while (item = @events.pop)
187
+ break if @cancel_code || terminal
188
+ kind, event = item
189
+ raise event if kind == :error
190
+ next if event['type'] == 'RUN_STARTED'
191
+ batch = [event]
192
+ if @gateway.supports_batch
193
+ while batch.length < 32 && !%w[RUN_FINISHED RUN_ERROR].include?(batch.last['type'])
194
+ begin
195
+ next_kind, next_event = @events.pop(true)
196
+ break unless next_kind
197
+ raise next_event if next_kind == :error
198
+ batch << next_event unless next_event['type'] == 'RUN_STARTED'
199
+ rescue ThreadError
200
+ break
201
+ end
202
+ end
203
+ end
204
+ event = batch.last
205
+ terminal ||= %w[RUN_FINISHED RUN_ERROR].include?(event['type'])
206
+ if event['type'] == 'RUN_ERROR'
207
+ outcome = 'error'
208
+ @telemetry.emit('oss.runtime.agent_execution_stream_errored')
209
+ report_error(Error.new(502, 'Agent run failed'))
210
+ end
211
+ emit_batch(batch)
212
+ break if terminal
213
+ end
214
+ if @cancel_code && !terminal
215
+ outcome = 'error'
216
+ @telemetry.emit('oss.runtime.agent_execution_stream_errored')
217
+ emit('type' => 'RUN_ERROR', 'message' => @cancel_code == 'STOPPED' ? 'Run stopped by user' : 'Run lease was lost', 'code' => @cancel_code)
218
+ elsif !terminal
219
+ outcome = 'error'
220
+ @telemetry.emit('oss.runtime.agent_execution_stream_errored')
221
+ report_error(Error.new(502, 'Run ended without emitting a terminal event'))
222
+ finalize_incomplete_stream
223
+ end
224
+ stream_completed = terminal
225
+ rescue StandardError => error
226
+ outcome = 'error'
227
+ @telemetry.emit('oss.runtime.agent_execution_stream_errored')
228
+ report_error(error)
229
+ begin
230
+ emit('type' => 'RUN_ERROR', 'message' => 'Agent run failed', 'code' => 'AGENT_RUN_FAILED') unless @stopped || @pending_event
231
+ rescue StandardError
232
+ nil
233
+ end
234
+ ensure
235
+ @stopped = true
236
+ @events.close
237
+ @producer&.kill
238
+ @producer&.join(0.1)
239
+ heartbeat.kill
240
+ heartbeat.join
241
+ @lease_thread&.kill
242
+ @lease_thread&.join
243
+ @gateway.close
244
+ begin
245
+ Timeout.timeout(3) { @platform.request('DELETE', @lock_path, 'runId' => @lock['runId']) }
246
+ rescue StandardError => error
247
+ report_error(error)
248
+ end
249
+ @telemetry.emit('oss.runtime.agent_execution_stream_ended') if (outcome == 'completed' || stream_completed) && @durable_terminal
250
+ finished.call
251
+ end
252
+ end
253
+ end
254
+
255
+ def join(timeout)
256
+ @thread&.join(timeout)
257
+ end
258
+
259
+ # Cancels only the producer; the publisher finishes its current durability ACK.
260
+ def request_stop(code: 'STOPPED')
261
+ @state_mutex.synchronize do
262
+ return false if @stopped || @cancel_code || @terminal_sent
263
+ @cancel_code = code
264
+ @producer&.kill
265
+ @events.close
266
+ end
267
+ true
268
+ end
269
+
270
+ # Bounded force-close used only after the caller's graceful drain deadline.
271
+ def stop(timeout: 0.1)
272
+ request_stop
273
+ @stopped = true
274
+ @gateway.close
275
+ @lease_thread&.kill
276
+ @lease_thread&.join(timeout)
277
+ return unless @thread&.alive?
278
+ @thread.kill
279
+ @thread.join(timeout)
280
+ end
281
+
282
+ private
283
+
284
+ def produce
285
+ @agent.each_event(@input) { |event| @events.push([:event, event]) }
286
+ rescue ClosedQueueError
287
+ nil
288
+ rescue StandardError => error
289
+ @events.push([:error, error]) unless @events.closed?
290
+ ensure
291
+ @events.close
292
+ end
293
+
294
+ def report_error(error)
295
+ @on_error&.call(error)
296
+ rescue StandardError
297
+ nil
298
+ end
299
+
300
+ def emit(source)
301
+ emit_batch([source])
302
+ end
303
+
304
+ def finalize_incomplete_stream
305
+ message = 'Run ended without emitting a terminal event'
306
+ @open_messages.keys.each { |id| emit('type' => 'TEXT_MESSAGE_END', 'messageId' => id) }
307
+ @open_tools.to_a.each do |id, state|
308
+ emit('type' => 'TOOL_CALL_END', 'toolCallId' => id) unless state[:ended]
309
+ unless state[:result]
310
+ emit('type' => 'TOOL_CALL_RESULT', 'toolCallId' => id, 'messageId' => "#{id}-result", 'role' => 'tool',
311
+ 'content' => JSON.generate('status' => 'error', 'reason' => 'missing_terminal_event', 'message' => message))
312
+ end
313
+ end
314
+ emit('type' => 'RUN_ERROR', 'code' => 'INCOMPLETE_STREAM', 'message' => message)
315
+ end
316
+
317
+ def track_stream(event)
318
+ id = event['toolCallId']
319
+ case event['type']
320
+ when 'TEXT_MESSAGE_START' then @open_messages[event['messageId']] = true if event['messageId'].is_a?(String)
321
+ when 'TEXT_MESSAGE_END' then @open_messages.delete(event['messageId'])
322
+ when 'TOOL_CALL_START' then @open_tools[id] = {} if id.is_a?(String)
323
+ when 'TOOL_CALL_END' then @open_tools[id][:ended] = true if @open_tools[id]
324
+ when 'TOOL_CALL_RESULT' then @open_tools[id][:result] = true if @open_tools[id]
325
+ end
326
+ @open_tools.delete(id) if @open_tools[id] && @open_tools[id][:ended] && @open_tools[id][:result]
327
+ end
328
+
329
+ def emit_batch(sources)
330
+ events = sources.map do |source|
331
+ @sequence += 1
332
+ source.merge('threadId' => @lock['threadId'], 'runId' => @lock['runId'], 'thread_id' => @lock['threadId'], 'run_id' => @lock['runId'],
333
+ 'metadata' => (source['metadata'].is_a?(Hash) ? source['metadata'] : {}).merge(
334
+ 'cpki_event_id' => SecureRandom.uuid, 'cpki_event_seq' => @sequence))
335
+ end
336
+ @pending_event = events
337
+ @terminal_sent = true if events.any? { |event| %w[RUN_FINISHED RUN_ERROR].include?(event['type']) }
338
+ 4.times do |attempt|
339
+ begin
340
+ @gateway.publish(events)
341
+ events.each { |event| track_stream(event) }
342
+ @pending_event = nil
343
+ @durable_terminal = true if events.any? { |event| %w[RUN_FINISHED RUN_ERROR].include?(event['type']) }
344
+ return
345
+ rescue StandardError => error
346
+ raise if attempt == 3 || @stopped || error.is_a?(PermanentGatewayError)
347
+ sleep(0.1 * (2**attempt))
348
+ @gateway.reconnect
349
+ end
350
+ end
351
+ end
352
+ end
353
+ end