acp-sdk 0.10.0 → 0.11.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 +21 -0
- data/README.md +15 -5
- data/lib/acp/agent_connection/client.rb +7 -19
- data/lib/acp/agent_connection/optional_method/spec.rb +42 -0
- data/lib/acp/agent_connection/optional_method.rb +36 -0
- data/lib/acp/agent_connection.rb +17 -40
- data/lib/acp/agent_process.rb +0 -13
- data/lib/acp/client_connection/streams.rb +183 -0
- data/lib/acp/client_connection.rb +227 -384
- data/lib/acp/protocol.rb +33 -0
- data/lib/acp/request_error.rb +7 -17
- data/lib/acp/transport/cancellation.rb +0 -7
- data/lib/acp/transport/reply.rb +0 -2
- data/lib/acp/transport/stderr_logger.rb +1 -2
- data/lib/acp/transport/stdio.rb +16 -22
- data/lib/acp/types/audio_content.rb +3 -46
- data/lib/acp/types/auth_method_terminal.rb +3 -59
- data/lib/acp/types/available_commands_update.rb +3 -32
- data/lib/acp/types/boolean_property_schema.rb +3 -41
- data/lib/acp/types/check.rb +210 -0
- data/lib/acp/types/config_option_update.rb +3 -32
- data/lib/acp/types/content.rb +3 -29
- data/lib/acp/types/{elicitation_url_mode → create_elicitation_request/mode/form}/scope.rb +1 -1
- data/lib/acp/types/create_elicitation_request/mode/form.rb +3 -3
- data/lib/acp/types/{elicitation_form_mode → create_elicitation_request/mode/url}/scope.rb +1 -1
- data/lib/acp/types/create_elicitation_request/mode/url.rb +3 -3
- data/lib/acp/types/current_mode_update.rb +3 -29
- data/lib/acp/types/diff.rb +3 -41
- data/lib/acp/types/elicitation_accept_action.rb +3 -26
- data/lib/acp/types/elicitation_form_mode.rb +3 -33
- data/lib/acp/types/elicitation_url_mode.rb +3 -35
- data/lib/acp/types/embedded_resource.rb +3 -44
- data/lib/acp/types/image_content.rb +3 -58
- data/lib/acp/types/integer_property_schema.rb +3 -60
- data/lib/acp/types/mcp_server_http.rb +3 -44
- data/lib/acp/types/mcp_server_sse.rb +3 -44
- data/lib/acp/types/multi_select_property_schema.rb +3 -70
- data/lib/acp/types/number_property_schema.rb +3 -60
- data/lib/acp/types/parse_error.rb +37 -0
- data/lib/acp/types/plan.rb +3 -29
- data/lib/acp/types/resource_link.rb +3 -79
- data/lib/acp/types/selected_permission_outcome.rb +3 -26
- data/lib/acp/types/session_config_boolean.rb +3 -21
- data/lib/acp/types/session_config_select.rb +3 -33
- data/lib/acp/types/session_info_update.rb +3 -42
- data/lib/acp/types/string_multi_select_items.rb +3 -29
- data/lib/acp/types/string_property_schema.rb +3 -105
- data/lib/acp/types/terminal.rb +3 -29
- data/lib/acp/types/text_content.rb +3 -40
- data/lib/acp/types/tool_call.rb +3 -115
- data/lib/acp/types/unstable/compaction_summary_chunk.rb +3 -35
- data/lib/acp/types/unstable/compaction_update.rb +3 -56
- data/lib/acp/types/unstable/idle_state_update.rb +3 -40
- data/lib/acp/types/unstable/mcp_server_acp.rb +3 -35
- data/lib/acp/types/unstable/nes_edit_suggestion.rb +3 -61
- data/lib/acp/types/unstable/nes_jump_suggestion.rb +3 -45
- data/lib/acp/types/unstable/nes_rename_suggestion.rb +3 -51
- data/lib/acp/types/unstable/nes_search_and_replace_suggestion.rb +3 -60
- data/lib/acp/types/unstable/notice.rb +3 -41
- data/lib/acp/types/unstable/plan_file.rb +3 -35
- data/lib/acp/types/unstable/plan_items.rb +3 -38
- data/lib/acp/types/unstable/plan_markdown.rb +3 -35
- data/lib/acp/types/unstable/plan_removed.rb +3 -26
- data/lib/acp/types/unstable/plan_update.rb +3 -33
- data/lib/acp/types/unstable/requires_action_state_update.rb +3 -21
- data/lib/acp/types/unstable/running_state_update.rb +3 -21
- data/lib/acp/types/unstable/session_message.rb +3 -56
- data/lib/acp/types/unstable/session_message_chunk.rb +3 -53
- data/lib/acp/types/unstable/subagent_update.rb +3 -65
- data/lib/acp/types/unstable/unknown_state_update.rb +3 -21
- data/lib/acp/types/unstable.rb +20 -20
- data/lib/acp/types/usage_update.rb +3 -41
- data/lib/acp/types.rb +7 -246
- data/lib/acp/version.rb +1 -1
- data/sig/generated/acp/agent_connection/client.rbs +10 -19
- data/sig/generated/acp/agent_connection/optional_method/spec.rbs +32 -0
- data/sig/generated/acp/agent_connection/optional_method.rbs +3 -0
- data/sig/generated/acp/agent_connection.rbs +16 -24
- data/sig/generated/acp/agent_process.rbs +0 -13
- data/sig/generated/acp/client_connection/streams.rbs +94 -0
- data/sig/generated/acp/client_connection.rbs +100 -159
- data/sig/generated/acp/protocol.rbs +9 -0
- data/sig/generated/acp/request_error.rbs +7 -17
- data/sig/generated/acp/transport/cancellation.rbs +0 -7
- data/sig/generated/acp/transport/reply.rbs +0 -2
- data/sig/generated/acp/transport/stderr_logger.rbs +1 -2
- data/sig/generated/acp/transport/stdio.rbs +9 -21
- data/sig/generated/acp/types/audio_content.rbs +1 -27
- data/sig/generated/acp/types/auth_method_terminal.rbs +1 -35
- data/sig/generated/acp/types/available_commands_update.rbs +1 -19
- data/sig/generated/acp/types/boolean_property_schema.rbs +1 -27
- data/sig/generated/acp/types/check.rbs +115 -0
- data/sig/generated/acp/types/config_option_update.rbs +1 -19
- data/sig/generated/acp/types/content.rbs +1 -19
- data/sig/generated/acp/types/{elicitation_url_mode → create_elicitation_request/mode/form}/scope.rbs +2 -2
- data/sig/generated/acp/types/create_elicitation_request/mode/form.rbs +3 -3
- data/sig/generated/acp/types/{elicitation_form_mode → create_elicitation_request/mode/url}/scope.rbs +2 -2
- data/sig/generated/acp/types/create_elicitation_request/mode/url.rbs +3 -3
- data/sig/generated/acp/types/current_mode_update.rbs +1 -19
- data/sig/generated/acp/types/diff.rbs +1 -27
- data/sig/generated/acp/types/elicitation_accept_action.rbs +1 -15
- data/sig/generated/acp/types/elicitation_form_mode.rbs +1 -19
- data/sig/generated/acp/types/elicitation_url_mode.rbs +1 -23
- data/sig/generated/acp/types/embedded_resource.rbs +1 -23
- data/sig/generated/acp/types/image_content.rbs +1 -31
- data/sig/generated/acp/types/integer_property_schema.rbs +1 -35
- data/sig/generated/acp/types/mcp_server_http.rbs +1 -27
- data/sig/generated/acp/types/mcp_server_sse.rbs +1 -27
- data/sig/generated/acp/types/multi_select_property_schema.rbs +1 -39
- data/sig/generated/acp/types/number_property_schema.rbs +1 -35
- data/sig/generated/acp/types/parse_error.rbs +22 -0
- data/sig/generated/acp/types/plan.rbs +1 -19
- data/sig/generated/acp/types/resource_link.rbs +1 -43
- data/sig/generated/acp/types/selected_permission_outcome.rbs +1 -19
- data/sig/generated/acp/types/session_config_boolean.rbs +1 -15
- data/sig/generated/acp/types/session_config_select.rbs +1 -19
- data/sig/generated/acp/types/session_info_update.rbs +1 -23
- data/sig/generated/acp/types/string_multi_select_items.rbs +1 -19
- data/sig/generated/acp/types/string_property_schema.rbs +1 -51
- data/sig/generated/acp/types/terminal.rbs +1 -19
- data/sig/generated/acp/types/text_content.rbs +1 -23
- data/sig/generated/acp/types/tool_call.rbs +1 -51
- data/sig/generated/acp/types/unstable/compaction_summary_chunk.rbs +1 -23
- data/sig/generated/acp/types/unstable/compaction_update.rbs +1 -31
- data/sig/generated/acp/types/unstable/idle_state_update.rbs +1 -23
- data/sig/generated/acp/types/unstable/mcp_server_acp.rbs +1 -23
- data/sig/generated/acp/types/unstable/nes_edit_suggestion.rbs +1 -31
- data/sig/generated/acp/types/unstable/nes_jump_suggestion.rbs +1 -27
- data/sig/generated/acp/types/unstable/nes_rename_suggestion.rbs +1 -31
- data/sig/generated/acp/types/unstable/nes_search_and_replace_suggestion.rbs +1 -35
- data/sig/generated/acp/types/unstable/notice.rbs +1 -27
- data/sig/generated/acp/types/unstable/plan_file.rbs +1 -23
- data/sig/generated/acp/types/unstable/plan_items.rbs +1 -23
- data/sig/generated/acp/types/unstable/plan_markdown.rbs +1 -23
- data/sig/generated/acp/types/unstable/plan_removed.rbs +1 -19
- data/sig/generated/acp/types/unstable/plan_update.rbs +1 -19
- data/sig/generated/acp/types/unstable/requires_action_state_update.rbs +1 -15
- data/sig/generated/acp/types/unstable/running_state_update.rbs +1 -15
- data/sig/generated/acp/types/unstable/session_message.rbs +1 -31
- data/sig/generated/acp/types/unstable/session_message_chunk.rbs +1 -31
- data/sig/generated/acp/types/unstable/subagent_update.rbs +1 -35
- data/sig/generated/acp/types/unstable/unknown_state_update.rbs +1 -15
- data/sig/generated/acp/types/usage_update.rbs +1 -27
- data/sig/generated/acp/types.rbs +6 -143
- data/sig/manual/acp/agent_connection.rbs +0 -30
- data/sig/manual/acp/client_connection.rbs +7 -2
- data/sig/manual/acp/{agent_connection/optional_method/logout.rbs → protocol.rbs} +3 -3
- data/sig/manual/acp/transport/stdio.rbs +0 -2
- data/sig/manual/acp/transport.rbs +25 -2
- metadata +16 -40
- data/lib/acp/agent_connection/optional_method/authenticate.rb +0 -21
- data/lib/acp/agent_connection/optional_method/close_session.rb +0 -21
- data/lib/acp/agent_connection/optional_method/delete_session.rb +0 -21
- data/lib/acp/agent_connection/optional_method/disable_provider.rb +0 -23
- data/lib/acp/agent_connection/optional_method/fork_session.rb +0 -23
- data/lib/acp/agent_connection/optional_method/list_providers.rb +0 -23
- data/lib/acp/agent_connection/optional_method/list_sessions.rb +0 -21
- data/lib/acp/agent_connection/optional_method/load_session.rb +0 -21
- data/lib/acp/agent_connection/optional_method/logout.rb +0 -21
- data/lib/acp/agent_connection/optional_method/resume_session.rb +0 -21
- data/lib/acp/agent_connection/optional_method/set_provider.rb +0 -23
- data/lib/acp/types/content_chunk.rb +0 -40
- data/sig/generated/acp/agent_connection/optional_method/authenticate.rbs +0 -13
- data/sig/generated/acp/agent_connection/optional_method/close_session.rbs +0 -13
- data/sig/generated/acp/agent_connection/optional_method/delete_session.rbs +0 -13
- data/sig/generated/acp/agent_connection/optional_method/disable_provider.rbs +0 -13
- data/sig/generated/acp/agent_connection/optional_method/fork_session.rbs +0 -13
- data/sig/generated/acp/agent_connection/optional_method/list_providers.rbs +0 -13
- data/sig/generated/acp/agent_connection/optional_method/list_sessions.rbs +0 -13
- data/sig/generated/acp/agent_connection/optional_method/load_session.rbs +0 -13
- data/sig/generated/acp/agent_connection/optional_method/logout.rbs +0 -13
- data/sig/generated/acp/agent_connection/optional_method/resume_session.rbs +0 -13
- data/sig/generated/acp/agent_connection/optional_method/set_provider.rbs +0 -13
- data/sig/generated/acp/types/content_chunk.rbs +0 -25
- data/sig/manual/acp/agent_connection/optional_method/authenticate.rbs +0 -6
- data/sig/manual/acp/agent_connection/optional_method/close_session.rbs +0 -6
- data/sig/manual/acp/agent_connection/optional_method/delete_session.rbs +0 -6
- data/sig/manual/acp/agent_connection/optional_method/disable_provider.rbs +0 -6
- data/sig/manual/acp/agent_connection/optional_method/fork_session.rbs +0 -6
- data/sig/manual/acp/agent_connection/optional_method/list_providers.rbs +0 -6
- data/sig/manual/acp/agent_connection/optional_method/list_sessions.rbs +0 -6
- data/sig/manual/acp/agent_connection/optional_method/load_session.rbs +0 -6
- data/sig/manual/acp/agent_connection/optional_method/resume_session.rbs +0 -6
- data/sig/manual/acp/agent_connection/optional_method/set_provider.rbs +0 -6
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 96418a5250fccc2c0eb4380e15309f7658f347bc91d5df3c1218cc3794389f48
|
|
4
|
+
data.tar.gz: 52c089fd1c4238da195880f4fcbf823452272de94a6c3f6ad3a2d7c06f0580d1
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 84820ff6950c13e9d2fd30f5e5936f7951700af3622bfdc84af49cfcd6b7f24465e00d4889d2d451eaa01fc5091b6af7ecc103579c175d683f451e7ac27a86ce
|
|
7
|
+
data.tar.gz: 696efcc33f82fd2bc5ecd1a8e9843138c5be690e13ed4a72c955202c95af404c43a7c794868a1a0dad712174bb6ceaf71b0ab9d51bcf86e5954f6f6acdee3d61
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,26 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.11.0](https://github.com/bottrall/acp-sdk/compare/v0.10.0...v0.11.0) (2026-10-12)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* standardize ClientConnection senders on verb-noun naming ([#118](https://github.com/bottrall/acp-sdk/issues/118)) ([1b844ec](https://github.com/bottrall/acp-sdk/commit/1b844ecccef5b8714c5e3f2d0299713f0ee09e0f))
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Bug Fixes
|
|
12
|
+
|
|
13
|
+
* answer malformed elicitation params as invalid params ([#133](https://github.com/bottrall/acp-sdk/issues/133)) ([b9e1c51](https://github.com/bottrall/acp-sdk/commit/b9e1c510b60f78986998ba8be56c8a039488ee95)), closes [#125](https://github.com/bottrall/acp-sdk/issues/125)
|
|
14
|
+
* answer the agent when a permission handler raises ([#134](https://github.com/bottrall/acp-sdk/issues/134)) ([a0f61a9](https://github.com/bottrall/acp-sdk/commit/a0f61a9db3589848fafadb7b4e19ad70b260f508)), closes [#124](https://github.com/bottrall/acp-sdk/issues/124)
|
|
15
|
+
* cancel the echo agent's in-flight turn when its session is closed or deleted ([#136](https://github.com/bottrall/acp-sdk/issues/136)) ([e04a8c0](https://github.com/bottrall/acp-sdk/commit/e04a8c0bb672b569d68029c63eaee826d00c84fd)), closes [#130](https://github.com/bottrall/acp-sdk/issues/130)
|
|
16
|
+
* **codegen:** keep the unset sentinel out of clearable to_h conversions ([#140](https://github.com/bottrall/acp-sdk/issues/140)) ([67769d7](https://github.com/bottrall/acp-sdk/commit/67769d7ff5d9f93dfe3f3ea2ba4ac71126caad60)), closes [#131](https://github.com/bottrall/acp-sdk/issues/131)
|
|
17
|
+
* **codegen:** resolve primitive unions from each option's resolved shape ([#141](https://github.com/bottrall/acp-sdk/issues/141)) ([0b574ae](https://github.com/bottrall/acp-sdk/commit/0b574ae9d33c6c10eb0a00866b6c07f1601e01e8)), closes [#132](https://github.com/bottrall/acp-sdk/issues/132)
|
|
18
|
+
* **codegen:** subsume union-variant twins into variant aliases ([#103](https://github.com/bottrall/acp-sdk/issues/103)) ([bdc0005](https://github.com/bottrall/acp-sdk/commit/bdc0005460c0fd133a09f10cf61f9f5717012e16)), closes [#93](https://github.com/bottrall/acp-sdk/issues/93)
|
|
19
|
+
* echo slash commands followed only by whitespace ([#135](https://github.com/bottrall/acp-sdk/issues/135)) ([bb4bde6](https://github.com/bottrall/acp-sdk/commit/bb4bde6951ceb54ac87c92ec335dcdd319fd7b81)), closes [#129](https://github.com/bottrall/acp-sdk/issues/129)
|
|
20
|
+
* read mcpCapabilities alone when deciding whether MCP is advertised ([#139](https://github.com/bottrall/acp-sdk/issues/139)) ([a6e882e](https://github.com/bottrall/acp-sdk/commit/a6e882ebf3c69f0aa8692100e81e61a085007a8b)), closes [#128](https://github.com/bottrall/acp-sdk/issues/128)
|
|
21
|
+
* return a null or false result instead of "Connection closed" ([#137](https://github.com/bottrall/acp-sdk/issues/137)) ([19be64d](https://github.com/bottrall/acp-sdk/commit/19be64df6064e370bb23614a3b8df1056649cd3b)), closes [#127](https://github.com/bottrall/acp-sdk/issues/127)
|
|
22
|
+
* settle a pending request when its response is malformed ([#138](https://github.com/bottrall/acp-sdk/issues/138)) ([7d0586e](https://github.com/bottrall/acp-sdk/commit/7d0586e856a54e5779e4d523bd0862ab57f44b10)), closes [#126](https://github.com/bottrall/acp-sdk/issues/126)
|
|
23
|
+
|
|
3
24
|
## [0.10.0](https://github.com/bottrall/acp-sdk/compare/v0.9.0...v0.10.0) (2026-10-07)
|
|
4
25
|
|
|
5
26
|
|
data/README.md
CHANGED
|
@@ -65,7 +65,7 @@ Extensions are `_`-prefixed methods outside the spec. `AgentConnection` takes `e
|
|
|
65
65
|
|
|
66
66
|
## Driving an agent
|
|
67
67
|
|
|
68
|
-
`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. `
|
|
68
|
+
`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. `set_session_mode` sends `session/set_mode`, and `set_session_config_option` sends `session/set_config_option` and returns the session's complete option list, which replaces any state held from an earlier reply. `fork_session` sends `session/fork`; it is unstable, so the request and response types exist only under `ACP::Types::Unstable` and calling it before `require 'acp/types/unstable'` raises `NameError`. `list_providers`, `set_provider` and `disable_provider` send `providers/list`, `providers/set` and `providers/disable` on the same unstable terms. A handler that raises is answered with `-32603` and the message `Internal error`, and the exception is only logged to the transport's logger.
|
|
69
69
|
|
|
70
70
|
`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.
|
|
71
71
|
|
|
@@ -81,9 +81,9 @@ response = ACP::AgentProcess.spawn(
|
|
|
81
81
|
updates: ->(notification) { show(notification) }
|
|
82
82
|
) do |connection|
|
|
83
83
|
connection.connect(ACP::Types::InitializeRequest.new(protocol_version: 1))
|
|
84
|
-
session = connection.
|
|
84
|
+
session = connection.new_session(ACP::Types::NewSessionRequest.new(cwd: Dir.pwd, mcp_servers: []))
|
|
85
85
|
prompt = [ACP::Types::ContentBlock::Text.new(text: 'hello')]
|
|
86
|
-
connection.
|
|
86
|
+
connection.prompt_session(ACP::Types::PromptRequest.new(session_id: session.session_id, prompt:)) do |update|
|
|
87
87
|
print update.content.text if update.is_a?(ACP::Types::SessionUpdate::AgentMessageChunk)
|
|
88
88
|
end
|
|
89
89
|
end
|
|
@@ -104,8 +104,8 @@ connection = ACP::ClientConnection.new(
|
|
|
104
104
|
)
|
|
105
105
|
```
|
|
106
106
|
|
|
107
|
-
- `
|
|
108
|
-
- `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 `
|
|
107
|
+
- `prompt_session` and `load_session` 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. `resume_session` does not take a block: the agent must not replay history on resume, so unlike `load_session` there is nothing to stream. `close_session` and `delete_session` end the session, discarding it on the agent, and return once the agent replies.
|
|
108
|
+
- `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 `cancel_session` can be sent meanwhile.
|
|
109
109
|
- The agent may cancel any outstanding request with `$/cancel_request`: a handler that can end early checks `connection.cancelled?` — close over the connection when constructing it — and returns `ACP::RequestError.request_cancelled`, which is answered as `-32800`. The permission handler still sees the cancel on its own thread. Whatever the handler returns, the transport sends exactly one response.
|
|
110
110
|
- `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
111
|
- `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.
|
|
@@ -113,6 +113,14 @@ connection = ACP::ClientConnection.new(
|
|
|
113
113
|
|
|
114
114
|
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
115
|
|
|
116
|
+
## Types
|
|
117
|
+
|
|
118
|
+
Every ACP request, response and notification is a generated, frozen value class under `ACP::Types` with `from_h` and `to_h`. A few of its names are deliberate enough to deserve a note:
|
|
119
|
+
|
|
120
|
+
- `CancelRequestNotification` and `CancelNotification` are different methods with different payloads: the former is the params of the JSON-RPC-level `$/cancel_request`, which the transport handles itself, the latter is the params of ACP's `session/cancel`. Both spellings are the schema's, not a typo.
|
|
121
|
+
- The schema names `session/new`, `session/load`, `session/list` and `session/prompt`'s params and result `NewSessionRequest`, `LoadSessionRequest`, `ListSessionsRequest` and `PromptRequest`, so the wire-shaped spellings exist as alias constants: `ACP::Types::SessionNewRequest`, `SessionLoadRequest`, `SessionListRequest`, `SessionListResponse` and `SessionPromptRequest` are the same classes as their targets. Both names are public API and here to stay — use whichever reads better.
|
|
122
|
+
- The schema's pure string enums are generated as constant modules, for building requests and matching responses without string literals: `ACP::Types::ToolKind::EDIT`, `ACP::Types::StopReason::CANCELLED`, `ACP::Types::PlanEntryPriority::HIGH`, `ACP::Types::Role::USER` and the rest. They carry no validation of their own — the generated `from_h` checks incoming values against the schema regardless of how you build outgoing ones.
|
|
123
|
+
|
|
116
124
|
## Unstable types
|
|
117
125
|
|
|
118
126
|
Some protocol features are not part of the spec yet, so their schema lives in a separate unstable file: draft session updates (`plan_update`, `plan_removed`, `notice`, `compaction_update`, `compaction_summary_chunk`, `subagent_update`, `session_message`, `session_message_chunk`), `PromptResponse#usage`, the NE (next edit) and provider methods, and the matching capabilities. They are generated under `ACP::Types::Unstable` and load only when you opt in:
|
|
@@ -125,6 +133,8 @@ Until then, referencing `ACP::Types::Unstable` is a `NameError`, and the stable
|
|
|
125
133
|
|
|
126
134
|
These types are **unstable**: they are not part of the ACP spec, agents and clients must not rely on the peer supporting them, and any of them may change or disappear between releases without a major version bump. When a stable schema absorbs one of them, the unstable twin goes away and the stable name stays.
|
|
127
135
|
|
|
136
|
+
The connections route `session/fork`, the provider methods and `mcp/message`, but not every unstable method has routing yet: the NE methods (`nes/start`, `nes/suggest`, `nes/accept`, `nes/reject`, `nes/close`), the document notifications (`document/didOpen` and friends) and the unstable `ClientCapabilities` are generated as public API for callers wiring those methods at the transport level, and may gain connection routing later.
|
|
137
|
+
|
|
128
138
|
## Contributing
|
|
129
139
|
|
|
130
140
|
Bug reports and pull requests are welcome on [GitHub](https://github.com/bottrall/acp-sdk). See [CONTRIBUTING.md](CONTRIBUTING.md) for setup and workflow. Everyone interacting in the project is expected to follow the [code of conduct](CODE_OF_CONDUCT.md).
|
|
@@ -1,20 +1,17 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
class ACP::AgentConnection::Client
|
|
4
|
-
# @rbs @peer: ACP::
|
|
4
|
+
# @rbs @peer: ACP::Transport::_Peer
|
|
5
5
|
# @rbs @mcp_advertised: bool
|
|
6
6
|
|
|
7
7
|
# @dynamic capabilities, capabilities=
|
|
8
8
|
attr_accessor :capabilities #: ACP::Types::ClientCapabilities?
|
|
9
9
|
|
|
10
|
-
# @rbs peer: ACP::
|
|
10
|
+
# @rbs peer: ACP::Transport::_Peer
|
|
11
11
|
# @rbs mcp_advertised: bool
|
|
12
12
|
# @rbs return: void
|
|
13
13
|
def initialize(peer:, mcp_advertised: false)
|
|
14
14
|
@peer = peer
|
|
15
|
-
# The agent's own initialize advertisement of mcpCapabilities.acp, which
|
|
16
|
-
# licenses this side's mcp/message sends; the client's advertisement is
|
|
17
|
-
# the separate capabilities accessor.
|
|
18
15
|
@mcp_advertised = mcp_advertised
|
|
19
16
|
@capabilities = nil
|
|
20
17
|
end
|
|
@@ -33,9 +30,6 @@ class ACP::AgentConnection::Client
|
|
|
33
30
|
update(session_id, ACP::Types::SessionUpdate::AvailableCommandsUpdate.new(available_commands: commands))
|
|
34
31
|
end
|
|
35
32
|
|
|
36
|
-
# Blocks the calling thread until the client answers or the connection
|
|
37
|
-
# closes.
|
|
38
|
-
#
|
|
39
33
|
# @rbs request: ACP::Types::RequestPermissionRequest
|
|
40
34
|
# @rbs return: ACP::Types::RequestPermissionResponse | ACP::RequestError
|
|
41
35
|
def request_permission(request)
|
|
@@ -123,10 +117,6 @@ class ACP::AgentConnection::Client
|
|
|
123
117
|
@peer.notify('elicitation/complete', notification.to_h)
|
|
124
118
|
end
|
|
125
119
|
|
|
126
|
-
# Sends one inner MCP operation to the client's declared ACP MCP server. The
|
|
127
|
-
# reply's result may be null, and a reply matching neither carrier branch
|
|
128
|
-
# comes back as the raw hash.
|
|
129
|
-
#
|
|
130
120
|
# @rbs request: ACP::Types::Unstable::MessageMcpRequest
|
|
131
121
|
# @rbs return: (ACP::Types::Unstable::MessageMcpResponse::t | ACP::RequestError)
|
|
132
122
|
def mcp_message(request)
|
|
@@ -135,9 +125,7 @@ class ACP::AgentConnection::Client
|
|
|
135
125
|
call('mcp/message', ACP::Types::Unstable::MessageMcpResponse, request)
|
|
136
126
|
end
|
|
137
127
|
|
|
138
|
-
#
|
|
139
|
-
# returns the client's reply as-is: extension methods have no schema to
|
|
140
|
-
# parse the result into.
|
|
128
|
+
# Extension methods have no schema to parse the result into.
|
|
141
129
|
#
|
|
142
130
|
# @rbs method: String
|
|
143
131
|
# @rbs params: untyped
|
|
@@ -158,8 +146,8 @@ class ACP::AgentConnection::Client
|
|
|
158
146
|
private
|
|
159
147
|
|
|
160
148
|
# @rbs rpc_method: String
|
|
161
|
-
# @rbs type: ACP::
|
|
162
|
-
# @rbs request: ACP::
|
|
149
|
+
# @rbs type: ACP::Transport::_Parser
|
|
150
|
+
# @rbs request: ACP::Transport::_Response
|
|
163
151
|
# @rbs return: untyped
|
|
164
152
|
def call(rpc_method, type, request)
|
|
165
153
|
result = @peer.request(rpc_method, request.to_h)
|
|
@@ -171,8 +159,8 @@ class ACP::AgentConnection::Client
|
|
|
171
159
|
end
|
|
172
160
|
|
|
173
161
|
# @rbs rpc_method: String
|
|
174
|
-
# @rbs type: ACP::
|
|
175
|
-
# @rbs request: ACP::
|
|
162
|
+
# @rbs type: ACP::Transport::_Parser
|
|
163
|
+
# @rbs request: ACP::Transport::_Response
|
|
176
164
|
# @rbs return: untyped
|
|
177
165
|
def terminal_call(rpc_method, type, request)
|
|
178
166
|
return ACP::RequestError.unadvertised('terminal') unless capabilities&.terminal
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class ACP::AgentConnection::OptionalMethod::Spec
|
|
4
|
+
# @dynamic rpc_method
|
|
5
|
+
attr_reader :rpc_method #: String
|
|
6
|
+
# @dynamic agent_method
|
|
7
|
+
attr_reader :agent_method #: Symbol
|
|
8
|
+
# @dynamic path
|
|
9
|
+
attr_reader :path #: Array[Symbol]
|
|
10
|
+
|
|
11
|
+
# @rbs rpc_method: String
|
|
12
|
+
# @rbs agent_method: Symbol
|
|
13
|
+
# @rbs path: Array[Symbol]
|
|
14
|
+
# @rbs return: void
|
|
15
|
+
def initialize(rpc_method:, agent_method:, path:)
|
|
16
|
+
@rpc_method = rpc_method
|
|
17
|
+
@agent_method = agent_method
|
|
18
|
+
@path = path.freeze
|
|
19
|
+
freeze
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Readers missing along the chain count as unadvertised, so the stable
|
|
23
|
+
# capabilities answer false for the unstable-only paths (fork, providers).
|
|
24
|
+
#
|
|
25
|
+
# @rbs initialize_response: ACP::Types::InitializeResponse
|
|
26
|
+
# @rbs return: bool
|
|
27
|
+
def advertised?(initialize_response)
|
|
28
|
+
# reduce's U pins to the InitializeResponse argument, but the chain walks
|
|
29
|
+
# through unrelated capability types.
|
|
30
|
+
value = path.reduce(initialize_response) { |node, name| follow(node, name) } #: untyped
|
|
31
|
+
value ? true : false
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
# @rbs node: untyped
|
|
37
|
+
# @rbs name: Symbol
|
|
38
|
+
# @rbs return: untyped
|
|
39
|
+
def follow(node, name)
|
|
40
|
+
node.respond_to?(name) ? node.public_send(name) : nil
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -1,4 +1,40 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module ACP::AgentConnection::OptionalMethod
|
|
4
|
+
SPECS = [
|
|
5
|
+
Spec.new(rpc_method: 'authenticate', agent_method: :authenticate, path: %i[auth_methods any?]),
|
|
6
|
+
Spec.new(rpc_method: 'session/load', agent_method: :load_session, path: %i[agent_capabilities load_session]),
|
|
7
|
+
Spec.new(
|
|
8
|
+
rpc_method: 'session/list',
|
|
9
|
+
agent_method: :list_sessions,
|
|
10
|
+
path: %i[agent_capabilities session_capabilities list]
|
|
11
|
+
),
|
|
12
|
+
Spec.new(
|
|
13
|
+
rpc_method: 'session/resume',
|
|
14
|
+
agent_method: :resume_session,
|
|
15
|
+
path: %i[agent_capabilities session_capabilities resume]
|
|
16
|
+
),
|
|
17
|
+
Spec.new(
|
|
18
|
+
rpc_method: 'session/close',
|
|
19
|
+
agent_method: :close_session,
|
|
20
|
+
path: %i[agent_capabilities session_capabilities close]
|
|
21
|
+
),
|
|
22
|
+
Spec.new(
|
|
23
|
+
rpc_method: 'session/delete',
|
|
24
|
+
agent_method: :delete_session,
|
|
25
|
+
path: %i[agent_capabilities session_capabilities delete]
|
|
26
|
+
),
|
|
27
|
+
Spec.new(rpc_method: 'logout', agent_method: :logout, path: %i[agent_capabilities auth logout])
|
|
28
|
+
].freeze #: Array[Spec]
|
|
29
|
+
|
|
30
|
+
UNSTABLE_SPECS = [
|
|
31
|
+
Spec.new(
|
|
32
|
+
rpc_method: 'session/fork',
|
|
33
|
+
agent_method: :fork_session,
|
|
34
|
+
path: %i[agent_capabilities session_capabilities fork]
|
|
35
|
+
),
|
|
36
|
+
Spec.new(rpc_method: 'providers/list', agent_method: :list_providers, path: %i[agent_capabilities providers]),
|
|
37
|
+
Spec.new(rpc_method: 'providers/set', agent_method: :set_provider, path: %i[agent_capabilities providers]),
|
|
38
|
+
Spec.new(rpc_method: 'providers/disable', agent_method: :disable_provider, path: %i[agent_capabilities providers])
|
|
39
|
+
].freeze #: Array[Spec]
|
|
4
40
|
end
|
data/lib/acp/agent_connection.rb
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
class ACP::AgentConnection
|
|
4
|
-
# @rbs @transport: ACP::
|
|
4
|
+
# @rbs @transport: ACP::Transport::_Transport
|
|
5
5
|
# @rbs @capabilities: ACP::Types::AgentCapabilities
|
|
6
6
|
# @rbs @agent_info: ACP::Types::Implementation?
|
|
7
7
|
# @rbs @auth_methods: Array[ACP::Types::AuthMethod::t]
|
|
@@ -10,16 +10,6 @@ class ACP::AgentConnection
|
|
|
10
10
|
# @rbs @extension_notifications: Hash[String, ^(untyped) -> void]
|
|
11
11
|
# @rbs @logger: ACP::Transport::_Logger
|
|
12
12
|
|
|
13
|
-
PROTOCOL_VERSION = 1 #: Integer
|
|
14
|
-
OPTIONAL = [
|
|
15
|
-
ACP::AgentConnection::OptionalMethod::LoadSession,
|
|
16
|
-
ACP::AgentConnection::OptionalMethod::ListSessions,
|
|
17
|
-
ACP::AgentConnection::OptionalMethod::Authenticate,
|
|
18
|
-
ACP::AgentConnection::OptionalMethod::ResumeSession,
|
|
19
|
-
ACP::AgentConnection::OptionalMethod::CloseSession,
|
|
20
|
-
ACP::AgentConnection::OptionalMethod::DeleteSession,
|
|
21
|
-
ACP::AgentConnection::OptionalMethod::Logout
|
|
22
|
-
].freeze #: Array[ACP::AgentConnection::_OptionalMethod]
|
|
23
13
|
# The schema has no initialize capability for modes or config options: an
|
|
24
14
|
# agent offers them per session, in its session responses, so they are
|
|
25
15
|
# routed whenever the agent defines them.
|
|
@@ -28,7 +18,7 @@ class ACP::AgentConnection
|
|
|
28
18
|
'session/set_config_option' => :change_session_config_option
|
|
29
19
|
}.freeze #: Hash[String, Symbol]
|
|
30
20
|
|
|
31
|
-
# @rbs transport: ACP::
|
|
21
|
+
# @rbs transport: ACP::Transport::_Transport
|
|
32
22
|
# @rbs capabilities: ACP::Types::AgentCapabilities
|
|
33
23
|
# @rbs agent_info: ACP::Types::Implementation?
|
|
34
24
|
# @rbs auth_methods: Array[ACP::Types::AuthMethod::t]
|
|
@@ -87,7 +77,7 @@ class ACP::AgentConnection
|
|
|
87
77
|
|
|
88
78
|
# @rbs agent: ACP::AgentConnection::_Agent
|
|
89
79
|
# @rbs client: ACP::AgentConnection::Client
|
|
90
|
-
# @rbs return: Hash[String, ^(untyped) -> (ACP::
|
|
80
|
+
# @rbs return: Hash[String, ^(untyped) -> (ACP::Transport::_Response | ACP::RequestError | ACP::Transport::Reply)]
|
|
91
81
|
def requests(agent, client)
|
|
92
82
|
# Safe: start drops the optional handlers initialize does not advertise
|
|
93
83
|
# or the agent does not define, and checks the agent defines the rest.
|
|
@@ -115,9 +105,9 @@ class ACP::AgentConnection
|
|
|
115
105
|
# advertises fork or providers.
|
|
116
106
|
#
|
|
117
107
|
# @rbs full: ACP::AgentConnection::_FullAgent
|
|
118
|
-
# @rbs return: Hash[String, ^(untyped) -> (ACP::
|
|
108
|
+
# @rbs return: Hash[String, ^(untyped) -> (ACP::Transport::_Response | ACP::RequestError | ACP::Transport::Reply)]
|
|
119
109
|
def unstable_requests(full)
|
|
120
|
-
return {} unless
|
|
110
|
+
return {} unless ACP::Types.unstable_enabled?
|
|
121
111
|
|
|
122
112
|
{
|
|
123
113
|
'session/fork' => route(ACP::Types::Unstable::ForkSessionRequest) { |request| full.fork_session(request) },
|
|
@@ -127,20 +117,16 @@ class ACP::AgentConnection
|
|
|
127
117
|
}
|
|
128
118
|
end
|
|
129
119
|
|
|
130
|
-
#
|
|
131
|
-
#
|
|
132
|
-
# `require 'acp/types/unstable'`.
|
|
120
|
+
# The unstable specs pair with the unstable request routes, which reference
|
|
121
|
+
# the unstable request classes, so both are only probed after the caller
|
|
122
|
+
# opted in with `require 'acp/types/unstable'`.
|
|
133
123
|
#
|
|
134
|
-
# @rbs return: Array[ACP::AgentConnection::
|
|
124
|
+
# @rbs return: Array[ACP::AgentConnection::OptionalMethod::Spec]
|
|
135
125
|
def optional_methods
|
|
136
|
-
|
|
126
|
+
specs = ACP::AgentConnection::OptionalMethod::SPECS
|
|
127
|
+
return specs unless ACP::Types.unstable_enabled?
|
|
137
128
|
|
|
138
|
-
|
|
139
|
-
ACP::AgentConnection::OptionalMethod::ForkSession,
|
|
140
|
-
ACP::AgentConnection::OptionalMethod::ListProviders,
|
|
141
|
-
ACP::AgentConnection::OptionalMethod::SetProvider,
|
|
142
|
-
ACP::AgentConnection::OptionalMethod::DisableProvider
|
|
143
|
-
]
|
|
129
|
+
specs + ACP::AgentConnection::OptionalMethod::UNSTABLE_SPECS
|
|
144
130
|
end
|
|
145
131
|
|
|
146
132
|
# @rbs agent: ACP::AgentConnection::_Agent
|
|
@@ -165,18 +151,9 @@ class ACP::AgentConnection
|
|
|
165
151
|
agent.cancel(notification)
|
|
166
152
|
end
|
|
167
153
|
|
|
168
|
-
# The unstable gates reference the unstable types, so they can only be
|
|
169
|
-
# probed when the caller opted in with `require 'acp/types/unstable'`; the
|
|
170
|
-
# stable McpCapabilities has no acp field at all.
|
|
171
|
-
#
|
|
172
154
|
# @rbs return: bool
|
|
173
155
|
def mcp_acp_advertised?
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
mcp = @capabilities.mcp_capabilities #: ACP::Types::McpCapabilities | ACP::Types::Unstable::McpCapabilities?
|
|
177
|
-
return false unless mcp.is_a?(ACP::Types::Unstable::McpCapabilities)
|
|
178
|
-
|
|
179
|
-
mcp.acp ? true : false
|
|
156
|
+
ACP::Protocol.mcp_acp_advertised?(@capabilities)
|
|
180
157
|
end
|
|
181
158
|
|
|
182
159
|
# A notification has no reply to carry a parse failure.
|
|
@@ -195,9 +172,9 @@ class ACP::AgentConnection
|
|
|
195
172
|
|
|
196
173
|
# Generated from_h raises on a missing key or a value of the wrong shape.
|
|
197
174
|
#
|
|
198
|
-
# @rbs type: ACP::
|
|
199
|
-
# @rbs &handle: (untyped) -> (ACP::
|
|
200
|
-
# @rbs return: ^(untyped) -> (ACP::
|
|
175
|
+
# @rbs type: ACP::Transport::_Parser
|
|
176
|
+
# @rbs &handle: (untyped) -> (ACP::Transport::_Response | ACP::RequestError | ACP::Transport::Reply)
|
|
177
|
+
# @rbs return: ^(untyped) -> (ACP::Transport::_Response | ACP::RequestError | ACP::Transport::Reply)
|
|
201
178
|
def route(type, &)
|
|
202
179
|
lambda do |params|
|
|
203
180
|
request = type.from_h(params)
|
|
@@ -223,7 +200,7 @@ class ACP::AgentConnection
|
|
|
223
200
|
# @rbs return: ACP::Types::InitializeResponse
|
|
224
201
|
def initialize_response(auth_methods)
|
|
225
202
|
ACP::Types::InitializeResponse.new(
|
|
226
|
-
protocol_version: PROTOCOL_VERSION,
|
|
203
|
+
protocol_version: ACP::Protocol::PROTOCOL_VERSION,
|
|
227
204
|
agent_capabilities: @capabilities,
|
|
228
205
|
auth_methods:,
|
|
229
206
|
agent_info: @agent_info
|
data/lib/acp/agent_process.rb
CHANGED
|
@@ -2,8 +2,6 @@
|
|
|
2
2
|
|
|
3
3
|
require 'open3'
|
|
4
4
|
|
|
5
|
-
# A child agent process and the ACP::ClientConnection wired to its stdio.
|
|
6
|
-
# Spawn one with ACP::AgentProcess.spawn.
|
|
7
5
|
class ACP::AgentProcess
|
|
8
6
|
# @rbs @connection: ACP::ClientConnection
|
|
9
7
|
# @rbs @reader: Thread
|
|
@@ -21,16 +19,9 @@ class ACP::AgentProcess
|
|
|
21
19
|
# @dynamic connection
|
|
22
20
|
attr_reader :connection #: ACP::ClientConnection
|
|
23
21
|
|
|
24
|
-
# Child stderr captured when spawn was given stderr: :capture, else nil.
|
|
25
|
-
#
|
|
26
22
|
# @dynamic stderr
|
|
27
23
|
attr_reader :stderr #: String?
|
|
28
24
|
|
|
29
|
-
# Starts a command with args, env and cwd, wires its stdio to a new
|
|
30
|
-
# ACP::ClientConnection and starts it. With a block, yields the connection
|
|
31
|
-
# and this process, then terminates the child when the block exits; without
|
|
32
|
-
# one, returns the process and terminate is the caller's job.
|
|
33
|
-
#
|
|
34
25
|
# @rbs command: String
|
|
35
26
|
# @rbs *args: String
|
|
36
27
|
# @rbs env: Hash[String, String]?
|
|
@@ -72,10 +63,6 @@ class ACP::AgentProcess
|
|
|
72
63
|
end
|
|
73
64
|
end
|
|
74
65
|
|
|
75
|
-
# Closes stdin, waits for the child to exit — sending TERM and then KILL if
|
|
76
|
-
# it will not — and joins the transport reader before closing the pipe it
|
|
77
|
-
# reads.
|
|
78
|
-
#
|
|
79
66
|
# @rbs return: void
|
|
80
67
|
def terminate
|
|
81
68
|
@stdin.close unless @stdin.closed?
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
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
|
+
class ACP::ClientConnection::Streams
|
|
7
|
+
CANCELLED = ACP::Types::RequestPermissionResponse.new(
|
|
8
|
+
outcome: ACP::Types::RequestPermissionOutcome::Cancelled.new
|
|
9
|
+
) #: ACP::Types::RequestPermissionResponse
|
|
10
|
+
|
|
11
|
+
# @rbs @sender: ACP::ClientConnection::Streams::_Sender
|
|
12
|
+
# @rbs @permission: ACP::ClientConnection::_PermissionHandler
|
|
13
|
+
# @rbs @updates: ACP::ClientConnection::_UpdateHandler
|
|
14
|
+
# @rbs @complete_elicitation: (^(ACP::Types::CompleteElicitationNotification) -> void)?
|
|
15
|
+
# @rbs @logger: ACP::Transport::_Logger
|
|
16
|
+
# @rbs @lock: Thread::Mutex
|
|
17
|
+
# @rbs @streams: Hash[String, Thread::Queue]
|
|
18
|
+
# @rbs @pending_permissions: Hash[String, Array[Thread::Queue]]
|
|
19
|
+
# @rbs @outstanding_elicitations: Hash[String, bool]
|
|
20
|
+
|
|
21
|
+
# @rbs sender: ACP::ClientConnection::Streams::_Sender
|
|
22
|
+
# @rbs permission: ACP::ClientConnection::_PermissionHandler
|
|
23
|
+
# @rbs updates: ACP::ClientConnection::_UpdateHandler
|
|
24
|
+
# @rbs logger: ACP::Transport::_Logger
|
|
25
|
+
# @rbs complete_elicitation: (^(ACP::Types::CompleteElicitationNotification) -> void)?
|
|
26
|
+
# @rbs return: void
|
|
27
|
+
def initialize(sender:, permission:, updates:, logger:, complete_elicitation: nil)
|
|
28
|
+
@sender = sender
|
|
29
|
+
@permission = permission
|
|
30
|
+
@updates = updates
|
|
31
|
+
@complete_elicitation = complete_elicitation
|
|
32
|
+
@logger = logger
|
|
33
|
+
@lock = Mutex.new
|
|
34
|
+
@streams = {}
|
|
35
|
+
@pending_permissions = {}
|
|
36
|
+
@outstanding_elicitations = {}
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# The request waits on its own thread so the caller's thread can yield each
|
|
40
|
+
# update as it arrives. The reader thread queues an update before it settles
|
|
41
|
+
# a reply sent after it, so the queue closes only after the last one.
|
|
42
|
+
#
|
|
43
|
+
# @rbs session_id: String
|
|
44
|
+
# @rbs method: String
|
|
45
|
+
# @rbs params: Hash[String, untyped]
|
|
46
|
+
# @rbs &block: (ACP::Types::SessionUpdate::t) -> void
|
|
47
|
+
# @rbs return: (Hash[String, untyped] | ACP::RequestError)
|
|
48
|
+
def stream(session_id, method, params, &)
|
|
49
|
+
queue = Thread::Queue.new
|
|
50
|
+
# The check and the insert share one lock acquisition so a concurrent
|
|
51
|
+
# stream cannot register between them.
|
|
52
|
+
claimed = @lock.synchronize do
|
|
53
|
+
if @streams.key?(session_id)
|
|
54
|
+
false
|
|
55
|
+
else
|
|
56
|
+
@streams[session_id] = queue
|
|
57
|
+
true
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
unless claimed
|
|
61
|
+
return ACP::RequestError.new(
|
|
62
|
+
code: ACP::RequestError::INVALID_REQUEST, message: "A stream is already open for session #{session_id}"
|
|
63
|
+
)
|
|
64
|
+
end
|
|
65
|
+
requester = Thread.new do
|
|
66
|
+
@sender.request(method, params)
|
|
67
|
+
ensure
|
|
68
|
+
@lock.synchronize { @streams.delete(session_id)&.close }
|
|
69
|
+
end
|
|
70
|
+
while (update = queue.pop)
|
|
71
|
+
yield update
|
|
72
|
+
end
|
|
73
|
+
requester.value
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Pushes under the lock so a stream cannot close its queue between the
|
|
77
|
+
# lookup and the push. A malformed notification has no reply to carry the
|
|
78
|
+
# failure.
|
|
79
|
+
#
|
|
80
|
+
# @rbs params: untyped
|
|
81
|
+
# @rbs return: void
|
|
82
|
+
def dispatch(params)
|
|
83
|
+
notification = ACP::Types::SessionNotification.from_h(params)
|
|
84
|
+
rescue ACP::Types::ParseError, KeyError, TypeError, NoMethodError => e
|
|
85
|
+
@logger.warn("dropped malformed session/update: #{e.class}: #{e.message}")
|
|
86
|
+
else
|
|
87
|
+
queue = @lock.synchronize { @streams[notification.session_id]&.push(notification.update) }
|
|
88
|
+
@updates.call(notification) unless queue
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# The handler runs on its own thread so a cancelled request is answered
|
|
92
|
+
# without waiting for it, and whatever it returns after that is pushed to a
|
|
93
|
+
# queue nobody reads.
|
|
94
|
+
#
|
|
95
|
+
# @rbs params: untyped
|
|
96
|
+
# @rbs return: (ACP::Types::RequestPermissionResponse | ACP::RequestError)
|
|
97
|
+
def request_permission(params)
|
|
98
|
+
request = ACP::Types::RequestPermissionRequest.from_h(params)
|
|
99
|
+
rescue ACP::Types::ParseError => e
|
|
100
|
+
ACP::RequestError.invalid_params([e.message])
|
|
101
|
+
rescue KeyError, TypeError, NoMethodError
|
|
102
|
+
ACP::RequestError.invalid_params
|
|
103
|
+
else
|
|
104
|
+
replies = Thread::Queue.new
|
|
105
|
+
register_permission(request.session_id, replies)
|
|
106
|
+
# The handler runs on the transport's serve thread, so the serving
|
|
107
|
+
# identity must be carried over for cancelled? to see the cancel there.
|
|
108
|
+
serving = @sender.cancellation
|
|
109
|
+
Thread.new do
|
|
110
|
+
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
|
+
replies << begin
|
|
114
|
+
[:value, @permission.call(request)]
|
|
115
|
+
rescue StandardError => e
|
|
116
|
+
[:raised, e]
|
|
117
|
+
end
|
|
118
|
+
ensure
|
|
119
|
+
unregister_permission(request.session_id, replies)
|
|
120
|
+
end
|
|
121
|
+
settled = replies.pop
|
|
122
|
+
# Re-raised so the transport's invoke turns it into an error reply, as it
|
|
123
|
+
# would if the handler ran on the serve thread itself.
|
|
124
|
+
raise settled[1] if settled[0] == :raised
|
|
125
|
+
|
|
126
|
+
settled[1]
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# The spec requires a pending session/request_permission to be answered with
|
|
130
|
+
# the cancelled outcome once the turn is cancelled, so every queue registered
|
|
131
|
+
# for the session gets one before this returns.
|
|
132
|
+
#
|
|
133
|
+
# @rbs session_id: String
|
|
134
|
+
# @rbs return: void
|
|
135
|
+
def cancel(session_id)
|
|
136
|
+
pending = @lock.synchronize { @pending_permissions.delete(session_id) }
|
|
137
|
+
pending&.each { |replies| replies << [:value, CANCELLED] }
|
|
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
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
private
|
|
163
|
+
|
|
164
|
+
# @rbs session_id: String
|
|
165
|
+
# @rbs replies: Thread::Queue
|
|
166
|
+
# @rbs return: void
|
|
167
|
+
def register_permission(session_id, replies)
|
|
168
|
+
@lock.synchronize { (@pending_permissions[session_id] ||= []) << replies }
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# @rbs session_id: String
|
|
172
|
+
# @rbs replies: Thread::Queue
|
|
173
|
+
# @rbs return: void
|
|
174
|
+
def unregister_permission(session_id, replies)
|
|
175
|
+
@lock.synchronize do
|
|
176
|
+
pending = @pending_permissions[session_id]
|
|
177
|
+
if pending
|
|
178
|
+
pending.delete(replies)
|
|
179
|
+
@pending_permissions.delete(session_id) if pending.empty?
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|