ask-app-server 0.4.8 → 0.4.9
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/lib/ask/app_server/agent_adapter.rb +25 -2
- data/lib/ask/app_server/cli.rb +40 -13
- data/lib/ask/app_server/event_translator.rb +25 -8
- data/lib/ask/app_server/server.rb +6 -1
- data/lib/ask/app_server/session_manager.rb +33 -0
- data/lib/ask/app_server/socket_server.rb +6 -0
- data/lib/ask/app_server/version.rb +1 -1
- metadata +3 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2272260cc312765a2fb145f0c7e121c895ef47baf6aa1e8394c4ad94e36ee835
|
|
4
|
+
data.tar.gz: 2c344972b0c6629793bc26901d4ea9560dacfa8cc5c1f9367749dbd7e90b4438
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 126c904d33f636b7336d6f44bca17cd8b2b6914d75d120cc1285537c353635e2eb800fccb2a52b651c5003c2f1afd7f6dd8cc3d069a53937175e9c0eb69211b9
|
|
7
|
+
data.tar.gz: 92f6895aa13391cb72d61e72764cd273d7bc0bcb12a8f627113fa315e90935818360e2a24c9cdad1b6616cabdae94396825acde43cb6fd9e2fa7763f896c7a48
|
|
@@ -44,6 +44,11 @@ module Ask
|
|
|
44
44
|
@approval = approval
|
|
45
45
|
@require_approval = require_approval
|
|
46
46
|
@agent_dir = agent_dir
|
|
47
|
+
# The session's workspace is the tools' home: bash commands
|
|
48
|
+
# without an explicit cd run there (default_workdir), so the
|
|
49
|
+
# agent never drifts into the host's cwd — the recurring
|
|
50
|
+
# "where is the project?" failure mode.
|
|
51
|
+
@tools.each { |tool| tool.default_workdir = @agent_dir if tool.respond_to?(:default_workdir=) }
|
|
47
52
|
@session = nil
|
|
48
53
|
@translator = nil
|
|
49
54
|
@on_event_block = nil
|
|
@@ -293,11 +298,29 @@ module Ask
|
|
|
293
298
|
while @session.queued_steers.positive?
|
|
294
299
|
@session.run("", reset: false)
|
|
295
300
|
end
|
|
301
|
+
# A run that returned with the turn still active ended
|
|
302
|
+
# without a terminal event — the model stream dropped
|
|
303
|
+
# mid-turn (a dead connection, a provider that closed the
|
|
304
|
+
# stream early). Without this, the client would wait on a
|
|
305
|
+
# ghost run forever. Surface it as a failure so the fix
|
|
306
|
+
# loop can redeliver.
|
|
307
|
+
if @translator.turn_active?
|
|
308
|
+
@logger.error("Turn ended without a completion event — the model stream dropped mid-turn")
|
|
309
|
+
@translator.turn_failed("The model stream ended without completing the turn (the connection dropped mid-stream)")
|
|
310
|
+
end
|
|
296
311
|
rescue => e
|
|
297
|
-
#
|
|
298
|
-
|
|
312
|
+
# An aborted turn raises Ask::Agent::Aborted — that's not a
|
|
313
|
+
# failure, the client asked for it. Everything else is a real
|
|
314
|
+
# run failure the client must see: without a turn.failed, a
|
|
315
|
+
# watching board would wait forever on a dead turn. The
|
|
316
|
+
# translator's failure event carries the message.
|
|
317
|
+
unless e.is_a?(Ask::Agent::Aborted) || e.class.name.to_s.include?("Aborted")
|
|
318
|
+
@logger.error("Agent run failed: #{e.class}: #{e.message}")
|
|
319
|
+
@translator.turn_failed(e.message.to_s[0, 500])
|
|
320
|
+
end
|
|
299
321
|
ensure
|
|
300
322
|
@running_mutex.synchronize { @running = false }
|
|
323
|
+
@logger.debug("Run thread ended: turn_active=#{@translator.turn_active?} last_seq=#{@translator.last_seq}")
|
|
301
324
|
end
|
|
302
325
|
end
|
|
303
326
|
|
data/lib/ask/app_server/cli.rb
CHANGED
|
@@ -37,11 +37,14 @@ module Ask
|
|
|
37
37
|
# Parse --config and --socket from args
|
|
38
38
|
config_path = nil
|
|
39
39
|
socket_path = nil
|
|
40
|
+
socket_only = false
|
|
40
41
|
args.each_with_index do |arg, i|
|
|
41
42
|
if arg == "--config" && i + 1 < args.length
|
|
42
43
|
config_path = args[i + 1]
|
|
43
44
|
elsif arg == "--socket" && i + 1 < args.length
|
|
44
45
|
socket_path = args[i + 1]
|
|
46
|
+
elsif arg == "--socket-only"
|
|
47
|
+
socket_only = true
|
|
45
48
|
end
|
|
46
49
|
end
|
|
47
50
|
|
|
@@ -90,19 +93,37 @@ module Ask
|
|
|
90
93
|
$stderr.puts "socket: #{socket_server.socket_path}"
|
|
91
94
|
end
|
|
92
95
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
96
|
+
if socket_only
|
|
97
|
+
# Daemon mode: the host lives on the socket alone, no stdio
|
|
98
|
+
# transport — its lifetime must not depend on stdin (a
|
|
99
|
+
# supervisor's EOF would take the whole host down with it).
|
|
100
|
+
if socket_server.nil?
|
|
101
|
+
$stderr.puts "[ask-app-server] --socket-only needs --socket"
|
|
102
|
+
exit 1
|
|
103
|
+
end
|
|
104
|
+
begin
|
|
105
|
+
socket_server.join
|
|
106
|
+
rescue Interrupt
|
|
107
|
+
$stderr.puts "\n[ask-app-server] Shutting down..." if config.debug?
|
|
108
|
+
ensure
|
|
109
|
+
socket_server&.stop
|
|
110
|
+
herdr_reporter&.close
|
|
111
|
+
end
|
|
112
|
+
else
|
|
113
|
+
# Start the server (stdio transport, blocks)
|
|
114
|
+
server = Server.new(session_manager: session_manager)
|
|
115
|
+
|
|
116
|
+
begin
|
|
117
|
+
server.start
|
|
118
|
+
rescue Interrupt
|
|
119
|
+
$stderr.puts "\n[ask-app-server] Shutting down..." if config.debug?
|
|
120
|
+
ensure
|
|
121
|
+
# Clean up on Ctrl-C AND on normal exit (stdin EOF): the socket
|
|
122
|
+
# file must not outlive the host.
|
|
123
|
+
server.stop
|
|
124
|
+
socket_server&.stop
|
|
125
|
+
herdr_reporter&.close
|
|
126
|
+
end
|
|
106
127
|
end
|
|
107
128
|
end
|
|
108
129
|
|
|
@@ -141,11 +162,17 @@ module Ask
|
|
|
141
162
|
|
|
142
163
|
USAGE:
|
|
143
164
|
ask-app-server Start in stdio mode
|
|
165
|
+
ask-app-server --socket SOCK --socket-only
|
|
166
|
+
Start as a socket daemon (no stdio)
|
|
144
167
|
|
|
145
168
|
OPTIONS:
|
|
146
169
|
--version, -v Show version
|
|
147
170
|
--help, -h Show this help message
|
|
148
171
|
--config PATH Config file path (default: auto-detect)
|
|
172
|
+
--socket PATH Also expose a unix socket at PATH
|
|
173
|
+
--socket-only Daemon mode: serve the socket alone
|
|
174
|
+
(the host's lifetime does not depend
|
|
175
|
+
on stdin)
|
|
149
176
|
|
|
150
177
|
CONFIG FILE (JSON):
|
|
151
178
|
Search order:
|
|
@@ -17,7 +17,13 @@ module Ask
|
|
|
17
17
|
# delivery means the log is append-only; clients with cursors older
|
|
18
18
|
# than the cap miss the dropped events (durable log is a host
|
|
19
19
|
# storage concern).
|
|
20
|
-
|
|
20
|
+
# How many events the translator buffers for cursor-based
|
|
21
|
+
# delivery. The cap must clear the largest thinking flood: a
|
|
22
|
+
# reasoning model streams thousands of deltas per turn (observed
|
|
23
|
+
# >9000), and events dropped here are dropped for every connected
|
|
24
|
+
# client — the completion event could fall in the gap and the
|
|
25
|
+
# client would watch a ghost run forever.
|
|
26
|
+
MAX_EVENTS = 20_000
|
|
21
27
|
|
|
22
28
|
# Optional observer called with every emitted canonical Event.
|
|
23
29
|
# This is the single choke point for all session events (translations
|
|
@@ -135,6 +141,24 @@ module Ask
|
|
|
135
141
|
@events.select { |e| e.seq > after_seq }
|
|
136
142
|
end
|
|
137
143
|
|
|
144
|
+
# Whether a turn is mid-flight (started, not completed or failed).
|
|
145
|
+
# A run that returns while a turn is still active ended without a
|
|
146
|
+
# terminal event — the model stream dropped mid-turn — and the
|
|
147
|
+
# adapter must surface that as a failure, not let the client wait
|
|
148
|
+
# on a ghost.
|
|
149
|
+
def turn_active?
|
|
150
|
+
@turn_active
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Emit a turn.failed event. Called by the adapter when a run dies
|
|
154
|
+
# (an exception, or a stream that ended without completing).
|
|
155
|
+
def turn_failed(message)
|
|
156
|
+
@turn_active = false
|
|
157
|
+
payload = { "error" => message.to_s }
|
|
158
|
+
payload["turnId"] = @turn_id if @turn_id
|
|
159
|
+
emit("turn.failed", payload)
|
|
160
|
+
end
|
|
161
|
+
|
|
138
162
|
private
|
|
139
163
|
|
|
140
164
|
def next_seq
|
|
@@ -231,13 +255,6 @@ module Ask
|
|
|
231
255
|
emit("error", payload)
|
|
232
256
|
end
|
|
233
257
|
|
|
234
|
-
def turn_failed(message)
|
|
235
|
-
@turn_active = false
|
|
236
|
-
payload = { "error" => message.to_s }
|
|
237
|
-
payload["turnId"] = @turn_id if @turn_id
|
|
238
|
-
emit("turn.failed", payload)
|
|
239
|
-
end
|
|
240
|
-
|
|
241
258
|
def emit(type, payload)
|
|
242
259
|
event = Ask::SessionProtocol::Events.event(type: type, seq: next_seq, payload: payload)
|
|
243
260
|
@events << event
|
|
@@ -188,6 +188,11 @@ module Ask
|
|
|
188
188
|
# events its sessions have produced since the connection's cursor,
|
|
189
189
|
# then advance the cursor. Cursor-based, so each client receives
|
|
190
190
|
# exactly the events after its own seq (replay on subscribe).
|
|
191
|
+
#
|
|
192
|
+
# Each event carries its sessionId: a connection may subscribe to
|
|
193
|
+
# several sessions, and the client routes events to the right
|
|
194
|
+
# watcher — without it, a multi-session client would record one
|
|
195
|
+
# session's events into another's run.
|
|
191
196
|
def push_pending
|
|
192
197
|
connections.each do |connection|
|
|
193
198
|
connection.subscriptions.keys.each do |session_id|
|
|
@@ -198,7 +203,7 @@ module Ask
|
|
|
198
203
|
next if events.empty?
|
|
199
204
|
|
|
200
205
|
events.each do |ev|
|
|
201
|
-
connection.write({ method: "session/event", params: { event: ev.to_h } })
|
|
206
|
+
connection.write({ method: "session/event", params: { sessionId: session_id, event: ev.to_h } })
|
|
202
207
|
end
|
|
203
208
|
connection.advance(session_id, events.last.seq)
|
|
204
209
|
end
|
|
@@ -62,6 +62,14 @@ module Ask
|
|
|
62
62
|
|
|
63
63
|
# Extract provider prefix from model string (e.g., "opencode_go/deepseek-v4-flash")
|
|
64
64
|
model_id, model_provider = parse_model_string(model || DEFAULT_MODEL)
|
|
65
|
+
# A provider-qualified model must stay resolvable: the agent's
|
|
66
|
+
# model catalog looks up bare ids, so the prefixed model is
|
|
67
|
+
# registered under its provider before the session builds its
|
|
68
|
+
# chat. Without this, "deepseek/deepseek-v4-flash" becomes the
|
|
69
|
+
# bare "deepseek-v4-flash" which the catalog may not know — the
|
|
70
|
+
# session falls back to the wrong provider and the run hangs
|
|
71
|
+
# silently on a missing credential.
|
|
72
|
+
register_prefixed_model(model_id, model_provider) if model_provider
|
|
65
73
|
model_for_agent = model_provider ? model_id : (model || DEFAULT_MODEL)
|
|
66
74
|
|
|
67
75
|
adapter = AgentAdapter.new(
|
|
@@ -318,6 +326,31 @@ module Ask
|
|
|
318
326
|
parts = str.split("/", 2)
|
|
319
327
|
[parts[1], parts[0]]
|
|
320
328
|
end
|
|
329
|
+
|
|
330
|
+
# Make a provider-qualified model resolvable by the agent's model
|
|
331
|
+
# catalog: the catalog looks up bare ids, so a model that arrived
|
|
332
|
+
# as "provider/id" is registered under its provider. If the id is
|
|
333
|
+
# already known, its existing provider is preserved — the caller's
|
|
334
|
+
# explicit prefix is the override.
|
|
335
|
+
def register_prefixed_model(model_id, model_provider)
|
|
336
|
+
catalog = Ask::ModelCatalog.respond_to?(:instance) ? Ask::ModelCatalog.instance : nil
|
|
337
|
+
return unless catalog&.respond_to?(:register)
|
|
338
|
+
|
|
339
|
+
existing = catalog.find(model_id) rescue nil
|
|
340
|
+
return if existing && existing.provider.to_s == model_provider.to_s
|
|
341
|
+
return if existing && existing.provider.to_s != "unknown"
|
|
342
|
+
|
|
343
|
+
info = OpenStruct.new(
|
|
344
|
+
id: model_id.to_s,
|
|
345
|
+
provider: model_provider.to_s,
|
|
346
|
+
chat?: true,
|
|
347
|
+
context: 4096,
|
|
348
|
+
output: 4096
|
|
349
|
+
)
|
|
350
|
+
catalog.register(info)
|
|
351
|
+
rescue StandardError
|
|
352
|
+
nil # registration is best-effort; the session will surface a real error
|
|
353
|
+
end
|
|
321
354
|
end
|
|
322
355
|
end
|
|
323
356
|
end
|
|
@@ -63,6 +63,12 @@ module Ask
|
|
|
63
63
|
FileUtils.rm_f(@socket_path) rescue nil
|
|
64
64
|
end
|
|
65
65
|
|
|
66
|
+
# Block until the acceptor stops (the process's main loop in
|
|
67
|
+
# socket-only mode — there's no stdio transport to join on).
|
|
68
|
+
def join
|
|
69
|
+
@acceptor&.join
|
|
70
|
+
end
|
|
71
|
+
|
|
66
72
|
# Whether the server is running.
|
|
67
73
|
def running?
|
|
68
74
|
@running
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: ask-app-server
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.4.
|
|
4
|
+
version: 0.4.9
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Kaka Ruto
|
|
@@ -43,14 +43,14 @@ dependencies:
|
|
|
43
43
|
requirements:
|
|
44
44
|
- - ">="
|
|
45
45
|
- !ruby/object:Gem::Version
|
|
46
|
-
version:
|
|
46
|
+
version: 0.2.0
|
|
47
47
|
type: :runtime
|
|
48
48
|
prerelease: false
|
|
49
49
|
version_requirements: !ruby/object:Gem::Requirement
|
|
50
50
|
requirements:
|
|
51
51
|
- - ">="
|
|
52
52
|
- !ruby/object:Gem::Version
|
|
53
|
-
version:
|
|
53
|
+
version: 0.2.0
|
|
54
54
|
- !ruby/object:Gem::Dependency
|
|
55
55
|
name: ask-tools
|
|
56
56
|
requirement: !ruby/object:Gem::Requirement
|