ask-app-server 0.1.2 → 0.4.2
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 +99 -0
- data/README.md +60 -23
- data/lib/ask/app_server/agent_adapter.rb +239 -57
- data/lib/ask/app_server/cli.rb +31 -16
- data/lib/ask/app_server/connection.rb +73 -0
- data/lib/ask/app_server/event_translator.rb +173 -117
- data/lib/ask/app_server/herdr_reporter.rb +201 -0
- data/lib/ask/app_server/server.rb +333 -185
- data/lib/ask/app_server/session_manager.rb +166 -80
- data/lib/ask/app_server/socket_server.rb +116 -0
- data/lib/ask/app_server/version.rb +1 -1
- data/lib/ask/app_server.rb +2 -0
- data/lib/ask-app-server.rb +4 -0
- metadata +22 -5
|
@@ -5,26 +5,47 @@ require "time"
|
|
|
5
5
|
|
|
6
6
|
module Ask
|
|
7
7
|
module AppServer
|
|
8
|
-
# JSON-RPC
|
|
8
|
+
# JSON-RPC protocol engine implementing the canonical
|
|
9
|
+
# Ask::SessionProtocol. The session host: owns agent sessions behind one
|
|
10
|
+
# versioned contract that every client (terminal TUI, web console, bots,
|
|
11
|
+
# IDE) speaks.
|
|
9
12
|
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
13
|
+
# The engine is transport-agnostic: messages arrive per-connection
|
|
14
|
+
# ({#dispatch}) and responses/notifications are written back to the
|
|
15
|
+
# requesting connection. Event delivery is cursor-based — each
|
|
16
|
+
# connection tracks the last delivered seq per subscribed session
|
|
17
|
+
# ({Connection}), so any number of clients can attach to the same
|
|
18
|
+
# sessions. Two transports use the engine:
|
|
12
19
|
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
# session/send, session/events, session/abort, workspace/readState
|
|
20
|
+
# Server#start — stdio mode (one Connection over stdin/stdout)
|
|
21
|
+
# SocketServer — unix socket mode (a Connection per client)
|
|
16
22
|
#
|
|
17
|
-
#
|
|
18
|
-
# session/
|
|
23
|
+
# Client → host methods (see Ask::SessionProtocol::Methods):
|
|
24
|
+
# ping, initialize, session/create, session/list, session/resume,
|
|
25
|
+
# session/subscribe, session/events, session/send, session/abort,
|
|
26
|
+
# session/close, session/artifacts, session/artifact/get,
|
|
27
|
+
# interaction/list, interaction/approve, interaction/reject,
|
|
28
|
+
# interaction/approve-all, interaction/reject-all,
|
|
29
|
+
# interaction/respond, plan/approve, plan/reject, workspace/readState
|
|
19
30
|
#
|
|
20
|
-
#
|
|
21
|
-
#
|
|
31
|
+
# Host → client notifications:
|
|
32
|
+
# session/event — carries a canonical event envelope {type, seq, payload}
|
|
33
|
+
#
|
|
34
|
+
# The engine also handles incoming responses to its outgoing reverse
|
|
35
|
+
# requests (interaction/requestPermission, interaction/requestUserInput
|
|
36
|
+
# — the app-server interop surface).
|
|
22
37
|
class Server
|
|
38
|
+
# Host capabilities advertised in `initialize`. The host emits every
|
|
39
|
+
# canonical event except file.changed today.
|
|
40
|
+
HOST_CAPABILITIES =
|
|
41
|
+
(Ask::SessionProtocol::Methods::CAPABILITIES - %w[fileEvents]).freeze
|
|
42
|
+
|
|
23
43
|
def initialize(session_manager: nil)
|
|
24
44
|
@session_manager = session_manager || SessionManager.new
|
|
25
45
|
@running = false
|
|
26
46
|
@started_at = nil
|
|
27
|
-
@
|
|
47
|
+
@connections = []
|
|
48
|
+
@connections_mutex = Mutex.new
|
|
28
49
|
@response_handlers = {} # outgoing request_id => Proc
|
|
29
50
|
@outgoing_id = 0
|
|
30
51
|
@logger = Logger.new($stdout, level: ENV["DEBUG"] ? Logger::DEBUG : Logger::WARN)
|
|
@@ -34,59 +55,51 @@ module Ask
|
|
|
34
55
|
register_default_handlers
|
|
35
56
|
end
|
|
36
57
|
|
|
37
|
-
|
|
38
|
-
|
|
58
|
+
attr_reader :session_manager
|
|
59
|
+
|
|
60
|
+
# ── Connection registry ────────────────────────────────────────────
|
|
61
|
+
|
|
62
|
+
# All live connections (snapshot).
|
|
63
|
+
def connections
|
|
64
|
+
@connections_mutex.synchronize { @connections.dup }
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Register a connection for dispatch and event delivery.
|
|
68
|
+
def add_connection(connection)
|
|
69
|
+
@connections_mutex.synchronize { @connections << connection }
|
|
70
|
+
connection
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Deregister a connection (client disconnected).
|
|
74
|
+
def remove_connection(connection)
|
|
75
|
+
@connections_mutex.synchronize { @connections.delete(connection) }
|
|
76
|
+
connection.close
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# ── Transports ─────────────────────────────────────────────────────
|
|
80
|
+
|
|
81
|
+
# Start the stdio transport: one Connection over stdin/stdout, a
|
|
82
|
+
# reader thread dispatching messages, and a pusher delivering events.
|
|
83
|
+
# Blocks until the input closes or {#stop} is called.
|
|
39
84
|
def start
|
|
40
85
|
@running = true
|
|
41
86
|
@started_at = Time.now
|
|
87
|
+
connection = add_connection(Connection.new($stdin, $stdout))
|
|
42
88
|
|
|
43
|
-
# Reader thread: reads NDJSON lines from stdin
|
|
44
89
|
@reader = Thread.new do
|
|
45
|
-
|
|
46
|
-
begin
|
|
47
|
-
line = $stdin.gets
|
|
48
|
-
break unless line
|
|
49
|
-
line = line.strip
|
|
50
|
-
next if line.empty?
|
|
51
|
-
|
|
52
|
-
@input_queue << JSON.parse(line)
|
|
53
|
-
rescue JSON::ParserError => e
|
|
54
|
-
send_error(nil, -32700, "Parse error: #{e.message}")
|
|
55
|
-
rescue => e
|
|
56
|
-
@logger.error("Reader error: #{e.message}")
|
|
57
|
-
break
|
|
58
|
-
end
|
|
59
|
-
end
|
|
60
|
-
@input_queue << nil # signal shutdown
|
|
61
|
-
end
|
|
62
|
-
|
|
63
|
-
# Event push thread: polls subscribed sessions and pushes notifications
|
|
64
|
-
@pusher = Thread.new do
|
|
65
|
-
while @running
|
|
66
|
-
push_session_events
|
|
67
|
-
sleep 0.1 # 100ms poll interval
|
|
68
|
-
end
|
|
90
|
+
reader_loop(connection)
|
|
69
91
|
end
|
|
92
|
+
@pusher = Thread.new { pusher_loop }
|
|
70
93
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
msg = @input_queue.pop
|
|
74
|
-
break if msg.nil?
|
|
75
|
-
|
|
76
|
-
handle_message(msg)
|
|
77
|
-
end
|
|
78
|
-
rescue => e
|
|
79
|
-
@logger.error("Server error: #{e.message}")
|
|
80
|
-
raise
|
|
81
|
-
ensure
|
|
82
|
-
@running = false
|
|
83
|
-
@reader&.kill rescue nil
|
|
84
|
-
@pusher&.kill rescue nil
|
|
94
|
+
@reader.join
|
|
95
|
+
stop
|
|
85
96
|
end
|
|
86
97
|
|
|
87
98
|
# Stop the server.
|
|
88
99
|
def stop
|
|
89
100
|
@running = false
|
|
101
|
+
@reader&.kill rescue nil
|
|
102
|
+
@pusher&.kill rescue nil
|
|
90
103
|
end
|
|
91
104
|
|
|
92
105
|
# Whether the server is running.
|
|
@@ -94,52 +107,178 @@ module Ask
|
|
|
94
107
|
@running
|
|
95
108
|
end
|
|
96
109
|
|
|
97
|
-
#
|
|
110
|
+
# ── Dispatch ───────────────────────────────────────────────────────
|
|
111
|
+
|
|
112
|
+
# Process one JSON-RPC message from a connection. Requests are
|
|
113
|
+
# validated against the contract before dispatch; responses and
|
|
114
|
+
# errors are written back to `connection`.
|
|
115
|
+
#
|
|
116
|
+
# @param msg [Hash] parsed JSON-RPC message
|
|
117
|
+
# @param connection [Connection] the sending connection
|
|
118
|
+
def dispatch(msg, connection)
|
|
119
|
+
id = msg["id"] || msg[:id]
|
|
120
|
+
|
|
121
|
+
# Check if this is a response to an outgoing request.
|
|
122
|
+
# A response has an id and a result (or error), but no method.
|
|
123
|
+
if id && !msg.key?("method") && !msg.key?(:method)
|
|
124
|
+
if msg.key?("result") || msg.key?(:result)
|
|
125
|
+
handle_incoming_response(id, msg["result"] || msg[:result])
|
|
126
|
+
return
|
|
127
|
+
elsif msg.key?("error") || msg.key?(:error)
|
|
128
|
+
handle_incoming_response(id, nil, msg["error"] || msg[:error])
|
|
129
|
+
return
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
method = msg["method"] || msg[:method]
|
|
134
|
+
params = (msg["params"] || msg[:params] || {})
|
|
135
|
+
params = params.transform_keys(&:to_s) if params.is_a?(Hash)
|
|
136
|
+
|
|
137
|
+
unless method
|
|
138
|
+
send_error(id, -32600, "Method not specified", connection) if id
|
|
139
|
+
return
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
handler_block = @handlers[method]
|
|
143
|
+
unless handler_block
|
|
144
|
+
send_error(id, -32601, "Method not found: #{method}", connection) if id
|
|
145
|
+
return
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
begin
|
|
149
|
+
validate_request!(method, params)
|
|
150
|
+
result = handler_block.call(params, id, connection)
|
|
151
|
+
send_result(id, result, connection) if id
|
|
152
|
+
rescue Ask::AppServer::SessionNotFound => e
|
|
153
|
+
send_error(id, -32004, e.message, connection) if id
|
|
154
|
+
rescue Ask::AppServer::SessionAlreadyExists => e
|
|
155
|
+
send_error(id, -32005, e.message, connection) if id
|
|
156
|
+
rescue Ask::AppServer::InteractionNotFound => e
|
|
157
|
+
send_error(id, -32006, e.message, connection) if id
|
|
158
|
+
rescue Ask::AppServer::PlanNotFound => e
|
|
159
|
+
send_error(id, -32007, e.message, connection) if id
|
|
160
|
+
rescue Ask::AppServer::InvalidRequest => e
|
|
161
|
+
send_error(id, -32602, e.message, connection) if id
|
|
162
|
+
rescue ArgumentError => e
|
|
163
|
+
# Contract validation failures (invalid params)
|
|
164
|
+
send_error(id, -32602, e.message, connection) if id
|
|
165
|
+
rescue => e
|
|
166
|
+
@logger.error("Handler error for #{method}: #{e.message}")
|
|
167
|
+
send_error(id, -32603, "Internal error: #{e.message}", connection) if id
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Send an outgoing JSON-RPC request to a client (reverse request).
|
|
98
172
|
# If a block is given, it will be called with (result, error) when
|
|
99
|
-
# the client responds.
|
|
100
|
-
def send_request(method, params, &block)
|
|
173
|
+
# the client responds. Defaults to the stdio transport ($stdout).
|
|
174
|
+
def send_request(method, params, connection = nil, &block)
|
|
101
175
|
id = next_outgoing_id
|
|
102
176
|
@response_handlers[id] = block if block
|
|
103
|
-
|
|
177
|
+
if connection
|
|
178
|
+
connection.write({ id: id, method: method, params: params })
|
|
179
|
+
else
|
|
180
|
+
write_line({ id: id, method: method, params: params })
|
|
181
|
+
end
|
|
104
182
|
id
|
|
105
183
|
end
|
|
106
184
|
|
|
107
|
-
#
|
|
108
|
-
|
|
109
|
-
#
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
185
|
+
# ── Event push ─────────────────────────────────────────────────────
|
|
186
|
+
|
|
187
|
+
# One delivery pass: for every subscribed connection, deliver the
|
|
188
|
+
# events its sessions have produced since the connection's cursor,
|
|
189
|
+
# then advance the cursor. Cursor-based, so each client receives
|
|
190
|
+
# exactly the events after its own seq (replay on subscribe).
|
|
191
|
+
def push_pending
|
|
192
|
+
connections.each do |connection|
|
|
193
|
+
connection.subscriptions.keys.each do |session_id|
|
|
194
|
+
adapter = @session_manager.get(session_id)
|
|
195
|
+
next unless adapter
|
|
196
|
+
|
|
197
|
+
events = adapter.events_after(connection.cursor(session_id))
|
|
198
|
+
next if events.empty?
|
|
199
|
+
|
|
200
|
+
events.each do |ev|
|
|
201
|
+
connection.write({ method: "session/event", params: { event: ev.to_h } })
|
|
124
202
|
end
|
|
203
|
+
connection.advance(session_id, events.last.seq)
|
|
125
204
|
end
|
|
126
205
|
end
|
|
206
|
+
rescue => e
|
|
207
|
+
@logger.debug("Push error: #{e.message}") if ENV["DEBUG"]
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# ── Response/notification helpers ──────────────────────────────────
|
|
211
|
+
|
|
212
|
+
def send_result(id, result, connection = nil)
|
|
213
|
+
connection ? connection.write({ id: id, result: result }) : write_line({ id: id, result: result })
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def send_error(id, code, message, connection = nil)
|
|
217
|
+
response = { id: id, error: { code: code, message: message } }
|
|
218
|
+
connection ? connection.write(response) : write_line(response)
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def send_notification(method, params, connection = nil)
|
|
222
|
+
msg = { method: method, params: params }
|
|
223
|
+
connection ? connection.write(msg) : write_line(msg)
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def write_line(msg)
|
|
227
|
+
$stdout.puts(JSON.generate(msg))
|
|
228
|
+
$stdout.flush
|
|
127
229
|
end
|
|
128
230
|
|
|
129
231
|
private
|
|
130
232
|
|
|
233
|
+
def validate_request!(method, params)
|
|
234
|
+
# Host-side contract enforcement: canonical client → host methods
|
|
235
|
+
# validate their params against the protocol registry. Handlers
|
|
236
|
+
# outside the canonical surface (e.g. the requestPermission query)
|
|
237
|
+
# are exempt.
|
|
238
|
+
return unless Ask::SessionProtocol::Methods.known?(method)
|
|
239
|
+
|
|
240
|
+
Ask::SessionProtocol::Methods.validate_params!(method, params)
|
|
241
|
+
end
|
|
242
|
+
|
|
131
243
|
def next_outgoing_id
|
|
132
244
|
@outgoing_id += 1
|
|
133
245
|
# Use IDs starting from a high number to avoid collision with client IDs
|
|
134
246
|
10_000 + @outgoing_id
|
|
135
247
|
end
|
|
136
248
|
|
|
137
|
-
def
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
249
|
+
def reader_loop(connection)
|
|
250
|
+
while @running
|
|
251
|
+
line = connection.read_line
|
|
252
|
+
break unless line
|
|
253
|
+
line = line.strip
|
|
254
|
+
next if line.empty?
|
|
255
|
+
|
|
256
|
+
begin
|
|
257
|
+
dispatch(JSON.parse(line), connection)
|
|
258
|
+
rescue JSON::ParserError => e
|
|
259
|
+
send_error(nil, -32700, "Parse error: #{e.message}", connection)
|
|
260
|
+
rescue => e
|
|
261
|
+
@logger.error("Reader error: #{e.message}")
|
|
262
|
+
break
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
ensure
|
|
266
|
+
remove_connection(connection)
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def pusher_loop
|
|
270
|
+
while @running
|
|
271
|
+
push_pending
|
|
272
|
+
sleep 0.1 # 100ms poll interval
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
def handle_incoming_response(id, result, error = nil)
|
|
277
|
+
handler_block = @response_handlers.delete(id)
|
|
278
|
+
if handler_block
|
|
279
|
+
handler_block.call(result, error)
|
|
280
|
+
else
|
|
281
|
+
@logger.debug("No handler for response #{id}")
|
|
143
282
|
end
|
|
144
283
|
end
|
|
145
284
|
|
|
@@ -148,23 +287,20 @@ module Ask
|
|
|
148
287
|
handler("ping") do |_params, _id|
|
|
149
288
|
{
|
|
150
289
|
status: "ok",
|
|
151
|
-
uptime: @started_at ? (Time.now - @started_at).to_i : 0,
|
|
152
290
|
version: Ask::AppServer::VERSION,
|
|
291
|
+
protocolVersion: Ask::SessionProtocol::PROTOCOL_VERSION,
|
|
292
|
+
uptime: @started_at ? (Time.now - @started_at).to_i : 0,
|
|
153
293
|
sessions: @session_manager&.store&.count || 0
|
|
154
294
|
}
|
|
155
295
|
end
|
|
156
296
|
|
|
157
|
-
# Initialize handshake
|
|
158
|
-
|
|
297
|
+
# Initialize handshake — negotiate the protocol version and
|
|
298
|
+
# exchange capabilities.
|
|
299
|
+
handler("initialize") do |_params, _id|
|
|
159
300
|
{
|
|
160
|
-
protocolVersion:
|
|
161
|
-
capabilities:
|
|
162
|
-
|
|
163
|
-
eventStreaming: true,
|
|
164
|
-
midExecutionInjection: true,
|
|
165
|
-
permissions: true
|
|
166
|
-
},
|
|
167
|
-
serverInfo: {
|
|
301
|
+
protocolVersion: Ask::SessionProtocol::PROTOCOL_VERSION,
|
|
302
|
+
capabilities: HOST_CAPABILITIES,
|
|
303
|
+
server: {
|
|
168
304
|
name: "ask-app-server",
|
|
169
305
|
version: Ask::AppServer::VERSION
|
|
170
306
|
}
|
|
@@ -222,35 +358,36 @@ module Ask
|
|
|
222
358
|
}
|
|
223
359
|
end
|
|
224
360
|
|
|
225
|
-
# Session: subscribe
|
|
226
|
-
|
|
361
|
+
# Session: subscribe — attach to the event stream with replay.
|
|
362
|
+
# The connection's delivery cursor starts at afterSeq, so the
|
|
363
|
+
# pusher delivers everything the client hasn't seen.
|
|
364
|
+
handler("session/subscribe") do |params, _id, connection|
|
|
227
365
|
session_id = params["sessionId"] || params[:sessionId]
|
|
228
|
-
delivery_kind = params["deliveryKind"] || params[:deliveryKind] || "
|
|
366
|
+
delivery_kind = params["deliveryKind"] || params[:deliveryKind] || "replay"
|
|
229
367
|
after_seq = params["afterSeq"] || params[:afterSeq] || 0
|
|
230
368
|
include_snapshot = params["includeSnapshot"] || params[:includeSnapshot] || false
|
|
231
369
|
|
|
232
370
|
result = @session_manager.subscribe(session_id, delivery_kind: delivery_kind)
|
|
371
|
+
connection.subscribe(session_id, after_seq: after_seq)
|
|
233
372
|
|
|
234
|
-
|
|
235
|
-
@session_manager.get_events(session_id, after_seq: after_seq)
|
|
236
|
-
|
|
237
|
-
nil
|
|
373
|
+
if include_snapshot
|
|
374
|
+
snapshot = @session_manager.get_events(session_id, after_seq: after_seq)
|
|
375
|
+
result[:snapshot] = serialize_events(snapshot[:events])
|
|
238
376
|
end
|
|
239
377
|
|
|
240
|
-
result
|
|
378
|
+
result
|
|
241
379
|
end
|
|
242
380
|
|
|
243
|
-
# Session: send
|
|
381
|
+
# Session: send — prompt an idle session or inject mid-run.
|
|
244
382
|
handler("session/send") do |params, _id|
|
|
245
383
|
session_id = params["sessionId"] || params[:sessionId]
|
|
246
384
|
content = params["content"] || params[:content]
|
|
385
|
+
expected_turn_id = params["expectedTurnId"] || params[:expectedTurnId]
|
|
247
386
|
|
|
248
387
|
raise InvalidRequest, "sessionId is required" unless session_id
|
|
249
388
|
raise InvalidRequest, "content is required" unless content
|
|
250
389
|
|
|
251
|
-
@session_manager.send_message(session_id, content
|
|
252
|
-
|
|
253
|
-
{ accepted: true, sessionId: session_id }
|
|
390
|
+
@session_manager.send_message(session_id, content, expected_turn_id: expected_turn_id)
|
|
254
391
|
end
|
|
255
392
|
|
|
256
393
|
# Session: events (polling)
|
|
@@ -259,7 +396,9 @@ module Ask
|
|
|
259
396
|
after_seq = params["afterSeq"] || params[:afterSeq] || 0
|
|
260
397
|
limit = params["limit"] || params[:limit]
|
|
261
398
|
|
|
262
|
-
@session_manager.get_events(session_id, after_seq: after_seq, limit: limit)
|
|
399
|
+
result = @session_manager.get_events(session_id, after_seq: after_seq, limit: limit)
|
|
400
|
+
result[:events] = serialize_events(result[:events])
|
|
401
|
+
result
|
|
263
402
|
end
|
|
264
403
|
|
|
265
404
|
# Session: abort
|
|
@@ -274,6 +413,17 @@ module Ask
|
|
|
274
413
|
{ aborted: true, sessionId: session_id }
|
|
275
414
|
end
|
|
276
415
|
|
|
416
|
+
# Session: close
|
|
417
|
+
handler("session/close") do |params, _id|
|
|
418
|
+
session_id = params["sessionId"] || params[:sessionId]
|
|
419
|
+
raise InvalidRequest, "sessionId is required" unless session_id
|
|
420
|
+
|
|
421
|
+
closed = @session_manager.close_session(session_id)
|
|
422
|
+
raise Ask::AppServer::SessionNotFound, "Session #{session_id} not found" unless closed
|
|
423
|
+
|
|
424
|
+
{ closed: true, sessionId: session_id }
|
|
425
|
+
end
|
|
426
|
+
|
|
277
427
|
# Artifacts: list the session's tool deliverables
|
|
278
428
|
handler("session/artifacts") do |params, _id|
|
|
279
429
|
session_id = params["sessionId"] || params[:sessionId]
|
|
@@ -307,111 +457,109 @@ module Ask
|
|
|
307
457
|
{ artifact: record }
|
|
308
458
|
end
|
|
309
459
|
|
|
310
|
-
#
|
|
311
|
-
handler("workspace/readState") do |params, _id|
|
|
312
|
-
@session_manager.read_workspace_state
|
|
313
|
-
end
|
|
460
|
+
# ── Interactions (resolvable by id from any client) ──────────────
|
|
314
461
|
|
|
315
|
-
#
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
{
|
|
462
|
+
# Interaction: list pending approval interactions
|
|
463
|
+
handler("interaction/list") do |params, _id|
|
|
464
|
+
session_id = params["sessionId"] || params[:sessionId]
|
|
465
|
+
raise InvalidRequest, "sessionId is required" unless session_id
|
|
466
|
+
|
|
467
|
+
interactions = @session_manager.pending_interactions(session_id)
|
|
468
|
+
{ interactions: interactions.map(&:to_h) }
|
|
322
469
|
end
|
|
323
|
-
end
|
|
324
470
|
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
471
|
+
# Interaction: approve by id
|
|
472
|
+
handler("interaction/approve") do |params, _id|
|
|
473
|
+
session_id = params["sessionId"] || params[:sessionId]
|
|
474
|
+
interaction_id = params["interactionId"] || params[:interactionId]
|
|
475
|
+
raise InvalidRequest, "sessionId and interactionId are required" unless session_id && interaction_id
|
|
328
476
|
|
|
329
|
-
|
|
330
|
-
|
|
477
|
+
approved = @session_manager.approve_interaction(session_id, interaction_id)
|
|
478
|
+
raise Ask::AppServer::InteractionNotFound, "Interaction #{interaction_id} not found" unless approved
|
|
331
479
|
|
|
332
|
-
|
|
333
|
-
# A response has an id and a result (or error), but no method.
|
|
334
|
-
if id && !msg.key?("method") && !msg.key?(:method)
|
|
335
|
-
if msg.key?("result") || msg.key?(:result)
|
|
336
|
-
handle_incoming_response(id, msg["result"] || msg[:result])
|
|
337
|
-
return
|
|
338
|
-
elsif msg.key?("error") || msg.key?(:error)
|
|
339
|
-
handle_incoming_response(id, nil, msg["error"] || msg[:error])
|
|
340
|
-
return
|
|
341
|
-
end
|
|
480
|
+
{ approved: true, interactionId: interaction_id }
|
|
342
481
|
end
|
|
343
482
|
|
|
344
|
-
|
|
345
|
-
|
|
483
|
+
# Interaction: reject by id
|
|
484
|
+
handler("interaction/reject") do |params, _id|
|
|
485
|
+
session_id = params["sessionId"] || params[:sessionId]
|
|
486
|
+
interaction_id = params["interactionId"] || params[:interactionId]
|
|
487
|
+
raise InvalidRequest, "sessionId and interactionId are required" unless session_id && interaction_id
|
|
346
488
|
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
489
|
+
rejected = @session_manager.reject_interaction(session_id, interaction_id)
|
|
490
|
+
raise Ask::AppServer::InteractionNotFound, "Interaction #{interaction_id} not found" unless rejected
|
|
491
|
+
|
|
492
|
+
{ rejected: true, interactionId: interaction_id }
|
|
350
493
|
end
|
|
351
494
|
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
495
|
+
# Interaction: approve all pending
|
|
496
|
+
handler("interaction/approve-all") do |params, _id|
|
|
497
|
+
session_id = params["sessionId"] || params[:sessionId]
|
|
498
|
+
raise InvalidRequest, "sessionId is required" unless session_id
|
|
499
|
+
|
|
500
|
+
{ approved: @session_manager.approve_all_interactions(session_id) }
|
|
356
501
|
end
|
|
357
502
|
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
send_error(id, -32005, e.message) if id
|
|
365
|
-
rescue Ask::AppServer::InvalidRequest => e
|
|
366
|
-
send_error(id, -32602, e.message) if id
|
|
367
|
-
rescue => e
|
|
368
|
-
@logger.error("Handler error for #{method}: #{e.message}")
|
|
369
|
-
send_error(id, -32603, "Internal error: #{e.message}") if id
|
|
503
|
+
# Interaction: reject all pending
|
|
504
|
+
handler("interaction/reject-all") do |params, _id|
|
|
505
|
+
session_id = params["sessionId"] || params[:sessionId]
|
|
506
|
+
raise InvalidRequest, "sessionId is required" unless session_id
|
|
507
|
+
|
|
508
|
+
{ rejected: @session_manager.reject_all_interactions(session_id) }
|
|
370
509
|
end
|
|
371
|
-
end
|
|
372
510
|
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
511
|
+
# Interaction: respond to a user_input interaction (elicitation).
|
|
512
|
+
# Not implemented: ask-agent has no elicitation surface yet.
|
|
513
|
+
handler("interaction/respond") do |_params, id, connection|
|
|
514
|
+
send_error(id, Ask::SessionProtocol::Methods::ERROR_CODES[:not_implemented],
|
|
515
|
+
"interaction/respond is not implemented: user input elicitation is not supported by the runtime",
|
|
516
|
+
connection)
|
|
379
517
|
end
|
|
380
|
-
end
|
|
381
518
|
|
|
382
|
-
|
|
383
|
-
write_line({ id: id, result: result })
|
|
384
|
-
end
|
|
519
|
+
# ── Plan mode ────────────────────────────────────────────────────
|
|
385
520
|
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
521
|
+
# Plan: approve the pending proposal
|
|
522
|
+
handler("plan/approve") do |params, _id|
|
|
523
|
+
session_id = params["sessionId"] || params[:sessionId]
|
|
524
|
+
raise InvalidRequest, "sessionId is required" unless session_id
|
|
390
525
|
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
end
|
|
526
|
+
approved = @session_manager.plan_approve(session_id)
|
|
527
|
+
raise Ask::AppServer::PlanNotFound, "No pending plan for session #{session_id}" unless approved
|
|
394
528
|
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
$stdout.flush
|
|
398
|
-
end
|
|
529
|
+
{ approved: true }
|
|
530
|
+
end
|
|
399
531
|
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
next unless @session_manager.subscribed?(sid)
|
|
532
|
+
# Plan: reject the pending proposal
|
|
533
|
+
handler("plan/reject") do |params, _id|
|
|
534
|
+
session_id = params["sessionId"] || params[:sessionId]
|
|
535
|
+
raise InvalidRequest, "sessionId is required" unless session_id
|
|
405
536
|
|
|
406
|
-
|
|
407
|
-
|
|
537
|
+
rejected = @session_manager.plan_reject(session_id)
|
|
538
|
+
raise Ask::AppServer::PlanNotFound, "No pending plan for session #{session_id}" unless rejected
|
|
408
539
|
|
|
409
|
-
|
|
410
|
-
send_notification("session/event", ev)
|
|
411
|
-
end
|
|
540
|
+
{ rejected: true }
|
|
412
541
|
end
|
|
413
|
-
|
|
414
|
-
|
|
542
|
+
|
|
543
|
+
# Workspace: read state
|
|
544
|
+
handler("workspace/readState") do |params, _id|
|
|
545
|
+
session_id = params["sessionId"] || params[:sessionId]
|
|
546
|
+
@session_manager.read_workspace_state(session_id)
|
|
547
|
+
end
|
|
548
|
+
|
|
549
|
+
# Interaction: requestPermission — when a client sends this as a
|
|
550
|
+
# request, respond with the current approval mode (interop query).
|
|
551
|
+
handler("interaction/requestPermission") do |_params, _id|
|
|
552
|
+
{ mode: @session_manager.approval_mode.to_s, pending: false }
|
|
553
|
+
end
|
|
554
|
+
end
|
|
555
|
+
|
|
556
|
+
def handler(method, &block)
|
|
557
|
+
@handlers[method] = block
|
|
558
|
+
end
|
|
559
|
+
|
|
560
|
+
# Canonical Event objects → wire hashes.
|
|
561
|
+
def serialize_events(events)
|
|
562
|
+
events.map(&:to_h)
|
|
415
563
|
end
|
|
416
564
|
end
|
|
417
565
|
end
|