acp_sdk_async 0.2.0 → 0.3.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a87705f9f7fc44a42ab713bc4122e1298c84cd5fa736bab7f605d83ee91fe493
4
- data.tar.gz: 05f8fe85747082078abd8963111084d6e8d5fcac56c64afb5e7d4cc9dadf22dc
3
+ metadata.gz: ec9246d9b39f427c0ce91bc4d9538808c0721f391dd29cef5f0a5cf2856a7328
4
+ data.tar.gz: 2666f26808cc8401f430c18a5ff47199343ea712c96915bd9325a289de817e93
5
5
  SHA512:
6
- metadata.gz: '0170289fc4f146993df1a91092643774248cfd86d806cb12ef932788462c4333da1beb24c00dda4c672b0ae3b8d3a14be6c732738ecf459f02e01f170b7218eb'
7
- data.tar.gz: 361eb5a9641e852eb48d71bce99a8ca811ba50a364b497f62e13d9490c4456b34133a9e6d4bb4336c6c34e4e9a2335affdbb932e41e249b70faabda88911768f
6
+ metadata.gz: 130265b9d6ba0d8bc97441e8412732dd7118bf1727712288837ee176d38fbb56aea27534eb235341034d8083ac13f28b2898e98da82767d80f3fda01fbaecb44
7
+ data.tar.gz: 77b2eebd91c6a8f0572ecdfc16b6e8bfb902b7d643e4210af9cdd311f074967338e2ddb17eaa520ddeca3d731d72e9799bc856775014f8bb6b039317852d9a31
data/CHANGELOG.md ADDED
@@ -0,0 +1,53 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ ## [0.3.0]
6
+
7
+ ### Added
8
+
9
+ - New `ACP::Contrib` helpers: `ToolCallTracker`, `SessionAccumulator`,
10
+ `PermissionBroker` (+ `default_permission_options`).
11
+ - `Client#set_config_option` alias for `set_session_config_option`
12
+ (short alias for the same wire method).
13
+ - `Agent#create_elicitation` now also accepts `message:` + `mode:`
14
+ kwargs next to the full request hash/model.
15
+ - `Agent#write_text_file` / `#release_terminal` / `#kill_terminal`
16
+ return `nil` on null responses instead of an empty model.
17
+ - RuboCop config (`rake rubocop`), SimpleCov gate (90% minimum),
18
+ GitHub Actions matrix (Ruby 3.2, 3.3, 3.4, 4.0).
19
+
20
+ ### Fixed
21
+
22
+ - Works with any `async` 2.x: task cancellation supports both
23
+ `Async::Stop` (older) and `Async::Cancel` (newer); timed join no
24
+ longer relies on `Task#wait(timeout:)`. Previously `close` could
25
+ hang forever on older async releases.
26
+ - `MemoryTransport#close` signals EOF to the peer only; local reads
27
+ drain queued messages first.
28
+ - `NdjsonTransport` receive timeout now covers partial lines
29
+ (previously a line without `\n` hung forever).
30
+ - Outgoing observer events fire after a successful send and receive a
31
+ deep copy, so observers can neither see unsent payloads nor mutate
32
+ the wire format.
33
+ - `handle_response` prefers `result` over `error` when both are present
34
+ and resolves `nil` when neither is present.
35
+ - Parallel shutdown of handler tasks instead of sequential grace waits.
36
+ - `Router` no longer coerces missing (`nil`) params to `{}`; they fail
37
+ validation as `invalid_params` instead of being silently replaced.
38
+ - Schema models validate `required` fields in `.new`, reject
39
+ catch-all `Other*` values reserved by known variants
40
+ (`action: accept`, `mode: form`, …), and apply `default_on_error`
41
+ on direct construction too.
42
+ - Schema generator output is byte-identical on every Ruby
43
+ (`Hash#inspect` spacing changed in Ruby 3.4).
44
+
45
+ ### Compatibility
46
+
47
+ - Verified: full suite (189 tests) green on Ruby 3.2, 3.3, 3.4 and 4.0,
48
+ with `async` 2.37 and 2.45.
49
+
50
+ ## [0.2.0]
51
+
52
+ - Initial Ruby SDK snapshot: typed schema models, JSON-RPC connection
53
+ over NDJSON, client/agent wrappers, stdio process management.
data/README.md CHANGED
@@ -4,6 +4,8 @@ Ruby SDK for the [Agent Client Protocol](https://agentclientprotocol.com) (ACP):
4
4
 
5
5
  Requires Ruby 3.2+ and runs on [`async`](https://github.com/socketry/async), the same cooperative scheduler used by Falcon. The public API is blocking (`client.prompt(...)`) like a normal Ruby method; inside a reactor those calls yield to other tasks instead of occupying a thread.
6
6
 
7
+ Inspired by the [official Python ACP SDK](https://github.com/agentclientprotocol/python-sdk).
8
+
7
9
  ## Installation
8
10
 
9
11
  ```ruby
data/lib/acp/agent.rb CHANGED
@@ -108,7 +108,7 @@ module ACP
108
108
  end
109
109
 
110
110
  def write_text_file(session_id:, path:, content:, **meta)
111
- request(
111
+ request_optional(
112
112
  CLIENT_METHODS["fs_write_text_file"], Schema::WriteTextFileRequest, Schema::WriteTextFileResponse,
113
113
  session_id: session_id, path: path, content: content, **meta
114
114
  )
@@ -130,7 +130,7 @@ module ACP
130
130
  end
131
131
 
132
132
  def release_terminal(session_id:, terminal_id:, **meta)
133
- request(
133
+ request_optional(
134
134
  CLIENT_METHODS["terminal_release"], Schema::ReleaseTerminalRequest, Schema::ReleaseTerminalResponse,
135
135
  session_id: session_id, terminal_id: terminal_id, **meta
136
136
  )
@@ -145,14 +145,25 @@ module ACP
145
145
  end
146
146
 
147
147
  def kill_terminal(session_id:, terminal_id:, **meta)
148
- request(
148
+ request_optional(
149
149
  CLIENT_METHODS["terminal_kill"], Schema::KillTerminalRequest, Schema::KillTerminalResponse,
150
150
  session_id: session_id, terminal_id: terminal_id, **meta
151
151
  )
152
152
  end
153
153
 
154
- def create_elicitation(request)
155
- payload = Schema::CreateElicitationRequest.coerce(request)
154
+ # Accepts either a full request model/hash or message:+mode: kwargs.
155
+ # Mode is one of ElicitationFormSessionMode / ElicitationFormRequestMode /
156
+ # ElicitationUrlSessionMode / ElicitationUrlRequestMode.
157
+ def create_elicitation(request = nil, message: nil, mode: nil, **meta)
158
+ payload =
159
+ if !message.nil? || !mode.nil?
160
+ raise ArgumentError, "message: and mode: are both required" if message.nil? || mode.nil?
161
+ raise ArgumentError, "request must not be given with message:/mode:" unless request.nil?
162
+
163
+ build_elicitation_request(message, mode, meta)
164
+ else
165
+ Schema::CreateElicitationRequest.coerce(request)
166
+ end
156
167
  result = @conn.send_request(CLIENT_METHODS["elicitation_create"], payload)
157
168
  Schema::CreateElicitationResponse.coerce(result || {})
158
169
  end
@@ -178,6 +189,16 @@ module ACP
178
189
  response_class.coerce(result || {})
179
190
  end
180
191
 
192
+ # Optional responses: null / non-dict responses become nil
193
+ # instead of an empty model.
194
+ def request_optional(method, request_class, response_class, **kwargs)
195
+ payload = build(request_class, kwargs)
196
+ result = @conn.send_request(method, payload)
197
+ return nil unless result.is_a?(Hash)
198
+
199
+ response_class.coerce(result)
200
+ end
201
+
181
202
  def notify(method, request_class, **kwargs)
182
203
  @conn.send_notification(method, build(request_class, kwargs))
183
204
  end
@@ -188,6 +209,27 @@ module ACP
188
209
  model.field_meta = meta if meta
189
210
  model
190
211
  end
212
+
213
+ def build_elicitation_request(message, mode, meta)
214
+ mode_hash = mode.is_a?(Schema::Base) ? mode.to_h : Schema.serialize(mode)
215
+ mode_hash = mode_hash.transform_keys(&:to_s)
216
+ field_meta = meta.delete(:field_meta) || meta.delete(:meta)
217
+ # The mode models carry no "mode" discriminator (it lives on the
218
+ # request), so infer it from the mode class.
219
+ discriminator =
220
+ case mode
221
+ when Schema::ElicitationFormSessionMode, Schema::ElicitationFormRequestMode then "form"
222
+ when Schema::ElicitationUrlSessionMode, Schema::ElicitationUrlRequestMode then "url"
223
+ else mode_hash["mode"]
224
+ end
225
+ # Merge message + mode fields; the CreateElicitationRequest union
226
+ # dispatches to the correct form/url variant.
227
+ hash = { "message" => message }.merge(mode_hash).merge(meta.compact.transform_keys(&:to_s))
228
+ hash["mode"] = discriminator if discriminator
229
+ request = Schema::CreateElicitationRequest.coerce(hash)
230
+ request.field_meta = field_meta if field_meta
231
+ request
232
+ end
191
233
  end
192
234
 
193
235
  def self.run_agent(agent, input: $stdin, output: $stdout, **connection_options)
data/lib/acp/client.rb CHANGED
@@ -152,7 +152,7 @@ module ACP
152
152
  end
153
153
 
154
154
  def set_session_config_option(session_id:, config_id:, value:, **meta)
155
- request_class = if value == true || value == false
155
+ request_class = if [true, false].include?(value)
156
156
  Schema::SetSessionConfigOptionBooleanRequest
157
157
  else
158
158
  Schema::SetSessionConfigOptionSelectRequest
@@ -163,6 +163,9 @@ module ACP
163
163
  )
164
164
  end
165
165
 
166
+ # Short alias for the same wire method (session/set_config_option).
167
+ alias set_config_option set_session_config_option
168
+
166
169
  def prompt(session_id:, prompt:, **meta)
167
170
  request(AGENT_METHODS["session_prompt"], Schema::PromptRequest, Schema::PromptResponse,
168
171
  session_id: session_id, prompt: prompt, **meta)
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "json"
4
- require "set"
5
4
  require "async/queue"
6
5
  require_relative "exceptions"
7
6
  require_relative "schema_base"
@@ -34,7 +33,7 @@ module ACP
34
33
  end
35
34
 
36
35
  def closed?
37
- @closed
36
+ @mutex.synchronize { @closed }
38
37
  end
39
38
 
40
39
  def add_observer(callable = nil, &block)
@@ -74,11 +73,15 @@ module ACP
74
73
  end
75
74
 
76
75
  def send_request(method, params = nil, timeout: nil)
77
- raise ConnectionError, "Connection closed" if @closed
78
-
79
- request_id = next_id
76
+ request_id = nil
80
77
  promise = Wait::Promise.new
81
- @mutex.synchronize { @pending[request_id] = promise }
78
+ @mutex.synchronize do
79
+ raise ConnectionError, "Connection closed" if @closed
80
+
81
+ request_id = @next_id
82
+ @next_id += 1
83
+ @pending[request_id] = promise
84
+ end
82
85
 
83
86
  payload = { "jsonrpc" => "2.0", "id" => request_id, "method" => method }
84
87
  payload["params"] = serialize(params) unless params.nil?
@@ -99,7 +102,7 @@ module ACP
99
102
  end
100
103
 
101
104
  def send_notification(method, params = nil)
102
- raise ConnectionError, "Connection closed" if @closed
105
+ @mutex.synchronize { raise ConnectionError, "Connection closed" if @closed }
103
106
 
104
107
  payload = { "jsonrpc" => "2.0", "method" => method }
105
108
  payload["params"] = serialize(params) unless params.nil?
@@ -110,14 +113,23 @@ module ACP
110
113
  return true unless Wait.alive?(@notification_worker)
111
114
 
112
115
  latch = Wait::Latch.new
113
- @notification_queue.push(latch)
116
+ begin
117
+ @notification_queue.push(latch)
118
+ rescue ::Async::Queue::ClosedError
119
+ return false
120
+ end
114
121
  latch.wait(timeout)
115
122
  end
116
123
 
117
124
  def close
118
- return if @closed
125
+ already_closed = @mutex.synchronize do
126
+ next true if @closed
127
+
128
+ @closed = true
129
+ false
130
+ end
131
+ return if already_closed
119
132
 
120
- @closed = true
121
133
  reject_all(ConnectionError.new("Connection closed"))
122
134
  begin
123
135
  @transport.close
@@ -143,13 +155,15 @@ module ACP
143
155
  end
144
156
 
145
157
  def write(payload)
146
- notify_observers(:outgoing, payload)
158
+ # Notify AFTER successful send: notifying before would expose observers
159
+ # to messages that never hit the wire and let them mutate the payload.
147
160
  @transport.send_message(payload)
161
+ notify_observers(:outgoing, payload)
148
162
  end
149
163
 
150
164
  def receive_loop
151
165
  loop do
152
- break if @closed
166
+ break if closed?
153
167
 
154
168
  message = begin
155
169
  @transport.receive_message
@@ -159,7 +173,7 @@ module ACP
159
173
  rescue ConnectionError => e
160
174
  ACP.logger.debug("acp: receive failed: #{e.message}")
161
175
  break
162
- rescue ::Async::Cancel
176
+ rescue Wait::TASK_STOPPED
163
177
  break
164
178
  end
165
179
  break if message.nil?
@@ -210,7 +224,7 @@ module ACP
210
224
  payload["error"] = RequestError.invalid_params(
211
225
  "errors" => [{ "message" => e.message, "loc" => e.path }]
212
226
  ).to_error_obj
213
- rescue ::Async::Cancel
227
+ rescue Wait::TASK_STOPPED
214
228
  return
215
229
  rescue StandardError => e
216
230
  ACP.logger.error("acp: handler for #{message['method']} failed: #{e.class}: #{e.message}")
@@ -219,19 +233,19 @@ module ACP
219
233
  write(payload)
220
234
  rescue ConnectionError => e
221
235
  ACP.logger.debug("acp: could not send response for #{message['method']}: #{e.message}")
222
- rescue ::Async::Cancel
236
+ rescue Wait::TASK_STOPPED
223
237
  nil
224
238
  end
225
239
 
226
240
  def respond_error(id, error)
227
241
  write({ "jsonrpc" => "2.0", "id" => id, "error" => error.to_error_obj })
228
- rescue ConnectionError, ::Async::Cancel
242
+ rescue ConnectionError, Wait::TASK_STOPPED
229
243
  nil
230
244
  end
231
245
 
232
246
  def run_notification(message)
233
247
  @handler.call(message["method"], message["params"], true)
234
- rescue ::Async::Cancel
248
+ rescue Wait::TASK_STOPPED
235
249
  nil
236
250
  rescue StandardError => e
237
251
  ACP.logger.error("acp: notification handler for #{message['method']} failed: #{e.class}: #{e.message}")
@@ -243,12 +257,18 @@ module ACP
243
257
  ACP.logger.debug("acp: response for unknown request id #{message['id'].inspect}")
244
258
  return
245
259
  end
260
+ return if promise.settled?
246
261
 
247
- if message.key?("error")
262
+ # JSON-RPC result and error are mutually exclusive; when both are present
263
+ # prefer result.
264
+ if message.key?("result")
265
+ promise.resolve(message["result"])
266
+ elsif message.key?("error")
248
267
  error = message["error"] || {}
268
+ error = {} unless error.is_a?(Hash)
249
269
  promise.reject(RequestError.new(error["code"] || -32603, error["message"] || "Error", error["data"]))
250
270
  else
251
- promise.resolve(message["result"])
271
+ promise.resolve(nil)
252
272
  end
253
273
  end
254
274
 
@@ -258,14 +278,30 @@ module ACP
258
278
  @pending.clear
259
279
  items
260
280
  end
261
- pending.each { |promise| promise.reject(error) }
281
+ pending.each do |promise|
282
+ promise.reject(error) unless promise.settled?
283
+ rescue StandardError
284
+ nil
285
+ end
286
+ end
287
+
288
+ def deep_copy_message(message)
289
+ JSON.parse(JSON.generate(message))
290
+ rescue StandardError
291
+ begin
292
+ Marshal.load(Marshal.dump(message))
293
+ rescue StandardError
294
+ message.is_a?(Hash) ? message.dup : message
295
+ end
262
296
  end
263
297
 
264
298
  def notify_observers(direction, message)
265
299
  observers = @mutex.synchronize { @observers.dup }
266
300
  return if observers.empty?
267
301
 
268
- event = StreamEvent.new(direction: direction, message: message)
302
+ # Deep-copy once so observers cannot mutate the wire payload.
303
+ snapshot = deep_copy_message(message)
304
+ event = StreamEvent.new(direction: direction, message: snapshot)
269
305
  observers.each do |observer|
270
306
  observer.call(event)
271
307
  rescue StandardError => e
@@ -297,7 +333,7 @@ module ACP
297
333
 
298
334
  begin
299
335
  @notification_queue.push(nil)
300
- rescue ::Async::Queue::ClosedError, ClosedQueueError
336
+ rescue ::Async::Queue::ClosedError
301
337
  nil
302
338
  end
303
339
  return if Wait.current?(worker)
@@ -306,6 +342,8 @@ module ACP
306
342
  end
307
343
 
308
344
  def spawn_worker(name, &block)
345
+ return nil if closed?
346
+
309
347
  Wait.spawn(name) do
310
348
  me = Wait.current_worker
311
349
  @mutex.synchronize { @workers << me }
@@ -327,11 +365,12 @@ module ACP
327
365
  @workers.clear
328
366
  list
329
367
  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
368
+ # Stop all first, then join: parallel shutdown instead of N * grace.
369
+ # rubocop:disable Style/CombinableLoops -- merging the loops would serialize stop+join per worker
370
+ pending = workers.reject { |worker| Wait.current?(worker) }
371
+ pending.each { |worker| Wait.stop(worker) }
372
+ pending.each { |worker| Wait.join(worker, @worker_grace) }
373
+ # rubocop:enable Style/CombinableLoops
335
374
  listener = @listen_worker
336
375
  return if listener.nil? || Wait.current?(listener)
337
376
 
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../schema"
4
+ require_relative "tool_calls"
5
+
6
+ module ACP
7
+ module Contrib
8
+ class PermissionBrokerError < StandardError; end
9
+
10
+ class MissingToolCallError < PermissionBrokerError
11
+ def initialize
12
+ super("tool_call must be provided when no ToolCallTracker is configured")
13
+ end
14
+ end
15
+
16
+ class MissingPermissionOptionsError < PermissionBrokerError
17
+ def initialize
18
+ super("PermissionBroker requires at least one permission option")
19
+ end
20
+ end
21
+
22
+ def self.default_permission_options
23
+ [
24
+ Schema::PermissionOption.new(option_id: "approve", name: "Approve", kind: "allow_once"),
25
+ Schema::PermissionOption.new(option_id: "approve_for_session", name: "Approve for session", kind: "allow_always"),
26
+ Schema::PermissionOption.new(option_id: "reject", name: "Reject", kind: "reject_once")
27
+ ]
28
+ end
29
+
30
+ # Helper for issuing permission requests tied to tracked tool calls.
31
+ # The requester is a sync callable: response = requester.call(request).
32
+ class PermissionBroker
33
+ def initialize(session_id, requester, tracker: nil, default_options: nil)
34
+ @session_id = session_id
35
+ @requester = requester
36
+ @tracker = tracker
37
+ @default_options = (default_options || Contrib.default_permission_options).map do |option|
38
+ Contrib.deep_copy_model(option)
39
+ end
40
+ end
41
+
42
+ def request_for(external_id, description: nil, options: nil, content: nil, tool_call: nil)
43
+ resolved =
44
+ if tool_call.nil?
45
+ raise MissingToolCallError if @tracker.nil?
46
+
47
+ @tracker.tool_call_model(external_id)
48
+ else
49
+ Contrib.deep_copy_model(tool_call)
50
+ end
51
+
52
+ resolved.content = Contrib.copy_model_list(content) unless content.nil?
53
+
54
+ if description
55
+ existing = resolved.content || []
56
+ existing << Schema::ContentToolCallContent.new(
57
+ content: Schema::TextContentBlock.new(text: description)
58
+ )
59
+ resolved.content = existing
60
+ end
61
+
62
+ option_set = (options || @default_options).map { |option| Contrib.deep_copy_model(option) }
63
+ raise MissingPermissionOptionsError if option_set.empty?
64
+
65
+ request = Schema::RequestPermissionRequest.new(
66
+ session_id: @session_id,
67
+ tool_call: resolved,
68
+ options: option_set
69
+ )
70
+ @requester.call(request)
71
+ end
72
+ end
73
+ end
74
+ end