acp-sdk 0.13.0 → 0.14.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.
Files changed (39) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +16 -0
  3. data/README.md +141 -69
  4. data/lib/acp/agent_connection/client.rb +2 -3
  5. data/lib/acp/agent_connection/optional_route.rb +45 -0
  6. data/lib/acp/agent_connection.rb +120 -76
  7. data/lib/acp/agent_process.rb +4 -4
  8. data/lib/acp/client_connection/elicitations.rb +44 -0
  9. data/lib/acp/client_connection/inbound/route.rb +29 -0
  10. data/lib/acp/client_connection/inbound.rb +232 -0
  11. data/lib/acp/client_connection/streams.rb +20 -52
  12. data/lib/acp/client_connection.rb +34 -309
  13. data/lib/acp/extensions.rb +6 -0
  14. data/lib/acp/protocol.rb +14 -4
  15. data/lib/acp/transport/reply.rb +2 -2
  16. data/lib/acp/transport/stdio.rb +2 -2
  17. data/lib/acp/types/parse_error.rb +1 -1
  18. data/lib/acp/types.rb +13 -0
  19. data/lib/acp/version.rb +1 -1
  20. data/sig/generated/acp/agent_connection/optional_route.rbs +35 -0
  21. data/sig/generated/acp/agent_connection.rbs +5 -21
  22. data/sig/generated/acp/client_connection/elicitations.rbs +29 -0
  23. data/sig/generated/acp/client_connection/inbound/route.rbs +26 -0
  24. data/sig/generated/acp/client_connection/inbound.rbs +90 -0
  25. data/sig/generated/acp/client_connection/streams.rbs +7 -28
  26. data/sig/generated/acp/client_connection.rbs +12 -136
  27. data/sig/generated/acp/extensions.rbs +4 -0
  28. data/sig/generated/acp/protocol.rbs +8 -0
  29. data/sig/generated/acp/transport/reply.rbs +3 -3
  30. data/sig/generated/acp/transport/stdio.rbs +4 -4
  31. data/sig/generated/acp/types.rbs +9 -0
  32. data/sig/manual/acp/agent_connection.rbs +5 -57
  33. data/sig/manual/acp/client_connection.rbs +27 -0
  34. data/sig/manual/acp/transport/stdio.rbs +1 -5
  35. metadata +9 -5
  36. data/lib/acp/agent_connection/optional_method/spec.rb +0 -42
  37. data/lib/acp/agent_connection/optional_method.rb +0 -40
  38. data/sig/generated/acp/agent_connection/optional_method/spec.rbs +0 -32
  39. data/sig/generated/acp/agent_connection/optional_method.rbs +0 -7
@@ -1,8 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # It holds no send facade of its own — requests go through the sender and
4
- # answers to the agent ride the connection's routes — so it is unit-testable
5
- # without a transport.
6
3
  class ACP::ClientConnection::Streams
7
4
  CANCELLED = ACP::Types::RequestPermissionResponse.new(
8
5
  outcome: ACP::Types::RequestPermissionOutcome::Cancelled.new
@@ -11,29 +8,24 @@ class ACP::ClientConnection::Streams
11
8
  # @rbs @sender: ACP::Transport::Stdio
12
9
  # @rbs @permission: ACP::ClientConnection::_PermissionHandler
13
10
  # @rbs @updates: ACP::ClientConnection::_UpdateHandler
14
- # @rbs @complete_elicitation: (^(ACP::Types::CompleteElicitationNotification) -> void)?
15
11
  # @rbs @logger: ACP::Transport::_Logger
16
12
  # @rbs @lock: Thread::Mutex
17
13
  # @rbs @streams: Hash[String, Thread::Queue]
18
14
  # @rbs @pending_permissions: Hash[String, Array[Thread::Queue]]
19
- # @rbs @outstanding_elicitations: Hash[String, bool]
20
15
 
21
16
  # @rbs sender: ACP::Transport::Stdio
22
17
  # @rbs permission: ACP::ClientConnection::_PermissionHandler
23
18
  # @rbs updates: ACP::ClientConnection::_UpdateHandler
24
19
  # @rbs logger: ACP::Transport::_Logger
25
- # @rbs complete_elicitation: (^(ACP::Types::CompleteElicitationNotification) -> void)?
26
20
  # @rbs return: void
27
- def initialize(sender:, permission:, updates:, logger:, complete_elicitation: nil)
21
+ def initialize(sender:, permission:, updates:, logger:)
28
22
  @sender = sender
29
23
  @permission = permission
30
24
  @updates = updates
31
- @complete_elicitation = complete_elicitation
32
25
  @logger = logger
33
26
  @lock = Mutex.new
34
27
  @streams = {}
35
28
  @pending_permissions = {}
36
- @outstanding_elicitations = {}
37
29
  end
38
30
 
39
31
  # The request waits on its own thread so the caller's thread can yield each
@@ -80,12 +72,13 @@ class ACP::ClientConnection::Streams
80
72
  # @rbs params: untyped
81
73
  # @rbs return: void
82
74
  def dispatch(params)
83
- notification = ACP::Types::SessionNotification.from_h(params)
84
- rescue ACP::Types::ParseError, KeyError, TypeError, NoMethodError => e
85
- @logger.warn("dropped malformed session/update: #{e.class}: #{e.message}")
86
- else
87
- queue = @lock.synchronize { @streams[notification.session_id]&.push(notification.update) }
88
- @updates.call(notification) unless queue
75
+ notification = ACP::Types.parse(ACP::Types::SessionNotification, params)
76
+ if notification.is_a?(ACP::Types::ParseError)
77
+ @logger.warn("dropped malformed session/update: #{notification.message}")
78
+ else
79
+ queue = @lock.synchronize { @streams[notification.session_id]&.push(notification.update) }
80
+ @updates.call(notification) unless queue
81
+ end
89
82
  end
90
83
 
91
84
  # The handler runs on its own thread so a cancelled request is answered
@@ -95,12 +88,9 @@ class ACP::ClientConnection::Streams
95
88
  # @rbs params: untyped
96
89
  # @rbs return: (ACP::Types::RequestPermissionResponse | ACP::RequestError)
97
90
  def request_permission(params)
98
- request = ACP::Types::RequestPermissionRequest.from_h(params)
99
- rescue ACP::Types::ParseError => e
100
- ACP::RequestError.invalid_params([e.message])
101
- rescue KeyError, TypeError, NoMethodError
102
- ACP::RequestError.invalid_params
103
- else
91
+ request = ACP::Types.parse(ACP::Types::RequestPermissionRequest, params)
92
+ return ACP::RequestError.invalid_params([request.message]) if request.is_a?(ACP::Types::ParseError)
93
+
104
94
  replies = Thread::Queue.new
105
95
  register_permission(request.session_id, replies)
106
96
  # The handler runs on the transport's serve thread, so the serving
@@ -108,22 +98,22 @@ class ACP::ClientConnection::Streams
108
98
  serving = @sender.cancellation
109
99
  Thread.new do
110
100
  ACP::Transport::Cancellation.current = serving
111
- # A raised StandardError is not a returned ACP::RequestError, so the two
112
- # need distinct shapes on the queue.
113
101
  replies << begin
114
- [:value, @permission.call(request)]
102
+ @permission.call(request)
115
103
  rescue StandardError => e
116
- [:raised, e]
104
+ e
117
105
  end
118
106
  ensure
119
107
  unregister_permission(request.session_id, replies)
120
108
  end
121
109
  settled = replies.pop
122
- # Re-raised so the transport's invoke turns it into an error reply, as it
123
- # would if the handler ran on the serve thread itself.
124
- raise settled[1] if settled[0] == :raised
110
+ # A handler answers with a response or an ACP::RequestError, neither of
111
+ # them an exception, so one on the queue was raised. Re-raised so the
112
+ # transport's invoke turns it into an error reply, as it would if the
113
+ # handler ran on the serve thread itself.
114
+ raise settled if settled.is_a?(StandardError)
125
115
 
126
- settled[1]
116
+ settled
127
117
  end
128
118
 
129
119
  # The spec requires a pending session/request_permission to be answered with
@@ -134,29 +124,7 @@ class ACP::ClientConnection::Streams
134
124
  # @rbs return: void
135
125
  def cancel(session_id)
136
126
  pending = @lock.synchronize { @pending_permissions.delete(session_id) }
137
- pending&.each { |replies| replies << [:value, CANCELLED] }
138
- end
139
-
140
- # @rbs elicitation_id: String
141
- # @rbs return: void
142
- def register_elicitation(elicitation_id)
143
- @lock.synchronize { @outstanding_elicitations[elicitation_id] = true }
144
- end
145
-
146
- # The spec requires a completion for an unknown or already-completed
147
- # elicitation id to be ignored, so only an id registered by a served
148
- # url-mode request reaches the handler. It runs on the transport's reader
149
- # thread, so it must return quickly.
150
- #
151
- # @rbs params: untyped
152
- # @rbs return: void
153
- def complete_elicitation(params)
154
- notification = ACP::Types::CompleteElicitationNotification.from_h(params)
155
- rescue ACP::Types::ParseError, KeyError, TypeError, NoMethodError => e
156
- @logger.warn("dropped malformed elicitation/complete: #{e.class}: #{e.message}")
157
- else
158
- known = @lock.synchronize { @outstanding_elicitations.delete(notification.elicitation_id) }
159
- @complete_elicitation&.call(notification) if known
127
+ pending&.each { |replies| replies << CANCELLED }
160
128
  end
161
129
 
162
130
  private
@@ -2,222 +2,62 @@
2
2
 
3
3
  class ACP::ClientConnection
4
4
  # @rbs @transport: ACP::Transport::Stdio
5
- # @rbs @permission: ACP::ClientConnection::_PermissionHandler
6
- # @rbs @read_text_file: (^(ACP::Types::ReadTextFileRequest) -> (ACP::Types::ReadTextFileResponse | ACP::RequestError))?
7
- # @rbs @write_text_file: (^(ACP::Types::WriteTextFileRequest) -> (ACP::Types::WriteTextFileResponse | ACP::RequestError))?
8
- # @rbs @create_terminal: (^(ACP::Types::CreateTerminalRequest) -> (ACP::Types::CreateTerminalResponse | ACP::RequestError))?
9
- # @rbs @terminal_output: (^(ACP::Types::TerminalOutputRequest) -> (ACP::Types::TerminalOutputResponse | ACP::RequestError))?
10
- # @rbs @wait_for_terminal_exit: (^(ACP::Types::WaitForTerminalExitRequest) ->
11
- # (ACP::Types::WaitForTerminalExitResponse | ACP::RequestError))?
12
- # @rbs @kill_terminal: (^(ACP::Types::KillTerminalRequest) -> (ACP::Types::KillTerminalResponse | ACP::RequestError))?
13
- # @rbs @release_terminal: (^(ACP::Types::ReleaseTerminalRequest) -> (ACP::Types::ReleaseTerminalResponse | ACP::RequestError))?
14
- # @rbs @elicitation: (^(ACP::Types::CreateElicitationRequest) ->
15
- # (ACP::Types::CreateElicitationResponse | ACP::RequestError))?
16
- # @rbs @mcp_message: (^(ACP::Types::Unstable::MessageMcpRequest) ->
17
- # (ACP::Types::Unstable::MessageMcpResponse::t | ACP::RequestError))?
18
- # @rbs @mcp_advertised: bool?
19
- # @rbs @fs_capabilities: ACP::Types::FileSystemCapabilities?
20
- # @rbs @terminal: bool?
21
- # @rbs @elicitation_capabilities: ACP::Types::ElicitationCapabilities?
22
- # @rbs @extension_requests: Hash[String, ^(untyped) -> untyped]
23
- # @rbs @extension_notifications: Hash[String, ^(untyped) -> void]
24
5
  # @rbs @streams: ACP::ClientConnection::Streams
25
- # @rbs @logger: ACP::Transport::_Logger
6
+ # @rbs @inbound: ACP::ClientConnection::Inbound
26
7
 
27
8
  IGNORE = ->(_notification) {} #: ^(ACP::Types::SessionNotification) -> void
28
9
 
29
- class Route
30
- # @rbs @wire: String
31
- # @rbs @type: ACP::Transport::_Parser
32
- # @rbs @handler: Symbol
33
- # @rbs @family: Symbol
34
- # @rbs @advertised: ^(untyped) -> (bool | nil)
35
- # @rbs @refuse: Symbol
36
- # @rbs @key: String
37
- # @rbs @label: String
38
-
39
- # @rbs wire: String
40
- # @rbs type: ACP::Transport::_Parser
41
- # @rbs handler: Symbol
42
- # @rbs family: Symbol
43
- # @rbs key: String
44
- # @rbs advertised: ^(untyped) -> (bool | nil)
45
- # @rbs refuse: Symbol
46
- # @rbs label: String?
47
- # @rbs return: void
48
- def initialize(
49
- wire:,
50
- type:,
51
- handler:,
52
- family:,
53
- key:,
54
- advertised: ->(caps) { caps },
55
- refuse: :unadvertised,
56
- label: nil
57
- )
58
- @wire = wire
59
- @type = type
60
- @handler = handler
61
- @family = family
62
- @advertised = advertised
63
- @refuse = refuse
64
- @key = key
65
- # The capability key connect's pre-flight names; it differs from the
66
- # refusal key where a family gates several routes at once.
67
- @label = label || key
68
- freeze
69
- end
70
-
71
- # @dynamic wire, type, handler, family, advertised, refuse, key, label
72
- attr_reader :wire #: String
73
- attr_reader :type #: ACP::Transport::_Parser
74
- attr_reader :handler #: Symbol
75
- attr_reader :family #: Symbol
76
- attr_reader :advertised #: ^(untyped) -> (bool | nil)
77
- attr_reader :refuse #: Symbol
78
- attr_reader :key #: String
79
- attr_reader :label #: String
80
- end
81
-
82
- # The routes are registered at start, before connect records the
83
- # advertisement, so each one answers its refusal until then. The terminal
84
- # capability is a single bool, so its five routes share one gate.
85
- #
86
- # The terminal handlers may block for as long as the command runs, so the
87
- # transport must serve each request on its own thread for this not to hold
88
- # up others.
89
- SERVE = [
90
- Route.new(
91
- wire: 'fs/read_text_file',
92
- type: ACP::Types::ReadTextFileRequest,
93
- handler: :@read_text_file,
94
- family: :@fs_capabilities,
95
- advertised: ->(caps) { caps&.read_text_file },
96
- key: 'fs.readTextFile'
97
- ),
98
- Route.new(
99
- wire: 'fs/write_text_file',
100
- type: ACP::Types::WriteTextFileRequest,
101
- handler: :@write_text_file,
102
- family: :@fs_capabilities,
103
- advertised: ->(caps) { caps&.write_text_file },
104
- key: 'fs.writeTextFile'
105
- ),
106
- Route.new(
107
- wire: 'terminal/create',
108
- type: ACP::Types::CreateTerminalRequest,
109
- handler: :@create_terminal,
110
- family: :@terminal,
111
- key: 'terminal',
112
- label: 'terminal.create'
113
- ),
114
- Route.new(
115
- wire: 'terminal/output',
116
- type: ACP::Types::TerminalOutputRequest,
117
- handler: :@terminal_output,
118
- family: :@terminal,
119
- key: 'terminal',
120
- label: 'terminal.output'
121
- ),
122
- Route.new(
123
- wire: 'terminal/wait_for_exit',
124
- type: ACP::Types::WaitForTerminalExitRequest,
125
- handler: :@wait_for_terminal_exit,
126
- family: :@terminal,
127
- key: 'terminal',
128
- label: 'terminal.wait_for_exit'
129
- ),
130
- Route.new(
131
- wire: 'terminal/kill',
132
- type: ACP::Types::KillTerminalRequest,
133
- handler: :@kill_terminal,
134
- family: :@terminal,
135
- key: 'terminal',
136
- label: 'terminal.kill'
137
- ),
138
- Route.new(
139
- wire: 'terminal/release',
140
- type: ACP::Types::ReleaseTerminalRequest,
141
- handler: :@release_terminal,
142
- family: :@terminal,
143
- key: 'terminal',
144
- label: 'terminal.release'
145
- )
146
- ].freeze #: Array[ACP::ClientConnection::Route]
147
-
148
10
  # @rbs transport: ACP::Transport::Stdio
149
11
  # @rbs permission: ACP::ClientConnection::_PermissionHandler
150
- # @rbs read_text_file: (^(ACP::Types::ReadTextFileRequest) -> (ACP::Types::ReadTextFileResponse | ACP::RequestError))?
151
- # @rbs write_text_file: (^(ACP::Types::WriteTextFileRequest) -> (ACP::Types::WriteTextFileResponse | ACP::RequestError))?
152
- # @rbs create_terminal: (^(ACP::Types::CreateTerminalRequest) -> (ACP::Types::CreateTerminalResponse | ACP::RequestError))?
153
- # @rbs terminal_output: (^(ACP::Types::TerminalOutputRequest) -> (ACP::Types::TerminalOutputResponse | ACP::RequestError))?
154
- # @rbs wait_for_terminal_exit: (^(ACP::Types::WaitForTerminalExitRequest) ->
155
- # (ACP::Types::WaitForTerminalExitResponse | ACP::RequestError))?
156
- # @rbs kill_terminal: (^(ACP::Types::KillTerminalRequest) -> (ACP::Types::KillTerminalResponse | ACP::RequestError))?
157
- # @rbs release_terminal: (^(ACP::Types::ReleaseTerminalRequest) -> (ACP::Types::ReleaseTerminalResponse | ACP::RequestError))?
158
- # @rbs elicitation: (^(ACP::Types::CreateElicitationRequest) ->
159
- # (ACP::Types::CreateElicitationResponse | ACP::RequestError))?
160
- # @rbs complete_elicitation: (^(ACP::Types::CompleteElicitationNotification) -> void)?
161
12
  # @rbs updates: ACP::ClientConnection::_UpdateHandler
13
+ # @rbs fs: ACP::ClientConnection::_FileSystem?
14
+ # @rbs terminal: ACP::ClientConnection::_Terminal?
15
+ # @rbs elicitation: ACP::ClientConnection::_Elicitation?
162
16
  # @rbs mcp_message: (^(ACP::Types::Unstable::MessageMcpRequest) ->
163
17
  # (ACP::Types::Unstable::MessageMcpResponse::t | ACP::RequestError))?
164
18
  # @rbs extension_requests: Hash[String, ^(untyped) -> untyped]
165
19
  # @rbs extension_notifications: Hash[String, ^(untyped) -> void]
166
- # @rbs streams: ACP::ClientConnection::Streams?
167
20
  # @rbs logger: ACP::Transport::_Logger
168
- # @rbs return: void
169
- def initialize(
21
+ # @rbs return: ACP::ClientConnection
22
+ def self.build(
170
23
  transport:,
171
24
  permission:,
172
- read_text_file: nil,
173
- write_text_file: nil,
174
- create_terminal: nil,
175
- terminal_output: nil,
176
- wait_for_terminal_exit: nil,
177
- kill_terminal: nil,
178
- release_terminal: nil,
179
- elicitation: nil,
180
- complete_elicitation: nil,
181
25
  updates: IGNORE,
26
+ fs: nil, # rubocop:disable Naming/MethodParameterName -- named after ClientCapabilities#fs
27
+ terminal: nil,
28
+ elicitation: nil,
182
29
  mcp_message: nil,
183
30
  extension_requests: {},
184
31
  extension_notifications: {},
185
- streams: nil,
186
32
  logger: ACP::Transport::StderrLogger.new
187
33
  )
34
+ inbound = Inbound.new(
35
+ elicitations: Elicitations.new(handler: elicitation, logger:),
36
+ fs:,
37
+ terminal:,
38
+ elicitation:,
39
+ mcp_message:,
40
+ extension_requests:,
41
+ extension_notifications:
42
+ )
43
+ new(transport:, streams: Streams.new(sender: transport, permission:, updates:, logger:), inbound:)
44
+ end
45
+
46
+ # @rbs transport: ACP::Transport::Stdio
47
+ # @rbs streams: ACP::ClientConnection::Streams
48
+ # @rbs inbound: ACP::ClientConnection::Inbound
49
+ # @rbs return: void
50
+ def initialize(transport:, streams:, inbound:)
188
51
  @transport = transport
189
- @read_text_file = read_text_file
190
- @write_text_file = write_text_file
191
- @create_terminal = create_terminal
192
- @terminal_output = terminal_output
193
- @wait_for_terminal_exit = wait_for_terminal_exit
194
- @kill_terminal = kill_terminal
195
- @release_terminal = release_terminal
196
- @elicitation = elicitation
197
- @mcp_message = mcp_message
198
- @mcp_advertised = nil
199
- @fs_capabilities = nil
200
- @terminal = nil
201
- @elicitation_capabilities = nil
202
- @extension_requests = extension_requests
203
- @extension_notifications = extension_notifications
204
- extension_requests.each_key { |name| ACP::Extensions.validate_name(name) }
205
- extension_notifications.each_key { |name| ACP::Extensions.validate_name(name) }
206
- @streams = streams || Streams.new(sender: transport, permission:, updates:, logger:, complete_elicitation:)
207
- @logger = logger
52
+ @streams = streams
53
+ @inbound = inbound
208
54
  end
209
55
 
210
56
  # @rbs return: Thread
211
57
  def start
212
58
  @transport.start(
213
- requests: {
214
- 'session/request_permission' => @streams.method(:request_permission),
215
- 'elicitation/create' => method(:create_elicitation)
216
- }.merge(SERVE.to_h { |route| [route.wire, serve(route)] }).merge(mcp_requests).merge(@extension_requests),
217
- notifications: {
218
- 'session/update' => @streams.method(:dispatch),
219
- 'elicitation/complete' => @streams.method(:complete_elicitation)
220
- }.merge(@extension_notifications)
59
+ requests: { 'session/request_permission' => @streams.method(:request_permission), **@inbound.requests },
60
+ notifications: { 'session/update' => @streams.method(:dispatch), **@inbound.notifications }
221
61
  )
222
62
  end
223
63
 
@@ -227,21 +67,7 @@ class ACP::ClientConnection
227
67
  # @rbs request: ACP::Types::InitializeRequest
228
68
  # @rbs return: ACP::Types::InitializeResponse | ACP::RequestError
229
69
  def connect(request)
230
- capabilities = request.client_capabilities
231
- fs = capabilities&.fs
232
- terminal = capabilities&.terminal
233
- check_served('fs', :@fs_capabilities, fs)
234
- check_served('terminal', :@terminal, terminal)
235
-
236
- elicitation = capabilities&.elicitation
237
- unserved = unserved_elicitation_modes(elicitation)
238
- unless unserved.empty?
239
- raise ArgumentError, "initialize advertises elicitation modes no handler serves: #{unserved.join(', ')}"
240
- end
241
-
242
- @fs_capabilities = fs
243
- @terminal = terminal
244
- @elicitation_capabilities = elicitation
70
+ @inbound.advertise(request.client_capabilities)
245
71
  result = @transport.request('initialize', request.to_h)
246
72
  response = parse(ACP::Types::InitializeResponse, result)
247
73
  return response if response.is_a?(ACP::RequestError)
@@ -249,7 +75,7 @@ class ACP::ClientConnection
249
75
  # Safe: parse only calls from_h on a Hash, and its RequestError answer
250
76
  # short-circuited above.
251
77
  reply = result #: Hash[String, untyped]
252
- @mcp_advertised = ACP::Protocol.mcp_acp_advertised?(reply['agentCapabilities'] || {})
78
+ @inbound.mcp_advertised = ACP::Protocol.mcp_acp_advertised?(reply['agentCapabilities'] || {})
253
79
  return response if response.protocol_version == ACP::Protocol::PROTOCOL_VERSION
254
80
 
255
81
  ACP::RequestError.unsupported_protocol_version(
@@ -407,114 +233,13 @@ class ACP::ClientConnection
407
233
 
408
234
  private
409
235
 
410
- # @rbs route: ACP::ClientConnection::Route
411
- # @rbs return: ^(untyped) -> (untyped | ACP::RequestError)
412
- def serve(route)
413
- lambda do |params|
414
- next refuse(route) unless route.advertised.call(instance_variable_get(route.family))
415
-
416
- request = route.type.from_h(params)
417
- rescue ACP::Types::ParseError => e
418
- ACP::RequestError.invalid_params([e.message])
419
- rescue KeyError, TypeError, NoMethodError
420
- ACP::RequestError.invalid_params
421
- else
422
- # Safe: connect refuses an advertised capability no handler serves, and
423
- # the mcp route is only registered when one is injected.
424
- instance_variable_get(route.handler).call(request)
425
- end
426
- end
427
-
428
- # @rbs route: ACP::ClientConnection::Route
429
- # @rbs return: ACP::RequestError
430
- def refuse(route)
431
- ACP::RequestError.public_send(route.refuse, route.key)
432
- end
433
-
434
- # @rbs name: String
435
- # @rbs family: Symbol
436
- # @rbs capabilities: untyped
437
- # @rbs return: void
438
- def check_served(name, family, capabilities)
439
- unserved = SERVE.filter_map do |route|
440
- next unless route.family == family
441
- next if instance_variable_get(route.handler)
442
- next unless route.advertised.call(capabilities)
443
-
444
- route.label
445
- end
446
- return if unserved.empty?
447
-
448
- raise ArgumentError, "initialize advertises #{name} methods no handler serves: #{unserved.join(', ')}"
449
- end
450
-
451
- # The unstable types are referenced while the row is built, so the route can
452
- # only be served when the caller opted in with `require 'acp/types/unstable'`.
453
- # It is registered only when a handler serves it: unlike the fs and terminal
454
- # gates, the mcp gate reads the agent's advertisement, so nothing forces an
455
- # advertised request to have a handler.
456
- #
457
- # @rbs return: Hash[String, ^(untyped) -> (untyped | ACP::RequestError)]
458
- def mcp_requests
459
- return {} unless @mcp_message
460
- return {} unless ACP::Types.unstable_enabled?
461
-
462
- route = Route.new(
463
- wire: 'mcp/message',
464
- type: ACP::Types::Unstable::MessageMcpRequest,
465
- handler: :@mcp_message,
466
- family: :@mcp_advertised,
467
- refuse: :unadvertised_agent,
468
- key: 'mcpCapabilities.acp'
469
- )
470
- { 'mcp/message' => serve(route) }
471
- end
472
-
473
236
  # @rbs type: ACP::Transport::_Parser
474
237
  # @rbs result: (Hash[String, untyped] | ACP::RequestError)
475
238
  # @rbs return: untyped
476
239
  def parse(type, result)
477
240
  return result if result.is_a?(ACP::RequestError)
478
241
 
479
- type.from_h(result)
480
- rescue ACP::Types::ParseError, KeyError, TypeError, NoMethodError
481
- ACP::RequestError.invalid_response(result)
482
- end
483
-
484
- # The route is registered at start, before connect records the advertised
485
- # capabilities, so a request for an unadvertised mode answers -32602.
486
- #
487
- # @rbs params: untyped
488
- # @rbs return: (ACP::Types::CreateElicitationResponse | ACP::RequestError)
489
- def create_elicitation(params)
490
- request = ACP::Types::CreateElicitationRequest.from_h(params)
491
- rescue ACP::Types::ParseError => e
492
- ACP::RequestError.invalid_params([e.message])
493
- rescue KeyError, TypeError, NoMethodError
494
- ACP::RequestError.invalid_params
495
- else
496
- case request.mode
497
- when ACP::Types::CreateElicitationRequest::Mode::Form
498
- return ACP::RequestError.unadvertised_mode('elicitation.form') unless @elicitation_capabilities&.form
499
- when ACP::Types::CreateElicitationRequest::Mode::Url
500
- return ACP::RequestError.unadvertised_mode('elicitation.url') unless @elicitation_capabilities&.url
501
-
502
- @streams.register_elicitation(request.mode.elicitation_id)
503
- else
504
- return ACP::RequestError.unadvertised_mode("elicitation.#{request.mode['mode']}")
505
- end
506
-
507
- # Safe: connect refuses an advertised mode no handler serves.
508
- handler = @elicitation #: ^(ACP::Types::CreateElicitationRequest) -> (ACP::Types::CreateElicitationResponse | ACP::RequestError)
509
- handler.call(request)
510
- end
511
-
512
- # @rbs capabilities: ACP::Types::ElicitationCapabilities?
513
- # @rbs return: Array[String]
514
- def unserved_elicitation_modes(capabilities)
515
- {
516
- 'elicitation.form' => capabilities&.form && !@elicitation,
517
- 'elicitation.url' => capabilities&.url && !@elicitation
518
- }.select { |_, unserved| unserved }.keys
242
+ response = ACP::Types.parse(type, result)
243
+ response.is_a?(ACP::Types::ParseError) ? ACP::RequestError.invalid_response(result) : response
519
244
  end
520
245
  end
@@ -12,4 +12,10 @@ module ACP::Extensions
12
12
 
13
13
  raise ArgumentError, "extension method names must start with '_' (got #{name.inspect})"
14
14
  end
15
+
16
+ # @rbs names: Array[String]
17
+ # @rbs return: void
18
+ def validate_names(names)
19
+ names.each { |name| validate_name(name) }
20
+ end
15
21
  end
data/lib/acp/protocol.rb CHANGED
@@ -5,6 +5,19 @@ module ACP::Protocol
5
5
 
6
6
  extend self
7
7
 
8
+ # Readers missing along the chain count as unadvertised, so the stable
9
+ # capabilities answer false for the unstable-only paths (fork, providers).
10
+ #
11
+ # @rbs capabilities: untyped
12
+ # @rbs path: Array[Symbol]
13
+ # @rbs return: bool
14
+ def advertised?(capabilities, path)
15
+ # reduce's U pins to the argument's type, but the chain walks through
16
+ # unrelated capability types.
17
+ value = path.reduce(capabilities) { |node, name| node.respond_to?(name) ? node.public_send(name) : nil } #: untyped
18
+ value ? true : false
19
+ end
20
+
8
21
  # @rbs capabilities: (ACP::Types::AgentCapabilities | ACP::Types::Unstable::AgentCapabilities | Hash[String, untyped])
9
22
  # @rbs return: bool
10
23
  def mcp_acp_advertised?(capabilities)
@@ -21,13 +34,10 @@ module ACP::Protocol
21
34
  # mcpCapabilities is re-parsed from the raw hash with the unstable
22
35
  # type. Only that sub-object: the other unstable fields arrive
23
36
  # unvalidated and must not decide whether MCP is advertised.
24
- raw = capabilities['mcpCapabilities']
25
- ACP::Types::Unstable::McpCapabilities.from_h(raw) if raw.is_a?(Hash)
37
+ ACP::Types.parse(ACP::Types::Unstable::McpCapabilities, capabilities['mcpCapabilities'])
26
38
  end
27
39
  return false unless mcp.is_a?(ACP::Types::Unstable::McpCapabilities)
28
40
 
29
41
  mcp.acp ? true : false
30
- rescue ACP::Types::ParseError
31
- false
32
42
  end
33
43
  end
@@ -2,12 +2,12 @@
2
2
 
3
3
  class ACP::Transport::Reply
4
4
  # @dynamic result
5
- attr_reader :result #: ACP::Transport::Stdio::_ToH
5
+ attr_reader :result #: ACP::Transport::_Response
6
6
 
7
7
  # @dynamic after
8
8
  attr_reader :after #: ^() -> void
9
9
 
10
- # @rbs result: ACP::Transport::Stdio::_ToH
10
+ # @rbs result: ACP::Transport::_Response
11
11
  # @rbs after: ^() -> void
12
12
  # @rbs return: void
13
13
  def initialize(result, after:)
@@ -245,7 +245,7 @@ class ACP::Transport::Stdio
245
245
  #
246
246
  # @rbs handler: _Handler
247
247
  # @rbs params: untyped
248
- # @rbs return: (_ToH | ACP::RequestError | ACP::Transport::Reply)
248
+ # @rbs return: (ACP::Transport::_Response | ACP::RequestError | ACP::Transport::Reply)
249
249
  def invoke(handler, params)
250
250
  handler.call(params)
251
251
  rescue StandardError => e
@@ -254,7 +254,7 @@ class ACP::Transport::Stdio
254
254
  end
255
255
 
256
256
  # @rbs id: untyped
257
- # @rbs outcome: (_ToH | ACP::RequestError)
257
+ # @rbs outcome: (ACP::Transport::_Response | ACP::RequestError)
258
258
  # @rbs return: void
259
259
  def reply(id, outcome)
260
260
  payload = case outcome
@@ -13,7 +13,7 @@ class ACP::Types::ParseError < StandardError
13
13
  def initialize(segments, detail)
14
14
  @segments = segments.freeze
15
15
  @detail = detail
16
- super("#{path}: #{detail}")
16
+ super(segments.empty? ? detail : "#{path}: #{detail}")
17
17
  end
18
18
 
19
19
  # @dynamic segments
data/lib/acp/types.rb CHANGED
@@ -9,4 +9,17 @@ module ACP::Types
9
9
  def self.unstable_enabled?
10
10
  !defined?(ACP::Types::Unstable).nil?
11
11
  end
12
+
13
+ # Generated from_h checks only nested values, so a payload that is not an
14
+ # object is rejected here: a String would answer nil for every key and parse
15
+ # as an empty object.
16
+ #
17
+ # @rbs type: ACP::Transport::_Parser
18
+ # @rbs payload: untyped
19
+ # @rbs return: untyped
20
+ def self.parse(type, payload)
21
+ ACP::Types::Check.object(payload, [], type)
22
+ rescue ACP::Types::ParseError => e
23
+ e
24
+ end
12
25
  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.13.0'
4
+ VERSION = '0.14.0'
5
5
  end