acp-sdk 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +8 -0
- data/README.md +28 -15
- data/lib/acp/agent_connection/client.rb +25 -12
- data/lib/acp/agent_connection.rb +18 -31
- data/lib/acp/agent_process.rb +119 -0
- data/lib/acp/client_connection.rb +13 -15
- data/lib/acp/request_error.rb +81 -0
- data/lib/acp/transport/reply.rb +4 -4
- data/lib/acp/transport/stdio.rb +28 -25
- data/lib/acp/version.rb +1 -1
- data/sig/generated/acp/agent_connection/client.rbs +23 -16
- 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 +65 -0
- data/sig/generated/acp/transport/reply.rbs +5 -5
- data/sig/generated/acp/transport/stdio.rbs +14 -19
- data/sig/manual/acp/agent_connection.rbs +19 -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 +6 -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: 59dca9a568751a3b4c29c1ff4afdd9ad12e997689d5eb3a4782f2a4c5ced81b0
|
|
4
|
+
data.tar.gz: 99bde6bbc81d16012a6d9b3654bf15f15a35a40713f3d6dbbe686557f182ac50
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: f803ca1e9d3dae3b791cebe930d49c221b2cd3f68df6cc5734bc3e40103af0d101db4447851596c2f6834b855f08a110c24455b06c517aa62e51b95a23cbdfd2
|
|
7
|
+
data.tar.gz: 844f53d23060359a77314f196fdeab023a7ded7c67fdea9f1a75b80bb28db3efb2421977001c13b3ec37f84479f6b9bf816e53a4a8da92c89bc58ee211b88ab8
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.3.0](https://github.com/bottrall/acp-sdk/compare/v0.2.0...v0.3.0) (2026-10-02)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* named protocol error codes ([#52](https://github.com/bottrall/acp-sdk/issues/52)) ([0a81700](https://github.com/bottrall/acp-sdk/commit/0a81700e3fa095279a77e2f161a7d6369c46792f))
|
|
9
|
+
* spawn an agent process for ACP::ClientConnection ([#54](https://github.com/bottrall/acp-sdk/issues/54)) ([e01d2d2](https://github.com/bottrall/acp-sdk/commit/e01d2d280e9cfd08c203f6dccd1ce602979e21c8))
|
|
10
|
+
|
|
3
11
|
## [0.2.0](https://github.com/bottrall/acp-sdk/compare/v0.1.0...v0.2.0) (2026-09-30)
|
|
4
12
|
|
|
5
13
|
|
data/README.md
CHANGED
|
@@ -49,40 +49,53 @@ connection.start.join
|
|
|
49
49
|
The agent's contract:
|
|
50
50
|
|
|
51
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::
|
|
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,9 @@ 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)
|
|
94
96
|
end
|
|
95
97
|
|
|
96
98
|
# @rbs rpc_method: String
|
|
@@ -98,8 +100,19 @@ class ACP::AgentConnection::Client
|
|
|
98
100
|
# @rbs request: ACP::AgentConnection::_Response
|
|
99
101
|
# @rbs return: untyped
|
|
100
102
|
def terminal_call(rpc_method, type, request)
|
|
101
|
-
return
|
|
103
|
+
return unadvertised('terminal') unless capabilities&.terminal
|
|
102
104
|
|
|
103
105
|
call(rpc_method, type, request)
|
|
104
106
|
end
|
|
107
|
+
|
|
108
|
+
# Same method-not-found code a peer would send, but with a message that says
|
|
109
|
+
# the refusal was local, so logs can tell the two cases apart.
|
|
110
|
+
#
|
|
111
|
+
# @rbs capability: String
|
|
112
|
+
# @rbs return: ACP::RequestError
|
|
113
|
+
def unadvertised(capability)
|
|
114
|
+
ACP::RequestError.new(
|
|
115
|
+
code: ACP::RequestError::METHOD_NOT_FOUND, message: "Client does not advertise #{capability}"
|
|
116
|
+
)
|
|
117
|
+
end
|
|
105
118
|
end
|
data/lib/acp/agent_connection.rb
CHANGED
|
@@ -6,7 +6,6 @@ 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,
|
|
@@ -56,26 +55,24 @@ class ACP::AgentConnection
|
|
|
56
55
|
|
|
57
56
|
# @rbs agent: ACP::AgentConnection::_Agent
|
|
58
57
|
# @rbs client: ACP::AgentConnection::Client
|
|
59
|
-
# @rbs return: Hash[String, ^(untyped) -> (ACP::
|
|
58
|
+
# @rbs return: Hash[String, ^(untyped) -> (ACP::AgentConnection::_Response | ACP::RequestError | ACP::Transport::Reply)]
|
|
60
59
|
def requests(agent, client)
|
|
61
60
|
# Safe: start drops the optional handlers initialize does not advertise
|
|
62
61
|
# or the agent does not define, and checks the agent defines the rest.
|
|
63
62
|
full = agent #: ACP::AgentConnection::_FullAgent
|
|
64
63
|
{
|
|
65
64
|
'initialize' => route(ACP::Types::InitializeRequest) { |request| connect(client, request) },
|
|
66
|
-
'authenticate' => route(ACP::Types::AuthenticateRequest) { |request|
|
|
65
|
+
'authenticate' => route(ACP::Types::AuthenticateRequest) { |request| full.authenticate(request) },
|
|
67
66
|
'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
|
-
'session/set_mode' => route(ACP::Types::SetSessionModeRequest)
|
|
75
|
-
respond(full.change_session_mode(request))
|
|
76
|
-
end,
|
|
67
|
+
'session/prompt' => route(ACP::Types::PromptRequest) { |request| agent.prompt(request) },
|
|
68
|
+
'session/load' => route(ACP::Types::LoadSessionRequest) { |request| full.load_session(request) },
|
|
69
|
+
'session/list' => route(ACP::Types::ListSessionsRequest) { |request| full.list_sessions(request) },
|
|
70
|
+
'session/resume' => route(ACP::Types::ResumeSessionRequest) { |request| full.resume_session(request) },
|
|
71
|
+
'session/close' => route(ACP::Types::CloseSessionRequest) { |request| full.close_session(request) },
|
|
72
|
+
'session/delete' => route(ACP::Types::DeleteSessionRequest) { |request| full.delete_session(request) },
|
|
73
|
+
'session/set_mode' => route(ACP::Types::SetSessionModeRequest) { |request| full.change_session_mode(request) },
|
|
77
74
|
'session/set_config_option' => route(ACP::Types::SetSessionConfigOptionRequest) do |request|
|
|
78
|
-
|
|
75
|
+
full.change_session_config_option(request)
|
|
79
76
|
end
|
|
80
77
|
}
|
|
81
78
|
end
|
|
@@ -89,33 +86,24 @@ class ACP::AgentConnection
|
|
|
89
86
|
# Generated from_h raises on a missing key or a value of the wrong shape.
|
|
90
87
|
#
|
|
91
88
|
# @rbs type: ACP::AgentConnection::_Parser
|
|
92
|
-
# @rbs &handle: (untyped) -> (ACP::
|
|
93
|
-
# @rbs return: ^(untyped) -> (ACP::
|
|
89
|
+
# @rbs &handle: (untyped) -> (ACP::AgentConnection::_Response | ACP::RequestError | ACP::Transport::Reply)
|
|
90
|
+
# @rbs return: ^(untyped) -> (ACP::AgentConnection::_Response | ACP::RequestError | ACP::Transport::Reply)
|
|
94
91
|
def route(type, &)
|
|
95
92
|
lambda do |params|
|
|
96
93
|
request = type.from_h(params)
|
|
97
94
|
rescue KeyError, TypeError, NoMethodError
|
|
98
|
-
ACP::
|
|
95
|
+
ACP::RequestError.invalid_params
|
|
99
96
|
else
|
|
100
97
|
yield(request)
|
|
101
98
|
end
|
|
102
99
|
end
|
|
103
100
|
|
|
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
101
|
# @rbs client: ACP::AgentConnection::Client
|
|
114
102
|
# @rbs request: ACP::Types::InitializeRequest
|
|
115
|
-
# @rbs return: ACP::
|
|
103
|
+
# @rbs return: ACP::Types::InitializeResponse
|
|
116
104
|
def connect(client, request)
|
|
117
105
|
client.capabilities = request.client_capabilities || ACP::Types::ClientCapabilities.new
|
|
118
|
-
|
|
106
|
+
@initialize_response
|
|
119
107
|
end
|
|
120
108
|
|
|
121
109
|
# session_created runs after the reply because the client must know the
|
|
@@ -123,13 +111,12 @@ class ACP::AgentConnection
|
|
|
123
111
|
#
|
|
124
112
|
# @rbs agent: ACP::AgentConnection::_Agent
|
|
125
113
|
# @rbs request: ACP::Types::NewSessionRequest
|
|
126
|
-
# @rbs return: ACP::
|
|
114
|
+
# @rbs return: (ACP::Types::NewSessionResponse | ACP::RequestError | ACP::Transport::Reply)
|
|
127
115
|
def new_session(agent, request)
|
|
128
116
|
response = agent.new_session(request)
|
|
129
|
-
|
|
130
|
-
return result unless response.is_a?(ACP::Types::NewSessionResponse) && agent.respond_to?(:session_created)
|
|
117
|
+
return response unless response.is_a?(ACP::Types::NewSessionResponse) && agent.respond_to?(:session_created)
|
|
131
118
|
|
|
132
119
|
hook = agent #: ACP::AgentConnection::_Agent & ACP::AgentConnection::_SessionCreated
|
|
133
|
-
ACP::Transport::Reply.new(
|
|
120
|
+
ACP::Transport::Reply.new(response, after: -> { hook.session_created(response) })
|
|
134
121
|
end
|
|
135
122
|
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,12 @@ 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)
|
|
80
82
|
end
|
|
81
83
|
|
|
82
84
|
# The request waits on its own thread so the caller's thread can yield each
|
|
@@ -87,7 +89,7 @@ class ACP::ClientConnection
|
|
|
87
89
|
# @rbs method: String
|
|
88
90
|
# @rbs params: Hash[String, untyped]
|
|
89
91
|
# @rbs &block: (ACP::Types::SessionUpdate::t) -> void
|
|
90
|
-
# @rbs return: ACP::
|
|
92
|
+
# @rbs return: (Hash[String, untyped] | ACP::RequestError)
|
|
91
93
|
def stream(session_id, method, params)
|
|
92
94
|
queue = Thread::Queue.new
|
|
93
95
|
@lock.synchronize { @streams[session_id] = queue }
|
|
@@ -114,16 +116,12 @@ class ACP::ClientConnection
|
|
|
114
116
|
end
|
|
115
117
|
|
|
116
118
|
# @rbs params: untyped
|
|
117
|
-
# @rbs return: ACP::
|
|
119
|
+
# @rbs return: (ACP::Types::RequestPermissionResponse | ACP::RequestError)
|
|
118
120
|
def request_permission(params)
|
|
119
121
|
request = ACP::Types::RequestPermissionRequest.from_h(params)
|
|
120
122
|
rescue KeyError, TypeError, NoMethodError
|
|
121
|
-
ACP::
|
|
123
|
+
ACP::RequestError.invalid_params
|
|
122
124
|
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
|
|
125
|
+
@permission.call(request)
|
|
128
126
|
end
|
|
129
127
|
end
|
|
@@ -0,0 +1,81 @@
|
|
|
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
|
+
# @rbs return: ACP::RequestError
|
|
67
|
+
def self.request_cancelled
|
|
68
|
+
new(code: REQUEST_CANCELLED, message: 'Request cancelled')
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# @rbs return: ACP::RequestError
|
|
72
|
+
def self.auth_required
|
|
73
|
+
new(code: AUTH_REQUIRED, message: 'Authentication required')
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# @rbs uri: String?
|
|
77
|
+
# @rbs return: ACP::RequestError
|
|
78
|
+
def self.resource_not_found(uri = nil)
|
|
79
|
+
new(code: RESOURCE_NOT_FOUND, message: 'Resource not found', data: uri && { uri: uri })
|
|
80
|
+
end
|
|
81
|
+
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:)
|