acp_sdk_async 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,341 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "set"
5
+ require "async/queue"
6
+ require_relative "exceptions"
7
+ require_relative "schema_base"
8
+ require_relative "transport"
9
+ require_relative "wait"
10
+
11
+ module ACP
12
+ StreamEvent = Struct.new(:direction, :message, keyword_init: true) do
13
+ def incoming? = direction == :incoming
14
+ def outgoing? = direction == :outgoing
15
+ end
16
+
17
+ class Connection
18
+ attr_reader :transport
19
+
20
+ def initialize(handler, transport, worker_grace: 0.5)
21
+ @handler = handler
22
+ @transport = transport
23
+ @worker_grace = worker_grace
24
+ @next_id = 0
25
+ @pending = {}
26
+ @closed = false
27
+ @mutex = Mutex.new
28
+ @workers = Set.new
29
+ @observers = []
30
+ @listen_worker = nil
31
+ @notification_queue = ::Async::Queue.new
32
+ @notification_worker = nil
33
+ @finished = Wait::Latch.new
34
+ end
35
+
36
+ def closed?
37
+ @closed
38
+ end
39
+
40
+ def add_observer(callable = nil, &block)
41
+ observer = callable || block
42
+ raise ArgumentError, "Observer must respond to #call" unless observer.respond_to?(:call)
43
+
44
+ @mutex.synchronize { @observers << observer }
45
+ observer
46
+ end
47
+
48
+ def remove_observer(observer)
49
+ @mutex.synchronize { @observers.delete(observer) }
50
+ end
51
+
52
+ def listen
53
+ start_notification_worker
54
+ receive_loop
55
+ rescue StandardError => e
56
+ ACP.logger.error("acp: receive loop failed: #{e.class}: #{e.message}")
57
+ raise
58
+ ensure
59
+ reject_all(ConnectionError.new("Connection closed"))
60
+ stop_notification_worker
61
+ @finished.open
62
+ end
63
+
64
+ def start
65
+ return @listen_worker if listening?
66
+
67
+ Wait.ensure_reactor!
68
+ @finished = Wait::Latch.new
69
+ @listen_worker = Wait.spawn("acp-listen") { listen }
70
+ end
71
+
72
+ def join(timeout = nil)
73
+ @finished.wait(timeout)
74
+ end
75
+
76
+ def send_request(method, params = nil, timeout: nil)
77
+ raise ConnectionError, "Connection closed" if @closed
78
+
79
+ request_id = next_id
80
+ promise = Wait::Promise.new
81
+ @mutex.synchronize { @pending[request_id] = promise }
82
+
83
+ payload = { "jsonrpc" => "2.0", "id" => request_id, "method" => method }
84
+ payload["params"] = serialize(params) unless params.nil?
85
+
86
+ begin
87
+ write(payload)
88
+ rescue StandardError
89
+ @mutex.synchronize { @pending.delete(request_id) }
90
+ raise
91
+ end
92
+
93
+ begin
94
+ promise.wait(timeout)
95
+ rescue TimeoutError
96
+ @mutex.synchronize { @pending.delete(request_id) }
97
+ raise TimeoutError, "Request #{method} (id=#{request_id}) timed out after #{timeout}s"
98
+ end
99
+ end
100
+
101
+ def send_notification(method, params = nil)
102
+ raise ConnectionError, "Connection closed" if @closed
103
+
104
+ payload = { "jsonrpc" => "2.0", "method" => method }
105
+ payload["params"] = serialize(params) unless params.nil?
106
+ write(payload)
107
+ end
108
+
109
+ def drain_notifications(timeout: nil)
110
+ return true unless Wait.alive?(@notification_worker)
111
+
112
+ latch = Wait::Latch.new
113
+ @notification_queue.push(latch)
114
+ latch.wait(timeout)
115
+ end
116
+
117
+ def close
118
+ return if @closed
119
+
120
+ @closed = true
121
+ reject_all(ConnectionError.new("Connection closed"))
122
+ begin
123
+ @transport.close
124
+ rescue StandardError => e
125
+ ACP.logger.debug("acp: transport close failed: #{e.message}")
126
+ end
127
+ stop_notification_worker
128
+ stop_workers
129
+ end
130
+
131
+ private
132
+
133
+ def next_id
134
+ @mutex.synchronize do
135
+ id = @next_id
136
+ @next_id += 1
137
+ id
138
+ end
139
+ end
140
+
141
+ def serialize(params)
142
+ Schema.serialize(params)
143
+ end
144
+
145
+ def write(payload)
146
+ notify_observers(:outgoing, payload)
147
+ @transport.send_message(payload)
148
+ end
149
+
150
+ def receive_loop
151
+ loop do
152
+ break if @closed
153
+
154
+ message = begin
155
+ @transport.receive_message
156
+ rescue TimeoutError => e
157
+ ACP.logger.error("acp: #{e.message}")
158
+ break
159
+ rescue ConnectionError => e
160
+ ACP.logger.debug("acp: receive failed: #{e.message}")
161
+ break
162
+ rescue ::Async::Cancel
163
+ break
164
+ end
165
+ break if message.nil?
166
+
167
+ notify_observers(:incoming, message)
168
+ process_message(message)
169
+ end
170
+ end
171
+
172
+ def process_message(message)
173
+ unless message.is_a?(Hash)
174
+ ACP.logger.warn("acp: ignoring non-object message: #{message.inspect[0, 200]}")
175
+ return
176
+ end
177
+
178
+ method = message["method"]
179
+ has_id = message.key?("id")
180
+
181
+ if method
182
+ unless method.is_a?(String)
183
+ respond_error(message["id"], RequestError.invalid_request("details" => "method must be a string")) if has_id
184
+ return
185
+ end
186
+
187
+ if has_id
188
+ spawn_worker("acp-request:#{method}") { run_request(message) }
189
+ else
190
+ @notification_queue.push(message)
191
+ end
192
+ return
193
+ end
194
+
195
+ if has_id
196
+ handle_response(message)
197
+ else
198
+ ACP.logger.warn("acp: ignoring message without method or id: #{message.inspect[0, 200]}")
199
+ end
200
+ end
201
+
202
+ def run_request(message)
203
+ payload = { "jsonrpc" => "2.0", "id" => message["id"] }
204
+ begin
205
+ result = @handler.call(message["method"], message["params"], false)
206
+ payload["result"] = serialize(result)
207
+ rescue RequestError => e
208
+ payload["error"] = e.to_error_obj
209
+ rescue Schema::ValidationError => e
210
+ payload["error"] = RequestError.invalid_params(
211
+ "errors" => [{ "message" => e.message, "loc" => e.path }]
212
+ ).to_error_obj
213
+ rescue ::Async::Cancel
214
+ return
215
+ rescue StandardError => e
216
+ ACP.logger.error("acp: handler for #{message['method']} failed: #{e.class}: #{e.message}")
217
+ payload["error"] = RequestError.internal_error("details" => e.message).to_error_obj
218
+ end
219
+ write(payload)
220
+ rescue ConnectionError => e
221
+ ACP.logger.debug("acp: could not send response for #{message['method']}: #{e.message}")
222
+ rescue ::Async::Cancel
223
+ nil
224
+ end
225
+
226
+ def respond_error(id, error)
227
+ write({ "jsonrpc" => "2.0", "id" => id, "error" => error.to_error_obj })
228
+ rescue ConnectionError, ::Async::Cancel
229
+ nil
230
+ end
231
+
232
+ def run_notification(message)
233
+ @handler.call(message["method"], message["params"], true)
234
+ rescue ::Async::Cancel
235
+ nil
236
+ rescue StandardError => e
237
+ ACP.logger.error("acp: notification handler for #{message['method']} failed: #{e.class}: #{e.message}")
238
+ end
239
+
240
+ def handle_response(message)
241
+ promise = @mutex.synchronize { @pending.delete(message["id"]) }
242
+ unless promise
243
+ ACP.logger.debug("acp: response for unknown request id #{message['id'].inspect}")
244
+ return
245
+ end
246
+
247
+ if message.key?("error")
248
+ error = message["error"] || {}
249
+ promise.reject(RequestError.new(error["code"] || -32603, error["message"] || "Error", error["data"]))
250
+ else
251
+ promise.resolve(message["result"])
252
+ end
253
+ end
254
+
255
+ def reject_all(error)
256
+ pending = @mutex.synchronize do
257
+ items = @pending.values
258
+ @pending.clear
259
+ items
260
+ end
261
+ pending.each { |promise| promise.reject(error) }
262
+ end
263
+
264
+ def notify_observers(direction, message)
265
+ observers = @mutex.synchronize { @observers.dup }
266
+ return if observers.empty?
267
+
268
+ event = StreamEvent.new(direction: direction, message: message)
269
+ observers.each do |observer|
270
+ observer.call(event)
271
+ rescue StandardError => e
272
+ ACP.logger.error("acp: observer failed: #{e.class}: #{e.message}")
273
+ end
274
+ end
275
+
276
+ def start_notification_worker
277
+ return if Wait.alive?(@notification_worker)
278
+
279
+ @notification_worker = Wait.spawn("acp-notifications") do
280
+ while (message = @notification_queue.pop)
281
+ if message.is_a?(Wait::Latch)
282
+ message.open
283
+ else
284
+ run_notification(message)
285
+ end
286
+ end
287
+ end
288
+ end
289
+
290
+ def stop_notification_worker
291
+ worker = @mutex.synchronize do
292
+ current = @notification_worker
293
+ @notification_worker = nil
294
+ current
295
+ end
296
+ return unless worker
297
+
298
+ begin
299
+ @notification_queue.push(nil)
300
+ rescue ::Async::Queue::ClosedError, ClosedQueueError
301
+ nil
302
+ end
303
+ return if Wait.current?(worker)
304
+
305
+ Wait.stop(worker) unless Wait.join(worker, @worker_grace)
306
+ end
307
+
308
+ def spawn_worker(name, &block)
309
+ Wait.spawn(name) do
310
+ me = Wait.current_worker
311
+ @mutex.synchronize { @workers << me }
312
+ begin
313
+ block.call
314
+ ensure
315
+ @mutex.synchronize { @workers.delete(me) }
316
+ end
317
+ end
318
+ end
319
+
320
+ def listening?
321
+ Wait.alive?(@listen_worker)
322
+ end
323
+
324
+ def stop_workers
325
+ workers = @mutex.synchronize do
326
+ list = @workers.to_a
327
+ @workers.clear
328
+ list
329
+ end
330
+ workers.each do |worker|
331
+ next if Wait.current?(worker)
332
+
333
+ Wait.stop(worker) unless Wait.join(worker, @worker_grace)
334
+ end
335
+ listener = @listen_worker
336
+ return if listener.nil? || Wait.current?(listener)
337
+
338
+ Wait.stop(listener) unless Wait.join(listener, @worker_grace)
339
+ end
340
+ end
341
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ACP
4
+ class Error < StandardError; end
5
+
6
+ class RequestError < Error
7
+ attr_reader :code, :data
8
+
9
+ def initialize(code, message, data = nil)
10
+ super(message)
11
+ @code = code
12
+ @data = data
13
+ end
14
+
15
+ def self.parse_error(data = nil) = new(-32700, "Parse error", data)
16
+ def self.invalid_request(data = nil) = new(-32600, "Invalid request", data)
17
+ def self.method_not_found(method) = new(-32601, "Method not found", { "method" => method })
18
+ def self.invalid_params(data = nil) = new(-32602, "Invalid params", data)
19
+ def self.internal_error(data = nil) = new(-32603, "Internal error", data)
20
+ def self.request_cancelled(data = nil) = new(-32800, "Request cancelled", data)
21
+ def self.auth_required(data = nil) = new(-32000, "Authentication required", data)
22
+ def self.resource_not_found(uri = nil) = new(-32002, "Resource not found", uri ? { "uri" => uri } : nil)
23
+
24
+ def to_error_obj
25
+ { "code" => @code, "message" => message, "data" => @data }
26
+ end
27
+ end
28
+
29
+ class ConnectionError < Error; end
30
+
31
+ class TimeoutError < Error; end
32
+ end
data/lib/acp/meta.rb ADDED
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ACP
4
+ PROTOCOL_VERSION = 1
5
+ SCHEMA_REF = "refs/tags/schema-v1.21.0"
6
+
7
+ AGENT_METHODS = {
8
+ "initialize" => "initialize",
9
+ "authenticate" => "authenticate",
10
+ "providers_list" => "providers/list",
11
+ "providers_set" => "providers/set",
12
+ "providers_disable" => "providers/disable",
13
+ "session_new" => "session/new",
14
+ "session_load" => "session/load",
15
+ "session_set_mode" => "session/set_mode",
16
+ "session_set_config_option" => "session/set_config_option",
17
+ "session_prompt" => "session/prompt",
18
+ "session_cancel" => "session/cancel",
19
+ "mcp_message" => "mcp/message",
20
+ "session_list" => "session/list",
21
+ "session_delete" => "session/delete",
22
+ "session_fork" => "session/fork",
23
+ "session_resume" => "session/resume",
24
+ "session_close" => "session/close",
25
+ "logout" => "logout",
26
+ "nes_start" => "nes/start",
27
+ "nes_suggest" => "nes/suggest",
28
+ "nes_accept" => "nes/accept",
29
+ "nes_reject" => "nes/reject",
30
+ "nes_close" => "nes/close",
31
+ "document_did_open" => "document/didOpen",
32
+ "document_did_change" => "document/didChange",
33
+ "document_did_close" => "document/didClose",
34
+ "document_did_save" => "document/didSave",
35
+ "document_did_focus" => "document/didFocus"
36
+ }.freeze
37
+
38
+ CLIENT_METHODS = {
39
+ "session_request_permission" => "session/request_permission",
40
+ "session_update" => "session/update",
41
+ "fs_write_text_file" => "fs/write_text_file",
42
+ "fs_read_text_file" => "fs/read_text_file",
43
+ "terminal_create" => "terminal/create",
44
+ "terminal_output" => "terminal/output",
45
+ "terminal_release" => "terminal/release",
46
+ "terminal_wait_for_exit" => "terminal/wait_for_exit",
47
+ "terminal_kill" => "terminal/kill",
48
+ "mcp_connect" => "mcp/connect",
49
+ "mcp_message" => "mcp/message",
50
+ "mcp_disconnect" => "mcp/disconnect",
51
+ "elicitation_create" => "elicitation/create",
52
+ "elicitation_complete" => "elicitation/complete"
53
+ }.freeze
54
+
55
+ PROTOCOL_METHODS = {
56
+ "cancel_request" => "$/cancel_request"
57
+ }.freeze
58
+ end
data/lib/acp/router.rb ADDED
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "exceptions"
4
+ require_relative "schema_base"
5
+
6
+ module ACP
7
+ class Route
8
+ attr_reader :method, :kind
9
+
10
+ def initialize(method:, handler:, kind:, model: nil, optional: false, default_result: nil, adapt_result: nil)
11
+ @method = method
12
+ @handler = handler
13
+ @kind = kind
14
+ @model = model
15
+ @optional = optional
16
+ @default_result = default_result
17
+ @adapt_result = adapt_result
18
+ end
19
+
20
+ def handle(params)
21
+ unless @handler
22
+ return @default_result if @optional
23
+
24
+ raise RequestError.method_not_found(@method)
25
+ end
26
+
27
+ argument = @model ? @model.coerce(params.nil? ? {} : params) : params
28
+ result = @handler.call(argument)
29
+ return result unless @kind == :request && @adapt_result
30
+
31
+ @adapt_result.call(result)
32
+ end
33
+ end
34
+
35
+ class Router
36
+ NORMALIZE_RESULT = ->(result) { result.nil? ? {} : result }
37
+
38
+ def initialize
39
+ @requests = {}
40
+ @notifications = {}
41
+ @extension_request = nil
42
+ @extension_notification = nil
43
+ end
44
+
45
+ def add_route(route)
46
+ if route.kind == :request
47
+ @requests[route.method] = route
48
+ else
49
+ @notifications[route.method] = route
50
+ end
51
+ route
52
+ end
53
+
54
+ def route_request(method, model, target, *names, optional: false, default_result: nil, normalize: false)
55
+ handler = resolve_handler(target, names)
56
+ add_route(Route.new(
57
+ method: method,
58
+ handler: handler,
59
+ kind: :request,
60
+ model: model,
61
+ optional: optional,
62
+ default_result: default_result,
63
+ adapt_result: normalize ? NORMALIZE_RESULT : nil
64
+ ))
65
+ end
66
+
67
+ def route_notification(method, model, target, *names)
68
+ handler = resolve_handler(target, names)
69
+ add_route(Route.new(
70
+ method: method,
71
+ handler: handler,
72
+ kind: :notification,
73
+ model: model,
74
+ optional: true
75
+ ))
76
+ end
77
+
78
+ def on_extension_request(&block)
79
+ @extension_request = block
80
+ end
81
+
82
+ def on_extension_notification(&block)
83
+ @extension_notification = block
84
+ end
85
+
86
+ def requests
87
+ @requests.keys
88
+ end
89
+
90
+ def notifications
91
+ @notifications.keys
92
+ end
93
+
94
+ def call(method, params, is_notification)
95
+ if method.start_with?("_")
96
+ handler = is_notification ? @extension_notification : @extension_request
97
+ return nil if is_notification && handler.nil?
98
+ raise RequestError.method_not_found(method) unless handler
99
+
100
+ return handler.call(method[1..], params.is_a?(Hash) ? params : {})
101
+ end
102
+
103
+ routes = is_notification ? @notifications : @requests
104
+ route = routes[method]
105
+ raise RequestError.method_not_found(method) unless route
106
+
107
+ route.handle(params)
108
+ end
109
+
110
+ private
111
+
112
+ def resolve_handler(target, names)
113
+ name = names.flatten.find { |candidate| target.respond_to?(candidate) }
114
+ return nil unless name
115
+
116
+ target.method(name)
117
+ end
118
+ end
119
+ end