acp-sdk 0.2.0 → 0.4.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 (38) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +21 -0
  3. data/README.md +29 -16
  4. data/lib/acp/agent_connection/client.rb +27 -12
  5. data/lib/acp/agent_connection/optional_method/logout.rb +21 -0
  6. data/lib/acp/agent_connection.rb +21 -32
  7. data/lib/acp/agent_process.rb +119 -0
  8. data/lib/acp/client_connection.rb +15 -15
  9. data/lib/acp/request_error.rb +90 -0
  10. data/lib/acp/transport/reply.rb +4 -4
  11. data/lib/acp/transport/stdio.rb +28 -25
  12. data/lib/acp/types/session_update/tool_call.rb +8 -0
  13. data/lib/acp/types/session_update/tool_call_update.rb +8 -0
  14. data/lib/acp/types/tool_call.rb +8 -0
  15. data/lib/acp/types/tool_call_update.rb +8 -0
  16. data/lib/acp/version.rb +1 -1
  17. data/sig/generated/acp/agent_connection/client.rbs +23 -16
  18. data/sig/generated/acp/agent_connection/optional_method/logout.rbs +13 -0
  19. data/sig/generated/acp/agent_connection.rbs +9 -15
  20. data/sig/generated/acp/agent_process.rbs +72 -0
  21. data/sig/generated/acp/client_connection.rbs +16 -16
  22. data/sig/generated/acp/request_error.rbs +72 -0
  23. data/sig/generated/acp/transport/reply.rbs +5 -5
  24. data/sig/generated/acp/transport/stdio.rbs +14 -19
  25. data/sig/generated/acp/types/session_update/tool_call.rbs +5 -1
  26. data/sig/generated/acp/types/session_update/tool_call_update.rbs +5 -1
  27. data/sig/generated/acp/types/tool_call.rbs +5 -1
  28. data/sig/generated/acp/types/tool_call_update.rbs +5 -1
  29. data/sig/manual/acp/agent_connection/optional_method/logout.rbs +6 -0
  30. data/sig/manual/acp/agent_connection.rbs +24 -19
  31. data/sig/manual/acp/agent_process.rbs +7 -0
  32. data/sig/manual/acp/client_connection.rbs +1 -1
  33. data/sig/manual/acp/transport/stdio.rbs +7 -1
  34. metadata +9 -5
  35. data/lib/acp/transport/response_error.rb +0 -28
  36. data/lib/acp/transport/result.rb +0 -35
  37. data/sig/generated/acp/transport/response_error.rbs +0 -21
  38. data/sig/generated/acp/transport/result.rbs +0 -25
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4fcba010891683b66eaf2fd2ae8bf9ae27e2aeb13b76666cc8c4478dfb7c4e9a
4
- data.tar.gz: 5045a772ca2f080a7566ad3556a16087b3d15f4cbd39c3ed0ee5ae4dfe0ab521
3
+ metadata.gz: f0121ff621c45deb5578a15051611ea98d8d174193528c3df214fe4e036561ff
4
+ data.tar.gz: e26701095772e5bbf41a18a8e2bc9f4ac9609657aae1dee7c82891de6cf5b48c
5
5
  SHA512:
6
- metadata.gz: c1a7eb1a2fc03be127272d8e3a97564c014691d1b582bd317f1da7b7022f250d603a09a811ef1271a9133e2bcf2fa5a6c7cde1f7d7ffe8c08a1f37fa28080d71
7
- data.tar.gz: 4d006a52359dfdde15978220b83b9d7b10251b44c73a66c43be7a4259c0646a559b39ee05b09b1010e6b0108a95945751ad2cf0ffea4c6f9f1be041a0d4efbfa
6
+ metadata.gz: 66f390b3cf58c661064fe12ca5c7bbf04f560a81187f8cd37cce6d820870eb9f83717b655d04305a9a926e3cc08af618c1956acb7c2425899d2d3238e05d2651
7
+ data.tar.gz: 13853bc47c5c15a249296d9418fb951f52d512fea28f92fe9bf601c1a3e43d98840cbbbbfc181ff53150572db0393388553bf27f3c1746ca135f0467905d4270
data/CHANGELOG.md CHANGED
@@ -1,5 +1,26 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.4.0](https://github.com/bottrall/acp-sdk/compare/v0.3.0...v0.4.0) (2026-10-02)
4
+
5
+
6
+ ### Features
7
+
8
+ * bump the vendored schema to v1.24.1 ([#58](https://github.com/bottrall/acp-sdk/issues/58)) ([56d8505](https://github.com/bottrall/acp-sdk/commit/56d85057a1c34f1a089580a78b7b66973c92a9e8))
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * return a RequestError for malformed peer responses ([#57](https://github.com/bottrall/acp-sdk/issues/57)) ([e52d479](https://github.com/bottrall/acp-sdk/commit/e52d479ce8cd6de37f753e7580d5562f1ace2d7d)), closes [#27](https://github.com/bottrall/acp-sdk/issues/27)
14
+ * route logout on ACP::AgentConnection ([#55](https://github.com/bottrall/acp-sdk/issues/55)) ([3b2b144](https://github.com/bottrall/acp-sdk/commit/3b2b1444c502380a058231884c66efe26bb3770a)), closes [#26](https://github.com/bottrall/acp-sdk/issues/26)
15
+
16
+ ## [0.3.0](https://github.com/bottrall/acp-sdk/compare/v0.2.0...v0.3.0) (2026-10-02)
17
+
18
+
19
+ ### Features
20
+
21
+ * named protocol error codes ([#52](https://github.com/bottrall/acp-sdk/issues/52)) ([0a81700](https://github.com/bottrall/acp-sdk/commit/0a81700e3fa095279a77e2f161a7d6369c46792f))
22
+ * 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))
23
+
3
24
  ## [0.2.0](https://github.com/bottrall/acp-sdk/compare/v0.1.0...v0.2.0) (2026-09-30)
4
25
 
5
26
 
data/README.md CHANGED
@@ -48,41 +48,54 @@ connection.start.join
48
48
 
49
49
  The agent's contract:
50
50
 
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::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, and `logout` only when `capabilities` advertises `auth.logout`; `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.
53
53
  - `cancel` runs on the transport's reader thread, so it must return quickly: set a flag and let the prompt notice it.
54
54
  - After a cancel, the agent must itself end the turn with `stopReason: cancelled`. `ACP::AgentConnection` does not enforce it.
55
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::Transport::ResponseError`. 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::Transport::ResponseError`. Unless `client.capabilities` advertises `terminal`, they return `-32601` without sending anything to the client. The agent must release every terminal it creates.
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
58
 
59
59
  [`examples/echo_agent.rb`](https://github.com/bottrall/acp-sdk/blob/main/examples/echo_agent.rb) is a complete agent that streams updates, asks permission, handles cancellation, reads and writes files and runs commands through the client (`/read <path>`, `/write <path> <text>` and `/run <command> [args]`, each advertised as a slash command only when the client supports it), and supports `session/load` and `session/list`.
60
60
 
61
61
  ## Driving an agent
62
62
 
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::Transport::ResponseError`. `connect` sends `initialize`, since Ruby reserves that name 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.
64
66
 
65
67
  ```ruby
66
68
  require 'acp/sdk'
67
- require 'open3'
68
69
 
69
- stdin, stdout, = Open3.popen2('my-agent')
70
+ response = ACP::AgentProcess.spawn(
71
+ 'my-agent', '--flag',
72
+ env: { 'API_KEY' => '...' },
73
+ cwd: Dir.pwd,
74
+ stderr: :capture,
75
+ permission: ->(request) { ask_the_user(request) },
76
+ updates: ->(notification) { show(notification) }
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
84
+ end
85
+ ```
86
+
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:
88
+
89
+ ```ruby
70
90
  connection = ACP::ClientConnection.new(
71
- transport: ACP::Transport::Stdio.new(input: stdout, output: stdin),
91
+ transport: ACP::Transport::Stdio.new(input: some_io, output: other_io),
72
92
  permission: ->(request) { ask_the_user(request) },
73
93
  updates: ->(notification) { show(notification) }
74
94
  )
75
- connection.start
76
- connection.connect(ACP::Types::InitializeRequest.new(protocol_version: 1))
77
- session = connection.session_new(ACP::Types::NewSessionRequest.new(cwd: Dir.pwd, mcp_servers: []))
78
- prompt = [ACP::Types::ContentBlock::Text.new(text: 'hello')]
79
- response = connection.session_prompt(ACP::Types::PromptRequest.new(session_id: session.session_id, prompt:)) do |update|
80
- print update.content.text if update.is_a?(ACP::Types::SessionUpdate::AgentMessageChunk)
81
- end
82
95
  ```
83
96
 
84
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.
85
- - `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.
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.
86
99
 
87
100
  ## Contributing
88
101
 
@@ -31,53 +31,53 @@ 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
36
  call('session/request_permission', ACP::Types::RequestPermissionResponse, request)
37
37
  end
38
38
 
39
39
  # @rbs request: ACP::Types::ReadTextFileRequest
40
- # @rbs return: ACP::Types::ReadTextFileResponse | ACP::Transport::ResponseError
40
+ # @rbs return: ACP::Types::ReadTextFileResponse | ACP::RequestError
41
41
  def read_text_file(request)
42
- return ACP::Transport::Stdio::METHOD_NOT_FOUND unless capabilities&.fs&.read_text_file
42
+ return unadvertised('fs.readTextFile') unless capabilities&.fs&.read_text_file
43
43
 
44
44
  call('fs/read_text_file', ACP::Types::ReadTextFileResponse, request)
45
45
  end
46
46
 
47
47
  # @rbs request: ACP::Types::WriteTextFileRequest
48
- # @rbs return: ACP::Types::WriteTextFileResponse | ACP::Transport::ResponseError
48
+ # @rbs return: ACP::Types::WriteTextFileResponse | ACP::RequestError
49
49
  def write_text_file(request)
50
- return ACP::Transport::Stdio::METHOD_NOT_FOUND unless capabilities&.fs&.write_text_file
50
+ return unadvertised('fs.writeTextFile') unless capabilities&.fs&.write_text_file
51
51
 
52
52
  call('fs/write_text_file', ACP::Types::WriteTextFileResponse, request)
53
53
  end
54
54
 
55
55
  # @rbs request: ACP::Types::CreateTerminalRequest
56
- # @rbs return: ACP::Types::CreateTerminalResponse | ACP::Transport::ResponseError
56
+ # @rbs return: ACP::Types::CreateTerminalResponse | ACP::RequestError
57
57
  def create_terminal(request)
58
58
  terminal_call('terminal/create', ACP::Types::CreateTerminalResponse, request)
59
59
  end
60
60
 
61
61
  # @rbs request: ACP::Types::TerminalOutputRequest
62
- # @rbs return: ACP::Types::TerminalOutputResponse | ACP::Transport::ResponseError
62
+ # @rbs return: ACP::Types::TerminalOutputResponse | ACP::RequestError
63
63
  def terminal_output(request)
64
64
  terminal_call('terminal/output', ACP::Types::TerminalOutputResponse, request)
65
65
  end
66
66
 
67
67
  # @rbs request: ACP::Types::WaitForTerminalExitRequest
68
- # @rbs return: ACP::Types::WaitForTerminalExitResponse | ACP::Transport::ResponseError
68
+ # @rbs return: ACP::Types::WaitForTerminalExitResponse | ACP::RequestError
69
69
  def wait_for_terminal_exit(request)
70
70
  terminal_call('terminal/wait_for_exit', ACP::Types::WaitForTerminalExitResponse, request)
71
71
  end
72
72
 
73
73
  # @rbs request: ACP::Types::KillTerminalRequest
74
- # @rbs return: ACP::Types::KillTerminalResponse | ACP::Transport::ResponseError
74
+ # @rbs return: ACP::Types::KillTerminalResponse | ACP::RequestError
75
75
  def kill_terminal(request)
76
76
  terminal_call('terminal/kill', ACP::Types::KillTerminalResponse, request)
77
77
  end
78
78
 
79
79
  # @rbs request: ACP::Types::ReleaseTerminalRequest
80
- # @rbs return: ACP::Types::ReleaseTerminalResponse | ACP::Transport::ResponseError
80
+ # @rbs return: ACP::Types::ReleaseTerminalResponse | ACP::RequestError
81
81
  def release_terminal(request)
82
82
  terminal_call('terminal/release', ACP::Types::ReleaseTerminalResponse, request)
83
83
  end
@@ -90,7 +90,11 @@ class ACP::AgentConnection::Client
90
90
  # @rbs return: untyped
91
91
  def call(rpc_method, type, request)
92
92
  result = @peer.request(rpc_method, request.to_h)
93
- result.error || type.from_h(result.value)
93
+ return result if result.is_a?(ACP::RequestError)
94
+
95
+ type.from_h(result)
96
+ rescue KeyError, TypeError, NoMethodError
97
+ ACP::RequestError.invalid_response(result)
94
98
  end
95
99
 
96
100
  # @rbs rpc_method: String
@@ -98,8 +102,19 @@ class ACP::AgentConnection::Client
98
102
  # @rbs request: ACP::AgentConnection::_Response
99
103
  # @rbs return: untyped
100
104
  def terminal_call(rpc_method, type, request)
101
- return ACP::Transport::Stdio::METHOD_NOT_FOUND unless capabilities&.terminal
105
+ return unadvertised('terminal') unless capabilities&.terminal
102
106
 
103
107
  call(rpc_method, type, request)
104
108
  end
109
+
110
+ # Same method-not-found code a peer would send, but with a message that says
111
+ # the refusal was local, so logs can tell the two cases apart.
112
+ #
113
+ # @rbs capability: String
114
+ # @rbs return: ACP::RequestError
115
+ def unadvertised(capability)
116
+ ACP::RequestError.new(
117
+ code: ACP::RequestError::METHOD_NOT_FOUND, message: "Client does not advertise #{capability}"
118
+ )
119
+ end
105
120
  end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ACP::AgentConnection::OptionalMethod::Logout
4
+ extend self
5
+
6
+ # @rbs return: String
7
+ def rpc_method
8
+ 'logout'
9
+ end
10
+
11
+ # @rbs return: Symbol
12
+ def agent_method
13
+ :logout
14
+ end
15
+
16
+ # @rbs initialize_response: ACP::Types::InitializeResponse
17
+ # @rbs return: bool
18
+ def advertised?(initialize_response)
19
+ initialize_response.agent_capabilities&.auth&.logout ? true : false
20
+ end
21
+ end
@@ -6,14 +6,14 @@ class ACP::AgentConnection
6
6
  # @rbs @factory: ^(ACP::AgentConnection::Client) -> ACP::AgentConnection::_Agent
7
7
 
8
8
  PROTOCOL_VERSION = 1 #: Integer
9
- INVALID_PARAMS = ACP::Transport::ResponseError.new(code: -32_602, message: 'Invalid params') #: ACP::Transport::ResponseError
10
9
  OPTIONAL = [
11
10
  ACP::AgentConnection::OptionalMethod::LoadSession,
12
11
  ACP::AgentConnection::OptionalMethod::ListSessions,
13
12
  ACP::AgentConnection::OptionalMethod::Authenticate,
14
13
  ACP::AgentConnection::OptionalMethod::ResumeSession,
15
14
  ACP::AgentConnection::OptionalMethod::CloseSession,
16
- ACP::AgentConnection::OptionalMethod::DeleteSession
15
+ ACP::AgentConnection::OptionalMethod::DeleteSession,
16
+ ACP::AgentConnection::OptionalMethod::Logout
17
17
  ].freeze #: Array[ACP::AgentConnection::_OptionalMethod]
18
18
  # The schema has no initialize capability for modes or config options: an
19
19
  # agent offers them per session, in its session responses, so they are
@@ -56,26 +56,25 @@ class ACP::AgentConnection
56
56
 
57
57
  # @rbs agent: ACP::AgentConnection::_Agent
58
58
  # @rbs client: ACP::AgentConnection::Client
59
- # @rbs return: Hash[String, ^(untyped) -> (ACP::Transport::Result | ACP::Transport::Reply)]
59
+ # @rbs return: Hash[String, ^(untyped) -> (ACP::AgentConnection::_Response | ACP::RequestError | ACP::Transport::Reply)]
60
60
  def requests(agent, client)
61
61
  # Safe: start drops the optional handlers initialize does not advertise
62
62
  # or the agent does not define, and checks the agent defines the rest.
63
63
  full = agent #: ACP::AgentConnection::_FullAgent
64
64
  {
65
65
  'initialize' => route(ACP::Types::InitializeRequest) { |request| connect(client, request) },
66
- 'authenticate' => route(ACP::Types::AuthenticateRequest) { |request| respond(full.authenticate(request)) },
66
+ 'authenticate' => route(ACP::Types::AuthenticateRequest) { |request| full.authenticate(request) },
67
67
  'session/new' => route(ACP::Types::NewSessionRequest) { |request| new_session(agent, request) },
68
- 'session/prompt' => route(ACP::Types::PromptRequest) { |request| respond(agent.prompt(request)) },
69
- 'session/load' => route(ACP::Types::LoadSessionRequest) { |request| respond(full.load_session(request)) },
70
- 'session/list' => route(ACP::Types::ListSessionsRequest) { |request| respond(full.list_sessions(request)) },
71
- 'session/resume' => route(ACP::Types::ResumeSessionRequest) { |request| respond(full.resume_session(request)) },
72
- 'session/close' => route(ACP::Types::CloseSessionRequest) { |request| respond(full.close_session(request)) },
73
- 'session/delete' => route(ACP::Types::DeleteSessionRequest) { |request| respond(full.delete_session(request)) },
74
- 'session/set_mode' => route(ACP::Types::SetSessionModeRequest) do |request|
75
- respond(full.change_session_mode(request))
76
- end,
68
+ 'session/prompt' => route(ACP::Types::PromptRequest) { |request| agent.prompt(request) },
69
+ 'session/load' => route(ACP::Types::LoadSessionRequest) { |request| full.load_session(request) },
70
+ 'session/list' => route(ACP::Types::ListSessionsRequest) { |request| full.list_sessions(request) },
71
+ 'session/resume' => route(ACP::Types::ResumeSessionRequest) { |request| full.resume_session(request) },
72
+ 'session/close' => route(ACP::Types::CloseSessionRequest) { |request| full.close_session(request) },
73
+ 'session/delete' => route(ACP::Types::DeleteSessionRequest) { |request| full.delete_session(request) },
74
+ 'logout' => route(ACP::Types::LogoutRequest) { |request| full.logout(request) },
75
+ 'session/set_mode' => route(ACP::Types::SetSessionModeRequest) { |request| full.change_session_mode(request) },
77
76
  'session/set_config_option' => route(ACP::Types::SetSessionConfigOptionRequest) do |request|
78
- respond(full.change_session_config_option(request))
77
+ full.change_session_config_option(request)
79
78
  end
80
79
  }
81
80
  end
@@ -89,33 +88,24 @@ class ACP::AgentConnection
89
88
  # Generated from_h raises on a missing key or a value of the wrong shape.
90
89
  #
91
90
  # @rbs type: ACP::AgentConnection::_Parser
92
- # @rbs &handle: (untyped) -> (ACP::Transport::Result | ACP::Transport::Reply)
93
- # @rbs return: ^(untyped) -> (ACP::Transport::Result | ACP::Transport::Reply)
91
+ # @rbs &handle: (untyped) -> (ACP::AgentConnection::_Response | ACP::RequestError | ACP::Transport::Reply)
92
+ # @rbs return: ^(untyped) -> (ACP::AgentConnection::_Response | ACP::RequestError | ACP::Transport::Reply)
94
93
  def route(type, &)
95
94
  lambda do |params|
96
95
  request = type.from_h(params)
97
96
  rescue KeyError, TypeError, NoMethodError
98
- ACP::Transport::Result.error(INVALID_PARAMS)
97
+ ACP::RequestError.invalid_params
99
98
  else
100
99
  yield(request)
101
100
  end
102
101
  end
103
102
 
104
- # @rbs response: ACP::AgentConnection::_Response | ACP::Transport::ResponseError
105
- # @rbs return: ACP::Transport::Result
106
- def respond(response)
107
- case response
108
- when ACP::Transport::ResponseError then ACP::Transport::Result.error(response)
109
- else ACP::Transport::Result.ok(response.to_h)
110
- end
111
- end
112
-
113
103
  # @rbs client: ACP::AgentConnection::Client
114
104
  # @rbs request: ACP::Types::InitializeRequest
115
- # @rbs return: ACP::Transport::Result
105
+ # @rbs return: ACP::Types::InitializeResponse
116
106
  def connect(client, request)
117
107
  client.capabilities = request.client_capabilities || ACP::Types::ClientCapabilities.new
118
- respond(@initialize_response)
108
+ @initialize_response
119
109
  end
120
110
 
121
111
  # session_created runs after the reply because the client must know the
@@ -123,13 +113,12 @@ class ACP::AgentConnection
123
113
  #
124
114
  # @rbs agent: ACP::AgentConnection::_Agent
125
115
  # @rbs request: ACP::Types::NewSessionRequest
126
- # @rbs return: ACP::Transport::Result | ACP::Transport::Reply
116
+ # @rbs return: (ACP::Types::NewSessionResponse | ACP::RequestError | ACP::Transport::Reply)
127
117
  def new_session(agent, request)
128
118
  response = agent.new_session(request)
129
- result = respond(response)
130
- return result unless response.is_a?(ACP::Types::NewSessionResponse) && agent.respond_to?(:session_created)
119
+ return response unless response.is_a?(ACP::Types::NewSessionResponse) && agent.respond_to?(:session_created)
131
120
 
132
121
  hook = agent #: ACP::AgentConnection::_Agent & ACP::AgentConnection::_SessionCreated
133
- ACP::Transport::Reply.new(result, after: -> { hook.session_created(response) })
122
+ ACP::Transport::Reply.new(response, after: -> { hook.session_created(response) })
134
123
  end
135
124
  end
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'open3'
4
+
5
+ # A child agent process and the ACP::ClientConnection wired to its stdio.
6
+ # Spawn one with ACP::AgentProcess.spawn.
7
+ class ACP::AgentProcess
8
+ # @rbs @connection: ACP::ClientConnection
9
+ # @rbs @reader: Thread
10
+ # @rbs @pid: Integer
11
+ # @rbs @stdin: IO
12
+ # @rbs @stdout: IO
13
+ # @rbs @waiter: Process::Waiter
14
+ # @rbs @err_r: IO?
15
+ # @rbs @err_reader: Thread?
16
+ # @rbs @stderr: String?
17
+
18
+ # @dynamic pid
19
+ attr_reader :pid #: Integer
20
+
21
+ # @dynamic connection
22
+ attr_reader :connection #: ACP::ClientConnection
23
+
24
+ # Child stderr captured when spawn was given stderr: :capture, else nil.
25
+ #
26
+ # @dynamic stderr
27
+ attr_reader :stderr #: String?
28
+
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
+ # @rbs command: String
35
+ # @rbs *args: String
36
+ # @rbs env: Hash[String, String]?
37
+ # @rbs cwd: String?
38
+ # @rbs stderr: (:inherit | :capture)
39
+ # @rbs permission: ACP::ClientConnection::_PermissionHandler
40
+ # @rbs updates: ACP::ClientConnection::_UpdateHandler
41
+ # @rbs &block: ? (ACP::ClientConnection, ACP::AgentProcess) -> untyped
42
+ # @rbs return: (untyped | ACP::AgentProcess)
43
+ def self.spawn(
44
+ command,
45
+ *args,
46
+ permission:,
47
+ env: nil,
48
+ cwd: nil,
49
+ stderr: :inherit,
50
+ updates: ACP::ClientConnection::IGNORE,
51
+ &block
52
+ )
53
+ err_r, err_w = stderr == :capture ? IO.pipe : nil
54
+ opts = {} #: Hash[Symbol, untyped]
55
+ opts[:chdir] = cwd if cwd
56
+ opts[:err] = err_w if err_w
57
+ spawner = Open3.method(:popen2) #: _Spawner
58
+ stdin, stdout, waiter = spawner.call(*(env ? [env, command, *args] : [command, *args]), **opts)
59
+ err_w&.close
60
+ connection = ACP::ClientConnection.new(
61
+ transport: ACP::Transport::Stdio.new(input: stdout, output: stdin),
62
+ permission:,
63
+ updates:
64
+ )
65
+ process = new(connection:, reader: connection.start, stdin:, stdout:, waiter:, err_r:)
66
+ return process unless block
67
+
68
+ begin
69
+ yield connection, process
70
+ ensure
71
+ process.terminate
72
+ end
73
+ end
74
+
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
+ # @rbs return: void
80
+ def terminate
81
+ @stdin.close unless @stdin.closed?
82
+ @waiter.join(1) or signal('TERM')
83
+ @waiter.join(2) or signal('KILL')
84
+ @waiter.join(2)
85
+ @reader.join(1)
86
+ @stdout.close unless @stdout.closed?
87
+ @err_reader&.join(1)
88
+ @err_r&.close unless @err_r&.closed?
89
+ end
90
+
91
+ private
92
+
93
+ # @rbs connection: ACP::ClientConnection
94
+ # @rbs reader: Thread
95
+ # @rbs stdin: IO
96
+ # @rbs stdout: IO
97
+ # @rbs waiter: Process::Waiter
98
+ # @rbs err_r: IO?
99
+ # @rbs return: void
100
+ def initialize(connection:, reader:, stdin:, stdout:, waiter:, err_r:)
101
+ @connection = connection
102
+ @reader = reader
103
+ @pid = waiter.pid
104
+ @stdin = stdin
105
+ @stdout = stdout
106
+ @waiter = waiter
107
+ @err_r = err_r
108
+ @stderr = err_r ? +'' : nil
109
+ @err_reader = Thread.new { err_r.each_line { |line| @stderr&.concat(line) } } if err_r
110
+ end
111
+
112
+ # @rbs sig: String
113
+ # @rbs return: void
114
+ def signal(sig)
115
+ Process.kill(sig, @pid)
116
+ rescue Errno::ESRCH
117
+ nil
118
+ end
119
+ end
@@ -33,33 +33,33 @@ class ACP::ClientConnection
33
33
  # sent by connect.
34
34
  #
35
35
  # @rbs request: ACP::Types::InitializeRequest
36
- # @rbs return: ACP::Types::InitializeResponse | ACP::Transport::ResponseError
36
+ # @rbs return: ACP::Types::InitializeResponse | ACP::RequestError
37
37
  def connect(request)
38
38
  parse(ACP::Types::InitializeResponse, @transport.request('initialize', request.to_h))
39
39
  end
40
40
 
41
41
  # @rbs request: ACP::Types::NewSessionRequest
42
- # @rbs return: ACP::Types::NewSessionResponse | ACP::Transport::ResponseError
42
+ # @rbs return: ACP::Types::NewSessionResponse | ACP::RequestError
43
43
  def session_new(request)
44
44
  parse(ACP::Types::NewSessionResponse, @transport.request('session/new', request.to_h))
45
45
  end
46
46
 
47
47
  # @rbs request: ACP::Types::PromptRequest
48
48
  # @rbs &block: (ACP::Types::SessionUpdate::t) -> void
49
- # @rbs return: ACP::Types::PromptResponse | ACP::Transport::ResponseError
49
+ # @rbs return: ACP::Types::PromptResponse | ACP::RequestError
50
50
  def session_prompt(request, &)
51
51
  parse(ACP::Types::PromptResponse, stream(request.session_id, 'session/prompt', request.to_h, &))
52
52
  end
53
53
 
54
54
  # @rbs request: ACP::Types::LoadSessionRequest
55
55
  # @rbs &block: (ACP::Types::SessionUpdate::t) -> void
56
- # @rbs return: ACP::Types::LoadSessionResponse | ACP::Transport::ResponseError
56
+ # @rbs return: ACP::Types::LoadSessionResponse | ACP::RequestError
57
57
  def session_load(request, &)
58
58
  parse(ACP::Types::LoadSessionResponse, stream(request.session_id, 'session/load', request.to_h, &))
59
59
  end
60
60
 
61
61
  # @rbs request: ACP::Types::ListSessionsRequest
62
- # @rbs return: ACP::Types::ListSessionsResponse | ACP::Transport::ResponseError
62
+ # @rbs return: ACP::Types::ListSessionsResponse | ACP::RequestError
63
63
  def session_list(request)
64
64
  parse(ACP::Types::ListSessionsResponse, @transport.request('session/list', request.to_h))
65
65
  end
@@ -73,10 +73,14 @@ class ACP::ClientConnection
73
73
  private
74
74
 
75
75
  # @rbs type: ACP::AgentConnection::_Parser
76
- # @rbs result: ACP::Transport::Result
76
+ # @rbs result: (Hash[String, untyped] | ACP::RequestError)
77
77
  # @rbs return: untyped
78
78
  def parse(type, result)
79
- result.error || type.from_h(result.value)
79
+ return result if result.is_a?(ACP::RequestError)
80
+
81
+ type.from_h(result)
82
+ rescue KeyError, TypeError, NoMethodError
83
+ ACP::RequestError.invalid_response(result)
80
84
  end
81
85
 
82
86
  # The request waits on its own thread so the caller's thread can yield each
@@ -87,7 +91,7 @@ class ACP::ClientConnection
87
91
  # @rbs method: String
88
92
  # @rbs params: Hash[String, untyped]
89
93
  # @rbs &block: (ACP::Types::SessionUpdate::t) -> void
90
- # @rbs return: ACP::Transport::Result
94
+ # @rbs return: (Hash[String, untyped] | ACP::RequestError)
91
95
  def stream(session_id, method, params)
92
96
  queue = Thread::Queue.new
93
97
  @lock.synchronize { @streams[session_id] = queue }
@@ -114,16 +118,12 @@ class ACP::ClientConnection
114
118
  end
115
119
 
116
120
  # @rbs params: untyped
117
- # @rbs return: ACP::Transport::Result
121
+ # @rbs return: (ACP::Types::RequestPermissionResponse | ACP::RequestError)
118
122
  def request_permission(params)
119
123
  request = ACP::Types::RequestPermissionRequest.from_h(params)
120
124
  rescue KeyError, TypeError, NoMethodError
121
- ACP::Transport::Result.error(ACP::AgentConnection::INVALID_PARAMS)
125
+ ACP::RequestError.invalid_params
122
126
  else
123
- response = @permission.call(request)
124
- case response
125
- when ACP::Transport::ResponseError then ACP::Transport::Result.error(response)
126
- else ACP::Transport::Result.ok(response.to_h)
127
- end
127
+ @permission.call(request)
128
128
  end
129
129
  end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The schema's JSON-RPC Error object, named after the official SDKs'
4
+ # RequestError: built by handlers and local refusals, and decoded from a
5
+ # peer's error replies by the transport.
6
+ class ACP::RequestError
7
+ PARSE_ERROR = -32_700 #: Integer
8
+ INVALID_REQUEST = -32_600 #: Integer
9
+ METHOD_NOT_FOUND = -32_601 #: Integer
10
+ INVALID_PARAMS = -32_602 #: Integer
11
+ INTERNAL_ERROR = -32_603 #: Integer
12
+ REQUEST_CANCELLED = -32_800 #: Integer
13
+ AUTH_REQUIRED = -32_000 #: Integer
14
+ RESOURCE_NOT_FOUND = -32_002 #: Integer
15
+
16
+ # @rbs code: Integer
17
+ # @rbs message: String
18
+ # @rbs data: untyped
19
+ # @rbs return: void
20
+ def initialize(code:, message:, data: nil)
21
+ @code = code
22
+ @message = message
23
+ @data = data
24
+ freeze
25
+ end
26
+
27
+ # @dynamic code
28
+ attr_reader :code #: Integer
29
+
30
+ # @dynamic message
31
+ attr_reader :message #: String
32
+
33
+ # @dynamic data
34
+ attr_reader :data #: untyped
35
+
36
+ # @rbs return: Hash[String, untyped]
37
+ def to_h
38
+ { 'code' => code, 'message' => message, 'data' => data }.compact
39
+ end
40
+
41
+ # @rbs return: ACP::RequestError
42
+ def self.parse_error
43
+ new(code: PARSE_ERROR, message: 'Parse error')
44
+ end
45
+
46
+ # @rbs return: ACP::RequestError
47
+ def self.invalid_request
48
+ new(code: INVALID_REQUEST, message: 'Invalid request')
49
+ end
50
+
51
+ # @rbs return: ACP::RequestError
52
+ def self.method_not_found
53
+ new(code: METHOD_NOT_FOUND, message: 'Method not found')
54
+ end
55
+
56
+ # @rbs return: ACP::RequestError
57
+ def self.invalid_params
58
+ new(code: INVALID_PARAMS, message: 'Invalid params')
59
+ end
60
+
61
+ # @rbs return: ACP::RequestError
62
+ def self.internal_error
63
+ new(code: INTERNAL_ERROR, message: 'Internal error')
64
+ end
65
+
66
+ # The message tells a malformed peer reply apart from an internal error a
67
+ # handler raised, though both use the -32603 code.
68
+ #
69
+ # @rbs response: untyped
70
+ # @rbs return: ACP::RequestError
71
+ def self.invalid_response(response)
72
+ new(code: INTERNAL_ERROR, message: 'Invalid response', data: response)
73
+ end
74
+
75
+ # @rbs return: ACP::RequestError
76
+ def self.request_cancelled
77
+ new(code: REQUEST_CANCELLED, message: 'Request cancelled')
78
+ end
79
+
80
+ # @rbs return: ACP::RequestError
81
+ def self.auth_required
82
+ new(code: AUTH_REQUIRED, message: 'Authentication required')
83
+ end
84
+
85
+ # @rbs uri: String?
86
+ # @rbs return: ACP::RequestError
87
+ def self.resource_not_found(uri = nil)
88
+ new(code: RESOURCE_NOT_FOUND, message: 'Resource not found', data: uri && { uri: uri })
89
+ end
90
+ end
@@ -1,15 +1,15 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # A request handler's result plus a callback the transport runs once that
4
- # result is on the wire, for messages the peer must only see after the reply.
3
+ # A request handler's outcome plus a callback the transport runs once that
4
+ # outcome is on the wire, for messages the peer must only see after the reply.
5
5
  class ACP::Transport::Reply
6
6
  # @dynamic result
7
- attr_reader :result #: ACP::Transport::Result
7
+ attr_reader :result #: ACP::Transport::Stdio::_ToH
8
8
 
9
9
  # @dynamic after
10
10
  attr_reader :after #: ^() -> void
11
11
 
12
- # @rbs result: ACP::Transport::Result
12
+ # @rbs result: ACP::Transport::Stdio::_ToH
13
13
  # @rbs after: ^() -> void
14
14
  # @rbs return: void
15
15
  def initialize(result, after:)