acp-sdk 0.4.0 → 0.6.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f0121ff621c45deb5578a15051611ea98d8d174193528c3df214fe4e036561ff
4
- data.tar.gz: e26701095772e5bbf41a18a8e2bc9f4ac9609657aae1dee7c82891de6cf5b48c
3
+ metadata.gz: 367678f3b99c82b2f768879be0308249d9b32ce8ce94c3d4e6de6c12c4a1c043
4
+ data.tar.gz: 071e457b8e00fa19be760fff65cfd69998102e54b4b3d47f9b3cc0d80c359fc3
5
5
  SHA512:
6
- metadata.gz: 66f390b3cf58c661064fe12ca5c7bbf04f560a81187f8cd37cce6d820870eb9f83717b655d04305a9a926e3cc08af618c1956acb7c2425899d2d3238e05d2651
7
- data.tar.gz: 13853bc47c5c15a249296d9418fb951f52d512fea28f92fe9bf601c1a3e43d98840cbbbbfc181ff53150572db0393388553bf27f3c1746ca135f0467905d4270
6
+ metadata.gz: 706206bd29062ef6ecf1856b9e9671113e02bb6eb6ac837c2815bfcdbbff4bc07ac8d2c6394ec6f7b33c37a3832a9e0c4bb5c4e5c4699c1cdc129e875f693605
7
+ data.tar.gz: 5a73a945d099dedcf5837aa1c897b1dca0313a49d131337756b26c28731ddedb14b96c21722c7d6ecff3a21e6bed811e036551724c12bebcdf53f88a56121f8e
data/CHANGELOG.md CHANGED
@@ -1,5 +1,33 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.6.0](https://github.com/bottrall/acp-sdk/compare/v0.5.0...v0.6.0) (2026-10-04)
4
+
5
+
6
+ ### Features
7
+
8
+ * answer pending permission requests with cancelled after session_cancel ([#70](https://github.com/bottrall/acp-sdk/issues/70)) ([06a218b](https://github.com/bottrall/acp-sdk/commit/06a218b6262ff409e301d08a43330af5b60837e0))
9
+ * extension methods on both connections ([#72](https://github.com/bottrall/acp-sdk/issues/72)) ([d1faa1a](https://github.com/bottrall/acp-sdk/commit/d1faa1a738019e73a4dbad5b59f480795f8dd4bf))
10
+
11
+ ## [0.5.0](https://github.com/bottrall/acp-sdk/compare/v0.4.0...v0.5.0) (2026-10-04)
12
+
13
+
14
+ ### Features
15
+
16
+ * add authenticate and logout to ACP::ClientConnection ([#63](https://github.com/bottrall/acp-sdk/issues/63)) ([460585a](https://github.com/bottrall/acp-sdk/commit/460585a9a9401e35725ea379432694d9b76f35b6))
17
+ * add session_set_mode and session_set_config_option to ACP::ClientConnection ([#66](https://github.com/bottrall/acp-sdk/issues/66)) ([994179f](https://github.com/bottrall/acp-sdk/commit/994179f9ab51918bee7d29c8b473e0f05a55a7ad))
18
+ * add session/resume, session/close and session/delete to the client ([#65](https://github.com/bottrall/acp-sdk/issues/65)) ([4af834a](https://github.com/bottrall/acp-sdk/commit/4af834a5bc519753bad36d41efba902bcddc296b)), closes [#37](https://github.com/bottrall/acp-sdk/issues/37)
19
+ * elicitation on ACP::AgentConnection::Client ([#67](https://github.com/bottrall/acp-sdk/issues/67)) ([7d3212a](https://github.com/bottrall/acp-sdk/commit/7d3212a82ec6392351d8df1349b2fc21bf0bf030)), closes [#41](https://github.com/bottrall/acp-sdk/issues/41)
20
+ * filesystem handlers on ACP::ClientConnection ([#68](https://github.com/bottrall/acp-sdk/issues/68)) ([053f091](https://github.com/bottrall/acp-sdk/commit/053f0910adc3a7016bf1769099a54655f41bb9a6))
21
+ * injectable logger for dropped messages and swallowed errors ([#59](https://github.com/bottrall/acp-sdk/issues/59)) ([e66a306](https://github.com/bottrall/acp-sdk/commit/e66a30622922717d1be6d27c5b9a2931643fcd6b))
22
+
23
+
24
+ ### Bug Fixes
25
+
26
+ * call ACP::RequestError.unadvertised in elicitation capability checks ([#69](https://github.com/bottrall/acp-sdk/issues/69)) ([fda6dad](https://github.com/bottrall/acp-sdk/commit/fda6dad13e7fe308b5ff63e4c9e6fa9eef6e8274))
27
+ * only advertise terminal auth methods to clients that support them ([#61](https://github.com/bottrall/acp-sdk/issues/61)) ([1d2674a](https://github.com/bottrall/acp-sdk/commit/1d2674a978d938834d69d2c68df4f2c918ea801c)), closes [#33](https://github.com/bottrall/acp-sdk/issues/33)
28
+ * refuse overlapping streaming calls for the same session ([#64](https://github.com/bottrall/acp-sdk/issues/64)) ([a3a5b34](https://github.com/bottrall/acp-sdk/commit/a3a5b343f0ba2555b62643577226166fada27485))
29
+ * reject an unsupported protocol version in ACP::ClientConnection#connect ([#62](https://github.com/bottrall/acp-sdk/issues/62)) ([6980b32](https://github.com/bottrall/acp-sdk/commit/6980b328bcec997ae68c6af9077d623a73c15d39)), closes [#34](https://github.com/bottrall/acp-sdk/issues/34)
30
+
3
31
  ## [0.4.0](https://github.com/bottrall/acp-sdk/compare/v0.3.0...v0.4.0) (2026-10-02)
4
32
 
5
33
 
data/README.md CHANGED
@@ -55,12 +55,15 @@ The agent's contract:
55
55
  - `client.capabilities` is `nil` until the client sends `initialize`.
56
56
  - `client.read_text_file` and `client.write_text_file` take an `ACP::Types::ReadTextFileRequest` or `ACP::Types::WriteTextFileRequest` and return the response type or an `ACP::RequestError`. Unless `client.capabilities` advertises `fs.read_text_file` or `fs.write_text_file`, they return `-32601` without sending anything to the client.
57
57
  - `client.create_terminal`, `client.terminal_output`, `client.wait_for_terminal_exit`, `client.kill_terminal` and `client.release_terminal` take the matching `ACP::Types` request (`CreateTerminalRequest`, `TerminalOutputRequest`, `WaitForTerminalExitRequest`, `KillTerminalRequest`, `ReleaseTerminalRequest`) and return its response type or an `ACP::RequestError`. Unless `client.capabilities` advertises `terminal`, they return `-32601` without sending anything to the client. The agent must release every terminal it creates.
58
+ - `client.create_elicitation` takes an `ACP::Types::CreateElicitationRequest` and returns an `ACP::Types::CreateElicitationResponse` or an `ACP::RequestError`. Unless `client.capabilities` advertises the `elicitation.form` or `elicitation.url` mode the request asks for, it returns `-32601` without sending anything to the client. A response action the SDK does not know comes through as the raw hash the client sent. `client.complete_elicitation` takes an `ACP::Types::CompleteElicitationNotification` and sends nothing, returning `-32601`, unless the client advertises `elicitation.url`, since only URL-mode elicitations are completed.
58
59
 
59
- [`examples/echo_agent.rb`](https://github.com/bottrall/acp-sdk/blob/main/examples/echo_agent.rb) is a complete agent that streams updates, asks permission, handles cancellation, reads and writes files and runs commands through the client (`/read <path>`, `/write <path> <text>` and `/run <command> [args]`, each advertised as a slash command only when the client supports it), and supports `session/load` and `session/list`.
60
+ Extensions are `_`-prefixed methods outside the spec. `AgentConnection` takes `extension_requests` and `extension_notifications` hashes keyed by the raw wire name, and `client.ext_request` and `client.ext_notify` send them to the client; a handler name without the `_` prefix raises `ArgumentError`, and an extension request with no handler is answered with `-32601`.
61
+
62
+ [`examples/echo_agent.rb`](https://github.com/bottrall/acp-sdk/blob/main/examples/echo_agent.rb) is a complete agent that streams updates, asks permission, handles cancellation, reads and writes files and runs commands through the client (`/read <path>`, `/write <path> <text>` and `/run <command> [args]`, each advertised as a slash command only when the client supports it), and supports `session/load`, `session/list`, `session/resume`, `session/close` and `session/delete`.
60
63
 
61
64
  ## Driving an agent
62
65
 
63
- `ACP::ClientConnection` is the other side of the same transport. Each method takes the request's generated `ACP::Types` object and returns its response type or the agent's `ACP::RequestError`. `connect` sends `initialize`, since Ruby reserves that name for the constructor.
66
+ `ACP::ClientConnection` is the other side of the same transport. Each method takes the request's generated `ACP::Types` object and returns its response type or the agent's `ACP::RequestError`. `connect` sends `initialize`, since Ruby reserves that name for the constructor, and `authenticate` and `logout` send their namesake methods. `session_set_mode` sends `session/set_mode`, and `session_set_config_option` sends `session/set_config_option` and returns the session's complete option list, which replaces any state held from an earlier reply.
64
67
 
65
68
  `ACP::AgentProcess.spawn` starts a command with args, env and cwd, wires its stdio to a new `ACP::ClientConnection` and starts it. The block receives the connection and the process handle (`pid`, plus `stderr` when captured), and the child is terminated — after its stdin is closed, with TERM and then KILL if it will not exit — when the block exits, so a raised block cannot leak the process. Without a block, `spawn` returns the process and calling `terminate` is yours. `stderr` is `:inherit` by default, so the child writes to your stderr; pass `:capture` to collect it on the process instead.
66
69
 
@@ -90,12 +93,20 @@ Any transport over a pair of IOs works too, so the connection can still be wired
90
93
  connection = ACP::ClientConnection.new(
91
94
  transport: ACP::Transport::Stdio.new(input: some_io, output: other_io),
92
95
  permission: ->(request) { ask_the_user(request) },
96
+ read_text_file: ->(request) { ACP::Types::ReadTextFileResponse.new(content: File.read(request.path)) },
97
+ write_text_file: ->(request) {
98
+ File.write(request.path, request.content)
99
+ ACP::Types::WriteTextFileResponse.new
100
+ },
93
101
  updates: ->(notification) { show(notification) }
94
102
  )
95
103
  ```
96
104
 
97
- - `session_prompt` and `session_load` yield the session's updates on the calling thread as they arrive and return once the agent replies. Updates outside those calls, such as the available commands after `session/new`, go to `updates`, which runs on the reader thread and must return quickly.
105
+ - `session_prompt` and `session_load` yield the session's updates on the calling thread as they arrive and return once the agent replies. Updates outside those calls, such as the available commands after `session/new`, go to `updates`, which runs on the reader thread and must return quickly. `session_resume` does not take a block: the agent must not replay history on resume, so unlike `session_load` there is nothing to stream. `session_close` and `session_delete` end the session, discarding it on the agent, and return once the agent replies.
98
106
  - `permission` answers `session/request_permission` with an `ACP::Types::RequestPermissionResponse` or an `ACP::RequestError`. It runs on its own thread, so it may block while the user decides, and `session_cancel` can be sent meanwhile.
107
+ - `read_text_file` and `write_text_file` answer the agent's `fs/read_text_file` and `fs/write_text_file` with an `ACP::Types::ReadTextFileResponse`, an `ACP::Types::WriteTextFileResponse` or an `ACP::RequestError`. They are routed by the `fs` capabilities `connect` sends: a method the client does not advertise is answered with `-32601` without reaching the handler, and `connect` raises `ArgumentError` — before sending `initialize` — when the request advertises a capability that has no handler.
108
+
109
+ Extensions work the same way in both directions: `extension_requests` and `extension_notifications` are hashes keyed by the raw `_`-prefixed wire name, `ext_request` sends one to the agent and returns its result as-is, and `ext_notify` sends an extension notification. A handler name without the `_` prefix raises `ArgumentError`, and an extension request nothing answers gets `-32601`.
99
110
 
100
111
  ## Contributing
101
112
 
@@ -39,7 +39,7 @@ class ACP::AgentConnection::Client
39
39
  # @rbs request: ACP::Types::ReadTextFileRequest
40
40
  # @rbs return: ACP::Types::ReadTextFileResponse | ACP::RequestError
41
41
  def read_text_file(request)
42
- return unadvertised('fs.readTextFile') unless capabilities&.fs&.read_text_file
42
+ return ACP::RequestError.unadvertised('fs.readTextFile') unless capabilities&.fs&.read_text_file
43
43
 
44
44
  call('fs/read_text_file', ACP::Types::ReadTextFileResponse, request)
45
45
  end
@@ -47,7 +47,7 @@ class ACP::AgentConnection::Client
47
47
  # @rbs request: ACP::Types::WriteTextFileRequest
48
48
  # @rbs return: ACP::Types::WriteTextFileResponse | ACP::RequestError
49
49
  def write_text_file(request)
50
- return unadvertised('fs.writeTextFile') unless capabilities&.fs&.write_text_file
50
+ return ACP::RequestError.unadvertised('fs.writeTextFile') unless capabilities&.fs&.write_text_file
51
51
 
52
52
  call('fs/write_text_file', ACP::Types::WriteTextFileResponse, request)
53
53
  end
@@ -82,6 +82,49 @@ class ACP::AgentConnection::Client
82
82
  terminal_call('terminal/release', ACP::Types::ReleaseTerminalResponse, request)
83
83
  end
84
84
 
85
+ # @rbs request: ACP::Types::CreateElicitationRequest
86
+ # @rbs return: ACP::Types::CreateElicitationResponse | ACP::RequestError
87
+ def create_elicitation(request)
88
+ case request.mode
89
+ when ACP::Types::CreateElicitationRequest::Mode::Form
90
+ return ACP::RequestError.unadvertised('elicitation.form') unless capabilities&.elicitation&.form
91
+ when ACP::Types::CreateElicitationRequest::Mode::Url
92
+ return ACP::RequestError.unadvertised('elicitation.url') unless capabilities&.elicitation&.url
93
+ else
94
+ return ACP::RequestError.unadvertised("elicitation.#{request.mode['mode']}")
95
+ end
96
+
97
+ call('elicitation/create', ACP::Types::CreateElicitationResponse, request)
98
+ end
99
+
100
+ # @rbs notification: ACP::Types::CompleteElicitationNotification
101
+ # @rbs return: void
102
+ def complete_elicitation(notification)
103
+ return ACP::RequestError.unadvertised('elicitation.url') unless capabilities&.elicitation&.url
104
+
105
+ @peer.notify('elicitation/complete', notification.to_h)
106
+ end
107
+
108
+ # Sends an extension request, keyed by its raw `_`-prefixed wire name, and
109
+ # returns the client's reply as-is: extension methods have no schema to
110
+ # parse the result into.
111
+ #
112
+ # @rbs method: String
113
+ # @rbs params: untyped
114
+ # @rbs return: (Hash[String, untyped] | ACP::RequestError)
115
+ def ext_request(method, params = nil)
116
+ ACP::Extensions.validate_name(method)
117
+ @peer.request(method, params)
118
+ end
119
+
120
+ # @rbs method: String
121
+ # @rbs params: untyped
122
+ # @rbs return: void
123
+ def ext_notify(method, params = nil)
124
+ ACP::Extensions.validate_name(method)
125
+ @peer.notify(method, params)
126
+ end
127
+
85
128
  private
86
129
 
87
130
  # @rbs rpc_method: String
@@ -102,19 +145,8 @@ class ACP::AgentConnection::Client
102
145
  # @rbs request: ACP::AgentConnection::_Response
103
146
  # @rbs return: untyped
104
147
  def terminal_call(rpc_method, type, request)
105
- return unadvertised('terminal') unless capabilities&.terminal
148
+ return ACP::RequestError.unadvertised('terminal') unless capabilities&.terminal
106
149
 
107
150
  call(rpc_method, type, request)
108
151
  end
109
-
110
- # Same method-not-found code a peer would send, but with a message that says
111
- # the refusal was local, so logs can tell the two cases apart.
112
- #
113
- # @rbs capability: String
114
- # @rbs return: ACP::RequestError
115
- def unadvertised(capability)
116
- ACP::RequestError.new(
117
- code: ACP::RequestError::METHOD_NOT_FOUND, message: "Client does not advertise #{capability}"
118
- )
119
- end
120
152
  end
@@ -2,8 +2,13 @@
2
2
 
3
3
  class ACP::AgentConnection
4
4
  # @rbs @transport: ACP::AgentConnection::_Transport
5
- # @rbs @initialize_response: ACP::Types::InitializeResponse
5
+ # @rbs @capabilities: ACP::Types::AgentCapabilities
6
+ # @rbs @agent_info: ACP::Types::Implementation?
7
+ # @rbs @auth_methods: Array[ACP::Types::AuthMethod::t]
6
8
  # @rbs @factory: ^(ACP::AgentConnection::Client) -> ACP::AgentConnection::_Agent
9
+ # @rbs @extension_requests: Hash[String, ^(untyped) -> untyped]
10
+ # @rbs @extension_notifications: Hash[String, ^(untyped) -> void]
11
+ # @rbs @logger: ACP::Transport::_Logger
7
12
 
8
13
  PROTOCOL_VERSION = 1 #: Integer
9
14
  OPTIONAL = [
@@ -27,16 +32,30 @@ class ACP::AgentConnection
27
32
  # @rbs capabilities: ACP::Types::AgentCapabilities
28
33
  # @rbs agent_info: ACP::Types::Implementation?
29
34
  # @rbs auth_methods: Array[ACP::Types::AuthMethod::t]
35
+ # @rbs extension_requests: Hash[String, ^(untyped) -> untyped]
36
+ # @rbs extension_notifications: Hash[String, ^(untyped) -> void]
37
+ # @rbs logger: ACP::Transport::_Logger
30
38
  # @rbs &factory: (ACP::AgentConnection::Client) -> ACP::AgentConnection::_Agent
31
39
  # @rbs return: void
32
- def initialize(transport:, capabilities:, agent_info: nil, auth_methods: [], &factory)
40
+ def initialize(
41
+ transport:,
42
+ capabilities:,
43
+ agent_info: nil,
44
+ auth_methods: [],
45
+ extension_requests: {},
46
+ extension_notifications: {},
47
+ logger: ACP::Transport::StderrLogger.new,
48
+ &factory
49
+ )
33
50
  @transport = transport
34
- @initialize_response = ACP::Types::InitializeResponse.new(
35
- protocol_version: PROTOCOL_VERSION,
36
- agent_capabilities: capabilities,
37
- auth_methods:,
38
- agent_info:
39
- )
51
+ @logger = logger
52
+ @capabilities = capabilities
53
+ @agent_info = agent_info
54
+ @auth_methods = auth_methods
55
+ @extension_requests = extension_requests
56
+ @extension_notifications = extension_notifications
57
+ extension_requests.each_key { |name| ACP::Extensions.validate_name(name) }
58
+ extension_notifications.each_key { |name| ACP::Extensions.validate_name(name) }
40
59
  @factory = factory
41
60
  end
42
61
 
@@ -44,12 +63,18 @@ class ACP::AgentConnection
44
63
  def start
45
64
  client = ACP::AgentConnection::Client.new(peer: @transport)
46
65
  agent = @factory.call(client)
47
- advertised, unadvertised = OPTIONAL.partition { |method| method.advertised?(@initialize_response) }
66
+ # Routes are fixed before a client connects, so the check sees the
67
+ # unfiltered auth methods: a terminal-capable client still needs
68
+ # authenticate routed.
69
+ advertised, unadvertised = OPTIONAL.partition { |method| method.advertised?(initialize_response(@auth_methods)) }
48
70
  missing = advertised.map(&:agent_method).reject { |name| agent.respond_to?(name) }
49
71
  raise ArgumentError, "initialize advertises methods the agent lacks: #{missing.join(', ')}" unless missing.empty?
50
72
 
51
73
  unrouted = unadvertised.map(&:rpc_method) + PER_SESSION.reject { |_, name| agent.respond_to?(name) }.keys
52
- @transport.start(requests: requests(agent, client).except(*unrouted), notifications: notifications(agent))
74
+ @transport.start(
75
+ requests: requests(agent, client).except(*unrouted).merge(@extension_requests),
76
+ notifications: notifications(agent).merge(@extension_notifications)
77
+ )
53
78
  end
54
79
 
55
80
  private
@@ -82,7 +107,20 @@ class ACP::AgentConnection
82
107
  # @rbs agent: ACP::AgentConnection::_Agent
83
108
  # @rbs return: Hash[String, ^(untyped) -> void]
84
109
  def notifications(agent)
85
- { 'session/cancel' => ->(params) { agent.cancel(ACP::Types::CancelNotification.from_h(params)) } }
110
+ { 'session/cancel' => ->(params) { cancel(agent, params) } }
111
+ end
112
+
113
+ # A notification has no reply to carry a parse failure.
114
+ #
115
+ # @rbs agent: ACP::AgentConnection::_Agent
116
+ # @rbs params: untyped
117
+ # @rbs return: void
118
+ def cancel(agent, params)
119
+ notification = ACP::Types::CancelNotification.from_h(params)
120
+ rescue KeyError, TypeError, NoMethodError => e
121
+ @logger.warn("dropped malformed session/cancel: #{e.class}: #{e.message}")
122
+ else
123
+ agent.cancel(notification)
86
124
  end
87
125
 
88
126
  # Generated from_h raises on a missing key or a value of the wrong shape.
@@ -104,8 +142,34 @@ class ACP::AgentConnection
104
142
  # @rbs request: ACP::Types::InitializeRequest
105
143
  # @rbs return: ACP::Types::InitializeResponse
106
144
  def connect(client, request)
107
- client.capabilities = request.client_capabilities || ACP::Types::ClientCapabilities.new
108
- @initialize_response
145
+ capabilities = request.client_capabilities || ACP::Types::ClientCapabilities.new
146
+ client.capabilities = capabilities
147
+ initialize_response(auth_methods(capabilities))
148
+ end
149
+
150
+ # @rbs auth_methods: Array[ACP::Types::AuthMethod::t]
151
+ # @rbs return: ACP::Types::InitializeResponse
152
+ def initialize_response(auth_methods)
153
+ ACP::Types::InitializeResponse.new(
154
+ protocol_version: PROTOCOL_VERSION,
155
+ agent_capabilities: @capabilities,
156
+ auth_methods:,
157
+ agent_info: @agent_info
158
+ )
159
+ end
160
+
161
+ # @rbs capabilities: ACP::Types::ClientCapabilities
162
+ # @rbs return: Array[ACP::Types::AuthMethod::t]
163
+ def auth_methods(capabilities)
164
+ return @auth_methods if capabilities.auth&.terminal
165
+
166
+ @auth_methods.reject { |method| terminal_auth_method?(method) }
167
+ end
168
+
169
+ # @rbs method: ACP::Types::AuthMethod::t
170
+ # @rbs return: bool
171
+ def terminal_auth_method?(method)
172
+ method.is_a?(ACP::Types::AuthMethod::Terminal) || (method.is_a?(Hash) && method['type'] == 'terminal')
109
173
  end
110
174
 
111
175
  # session_created runs after the reply because the client must know the
@@ -3,29 +3,69 @@
3
3
  class ACP::ClientConnection
4
4
  # @rbs @transport: ACP::AgentConnection::_Transport
5
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))?
6
8
  # @rbs @updates: ACP::ClientConnection::_UpdateHandler
9
+ # @rbs @fs_capabilities: ACP::Types::FileSystemCapabilities?
10
+ # @rbs @extension_requests: Hash[String, ^(untyped) -> untyped]
11
+ # @rbs @extension_notifications: Hash[String, ^(untyped) -> void]
7
12
  # @rbs @lock: Thread::Mutex
8
13
  # @rbs @streams: Hash[String, Thread::Queue]
14
+ # @rbs @pending_permissions: Hash[String, Array[Thread::Queue]]
15
+ # @rbs @logger: ACP::Transport::_Logger
16
+
17
+ PROTOCOL_VERSION = ACP::AgentConnection::PROTOCOL_VERSION #: Integer
9
18
 
10
19
  IGNORE = ->(_notification) {} #: ^(ACP::Types::SessionNotification) -> void
11
20
 
21
+ CANCELLED = ACP::Types::RequestPermissionResponse.new(
22
+ outcome: ACP::Types::RequestPermissionOutcome::Cancelled.new
23
+ ) #: ACP::Types::RequestPermissionResponse
24
+
12
25
  # @rbs transport: ACP::AgentConnection::_Transport
13
26
  # @rbs permission: ACP::ClientConnection::_PermissionHandler
27
+ # @rbs read_text_file: (^(ACP::Types::ReadTextFileRequest) -> (ACP::Types::ReadTextFileResponse | ACP::RequestError))?
28
+ # @rbs write_text_file: (^(ACP::Types::WriteTextFileRequest) -> (ACP::Types::WriteTextFileResponse | ACP::RequestError))?
14
29
  # @rbs updates: ACP::ClientConnection::_UpdateHandler
30
+ # @rbs extension_requests: Hash[String, ^(untyped) -> untyped]
31
+ # @rbs extension_notifications: Hash[String, ^(untyped) -> void]
32
+ # @rbs logger: ACP::Transport::_Logger
15
33
  # @rbs return: void
16
- def initialize(transport:, permission:, updates: IGNORE)
34
+ def initialize(
35
+ transport:,
36
+ permission:,
37
+ read_text_file: nil,
38
+ write_text_file: nil,
39
+ updates: IGNORE,
40
+ extension_requests: {},
41
+ extension_notifications: {},
42
+ logger: ACP::Transport::StderrLogger.new
43
+ )
17
44
  @transport = transport
18
45
  @permission = permission
46
+ @read_text_file = read_text_file
47
+ @write_text_file = write_text_file
19
48
  @updates = updates
49
+ @fs_capabilities = nil
50
+ @extension_requests = extension_requests
51
+ @extension_notifications = extension_notifications
52
+ extension_requests.each_key { |name| ACP::Extensions.validate_name(name) }
53
+ extension_notifications.each_key { |name| ACP::Extensions.validate_name(name) }
20
54
  @lock = Mutex.new
21
55
  @streams = {}
56
+ @pending_permissions = {}
57
+ @logger = logger
22
58
  end
23
59
 
24
60
  # @rbs return: Thread
25
61
  def start
26
62
  @transport.start(
27
- requests: { 'session/request_permission' => method(:request_permission) },
28
- notifications: { 'session/update' => method(:dispatch) }
63
+ requests: {
64
+ 'session/request_permission' => method(:request_permission),
65
+ 'fs/read_text_file' => method(:read_text_file),
66
+ 'fs/write_text_file' => method(:write_text_file)
67
+ }.merge(@extension_requests),
68
+ notifications: { 'session/update' => method(:dispatch) }.merge(@extension_notifications)
29
69
  )
30
70
  end
31
71
 
@@ -35,7 +75,23 @@ class ACP::ClientConnection
35
75
  # @rbs request: ACP::Types::InitializeRequest
36
76
  # @rbs return: ACP::Types::InitializeResponse | ACP::RequestError
37
77
  def connect(request)
38
- parse(ACP::Types::InitializeResponse, @transport.request('initialize', request.to_h))
78
+ capabilities = request.client_capabilities&.fs
79
+ unserved = unserved_fs_methods(capabilities)
80
+ unless unserved.empty?
81
+ raise ArgumentError, "initialize advertises fs methods no handler serves: #{unserved.join(', ')}"
82
+ end
83
+
84
+ @fs_capabilities = capabilities
85
+ parse(ACP::Types::InitializeResponse, @transport.request('initialize', request.to_h)).then do |response|
86
+ next response if response.is_a?(ACP::RequestError)
87
+
88
+ next response if response.protocol_version == PROTOCOL_VERSION
89
+
90
+ ACP::RequestError.unsupported_protocol_version(
91
+ requested: request.protocol_version,
92
+ returned: response.protocol_version
93
+ )
94
+ end
39
95
  end
40
96
 
41
97
  # @rbs request: ACP::Types::NewSessionRequest
@@ -64,10 +120,85 @@ class ACP::ClientConnection
64
120
  parse(ACP::Types::ListSessionsResponse, @transport.request('session/list', request.to_h))
65
121
  end
66
122
 
123
+ # The agent does not replay history on resume, so unlike session_load there
124
+ # is nothing to stream and no block is taken.
125
+ #
126
+ # @rbs request: ACP::Types::ResumeSessionRequest
127
+ # @rbs return: ACP::Types::ResumeSessionResponse | ACP::RequestError
128
+ def session_resume(request)
129
+ parse(ACP::Types::ResumeSessionResponse, @transport.request('session/resume', request.to_h))
130
+ end
131
+
132
+ # @rbs request: ACP::Types::CloseSessionRequest
133
+ # @rbs return: ACP::Types::CloseSessionResponse | ACP::RequestError
134
+ def session_close(request)
135
+ parse(ACP::Types::CloseSessionResponse, @transport.request('session/close', request.to_h))
136
+ end
137
+
138
+ # @rbs request: ACP::Types::DeleteSessionRequest
139
+ # @rbs return: ACP::Types::DeleteSessionResponse | ACP::RequestError
140
+ def session_delete(request)
141
+ parse(ACP::Types::DeleteSessionResponse, @transport.request('session/delete', request.to_h))
142
+ end
143
+
144
+ # The spec requires a pending session/request_permission to be answered with
145
+ # the cancelled outcome once the turn is cancelled, so every queue registered
146
+ # for the session gets one before this returns. The notification goes first
147
+ # so the wire order is cancel, then the cancelled replies.
148
+ #
67
149
  # @rbs notification: ACP::Types::CancelNotification
68
150
  # @rbs return: void
69
151
  def session_cancel(notification)
70
152
  @transport.notify('session/cancel', notification.to_h)
153
+ pending = @lock.synchronize { @pending_permissions.delete(notification.session_id) }
154
+ pending&.each { |replies| replies << [:value, CANCELLED] }
155
+ end
156
+
157
+ # @rbs request: ACP::Types::SetSessionModeRequest
158
+ # @rbs return: ACP::Types::SetSessionModeResponse | ACP::RequestError
159
+ def session_set_mode(request)
160
+ parse(ACP::Types::SetSessionModeResponse, @transport.request('session/set_mode', request.to_h))
161
+ end
162
+
163
+ # The reply carries the session's complete option list, so it replaces any
164
+ # state held from an earlier reply rather than merging into it.
165
+ #
166
+ # @rbs request: ACP::Types::SetSessionConfigOptionRequest
167
+ # @rbs return: ACP::Types::SetSessionConfigOptionResponse | ACP::RequestError
168
+ def session_set_config_option(request)
169
+ parse(ACP::Types::SetSessionConfigOptionResponse, @transport.request('session/set_config_option', request.to_h))
170
+ end
171
+
172
+ # @rbs request: ACP::Types::AuthenticateRequest
173
+ # @rbs return: ACP::Types::AuthenticateResponse | ACP::RequestError
174
+ def authenticate(request)
175
+ parse(ACP::Types::AuthenticateResponse, @transport.request('authenticate', request.to_h))
176
+ end
177
+
178
+ # @rbs request: ACP::Types::LogoutRequest
179
+ # @rbs return: ACP::Types::LogoutResponse | ACP::RequestError
180
+ def logout(request)
181
+ parse(ACP::Types::LogoutResponse, @transport.request('logout', request.to_h))
182
+ end
183
+
184
+ # Sends an extension request, keyed by its raw `_`-prefixed wire name, and
185
+ # returns the agent's reply as-is: extension methods have no schema to
186
+ # parse the result into.
187
+ #
188
+ # @rbs method: String
189
+ # @rbs params: untyped
190
+ # @rbs return: (Hash[String, untyped] | ACP::RequestError)
191
+ def ext_request(method, params = nil)
192
+ ACP::Extensions.validate_name(method)
193
+ @transport.request(method, params)
194
+ end
195
+
196
+ # @rbs method: String
197
+ # @rbs params: untyped
198
+ # @rbs return: void
199
+ def ext_notify(method, params = nil)
200
+ ACP::Extensions.validate_name(method)
201
+ @transport.notify(method, params)
71
202
  end
72
203
 
73
204
  private
@@ -94,7 +225,21 @@ class ACP::ClientConnection
94
225
  # @rbs return: (Hash[String, untyped] | ACP::RequestError)
95
226
  def stream(session_id, method, params)
96
227
  queue = Thread::Queue.new
97
- @lock.synchronize { @streams[session_id] = queue }
228
+ # The check and the insert share one lock acquisition so a concurrent
229
+ # stream cannot register between them.
230
+ claimed = @lock.synchronize do
231
+ if @streams.key?(session_id)
232
+ false
233
+ else
234
+ @streams[session_id] = queue
235
+ true
236
+ end
237
+ end
238
+ unless claimed
239
+ return ACP::RequestError.new(
240
+ code: ACP::RequestError::INVALID_REQUEST, message: "A stream is already open for session #{session_id}"
241
+ )
242
+ end
98
243
  requester = Thread.new do
99
244
  @transport.request(method, params)
100
245
  ensure
@@ -107,16 +252,24 @@ class ACP::ClientConnection
107
252
  end
108
253
 
109
254
  # Pushes under the lock so a stream cannot close its queue between the
110
- # lookup and the push.
255
+ # lookup and the push. A malformed notification has no reply to carry the
256
+ # failure.
111
257
  #
112
258
  # @rbs params: untyped
113
259
  # @rbs return: void
114
260
  def dispatch(params)
115
261
  notification = ACP::Types::SessionNotification.from_h(params)
262
+ rescue KeyError, TypeError, NoMethodError => e
263
+ @logger.warn("dropped malformed session/update: #{e.class}: #{e.message}")
264
+ else
116
265
  queue = @lock.synchronize { @streams[notification.session_id]&.push(notification.update) }
117
266
  @updates.call(notification) unless queue
118
267
  end
119
268
 
269
+ # The handler runs on its own thread so a cancelled request is answered
270
+ # without waiting for it, and whatever it returns after that is pushed to a
271
+ # queue nobody reads.
272
+ #
120
273
  # @rbs params: untyped
121
274
  # @rbs return: (ACP::Types::RequestPermissionResponse | ACP::RequestError)
122
275
  def request_permission(params)
@@ -124,6 +277,86 @@ class ACP::ClientConnection
124
277
  rescue KeyError, TypeError, NoMethodError
125
278
  ACP::RequestError.invalid_params
126
279
  else
127
- @permission.call(request)
280
+ replies = Thread::Queue.new
281
+ register_permission(request.session_id, replies)
282
+ Thread.new do
283
+ # A raised StandardError is not a returned ACP::RequestError, so the two
284
+ # need distinct shapes on the queue.
285
+ replies << begin
286
+ [:value, @permission.call(request)]
287
+ rescue StandardError => e
288
+ [:raised, e]
289
+ end
290
+ ensure
291
+ unregister_permission(request.session_id, replies)
292
+ end
293
+ settled = replies.pop
294
+ # Re-raised so the transport's invoke turns it into an error reply, as it
295
+ # would if the handler ran on the serve thread itself.
296
+ raise settled[1] if settled[0] == :raise
297
+
298
+ settled[1]
299
+ end
300
+
301
+ # @rbs session_id: String
302
+ # @rbs replies: Thread::Queue
303
+ # @rbs return: void
304
+ def register_permission(session_id, replies)
305
+ @lock.synchronize { (@pending_permissions[session_id] ||= []) << replies }
306
+ end
307
+
308
+ # @rbs session_id: String
309
+ # @rbs replies: Thread::Queue
310
+ # @rbs return: void
311
+ def unregister_permission(session_id, replies)
312
+ @lock.synchronize do
313
+ pending = @pending_permissions[session_id]
314
+ if pending
315
+ pending.delete(replies)
316
+ @pending_permissions.delete(session_id) if pending.empty?
317
+ end
318
+ end
319
+ end
320
+
321
+ # The routes are registered at start, before connect records the advertised
322
+ # capabilities, so each one answers -32601 until then.
323
+ #
324
+ # @rbs params: untyped
325
+ # @rbs return: (ACP::Types::ReadTextFileResponse | ACP::RequestError)
326
+ def read_text_file(params)
327
+ return ACP::RequestError.unadvertised('fs.readTextFile') unless @fs_capabilities&.read_text_file
328
+
329
+ request = ACP::Types::ReadTextFileRequest.from_h(params)
330
+ rescue KeyError, TypeError, NoMethodError
331
+ ACP::RequestError.invalid_params
332
+ else
333
+ # Safe: connect refuses an advertised capability no handler serves.
334
+ handler = @read_text_file #: ^(ACP::Types::ReadTextFileRequest) -> (ACP::Types::ReadTextFileResponse | ACP::RequestError)
335
+ handler.call(request)
336
+ end
337
+
338
+ # @rbs params: untyped
339
+ # @rbs return: (ACP::Types::WriteTextFileResponse | ACP::RequestError)
340
+ def write_text_file(params)
341
+ return ACP::RequestError.unadvertised('fs.writeTextFile') unless @fs_capabilities&.write_text_file
342
+
343
+ request = ACP::Types::WriteTextFileRequest.from_h(params)
344
+ rescue KeyError, TypeError, NoMethodError
345
+ ACP::RequestError.invalid_params
346
+ else
347
+ # Safe: connect refuses an advertised capability no handler serves.
348
+ handler = @write_text_file #: ^(ACP::Types::WriteTextFileRequest) -> (ACP::Types::WriteTextFileResponse | ACP::RequestError)
349
+ handler.call(request)
350
+ end
351
+
352
+ # The fs capabilities initialize advertises that no injected handler serves.
353
+ #
354
+ # @rbs capabilities: ACP::Types::FileSystemCapabilities?
355
+ # @rbs return: Array[String]
356
+ def unserved_fs_methods(capabilities)
357
+ {
358
+ 'fs.readTextFile' => capabilities&.read_text_file && !@read_text_file,
359
+ 'fs.writeTextFile' => capabilities&.write_text_file && !@write_text_file
360
+ }.select { |_, unserved| unserved }.keys
128
361
  end
129
362
  end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Shared by the connections' handler maps and send paths, so a plain method
4
+ # name cannot slip into the extension routes.
5
+ module ACP::Extensions
6
+ extend self
7
+
8
+ # @rbs name: String
9
+ # @rbs return: void
10
+ def validate_name(name)
11
+ return if name.start_with?('_')
12
+
13
+ raise ArgumentError, "extension method names must start with '_' (got #{name.inspect})"
14
+ end
15
+ end
@@ -58,6 +58,16 @@ class ACP::RequestError
58
58
  new(code: INVALID_PARAMS, message: 'Invalid params')
59
59
  end
60
60
 
61
+ # The method-not-found code for a method the client's capabilities do not
62
+ # advertise, with a message that names the capability so it can be told
63
+ # apart from an unknown method.
64
+ #
65
+ # @rbs capability: String
66
+ # @rbs return: ACP::RequestError
67
+ def self.unadvertised(capability)
68
+ new(code: METHOD_NOT_FOUND, message: "Client does not advertise #{capability}")
69
+ end
70
+
61
71
  # @rbs return: ACP::RequestError
62
72
  def self.internal_error
63
73
  new(code: INTERNAL_ERROR, message: 'Internal error')
@@ -72,6 +82,17 @@ class ACP::RequestError
72
82
  new(code: INTERNAL_ERROR, message: 'Invalid response', data: response)
73
83
  end
74
84
 
85
+ # A local refusal of an initialize reply that names a version the SDK does
86
+ # not speak; the message names both versions so logs can tell it from a
87
+ # peer's error.
88
+ #
89
+ # @rbs requested: Integer
90
+ # @rbs returned: Integer
91
+ # @rbs return: ACP::RequestError
92
+ def self.unsupported_protocol_version(requested:, returned:)
93
+ new(code: INTERNAL_ERROR, message: "Unsupported protocol version: requested #{requested}, returned #{returned}")
94
+ end
95
+
75
96
  # @rbs return: ACP::RequestError
76
97
  def self.request_cancelled
77
98
  new(code: REQUEST_CANCELLED, message: 'Request cancelled')
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The default sink for dropped messages and swallowed errors: the spec
4
+ # sanctions agents logging to stderr.
5
+ class ACP::Transport::StderrLogger
6
+ # @rbs message: String
7
+ # @rbs return: void
8
+ def warn(message)
9
+ Kernel.warn("acp: WARN: #{message}")
10
+ end
11
+
12
+ # @rbs message: String
13
+ # @rbs return: void
14
+ def error(message)
15
+ Kernel.warn("acp: ERROR: #{message}")
16
+ end
17
+ end
@@ -15,6 +15,7 @@ class ACP::Transport::Stdio
15
15
  # @rbs @pending: Hash[untyped, Thread::Queue]
16
16
  # @rbs @next_id: Integer
17
17
  # @rbs @closed: bool
18
+ # @rbs @logger: ACP::Transport::_Logger
18
19
 
19
20
  CONNECTION_CLOSED = ACP::RequestError.new(
20
21
  code: ACP::RequestError::INTERNAL_ERROR, message: 'Connection closed'
@@ -22,8 +23,9 @@ class ACP::Transport::Stdio
22
23
 
23
24
  # @rbs input: _Reader
24
25
  # @rbs output: _Writer
26
+ # @rbs logger: ACP::Transport::_Logger
25
27
  # @rbs return: void
26
- def initialize(input:, output:)
28
+ def initialize(input:, output:, logger: ACP::Transport::StderrLogger.new)
27
29
  @input = input
28
30
  @output = output
29
31
  @write_lock = Mutex.new
@@ -31,6 +33,7 @@ class ACP::Transport::Stdio
31
33
  @pending = {}
32
34
  @next_id = 0
33
35
  @closed = false
36
+ @logger = logger
34
37
  end
35
38
 
36
39
  # Returns the reader thread, which ends at EOF on input after releasing any
@@ -94,7 +97,7 @@ class ACP::Transport::Stdio
94
97
  # Run on the reader thread so notifications (session/update) keep their
95
98
  # order; a JSON-RPC notification has no reply to carry a handler error.
96
99
  handler = notifications[method]
97
- quietly { handler.call(message['params']) } if handler
100
+ quietly('notification handler') { handler.call(message['params']) } if handler
98
101
  elsif message.key?('result')
99
102
  settle(message['id'], message['result'])
100
103
  elsif error.is_a?(Hash) && error['code'].is_a?(Integer) && error['message'].is_a?(String)
@@ -126,7 +129,7 @@ class ACP::Transport::Stdio
126
129
  case outcome
127
130
  when ACP::Transport::Reply
128
131
  reply(id, outcome.result)
129
- quietly(&outcome.after)
132
+ quietly('reply after callback', &outcome.after)
130
133
  else reply(id, outcome)
131
134
  end
132
135
  end
@@ -136,12 +139,13 @@ class ACP::Transport::Stdio
136
139
  # Reply's after once the reply is on the wire. An exception must not kill
137
140
  # the reader thread or print a thread report.
138
141
  #
142
+ # @rbs label: String
139
143
  # @rbs &block: () -> void
140
144
  # @rbs return: void
141
- def quietly
145
+ def quietly(label)
142
146
  yield
143
- rescue StandardError
144
- nil
147
+ rescue StandardError => e
148
+ @logger.error("#{label} raised #{e.class}: #{e.message}")
145
149
  end
146
150
 
147
151
  # Handlers are application code at the protocol boundary: an exception
@@ -198,6 +202,9 @@ class ACP::Transport::Stdio
198
202
  end
199
203
  end
200
204
 
205
+ # A closed peer must not kill the thread writing the reply: the reader has
206
+ # to keep draining input so pending requests still settle at EOF.
207
+ #
201
208
  # @rbs message: Hash[String, untyped]
202
209
  # @rbs return: void
203
210
  def write(message)
@@ -206,5 +213,7 @@ class ACP::Transport::Stdio
206
213
  @output.write(line)
207
214
  @output.flush
208
215
  end
216
+ rescue Errno::EPIPE => e
217
+ @logger.warn("write to closed peer: #{e.class}: #{e.message}")
209
218
  end
210
219
  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.4.0'
4
+ VERSION = '0.6.0'
5
5
  end
@@ -55,6 +55,28 @@ class ACP::AgentConnection::Client
55
55
  # @rbs return: ACP::Types::ReleaseTerminalResponse | ACP::RequestError
56
56
  def release_terminal: (ACP::Types::ReleaseTerminalRequest request) -> (ACP::Types::ReleaseTerminalResponse | ACP::RequestError)
57
57
 
58
+ # @rbs request: ACP::Types::CreateElicitationRequest
59
+ # @rbs return: ACP::Types::CreateElicitationResponse | ACP::RequestError
60
+ def create_elicitation: (ACP::Types::CreateElicitationRequest request) -> (ACP::Types::CreateElicitationResponse | ACP::RequestError)
61
+
62
+ # @rbs notification: ACP::Types::CompleteElicitationNotification
63
+ # @rbs return: void
64
+ def complete_elicitation: (ACP::Types::CompleteElicitationNotification notification) -> void
65
+
66
+ # Sends an extension request, keyed by its raw `_`-prefixed wire name, and
67
+ # returns the client's reply as-is: extension methods have no schema to
68
+ # parse the result into.
69
+ #
70
+ # @rbs method: String
71
+ # @rbs params: untyped
72
+ # @rbs return: (Hash[String, untyped] | ACP::RequestError)
73
+ def ext_request: (String method, ?untyped params) -> (Hash[String, untyped] | ACP::RequestError)
74
+
75
+ # @rbs method: String
76
+ # @rbs params: untyped
77
+ # @rbs return: void
78
+ def ext_notify: (String method, ?untyped params) -> void
79
+
58
80
  private
59
81
 
60
82
  # @rbs rpc_method: String
@@ -68,11 +90,4 @@ class ACP::AgentConnection::Client
68
90
  # @rbs request: ACP::AgentConnection::_Response
69
91
  # @rbs return: untyped
70
92
  def terminal_call: (String rpc_method, ACP::AgentConnection::_Parser type, ACP::AgentConnection::_Response request) -> untyped
71
-
72
- # Same method-not-found code a peer would send, but with a message that says
73
- # the refusal was local, so logs can tell the two cases apart.
74
- #
75
- # @rbs capability: String
76
- # @rbs return: ACP::RequestError
77
- def unadvertised: (String capability) -> ACP::RequestError
78
93
  end
@@ -1,12 +1,22 @@
1
1
  # Generated from lib/acp/agent_connection.rb with RBS::Inline
2
2
 
3
3
  class ACP::AgentConnection
4
- @transport: ACP::AgentConnection::_Transport
4
+ @logger: ACP::Transport::_Logger
5
+
6
+ @extension_notifications: Hash[String, ^(untyped) -> void]
5
7
 
6
- @initialize_response: ACP::Types::InitializeResponse
8
+ @extension_requests: Hash[String, ^(untyped) -> untyped]
7
9
 
8
10
  @factory: ^(ACP::AgentConnection::Client) -> ACP::AgentConnection::_Agent
9
11
 
12
+ @auth_methods: Array[ACP::Types::AuthMethod::t]
13
+
14
+ @agent_info: ACP::Types::Implementation?
15
+
16
+ @capabilities: ACP::Types::AgentCapabilities
17
+
18
+ @transport: ACP::AgentConnection::_Transport
19
+
10
20
  PROTOCOL_VERSION: Integer
11
21
 
12
22
  OPTIONAL: Array[ACP::AgentConnection::_OptionalMethod]
@@ -20,9 +30,12 @@ class ACP::AgentConnection
20
30
  # @rbs capabilities: ACP::Types::AgentCapabilities
21
31
  # @rbs agent_info: ACP::Types::Implementation?
22
32
  # @rbs auth_methods: Array[ACP::Types::AuthMethod::t]
33
+ # @rbs extension_requests: Hash[String, ^(untyped) -> untyped]
34
+ # @rbs extension_notifications: Hash[String, ^(untyped) -> void]
35
+ # @rbs logger: ACP::Transport::_Logger
23
36
  # @rbs &factory: (ACP::AgentConnection::Client) -> ACP::AgentConnection::_Agent
24
37
  # @rbs return: void
25
- def initialize: (transport: ACP::AgentConnection::_Transport, capabilities: ACP::Types::AgentCapabilities, ?agent_info: ACP::Types::Implementation?, ?auth_methods: Array[ACP::Types::AuthMethod::t]) { (ACP::AgentConnection::Client) -> ACP::AgentConnection::_Agent } -> void
38
+ def initialize: (transport: ACP::AgentConnection::_Transport, capabilities: ACP::Types::AgentCapabilities, ?agent_info: ACP::Types::Implementation?, ?auth_methods: Array[ACP::Types::AuthMethod::t], ?extension_requests: Hash[String, ^(untyped) -> untyped], ?extension_notifications: Hash[String, ^(untyped) -> void], ?logger: ACP::Transport::_Logger) { (ACP::AgentConnection::Client) -> ACP::AgentConnection::_Agent } -> void
26
39
 
27
40
  # @rbs return: Thread
28
41
  def start: () -> Thread
@@ -38,6 +51,13 @@ class ACP::AgentConnection
38
51
  # @rbs return: Hash[String, ^(untyped) -> void]
39
52
  def notifications: (ACP::AgentConnection::_Agent agent) -> Hash[String, ^(untyped) -> void]
40
53
 
54
+ # A notification has no reply to carry a parse failure.
55
+ #
56
+ # @rbs agent: ACP::AgentConnection::_Agent
57
+ # @rbs params: untyped
58
+ # @rbs return: void
59
+ def cancel: (ACP::AgentConnection::_Agent agent, untyped params) -> void
60
+
41
61
  # Generated from_h raises on a missing key or a value of the wrong shape.
42
62
  #
43
63
  # @rbs type: ACP::AgentConnection::_Parser
@@ -50,6 +70,18 @@ class ACP::AgentConnection
50
70
  # @rbs return: ACP::Types::InitializeResponse
51
71
  def connect: (ACP::AgentConnection::Client client, ACP::Types::InitializeRequest request) -> ACP::Types::InitializeResponse
52
72
 
73
+ # @rbs auth_methods: Array[ACP::Types::AuthMethod::t]
74
+ # @rbs return: ACP::Types::InitializeResponse
75
+ def initialize_response: (Array[ACP::Types::AuthMethod::t] auth_methods) -> ACP::Types::InitializeResponse
76
+
77
+ # @rbs capabilities: ACP::Types::ClientCapabilities
78
+ # @rbs return: Array[ACP::Types::AuthMethod::t]
79
+ def auth_methods: (ACP::Types::ClientCapabilities capabilities) -> Array[ACP::Types::AuthMethod::t]
80
+
81
+ # @rbs method: ACP::Types::AuthMethod::t
82
+ # @rbs return: bool
83
+ def terminal_auth_method?: (ACP::Types::AuthMethod::t method) -> bool
84
+
53
85
  # session_created runs after the reply because the client must know the
54
86
  # session id before updates for it (e.g. available commands) arrive.
55
87
  #
@@ -1,23 +1,46 @@
1
1
  # Generated from lib/acp/client_connection.rb with RBS::Inline
2
2
 
3
3
  class ACP::ClientConnection
4
+ @logger: ACP::Transport::_Logger
5
+
6
+ @pending_permissions: Hash[String, Array[Thread::Queue]]
7
+
4
8
  @streams: Hash[String, Thread::Queue]
5
9
 
6
10
  @lock: Thread::Mutex
7
11
 
12
+ @extension_notifications: Hash[String, ^(untyped) -> void]
13
+
14
+ @extension_requests: Hash[String, ^(untyped) -> untyped]
15
+
16
+ @fs_capabilities: ACP::Types::FileSystemCapabilities?
17
+
8
18
  @updates: ACP::ClientConnection::_UpdateHandler
9
19
 
20
+ @write_text_file: (^(ACP::Types::WriteTextFileRequest) -> (ACP::Types::WriteTextFileResponse | ACP::RequestError))?
21
+
22
+ @read_text_file: (^(ACP::Types::ReadTextFileRequest) -> (ACP::Types::ReadTextFileResponse | ACP::RequestError))?
23
+
10
24
  @permission: ACP::ClientConnection::_PermissionHandler
11
25
 
12
26
  @transport: ACP::AgentConnection::_Transport
13
27
 
28
+ PROTOCOL_VERSION: Integer
29
+
14
30
  IGNORE: ^(ACP::Types::SessionNotification) -> void
15
31
 
32
+ CANCELLED: ACP::Types::RequestPermissionResponse
33
+
16
34
  # @rbs transport: ACP::AgentConnection::_Transport
17
35
  # @rbs permission: ACP::ClientConnection::_PermissionHandler
36
+ # @rbs read_text_file: (^(ACP::Types::ReadTextFileRequest) -> (ACP::Types::ReadTextFileResponse | ACP::RequestError))?
37
+ # @rbs write_text_file: (^(ACP::Types::WriteTextFileRequest) -> (ACP::Types::WriteTextFileResponse | ACP::RequestError))?
18
38
  # @rbs updates: ACP::ClientConnection::_UpdateHandler
39
+ # @rbs extension_requests: Hash[String, ^(untyped) -> untyped]
40
+ # @rbs extension_notifications: Hash[String, ^(untyped) -> void]
41
+ # @rbs logger: ACP::Transport::_Logger
19
42
  # @rbs return: void
20
- def initialize: (transport: ACP::AgentConnection::_Transport, permission: ACP::ClientConnection::_PermissionHandler, ?updates: ACP::ClientConnection::_UpdateHandler) -> void
43
+ def initialize: (transport: ACP::AgentConnection::_Transport, permission: ACP::ClientConnection::_PermissionHandler, ?read_text_file: (^(ACP::Types::ReadTextFileRequest) -> (ACP::Types::ReadTextFileResponse | ACP::RequestError))?, ?write_text_file: (^(ACP::Types::WriteTextFileRequest) -> (ACP::Types::WriteTextFileResponse | ACP::RequestError))?, ?updates: ACP::ClientConnection::_UpdateHandler, ?extension_requests: Hash[String, ^(untyped) -> untyped], ?extension_notifications: Hash[String, ^(untyped) -> void], ?logger: ACP::Transport::_Logger) -> void
21
44
 
22
45
  # @rbs return: Thread
23
46
  def start: () -> Thread
@@ -47,10 +70,63 @@ class ACP::ClientConnection
47
70
  # @rbs return: ACP::Types::ListSessionsResponse | ACP::RequestError
48
71
  def session_list: (ACP::Types::ListSessionsRequest request) -> (ACP::Types::ListSessionsResponse | ACP::RequestError)
49
72
 
73
+ # The agent does not replay history on resume, so unlike session_load there
74
+ # is nothing to stream and no block is taken.
75
+ #
76
+ # @rbs request: ACP::Types::ResumeSessionRequest
77
+ # @rbs return: ACP::Types::ResumeSessionResponse | ACP::RequestError
78
+ def session_resume: (ACP::Types::ResumeSessionRequest request) -> (ACP::Types::ResumeSessionResponse | ACP::RequestError)
79
+
80
+ # @rbs request: ACP::Types::CloseSessionRequest
81
+ # @rbs return: ACP::Types::CloseSessionResponse | ACP::RequestError
82
+ def session_close: (ACP::Types::CloseSessionRequest request) -> (ACP::Types::CloseSessionResponse | ACP::RequestError)
83
+
84
+ # @rbs request: ACP::Types::DeleteSessionRequest
85
+ # @rbs return: ACP::Types::DeleteSessionResponse | ACP::RequestError
86
+ def session_delete: (ACP::Types::DeleteSessionRequest request) -> (ACP::Types::DeleteSessionResponse | ACP::RequestError)
87
+
88
+ # The spec requires a pending session/request_permission to be answered with
89
+ # the cancelled outcome once the turn is cancelled, so every queue registered
90
+ # for the session gets one before this returns. The notification goes first
91
+ # so the wire order is cancel, then the cancelled replies.
92
+ #
50
93
  # @rbs notification: ACP::Types::CancelNotification
51
94
  # @rbs return: void
52
95
  def session_cancel: (ACP::Types::CancelNotification notification) -> void
53
96
 
97
+ # @rbs request: ACP::Types::SetSessionModeRequest
98
+ # @rbs return: ACP::Types::SetSessionModeResponse | ACP::RequestError
99
+ def session_set_mode: (ACP::Types::SetSessionModeRequest request) -> (ACP::Types::SetSessionModeResponse | ACP::RequestError)
100
+
101
+ # The reply carries the session's complete option list, so it replaces any
102
+ # state held from an earlier reply rather than merging into it.
103
+ #
104
+ # @rbs request: ACP::Types::SetSessionConfigOptionRequest
105
+ # @rbs return: ACP::Types::SetSessionConfigOptionResponse | ACP::RequestError
106
+ def session_set_config_option: (ACP::Types::SetSessionConfigOptionRequest request) -> (ACP::Types::SetSessionConfigOptionResponse | ACP::RequestError)
107
+
108
+ # @rbs request: ACP::Types::AuthenticateRequest
109
+ # @rbs return: ACP::Types::AuthenticateResponse | ACP::RequestError
110
+ def authenticate: (ACP::Types::AuthenticateRequest request) -> (ACP::Types::AuthenticateResponse | ACP::RequestError)
111
+
112
+ # @rbs request: ACP::Types::LogoutRequest
113
+ # @rbs return: ACP::Types::LogoutResponse | ACP::RequestError
114
+ def logout: (ACP::Types::LogoutRequest request) -> (ACP::Types::LogoutResponse | ACP::RequestError)
115
+
116
+ # Sends an extension request, keyed by its raw `_`-prefixed wire name, and
117
+ # returns the agent's reply as-is: extension methods have no schema to
118
+ # parse the result into.
119
+ #
120
+ # @rbs method: String
121
+ # @rbs params: untyped
122
+ # @rbs return: (Hash[String, untyped] | ACP::RequestError)
123
+ def ext_request: (String method, ?untyped params) -> (Hash[String, untyped] | ACP::RequestError)
124
+
125
+ # @rbs method: String
126
+ # @rbs params: untyped
127
+ # @rbs return: void
128
+ def ext_notify: (String method, ?untyped params) -> void
129
+
54
130
  private
55
131
 
56
132
  # @rbs type: ACP::AgentConnection::_Parser
@@ -70,13 +146,45 @@ class ACP::ClientConnection
70
146
  def stream: (String session_id, String method, Hash[String, untyped] params) { (ACP::Types::SessionUpdate::t) -> void } -> (Hash[String, untyped] | ACP::RequestError)
71
147
 
72
148
  # Pushes under the lock so a stream cannot close its queue between the
73
- # lookup and the push.
149
+ # lookup and the push. A malformed notification has no reply to carry the
150
+ # failure.
74
151
  #
75
152
  # @rbs params: untyped
76
153
  # @rbs return: void
77
154
  def dispatch: (untyped params) -> void
78
155
 
156
+ # The handler runs on its own thread so a cancelled request is answered
157
+ # without waiting for it, and whatever it returns after that is pushed to a
158
+ # queue nobody reads.
159
+ #
79
160
  # @rbs params: untyped
80
161
  # @rbs return: (ACP::Types::RequestPermissionResponse | ACP::RequestError)
81
162
  def request_permission: (untyped params) -> (ACP::Types::RequestPermissionResponse | ACP::RequestError)
163
+
164
+ # @rbs session_id: String
165
+ # @rbs replies: Thread::Queue
166
+ # @rbs return: void
167
+ def register_permission: (String session_id, Thread::Queue replies) -> void
168
+
169
+ # @rbs session_id: String
170
+ # @rbs replies: Thread::Queue
171
+ # @rbs return: void
172
+ def unregister_permission: (String session_id, Thread::Queue replies) -> void
173
+
174
+ # The routes are registered at start, before connect records the advertised
175
+ # capabilities, so each one answers -32601 until then.
176
+ #
177
+ # @rbs params: untyped
178
+ # @rbs return: (ACP::Types::ReadTextFileResponse | ACP::RequestError)
179
+ def read_text_file: (untyped params) -> (ACP::Types::ReadTextFileResponse | ACP::RequestError)
180
+
181
+ # @rbs params: untyped
182
+ # @rbs return: (ACP::Types::WriteTextFileResponse | ACP::RequestError)
183
+ def write_text_file: (untyped params) -> (ACP::Types::WriteTextFileResponse | ACP::RequestError)
184
+
185
+ # The fs capabilities initialize advertises that no injected handler serves.
186
+ #
187
+ # @rbs capabilities: ACP::Types::FileSystemCapabilities?
188
+ # @rbs return: Array[String]
189
+ def unserved_fs_methods: (ACP::Types::FileSystemCapabilities? capabilities) -> Array[String]
82
190
  end
@@ -0,0 +1,9 @@
1
+ # Generated from lib/acp/extensions.rb with RBS::Inline
2
+
3
+ # Shared by the connections' handler maps and send paths, so a plain method
4
+ # name cannot slip into the extension routes.
5
+ module ACP::Extensions
6
+ # @rbs name: String
7
+ # @rbs return: void
8
+ def validate_name: (String name) -> void
9
+ end
@@ -50,6 +50,14 @@ class ACP::RequestError
50
50
  # @rbs return: ACP::RequestError
51
51
  def self.invalid_params: () -> ACP::RequestError
52
52
 
53
+ # The method-not-found code for a method the client's capabilities do not
54
+ # advertise, with a message that names the capability so it can be told
55
+ # apart from an unknown method.
56
+ #
57
+ # @rbs capability: String
58
+ # @rbs return: ACP::RequestError
59
+ def self.unadvertised: (String capability) -> ACP::RequestError
60
+
53
61
  # @rbs return: ACP::RequestError
54
62
  def self.internal_error: () -> ACP::RequestError
55
63
 
@@ -60,6 +68,15 @@ class ACP::RequestError
60
68
  # @rbs return: ACP::RequestError
61
69
  def self.invalid_response: (untyped response) -> ACP::RequestError
62
70
 
71
+ # A local refusal of an initialize reply that names a version the SDK does
72
+ # not speak; the message names both versions so logs can tell it from a
73
+ # peer's error.
74
+ #
75
+ # @rbs requested: Integer
76
+ # @rbs returned: Integer
77
+ # @rbs return: ACP::RequestError
78
+ def self.unsupported_protocol_version: (requested: Integer, returned: Integer) -> ACP::RequestError
79
+
63
80
  # @rbs return: ACP::RequestError
64
81
  def self.request_cancelled: () -> ACP::RequestError
65
82
 
@@ -0,0 +1,13 @@
1
+ # Generated from lib/acp/transport/stderr_logger.rb with RBS::Inline
2
+
3
+ # The default sink for dropped messages and swallowed errors: the spec
4
+ # sanctions agents logging to stderr.
5
+ class ACP::Transport::StderrLogger
6
+ # @rbs message: String
7
+ # @rbs return: void
8
+ def warn: (String message) -> void
9
+
10
+ # @rbs message: String
11
+ # @rbs return: void
12
+ def error: (String message) -> void
13
+ end
@@ -6,6 +6,8 @@
6
6
  # outbound request mid-turn and still see notifications (e.g. session/cancel)
7
7
  # that arrive meanwhile.
8
8
  class ACP::Transport::Stdio
9
+ @logger: ACP::Transport::_Logger
10
+
9
11
  @closed: bool
10
12
 
11
13
  @next_id: Integer
@@ -24,8 +26,9 @@ class ACP::Transport::Stdio
24
26
 
25
27
  # @rbs input: _Reader
26
28
  # @rbs output: _Writer
29
+ # @rbs logger: ACP::Transport::_Logger
27
30
  # @rbs return: void
28
- def initialize: (input: _Reader, output: _Writer) -> void
31
+ def initialize: (input: _Reader, output: _Writer, ?logger: ACP::Transport::_Logger) -> void
29
32
 
30
33
  # Returns the reader thread, which ends at EOF on input after releasing any
31
34
  # pending outbound requests.
@@ -73,9 +76,10 @@ class ACP::Transport::Stdio
73
76
  # Reply's after once the reply is on the wire. An exception must not kill
74
77
  # the reader thread or print a thread report.
75
78
  #
79
+ # @rbs label: String
76
80
  # @rbs &block: () -> void
77
81
  # @rbs return: void
78
- def quietly: () { () -> void } -> void
82
+ def quietly: (String label) { () -> void } -> void
79
83
 
80
84
  # Handlers are application code at the protocol boundary: an exception
81
85
  # becomes an error response instead of killing the thread serving it.
@@ -105,6 +109,9 @@ class ACP::Transport::Stdio
105
109
  # @rbs return: void
106
110
  def close: () -> void
107
111
 
112
+ # A closed peer must not kill the thread writing the reply: the reader has
113
+ # to keep draining input so pending requests still settle at EOF.
114
+ #
108
115
  # @rbs message: Hash[String, untyped]
109
116
  # @rbs return: void
110
117
  def write: (Hash[String, untyped] message) -> void
@@ -79,6 +79,10 @@ class ACP::AgentConnection
79
79
  def wait_for_terminal_exit: (ACP::Types::WaitForTerminalExitRequest) -> (ACP::Types::WaitForTerminalExitResponse | ACP::RequestError)
80
80
  def kill_terminal: (ACP::Types::KillTerminalRequest) -> (ACP::Types::KillTerminalResponse | ACP::RequestError)
81
81
  def release_terminal: (ACP::Types::ReleaseTerminalRequest) -> (ACP::Types::ReleaseTerminalResponse | ACP::RequestError)
82
+ def create_elicitation: (ACP::Types::CreateElicitationRequest request) -> (ACP::Types::CreateElicitationResponse | ACP::RequestError)
83
+ def complete_elicitation: (ACP::Types::CompleteElicitationNotification notification) -> void
84
+ def ext_request: (String method, ?untyped params) -> (Hash[String, untyped] | ACP::RequestError)
85
+ def ext_notify: (String method, ?untyped params) -> void
82
86
  def capabilities: () -> ACP::Types::ClientCapabilities?
83
87
  end
84
88
 
@@ -0,0 +1,6 @@
1
+ # rbs-inline does not emit `extend self`, so without this reopen the module's
2
+ # methods aren't visible as singleton methods.
3
+
4
+ module ACP::Extensions
5
+ extend ::ACP::Extensions
6
+ end
@@ -0,0 +1,8 @@
1
+ module ACP::Transport
2
+ # Receives the reports the SDK logs for dropped messages and swallowed
3
+ # errors; a log message is a single line with no trailing newline.
4
+ interface _Logger
5
+ def warn: (String message) -> void
6
+ def error: (String message) -> void
7
+ end
8
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: acp-sdk
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.0
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jake Bottrall
@@ -44,10 +44,12 @@ files:
44
44
  - lib/acp/agent_connection/optional_method/resume_session.rb
45
45
  - lib/acp/agent_process.rb
46
46
  - lib/acp/client_connection.rb
47
+ - lib/acp/extensions.rb
47
48
  - lib/acp/request_error.rb
48
49
  - lib/acp/sdk.rb
49
50
  - lib/acp/transport.rb
50
51
  - lib/acp/transport/reply.rb
52
+ - lib/acp/transport/stderr_logger.rb
51
53
  - lib/acp/transport/stdio.rb
52
54
  - lib/acp/types.rb
53
55
  - lib/acp/types/agent_auth_capabilities.rb
@@ -258,10 +260,12 @@ files:
258
260
  - sig/generated/acp/agent_connection/optional_method/resume_session.rbs
259
261
  - sig/generated/acp/agent_process.rbs
260
262
  - sig/generated/acp/client_connection.rbs
263
+ - sig/generated/acp/extensions.rbs
261
264
  - sig/generated/acp/request_error.rbs
262
265
  - sig/generated/acp/sdk.rbs
263
266
  - sig/generated/acp/transport.rbs
264
267
  - sig/generated/acp/transport/reply.rbs
268
+ - sig/generated/acp/transport/stderr_logger.rbs
265
269
  - sig/generated/acp/transport/stdio.rbs
266
270
  - sig/generated/acp/types.rbs
267
271
  - sig/generated/acp/types/agent_auth_capabilities.rbs
@@ -470,6 +474,8 @@ files:
470
474
  - sig/manual/acp/agent_connection/optional_method/resume_session.rbs
471
475
  - sig/manual/acp/agent_process.rbs
472
476
  - sig/manual/acp/client_connection.rbs
477
+ - sig/manual/acp/extensions.rbs
478
+ - sig/manual/acp/transport.rbs
473
479
  - sig/manual/acp/transport/stdio.rbs
474
480
  homepage: https://github.com/bottrall/acp-sdk
475
481
  licenses: