acp-sdk 0.1.0 → 0.3.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.
Files changed (50) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +18 -0
  3. data/README.md +66 -41
  4. data/lib/acp/agent_connection/client.rb +82 -3
  5. data/lib/acp/agent_connection/optional_method/authenticate.rb +21 -0
  6. data/lib/acp/agent_connection/optional_method/close_session.rb +21 -0
  7. data/lib/acp/agent_connection/optional_method/delete_session.rb +21 -0
  8. data/lib/acp/agent_connection/optional_method/list_sessions.rb +21 -0
  9. data/lib/acp/agent_connection/optional_method/load_session.rb +21 -0
  10. data/lib/acp/agent_connection/optional_method/resume_session.rb +21 -0
  11. data/lib/acp/agent_connection/optional_method.rb +1 -25
  12. data/lib/acp/agent_connection.rb +47 -47
  13. data/lib/acp/agent_process.rb +119 -0
  14. data/lib/acp/client_connection.rb +13 -15
  15. data/lib/acp/request_error.rb +81 -0
  16. data/lib/acp/transport/reply.rb +4 -4
  17. data/lib/acp/transport/stdio.rb +28 -25
  18. data/lib/acp/types.rb +4 -0
  19. data/lib/acp/version.rb +1 -1
  20. data/sig/generated/acp/agent_connection/client.rbs +51 -2
  21. data/sig/generated/acp/agent_connection/optional_method/authenticate.rbs +13 -0
  22. data/sig/generated/acp/agent_connection/optional_method/close_session.rbs +13 -0
  23. data/sig/generated/acp/agent_connection/optional_method/delete_session.rbs +13 -0
  24. data/sig/generated/acp/agent_connection/optional_method/list_sessions.rbs +13 -0
  25. data/sig/generated/acp/agent_connection/optional_method/load_session.rbs +13 -0
  26. data/sig/generated/acp/agent_connection/optional_method/resume_session.rbs +13 -0
  27. data/sig/generated/acp/agent_connection/optional_method.rbs +1 -18
  28. data/sig/generated/acp/agent_connection.rbs +17 -22
  29. data/sig/generated/acp/agent_process.rbs +72 -0
  30. data/sig/generated/acp/client_connection.rbs +16 -16
  31. data/sig/generated/acp/request_error.rbs +65 -0
  32. data/sig/generated/acp/transport/reply.rbs +5 -5
  33. data/sig/generated/acp/transport/stdio.rbs +14 -19
  34. data/sig/generated/acp/types.rbs +4 -0
  35. data/sig/manual/acp/agent_connection/optional_method/authenticate.rbs +6 -0
  36. data/sig/manual/acp/agent_connection/optional_method/close_session.rbs +6 -0
  37. data/sig/manual/acp/agent_connection/optional_method/delete_session.rbs +6 -0
  38. data/sig/manual/acp/agent_connection/optional_method/list_sessions.rbs +6 -0
  39. data/sig/manual/acp/agent_connection/optional_method/load_session.rbs +6 -0
  40. data/sig/manual/acp/agent_connection/optional_method/resume_session.rbs +6 -0
  41. data/sig/manual/acp/agent_connection.rbs +55 -6
  42. data/sig/manual/acp/agent_process.rbs +7 -0
  43. data/sig/manual/acp/client_connection.rbs +1 -1
  44. data/sig/manual/acp/transport/stdio.rbs +7 -1
  45. metadata +26 -8
  46. data/lib/acp/transport/response_error.rb +0 -28
  47. data/lib/acp/transport/result.rb +0 -35
  48. data/sig/generated/acp/transport/response_error.rbs +0 -21
  49. data/sig/generated/acp/transport/result.rbs +0 -25
  50. data/sig/manual/acp/types.rbs +0 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b7ee3b18dbb3a538b575c1b3309f93bd84eab3566e4491d39646dde8f0114ff6
4
- data.tar.gz: ef1b3bed3a05dea92f076f4ed67e8378a3893d522db9ffe76ad445810b5fb20e
3
+ metadata.gz: 59dca9a568751a3b4c29c1ff4afdd9ad12e997689d5eb3a4782f2a4c5ced81b0
4
+ data.tar.gz: 99bde6bbc81d16012a6d9b3654bf15f15a35a40713f3d6dbbe686557f182ac50
5
5
  SHA512:
6
- metadata.gz: 69232d2be95232917e3f3c25c0eb890cbb2367069378e4ec415765a9606740da333b32631d3430c42fd7b5691b434329ef1ff2f6a0c19433d5149837bf4880d9
7
- data.tar.gz: 256d27b3b35c9e6e569fe60098885fa878fa6e7df06ffed8330c58d5b611f198a796df6928a62c6f8c2df01e4628312e5a6408efab669511dc4a402a8ab7b6e8
6
+ metadata.gz: f803ca1e9d3dae3b791cebe930d49c221b2cd3f68df6cc5734bc3e40103af0d101db4447851596c2f6834b855f08a110c24455b06c517aa62e51b95a23cbdfd2
7
+ data.tar.gz: 844f53d23060359a77314f196fdeab023a7ded7c67fdea9f1a75b80bb28db3efb2421977001c13b3ec37f84479f6b9bf816e53a4a8da92c89bc58ee211b88ab8
data/CHANGELOG.md CHANGED
@@ -1,5 +1,23 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.3.0](https://github.com/bottrall/acp-sdk/compare/v0.2.0...v0.3.0) (2026-10-02)
4
+
5
+
6
+ ### Features
7
+
8
+ * named protocol error codes ([#52](https://github.com/bottrall/acp-sdk/issues/52)) ([0a81700](https://github.com/bottrall/acp-sdk/commit/0a81700e3fa095279a77e2f161a7d6369c46792f))
9
+ * spawn an agent process for ACP::ClientConnection ([#54](https://github.com/bottrall/acp-sdk/issues/54)) ([e01d2d2](https://github.com/bottrall/acp-sdk/commit/e01d2d280e9cfd08c203f6dccd1ce602979e21c8))
10
+
11
+ ## [0.2.0](https://github.com/bottrall/acp-sdk/compare/v0.1.0...v0.2.0) (2026-09-30)
12
+
13
+
14
+ ### Features
15
+
16
+ * authenticate on ACP::AgentConnection ([#23](https://github.com/bottrall/acp-sdk/issues/23)) ([aac2bdd](https://github.com/bottrall/acp-sdk/commit/aac2bddbada2108bad3397d8f2c7bcdd617e8e0f))
17
+ * filesystem methods on ACP::AgentConnection::Client ([#20](https://github.com/bottrall/acp-sdk/issues/20)) ([a15d942](https://github.com/bottrall/acp-sdk/commit/a15d942d1fe48cd7042732f64ea6d94cdd191549)), closes [#10](https://github.com/bottrall/acp-sdk/issues/10)
18
+ * optional session methods on ACP::AgentConnection ([#24](https://github.com/bottrall/acp-sdk/issues/24)) ([952f906](https://github.com/bottrall/acp-sdk/commit/952f906e32bf455bac40effeb3d32fa6dea95904))
19
+ * terminal methods on ACP::AgentConnection::Client ([#22](https://github.com/bottrall/acp-sdk/issues/22)) ([b4108b8](https://github.com/bottrall/acp-sdk/commit/b4108b8570a36d78a2c1231c04f7c1f697848318))
20
+
3
21
  ## 0.1.0 (2026-09-30)
4
22
 
5
23
 
data/README.md CHANGED
@@ -1,20 +1,43 @@
1
1
  # acp-sdk
2
2
 
3
- A standalone Ruby implementation of the [Agent Client Protocol](https://agentclientprotocol.com) (ACP): the agent side (`ACP::AgentConnection`), for exposing an agent to ACP clients such as Zed and JetBrains, and the client side (`ACP::ClientConnection`), for driving ACP agents from Ruby.
3
+ [![Gem Version](https://badge.fury.io/rb/acp-sdk.svg)](https://rubygems.org/gems/acp-sdk)
4
4
 
5
- Namespace `ACP`. MIT licensed.
5
+ A Ruby SDK for the [Agent Client Protocol](https://agentclientprotocol.com) (ACP). It covers both sides of the protocol:
6
6
 
7
- ## Status
7
+ - **Agent side** (`ACP::AgentConnection`): expose your agent to ACP clients such as Zed and JetBrains.
8
+ - **Client side** (`ACP::ClientConnection`): drive ACP agents from Ruby, for an editor integration or for tests.
8
9
 
9
- Intent only. Nothing is published yet; the `acp-sdk` gem name is claimed at the first release.
10
+ Every ACP request, response and notification is a generated Ruby class under `ACP::Types`, and messages travel as JSON-RPC over stdio. The SDK speaks ACP protocol version 1.
10
11
 
11
- This repo exists so that [riffer-rig](https://github.com/bottrall/riffer-rig) can depend on it for its `riffer acp` host. Scope for the first release: JSON-RPC over stdio, `mcpServers` and `available_commands` in, permission requests and filesystem methods out.
12
+ ## Installation
13
+
14
+ Requires Ruby 4.0 or later.
15
+
16
+ Add the gem to your Gemfile:
17
+
18
+ ```sh
19
+ bundle add acp-sdk
20
+ ```
21
+
22
+ Or install it directly:
23
+
24
+ ```sh
25
+ gem install acp-sdk
26
+ ```
27
+
28
+ Then require it:
29
+
30
+ ```ruby
31
+ require 'acp/sdk'
32
+ ```
12
33
 
13
34
  ## Serving an agent
14
35
 
15
- An agent is a plain Ruby object. `ACP::AgentConnection` answers `initialize` itself, turns each request into its generated `ACP::Types` object, calls the agent, and sends back what it returns. The factory block receives an `ACP::AgentConnection::Client` handle, which the agent keeps for sending session updates and asking permission. [`examples/echo_agent.rb`](examples/echo_agent.rb) is a complete agent; `ruby examples/echo_agent.rb` serves it on stdio.
36
+ An agent is a plain Ruby object. `ACP::AgentConnection` answers `initialize` itself, turns each request into its generated `ACP::Types` object, calls the agent, and sends back what it returns. The factory block receives an `ACP::AgentConnection::Client` handle, which the agent keeps for sending session updates, asking permission, reading and writing files, and running commands in terminals.
16
37
 
17
38
  ```ruby
39
+ require 'acp/sdk'
40
+
18
41
  connection = ACP::AgentConnection.new(
19
42
  transport: ACP::Transport::Stdio.new(input: $stdin, output: $stdout),
20
43
  capabilities: ACP::Types::AgentCapabilities.new(load_session: true),
@@ -25,57 +48,59 @@ connection.start.join
25
48
 
26
49
  The agent's contract:
27
50
 
28
- - It defines `new_session`, `prompt` and `cancel`. `load_session` and `list_sessions` are required only when `capabilities` advertises `load_session` or `session_capabilities.list`; `start` raises `ArgumentError` when one is advertised but missing, and a method that is not advertised is answered with `-32601`. `session_created`, if defined, runs right after the `session/new` reply is sent.
29
- - Each request method returns its response type or an `ACP::Transport::ResponseError`, which is sent as the error reply. Params that do not match the schema are answered with `-32602` before the agent sees them.
51
+ - It defines `new_session`, `prompt` and `cancel`. `load_session`, `list_sessions`, `resume_session`, `close_session` and `delete_session` are required only when `capabilities` advertises `load_session` or `session_capabilities.list`, `.resume`, `.close` or `.delete`, and `authenticate` only when `auth_methods` is non-empty; `start` raises `ArgumentError` when one is advertised but missing, and a method that is not advertised is answered with `-32601`. `change_session_mode` and `change_session_config_option` are optional, since modes and config options are offered per session rather than in `initialize`; `session/set_mode` and `session/set_config_option` are answered with `-32601` when the agent does not define them. `session_created`, if defined, runs right after the `session/new` reply is sent.
52
+ - Each request method returns its response type or an `ACP::RequestError`, which is sent as the error reply. Params that do not match the schema are answered with `-32602` before the agent sees them.
30
53
  - `cancel` runs on the transport's reader thread, so it must return quickly: set a flag and let the prompt notice it.
31
54
  - After a cancel, the agent must itself end the turn with `stopReason: cancelled`. `ACP::AgentConnection` does not enforce it.
32
55
  - `client.capabilities` is `nil` until the client sends `initialize`.
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
+ - `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
+
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`.
33
60
 
34
61
  ## Driving an agent
35
62
 
36
- `ACP::ClientConnection` is the other side of the same transport, for editors and for tests that drive an agent. Each method takes the request's generated `ACP::Types` object and returns its response type or the agent's `ACP::Transport::ResponseError`. `connect` sends `initialize`, which Ruby reserves for the constructor.
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.
64
+
65
+ `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.
37
66
 
38
67
  ```ruby
39
- stdin, stdout, = Open3.popen2('ruby', 'examples/echo_agent.rb')
40
- connection = ACP::ClientConnection.new(
41
- transport: ACP::Transport::Stdio.new(input: stdout, output: stdin),
68
+ require 'acp/sdk'
69
+
70
+ response = ACP::AgentProcess.spawn(
71
+ 'my-agent', '--flag',
72
+ env: { 'API_KEY' => '...' },
73
+ cwd: Dir.pwd,
74
+ stderr: :capture,
42
75
  permission: ->(request) { ask_the_user(request) },
43
76
  updates: ->(notification) { show(notification) }
44
- )
45
- connection.start
46
- connection.connect(ACP::Types::InitializeRequest.new(protocol_version: 1))
47
- session = connection.session_new(ACP::Types::NewSessionRequest.new(cwd: Dir.pwd, mcp_servers: []))
48
- prompt = [ACP::Types::ContentBlock::Text.new(text: 'hello')]
49
- response = connection.session_prompt(ACP::Types::PromptRequest.new(session_id: session.session_id, prompt:)) do |update|
50
- print update.content.text if update.is_a?(ACP::Types::SessionUpdate::AgentMessageChunk)
77
+ ) do |connection|
78
+ connection.connect(ACP::Types::InitializeRequest.new(protocol_version: 1))
79
+ session = connection.session_new(ACP::Types::NewSessionRequest.new(cwd: Dir.pwd, mcp_servers: []))
80
+ prompt = [ACP::Types::ContentBlock::Text.new(text: 'hello')]
81
+ connection.session_prompt(ACP::Types::PromptRequest.new(session_id: session.session_id, prompt:)) do |update|
82
+ print update.content.text if update.is_a?(ACP::Types::SessionUpdate::AgentMessageChunk)
83
+ end
51
84
  end
52
85
  ```
53
86
 
54
- - `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.
55
- - `permission` answers `session/request_permission` with an `ACP::Types::RequestPermissionResponse` or an `ACP::Transport::ResponseError`. It runs on its own thread, so it may block while the user decides, and `session_cancel` can be sent meanwhile.
87
+ Any transport over a pair of IOs works too, so the connection can still be wired by hand when the agent is not a child process:
56
88
 
57
- ## Development
58
-
59
- Every project chore is a script in `bin/`. The Rakefile behind them is an implementation detail; you never need to call rake directly.
60
-
61
- | Script | What it does |
62
- | --------------- | ---------------------------------------------------------------------------------------------- |
63
- | `bin/setup` | Install dependencies on a fresh checkout (gems + rbs collection) |
64
- | `bin/test` | Run the test suite. Pass files and/or Minitest flags: `bin/test test/foo_test.rb -n /pattern/` |
65
- | `bin/lint` | Run RuboCop. Arguments are forwarded, e.g. `bin/lint -a` |
66
- | `bin/typecheck` | Check `lib/acp/types`, the rbs collection lockfile and `sig/generated` are current, then type-check with Steep |
67
- | `bin/rbs` | Regenerate `sig/generated` from the inline annotations in `lib/` |
68
- | `bin/types` | Regenerate `lib/acp/types` from `schema/schema.json`, then `sig/generated` |
69
- | `bin/rbs-watch` | Regenerate `sig/generated` whenever `lib/` changes |
70
- | `bin/ci` | Run everything CI runs, serially. Use before pushing |
71
- | `bin/build` | Build the gem into `pkg/`; the publish workflow runs this before `gem push` |
89
+ ```ruby
90
+ connection = ACP::ClientConnection.new(
91
+ transport: ACP::Transport::Stdio.new(input: some_io, output: other_io),
92
+ permission: ->(request) { ask_the_user(request) },
93
+ updates: ->(notification) { show(notification) }
94
+ )
95
+ ```
72
96
 
73
- ## Releasing
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.
98
+ - `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.
74
99
 
75
- PR titles are [conventional commits](https://www.conventionalcommits.org/) and are linted in CI: `feat:` bumps the minor version, `fix:` bumps the patch, and `feat!:` marks a breaking change (also a minor bump while we are on 0.x). `chore:`, `docs:`, `ci:`, `refactor:` and `test:` never release. Squash-merging makes the title the commit on `main`.
100
+ ## Contributing
76
101
 
77
- [release-please](https://github.com/googleapis/release-please) keeps a release PR open that bumps `lib/acp/version.rb` and writes `CHANGELOG.md`. Merging that PR tags `vX.Y.Z`, creates the GitHub Release and publishes the gem to RubyGems.org through Trusted Publishing; nothing is pushed by hand. The first release is 0.1.0.
102
+ 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).
78
103
 
79
- ## Maintainer
104
+ ## License
80
105
 
81
- - Jake Bottrall - https://github.com/bottrall
106
+ Released under the [MIT License](LICENSE.txt).
@@ -31,9 +31,88 @@ class ACP::AgentConnection::Client
31
31
  # closes.
32
32
  #
33
33
  # @rbs request: ACP::Types::RequestPermissionRequest
34
- # @rbs return: ACP::Types::RequestPermissionResponse | ACP::Transport::ResponseError
34
+ # @rbs return: ACP::Types::RequestPermissionResponse | ACP::RequestError
35
35
  def request_permission(request)
36
- result = @peer.request('session/request_permission', request.to_h)
37
- result.error || ACP::Types::RequestPermissionResponse.from_h(result.value)
36
+ call('session/request_permission', ACP::Types::RequestPermissionResponse, request)
37
+ end
38
+
39
+ # @rbs request: ACP::Types::ReadTextFileRequest
40
+ # @rbs return: ACP::Types::ReadTextFileResponse | ACP::RequestError
41
+ def read_text_file(request)
42
+ return unadvertised('fs.readTextFile') unless capabilities&.fs&.read_text_file
43
+
44
+ call('fs/read_text_file', ACP::Types::ReadTextFileResponse, request)
45
+ end
46
+
47
+ # @rbs request: ACP::Types::WriteTextFileRequest
48
+ # @rbs return: ACP::Types::WriteTextFileResponse | ACP::RequestError
49
+ def write_text_file(request)
50
+ return unadvertised('fs.writeTextFile') unless capabilities&.fs&.write_text_file
51
+
52
+ call('fs/write_text_file', ACP::Types::WriteTextFileResponse, request)
53
+ end
54
+
55
+ # @rbs request: ACP::Types::CreateTerminalRequest
56
+ # @rbs return: ACP::Types::CreateTerminalResponse | ACP::RequestError
57
+ def create_terminal(request)
58
+ terminal_call('terminal/create', ACP::Types::CreateTerminalResponse, request)
59
+ end
60
+
61
+ # @rbs request: ACP::Types::TerminalOutputRequest
62
+ # @rbs return: ACP::Types::TerminalOutputResponse | ACP::RequestError
63
+ def terminal_output(request)
64
+ terminal_call('terminal/output', ACP::Types::TerminalOutputResponse, request)
65
+ end
66
+
67
+ # @rbs request: ACP::Types::WaitForTerminalExitRequest
68
+ # @rbs return: ACP::Types::WaitForTerminalExitResponse | ACP::RequestError
69
+ def wait_for_terminal_exit(request)
70
+ terminal_call('terminal/wait_for_exit', ACP::Types::WaitForTerminalExitResponse, request)
71
+ end
72
+
73
+ # @rbs request: ACP::Types::KillTerminalRequest
74
+ # @rbs return: ACP::Types::KillTerminalResponse | ACP::RequestError
75
+ def kill_terminal(request)
76
+ terminal_call('terminal/kill', ACP::Types::KillTerminalResponse, request)
77
+ end
78
+
79
+ # @rbs request: ACP::Types::ReleaseTerminalRequest
80
+ # @rbs return: ACP::Types::ReleaseTerminalResponse | ACP::RequestError
81
+ def release_terminal(request)
82
+ terminal_call('terminal/release', ACP::Types::ReleaseTerminalResponse, request)
83
+ end
84
+
85
+ private
86
+
87
+ # @rbs rpc_method: String
88
+ # @rbs type: ACP::AgentConnection::_Parser
89
+ # @rbs request: ACP::AgentConnection::_Response
90
+ # @rbs return: untyped
91
+ def call(rpc_method, type, request)
92
+ result = @peer.request(rpc_method, request.to_h)
93
+ return result if result.is_a?(ACP::RequestError)
94
+
95
+ type.from_h(result)
96
+ end
97
+
98
+ # @rbs rpc_method: String
99
+ # @rbs type: ACP::AgentConnection::_Parser
100
+ # @rbs request: ACP::AgentConnection::_Response
101
+ # @rbs return: untyped
102
+ def terminal_call(rpc_method, type, request)
103
+ return unadvertised('terminal') unless capabilities&.terminal
104
+
105
+ call(rpc_method, type, request)
106
+ end
107
+
108
+ # Same method-not-found code a peer would send, but with a message that says
109
+ # the refusal was local, so logs can tell the two cases apart.
110
+ #
111
+ # @rbs capability: String
112
+ # @rbs return: ACP::RequestError
113
+ def unadvertised(capability)
114
+ ACP::RequestError.new(
115
+ code: ACP::RequestError::METHOD_NOT_FOUND, message: "Client does not advertise #{capability}"
116
+ )
38
117
  end
39
118
  end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ACP::AgentConnection::OptionalMethod::Authenticate
4
+ extend self
5
+
6
+ # @rbs return: String
7
+ def rpc_method
8
+ 'authenticate'
9
+ end
10
+
11
+ # @rbs return: Symbol
12
+ def agent_method
13
+ :authenticate
14
+ end
15
+
16
+ # @rbs initialize_response: ACP::Types::InitializeResponse
17
+ # @rbs return: bool
18
+ def advertised?(initialize_response)
19
+ initialize_response.auth_methods&.any? || false
20
+ end
21
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ACP::AgentConnection::OptionalMethod::CloseSession
4
+ extend self
5
+
6
+ # @rbs return: String
7
+ def rpc_method
8
+ 'session/close'
9
+ end
10
+
11
+ # @rbs return: Symbol
12
+ def agent_method
13
+ :close_session
14
+ end
15
+
16
+ # @rbs initialize_response: ACP::Types::InitializeResponse
17
+ # @rbs return: bool
18
+ def advertised?(initialize_response)
19
+ initialize_response.agent_capabilities&.session_capabilities&.close ? true : false
20
+ end
21
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ACP::AgentConnection::OptionalMethod::DeleteSession
4
+ extend self
5
+
6
+ # @rbs return: String
7
+ def rpc_method
8
+ 'session/delete'
9
+ end
10
+
11
+ # @rbs return: Symbol
12
+ def agent_method
13
+ :delete_session
14
+ end
15
+
16
+ # @rbs initialize_response: ACP::Types::InitializeResponse
17
+ # @rbs return: bool
18
+ def advertised?(initialize_response)
19
+ initialize_response.agent_capabilities&.session_capabilities&.delete ? true : false
20
+ end
21
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ACP::AgentConnection::OptionalMethod::ListSessions
4
+ extend self
5
+
6
+ # @rbs return: String
7
+ def rpc_method
8
+ 'session/list'
9
+ end
10
+
11
+ # @rbs return: Symbol
12
+ def agent_method
13
+ :list_sessions
14
+ end
15
+
16
+ # @rbs initialize_response: ACP::Types::InitializeResponse
17
+ # @rbs return: bool
18
+ def advertised?(initialize_response)
19
+ initialize_response.agent_capabilities&.session_capabilities&.list ? true : false
20
+ end
21
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ACP::AgentConnection::OptionalMethod::LoadSession
4
+ extend self
5
+
6
+ # @rbs return: String
7
+ def rpc_method
8
+ 'session/load'
9
+ end
10
+
11
+ # @rbs return: Symbol
12
+ def agent_method
13
+ :load_session
14
+ end
15
+
16
+ # @rbs initialize_response: ACP::Types::InitializeResponse
17
+ # @rbs return: bool
18
+ def advertised?(initialize_response)
19
+ initialize_response.agent_capabilities&.load_session ? true : false
20
+ end
21
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ACP::AgentConnection::OptionalMethod::ResumeSession
4
+ extend self
5
+
6
+ # @rbs return: String
7
+ def rpc_method
8
+ 'session/resume'
9
+ end
10
+
11
+ # @rbs return: Symbol
12
+ def agent_method
13
+ :resume_session
14
+ end
15
+
16
+ # @rbs initialize_response: ACP::Types::InitializeResponse
17
+ # @rbs return: bool
18
+ def advertised?(initialize_response)
19
+ initialize_response.agent_capabilities&.session_capabilities&.resume ? true : false
20
+ end
21
+ end
@@ -1,28 +1,4 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- class ACP::AgentConnection::OptionalMethod
4
- # @rbs @capability: ^(ACP::Types::AgentCapabilities) -> boolish
5
-
6
- # @dynamic rpc_method
7
- attr_reader :rpc_method #: String
8
-
9
- # @dynamic agent_method
10
- attr_reader :agent_method #: Symbol
11
-
12
- # @rbs rpc_method: String
13
- # @rbs agent_method: Symbol
14
- # @rbs &capability: (ACP::Types::AgentCapabilities) -> boolish
15
- # @rbs return: void
16
- def initialize(rpc_method:, agent_method:, &capability)
17
- @rpc_method = rpc_method
18
- @agent_method = agent_method
19
- @capability = capability
20
- freeze
21
- end
22
-
23
- # @rbs capabilities: ACP::Types::AgentCapabilities
24
- # @rbs return: bool
25
- def advertised?(capabilities)
26
- @capability.call(capabilities) ? true : false
27
- end
3
+ module ACP::AgentConnection::OptionalMethod
28
4
  end
@@ -2,19 +2,25 @@
2
2
 
3
3
  class ACP::AgentConnection
4
4
  # @rbs @transport: ACP::AgentConnection::_Transport
5
- # @rbs @capabilities: ACP::Types::AgentCapabilities
6
- # @rbs @agent_info: ACP::Types::Implementation?
7
- # @rbs @auth_methods: Array[ACP::Types::AuthMethod::t]
5
+ # @rbs @initialize_response: ACP::Types::InitializeResponse
8
6
  # @rbs @factory: ^(ACP::AgentConnection::Client) -> ACP::AgentConnection::_Agent
9
7
 
10
8
  PROTOCOL_VERSION = 1 #: Integer
11
- INVALID_PARAMS = ACP::Transport::ResponseError.new(code: -32_602, message: 'Invalid params') #: ACP::Transport::ResponseError
12
9
  OPTIONAL = [
13
- ACP::AgentConnection::OptionalMethod.new(rpc_method: 'session/load', agent_method: :load_session, &:load_session),
14
- ACP::AgentConnection::OptionalMethod.new(rpc_method: 'session/list', agent_method: :list_sessions) do |capabilities|
15
- capabilities.session_capabilities&.list
16
- end
17
- ].freeze #: Array[ACP::AgentConnection::OptionalMethod]
10
+ ACP::AgentConnection::OptionalMethod::LoadSession,
11
+ ACP::AgentConnection::OptionalMethod::ListSessions,
12
+ ACP::AgentConnection::OptionalMethod::Authenticate,
13
+ ACP::AgentConnection::OptionalMethod::ResumeSession,
14
+ ACP::AgentConnection::OptionalMethod::CloseSession,
15
+ ACP::AgentConnection::OptionalMethod::DeleteSession
16
+ ].freeze #: Array[ACP::AgentConnection::_OptionalMethod]
17
+ # The schema has no initialize capability for modes or config options: an
18
+ # agent offers them per session, in its session responses, so they are
19
+ # routed whenever the agent defines them.
20
+ PER_SESSION = {
21
+ 'session/set_mode' => :change_session_mode,
22
+ 'session/set_config_option' => :change_session_config_option
23
+ }.freeze #: Hash[String, Symbol]
18
24
 
19
25
  # @rbs transport: ACP::AgentConnection::_Transport
20
26
  # @rbs capabilities: ACP::Types::AgentCapabilities
@@ -24,9 +30,12 @@ class ACP::AgentConnection
24
30
  # @rbs return: void
25
31
  def initialize(transport:, capabilities:, agent_info: nil, auth_methods: [], &factory)
26
32
  @transport = transport
27
- @capabilities = capabilities
28
- @agent_info = agent_info
29
- @auth_methods = auth_methods
33
+ @initialize_response = ACP::Types::InitializeResponse.new(
34
+ protocol_version: PROTOCOL_VERSION,
35
+ agent_capabilities: capabilities,
36
+ auth_methods:,
37
+ agent_info:
38
+ )
30
39
  @factory = factory
31
40
  end
32
41
 
@@ -34,11 +43,11 @@ class ACP::AgentConnection
34
43
  def start
35
44
  client = ACP::AgentConnection::Client.new(peer: @transport)
36
45
  agent = @factory.call(client)
37
- advertised, unadvertised = OPTIONAL.partition { |method| method.advertised?(@capabilities) }
46
+ advertised, unadvertised = OPTIONAL.partition { |method| method.advertised?(@initialize_response) }
38
47
  missing = advertised.map(&:agent_method).reject { |name| agent.respond_to?(name) }
39
- raise ArgumentError, "capabilities advertise methods the agent lacks: #{missing.join(', ')}" unless missing.empty?
48
+ raise ArgumentError, "initialize advertises methods the agent lacks: #{missing.join(', ')}" unless missing.empty?
40
49
 
41
- unrouted = unadvertised.map(&:rpc_method)
50
+ unrouted = unadvertised.map(&:rpc_method) + PER_SESSION.reject { |_, name| agent.respond_to?(name) }.keys
42
51
  @transport.start(requests: requests(agent, client).except(*unrouted), notifications: notifications(agent))
43
52
  end
44
53
 
@@ -46,17 +55,25 @@ class ACP::AgentConnection
46
55
 
47
56
  # @rbs agent: ACP::AgentConnection::_Agent
48
57
  # @rbs client: ACP::AgentConnection::Client
49
- # @rbs return: Hash[String, ^(untyped) -> (ACP::Transport::Result | ACP::Transport::Reply)]
58
+ # @rbs return: Hash[String, ^(untyped) -> (ACP::AgentConnection::_Response | ACP::RequestError | ACP::Transport::Reply)]
50
59
  def requests(agent, client)
51
- # Safe: start drops the load/list handlers the capabilities do not
52
- # advertise and checks the agent defines the rest.
53
- full = agent #: ACP::AgentConnection::_Agent & ACP::AgentConnection::_LoadSession & ACP::AgentConnection::_ListSessions
60
+ # Safe: start drops the optional handlers initialize does not advertise
61
+ # or the agent does not define, and checks the agent defines the rest.
62
+ full = agent #: ACP::AgentConnection::_FullAgent
54
63
  {
55
64
  'initialize' => route(ACP::Types::InitializeRequest) { |request| connect(client, request) },
65
+ 'authenticate' => route(ACP::Types::AuthenticateRequest) { |request| full.authenticate(request) },
56
66
  'session/new' => route(ACP::Types::NewSessionRequest) { |request| new_session(agent, request) },
57
- 'session/prompt' => route(ACP::Types::PromptRequest) { |request| respond(agent.prompt(request)) },
58
- 'session/load' => route(ACP::Types::LoadSessionRequest) { |request| respond(full.load_session(request)) },
59
- 'session/list' => route(ACP::Types::ListSessionsRequest) { |request| respond(full.list_sessions(request)) }
67
+ 'session/prompt' => route(ACP::Types::PromptRequest) { |request| agent.prompt(request) },
68
+ 'session/load' => route(ACP::Types::LoadSessionRequest) { |request| full.load_session(request) },
69
+ 'session/list' => route(ACP::Types::ListSessionsRequest) { |request| full.list_sessions(request) },
70
+ 'session/resume' => route(ACP::Types::ResumeSessionRequest) { |request| full.resume_session(request) },
71
+ 'session/close' => route(ACP::Types::CloseSessionRequest) { |request| full.close_session(request) },
72
+ 'session/delete' => route(ACP::Types::DeleteSessionRequest) { |request| full.delete_session(request) },
73
+ 'session/set_mode' => route(ACP::Types::SetSessionModeRequest) { |request| full.change_session_mode(request) },
74
+ 'session/set_config_option' => route(ACP::Types::SetSessionConfigOptionRequest) do |request|
75
+ full.change_session_config_option(request)
76
+ end
60
77
  }
61
78
  end
62
79
 
@@ -69,40 +86,24 @@ class ACP::AgentConnection
69
86
  # Generated from_h raises on a missing key or a value of the wrong shape.
70
87
  #
71
88
  # @rbs type: ACP::AgentConnection::_Parser
72
- # @rbs &handle: (untyped) -> (ACP::Transport::Result | ACP::Transport::Reply)
73
- # @rbs return: ^(untyped) -> (ACP::Transport::Result | ACP::Transport::Reply)
89
+ # @rbs &handle: (untyped) -> (ACP::AgentConnection::_Response | ACP::RequestError | ACP::Transport::Reply)
90
+ # @rbs return: ^(untyped) -> (ACP::AgentConnection::_Response | ACP::RequestError | ACP::Transport::Reply)
74
91
  def route(type, &)
75
92
  lambda do |params|
76
93
  request = type.from_h(params)
77
94
  rescue KeyError, TypeError, NoMethodError
78
- ACP::Transport::Result.error(INVALID_PARAMS)
95
+ ACP::RequestError.invalid_params
79
96
  else
80
97
  yield(request)
81
98
  end
82
99
  end
83
100
 
84
- # @rbs response: ACP::AgentConnection::_Response | ACP::Transport::ResponseError
85
- # @rbs return: ACP::Transport::Result
86
- def respond(response)
87
- case response
88
- when ACP::Transport::ResponseError then ACP::Transport::Result.error(response)
89
- else ACP::Transport::Result.ok(response.to_h)
90
- end
91
- end
92
-
93
101
  # @rbs client: ACP::AgentConnection::Client
94
102
  # @rbs request: ACP::Types::InitializeRequest
95
- # @rbs return: ACP::Transport::Result
103
+ # @rbs return: ACP::Types::InitializeResponse
96
104
  def connect(client, request)
97
105
  client.capabilities = request.client_capabilities || ACP::Types::ClientCapabilities.new
98
- respond(
99
- ACP::Types::InitializeResponse.new(
100
- protocol_version: PROTOCOL_VERSION,
101
- agent_capabilities: @capabilities,
102
- auth_methods: @auth_methods,
103
- agent_info: @agent_info
104
- )
105
- )
106
+ @initialize_response
106
107
  end
107
108
 
108
109
  # session_created runs after the reply because the client must know the
@@ -110,13 +111,12 @@ class ACP::AgentConnection
110
111
  #
111
112
  # @rbs agent: ACP::AgentConnection::_Agent
112
113
  # @rbs request: ACP::Types::NewSessionRequest
113
- # @rbs return: ACP::Transport::Result | ACP::Transport::Reply
114
+ # @rbs return: (ACP::Types::NewSessionResponse | ACP::RequestError | ACP::Transport::Reply)
114
115
  def new_session(agent, request)
115
116
  response = agent.new_session(request)
116
- result = respond(response)
117
- return result unless response.is_a?(ACP::Types::NewSessionResponse) && agent.respond_to?(:session_created)
117
+ return response unless response.is_a?(ACP::Types::NewSessionResponse) && agent.respond_to?(:session_created)
118
118
 
119
119
  hook = agent #: ACP::AgentConnection::_Agent & ACP::AgentConnection::_SessionCreated
120
- ACP::Transport::Reply.new(result, after: -> { hook.session_created(response) })
120
+ ACP::Transport::Reply.new(response, after: -> { hook.session_created(response) })
121
121
  end
122
122
  end