acp-sdk 0.11.0 → 0.12.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: 96418a5250fccc2c0eb4380e15309f7658f347bc91d5df3c1218cc3794389f48
4
- data.tar.gz: 52c089fd1c4238da195880f4fcbf823452272de94a6c3f6ad3a2d7c06f0580d1
3
+ metadata.gz: 25494cd3370ba23c2d4e0055acbe0cc5ca7c30dba75abdb58ed1c5a9659ba471
4
+ data.tar.gz: 71522f1fd9a8bf5370f97719a78bd88149c9dfecbaa986031788693cde1e1c88
5
5
  SHA512:
6
- metadata.gz: 84820ff6950c13e9d2fd30f5e5936f7951700af3622bfdc84af49cfcd6b7f24465e00d4889d2d451eaa01fc5091b6af7ecc103579c175d683f451e7ac27a86ce
7
- data.tar.gz: 696efcc33f82fd2bc5ecd1a8e9843138c5be690e13ed4a72c955202c95af404c43a7c794868a1a0dad712174bb6ceaf71b0ab9d51bcf86e5954f6f6acdee3d61
6
+ metadata.gz: a29109841f97f7de1f5c1951e82379179741ba08100e6514949a17033e85b646eb35781b7bf01f26eb754831e510bb7de07be13cf0fa02d0817b102f5f370097
7
+ data.tar.gz: 594b91f54fb5722b3d884d210e485606c13a7902f1300e4fe42a467d1b2ed5725b087ad12346b4fe4e46df3fbf2d0a1d82100e6ea47091237f5017755f4264f3
data/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.12.0](https://github.com/bottrall/acp-sdk/compare/v0.11.0...v0.12.0) (2026-10-13)
4
+
5
+
6
+ ### Features
7
+
8
+ * add RequestError#connection_closed? predicate ([#145](https://github.com/bottrall/acp-sdk/issues/145)) ([16692be](https://github.com/bottrall/acp-sdk/commit/16692be92531e2ddbf14d6547a1bbb431cc151d3)), closes [#108](https://github.com/bottrall/acp-sdk/issues/108)
9
+ * expose Stdio serving state and drain ([#148](https://github.com/bottrall/acp-sdk/issues/148)) ([e8169c1](https://github.com/bottrall/acp-sdk/commit/e8169c1902d8f8ab185cc809e14f08ea98300007)), closes [#107](https://github.com/bottrall/acp-sdk/issues/107)
10
+
3
11
  ## [0.11.0](https://github.com/bottrall/acp-sdk/compare/v0.10.0...v0.11.0) (2026-10-12)
4
12
 
5
13
 
data/README.md CHANGED
@@ -58,9 +58,12 @@ The agent's contract:
58
58
  - `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.
59
59
  - `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.
60
60
  - `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.
61
+ - Once the transport's input ends, a `client` request still waiting for its answer and every one made afterwards return an `ACP::RequestError` whose `connection_closed?` is true. It is false for every error the client sent, even one with the same `-32603` code and `Connection closed` message, so a handler can tell a client that is gone from one that refused without matching the message.
61
62
 
62
63
  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`.
63
64
 
65
+ An embedder that manages the connection's lifecycle keeps a reference to the transport it built: `transport.serving_ids` returns the ids of the requests whose handlers are still running, and `transport.drain` blocks, with no timeout, until those handlers have replied and any `session_created` hook has run. `drain` waits only for the requests in flight when it is called, so join the reader thread `start` returns first — once the client hangs up nothing new arrives — and close the IOs after it returns. A write after either end of the output has closed is logged to the transport's logger as a warning and dropped rather than raised.
66
+
64
67
  [`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`.
65
68
 
66
69
  ## Driving an agent
@@ -110,6 +113,7 @@ connection = ACP::ClientConnection.new(
110
113
  - `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.
111
114
  - `create_terminal`, `terminal_output`, `wait_for_terminal_exit`, `kill_terminal` and `release_terminal` answer the agent's `terminal/create`, `terminal/output`, `terminal/wait_for_exit`, `terminal/kill` and `terminal/release` with the matching `ACP::Types` response or an `ACP::RequestError`. They are routed by the `terminal` capability `connect` sends, the same way as `fs`: 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 `terminal` with any of the five missing. The transport serves each request on its own thread, so `wait_for_terminal_exit` may block without holding up the others.
112
115
  - `elicitation` answers the agent's `elicitation/create` with an `ACP::Types::CreateElicitationResponse` or an `ACP::RequestError`, serving both form and url mode, and `complete_elicitation` receives the agent's `elicitation/complete` notification. They are routed by the `elicitation` capabilities `connect` sends: a request whose mode the client does not advertise is answered with `-32602` without reaching the handler, and `connect` raises `ArgumentError` — before sending `initialize` — when the request advertises `elicitation.form` or `elicitation.url` with no `elicitation` handler. The client tracks the elicitation ids of the url-mode requests it serves, so a completion for an unknown or already-completed id is ignored. `complete_elicitation` runs on the reader thread and must return quickly; it may be omitted, in which case completions are only tracked.
116
+ - Once the transport's input ends, a request still waiting for its answer and every one made afterwards return an `ACP::RequestError` whose `connection_closed?` is true. It is false for every error the agent sent, so a closed agent is told apart from a refusal without matching the message.
113
117
 
114
118
  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`.
115
119
 
@@ -36,6 +36,16 @@ class ACP::RequestError
36
36
  { 'code' => code, 'message' => message, 'data' => data }.compact
37
37
  end
38
38
 
39
+ CONNECTION_CLOSED = new(code: INTERNAL_ERROR, message: 'Connection closed') #: ACP::RequestError
40
+
41
+ # Identity rather than code and message: a live peer can answer with the
42
+ # same pair, and that refusal must not read as a closed connection.
43
+ #
44
+ # @rbs return: bool
45
+ def connection_closed?
46
+ equal?(CONNECTION_CLOSED)
47
+ end
48
+
39
49
  # @rbs return: ACP::RequestError
40
50
  def self.parse_error
41
51
  new(code: PARSE_ERROR, message: 'Parse error')
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ class ACP::Transport::Stdio::Serving
4
+ # @dynamic id
5
+ attr_reader :id #: untyped
6
+
7
+ # @dynamic cancellation
8
+ attr_reader :cancellation #: ACP::Transport::Cancellation
9
+
10
+ # @rbs id: untyped
11
+ # @rbs cancellation: ACP::Transport::Cancellation
12
+ # @rbs return: void
13
+ def initialize(id:, cancellation:)
14
+ @id = id
15
+ @cancellation = cancellation
16
+ freeze
17
+ end
18
+ end
@@ -12,14 +12,12 @@ class ACP::Transport::Stdio
12
12
  # @rbs @write_lock: Thread::Mutex
13
13
  # @rbs @lock: Thread::Mutex
14
14
  # @rbs @pending: Hash[untyped, Thread::Queue]
15
- # @rbs @serving: Hash[untyped, ACP::Transport::Cancellation]
15
+ # @rbs @serving: Hash[Thread, ACP::Transport::Stdio::Serving]
16
16
  # @rbs @next_id: Integer
17
17
  # @rbs @closed: bool
18
18
  # @rbs @logger: ACP::Transport::_Logger
19
19
 
20
- CONNECTION_CLOSED = ACP::RequestError.new(
21
- code: ACP::RequestError::INTERNAL_ERROR, message: 'Connection closed'
22
- ) #: ACP::RequestError
20
+ CONNECTION_CLOSED = ACP::RequestError::CONNECTION_CLOSED #: ACP::RequestError
23
21
 
24
22
  # @rbs input: _Reader
25
23
  # @rbs output: _Writer
@@ -37,6 +35,13 @@ class ACP::Transport::Stdio
37
35
  @logger = logger
38
36
  end
39
37
 
38
+ # Joining the returned thread does not wait for the requests still being
39
+ # served. EOF cancels none of them (only the peer's $/cancel_request does):
40
+ # each handler runs to completion and its reply is still written, to be
41
+ # warned and dropped if the peer is gone. A caller blocked in #request, a
42
+ # handler waiting on its own request included, is released with
43
+ # CONNECTION_CLOSED.
44
+ #
40
45
  # @rbs requests: Hash[String, _Handler]
41
46
  # @rbs notifications: Hash[String, _NotificationHandler]
42
47
  # @rbs return: Thread
@@ -93,6 +98,19 @@ class ACP::Transport::Stdio
93
98
  ACP::Transport::Cancellation.current
94
99
  end
95
100
 
101
+ # @rbs return: Array[untyped]
102
+ def serving_ids
103
+ @lock.synchronize { @serving.each_value.map(&:id) }
104
+ end
105
+
106
+ # Joins the requests in flight at the call, not one the reader dispatches
107
+ # meanwhile: join the reader thread first to leave nothing in flight.
108
+ #
109
+ # @rbs return: void
110
+ def drain
111
+ @lock.synchronize { @serving.keys }.each(&:join)
112
+ end
113
+
96
114
  private
97
115
 
98
116
  # @rbs line: String
@@ -167,39 +185,29 @@ class ACP::Transport::Stdio
167
185
  # @rbs method: String
168
186
  # @rbs return: void
169
187
  def serve(handler, id, params, method)
170
- cancellation = register_serving(id)
171
- Thread.new do
172
- ACP::Transport::Cancellation.current = cancellation
173
- outcome = handler ? invoke(handler, params) : ACP::RequestError.method_not_found(method)
174
- case outcome
175
- when ACP::Transport::Reply
176
- reply(id, outcome.result)
177
- quietly('reply after callback', &outcome.after)
178
- else reply(id, outcome)
188
+ cancellation = ACP::Transport::Cancellation.new
189
+ # Registered on the reader thread so a cancel racing the request cannot
190
+ # be missed. The serve thread starts under the lock so it is registered
191
+ # before a drain can miss it or it can unregister itself.
192
+ @lock.synchronize do
193
+ thread = Thread.new do
194
+ ACP::Transport::Cancellation.current = cancellation
195
+ outcome = handler ? invoke(handler, params) : ACP::RequestError.method_not_found(method)
196
+ case outcome
197
+ when ACP::Transport::Reply
198
+ reply(id, outcome.result)
199
+ quietly('reply after callback', &outcome.after)
200
+ else reply(id, outcome)
201
+ end
202
+ ensure
203
+ # Unregistered only after the reply and its after callback, so drain waits
204
+ # for both and later cancels for the id are ignored.
205
+ @lock.synchronize { @serving.delete(Thread.current) }
179
206
  end
180
- ensure
181
- unregister_serving(id)
207
+ @serving[thread] = Serving.new(id:, cancellation:)
182
208
  end
183
209
  end
184
210
 
185
- # Registered before the serve thread starts so a cancel racing the request
186
- # cannot be missed, and dropped once the response is on the wire so later
187
- # cancels for the id are ignored.
188
- #
189
- # @rbs id: untyped
190
- # @rbs return: ACP::Transport::Cancellation
191
- def register_serving(id)
192
- cancellation = ACP::Transport::Cancellation.new
193
- @lock.synchronize { @serving[id] = cancellation }
194
- cancellation
195
- end
196
-
197
- # @rbs id: untyped
198
- # @rbs return: void
199
- def unregister_serving(id)
200
- @lock.synchronize { @serving.delete(id) }
201
- end
202
-
203
211
  # A notification has no reply to carry a malformed params failure, so one
204
212
  # is warned and dropped rather than answered.
205
213
  #
@@ -211,8 +219,10 @@ class ACP::Transport::Stdio
211
219
  return @logger.warn('dropped malformed $/cancel_request: missing requestId')
212
220
  end
213
221
 
214
- cancellation = @lock.synchronize { @serving[id] }
215
- cancellation&.cancel
222
+ cancellations = @lock.synchronize do
223
+ @serving.each_value.filter_map { |entry| entry.cancellation if entry.id == id }
224
+ end
225
+ cancellations.each(&:cancel)
216
226
  end
217
227
 
218
228
  # For code with no reply to carry an error: a notification handler, or a
@@ -273,6 +283,9 @@ class ACP::Transport::Stdio
273
283
  @lock.synchronize { @pending.delete(id) }&.tap { |queue| queue.push(result) }
274
284
  end
275
285
 
286
+ # Leaves @serving alone on purpose: embedders rely on in-flight handlers
287
+ # running to completion after the input ends.
288
+ #
276
289
  # @rbs return: void
277
290
  def close
278
291
  @lock.synchronize do
@@ -282,8 +295,9 @@ class ACP::Transport::Stdio
282
295
  end
283
296
  end
284
297
 
285
- # A closed peer must not kill the thread writing the reply: the reader has
286
- # to keep draining input so pending requests still settle at EOF.
298
+ # A closed peer (EPIPE) or an output the embedder already closed (IOError)
299
+ # must not kill the thread writing the reply: the reader has to keep
300
+ # draining input so pending requests still settle at EOF.
287
301
  #
288
302
  # @rbs message: Hash[String, untyped]
289
303
  # @rbs return: void
@@ -293,7 +307,7 @@ class ACP::Transport::Stdio
293
307
  @output.write(line)
294
308
  @output.flush
295
309
  end
296
- rescue Errno::EPIPE => e
310
+ rescue Errno::EPIPE, IOError => e
297
311
  @logger.warn("write to closed peer: #{e.class}: #{e.message}")
298
312
  end
299
313
  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.11.0'
4
+ VERSION = '0.12.0'
5
5
  end
@@ -36,6 +36,14 @@ class ACP::RequestError
36
36
  # @rbs return: Hash[String, untyped]
37
37
  def to_h: () -> Hash[String, untyped]
38
38
 
39
+ CONNECTION_CLOSED: ACP::RequestError
40
+
41
+ # Identity rather than code and message: a live peer can answer with the
42
+ # same pair, and that refusal must not read as a closed connection.
43
+ #
44
+ # @rbs return: bool
45
+ def connection_closed?: () -> bool
46
+
39
47
  # @rbs return: ACP::RequestError
40
48
  def self.parse_error: () -> ACP::RequestError
41
49
 
@@ -0,0 +1,14 @@
1
+ # Generated from lib/acp/transport/stdio/serving.rb with RBS::Inline
2
+
3
+ class ACP::Transport::Stdio::Serving
4
+ # @dynamic id
5
+ attr_reader id: untyped
6
+
7
+ # @dynamic cancellation
8
+ attr_reader cancellation: ACP::Transport::Cancellation
9
+
10
+ # @rbs id: untyped
11
+ # @rbs cancellation: ACP::Transport::Cancellation
12
+ # @rbs return: void
13
+ def initialize: (id: untyped, cancellation: ACP::Transport::Cancellation) -> void
14
+ end
@@ -11,7 +11,7 @@ class ACP::Transport::Stdio
11
11
 
12
12
  @next_id: Integer
13
13
 
14
- @serving: Hash[untyped, ACP::Transport::Cancellation]
14
+ @serving: Hash[Thread, ACP::Transport::Stdio::Serving]
15
15
 
16
16
  @pending: Hash[untyped, Thread::Queue]
17
17
 
@@ -31,6 +31,13 @@ class ACP::Transport::Stdio
31
31
  # @rbs return: void
32
32
  def initialize: (input: _Reader, output: _Writer, ?logger: ACP::Transport::_Logger) -> void
33
33
 
34
+ # Joining the returned thread does not wait for the requests still being
35
+ # served. EOF cancels none of them (only the peer's $/cancel_request does):
36
+ # each handler runs to completion and its reply is still written, to be
37
+ # warned and dropped if the peer is gone. A caller blocked in #request, a
38
+ # handler waiting on its own request included, is released with
39
+ # CONNECTION_CLOSED.
40
+ #
34
41
  # @rbs requests: Hash[String, _Handler]
35
42
  # @rbs notifications: Hash[String, _NotificationHandler]
36
43
  # @rbs return: Thread
@@ -62,6 +69,15 @@ class ACP::Transport::Stdio
62
69
  # @rbs return: ACP::Transport::Cancellation?
63
70
  def cancellation: () -> ACP::Transport::Cancellation?
64
71
 
72
+ # @rbs return: Array[untyped]
73
+ def serving_ids: () -> Array[untyped]
74
+
75
+ # Joins the requests in flight at the call, not one the reader dispatches
76
+ # meanwhile: join the reader thread first to leave nothing in flight.
77
+ #
78
+ # @rbs return: void
79
+ def drain: () -> void
80
+
65
81
  private
66
82
 
67
83
  # @rbs line: String
@@ -95,18 +111,6 @@ class ACP::Transport::Stdio
95
111
  # @rbs return: void
96
112
  def serve: (_Handler? handler, untyped id, untyped params, String method) -> void
97
113
 
98
- # Registered before the serve thread starts so a cancel racing the request
99
- # cannot be missed, and dropped once the response is on the wire so later
100
- # cancels for the id are ignored.
101
- #
102
- # @rbs id: untyped
103
- # @rbs return: ACP::Transport::Cancellation
104
- def register_serving: (untyped id) -> ACP::Transport::Cancellation
105
-
106
- # @rbs id: untyped
107
- # @rbs return: void
108
- def unregister_serving: (untyped id) -> void
109
-
110
114
  # A notification has no reply to carry a malformed params failure, so one
111
115
  # is warned and dropped rather than answered.
112
116
  #
@@ -147,11 +151,15 @@ class ACP::Transport::Stdio
147
151
  # @rbs return: Thread::Queue?
148
152
  def settle: (untyped id, untyped result) -> Thread::Queue?
149
153
 
154
+ # Leaves @serving alone on purpose: embedders rely on in-flight handlers
155
+ # running to completion after the input ends.
156
+ #
150
157
  # @rbs return: void
151
158
  def close: () -> void
152
159
 
153
- # A closed peer must not kill the thread writing the reply: the reader has
154
- # to keep draining input so pending requests still settle at EOF.
160
+ # A closed peer (EPIPE) or an output the embedder already closed (IOError)
161
+ # must not kill the thread writing the reply: the reader has to keep
162
+ # draining input so pending requests still settle at EOF.
155
163
  #
156
164
  # @rbs message: Hash[String, untyped]
157
165
  # @rbs return: void
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.11.0
4
+ version: 0.12.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jake Bottrall
@@ -48,6 +48,7 @@ files:
48
48
  - lib/acp/transport/reply.rb
49
49
  - lib/acp/transport/stderr_logger.rb
50
50
  - lib/acp/transport/stdio.rb
51
+ - lib/acp/transport/stdio/serving.rb
51
52
  - lib/acp/types.rb
52
53
  - lib/acp/types/agent_auth_capabilities.rb
53
54
  - lib/acp/types/agent_capabilities.rb
@@ -405,6 +406,7 @@ files:
405
406
  - sig/generated/acp/transport/reply.rbs
406
407
  - sig/generated/acp/transport/stderr_logger.rbs
407
408
  - sig/generated/acp/transport/stdio.rbs
409
+ - sig/generated/acp/transport/stdio/serving.rbs
408
410
  - sig/generated/acp/types.rbs
409
411
  - sig/generated/acp/types/agent_auth_capabilities.rbs
410
412
  - sig/generated/acp/types/agent_capabilities.rbs