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
@@ -10,13 +10,62 @@ class ACP::AgentConnection
10
10
  # @rbs @extension_notifications: Hash[String, ^(untyped) -> void]
11
11
  # @rbs @logger: ACP::Transport::_Logger
12
12
 
13
- # The schema has no initialize capability for modes or config options: an
14
- # agent offers them per session, in its session responses, so they are
15
- # routed whenever the agent defines them.
16
- PER_SESSION = {
17
- 'session/set_mode' => :change_session_mode,
18
- 'session/set_config_option' => :change_session_config_option
19
- }.freeze #: Hash[String, Symbol]
13
+ OPTIONAL_ROUTES = [
14
+ OptionalRoute.new(
15
+ rpc_method: 'authenticate',
16
+ type: ACP::Types::AuthenticateRequest,
17
+ agent_method: :authenticate,
18
+ path: %i[auth_methods any?]
19
+ ) { |agent, request| agent.authenticate(request) },
20
+ OptionalRoute.new(
21
+ rpc_method: 'session/load',
22
+ type: ACP::Types::LoadSessionRequest,
23
+ agent_method: :load_session,
24
+ path: %i[agent_capabilities load_session]
25
+ ) { |agent, request| agent.load_session(request) },
26
+ OptionalRoute.new(
27
+ rpc_method: 'session/list',
28
+ type: ACP::Types::ListSessionsRequest,
29
+ agent_method: :list_sessions,
30
+ path: %i[agent_capabilities session_capabilities list]
31
+ ) { |agent, request| agent.list_sessions(request) },
32
+ OptionalRoute.new(
33
+ rpc_method: 'session/resume',
34
+ type: ACP::Types::ResumeSessionRequest,
35
+ agent_method: :resume_session,
36
+ path: %i[agent_capabilities session_capabilities resume]
37
+ ) { |agent, request| agent.resume_session(request) },
38
+ OptionalRoute.new(
39
+ rpc_method: 'session/close',
40
+ type: ACP::Types::CloseSessionRequest,
41
+ agent_method: :close_session,
42
+ path: %i[agent_capabilities session_capabilities close]
43
+ ) { |agent, request| agent.close_session(request) },
44
+ OptionalRoute.new(
45
+ rpc_method: 'session/delete',
46
+ type: ACP::Types::DeleteSessionRequest,
47
+ agent_method: :delete_session,
48
+ path: %i[agent_capabilities session_capabilities delete]
49
+ ) { |agent, request| agent.delete_session(request) },
50
+ OptionalRoute.new(
51
+ rpc_method: 'logout',
52
+ type: ACP::Types::LogoutRequest,
53
+ agent_method: :logout,
54
+ path: %i[agent_capabilities auth logout]
55
+ ) { |agent, request| agent.logout(request) },
56
+ OptionalRoute.new(
57
+ rpc_method: 'session/set_mode',
58
+ type: ACP::Types::SetSessionModeRequest,
59
+ agent_method: :change_session_mode,
60
+ path: nil
61
+ ) { |agent, request| agent.change_session_mode(request) },
62
+ OptionalRoute.new(
63
+ rpc_method: 'session/set_config_option',
64
+ type: ACP::Types::SetSessionConfigOptionRequest,
65
+ agent_method: :change_session_config_option,
66
+ path: nil
67
+ ) { |agent, request| agent.change_session_config_option(request) }
68
+ ].freeze #: Array[ACP::AgentConnection::OptionalRoute]
20
69
 
21
70
  # @rbs transport: ACP::Transport::Stdio
22
71
  # @rbs capabilities: ACP::Types::AgentCapabilities
@@ -44,8 +93,7 @@ class ACP::AgentConnection
44
93
  @auth_methods = auth_methods
45
94
  @extension_requests = extension_requests
46
95
  @extension_notifications = extension_notifications
47
- extension_requests.each_key { |name| ACP::Extensions.validate_name(name) }
48
- extension_notifications.each_key { |name| ACP::Extensions.validate_name(name) }
96
+ ACP::Extensions.validate_names(extension_requests.keys + extension_notifications.keys)
49
97
  @factory = factory
50
98
  end
51
99
 
@@ -53,22 +101,23 @@ class ACP::AgentConnection
53
101
  def start
54
102
  client = ACP::AgentConnection::Client.new(peer: @transport, mcp_advertised: mcp_acp_advertised?)
55
103
  agent = @factory.call(client)
104
+ routes = OPTIONAL_ROUTES + unstable_routes
56
105
  # Routes are fixed before a client connects, so the check sees the
57
106
  # unfiltered auth methods: a terminal-capable client still needs
58
107
  # authenticate routed.
59
- advertised, unadvertised = optional_methods.partition do |method|
60
- method.advertised?(initialize_response(@auth_methods))
108
+ response = initialize_response(@auth_methods)
109
+ missing = routes.select { |route| route.advertised?(response) && !agent.respond_to?(route.agent_method) }
110
+ unless missing.empty?
111
+ raise ArgumentError, "initialize advertises methods the agent lacks: #{missing.map(&:agent_method).join(', ')}"
61
112
  end
62
- missing = advertised.map(&:agent_method).reject { |name| agent.respond_to?(name) }
63
- raise ArgumentError, "initialize advertises methods the agent lacks: #{missing.join(', ')}" unless missing.empty?
64
113
 
65
114
  if mcp_acp_advertised? && !agent.respond_to?(:mcp_message_notification)
66
115
  raise ArgumentError, 'initialize advertises mcpCapabilities.acp but the agent lacks mcp_message_notification'
67
116
  end
68
117
 
69
- unrouted = unadvertised.map(&:rpc_method) + PER_SESSION.reject { |_, name| agent.respond_to?(name) }.keys
118
+ routed = routes.select { |route| route.routed?(response, agent) }
70
119
  @transport.start(
71
- requests: requests(agent, client).except(*unrouted).merge(@extension_requests),
120
+ requests: requests(agent, client, routed).merge(@extension_requests),
72
121
  notifications: notifications(agent).merge(@extension_notifications)
73
122
  )
74
123
  end
@@ -77,56 +126,54 @@ class ACP::AgentConnection
77
126
 
78
127
  # @rbs agent: ACP::AgentConnection::_Agent
79
128
  # @rbs client: ACP::AgentConnection::Client
129
+ # @rbs optional: Array[ACP::AgentConnection::OptionalRoute]
80
130
  # @rbs return: Hash[String, ^(untyped) -> (ACP::Transport::_Response | ACP::RequestError | ACP::Transport::Reply)]
81
- def requests(agent, client)
82
- # Safe: start drops the optional handlers initialize does not advertise
83
- # or the agent does not define, and checks the agent defines the rest.
131
+ def requests(agent, client, optional)
132
+ # Safe: start passes only the optional routes the agent defines.
84
133
  full = agent #: ACP::AgentConnection::_FullAgent
134
+ optional_requests = optional.to_h do |entry|
135
+ [entry.rpc_method, route(entry.type) { |request| entry.handle.call(full, request) }]
136
+ end
85
137
  {
86
138
  'initialize' => route(ACP::Types::InitializeRequest) { |request| connect(client, request) },
87
- 'authenticate' => route(ACP::Types::AuthenticateRequest) { |request| full.authenticate(request) },
88
139
  'session/new' => route(ACP::Types::NewSessionRequest) { |request| new_session(agent, request) },
89
- 'session/prompt' => route(ACP::Types::PromptRequest) { |request| agent.prompt(request) },
90
- 'session/load' => route(ACP::Types::LoadSessionRequest) { |request| full.load_session(request) },
91
- 'session/list' => route(ACP::Types::ListSessionsRequest) { |request| full.list_sessions(request) },
92
- 'session/resume' => route(ACP::Types::ResumeSessionRequest) { |request| full.resume_session(request) },
93
- 'session/close' => route(ACP::Types::CloseSessionRequest) { |request| full.close_session(request) },
94
- 'session/delete' => route(ACP::Types::DeleteSessionRequest) { |request| full.delete_session(request) },
95
- 'logout' => route(ACP::Types::LogoutRequest) { |request| full.logout(request) },
96
- 'session/set_mode' => route(ACP::Types::SetSessionModeRequest) { |request| full.change_session_mode(request) },
97
- 'session/set_config_option' => route(ACP::Types::SetSessionConfigOptionRequest) do |request|
98
- full.change_session_config_option(request)
99
- end
100
- }.merge(unstable_requests(full))
101
- end
102
-
103
- # The unstable request types are referenced while the hash is built, so
104
- # without the guard start would raise NameError even for an agent that never
105
- # advertises fork or providers.
106
- #
107
- # @rbs full: ACP::AgentConnection::_FullAgent
108
- # @rbs return: Hash[String, ^(untyped) -> (ACP::Transport::_Response | ACP::RequestError | ACP::Transport::Reply)]
109
- def unstable_requests(full)
110
- return {} unless ACP::Types.unstable_enabled?
111
-
112
- {
113
- 'session/fork' => route(ACP::Types::Unstable::ForkSessionRequest) { |request| full.fork_session(request) },
114
- 'providers/list' => route(ACP::Types::Unstable::ListProvidersRequest) { |request| full.list_providers(request) },
115
- 'providers/set' => route(ACP::Types::Unstable::SetProviderRequest) { |request| full.set_provider(request) },
116
- 'providers/disable' => route(ACP::Types::Unstable::DisableProviderRequest) { |request| full.disable_provider(request) }
117
- }
140
+ 'session/prompt' => route(ACP::Types::PromptRequest) { |request| agent.prompt(request) }
141
+ }.merge(optional_requests)
118
142
  end
119
143
 
120
- # The unstable specs pair with the unstable request routes, which reference
121
- # the unstable request classes, so both are only probed after the caller
122
- # opted in with `require 'acp/types/unstable'`.
123
- #
124
- # @rbs return: Array[ACP::AgentConnection::OptionalMethod::Spec]
125
- def optional_methods
126
- specs = ACP::AgentConnection::OptionalMethod::SPECS
127
- return specs unless ACP::Types.unstable_enabled?
144
+ # @rbs return: Array[ACP::AgentConnection::OptionalRoute]
145
+ def unstable_routes
146
+ # The rows reference the unstable request types, so without the guard start
147
+ # would raise NameError even for an agent that never advertises fork or
148
+ # providers.
149
+ return [] unless ACP::Types.unstable_enabled?
128
150
 
129
- specs + ACP::AgentConnection::OptionalMethod::UNSTABLE_SPECS
151
+ [
152
+ OptionalRoute.new(
153
+ rpc_method: 'session/fork',
154
+ type: ACP::Types::Unstable::ForkSessionRequest,
155
+ agent_method: :fork_session,
156
+ path: %i[agent_capabilities session_capabilities fork]
157
+ ) { |agent, request| agent.fork_session(request) },
158
+ OptionalRoute.new(
159
+ rpc_method: 'providers/list',
160
+ type: ACP::Types::Unstable::ListProvidersRequest,
161
+ agent_method: :list_providers,
162
+ path: %i[agent_capabilities providers]
163
+ ) { |agent, request| agent.list_providers(request) },
164
+ OptionalRoute.new(
165
+ rpc_method: 'providers/set',
166
+ type: ACP::Types::Unstable::SetProviderRequest,
167
+ agent_method: :set_provider,
168
+ path: %i[agent_capabilities providers]
169
+ ) { |agent, request| agent.set_provider(request) },
170
+ OptionalRoute.new(
171
+ rpc_method: 'providers/disable',
172
+ type: ACP::Types::Unstable::DisableProviderRequest,
173
+ agent_method: :disable_provider,
174
+ path: %i[agent_capabilities providers]
175
+ ) { |agent, request| agent.disable_provider(request) }
176
+ ]
130
177
  end
131
178
 
132
179
  # @rbs agent: ACP::AgentConnection::_Agent
@@ -144,11 +191,12 @@ class ACP::AgentConnection
144
191
  # @rbs params: untyped
145
192
  # @rbs return: void
146
193
  def cancel(agent, params)
147
- notification = ACP::Types::CancelNotification.from_h(params)
148
- rescue ACP::Types::ParseError, KeyError, TypeError, NoMethodError => e
149
- @logger.warn("dropped malformed session/cancel: #{e.class}: #{e.message}")
150
- else
151
- agent.cancel(notification)
194
+ notification = ACP::Types.parse(ACP::Types::CancelNotification, params)
195
+ if notification.is_a?(ACP::Types::ParseError)
196
+ @logger.warn("dropped malformed session/cancel: #{notification.message}")
197
+ else
198
+ agent.cancel(notification)
199
+ end
152
200
  end
153
201
 
154
202
  # @rbs return: bool
@@ -162,27 +210,23 @@ class ACP::AgentConnection
162
210
  # @rbs params: untyped
163
211
  # @rbs return: void
164
212
  def mcp_message_notification(agent, params)
165
- notification = ACP::Types::Unstable::MessageMcpNotification.from_h(params)
166
- rescue ACP::Types::ParseError, KeyError, TypeError, NoMethodError => e
167
- @logger.warn("dropped malformed mcp/message: #{e.class}: #{e.message}")
168
- else
169
- hook = agent #: ACP::AgentConnection::_Agent & ACP::AgentConnection::_McpMessageNotification
170
- hook.mcp_message_notification(notification)
213
+ notification = ACP::Types.parse(ACP::Types::Unstable::MessageMcpNotification, params)
214
+ if notification.is_a?(ACP::Types::ParseError)
215
+ @logger.warn("dropped malformed mcp/message: #{notification.message}")
216
+ else
217
+ hook = agent #: ACP::AgentConnection::_Agent & ACP::AgentConnection::_McpMessageNotification
218
+ hook.mcp_message_notification(notification)
219
+ end
171
220
  end
172
221
 
173
- # Generated from_h raises on a missing key or a value of the wrong shape.
174
- #
175
222
  # @rbs type: ACP::Transport::_Parser
176
223
  # @rbs &handle: (untyped) -> (ACP::Transport::_Response | ACP::RequestError | ACP::Transport::Reply)
177
224
  # @rbs return: ^(untyped) -> (ACP::Transport::_Response | ACP::RequestError | ACP::Transport::Reply)
178
225
  def route(type, &)
179
226
  lambda do |params|
180
- request = type.from_h(params)
181
- rescue ACP::Types::ParseError => e
182
- ACP::RequestError.invalid_params([e.message])
183
- rescue KeyError, TypeError, NoMethodError
184
- ACP::RequestError.invalid_params
185
- else
227
+ request = ACP::Types.parse(type, params)
228
+ next ACP::RequestError.invalid_params([request.message]) if request.is_a?(ACP::Types::ParseError)
229
+
186
230
  yield(request)
187
231
  end
188
232
  end
@@ -48,7 +48,7 @@ class ACP::AgentProcess
48
48
  spawner = Open3.method(:popen2) #: _Spawner
49
49
  stdin, stdout, waiter = spawner.call(*(env ? [env, command, *args] : [command, *args]), **opts)
50
50
  err_w&.close
51
- connection = ACP::ClientConnection.new(
51
+ connection = ACP::ClientConnection.build(
52
52
  transport: ACP::Transport::Stdio.new(input: stdout, output: stdin),
53
53
  permission:,
54
54
  updates:
@@ -65,14 +65,14 @@ class ACP::AgentProcess
65
65
 
66
66
  # @rbs return: void
67
67
  def terminate
68
- @stdin.close unless @stdin.closed?
68
+ @stdin.close
69
69
  @waiter.join(1) or signal('TERM')
70
70
  @waiter.join(2) or signal('KILL')
71
71
  @waiter.join(2)
72
72
  @reader.join(1)
73
- @stdout.close unless @stdout.closed?
73
+ @stdout.close
74
74
  @err_reader&.join(1)
75
- @err_r&.close unless @err_r&.closed?
75
+ @err_r&.close
76
76
  end
77
77
 
78
78
  private
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ class ACP::ClientConnection::Elicitations
4
+ # @rbs @handler: ACP::ClientConnection::_Elicitation?
5
+ # @rbs @logger: ACP::Transport::_Logger
6
+ # @rbs @lock: Thread::Mutex
7
+ # @rbs @outstanding: Hash[String, bool]
8
+
9
+ # @rbs handler: ACP::ClientConnection::_Elicitation?
10
+ # @rbs logger: ACP::Transport::_Logger
11
+ # @rbs return: void
12
+ def initialize(handler:, logger:)
13
+ @handler = handler
14
+ @logger = logger
15
+ @lock = Mutex.new
16
+ @outstanding = {}
17
+ end
18
+
19
+ # @rbs elicitation_id: String
20
+ # @rbs return: void
21
+ def register(elicitation_id)
22
+ @lock.synchronize { @outstanding[elicitation_id] = true }
23
+ end
24
+
25
+ # The spec requires a completion for an unknown or already-completed
26
+ # elicitation id to be ignored, so only an id registered by a served
27
+ # url-mode request reaches the handler. It runs on the transport's reader
28
+ # thread, so it must return quickly.
29
+ #
30
+ # @rbs params: untyped
31
+ # @rbs return: void
32
+ def complete(params)
33
+ notification = ACP::Types.parse(ACP::Types::CompleteElicitationNotification, params)
34
+ if notification.is_a?(ACP::Types::ParseError)
35
+ @logger.warn("dropped malformed elicitation/complete: #{notification.message}")
36
+ else
37
+ known = @lock.synchronize { @outstanding.delete(notification.elicitation_id) }
38
+ handler = @handler
39
+ return unless known && handler
40
+
41
+ handler.complete_elicitation(notification) if handler.respond_to?(:complete_elicitation)
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ class ACP::ClientConnection::Inbound::Route
4
+ # @dynamic rpc_method
5
+ attr_reader :rpc_method #: String
6
+ # @dynamic type
7
+ attr_reader :type #: ACP::Transport::_Parser
8
+ # @dynamic handler_method
9
+ attr_reader :handler_method #: Symbol
10
+ # @dynamic path
11
+ attr_reader :path #: Array[Symbol]
12
+ # @dynamic capability
13
+ attr_reader :capability #: String
14
+
15
+ # @rbs rpc_method: String
16
+ # @rbs type: ACP::Transport::_Parser
17
+ # @rbs handler_method: Symbol
18
+ # @rbs path: Array[Symbol]
19
+ # @rbs capability: String
20
+ # @rbs return: void
21
+ def initialize(rpc_method:, type:, handler_method:, path:, capability:)
22
+ @rpc_method = rpc_method
23
+ @type = type
24
+ @handler_method = handler_method
25
+ @path = path.freeze
26
+ @capability = capability
27
+ freeze
28
+ end
29
+ end
@@ -0,0 +1,232 @@
1
+ # frozen_string_literal: true
2
+
3
+ class ACP::ClientConnection::Inbound
4
+ # @rbs @fs: ACP::ClientConnection::_FileSystem?
5
+ # @rbs @terminal: ACP::ClientConnection::_Terminal?
6
+ # @rbs @elicitation: ACP::ClientConnection::_Elicitation?
7
+ # @rbs @elicitations: ACP::ClientConnection::Elicitations
8
+ # @rbs @mcp_message: (^(ACP::Types::Unstable::MessageMcpRequest) ->
9
+ # (ACP::Types::Unstable::MessageMcpResponse::t | ACP::RequestError))?
10
+ # @rbs @extension_requests: Hash[String, ^(untyped) -> untyped]
11
+ # @rbs @extension_notifications: Hash[String, ^(untyped) -> void]
12
+ # @rbs @capabilities: ACP::Types::ClientCapabilities?
13
+
14
+ FS_ROUTES = [
15
+ Route.new(
16
+ rpc_method: 'fs/read_text_file',
17
+ type: ACP::Types::ReadTextFileRequest,
18
+ handler_method: :read_text_file,
19
+ path: %i[fs read_text_file],
20
+ capability: 'fs.readTextFile'
21
+ ),
22
+ Route.new(
23
+ rpc_method: 'fs/write_text_file',
24
+ type: ACP::Types::WriteTextFileRequest,
25
+ handler_method: :write_text_file,
26
+ path: %i[fs write_text_file],
27
+ capability: 'fs.writeTextFile'
28
+ )
29
+ ].freeze #: Array[ACP::ClientConnection::Inbound::Route]
30
+
31
+ # The terminal capability is a single bool, so its five routes share one
32
+ # gate. Their handlers may block for as long as the command runs, so the
33
+ # transport must serve each request on its own thread for this not to hold
34
+ # up others.
35
+ TERMINAL_ROUTES = [
36
+ Route.new(
37
+ rpc_method: 'terminal/create',
38
+ type: ACP::Types::CreateTerminalRequest,
39
+ handler_method: :create_terminal,
40
+ path: %i[terminal],
41
+ capability: 'terminal'
42
+ ),
43
+ Route.new(
44
+ rpc_method: 'terminal/output',
45
+ type: ACP::Types::TerminalOutputRequest,
46
+ handler_method: :terminal_output,
47
+ path: %i[terminal],
48
+ capability: 'terminal'
49
+ ),
50
+ Route.new(
51
+ rpc_method: 'terminal/wait_for_exit',
52
+ type: ACP::Types::WaitForTerminalExitRequest,
53
+ handler_method: :wait_for_terminal_exit,
54
+ path: %i[terminal],
55
+ capability: 'terminal'
56
+ ),
57
+ Route.new(
58
+ rpc_method: 'terminal/kill',
59
+ type: ACP::Types::KillTerminalRequest,
60
+ handler_method: :kill_terminal,
61
+ path: %i[terminal],
62
+ capability: 'terminal'
63
+ ),
64
+ Route.new(
65
+ rpc_method: 'terminal/release',
66
+ type: ACP::Types::ReleaseTerminalRequest,
67
+ handler_method: :release_terminal,
68
+ path: %i[terminal],
69
+ capability: 'terminal'
70
+ )
71
+ ].freeze #: Array[ACP::ClientConnection::Inbound::Route]
72
+
73
+ # @dynamic mcp_advertised=
74
+ attr_writer :mcp_advertised #: bool
75
+
76
+ # @rbs elicitations: ACP::ClientConnection::Elicitations
77
+ # @rbs fs: ACP::ClientConnection::_FileSystem?
78
+ # @rbs terminal: ACP::ClientConnection::_Terminal?
79
+ # @rbs elicitation: ACP::ClientConnection::_Elicitation?
80
+ # @rbs mcp_message: (^(ACP::Types::Unstable::MessageMcpRequest) ->
81
+ # (ACP::Types::Unstable::MessageMcpResponse::t | ACP::RequestError))?
82
+ # @rbs extension_requests: Hash[String, ^(untyped) -> untyped]
83
+ # @rbs extension_notifications: Hash[String, ^(untyped) -> void]
84
+ # @rbs return: void
85
+ def initialize(
86
+ elicitations:,
87
+ fs: nil, # rubocop:disable Naming/MethodParameterName -- named after ClientCapabilities#fs
88
+ terminal: nil,
89
+ elicitation: nil,
90
+ mcp_message: nil,
91
+ extension_requests: {},
92
+ extension_notifications: {}
93
+ )
94
+ ACP::Extensions.validate_names(extension_requests.keys + extension_notifications.keys)
95
+ @elicitations = elicitations
96
+ @fs = fs
97
+ @terminal = terminal
98
+ @elicitation = elicitation
99
+ @mcp_message = mcp_message
100
+ @extension_requests = extension_requests
101
+ @extension_notifications = extension_notifications
102
+ @capabilities = nil
103
+ @mcp_advertised = false
104
+ end
105
+
106
+ # @rbs return: Hash[String, ACP::Transport::Stdio::_Handler]
107
+ def requests
108
+ {
109
+ **FS_ROUTES.to_h { |route| [route.rpc_method, serve(route, @fs)] },
110
+ **TERMINAL_ROUTES.to_h { |route| [route.rpc_method, serve(route, @terminal)] },
111
+ 'elicitation/create' => method(:create_elicitation),
112
+ **mcp_requests,
113
+ **@extension_requests
114
+ }
115
+ end
116
+
117
+ # @rbs return: Hash[String, ACP::Transport::Stdio::_NotificationHandler]
118
+ def notifications
119
+ { 'elicitation/complete' => @elicitations.method(:complete), **@extension_notifications }
120
+ end
121
+
122
+ # @rbs capabilities: ACP::Types::ClientCapabilities?
123
+ # @rbs return: void
124
+ def advertise(capabilities)
125
+ check_served('fs methods', unserved(FS_ROUTES, @fs, capabilities))
126
+ check_served('terminal methods', unserved(TERMINAL_ROUTES, @terminal, capabilities))
127
+ check_served('elicitation modes', unserved_elicitation_modes(capabilities&.elicitation))
128
+ @capabilities = capabilities
129
+ end
130
+
131
+ private
132
+
133
+ # @rbs route: ACP::ClientConnection::Inbound::Route
134
+ # @rbs handler: untyped
135
+ # @rbs return: ^(untyped) -> untyped
136
+ def serve(route, handler)
137
+ lambda do |params|
138
+ # The routes are registered at start, before connect advertises the
139
+ # capabilities, so each one refuses until then.
140
+ next ACP::RequestError.unadvertised(route.capability) unless ACP::Protocol.advertised?(@capabilities, route.path)
141
+
142
+ # Safe: advertise refuses an advertised capability no handler serves.
143
+ parsed(route.type, params) { |request| handler.public_send(route.handler_method, request) }
144
+ end
145
+ end
146
+
147
+ # @rbs type: ACP::Transport::_Parser
148
+ # @rbs params: untyped
149
+ # @rbs &block: (untyped) -> untyped
150
+ # @rbs return: untyped
151
+ def parsed(type, params)
152
+ request = ACP::Types.parse(type, params)
153
+ request.is_a?(ACP::Types::ParseError) ? ACP::RequestError.invalid_params([request.message]) : yield(request)
154
+ end
155
+
156
+ # @rbs routes: Array[ACP::ClientConnection::Inbound::Route]
157
+ # @rbs handler: untyped
158
+ # @rbs capabilities: ACP::Types::ClientCapabilities?
159
+ # @rbs return: Array[String]
160
+ def unserved(routes, handler, capabilities)
161
+ routes.filter_map do |route|
162
+ next unless ACP::Protocol.advertised?(capabilities, route.path)
163
+
164
+ route.handler_method.to_s unless handler.respond_to?(route.handler_method)
165
+ end
166
+ end
167
+
168
+ # @rbs capabilities: ACP::Types::ElicitationCapabilities?
169
+ # @rbs return: Array[String]
170
+ def unserved_elicitation_modes(capabilities)
171
+ return [] if @elicitation.respond_to?(:create_elicitation)
172
+
173
+ { 'elicitation.form' => capabilities&.form, 'elicitation.url' => capabilities&.url }.select { |_, on| on }.keys
174
+ end
175
+
176
+ # @rbs what: String
177
+ # @rbs unserved: Array[String]
178
+ # @rbs return: void
179
+ def check_served(what, unserved)
180
+ return if unserved.empty?
181
+
182
+ raise ArgumentError, "initialize advertises #{what} no handler serves: #{unserved.join(', ')}"
183
+ end
184
+
185
+ # @rbs return: Hash[String, ^(untyped) -> untyped]
186
+ def mcp_requests
187
+ handler = @mcp_message
188
+ # Registered only when a handler serves it: unlike the fs and terminal gates,
189
+ # the mcp gate reads the agent's advertisement, so nothing forces an
190
+ # advertised request to have a handler. The route references the unstable
191
+ # types, so it needs the caller's `require 'acp/types/unstable'` too.
192
+ return {} unless handler && ACP::Types.unstable_enabled?
193
+
194
+ route = lambda do |params|
195
+ next ACP::RequestError.unadvertised_agent('mcpCapabilities.acp') unless @mcp_advertised
196
+
197
+ parsed(ACP::Types::Unstable::MessageMcpRequest, params) { |request| handler.call(request) }
198
+ end
199
+ { 'mcp/message' => route }
200
+ end
201
+
202
+ # @rbs params: untyped
203
+ # @rbs return: (ACP::Types::CreateElicitationResponse | ACP::RequestError)
204
+ def create_elicitation(params)
205
+ parsed(ACP::Types::CreateElicitationRequest, params) do |request|
206
+ refusal = elicitation_refusal(request)
207
+ next refusal if refusal
208
+
209
+ mode = request.mode
210
+ @elicitations.register(mode.elicitation_id) if mode.is_a?(ACP::Types::CreateElicitationRequest::Mode::Url)
211
+ # Safe: advertise refuses an advertised mode no handler serves.
212
+ handler = @elicitation #: ACP::ClientConnection::_Elicitation
213
+ handler.create_elicitation(request)
214
+ end
215
+ end
216
+
217
+ # @rbs request: ACP::Types::CreateElicitationRequest
218
+ # @rbs return: ACP::RequestError?
219
+ def elicitation_refusal(request)
220
+ modes = @capabilities&.elicitation
221
+ # An unadvertised mode answers -32602 rather than -32601: the spec gives
222
+ # elicitation its mode in the params.
223
+ case request.mode
224
+ when ACP::Types::CreateElicitationRequest::Mode::Form
225
+ ACP::RequestError.unadvertised_mode('elicitation.form') unless modes&.form
226
+ when ACP::Types::CreateElicitationRequest::Mode::Url
227
+ ACP::RequestError.unadvertised_mode('elicitation.url') unless modes&.url
228
+ else
229
+ ACP::RequestError.unadvertised_mode("elicitation.#{request.mode['mode']}")
230
+ end
231
+ end
232
+ end