ask-acp 0.1.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 +7 -0
- data/lib/ask/acp/client.rb +288 -0
- data/lib/ask/acp/protocol.rb +108 -0
- data/lib/ask/acp/replay_client.rb +170 -0
- data/lib/ask/acp/server.rb +160 -0
- data/lib/ask/acp/version.rb +7 -0
- data/lib/ask/acp.rb +14 -0
- data/lib/ask-acp.rb +3 -0
- metadata +119 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 847f6fef76788fc8ddb79a41f6d071ed5f9c62ea05e191b713a628afe0325614
|
|
4
|
+
data.tar.gz: 81692ef719aff5a322b5fa9ae34dffad897d93f1b7c43acc661e369bdb573858
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 36000307696429f9d664b2d0e3d6e750ac146089b7f7dcf8d94fafd7392dc332e58973f824fc19d182d13ed7391abb1f89a6153c7e5a27d800c100f71fac26e8
|
|
7
|
+
data.tar.gz: e6ec8427722c7444cb2e9b1d2a06538d924f49530bf48795b719233d9372cf0a5e298c40ac4a807afd0e22883e4059b5a3368bf9964af8e6f618965b561a0c72
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "open3"
|
|
5
|
+
|
|
6
|
+
module Ask
|
|
7
|
+
module ACP
|
|
8
|
+
# ACP client that connects to a coding agent over stdio using JSON-RPC 2.0.
|
|
9
|
+
#
|
|
10
|
+
# Spawns the agent CLI as a subprocess and communicates via stdin/stdout.
|
|
11
|
+
# Supports all ACP methods: initialize, session/new, session/prompt, etc.
|
|
12
|
+
#
|
|
13
|
+
# @example
|
|
14
|
+
# client = Ask::ACP::Client.new(command: ["codex", "acp"])
|
|
15
|
+
# client.start
|
|
16
|
+
# result = client.initialize!(name: "askoda", version: "0.1.0")
|
|
17
|
+
# session = client.session_new(cwd: "/tmp")
|
|
18
|
+
# client.session_prompt(session[:id], "Hello") { |event| puts event }
|
|
19
|
+
# client.stop
|
|
20
|
+
class Client
|
|
21
|
+
attr_reader :command, :running
|
|
22
|
+
|
|
23
|
+
def initialize(command:, request_timeout: 30.0)
|
|
24
|
+
@command = command
|
|
25
|
+
@request_timeout = request_timeout
|
|
26
|
+
@stdin = nil
|
|
27
|
+
@next_id = 1
|
|
28
|
+
@pending = {}
|
|
29
|
+
@event_handlers = []
|
|
30
|
+
@running = false
|
|
31
|
+
@mutex = Mutex.new
|
|
32
|
+
@initialized = false
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Start the agent subprocess and begin reading stdout.
|
|
36
|
+
def start
|
|
37
|
+
@mutex.synchronize do
|
|
38
|
+
return if @running
|
|
39
|
+
@stdin, stdout, stderr, @wait_thr = Open3.popen3(*@command)
|
|
40
|
+
|
|
41
|
+
@stdout_thread = Thread.new(stdout) do |io|
|
|
42
|
+
io.each_line do |line|
|
|
43
|
+
line = line.strip
|
|
44
|
+
next if line.empty?
|
|
45
|
+
handle_message(Protocol.parse(line))
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Drain stderr to prevent deadlocks
|
|
50
|
+
Thread.new(stderr) { |io| io.each_line { |_| } }
|
|
51
|
+
|
|
52
|
+
@running = true
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Stop the agent subprocess.
|
|
57
|
+
def stop
|
|
58
|
+
@mutex.synchronize do
|
|
59
|
+
return unless @running
|
|
60
|
+
@running = false
|
|
61
|
+
@initialized = false
|
|
62
|
+
@stdin&.close rescue nil
|
|
63
|
+
@stdout_thread&.join(3) rescue nil
|
|
64
|
+
Process.kill("TERM", @wait_thr.pid) rescue nil
|
|
65
|
+
@wait_thr&.join(5) rescue nil
|
|
66
|
+
@stdin = nil
|
|
67
|
+
@pending.each_value { |f| f[:error] = Error.new("process exited"); f[:done] = true; f[:condition].signal }
|
|
68
|
+
@pending.clear
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def running?
|
|
73
|
+
@running && @wait_thr&.alive?
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Register a handler for incoming notifications (client methods from agent).
|
|
77
|
+
def on_notification(&handler)
|
|
78
|
+
@mutex.synchronize { @event_handlers << handler }
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# ── ACP Methods ──
|
|
82
|
+
|
|
83
|
+
# Initialize handshake. Must be called first.
|
|
84
|
+
def initialize!(client_name:, client_version:, capabilities: {})
|
|
85
|
+
result = request(Protocol::AGENT_METHODS[:initialize], {
|
|
86
|
+
protocolVersion: Protocol::PROTOCOL_VERSION,
|
|
87
|
+
clientInfo: { name: client_name, version: client_version },
|
|
88
|
+
capabilities: capabilities
|
|
89
|
+
})
|
|
90
|
+
@initialized = true
|
|
91
|
+
result
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Authenticate with the agent (if required).
|
|
95
|
+
def authenticate(token:, scheme: "bearer")
|
|
96
|
+
request(Protocol::AGENT_METHODS[:authenticate], {
|
|
97
|
+
credentials: { scheme: scheme, token: token }
|
|
98
|
+
})
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Create a new session in the given working directory.
|
|
102
|
+
# Returns { id:, status:, created_at: }.
|
|
103
|
+
def session_new(cwd: ".", model: nil, tools: nil)
|
|
104
|
+
ensure_initialized!
|
|
105
|
+
params = { cwd: cwd, mcpServers: [] }
|
|
106
|
+
params[:model] = model if model
|
|
107
|
+
result = request(Protocol::AGENT_METHODS[:session_new], params)
|
|
108
|
+
normalize_session(result)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Load an existing session by ID.
|
|
112
|
+
def session_load(session_id)
|
|
113
|
+
ensure_initialized!
|
|
114
|
+
result = request(Protocol::AGENT_METHODS[:session_load], { sessionId: session_id })
|
|
115
|
+
normalize_session(result)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# List sessions.
|
|
119
|
+
def session_list(cwd: nil)
|
|
120
|
+
ensure_initialized!
|
|
121
|
+
params = {}
|
|
122
|
+
params[:cwd] = cwd if cwd
|
|
123
|
+
result = request(Protocol::AGENT_METHODS[:session_list], params)
|
|
124
|
+
(result["sessions"] || result[:sessions] || []).map { |s| normalize_session(s) }
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Fork a session from an existing one.
|
|
128
|
+
def session_fork(session_id)
|
|
129
|
+
ensure_initialized!
|
|
130
|
+
result = request(Protocol::AGENT_METHODS[:session_fork], { sessionId: session_id })
|
|
131
|
+
normalize_session(result)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Resume a session.
|
|
135
|
+
def session_resume(session_id)
|
|
136
|
+
ensure_initialized!
|
|
137
|
+
result = request(Protocol::AGENT_METHODS[:session_resume], { sessionId: session_id })
|
|
138
|
+
normalize_session(result)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Close a session.
|
|
142
|
+
def session_close(session_id)
|
|
143
|
+
ensure_initialized!
|
|
144
|
+
request(Protocol::AGENT_METHODS[:session_close], { sessionId: session_id })
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Send a prompt to a session and stream events via the block.
|
|
148
|
+
# The prompt is automatically wrapped in a ContentBlock array if given
|
|
149
|
+
# as a plain string.
|
|
150
|
+
#
|
|
151
|
+
# @yield [Hash] event with :method and :params
|
|
152
|
+
# @return [Hash] the final session/prompt response
|
|
153
|
+
def session_prompt(session_id, prompt, timeout: nil, &block)
|
|
154
|
+
ensure_initialized!
|
|
155
|
+
prompt_blocks = prompt.is_a?(Array) ? prompt : [{ type: "text", text: prompt.to_s }]
|
|
156
|
+
params = { sessionId: session_id, prompt: prompt_blocks }
|
|
157
|
+
if block
|
|
158
|
+
# Register a temporary handler for streaming events
|
|
159
|
+
handler = ->(msg) { block.call(method: msg["method"], params: msg["params"] || {}) if msg["method"] }
|
|
160
|
+
on_notification(&handler)
|
|
161
|
+
begin
|
|
162
|
+
request(Protocol::AGENT_METHODS[:session_prompt], params, timeout: timeout)
|
|
163
|
+
ensure
|
|
164
|
+
@mutex.synchronize { @event_handlers.delete(handler) }
|
|
165
|
+
end
|
|
166
|
+
else
|
|
167
|
+
request(Protocol::AGENT_METHODS[:session_prompt], params, timeout: timeout)
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Cancel the current prompt execution.
|
|
172
|
+
def session_cancel(session_id)
|
|
173
|
+
ensure_initialized!
|
|
174
|
+
request(Protocol::AGENT_METHODS[:session_cancel], { sessionId: session_id })
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# Set a config option on a session.
|
|
178
|
+
def session_set_config_option(session_id, key, value)
|
|
179
|
+
ensure_initialized!
|
|
180
|
+
request(Protocol::AGENT_METHODS[:session_set_config_option], {
|
|
181
|
+
sessionId: session_id, key: key, value: value
|
|
182
|
+
})
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# Set the mode on a session.
|
|
186
|
+
def session_set_mode(session_id, mode)
|
|
187
|
+
ensure_initialized!
|
|
188
|
+
request(Protocol::AGENT_METHODS[:session_set_mode], {
|
|
189
|
+
sessionId: session_id, mode: mode
|
|
190
|
+
})
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# Set the model on a session.
|
|
194
|
+
def session_set_model(session_id, provider:, model:)
|
|
195
|
+
ensure_initialized!
|
|
196
|
+
request(Protocol::AGENT_METHODS[:session_set_model], {
|
|
197
|
+
sessionId: session_id, provider: provider, model: model
|
|
198
|
+
})
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# Send a JSON-RPC request and wait for the response.
|
|
202
|
+
def request(method, params = nil, timeout: nil)
|
|
203
|
+
ensure_running!
|
|
204
|
+
future = { done: false, result: nil, error: nil, condition: ConditionVariable.new }
|
|
205
|
+
|
|
206
|
+
@mutex.synchronize do
|
|
207
|
+
id = @next_id
|
|
208
|
+
@next_id += 1
|
|
209
|
+
@pending[id] = future
|
|
210
|
+
write(Protocol.build_request(method, params, id: id))
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
timeout ||= @request_timeout
|
|
214
|
+
deadline = Time.now + timeout
|
|
215
|
+
|
|
216
|
+
@mutex.synchronize do
|
|
217
|
+
until future[:done]
|
|
218
|
+
remaining = deadline - Time.now
|
|
219
|
+
raise TimeoutError, "Request timed out after #{timeout}s" if remaining <= 0
|
|
220
|
+
future[:condition].wait(@mutex, remaining)
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
raise future[:error] if future[:error]
|
|
225
|
+
future[:result]
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
private
|
|
229
|
+
|
|
230
|
+
def ensure_running!
|
|
231
|
+
raise Error, "ACP client not started. Call #start first." unless running?
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def ensure_initialized!
|
|
235
|
+
ensure_running!
|
|
236
|
+
raise Error, "ACP not initialized. Call #initialize! first." unless @initialized
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def write(msg)
|
|
240
|
+
@stdin&.puts(JSON.generate(msg))
|
|
241
|
+
@stdin&.flush
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def handle_message(msg)
|
|
245
|
+
return unless msg
|
|
246
|
+
|
|
247
|
+
# Response to a pending request
|
|
248
|
+
if msg.key?("id")
|
|
249
|
+
rid = msg["id"]
|
|
250
|
+
future = nil
|
|
251
|
+
@mutex.synchronize { future = @pending.delete(rid) }
|
|
252
|
+
|
|
253
|
+
if future
|
|
254
|
+
@mutex.synchronize do
|
|
255
|
+
if msg.key?("error")
|
|
256
|
+
err = msg["error"]
|
|
257
|
+
future[:error] = Error.new("[#{err["code"]}] #{err["message"]}")
|
|
258
|
+
elsif msg.key?("result")
|
|
259
|
+
future[:result] = msg["result"]
|
|
260
|
+
else
|
|
261
|
+
future[:error] = Error.new("No result or error")
|
|
262
|
+
end
|
|
263
|
+
future[:done] = true
|
|
264
|
+
future[:condition].signal
|
|
265
|
+
end
|
|
266
|
+
return
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
# Notification or event (no id or unmatched id)
|
|
271
|
+
dispatch_event(msg)
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
def dispatch_event(msg)
|
|
275
|
+
@event_handlers.dup.each { |h| h.call(msg) rescue nil }
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def normalize_session(result)
|
|
279
|
+
s = result["session"] || result[:session] || result
|
|
280
|
+
{
|
|
281
|
+
id: s["id"] || s["sessionId"],
|
|
282
|
+
status: s["status"] || "unknown",
|
|
283
|
+
created_at: s["createdAt"] || s["created_at"]
|
|
284
|
+
}
|
|
285
|
+
end
|
|
286
|
+
end
|
|
287
|
+
end
|
|
288
|
+
end
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Ask
|
|
6
|
+
module ACP
|
|
7
|
+
# Protocol constants and message helpers for the Agent Client Protocol (ACP).
|
|
8
|
+
#
|
|
9
|
+
# ACP uses JSON-RPC 2.0 over stdio (newline-delimited JSON).
|
|
10
|
+
# Protocol version: 1 (v0.11.3 schema)
|
|
11
|
+
module Protocol
|
|
12
|
+
PROTOCOL_VERSION = 1
|
|
13
|
+
|
|
14
|
+
# Agent methods (client → agent)
|
|
15
|
+
AGENT_METHODS = {
|
|
16
|
+
initialize: "initialize",
|
|
17
|
+
authenticate: "authenticate",
|
|
18
|
+
logout: "logout",
|
|
19
|
+
session_cancel: "session/cancel",
|
|
20
|
+
session_close: "session/close",
|
|
21
|
+
session_fork: "session/fork",
|
|
22
|
+
session_list: "session/list",
|
|
23
|
+
session_load: "session/load",
|
|
24
|
+
session_new: "session/new",
|
|
25
|
+
session_prompt: "session/prompt",
|
|
26
|
+
session_resume: "session/resume",
|
|
27
|
+
session_set_config_option: "session/set_config_option",
|
|
28
|
+
session_set_mode: "session/set_mode",
|
|
29
|
+
session_set_model: "session/set_model"
|
|
30
|
+
}.freeze
|
|
31
|
+
|
|
32
|
+
# Client methods (agent → client, for tool execution and elicitation)
|
|
33
|
+
CLIENT_METHODS = {
|
|
34
|
+
fs_read_text_file: "fs/read_text_file",
|
|
35
|
+
fs_write_text_file: "fs/write_text_file",
|
|
36
|
+
session_elicitation: "session/elicitation",
|
|
37
|
+
session_elicitation_complete: "session/elicitation/complete",
|
|
38
|
+
session_request_permission: "session/request_permission",
|
|
39
|
+
session_update: "session/update",
|
|
40
|
+
terminal_create: "terminal/create",
|
|
41
|
+
terminal_kill: "terminal/kill",
|
|
42
|
+
terminal_output: "terminal/output",
|
|
43
|
+
terminal_release: "terminal/release",
|
|
44
|
+
terminal_wait_for_exit: "terminal/wait_for_exit"
|
|
45
|
+
}.freeze
|
|
46
|
+
|
|
47
|
+
# Prompt response event types (streamed from session/prompt)
|
|
48
|
+
PROMPT_EVENTS = {
|
|
49
|
+
text: "text",
|
|
50
|
+
tool_use: "tool_use",
|
|
51
|
+
tool_result: "tool_result",
|
|
52
|
+
turn_complete: "turn_complete",
|
|
53
|
+
turn_failed: "turn_failed",
|
|
54
|
+
session_update: "session/update",
|
|
55
|
+
permission_request: "session/request_permission",
|
|
56
|
+
elicitation: "session/elicitation"
|
|
57
|
+
}.freeze
|
|
58
|
+
|
|
59
|
+
STATUSES = {
|
|
60
|
+
completed: "completed",
|
|
61
|
+
failed: "failed",
|
|
62
|
+
cancelled: "cancelled",
|
|
63
|
+
in_progress: "in_progress"
|
|
64
|
+
}.freeze
|
|
65
|
+
|
|
66
|
+
# Build a JSON-RPC 2.0 request.
|
|
67
|
+
def self.build_request(method, params = nil, id: nil)
|
|
68
|
+
msg = { jsonrpc: "2.0", method: method }
|
|
69
|
+
msg[:id] = id
|
|
70
|
+
msg[:params] = params if params
|
|
71
|
+
msg
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Build a JSON-RPC 2.0 notification (no id).
|
|
75
|
+
def self.build_notification(method, params = nil)
|
|
76
|
+
msg = { jsonrpc: "2.0", method: method }
|
|
77
|
+
msg[:params] = params if params
|
|
78
|
+
msg
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Build a JSON-RPC 2.0 success response.
|
|
82
|
+
def self.build_response(id, result)
|
|
83
|
+
{ jsonrpc: "2.0", id: id, result: result }
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Build a JSON-RPC 2.0 error response.
|
|
87
|
+
def self.build_error(id, code, message, data: nil)
|
|
88
|
+
err = { code: code, message: message }
|
|
89
|
+
err[:data] = data if data
|
|
90
|
+
{ jsonrpc: "2.0", id: id, error: err }
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Parse a JSON-RPC message from a line.
|
|
94
|
+
# Returns a hash with string keys, or nil if invalid.
|
|
95
|
+
def self.parse(line)
|
|
96
|
+
return nil if line.nil? || line.strip.empty?
|
|
97
|
+
JSON.parse(line)
|
|
98
|
+
rescue JSON::ParserError
|
|
99
|
+
nil
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Serialize a message to a JSON line (with newline).
|
|
103
|
+
def self.serialize(msg)
|
|
104
|
+
JSON.generate(msg) + "\n"
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Ask
|
|
6
|
+
module ACP
|
|
7
|
+
# ACP client that replays pre-recorded interactions from a fixture file.
|
|
8
|
+
#
|
|
9
|
+
# Like VCR for stdio — no subprocess, instant response, deterministic.
|
|
10
|
+
# Records are newline-delimited JSON with format:
|
|
11
|
+
# {"request": {...}} ← request sent to agent
|
|
12
|
+
# {"response": {...}} ← response from agent (or mock)
|
|
13
|
+
# {"notification": {...}} ← async notification from agent
|
|
14
|
+
#
|
|
15
|
+
# @example
|
|
16
|
+
# client = Ask::ACP::ReplayClient.new(fixture: "test/fixtures/opencode_session.jsonl")
|
|
17
|
+
# client.start
|
|
18
|
+
# client.initialize!(client_name: "test", client_version: "0.1.0")
|
|
19
|
+
class ReplayClient
|
|
20
|
+
attr_reader :fixture_path, :running
|
|
21
|
+
|
|
22
|
+
def initialize(fixture_path:)
|
|
23
|
+
@fixture_path = fixture_path
|
|
24
|
+
@events = []
|
|
25
|
+
@index = 0
|
|
26
|
+
@running = false
|
|
27
|
+
@initialized = false
|
|
28
|
+
@event_handlers = []
|
|
29
|
+
@pending = {}
|
|
30
|
+
@next_id = 1
|
|
31
|
+
@mutex = Mutex.new
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def start
|
|
35
|
+
load_fixture
|
|
36
|
+
@running = true
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def stop
|
|
40
|
+
@running = false
|
|
41
|
+
@initialized = false
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def running?
|
|
45
|
+
@running
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def on_notification(&handler)
|
|
49
|
+
@mutex.synchronize { @event_handlers << handler }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# ── ACP Methods (same interface as Client) ──
|
|
53
|
+
|
|
54
|
+
def initialize!(client_name:, client_version:, capabilities: {})
|
|
55
|
+
@initialized = true
|
|
56
|
+
make_request("initialize", {
|
|
57
|
+
protocolVersion: 1, clientInfo: { name: client_name, version: client_version }, capabilities: capabilities
|
|
58
|
+
})
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def session_new(cwd: ".", model: nil)
|
|
62
|
+
ensure_initialized!
|
|
63
|
+
params = { cwd: cwd, mcpServers: [] }
|
|
64
|
+
params[:model] = model if model
|
|
65
|
+
normalize_session(make_request(Protocol::AGENT_METHODS[:session_new], params))
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def session_load(session_id)
|
|
69
|
+
ensure_initialized!
|
|
70
|
+
result = make_request(Protocol::AGENT_METHODS[:session_load], { sessionId: session_id })
|
|
71
|
+
normalize_session(result)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def session_list(cwd: nil)
|
|
75
|
+
ensure_initialized!
|
|
76
|
+
params = {}
|
|
77
|
+
params[:cwd] = cwd if cwd
|
|
78
|
+
result = make_request(Protocol::AGENT_METHODS[:session_list], params)
|
|
79
|
+
(result["sessions"] || result[:sessions] || []).map { |s| normalize_session(s) }
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def session_resume(session_id)
|
|
83
|
+
ensure_initialized!
|
|
84
|
+
result = make_request(Protocol::AGENT_METHODS[:session_resume], { sessionId: session_id })
|
|
85
|
+
normalize_session(result)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def session_close(session_id)
|
|
89
|
+
ensure_initialized!
|
|
90
|
+
make_request(Protocol::AGENT_METHODS[:session_close], { sessionId: session_id })
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def session_prompt(session_id, prompt, timeout: nil, &block)
|
|
94
|
+
ensure_initialized!
|
|
95
|
+
prompt_blocks = prompt.is_a?(Array) ? prompt : [{ type: "text", text: prompt.to_s }]
|
|
96
|
+
params = { sessionId: session_id, prompt: prompt_blocks }
|
|
97
|
+
|
|
98
|
+
# Dispatch any notifications before the response
|
|
99
|
+
while @index < @events.length
|
|
100
|
+
event = @events[@index]
|
|
101
|
+
break if event.key?("response")
|
|
102
|
+
if event.key?("notification")
|
|
103
|
+
dispatch_event(event["notification"])
|
|
104
|
+
end
|
|
105
|
+
@index += 1
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Get the response
|
|
109
|
+
event = @events[@index]
|
|
110
|
+
@index += 1
|
|
111
|
+
if event && event["response"]
|
|
112
|
+
result = event["response"]["result"]
|
|
113
|
+
error = event["response"]["error"]
|
|
114
|
+
raise Error.new("[#{error["code"]}] #{error["message"]}") if error
|
|
115
|
+
result
|
|
116
|
+
else
|
|
117
|
+
{ "status" => "completed" }
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
private
|
|
122
|
+
|
|
123
|
+
def ensure_initialized!
|
|
124
|
+
raise Error, "ReplayClient not started. Call #start first." unless @running
|
|
125
|
+
raise Error, "Not initialized. Call #initialize! first." unless @initialized
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def load_fixture
|
|
129
|
+
@events = []
|
|
130
|
+
File.readlines(@fixture_path).each do |line|
|
|
131
|
+
line = line.strip
|
|
132
|
+
next if line.empty?
|
|
133
|
+
@events << JSON.parse(line)
|
|
134
|
+
end
|
|
135
|
+
@index = 0
|
|
136
|
+
rescue => e
|
|
137
|
+
raise Error, "Failed to load fixture #{@fixture_path}: #{e.message}"
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def make_request(method, params)
|
|
141
|
+
# Look for the next response event matching this method
|
|
142
|
+
while @index < @events.length
|
|
143
|
+
event = @events[@index]
|
|
144
|
+
@index += 1
|
|
145
|
+
|
|
146
|
+
if event["response"]
|
|
147
|
+
result = event["response"]["result"]
|
|
148
|
+
error = event["response"]["error"]
|
|
149
|
+
raise Error.new("[#{error["code"]}] #{error["message"]}") if error
|
|
150
|
+
return result
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
{}
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def dispatch_event(msg)
|
|
157
|
+
@event_handlers.dup.each { |h| h.call(msg) rescue nil }
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def normalize_session(result)
|
|
161
|
+
s = result["session"] || result[:session] || result
|
|
162
|
+
{
|
|
163
|
+
id: s["id"] || s["sessionId"],
|
|
164
|
+
status: s["status"] || "unknown",
|
|
165
|
+
created_at: s["createdAt"] || s["created_at"]
|
|
166
|
+
}
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Ask
|
|
6
|
+
module ACP
|
|
7
|
+
# ACP server that lets a Ruby agent speak the Agent Client Protocol over stdio.
|
|
8
|
+
#
|
|
9
|
+
# Handles the JSON-RPC 2.0 transport, method dispatch, and streaming responses.
|
|
10
|
+
# Subclass and implement the method handlers you need.
|
|
11
|
+
#
|
|
12
|
+
# @example
|
|
13
|
+
# class MyAgent < Ask::ACP::Server
|
|
14
|
+
# def handle_initialize(params)
|
|
15
|
+
# { protocolVersion: 1, serverInfo: { name: "my-agent", version: "0.1.0" } }
|
|
16
|
+
# end
|
|
17
|
+
#
|
|
18
|
+
# def handle_session_new(params)
|
|
19
|
+
# { session: { id: SecureRandom.uuid, status: "running" } }
|
|
20
|
+
# end
|
|
21
|
+
# end
|
|
22
|
+
#
|
|
23
|
+
# MyAgent.new.run
|
|
24
|
+
class Server
|
|
25
|
+
def initialize
|
|
26
|
+
@methods = {}
|
|
27
|
+
register_defaults
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Start reading from stdin and processing requests.
|
|
31
|
+
def run
|
|
32
|
+
$stdin.each_line do |line|
|
|
33
|
+
line = line.strip
|
|
34
|
+
next if line.empty?
|
|
35
|
+
handle(Protocol.parse(line))
|
|
36
|
+
end
|
|
37
|
+
rescue => e
|
|
38
|
+
$stderr.puts "ACP server error: #{e.message}"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def register_defaults
|
|
44
|
+
register("initialize") do |params|
|
|
45
|
+
{
|
|
46
|
+
protocolVersion: Protocol::PROTOCOL_VERSION,
|
|
47
|
+
capabilities: {},
|
|
48
|
+
serverInfo: { name: "ask-acp", version: VERSION }
|
|
49
|
+
}
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
register("session/new") do |params|
|
|
53
|
+
handle_session_new(params)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
register("session/load") do |params|
|
|
57
|
+
handle_session_load(params)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
register("session/list") do |params|
|
|
61
|
+
handle_session_list(params)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
register("session/resume") do |params|
|
|
65
|
+
handle_session_resume(params)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
register("session/close") do |params|
|
|
69
|
+
handle_session_close(params)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
register("session/prompt") do |params|
|
|
73
|
+
handle_session_prompt(params)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
register("session/cancel") do |params|
|
|
77
|
+
handle_session_cancel(params)
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Default handlers — override in subclasses.
|
|
82
|
+
|
|
83
|
+
def handle_initialize(params)
|
|
84
|
+
{ protocolVersion: Protocol::PROTOCOL_VERSION, capabilities: {}, serverInfo: { name: "ask-acp", version: VERSION } }
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def handle_session_new(params)
|
|
88
|
+
require "securerandom"
|
|
89
|
+
id = SecureRandom.uuid
|
|
90
|
+
{ session: { id: id, status: "running" } }
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def handle_session_load(params)
|
|
94
|
+
{ session: { id: params["sessionId"], status: "running" } }
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def handle_session_list(params)
|
|
98
|
+
{ sessions: [] }
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def handle_session_resume(params)
|
|
102
|
+
{ session: { id: params["sessionId"], status: "running" } }
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def handle_session_close(params)
|
|
106
|
+
{}
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def handle_session_prompt(params)
|
|
110
|
+
# Default: stream back a completion event immediately
|
|
111
|
+
send_text_delta(params["sessionId"], "Hello from ACP server!")
|
|
112
|
+
send_event("turn_complete", { sessionId: params["sessionId"] })
|
|
113
|
+
{ status: "completed" }
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def handle_session_cancel(params)
|
|
117
|
+
{}
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def register(method, &handler)
|
|
121
|
+
@methods[method] = handler
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def handle(msg)
|
|
125
|
+
return unless msg
|
|
126
|
+
|
|
127
|
+
if msg.key?("id")
|
|
128
|
+
id = msg["id"]
|
|
129
|
+
method = msg["method"]
|
|
130
|
+
params = msg["params"] || {}
|
|
131
|
+
|
|
132
|
+
handler = @methods[method]
|
|
133
|
+
if handler
|
|
134
|
+
begin
|
|
135
|
+
result = handler.call(params)
|
|
136
|
+
write(Protocol.build_response(id, result))
|
|
137
|
+
rescue => e
|
|
138
|
+
write(Protocol.build_error(id, -32_600, e.message))
|
|
139
|
+
end
|
|
140
|
+
else
|
|
141
|
+
write(Protocol.build_error(id, -32_601, "Unknown method: #{method}"))
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def write(msg)
|
|
147
|
+
$stdout.puts(JSON.generate(msg))
|
|
148
|
+
$stdout.flush
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def send_event(method, params)
|
|
152
|
+
write(Protocol.build_notification(method, params))
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def send_text_delta(session_id, content)
|
|
156
|
+
send_event("text", { sessionId: session_id, content: content })
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
data/lib/ask/acp.rb
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "acp/version"
|
|
4
|
+
require_relative "acp/protocol"
|
|
5
|
+
require_relative "acp/client"
|
|
6
|
+
require_relative "acp/replay_client"
|
|
7
|
+
require_relative "acp/server"
|
|
8
|
+
|
|
9
|
+
module Ask
|
|
10
|
+
module ACP
|
|
11
|
+
class Error < StandardError; end
|
|
12
|
+
class TimeoutError < Error; end
|
|
13
|
+
end
|
|
14
|
+
end
|
data/lib/ask-acp.rb
ADDED
metadata
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: ask-acp
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Kaka Ruto
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: json
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '0'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - ">="
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '0'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: minitest
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - "~>"
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '5.25'
|
|
33
|
+
type: :development
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - "~>"
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '5.25'
|
|
40
|
+
- !ruby/object:Gem::Dependency
|
|
41
|
+
name: mocha
|
|
42
|
+
requirement: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - "~>"
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '3.1'
|
|
47
|
+
type: :development
|
|
48
|
+
prerelease: false
|
|
49
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - "~>"
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '3.1'
|
|
54
|
+
- !ruby/object:Gem::Dependency
|
|
55
|
+
name: rake
|
|
56
|
+
requirement: !ruby/object:Gem::Requirement
|
|
57
|
+
requirements:
|
|
58
|
+
- - "~>"
|
|
59
|
+
- !ruby/object:Gem::Version
|
|
60
|
+
version: '13.0'
|
|
61
|
+
type: :development
|
|
62
|
+
prerelease: false
|
|
63
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
64
|
+
requirements:
|
|
65
|
+
- - "~>"
|
|
66
|
+
- !ruby/object:Gem::Version
|
|
67
|
+
version: '13.0'
|
|
68
|
+
- !ruby/object:Gem::Dependency
|
|
69
|
+
name: simplecov
|
|
70
|
+
requirement: !ruby/object:Gem::Requirement
|
|
71
|
+
requirements:
|
|
72
|
+
- - "~>"
|
|
73
|
+
- !ruby/object:Gem::Version
|
|
74
|
+
version: '0.22'
|
|
75
|
+
type: :development
|
|
76
|
+
prerelease: false
|
|
77
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
78
|
+
requirements:
|
|
79
|
+
- - "~>"
|
|
80
|
+
- !ruby/object:Gem::Version
|
|
81
|
+
version: '0.22'
|
|
82
|
+
description: Ruby implementation of the Agent Client Protocol (ACP) — a JSON-RPC standard
|
|
83
|
+
for agent-editor communication. Includes client (connect to ACP agents), server
|
|
84
|
+
(host Ruby agents via ACP), and transport layer.
|
|
85
|
+
email:
|
|
86
|
+
- kaka@myrrlabs.com
|
|
87
|
+
executables: []
|
|
88
|
+
extensions: []
|
|
89
|
+
extra_rdoc_files: []
|
|
90
|
+
files:
|
|
91
|
+
- lib/ask-acp.rb
|
|
92
|
+
- lib/ask/acp.rb
|
|
93
|
+
- lib/ask/acp/client.rb
|
|
94
|
+
- lib/ask/acp/protocol.rb
|
|
95
|
+
- lib/ask/acp/replay_client.rb
|
|
96
|
+
- lib/ask/acp/server.rb
|
|
97
|
+
- lib/ask/acp/version.rb
|
|
98
|
+
homepage: https://github.com/ask-rb/ask-acp
|
|
99
|
+
licenses:
|
|
100
|
+
- MIT
|
|
101
|
+
metadata: {}
|
|
102
|
+
rdoc_options: []
|
|
103
|
+
require_paths:
|
|
104
|
+
- lib
|
|
105
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
106
|
+
requirements:
|
|
107
|
+
- - ">="
|
|
108
|
+
- !ruby/object:Gem::Version
|
|
109
|
+
version: '3.2'
|
|
110
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
111
|
+
requirements:
|
|
112
|
+
- - ">="
|
|
113
|
+
- !ruby/object:Gem::Version
|
|
114
|
+
version: '0'
|
|
115
|
+
requirements: []
|
|
116
|
+
rubygems_version: 4.0.3
|
|
117
|
+
specification_version: 4
|
|
118
|
+
summary: Agent Client Protocol for Ruby
|
|
119
|
+
test_files: []
|