acp-sdk 0.12.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 +27 -0
- data/README.md +141 -69
- data/lib/acp/agent_connection/client.rb +4 -5
- data/lib/acp/agent_connection/optional_route.rb +45 -0
- data/lib/acp/agent_connection.rb +122 -78
- 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 +22 -54
- data/lib/acp/client_connection.rb +36 -311
- 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/client.rbs +3 -3
- data/sig/generated/acp/agent_connection/optional_route.rbs +35 -0
- data/sig/generated/acp/agent_connection.rbs +8 -24
- 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 +8 -29
- data/sig/generated/acp/client_connection.rbs +13 -137
- 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 -79
- data/sig/manual/acp/client_connection.rbs +25 -5
- data/sig/manual/acp/transport/stdio.rbs +1 -5
- data/sig/manual/acp/transport.rbs +0 -16
- 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
|
@@ -1,39 +1,31 @@
|
|
|
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
|
|
9
6
|
) #: ACP::Types::RequestPermissionResponse
|
|
10
7
|
|
|
11
|
-
# @rbs @sender: ACP::
|
|
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
|
-
# @rbs sender: ACP::
|
|
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
|
|
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
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
|
|
102
|
+
@permission.call(request)
|
|
115
103
|
rescue StandardError => e
|
|
116
|
-
|
|
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
|
-
#
|
|
123
|
-
#
|
|
124
|
-
|
|
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
|
|
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 <<
|
|
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
|
|
@@ -1,223 +1,63 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
class ACP::ClientConnection
|
|
4
|
-
# @rbs @transport: ACP::Transport::
|
|
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]
|
|
4
|
+
# @rbs @transport: ACP::Transport::Stdio
|
|
24
5
|
# @rbs @streams: ACP::ClientConnection::Streams
|
|
25
|
-
# @rbs @
|
|
6
|
+
# @rbs @inbound: ACP::ClientConnection::Inbound
|
|
26
7
|
|
|
27
8
|
IGNORE = ->(_notification) {} #: ^(ACP::Types::SessionNotification) -> void
|
|
28
9
|
|
|
29
|
-
|
|
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
|
-
# @rbs transport: ACP::Transport::_Transport
|
|
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:
|
|
169
|
-
def
|
|
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
|
-
@
|
|
190
|
-
@
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
480
|
-
|
|
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
|
data/lib/acp/extensions.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
data/lib/acp/transport/reply.rb
CHANGED
|
@@ -2,12 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
class ACP::Transport::Reply
|
|
4
4
|
# @dynamic result
|
|
5
|
-
attr_reader :result #: ACP::Transport::
|
|
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::
|
|
10
|
+
# @rbs result: ACP::Transport::_Response
|
|
11
11
|
# @rbs after: ^() -> void
|
|
12
12
|
# @rbs return: void
|
|
13
13
|
def initialize(result, after:)
|
data/lib/acp/transport/stdio.rb
CHANGED
|
@@ -245,7 +245,7 @@ class ACP::Transport::Stdio
|
|
|
245
245
|
#
|
|
246
246
|
# @rbs handler: _Handler
|
|
247
247
|
# @rbs params: untyped
|
|
248
|
-
# @rbs return: (
|
|
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: (
|
|
257
|
+
# @rbs outcome: (ACP::Transport::_Response | ACP::RequestError)
|
|
258
258
|
# @rbs return: void
|
|
259
259
|
def reply(id, outcome)
|
|
260
260
|
payload = case outcome
|
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
|
@@ -3,15 +3,15 @@
|
|
|
3
3
|
class ACP::AgentConnection::Client
|
|
4
4
|
@mcp_advertised: bool
|
|
5
5
|
|
|
6
|
-
@peer: ACP::Transport::
|
|
6
|
+
@peer: ACP::Transport::Stdio
|
|
7
7
|
|
|
8
8
|
# @dynamic capabilities, capabilities=
|
|
9
9
|
attr_accessor capabilities: ACP::Types::ClientCapabilities?
|
|
10
10
|
|
|
11
|
-
# @rbs peer: ACP::Transport::
|
|
11
|
+
# @rbs peer: ACP::Transport::Stdio
|
|
12
12
|
# @rbs mcp_advertised: bool
|
|
13
13
|
# @rbs return: void
|
|
14
|
-
def initialize: (peer: ACP::Transport::
|
|
14
|
+
def initialize: (peer: ACP::Transport::Stdio, ?mcp_advertised: bool) -> void
|
|
15
15
|
|
|
16
16
|
# @rbs session_id: String
|
|
17
17
|
# @rbs update: ACP::Types::SessionUpdate::t
|