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.
@@ -19,7 +19,7 @@ module ACP
19
19
  end
20
20
 
21
21
  def self.to_snake(camel)
22
- camel.to_s.gsub(/([A-Z])/) { "_#{$1.downcase}" }.sub(/\A_/, "")
22
+ camel.to_s.gsub(/([A-Z])/) { "_#{::Regexp.last_match(1).downcase}" }.sub(/\A_/, "")
23
23
  end
24
24
 
25
25
  def self.serialize(value)
@@ -75,7 +75,7 @@ module ACP
75
75
 
76
76
  fail!(value, path)
77
77
  when :boolean
78
- return value if value == true || value == false
78
+ return value if [true, false].include?(value)
79
79
 
80
80
  fail!(value, path)
81
81
  when :object
@@ -141,11 +141,9 @@ module ACP
141
141
 
142
142
  result = []
143
143
  value.each_with_index do |element, index|
144
- begin
145
- result << @item.coerce(element, path + [index])
146
- rescue ValidationError
147
- raise unless @skip_invalid
148
- end
144
+ result << @item.coerce(element, path + [index])
145
+ rescue ValidationError
146
+ raise unless @skip_invalid
149
147
  end
150
148
  result
151
149
  end
@@ -204,9 +202,7 @@ module ACP
204
202
  if @tag && value.is_a?(Hash)
205
203
  tag_value = read_tag(value)
206
204
  candidates = @tagged[tag_value] if tag_value
207
- if candidates && !candidates.empty?
208
- return try_variants(candidates, value, path) { |error| raise error }
209
- end
205
+ return try_variants(candidates, value, path) { |error| raise error } if candidates && !candidates.empty?
210
206
  end
211
207
 
212
208
  try_variants(@untagged, value, path) do
@@ -254,6 +250,17 @@ module ACP
254
250
  keyword_init: true
255
251
  )
256
252
 
253
+ # Catch-all Other* variants must reject values reserved by known
254
+ # variants, otherwise unions would accept malformed payloads
255
+ # (e.g. action=accept in Other).
256
+ RESERVED_TAGS = {
257
+ "CreateOtherSessionElicitationRequest" => ["mode", %w[form url].freeze],
258
+ "CreateOtherRequestElicitationRequest" => ["mode", %w[form url].freeze],
259
+ "OtherElicitationResponse" => ["action", %w[accept cancel decline].freeze],
260
+ "ElicitationOtherPropertySchema" => ["type", %w[array boolean integer number string].freeze],
261
+ "OtherMultiSelectItems" => ["type", %w[string].freeze]
262
+ }.freeze
263
+
257
264
  class << self
258
265
  def fields
259
266
  @fields ||= superclass.respond_to?(:fields) ? superclass.fields.dup : []
@@ -276,6 +283,7 @@ module ACP
276
283
  default_on_error: default_on_error
277
284
  )
278
285
  attr_reader name
286
+
279
287
  define_method(:"#{name}=") do |value|
280
288
  instance_variable_set(:"@#{name}", value)
281
289
  @set_fields << name
@@ -288,6 +296,7 @@ module ACP
288
296
 
289
297
  instance = allocate
290
298
  instance.send(:load_from_hash, value, path)
299
+ instance.send(:check_reserved_tags!, path)
291
300
  instance
292
301
  end
293
302
 
@@ -300,7 +309,7 @@ module ACP
300
309
  alias parse coerce
301
310
  end
302
311
 
303
- attr_reader :field_meta
312
+ attr_accessor :field_meta
304
313
 
305
314
  def initialize(**kwargs)
306
315
  @set_fields = []
@@ -311,18 +320,36 @@ module ACP
311
320
  self.class.fields.each do |f|
312
321
  if kwargs.key?(f.name)
313
322
  value = kwargs[f.name]
314
- value = f.type.coerce(value, [f.key]) unless value.nil?
315
- assign(f, value, set: !value.nil?)
323
+ if value.nil?
324
+ if f.const
325
+ assign(f, f.const, set: true)
326
+ elsif f.required && !f.default_on_error
327
+ raise ValidationError.new("missing required field #{f.key}", [f.key])
328
+ else
329
+ assign(f, default_for(f), set: false)
330
+ end
331
+ else
332
+ begin
333
+ coerced = f.type.coerce(value, [f.key])
334
+ rescue ValidationError
335
+ raise unless f.default_on_error
336
+
337
+ assign(f, default_for(f), set: false)
338
+ next
339
+ end
340
+ raise ValidationError.new("expected #{f.const.inspect}, got #{coerced.inspect}", [f.key]) if f.const && coerced != f.const
341
+
342
+ assign(f, coerced, set: true)
343
+ end
316
344
  elsif f.const
317
345
  assign(f, f.const, set: true)
346
+ elsif f.required
347
+ raise ValidationError.new("missing required field #{f.key}", [f.key])
318
348
  else
319
349
  assign(f, default_for(f), set: false)
320
350
  end
321
351
  end
322
- end
323
-
324
- def field_meta=(value)
325
- @field_meta = value
352
+ check_reserved_tags!
326
353
  end
327
354
 
328
355
  def set?(name)
@@ -418,9 +445,7 @@ module ACP
418
445
 
419
446
  def load_present(field, raw, path)
420
447
  value = field.type.coerce(raw, path + [field.key])
421
- if field.const && value != field.const
422
- raise ValidationError.new("expected #{field.const.inspect}, got #{value.inspect}", path + [field.key])
423
- end
448
+ raise ValidationError.new("expected #{field.const.inspect}, got #{value.inspect}", path + [field.key]) if field.const && value != field.const
424
449
 
425
450
  assign(field, value, set: true)
426
451
  rescue ValidationError
@@ -435,6 +460,25 @@ module ACP
435
460
  end
436
461
  [false, nil]
437
462
  end
463
+
464
+ def check_reserved_tags!(path = [])
465
+ rule = RESERVED_TAGS[self.class.name.split("::").last]
466
+ return unless rule
467
+
468
+ wire_field, reserved = rule
469
+ field = self.class.fields.find { |f| f.key == wire_field || f.name.to_s == wire_field }
470
+ return unless field
471
+
472
+ value = instance_variable_get(:"@#{field.name}")
473
+ return if value.nil?
474
+
475
+ return unless reserved.include?(value)
476
+
477
+ raise ValidationError.new(
478
+ "#{wire_field} value is reserved by a known variant",
479
+ path + [field.key]
480
+ )
481
+ end
438
482
  end
439
483
  end
440
484
  end
data/lib/acp/stdio.rb CHANGED
@@ -68,7 +68,7 @@ module ACP
68
68
  line = line.scrub unless line.valid_encoding?
69
69
  ACP.logger.info("acp[#{label}] stderr: #{line.chomp}")
70
70
  end
71
- rescue IOError, Errno::EBADF, ::Async::Cancel
71
+ rescue IOError, Errno::EBADF, Wait::TASK_STOPPED
72
72
  nil
73
73
  ensure
74
74
  io.close unless io.closed?
data/lib/acp/transport.rb CHANGED
@@ -84,11 +84,23 @@ module ACP
84
84
  private
85
85
 
86
86
  def read_line
87
- if @receive_timeout && @input.respond_to?(:wait_readable) && !@input.wait_readable(@receive_timeout)
88
- raise TimeoutError, "No message received within #{@receive_timeout}s"
89
- end
87
+ return @input.gets unless @receive_timeout
88
+
89
+ if Wait.async?
90
+ begin
91
+ ::Async::Task.current.with_timeout(@receive_timeout) { @input.gets }
92
+ rescue ::Async::TimeoutError
93
+ raise TimeoutError, "No message received within #{@receive_timeout}s"
94
+ end
95
+ elsif @input.respond_to?(:wait_readable)
96
+ raise TimeoutError, "No message received within #{@receive_timeout}s" unless @input.wait_readable(@receive_timeout)
97
+
98
+ @input.gets
99
+ else
100
+ raise TimeoutError, "No message received within #{@receive_timeout}s" unless IO.select([@input], nil, nil, @receive_timeout)
90
101
 
91
- @input.gets
102
+ @input.gets
103
+ end
92
104
  end
93
105
  end
94
106
 
@@ -104,31 +116,48 @@ module ACP
104
116
  def initialize(inbox, outbox)
105
117
  @inbox = inbox
106
118
  @outbox = outbox
119
+ @mutex = Mutex.new
107
120
  @closed = false
108
121
  end
109
122
 
110
123
  def closed?
111
- @closed
124
+ @mutex.synchronize { @closed }
112
125
  end
113
126
 
114
127
  def send_message(message)
115
- raise ConnectionError, "Transport is closed" if @closed
128
+ raise ConnectionError, "Transport is closed" if closed?
116
129
 
130
+ # JSON round-trip simulates the wire: isolates mutation between peers
131
+ # and normalizes symbol keys to strings, like NDJSON framing does.
117
132
  @outbox.push(JSON.parse(JSON.generate(message)))
133
+ rescue ::Async::Queue::ClosedError
134
+ raise ConnectionError, "Transport is closed"
118
135
  end
119
136
 
120
137
  def receive_message
121
- return nil if @closed && @inbox.empty?
138
+ return nil if closed? && @inbox.empty?
122
139
 
123
140
  @inbox.pop
124
141
  end
125
142
 
126
143
  def close
127
- return if @closed
144
+ should_signal = @mutex.synchronize do
145
+ next false if @closed
128
146
 
129
- @closed = true
130
- @outbox.push(nil)
131
- @inbox.push(nil)
147
+ @closed = true
148
+ true
149
+ end
150
+ return unless should_signal
151
+
152
+ # Signal EOF to the peer only. The local receive loop unblocks via
153
+ # task cancellation (Connection#stop_workers) or via the closed?+empty?
154
+ # fast path in receive_message above. Pushing nil to our own inbox
155
+ # would discard ordering guarantees, so we deliberately avoid it.
156
+ begin
157
+ @outbox.push(nil)
158
+ rescue ::Async::Queue::ClosedError
159
+ nil
160
+ end
132
161
  end
133
162
  end
134
163
  end
data/lib/acp/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ACP
4
- VERSION = "0.2.0"
4
+ VERSION = "0.3.0"
5
5
  end
data/lib/acp/wait.rb CHANGED
@@ -15,6 +15,18 @@ module ACP
15
15
  end
16
16
 
17
17
  module Wait
18
+ # Task cancellation exception renamed across async 2.x:
19
+ # older releases raise Async::Stop, newer raise Async::Cancel
20
+ # (with Stop kept as an alias). Pick whichever exists.
21
+ TASK_STOPPED =
22
+ if defined?(::Async::Cancel)
23
+ ::Async::Cancel
24
+ elsif defined?(::Async::Stop)
25
+ ::Async::Stop
26
+ else
27
+ StandardError
28
+ end
29
+
18
30
  def self.async?
19
31
  !::Async::Task.current?.nil?
20
32
  rescue RuntimeError
@@ -51,8 +63,12 @@ module ACP
51
63
  def self.stop(worker)
52
64
  return if worker.nil? || worker.finished?
53
65
 
54
- worker.stop
55
- rescue ::Async::Cancel, StandardError
66
+ if worker.respond_to?(:stop)
67
+ worker.stop
68
+ else
69
+ worker.cancel
70
+ end
71
+ rescue TASK_STOPPED, StandardError
56
72
  nil
57
73
  end
58
74
 
@@ -61,11 +77,24 @@ module ACP
61
77
 
62
78
  if timeout.nil?
63
79
  worker.wait
80
+ elsif async?
81
+ # Task#wait(timeout:) only exists on newer async; with_timeout
82
+ # around a blocking wait works on every 2.x.
83
+ begin
84
+ ::Async::Task.current.with_timeout(timeout) { worker.wait }
85
+ rescue ::Async::TimeoutError
86
+ return false
87
+ end
64
88
  else
65
- worker.wait(timeout: timeout)
89
+ require "timeout" unless defined?(::Timeout)
90
+ begin
91
+ ::Timeout.timeout(timeout) { worker.wait }
92
+ rescue ::Timeout::Error
93
+ return false
94
+ end
66
95
  end
67
96
  true
68
- rescue ::Async::Cancel
97
+ rescue TASK_STOPPED
69
98
  true
70
99
  rescue ::Async::TimeoutError, TimeoutError
71
100
  false
@@ -103,8 +132,11 @@ module ACP
103
132
  @promise = ::Async::Promise.new
104
133
  end
105
134
 
135
+ # Async::Promise#resolved? is true after either resolve or reject
136
+ # (completed? is only true for successful resolution), so it is the
137
+ # correct settled check. Fall back to failed?/completed? for safety.
106
138
  def settled?
107
- @promise.resolved?
139
+ @promise.resolved? || @promise.failed? || @promise.completed?
108
140
  end
109
141
 
110
142
  def resolve(value)
data/lib/acp_sdk_async.rb CHANGED
@@ -12,3 +12,6 @@ require_relative "acp/router"
12
12
  require_relative "acp/client"
13
13
  require_relative "acp/agent"
14
14
  require_relative "acp/stdio"
15
+ require_relative "acp/contrib/tool_calls"
16
+ require_relative "acp/contrib/session_state"
17
+ require_relative "acp/contrib/permissions"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: acp_sdk_async
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - nutsoriginal
@@ -43,11 +43,15 @@ executables: []
43
43
  extensions: []
44
44
  extra_rdoc_files: []
45
45
  files:
46
+ - CHANGELOG.md
46
47
  - LICENSE
47
48
  - README.md
48
49
  - lib/acp/agent.rb
49
50
  - lib/acp/client.rb
50
51
  - lib/acp/connection.rb
52
+ - lib/acp/contrib/permissions.rb
53
+ - lib/acp/contrib/session_state.rb
54
+ - lib/acp/contrib/tool_calls.rb
51
55
  - lib/acp/exceptions.rb
52
56
  - lib/acp/meta.rb
53
57
  - lib/acp/router.rb