acp-sdk 0.3.0 → 0.5.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 +33 -0
- data/README.md +11 -4
- data/lib/acp/agent_connection/client.rb +28 -14
- data/lib/acp/agent_connection/optional_method/logout.rb +21 -0
- data/lib/acp/agent_connection.rb +66 -13
- data/lib/acp/client_connection.rb +148 -5
- data/lib/acp/request_error.rb +30 -0
- data/lib/acp/transport/stderr_logger.rb +17 -0
- data/lib/acp/transport/stdio.rb +15 -6
- data/lib/acp/types/session_update/tool_call.rb +8 -0
- data/lib/acp/types/session_update/tool_call_update.rb +8 -0
- data/lib/acp/types/tool_call.rb +8 -0
- data/lib/acp/types/tool_call_update.rb +8 -0
- data/lib/acp/version.rb +1 -1
- data/sig/generated/acp/agent_connection/client.rbs +8 -7
- data/sig/generated/acp/agent_connection/optional_method/logout.rbs +13 -0
- data/sig/generated/acp/agent_connection.rbs +30 -4
- data/sig/generated/acp/client_connection.rbs +67 -2
- data/sig/generated/acp/request_error.rbs +24 -0
- data/sig/generated/acp/transport/stderr_logger.rbs +13 -0
- data/sig/generated/acp/transport/stdio.rbs +9 -2
- data/sig/generated/acp/types/session_update/tool_call.rbs +5 -1
- data/sig/generated/acp/types/session_update/tool_call_update.rbs +5 -1
- data/sig/generated/acp/types/tool_call.rbs +5 -1
- data/sig/generated/acp/types/tool_call_update.rbs +5 -1
- data/sig/manual/acp/agent_connection/optional_method/logout.rbs +6 -0
- data/sig/manual/acp/agent_connection.rbs +7 -0
- data/sig/manual/acp/transport.rbs +8 -0
- metadata +7 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 77ac970489b956c7216d4c94a8d5cc6e7bf3fe5bfd10110cb8558c119f58f69d
|
|
4
|
+
data.tar.gz: 29ec919463eb48d06811aa8b6c736163fd7872348ce99f3f8cdc10c5670bf8c4
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: acf89beeb444ff33ac273aececd55d645792fe591f346cbdce0a099b6b6bff4eedb4025cceadd3a46ec62253c85b111c0149466960707b3feaa92623dab600a8
|
|
7
|
+
data.tar.gz: 92fba44ccd9334876fa8cb689728a7a19cc3d473bf1e7ee27fe215d72e48952e04bc9c0428609e31e883bdef6986e1ec7b4c9383d8a37271646593b33ff069bd
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,38 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.5.0](https://github.com/bottrall/acp-sdk/compare/v0.4.0...v0.5.0) (2026-10-04)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* add authenticate and logout to ACP::ClientConnection ([#63](https://github.com/bottrall/acp-sdk/issues/63)) ([460585a](https://github.com/bottrall/acp-sdk/commit/460585a9a9401e35725ea379432694d9b76f35b6))
|
|
9
|
+
* add session_set_mode and session_set_config_option to ACP::ClientConnection ([#66](https://github.com/bottrall/acp-sdk/issues/66)) ([994179f](https://github.com/bottrall/acp-sdk/commit/994179f9ab51918bee7d29c8b473e0f05a55a7ad))
|
|
10
|
+
* add session/resume, session/close and session/delete to the client ([#65](https://github.com/bottrall/acp-sdk/issues/65)) ([4af834a](https://github.com/bottrall/acp-sdk/commit/4af834a5bc519753bad36d41efba902bcddc296b)), closes [#37](https://github.com/bottrall/acp-sdk/issues/37)
|
|
11
|
+
* elicitation on ACP::AgentConnection::Client ([#67](https://github.com/bottrall/acp-sdk/issues/67)) ([7d3212a](https://github.com/bottrall/acp-sdk/commit/7d3212a82ec6392351d8df1349b2fc21bf0bf030)), closes [#41](https://github.com/bottrall/acp-sdk/issues/41)
|
|
12
|
+
* filesystem handlers on ACP::ClientConnection ([#68](https://github.com/bottrall/acp-sdk/issues/68)) ([053f091](https://github.com/bottrall/acp-sdk/commit/053f0910adc3a7016bf1769099a54655f41bb9a6))
|
|
13
|
+
* injectable logger for dropped messages and swallowed errors ([#59](https://github.com/bottrall/acp-sdk/issues/59)) ([e66a306](https://github.com/bottrall/acp-sdk/commit/e66a30622922717d1be6d27c5b9a2931643fcd6b))
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
### Bug Fixes
|
|
17
|
+
|
|
18
|
+
* call ACP::RequestError.unadvertised in elicitation capability checks ([#69](https://github.com/bottrall/acp-sdk/issues/69)) ([fda6dad](https://github.com/bottrall/acp-sdk/commit/fda6dad13e7fe308b5ff63e4c9e6fa9eef6e8274))
|
|
19
|
+
* only advertise terminal auth methods to clients that support them ([#61](https://github.com/bottrall/acp-sdk/issues/61)) ([1d2674a](https://github.com/bottrall/acp-sdk/commit/1d2674a978d938834d69d2c68df4f2c918ea801c)), closes [#33](https://github.com/bottrall/acp-sdk/issues/33)
|
|
20
|
+
* refuse overlapping streaming calls for the same session ([#64](https://github.com/bottrall/acp-sdk/issues/64)) ([a3a5b34](https://github.com/bottrall/acp-sdk/commit/a3a5b343f0ba2555b62643577226166fada27485))
|
|
21
|
+
* reject an unsupported protocol version in ACP::ClientConnection#connect ([#62](https://github.com/bottrall/acp-sdk/issues/62)) ([6980b32](https://github.com/bottrall/acp-sdk/commit/6980b328bcec997ae68c6af9077d623a73c15d39)), closes [#34](https://github.com/bottrall/acp-sdk/issues/34)
|
|
22
|
+
|
|
23
|
+
## [0.4.0](https://github.com/bottrall/acp-sdk/compare/v0.3.0...v0.4.0) (2026-10-02)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
### Features
|
|
27
|
+
|
|
28
|
+
* 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))
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
### Bug Fixes
|
|
32
|
+
|
|
33
|
+
* 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)
|
|
34
|
+
* 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)
|
|
35
|
+
|
|
3
36
|
## [0.3.0](https://github.com/bottrall/acp-sdk/compare/v0.2.0...v0.3.0) (2026-10-02)
|
|
4
37
|
|
|
5
38
|
|
data/README.md
CHANGED
|
@@ -48,19 +48,20 @@ 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
|
|
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
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
56
|
- `client.read_text_file` and `client.write_text_file` take an `ACP::Types::ReadTextFileRequest` or `ACP::Types::WriteTextFileRequest` and return the response type or an `ACP::RequestError`. Unless `client.capabilities` advertises `fs.read_text_file` or `fs.write_text_file`, they return `-32601` without sending anything to the client.
|
|
57
57
|
- `client.create_terminal`, `client.terminal_output`, `client.wait_for_terminal_exit`, `client.kill_terminal` and `client.release_terminal` take the matching `ACP::Types` request (`CreateTerminalRequest`, `TerminalOutputRequest`, `WaitForTerminalExitRequest`, `KillTerminalRequest`, `ReleaseTerminalRequest`) and return its response type or an `ACP::RequestError`. Unless `client.capabilities` advertises `terminal`, they return `-32601` without sending anything to the client. The agent must release every terminal it creates.
|
|
58
|
+
- `client.create_elicitation` takes an `ACP::Types::CreateElicitationRequest` and returns an `ACP::Types::CreateElicitationResponse` or an `ACP::RequestError`. Unless `client.capabilities` advertises the `elicitation.form` or `elicitation.url` mode the request asks for, it returns `-32601` without sending anything to the client. A response action the SDK does not know comes through as the raw hash the client sent. `client.complete_elicitation` takes an `ACP::Types::CompleteElicitationNotification` and sends nothing, returning `-32601`, unless the client advertises `elicitation.url`, since only URL-mode elicitations are completed.
|
|
58
59
|
|
|
59
|
-
[`examples/echo_agent.rb`](https://github.com/bottrall/acp-sdk/blob/main/examples/echo_agent.rb) is a complete agent that streams updates, asks permission, handles cancellation, reads and writes files and runs commands through the client (`/read <path>`, `/write <path> <text>` and `/run <command> [args]`, each advertised as a slash command only when the client supports it), and supports `session/load` and `session/
|
|
60
|
+
[`examples/echo_agent.rb`](https://github.com/bottrall/acp-sdk/blob/main/examples/echo_agent.rb) is a complete agent that streams updates, asks permission, handles cancellation, reads and writes files and runs commands through the client (`/read <path>`, `/write <path> <text>` and `/run <command> [args]`, each advertised as a slash command only when the client supports it), and supports `session/load`, `session/list`, `session/resume`, `session/close` and `session/delete`.
|
|
60
61
|
|
|
61
62
|
## Driving an agent
|
|
62
63
|
|
|
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
|
+
`ACP::ClientConnection` is the other side of the same transport. Each method takes the request's generated `ACP::Types` object and returns its response type or the agent's `ACP::RequestError`. `connect` sends `initialize`, since Ruby reserves that name for the constructor, and `authenticate` and `logout` send their namesake methods. `session_set_mode` sends `session/set_mode`, and `session_set_config_option` sends `session/set_config_option` and returns the session's complete option list, which replaces any state held from an earlier reply.
|
|
64
65
|
|
|
65
66
|
`ACP::AgentProcess.spawn` starts a command with args, env and cwd, wires its stdio to a new `ACP::ClientConnection` and starts it. The block receives the connection and the process handle (`pid`, plus `stderr` when captured), and the child is terminated — after its stdin is closed, with TERM and then KILL if it will not exit — when the block exits, so a raised block cannot leak the process. Without a block, `spawn` returns the process and calling `terminate` is yours. `stderr` is `:inherit` by default, so the child writes to your stderr; pass `:capture` to collect it on the process instead.
|
|
66
67
|
|
|
@@ -90,12 +91,18 @@ Any transport over a pair of IOs works too, so the connection can still be wired
|
|
|
90
91
|
connection = ACP::ClientConnection.new(
|
|
91
92
|
transport: ACP::Transport::Stdio.new(input: some_io, output: other_io),
|
|
92
93
|
permission: ->(request) { ask_the_user(request) },
|
|
94
|
+
read_text_file: ->(request) { ACP::Types::ReadTextFileResponse.new(content: File.read(request.path)) },
|
|
95
|
+
write_text_file: ->(request) {
|
|
96
|
+
File.write(request.path, request.content)
|
|
97
|
+
ACP::Types::WriteTextFileResponse.new
|
|
98
|
+
},
|
|
93
99
|
updates: ->(notification) { show(notification) }
|
|
94
100
|
)
|
|
95
101
|
```
|
|
96
102
|
|
|
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.
|
|
103
|
+
- `session_prompt` and `session_load` yield the session's updates on the calling thread as they arrive and return once the agent replies. Updates outside those calls, such as the available commands after `session/new`, go to `updates`, which runs on the reader thread and must return quickly. `session_resume` does not take a block: the agent must not replay history on resume, so unlike `session_load` there is nothing to stream. `session_close` and `session_delete` end the session, discarding it on the agent, and return once the agent replies.
|
|
98
104
|
- `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.
|
|
105
|
+
- `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.
|
|
99
106
|
|
|
100
107
|
## Contributing
|
|
101
108
|
|
|
@@ -39,7 +39,7 @@ class ACP::AgentConnection::Client
|
|
|
39
39
|
# @rbs request: ACP::Types::ReadTextFileRequest
|
|
40
40
|
# @rbs return: ACP::Types::ReadTextFileResponse | ACP::RequestError
|
|
41
41
|
def read_text_file(request)
|
|
42
|
-
return unadvertised('fs.readTextFile') unless capabilities&.fs&.read_text_file
|
|
42
|
+
return ACP::RequestError.unadvertised('fs.readTextFile') unless capabilities&.fs&.read_text_file
|
|
43
43
|
|
|
44
44
|
call('fs/read_text_file', ACP::Types::ReadTextFileResponse, request)
|
|
45
45
|
end
|
|
@@ -47,7 +47,7 @@ class ACP::AgentConnection::Client
|
|
|
47
47
|
# @rbs request: ACP::Types::WriteTextFileRequest
|
|
48
48
|
# @rbs return: ACP::Types::WriteTextFileResponse | ACP::RequestError
|
|
49
49
|
def write_text_file(request)
|
|
50
|
-
return unadvertised('fs.writeTextFile') unless capabilities&.fs&.write_text_file
|
|
50
|
+
return ACP::RequestError.unadvertised('fs.writeTextFile') unless capabilities&.fs&.write_text_file
|
|
51
51
|
|
|
52
52
|
call('fs/write_text_file', ACP::Types::WriteTextFileResponse, request)
|
|
53
53
|
end
|
|
@@ -82,6 +82,29 @@ class ACP::AgentConnection::Client
|
|
|
82
82
|
terminal_call('terminal/release', ACP::Types::ReleaseTerminalResponse, request)
|
|
83
83
|
end
|
|
84
84
|
|
|
85
|
+
# @rbs request: ACP::Types::CreateElicitationRequest
|
|
86
|
+
# @rbs return: ACP::Types::CreateElicitationResponse | ACP::RequestError
|
|
87
|
+
def create_elicitation(request)
|
|
88
|
+
case request.mode
|
|
89
|
+
when ACP::Types::CreateElicitationRequest::Mode::Form
|
|
90
|
+
return ACP::RequestError.unadvertised('elicitation.form') unless capabilities&.elicitation&.form
|
|
91
|
+
when ACP::Types::CreateElicitationRequest::Mode::Url
|
|
92
|
+
return ACP::RequestError.unadvertised('elicitation.url') unless capabilities&.elicitation&.url
|
|
93
|
+
else
|
|
94
|
+
return ACP::RequestError.unadvertised("elicitation.#{request.mode['mode']}")
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
call('elicitation/create', ACP::Types::CreateElicitationResponse, request)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# @rbs notification: ACP::Types::CompleteElicitationNotification
|
|
101
|
+
# @rbs return: void
|
|
102
|
+
def complete_elicitation(notification)
|
|
103
|
+
return ACP::RequestError.unadvertised('elicitation.url') unless capabilities&.elicitation&.url
|
|
104
|
+
|
|
105
|
+
@peer.notify('elicitation/complete', notification.to_h)
|
|
106
|
+
end
|
|
107
|
+
|
|
85
108
|
private
|
|
86
109
|
|
|
87
110
|
# @rbs rpc_method: String
|
|
@@ -93,6 +116,8 @@ class ACP::AgentConnection::Client
|
|
|
93
116
|
return result if result.is_a?(ACP::RequestError)
|
|
94
117
|
|
|
95
118
|
type.from_h(result)
|
|
119
|
+
rescue KeyError, TypeError, NoMethodError
|
|
120
|
+
ACP::RequestError.invalid_response(result)
|
|
96
121
|
end
|
|
97
122
|
|
|
98
123
|
# @rbs rpc_method: String
|
|
@@ -100,19 +125,8 @@ class ACP::AgentConnection::Client
|
|
|
100
125
|
# @rbs request: ACP::AgentConnection::_Response
|
|
101
126
|
# @rbs return: untyped
|
|
102
127
|
def terminal_call(rpc_method, type, request)
|
|
103
|
-
return unadvertised('terminal') unless capabilities&.terminal
|
|
128
|
+
return ACP::RequestError.unadvertised('terminal') unless capabilities&.terminal
|
|
104
129
|
|
|
105
130
|
call(rpc_method, type, request)
|
|
106
131
|
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
|
-
)
|
|
117
|
-
end
|
|
118
132
|
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
|
data/lib/acp/agent_connection.rb
CHANGED
|
@@ -2,8 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
class ACP::AgentConnection
|
|
4
4
|
# @rbs @transport: ACP::AgentConnection::_Transport
|
|
5
|
-
# @rbs @
|
|
5
|
+
# @rbs @capabilities: ACP::Types::AgentCapabilities
|
|
6
|
+
# @rbs @agent_info: ACP::Types::Implementation?
|
|
7
|
+
# @rbs @auth_methods: Array[ACP::Types::AuthMethod::t]
|
|
6
8
|
# @rbs @factory: ^(ACP::AgentConnection::Client) -> ACP::AgentConnection::_Agent
|
|
9
|
+
# @rbs @logger: ACP::Transport::_Logger
|
|
7
10
|
|
|
8
11
|
PROTOCOL_VERSION = 1 #: Integer
|
|
9
12
|
OPTIONAL = [
|
|
@@ -12,7 +15,8 @@ class ACP::AgentConnection
|
|
|
12
15
|
ACP::AgentConnection::OptionalMethod::Authenticate,
|
|
13
16
|
ACP::AgentConnection::OptionalMethod::ResumeSession,
|
|
14
17
|
ACP::AgentConnection::OptionalMethod::CloseSession,
|
|
15
|
-
ACP::AgentConnection::OptionalMethod::DeleteSession
|
|
18
|
+
ACP::AgentConnection::OptionalMethod::DeleteSession,
|
|
19
|
+
ACP::AgentConnection::OptionalMethod::Logout
|
|
16
20
|
].freeze #: Array[ACP::AgentConnection::_OptionalMethod]
|
|
17
21
|
# The schema has no initialize capability for modes or config options: an
|
|
18
22
|
# agent offers them per session, in its session responses, so they are
|
|
@@ -26,16 +30,22 @@ class ACP::AgentConnection
|
|
|
26
30
|
# @rbs capabilities: ACP::Types::AgentCapabilities
|
|
27
31
|
# @rbs agent_info: ACP::Types::Implementation?
|
|
28
32
|
# @rbs auth_methods: Array[ACP::Types::AuthMethod::t]
|
|
33
|
+
# @rbs logger: ACP::Transport::_Logger
|
|
29
34
|
# @rbs &factory: (ACP::AgentConnection::Client) -> ACP::AgentConnection::_Agent
|
|
30
35
|
# @rbs return: void
|
|
31
|
-
def initialize(
|
|
36
|
+
def initialize(
|
|
37
|
+
transport:,
|
|
38
|
+
capabilities:,
|
|
39
|
+
agent_info: nil,
|
|
40
|
+
auth_methods: [],
|
|
41
|
+
logger: ACP::Transport::StderrLogger.new,
|
|
42
|
+
&factory
|
|
43
|
+
)
|
|
32
44
|
@transport = transport
|
|
33
|
-
@
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
agent_info:
|
|
38
|
-
)
|
|
45
|
+
@logger = logger
|
|
46
|
+
@capabilities = capabilities
|
|
47
|
+
@agent_info = agent_info
|
|
48
|
+
@auth_methods = auth_methods
|
|
39
49
|
@factory = factory
|
|
40
50
|
end
|
|
41
51
|
|
|
@@ -43,7 +53,10 @@ class ACP::AgentConnection
|
|
|
43
53
|
def start
|
|
44
54
|
client = ACP::AgentConnection::Client.new(peer: @transport)
|
|
45
55
|
agent = @factory.call(client)
|
|
46
|
-
|
|
56
|
+
# Routes are fixed before a client connects, so the check sees the
|
|
57
|
+
# unfiltered auth methods: a terminal-capable client still needs
|
|
58
|
+
# authenticate routed.
|
|
59
|
+
advertised, unadvertised = OPTIONAL.partition { |method| method.advertised?(initialize_response(@auth_methods)) }
|
|
47
60
|
missing = advertised.map(&:agent_method).reject { |name| agent.respond_to?(name) }
|
|
48
61
|
raise ArgumentError, "initialize advertises methods the agent lacks: #{missing.join(', ')}" unless missing.empty?
|
|
49
62
|
|
|
@@ -70,6 +83,7 @@ class ACP::AgentConnection
|
|
|
70
83
|
'session/resume' => route(ACP::Types::ResumeSessionRequest) { |request| full.resume_session(request) },
|
|
71
84
|
'session/close' => route(ACP::Types::CloseSessionRequest) { |request| full.close_session(request) },
|
|
72
85
|
'session/delete' => route(ACP::Types::DeleteSessionRequest) { |request| full.delete_session(request) },
|
|
86
|
+
'logout' => route(ACP::Types::LogoutRequest) { |request| full.logout(request) },
|
|
73
87
|
'session/set_mode' => route(ACP::Types::SetSessionModeRequest) { |request| full.change_session_mode(request) },
|
|
74
88
|
'session/set_config_option' => route(ACP::Types::SetSessionConfigOptionRequest) do |request|
|
|
75
89
|
full.change_session_config_option(request)
|
|
@@ -80,7 +94,20 @@ class ACP::AgentConnection
|
|
|
80
94
|
# @rbs agent: ACP::AgentConnection::_Agent
|
|
81
95
|
# @rbs return: Hash[String, ^(untyped) -> void]
|
|
82
96
|
def notifications(agent)
|
|
83
|
-
{ 'session/cancel' => ->(params) {
|
|
97
|
+
{ 'session/cancel' => ->(params) { cancel(agent, params) } }
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# A notification has no reply to carry a parse failure.
|
|
101
|
+
#
|
|
102
|
+
# @rbs agent: ACP::AgentConnection::_Agent
|
|
103
|
+
# @rbs params: untyped
|
|
104
|
+
# @rbs return: void
|
|
105
|
+
def cancel(agent, params)
|
|
106
|
+
notification = ACP::Types::CancelNotification.from_h(params)
|
|
107
|
+
rescue KeyError, TypeError, NoMethodError => e
|
|
108
|
+
@logger.warn("dropped malformed session/cancel: #{e.class}: #{e.message}")
|
|
109
|
+
else
|
|
110
|
+
agent.cancel(notification)
|
|
84
111
|
end
|
|
85
112
|
|
|
86
113
|
# Generated from_h raises on a missing key or a value of the wrong shape.
|
|
@@ -102,8 +129,34 @@ class ACP::AgentConnection
|
|
|
102
129
|
# @rbs request: ACP::Types::InitializeRequest
|
|
103
130
|
# @rbs return: ACP::Types::InitializeResponse
|
|
104
131
|
def connect(client, request)
|
|
105
|
-
|
|
106
|
-
|
|
132
|
+
capabilities = request.client_capabilities || ACP::Types::ClientCapabilities.new
|
|
133
|
+
client.capabilities = capabilities
|
|
134
|
+
initialize_response(auth_methods(capabilities))
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# @rbs auth_methods: Array[ACP::Types::AuthMethod::t]
|
|
138
|
+
# @rbs return: ACP::Types::InitializeResponse
|
|
139
|
+
def initialize_response(auth_methods)
|
|
140
|
+
ACP::Types::InitializeResponse.new(
|
|
141
|
+
protocol_version: PROTOCOL_VERSION,
|
|
142
|
+
agent_capabilities: @capabilities,
|
|
143
|
+
auth_methods:,
|
|
144
|
+
agent_info: @agent_info
|
|
145
|
+
)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# @rbs capabilities: ACP::Types::ClientCapabilities
|
|
149
|
+
# @rbs return: Array[ACP::Types::AuthMethod::t]
|
|
150
|
+
def auth_methods(capabilities)
|
|
151
|
+
return @auth_methods if capabilities.auth&.terminal
|
|
152
|
+
|
|
153
|
+
@auth_methods.reject { |method| terminal_auth_method?(method) }
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# @rbs method: ACP::Types::AuthMethod::t
|
|
157
|
+
# @rbs return: bool
|
|
158
|
+
def terminal_auth_method?(method)
|
|
159
|
+
method.is_a?(ACP::Types::AuthMethod::Terminal) || (method.is_a?(Hash) && method['type'] == 'terminal')
|
|
107
160
|
end
|
|
108
161
|
|
|
109
162
|
# session_created runs after the reply because the client must know the
|
|
@@ -3,28 +3,45 @@
|
|
|
3
3
|
class ACP::ClientConnection
|
|
4
4
|
# @rbs @transport: ACP::AgentConnection::_Transport
|
|
5
5
|
# @rbs @permission: ACP::ClientConnection::_PermissionHandler
|
|
6
|
+
# @rbs @read_text_file: (^(ACP::Types::ReadTextFileRequest) -> (ACP::Types::ReadTextFileResponse | ACP::RequestError))?
|
|
7
|
+
# @rbs @write_text_file: (^(ACP::Types::WriteTextFileRequest) -> (ACP::Types::WriteTextFileResponse | ACP::RequestError))?
|
|
6
8
|
# @rbs @updates: ACP::ClientConnection::_UpdateHandler
|
|
9
|
+
# @rbs @fs_capabilities: ACP::Types::FileSystemCapabilities?
|
|
7
10
|
# @rbs @lock: Thread::Mutex
|
|
8
11
|
# @rbs @streams: Hash[String, Thread::Queue]
|
|
12
|
+
# @rbs @logger: ACP::Transport::_Logger
|
|
13
|
+
|
|
14
|
+
PROTOCOL_VERSION = ACP::AgentConnection::PROTOCOL_VERSION #: Integer
|
|
9
15
|
|
|
10
16
|
IGNORE = ->(_notification) {} #: ^(ACP::Types::SessionNotification) -> void
|
|
11
17
|
|
|
12
18
|
# @rbs transport: ACP::AgentConnection::_Transport
|
|
13
19
|
# @rbs permission: ACP::ClientConnection::_PermissionHandler
|
|
20
|
+
# @rbs read_text_file: (^(ACP::Types::ReadTextFileRequest) -> (ACP::Types::ReadTextFileResponse | ACP::RequestError))?
|
|
21
|
+
# @rbs write_text_file: (^(ACP::Types::WriteTextFileRequest) -> (ACP::Types::WriteTextFileResponse | ACP::RequestError))?
|
|
14
22
|
# @rbs updates: ACP::ClientConnection::_UpdateHandler
|
|
23
|
+
# @rbs logger: ACP::Transport::_Logger
|
|
15
24
|
# @rbs return: void
|
|
16
|
-
def initialize(transport:, permission:, updates: IGNORE)
|
|
25
|
+
def initialize(transport:, permission:, read_text_file: nil, write_text_file: nil, updates: IGNORE, logger: ACP::Transport::StderrLogger.new)
|
|
17
26
|
@transport = transport
|
|
18
27
|
@permission = permission
|
|
28
|
+
@read_text_file = read_text_file
|
|
29
|
+
@write_text_file = write_text_file
|
|
19
30
|
@updates = updates
|
|
31
|
+
@fs_capabilities = nil
|
|
20
32
|
@lock = Mutex.new
|
|
21
33
|
@streams = {}
|
|
34
|
+
@logger = logger
|
|
22
35
|
end
|
|
23
36
|
|
|
24
37
|
# @rbs return: Thread
|
|
25
38
|
def start
|
|
26
39
|
@transport.start(
|
|
27
|
-
requests: {
|
|
40
|
+
requests: {
|
|
41
|
+
'session/request_permission' => method(:request_permission),
|
|
42
|
+
'fs/read_text_file' => method(:read_text_file),
|
|
43
|
+
'fs/write_text_file' => method(:write_text_file)
|
|
44
|
+
},
|
|
28
45
|
notifications: { 'session/update' => method(:dispatch) }
|
|
29
46
|
)
|
|
30
47
|
end
|
|
@@ -35,7 +52,23 @@ class ACP::ClientConnection
|
|
|
35
52
|
# @rbs request: ACP::Types::InitializeRequest
|
|
36
53
|
# @rbs return: ACP::Types::InitializeResponse | ACP::RequestError
|
|
37
54
|
def connect(request)
|
|
38
|
-
|
|
55
|
+
capabilities = request.client_capabilities&.fs
|
|
56
|
+
unserved = unserved_fs_methods(capabilities)
|
|
57
|
+
unless unserved.empty?
|
|
58
|
+
raise ArgumentError, "initialize advertises fs methods no handler serves: #{unserved.join(', ')}"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
@fs_capabilities = capabilities
|
|
62
|
+
parse(ACP::Types::InitializeResponse, @transport.request('initialize', request.to_h)).then do |response|
|
|
63
|
+
next response if response.is_a?(ACP::RequestError)
|
|
64
|
+
|
|
65
|
+
next response if response.protocol_version == PROTOCOL_VERSION
|
|
66
|
+
|
|
67
|
+
ACP::RequestError.unsupported_protocol_version(
|
|
68
|
+
requested: request.protocol_version,
|
|
69
|
+
returned: response.protocol_version
|
|
70
|
+
)
|
|
71
|
+
end
|
|
39
72
|
end
|
|
40
73
|
|
|
41
74
|
# @rbs request: ACP::Types::NewSessionRequest
|
|
@@ -64,12 +97,60 @@ class ACP::ClientConnection
|
|
|
64
97
|
parse(ACP::Types::ListSessionsResponse, @transport.request('session/list', request.to_h))
|
|
65
98
|
end
|
|
66
99
|
|
|
100
|
+
# The agent does not replay history on resume, so unlike session_load there
|
|
101
|
+
# is nothing to stream and no block is taken.
|
|
102
|
+
#
|
|
103
|
+
# @rbs request: ACP::Types::ResumeSessionRequest
|
|
104
|
+
# @rbs return: ACP::Types::ResumeSessionResponse | ACP::RequestError
|
|
105
|
+
def session_resume(request)
|
|
106
|
+
parse(ACP::Types::ResumeSessionResponse, @transport.request('session/resume', request.to_h))
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# @rbs request: ACP::Types::CloseSessionRequest
|
|
110
|
+
# @rbs return: ACP::Types::CloseSessionResponse | ACP::RequestError
|
|
111
|
+
def session_close(request)
|
|
112
|
+
parse(ACP::Types::CloseSessionResponse, @transport.request('session/close', request.to_h))
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# @rbs request: ACP::Types::DeleteSessionRequest
|
|
116
|
+
# @rbs return: ACP::Types::DeleteSessionResponse | ACP::RequestError
|
|
117
|
+
def session_delete(request)
|
|
118
|
+
parse(ACP::Types::DeleteSessionResponse, @transport.request('session/delete', request.to_h))
|
|
119
|
+
end
|
|
120
|
+
|
|
67
121
|
# @rbs notification: ACP::Types::CancelNotification
|
|
68
122
|
# @rbs return: void
|
|
69
123
|
def session_cancel(notification)
|
|
70
124
|
@transport.notify('session/cancel', notification.to_h)
|
|
71
125
|
end
|
|
72
126
|
|
|
127
|
+
# @rbs request: ACP::Types::SetSessionModeRequest
|
|
128
|
+
# @rbs return: ACP::Types::SetSessionModeResponse | ACP::RequestError
|
|
129
|
+
def session_set_mode(request)
|
|
130
|
+
parse(ACP::Types::SetSessionModeResponse, @transport.request('session/set_mode', request.to_h))
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# The reply carries the session's complete option list, so it replaces any
|
|
134
|
+
# state held from an earlier reply rather than merging into it.
|
|
135
|
+
#
|
|
136
|
+
# @rbs request: ACP::Types::SetSessionConfigOptionRequest
|
|
137
|
+
# @rbs return: ACP::Types::SetSessionConfigOptionResponse | ACP::RequestError
|
|
138
|
+
def session_set_config_option(request)
|
|
139
|
+
parse(ACP::Types::SetSessionConfigOptionResponse, @transport.request('session/set_config_option', request.to_h))
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# @rbs request: ACP::Types::AuthenticateRequest
|
|
143
|
+
# @rbs return: ACP::Types::AuthenticateResponse | ACP::RequestError
|
|
144
|
+
def authenticate(request)
|
|
145
|
+
parse(ACP::Types::AuthenticateResponse, @transport.request('authenticate', request.to_h))
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# @rbs request: ACP::Types::LogoutRequest
|
|
149
|
+
# @rbs return: ACP::Types::LogoutResponse | ACP::RequestError
|
|
150
|
+
def logout(request)
|
|
151
|
+
parse(ACP::Types::LogoutResponse, @transport.request('logout', request.to_h))
|
|
152
|
+
end
|
|
153
|
+
|
|
73
154
|
private
|
|
74
155
|
|
|
75
156
|
# @rbs type: ACP::AgentConnection::_Parser
|
|
@@ -79,6 +160,8 @@ class ACP::ClientConnection
|
|
|
79
160
|
return result if result.is_a?(ACP::RequestError)
|
|
80
161
|
|
|
81
162
|
type.from_h(result)
|
|
163
|
+
rescue KeyError, TypeError, NoMethodError
|
|
164
|
+
ACP::RequestError.invalid_response(result)
|
|
82
165
|
end
|
|
83
166
|
|
|
84
167
|
# The request waits on its own thread so the caller's thread can yield each
|
|
@@ -92,7 +175,21 @@ class ACP::ClientConnection
|
|
|
92
175
|
# @rbs return: (Hash[String, untyped] | ACP::RequestError)
|
|
93
176
|
def stream(session_id, method, params)
|
|
94
177
|
queue = Thread::Queue.new
|
|
95
|
-
|
|
178
|
+
# The check and the insert share one lock acquisition so a concurrent
|
|
179
|
+
# stream cannot register between them.
|
|
180
|
+
claimed = @lock.synchronize do
|
|
181
|
+
if @streams.key?(session_id)
|
|
182
|
+
false
|
|
183
|
+
else
|
|
184
|
+
@streams[session_id] = queue
|
|
185
|
+
true
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
unless claimed
|
|
189
|
+
return ACP::RequestError.new(
|
|
190
|
+
code: ACP::RequestError::INVALID_REQUEST, message: "A stream is already open for session #{session_id}"
|
|
191
|
+
)
|
|
192
|
+
end
|
|
96
193
|
requester = Thread.new do
|
|
97
194
|
@transport.request(method, params)
|
|
98
195
|
ensure
|
|
@@ -105,12 +202,16 @@ class ACP::ClientConnection
|
|
|
105
202
|
end
|
|
106
203
|
|
|
107
204
|
# Pushes under the lock so a stream cannot close its queue between the
|
|
108
|
-
# lookup and the push.
|
|
205
|
+
# lookup and the push. A malformed notification has no reply to carry the
|
|
206
|
+
# failure.
|
|
109
207
|
#
|
|
110
208
|
# @rbs params: untyped
|
|
111
209
|
# @rbs return: void
|
|
112
210
|
def dispatch(params)
|
|
113
211
|
notification = ACP::Types::SessionNotification.from_h(params)
|
|
212
|
+
rescue KeyError, TypeError, NoMethodError => e
|
|
213
|
+
@logger.warn("dropped malformed session/update: #{e.class}: #{e.message}")
|
|
214
|
+
else
|
|
114
215
|
queue = @lock.synchronize { @streams[notification.session_id]&.push(notification.update) }
|
|
115
216
|
@updates.call(notification) unless queue
|
|
116
217
|
end
|
|
@@ -124,4 +225,46 @@ class ACP::ClientConnection
|
|
|
124
225
|
else
|
|
125
226
|
@permission.call(request)
|
|
126
227
|
end
|
|
228
|
+
|
|
229
|
+
# The routes are registered at start, before connect records the advertised
|
|
230
|
+
# capabilities, so each one answers -32601 until then.
|
|
231
|
+
#
|
|
232
|
+
# @rbs params: untyped
|
|
233
|
+
# @rbs return: (ACP::Types::ReadTextFileResponse | ACP::RequestError)
|
|
234
|
+
def read_text_file(params)
|
|
235
|
+
return ACP::RequestError.unadvertised('fs.readTextFile') unless @fs_capabilities&.read_text_file
|
|
236
|
+
|
|
237
|
+
request = ACP::Types::ReadTextFileRequest.from_h(params)
|
|
238
|
+
rescue KeyError, TypeError, NoMethodError
|
|
239
|
+
ACP::RequestError.invalid_params
|
|
240
|
+
else
|
|
241
|
+
# Safe: connect refuses an advertised capability no handler serves.
|
|
242
|
+
handler = @read_text_file #: ^(ACP::Types::ReadTextFileRequest) -> (ACP::Types::ReadTextFileResponse | ACP::RequestError)
|
|
243
|
+
handler.call(request)
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
# @rbs params: untyped
|
|
247
|
+
# @rbs return: (ACP::Types::WriteTextFileResponse | ACP::RequestError)
|
|
248
|
+
def write_text_file(params)
|
|
249
|
+
return ACP::RequestError.unadvertised('fs.writeTextFile') unless @fs_capabilities&.write_text_file
|
|
250
|
+
|
|
251
|
+
request = ACP::Types::WriteTextFileRequest.from_h(params)
|
|
252
|
+
rescue KeyError, TypeError, NoMethodError
|
|
253
|
+
ACP::RequestError.invalid_params
|
|
254
|
+
else
|
|
255
|
+
# Safe: connect refuses an advertised capability no handler serves.
|
|
256
|
+
handler = @write_text_file #: ^(ACP::Types::WriteTextFileRequest) -> (ACP::Types::WriteTextFileResponse | ACP::RequestError)
|
|
257
|
+
handler.call(request)
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
# The fs capabilities initialize advertises that no injected handler serves.
|
|
261
|
+
#
|
|
262
|
+
# @rbs capabilities: ACP::Types::FileSystemCapabilities?
|
|
263
|
+
# @rbs return: Array[String]
|
|
264
|
+
def unserved_fs_methods(capabilities)
|
|
265
|
+
{
|
|
266
|
+
'fs.readTextFile' => capabilities&.read_text_file && !@read_text_file,
|
|
267
|
+
'fs.writeTextFile' => capabilities&.write_text_file && !@write_text_file
|
|
268
|
+
}.select { |_, unserved| unserved }.keys
|
|
269
|
+
end
|
|
127
270
|
end
|
data/lib/acp/request_error.rb
CHANGED
|
@@ -58,11 +58,41 @@ class ACP::RequestError
|
|
|
58
58
|
new(code: INVALID_PARAMS, message: 'Invalid params')
|
|
59
59
|
end
|
|
60
60
|
|
|
61
|
+
# The method-not-found code for a method the client's capabilities do not
|
|
62
|
+
# advertise, with a message that names the capability so it can be told
|
|
63
|
+
# apart from an unknown method.
|
|
64
|
+
#
|
|
65
|
+
# @rbs capability: String
|
|
66
|
+
# @rbs return: ACP::RequestError
|
|
67
|
+
def self.unadvertised(capability)
|
|
68
|
+
new(code: METHOD_NOT_FOUND, message: "Client does not advertise #{capability}")
|
|
69
|
+
end
|
|
70
|
+
|
|
61
71
|
# @rbs return: ACP::RequestError
|
|
62
72
|
def self.internal_error
|
|
63
73
|
new(code: INTERNAL_ERROR, message: 'Internal error')
|
|
64
74
|
end
|
|
65
75
|
|
|
76
|
+
# The message tells a malformed peer reply apart from an internal error a
|
|
77
|
+
# handler raised, though both use the -32603 code.
|
|
78
|
+
#
|
|
79
|
+
# @rbs response: untyped
|
|
80
|
+
# @rbs return: ACP::RequestError
|
|
81
|
+
def self.invalid_response(response)
|
|
82
|
+
new(code: INTERNAL_ERROR, message: 'Invalid response', data: response)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# A local refusal of an initialize reply that names a version the SDK does
|
|
86
|
+
# not speak; the message names both versions so logs can tell it from a
|
|
87
|
+
# peer's error.
|
|
88
|
+
#
|
|
89
|
+
# @rbs requested: Integer
|
|
90
|
+
# @rbs returned: Integer
|
|
91
|
+
# @rbs return: ACP::RequestError
|
|
92
|
+
def self.unsupported_protocol_version(requested:, returned:)
|
|
93
|
+
new(code: INTERNAL_ERROR, message: "Unsupported protocol version: requested #{requested}, returned #{returned}")
|
|
94
|
+
end
|
|
95
|
+
|
|
66
96
|
# @rbs return: ACP::RequestError
|
|
67
97
|
def self.request_cancelled
|
|
68
98
|
new(code: REQUEST_CANCELLED, message: 'Request cancelled')
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# The default sink for dropped messages and swallowed errors: the spec
|
|
4
|
+
# sanctions agents logging to stderr.
|
|
5
|
+
class ACP::Transport::StderrLogger
|
|
6
|
+
# @rbs message: String
|
|
7
|
+
# @rbs return: void
|
|
8
|
+
def warn(message)
|
|
9
|
+
Kernel.warn("acp: WARN: #{message}")
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# @rbs message: String
|
|
13
|
+
# @rbs return: void
|
|
14
|
+
def error(message)
|
|
15
|
+
Kernel.warn("acp: ERROR: #{message}")
|
|
16
|
+
end
|
|
17
|
+
end
|