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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +16 -0
- data/README.md +141 -69
- data/lib/acp/agent_connection/client.rb +2 -3
- data/lib/acp/agent_connection/optional_route.rb +45 -0
- data/lib/acp/agent_connection.rb +120 -76
- data/lib/acp/agent_process.rb +4 -4
- data/lib/acp/client_connection/elicitations.rb +44 -0
- data/lib/acp/client_connection/inbound/route.rb +29 -0
- data/lib/acp/client_connection/inbound.rb +232 -0
- data/lib/acp/client_connection/streams.rb +20 -52
- data/lib/acp/client_connection.rb +34 -309
- data/lib/acp/extensions.rb +6 -0
- data/lib/acp/protocol.rb +14 -4
- data/lib/acp/transport/reply.rb +2 -2
- data/lib/acp/transport/stdio.rb +2 -2
- data/lib/acp/types/parse_error.rb +1 -1
- data/lib/acp/types.rb +13 -0
- data/lib/acp/version.rb +1 -1
- data/sig/generated/acp/agent_connection/optional_route.rbs +35 -0
- data/sig/generated/acp/agent_connection.rbs +5 -21
- data/sig/generated/acp/client_connection/elicitations.rbs +29 -0
- data/sig/generated/acp/client_connection/inbound/route.rbs +26 -0
- data/sig/generated/acp/client_connection/inbound.rbs +90 -0
- data/sig/generated/acp/client_connection/streams.rbs +7 -28
- data/sig/generated/acp/client_connection.rbs +12 -136
- data/sig/generated/acp/extensions.rbs +4 -0
- data/sig/generated/acp/protocol.rbs +8 -0
- data/sig/generated/acp/transport/reply.rbs +3 -3
- data/sig/generated/acp/transport/stdio.rbs +4 -4
- data/sig/generated/acp/types.rbs +9 -0
- data/sig/manual/acp/agent_connection.rbs +5 -57
- data/sig/manual/acp/client_connection.rbs +27 -0
- data/sig/manual/acp/transport/stdio.rbs +1 -5
- metadata +9 -5
- data/lib/acp/agent_connection/optional_method/spec.rb +0 -42
- data/lib/acp/agent_connection/optional_method.rb +0 -40
- data/sig/generated/acp/agent_connection/optional_method/spec.rbs +0 -32
- data/sig/generated/acp/agent_connection/optional_method.rbs +0 -7
data/lib/acp/agent_connection.rb
CHANGED
|
@@ -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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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.
|
|
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
|
-
|
|
60
|
-
|
|
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
|
-
|
|
118
|
+
routed = routes.select { |route| route.routed?(response, agent) }
|
|
70
119
|
@transport.start(
|
|
71
|
-
requests: requests(agent, client).
|
|
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
|
|
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
|
-
|
|
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
|
-
#
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
-
|
|
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
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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 =
|
|
181
|
-
|
|
182
|
-
|
|
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
|
data/lib/acp/agent_process.rb
CHANGED
|
@@ -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.
|
|
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
|
|
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
|
|
73
|
+
@stdout.close
|
|
74
74
|
@err_reader&.join(1)
|
|
75
|
-
@err_r&.close
|
|
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
|