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 +4 -4
- data/CHANGELOG.md +53 -0
- data/README.md +2 -0
- data/lib/acp/agent.rb +47 -5
- data/lib/acp/client.rb +4 -1
- data/lib/acp/connection.rb +66 -27
- data/lib/acp/contrib/permissions.rb +74 -0
- data/lib/acp/contrib/session_state.rb +230 -0
- data/lib/acp/contrib/tool_calls.rb +240 -0
- data/lib/acp/exceptions.rb +8 -8
- data/lib/acp/router.rb +18 -15
- data/lib/acp/schema_base.rb +64 -20
- data/lib/acp/stdio.rb +1 -1
- data/lib/acp/transport.rb +40 -11
- data/lib/acp/version.rb +1 -1
- data/lib/acp/wait.rb +37 -5
- data/lib/acp_sdk_async.rb +3 -0
- metadata +5 -1
data/lib/acp/schema_base.rb
CHANGED
|
@@ -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])/) { "_#{
|
|
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
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
-
|
|
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
|
-
|
|
315
|
-
|
|
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
|
-
|
|
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, ::
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
138
|
+
return nil if closed? && @inbox.empty?
|
|
122
139
|
|
|
123
140
|
@inbox.pop
|
|
124
141
|
end
|
|
125
142
|
|
|
126
143
|
def close
|
|
127
|
-
|
|
144
|
+
should_signal = @mutex.synchronize do
|
|
145
|
+
next false if @closed
|
|
128
146
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
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
|
-
|
|
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
|
-
|
|
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
|
|
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
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.
|
|
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
|