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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +21 -0
- data/README.md +29 -16
- data/lib/acp/agent_connection/client.rb +27 -12
- data/lib/acp/agent_connection/optional_method/logout.rb +21 -0
- data/lib/acp/agent_connection.rb +21 -32
- data/lib/acp/agent_process.rb +119 -0
- data/lib/acp/client_connection.rb +15 -15
- data/lib/acp/request_error.rb +90 -0
- data/lib/acp/transport/reply.rb +4 -4
- data/lib/acp/transport/stdio.rb +28 -25
- 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 +23 -16
- data/sig/generated/acp/agent_connection/optional_method/logout.rbs +13 -0
- data/sig/generated/acp/agent_connection.rbs +9 -15
- data/sig/generated/acp/agent_process.rbs +72 -0
- data/sig/generated/acp/client_connection.rbs +16 -16
- data/sig/generated/acp/request_error.rbs +72 -0
- data/sig/generated/acp/transport/reply.rbs +5 -5
- data/sig/generated/acp/transport/stdio.rbs +14 -19
- 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 +24 -19
- data/sig/manual/acp/agent_process.rbs +7 -0
- data/sig/manual/acp/client_connection.rbs +1 -1
- data/sig/manual/acp/transport/stdio.rbs +7 -1
- metadata +9 -5
- data/lib/acp/transport/response_error.rb +0 -28
- data/lib/acp/transport/result.rb +0 -35
- data/sig/generated/acp/transport/response_error.rbs +0 -21
- data/sig/generated/acp/transport/result.rbs +0 -25
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f0121ff621c45deb5578a15051611ea98d8d174193528c3df214fe4e036561ff
|
|
4
|
+
data.tar.gz: e26701095772e5bbf41a18a8e2bc9f4ac9609657aae1dee7c82891de6cf5b48c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
52
|
-
- Each request method returns its response type or an `ACP::
|
|
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::
|
|
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::
|
|
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::
|
|
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
|
-
|
|
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:
|
|
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::
|
|
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::
|
|
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::
|
|
40
|
+
# @rbs return: ACP::Types::ReadTextFileResponse | ACP::RequestError
|
|
41
41
|
def read_text_file(request)
|
|
42
|
-
return
|
|
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::
|
|
48
|
+
# @rbs return: ACP::Types::WriteTextFileResponse | ACP::RequestError
|
|
49
49
|
def write_text_file(request)
|
|
50
|
-
return
|
|
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::
|
|
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::
|
|
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::
|
|
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::
|
|
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::
|
|
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
|
|
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
|
|
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
|
data/lib/acp/agent_connection.rb
CHANGED
|
@@ -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::
|
|
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|
|
|
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|
|
|
69
|
-
'session/load' => route(ACP::Types::LoadSessionRequest) { |request|
|
|
70
|
-
'session/list' => route(ACP::Types::ListSessionsRequest) { |request|
|
|
71
|
-
'session/resume' => route(ACP::Types::ResumeSessionRequest) { |request|
|
|
72
|
-
'session/close' => route(ACP::Types::CloseSessionRequest) { |request|
|
|
73
|
-
'session/delete' => route(ACP::Types::DeleteSessionRequest) { |request|
|
|
74
|
-
'
|
|
75
|
-
|
|
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
|
-
|
|
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::
|
|
93
|
-
# @rbs return: ^(untyped) -> (ACP::
|
|
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::
|
|
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::
|
|
105
|
+
# @rbs return: ACP::Types::InitializeResponse
|
|
116
106
|
def connect(client, request)
|
|
117
107
|
client.capabilities = request.client_capabilities || ACP::Types::ClientCapabilities.new
|
|
118
|
-
|
|
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::
|
|
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
|
-
|
|
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(
|
|
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::
|
|
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::
|
|
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::
|
|
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::
|
|
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::
|
|
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::
|
|
76
|
+
# @rbs result: (Hash[String, untyped] | ACP::RequestError)
|
|
77
77
|
# @rbs return: untyped
|
|
78
78
|
def parse(type, result)
|
|
79
|
-
result
|
|
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::
|
|
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::
|
|
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::
|
|
125
|
+
ACP::RequestError.invalid_params
|
|
122
126
|
else
|
|
123
|
-
|
|
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
|
data/lib/acp/transport/reply.rb
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
# A request handler's
|
|
4
|
-
#
|
|
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::
|
|
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::
|
|
12
|
+
# @rbs result: ACP::Transport::Stdio::_ToH
|
|
13
13
|
# @rbs after: ^() -> void
|
|
14
14
|
# @rbs return: void
|
|
15
15
|
def initialize(result, after:)
|