ask-coding-providers 0.2.1 → 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
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b1b9421ae222addaf585ea8b26333306078843d0574a4b71a34cecaa3fc4ff79
|
|
4
|
+
data.tar.gz: 8c7d16435c702703f0f661474077389c28d4fc796aafe196bfce4ccd1f7019b2
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 76cbfb82c26608b1d73a44673b09ab6a299a7c604e80566fa8e9555acd4c2443239d28fafafdee6e29589aa9062cef5e4c4bab465bdb103e41db66560e91b2fe
|
|
7
|
+
data.tar.gz: 4a704264075d1c25ce32bf4ae73106050c3ffd7aa52f34ed042f8e98c4132d0f150aca5fe88af3114ca4d0b8c066a83c9f545b44ca267b6abf34d7e802f5c829
|
|
@@ -2,52 +2,137 @@
|
|
|
2
2
|
|
|
3
3
|
require "securerandom"
|
|
4
4
|
|
|
5
|
+
begin
|
|
6
|
+
require "ask-llm-providers"
|
|
7
|
+
require "ask/agent"
|
|
8
|
+
rescue LoadError => e
|
|
9
|
+
raise "Missing dependency for AskAgent adapter: #{e.message}. Add ask-agent and ask-llm-providers to your Gemfile."
|
|
10
|
+
end
|
|
11
|
+
|
|
5
12
|
module Ask
|
|
6
13
|
module CodingProviders
|
|
7
14
|
module AskAgent
|
|
15
|
+
# Approval queue that notifies callbacks when actions are submitted or
|
|
16
|
+
# change status, so clients can stream approval state in real time.
|
|
17
|
+
#
|
|
18
|
+
# The session wires its own apply/reject/submit callbacks onto the
|
|
19
|
+
# queue (see Session#build_approval); this subclass only adds
|
|
20
|
+
# observation hooks on top, using its own listeners so the session's
|
|
21
|
+
# on_submit (pending-tool registration) is never clobbered.
|
|
22
|
+
class EmittingApprovalQueue < Ask::Agent::ApprovalQueue
|
|
23
|
+
# @param on_submit [Proc, nil] called with the new {Action} after
|
|
24
|
+
# submission (and after the auto-approval drain)
|
|
25
|
+
# @param on_status [Proc, nil] called with an {Action} whose status
|
|
26
|
+
# changed to :approved or :rejected
|
|
27
|
+
def initialize(on_submit: nil, on_status: nil, **kwargs)
|
|
28
|
+
@on_action_submitted = on_submit
|
|
29
|
+
@on_status = on_status
|
|
30
|
+
super(**kwargs)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def submit(tool_call_id:, tool_name:, args: {}, auto_approvable: false, message: nil)
|
|
34
|
+
id = super
|
|
35
|
+
@on_action_submitted&.call(self[id])
|
|
36
|
+
id
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def apply(action)
|
|
42
|
+
result = super
|
|
43
|
+
@on_status&.call(action.with(status: :approved))
|
|
44
|
+
result
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def reject_action(action)
|
|
48
|
+
result = super
|
|
49
|
+
@on_status&.call(action.with(status: :rejected))
|
|
50
|
+
result
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
8
54
|
# Adapter that wraps Ask::Agent::Session directly (in-process).
|
|
9
55
|
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
56
|
+
# Sessions persist across turns: each session id maps to one
|
|
57
|
+
# Ask::Agent::Session instance whose conversation history accumulates.
|
|
58
|
+
# Every agent event is translated and streamed to subscribers, including
|
|
59
|
+
# thinking deltas, tool executions, approvals, plans, and todos. When a
|
|
60
|
+
# tool queues for human approval, the turn pauses; approving or
|
|
61
|
+
# rejecting from any thread continues the turn (follow-up turns run
|
|
62
|
+
# inside ask-agent and their events reach the same subscribers).
|
|
12
63
|
#
|
|
13
64
|
# @example
|
|
14
|
-
# adapter = AskAgent::Adapter.new(
|
|
65
|
+
# adapter = AskAgent::Adapter.new(
|
|
66
|
+
# model: "deepseek-v4-flash", provider: "opencode_go",
|
|
67
|
+
# approval: :require, approval_required: %w[bash write edit]
|
|
68
|
+
# )
|
|
15
69
|
# adapter.start
|
|
16
70
|
# sid = adapter.create_session("/tmp")
|
|
17
|
-
# adapter.send_and_stream(sid, "Hello") { |ev| puts ev }
|
|
71
|
+
# adapter.send_and_stream(sid, "Hello") { |ev| puts ev[:type] }
|
|
72
|
+
# adapter.pending_approvals(sid) # => [{id: 1, tool_name: "bash", ...}]
|
|
73
|
+
# adapter.approve_action(sid, 1)
|
|
18
74
|
class Adapter < Ask::CodingProviders::Adapter
|
|
75
|
+
# Approval modes: :off disables the queue, :require gates
|
|
76
|
+
# approval_required tools behind human review, :auto keeps the queue
|
|
77
|
+
# (visible/inspectable) but never blocks.
|
|
78
|
+
APPROVAL_OFF = :off
|
|
79
|
+
APPROVAL_REQUIRE = :require
|
|
80
|
+
APPROVAL_AUTO = :auto
|
|
81
|
+
APPROVAL_MODES = [APPROVAL_OFF, APPROVAL_REQUIRE, APPROVAL_AUTO].freeze
|
|
82
|
+
|
|
83
|
+
# How long a turn stays settled before it is considered complete
|
|
84
|
+
# (protects against follow-up turns starting right after the queue
|
|
85
|
+
# drains).
|
|
86
|
+
SETTLE_POLL_INTERVAL = 0.05 # seconds
|
|
87
|
+
SETTLE_POLLS = 4
|
|
88
|
+
|
|
19
89
|
# @param model [String] model ID (e.g. "deepseek-v4-flash")
|
|
20
90
|
# @param provider [String] provider slug (e.g. "opencode_go")
|
|
21
91
|
# @param tools [Array] tool instances to make available
|
|
22
92
|
# @param max_turns [Integer] max conversation turns per session
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
93
|
+
# @param approval [Symbol] one of APPROVAL_MODES
|
|
94
|
+
# @param approval_required [Array<String>] tool names gated behind
|
|
95
|
+
# human approval when approval is :require
|
|
96
|
+
# @param plan_mode [Boolean] enable plan mode (exit_plan_mode tool +
|
|
97
|
+
# read-only gate until the plan is approved)
|
|
98
|
+
# @param todos [Boolean] enable the todo list (todo_write tool)
|
|
99
|
+
# @param session_opts [Hash] extra options passed to
|
|
100
|
+
# Ask::Agent::Session (hooks, system_prompt, compactor, ...)
|
|
101
|
+
def initialize(model:, provider:, tools: [], max_turns: 25,
|
|
102
|
+
approval: APPROVAL_OFF, approval_required: nil,
|
|
103
|
+
plan_mode: false, todos: false, **session_opts)
|
|
32
104
|
@model_id = model
|
|
33
105
|
@provider_slug = provider
|
|
34
|
-
@tools = tools
|
|
106
|
+
@tools = Array(tools)
|
|
35
107
|
@max_turns = max_turns
|
|
108
|
+
@approval = approval
|
|
109
|
+
@approval_required = Array(approval_required)
|
|
110
|
+
@plan_mode = !!plan_mode
|
|
111
|
+
@todos = !!todos
|
|
36
112
|
@session_opts = session_opts
|
|
37
113
|
@started = false
|
|
38
114
|
@provider = nil
|
|
115
|
+
@sessions = {}
|
|
116
|
+
@mutex = Mutex.new
|
|
117
|
+
unless APPROVAL_MODES.include?(approval)
|
|
118
|
+
raise ArgumentError, "approval must be one of #{APPROVAL_MODES.inspect}, got #{approval.inspect}"
|
|
119
|
+
end
|
|
39
120
|
end
|
|
40
121
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
122
|
+
def start
|
|
123
|
+
return if @started
|
|
124
|
+
klass = Ask::Provider.resolve(@provider_slug)
|
|
125
|
+
compat = klass.respond_to?(:compat_config) ? klass.compat_config : {}
|
|
126
|
+
api_key = ENV[compat[:alternate_env].to_s] || ENV[compat[:api_key_env].to_s] || ENV["#{@provider_slug.upcase}_API_KEY"]
|
|
127
|
+
@provider = klass.new(api_key: api_key)
|
|
128
|
+
@started = true
|
|
129
|
+
end
|
|
49
130
|
|
|
50
131
|
def stop
|
|
132
|
+
@mutex.synchronize do
|
|
133
|
+
@sessions.each_value { |entry| entry[:session]&.abort }
|
|
134
|
+
@sessions.clear
|
|
135
|
+
end
|
|
51
136
|
@started = false
|
|
52
137
|
@provider = nil
|
|
53
138
|
end
|
|
@@ -56,73 +141,167 @@ module Ask
|
|
|
56
141
|
@started
|
|
57
142
|
end
|
|
58
143
|
|
|
59
|
-
# Create a new conversation session.
|
|
60
|
-
# The workspace_path is noted but not used by in-process agent.
|
|
144
|
+
# Create a new conversation session for a workspace.
|
|
61
145
|
# Returns a session ID (UUID).
|
|
62
|
-
|
|
146
|
+
#
|
|
147
|
+
# @param workspace_path [String] working directory
|
|
148
|
+
# @param mode [String, nil] permission mode
|
|
149
|
+
# @param model [String, nil] model override for this session
|
|
150
|
+
def create_session(workspace_path, mode: nil, model: nil)
|
|
63
151
|
ensure_started
|
|
64
152
|
sid = "sess_#{SecureRandom.uuid}"
|
|
65
|
-
@
|
|
66
|
-
|
|
153
|
+
@mutex.synchronize do
|
|
154
|
+
@sessions[sid] = {
|
|
155
|
+
workspace: workspace_path,
|
|
156
|
+
mode: mode,
|
|
157
|
+
model: model || @model_id,
|
|
158
|
+
created_at: Time.now,
|
|
159
|
+
session: nil,
|
|
160
|
+
subscribers: [],
|
|
161
|
+
seq: 0,
|
|
162
|
+
turn_active: false
|
|
163
|
+
}
|
|
164
|
+
end
|
|
67
165
|
sid
|
|
68
166
|
end
|
|
69
167
|
|
|
70
168
|
def resume_session(session_id)
|
|
71
169
|
ensure_started
|
|
72
|
-
@sessions
|
|
170
|
+
entry = @sessions[session_id]
|
|
171
|
+
return {} unless entry
|
|
172
|
+
{
|
|
173
|
+
"session_id" => session_id,
|
|
174
|
+
"workspace" => entry[:workspace],
|
|
175
|
+
"model" => entry[:model],
|
|
176
|
+
"created_at" => entry[:created_at].iso8601
|
|
177
|
+
}
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def list_sessions(workspace_path: nil, limit: 20)
|
|
181
|
+
ensure_started
|
|
182
|
+
@mutex.synchronize do
|
|
183
|
+
@sessions
|
|
184
|
+
.select { |_sid, e| workspace_path.nil? || e[:workspace] == workspace_path }
|
|
185
|
+
.sort_by { |sid, e| [e[:created_at], sid] }
|
|
186
|
+
.reverse
|
|
187
|
+
.first(limit)
|
|
188
|
+
.map { |sid, e| { session_id: sid, workspace: e[:workspace], created_at: e[:created_at].iso8601 } }
|
|
189
|
+
end
|
|
73
190
|
end
|
|
74
191
|
|
|
75
192
|
def subscribe(session_id, after_seq: 0)
|
|
76
193
|
ensure_started
|
|
77
|
-
{ "eventSeq" =>
|
|
194
|
+
{ "eventSeq" => session_entry(session_id)[:seq] }
|
|
78
195
|
end
|
|
79
196
|
|
|
80
197
|
def send_message(session_id, content, attachments: nil)
|
|
81
198
|
ensure_started
|
|
82
|
-
|
|
83
|
-
|
|
199
|
+
result = nil
|
|
200
|
+
send_and_stream(session_id, content, attachments: attachments) do |ev|
|
|
201
|
+
result = ev.dig(:payload, "response") if ev[:type] == "turn.completed"
|
|
202
|
+
end
|
|
84
203
|
{ "response" => result }
|
|
85
204
|
end
|
|
86
205
|
|
|
206
|
+
# Send a message and stream translated events to the block.
|
|
207
|
+
#
|
|
208
|
+
# Runs the session's loop synchronously; when tools queue for
|
|
209
|
+
# approval the turn pauses and this method waits (up to turn_timeout)
|
|
210
|
+
# for the queue to drain, so subscribers receive the full turn —
|
|
211
|
+
# including follow-up turns that run when approvals resolve.
|
|
212
|
+
#
|
|
213
|
+
# @yield [Hash] events with :type, :seq, :payload
|
|
87
214
|
def send_and_stream(session_id, content, turn_timeout: 600.0, attachments: nil, &block)
|
|
88
215
|
return enum_for(:send_and_stream, session_id, content, turn_timeout: turn_timeout, attachments: attachments) unless block
|
|
89
216
|
ensure_started
|
|
90
217
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
session = Ask::Agent::Session.new(
|
|
94
|
-
model: chat,
|
|
95
|
-
max_turns: @max_turns,
|
|
96
|
-
**@session_opts
|
|
97
|
-
)
|
|
218
|
+
entry = session_entry(session_id)
|
|
219
|
+
session = (entry[:session] ||= build_session(entry))
|
|
98
220
|
|
|
99
|
-
|
|
100
|
-
|
|
221
|
+
subscription = subscribe_session(entry, &block)
|
|
222
|
+
emit(entry, { type: "turn.started", seq: next_seq(entry), payload: { "sessionId" => session_id } })
|
|
101
223
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
224
|
+
begin
|
|
225
|
+
result = run_with_approvals(entry, session, content, attachments: attachments, turn_timeout: turn_timeout)
|
|
226
|
+
if session.abort_requested?
|
|
227
|
+
emit(entry, { type: "turn.aborted", seq: next_seq(entry), payload: { "sessionId" => session_id } })
|
|
228
|
+
else
|
|
229
|
+
emit(entry, {
|
|
230
|
+
type: "turn.completed", seq: next_seq(entry),
|
|
231
|
+
payload: {
|
|
232
|
+
"response" => (result || accumulated_text(entry)).to_s,
|
|
233
|
+
"sessionId" => session_id,
|
|
234
|
+
"tokenCount" => session.total_input_tokens + session.total_output_tokens
|
|
235
|
+
}
|
|
109
236
|
})
|
|
110
237
|
end
|
|
111
|
-
end
|
|
112
|
-
|
|
113
|
-
begin
|
|
114
|
-
result = session.run(content, attachments: attachments)
|
|
115
|
-
block.call({
|
|
116
|
-
type: "turn.completed", seq: 3,
|
|
117
|
-
payload: { "response" => result, "sessionId" => session_id,
|
|
118
|
-
"tokenCount" => session.total_input_tokens + session.total_output_tokens }
|
|
119
|
-
})
|
|
120
238
|
rescue => e
|
|
121
|
-
|
|
122
|
-
type: "turn.failed", seq:
|
|
239
|
+
emit(entry, {
|
|
240
|
+
type: "turn.failed", seq: next_seq(entry),
|
|
123
241
|
payload: { "error" => { "message" => e.message }, "sessionId" => session_id }
|
|
124
242
|
})
|
|
243
|
+
ensure
|
|
244
|
+
unsubscribe_session(entry, subscription)
|
|
245
|
+
entry[:turn_active] = false
|
|
125
246
|
end
|
|
247
|
+
nil
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
# ── Approval controls ──
|
|
251
|
+
|
|
252
|
+
# Approve one queued tool action. Continues the turn (follow-up
|
|
253
|
+
# turns run in this thread and stream to active subscribers).
|
|
254
|
+
#
|
|
255
|
+
# @return [Array<Ask::Agent::ApprovalQueue::Action>]
|
|
256
|
+
def approve_action(session_id, action_id)
|
|
257
|
+
queue = approval_queue(session_id)
|
|
258
|
+
queue ? queue.approve(action_id) : []
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def reject_action(session_id, action_id)
|
|
262
|
+
queue = approval_queue(session_id)
|
|
263
|
+
queue ? queue.reject(action_id) : []
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
# Approve all pending tool actions.
|
|
267
|
+
def approve_all(session_id)
|
|
268
|
+
queue = approval_queue(session_id)
|
|
269
|
+
queue ? queue.approve_all : []
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def reject_all(session_id)
|
|
273
|
+
queue = approval_queue(session_id)
|
|
274
|
+
queue ? queue.reject_all : []
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
# Actions still awaiting a decision, as plain hashes.
|
|
278
|
+
def pending_approvals(session_id)
|
|
279
|
+
queue = approval_queue(session_id)
|
|
280
|
+
return [] unless queue
|
|
281
|
+
queue.pending_actions.map { |a| action_hash(a) }
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
# The pending plan awaiting approval (plan mode), or nil.
|
|
285
|
+
def pending_plan(session_id)
|
|
286
|
+
session = session_entry(session_id)[:session]
|
|
287
|
+
return nil unless session&.plan_queue
|
|
288
|
+
action = session.plan_queue.pending_actions.first
|
|
289
|
+
action && action_hash(action)
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
# Approve / reject the proposed plan (plan mode).
|
|
293
|
+
def approve_plan(session_id)
|
|
294
|
+
session_entry(session_id)[:session]&.plan_queue&.approve_all || []
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
def reject_plan(session_id)
|
|
298
|
+
session_entry(session_id)[:session]&.plan_queue&.reject_all || []
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
# Abort the current turn. The loop exits at the next checkpoint and
|
|
302
|
+
# the stream emits turn.aborted.
|
|
303
|
+
def abort(session_id)
|
|
304
|
+
session_entry(session_id)[:session]&.abort
|
|
126
305
|
end
|
|
127
306
|
|
|
128
307
|
def get_events(session_id, after_seq:, limit: nil)
|
|
@@ -133,30 +312,90 @@ module Ask
|
|
|
133
312
|
# No reverse requests in basic mode
|
|
134
313
|
end
|
|
135
314
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
315
|
+
def get_workspace_state(workspace_path)
|
|
316
|
+
# No workspace state to report
|
|
317
|
+
{}
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
def session_directory(session_id)
|
|
321
|
+
entry = @mutex.synchronize { @sessions[session_id] }
|
|
322
|
+
entry && entry[:workspace]
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
# Message history for a session, newest first. Empty for unknown or
|
|
326
|
+
# not-yet-run sessions.
|
|
327
|
+
def session_history(session_id, limit: 100)
|
|
328
|
+
entry = @mutex.synchronize { @sessions[session_id] }
|
|
329
|
+
return [] unless entry
|
|
330
|
+
session = entry[:session]
|
|
331
|
+
return [] unless session
|
|
332
|
+
session.messages.last(limit).reverse.map do |m|
|
|
333
|
+
{ text: m.content.to_s, role: m.role.to_s, origin: "ask_agent" }
|
|
334
|
+
end
|
|
335
|
+
end
|
|
140
336
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
337
|
+
# Build an AskAgent adapter from config.
|
|
338
|
+
# Reads ASK_AGENT_MODEL, ASK_AGENT_LLM_PROVIDER, ASK_AGENT_MAX_TURNS
|
|
339
|
+
# from ENV.
|
|
340
|
+
def self.from_config(model: nil, llm_provider: nil, max_turns: nil, **)
|
|
341
|
+
new(
|
|
342
|
+
model: model || ENV.fetch("ASK_AGENT_MODEL", "deepseek-v4-flash"),
|
|
343
|
+
provider: llm_provider || ENV.fetch("ASK_AGENT_LLM_PROVIDER", "opencode_go"),
|
|
344
|
+
max_turns: (max_turns || ENV.fetch("ASK_AGENT_MAX_TURNS", "10")).to_i
|
|
345
|
+
)
|
|
346
|
+
end
|
|
150
347
|
|
|
151
|
-
|
|
348
|
+
private
|
|
152
349
|
|
|
153
350
|
def ensure_started
|
|
154
351
|
raise "Adapter not started. Call #start first." unless @started
|
|
155
352
|
end
|
|
156
353
|
|
|
157
|
-
def
|
|
354
|
+
def session_entry(session_id)
|
|
355
|
+
entry = @mutex.synchronize { @sessions[session_id] }
|
|
356
|
+
raise ArgumentError, "Unknown session: #{session_id}" unless entry
|
|
357
|
+
entry
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
def approval_queue(session_id)
|
|
361
|
+
session_entry(session_id)[:session]&.approval_queue
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
# ── Session construction ──
|
|
365
|
+
|
|
366
|
+
def build_session(entry)
|
|
367
|
+
chat = build_chat(entry[:model])
|
|
368
|
+
|
|
369
|
+
queue = EmittingApprovalQueue.new(
|
|
370
|
+
on_submit: ->(a) { emit_approval(entry, a, :pending) },
|
|
371
|
+
on_status: ->(a) { emit_approval(entry, a, a.status) }
|
|
372
|
+
)
|
|
373
|
+
|
|
374
|
+
approval_cfg =
|
|
375
|
+
case @approval
|
|
376
|
+
when APPROVAL_REQUIRE then { queue: queue, require_approval: @approval_required }
|
|
377
|
+
when APPROVAL_AUTO then { queue: queue }
|
|
378
|
+
when APPROVAL_OFF then nil
|
|
379
|
+
else
|
|
380
|
+
raise ArgumentError, "approval must be one of #{APPROVAL_MODES.inspect}, got #{@approval.inspect}"
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
Ask::Agent::Session.new(
|
|
384
|
+
model: chat,
|
|
385
|
+
tools: @tools,
|
|
386
|
+
max_turns: @max_turns,
|
|
387
|
+
approval: approval_cfg,
|
|
388
|
+
plan_mode: @plan_mode,
|
|
389
|
+
todos: @todos,
|
|
390
|
+
**@session_opts
|
|
391
|
+
).tap do |session|
|
|
392
|
+
session.on_event { |event| translate_event(entry, session, event) }
|
|
393
|
+
end
|
|
394
|
+
end
|
|
395
|
+
|
|
396
|
+
def build_chat(model_id)
|
|
158
397
|
chat = Ask::Agent::Chat.new(
|
|
159
|
-
model:
|
|
398
|
+
model: model_id,
|
|
160
399
|
provider: @provider_slug,
|
|
161
400
|
tools: @tools
|
|
162
401
|
)
|
|
@@ -165,14 +404,152 @@ module Ask
|
|
|
165
404
|
chat
|
|
166
405
|
end
|
|
167
406
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
407
|
+
# ── Turn lifecycle ──
|
|
408
|
+
|
|
409
|
+
# Run the agent loop, then wait — up to turn_timeout — for the turn
|
|
410
|
+
# to fully settle: idle, no pending tools, and an empty approval
|
|
411
|
+
# queue. Approvals resolve from other threads; each resolution may
|
|
412
|
+
# trigger follow-up turns inside ask-agent (via
|
|
413
|
+
# complete_pending_tool), whose events stream to the same
|
|
414
|
+
# subscribers. The settle grace period absorbs the gap between the
|
|
415
|
+
# queue draining and a follow-up turn starting.
|
|
416
|
+
def run_with_approvals(entry, session, content, attachments:, turn_timeout:)
|
|
417
|
+
result = session.run(content, attachments: attachments)
|
|
418
|
+
entry[:turn_active] = true
|
|
419
|
+
deadline = monotonic + turn_timeout
|
|
420
|
+
settle_count = 0
|
|
421
|
+
|
|
422
|
+
loop do
|
|
423
|
+
return result if session.abort_requested?
|
|
424
|
+
if turn_settled?(session, entry)
|
|
425
|
+
settle_count += 1
|
|
426
|
+
return result if settle_count >= SETTLE_POLLS
|
|
427
|
+
else
|
|
428
|
+
settle_count = 0
|
|
429
|
+
end
|
|
430
|
+
|
|
431
|
+
if monotonic > deadline
|
|
432
|
+
session.abort
|
|
433
|
+
raise Timeout::Error, "Turn timed out after #{turn_timeout}s"
|
|
434
|
+
end
|
|
435
|
+
sleep SETTLE_POLL_INTERVAL
|
|
436
|
+
end
|
|
437
|
+
end
|
|
438
|
+
|
|
439
|
+
def turn_settled?(session, _entry)
|
|
440
|
+
queue = session.approval_queue
|
|
441
|
+
!session.running? &&
|
|
442
|
+
!session.pending_tools? &&
|
|
443
|
+
(queue.nil? || queue.pending_actions.empty?)
|
|
444
|
+
end
|
|
445
|
+
|
|
446
|
+
def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
447
|
+
|
|
448
|
+
# ── Event translation (ask-agent events → adapter events) ──
|
|
449
|
+
|
|
450
|
+
def translate_event(entry, session, event)
|
|
451
|
+
payload = { "sessionId" => session.id }
|
|
452
|
+
case event
|
|
453
|
+
when Ask::Agent::Events::TextDelta
|
|
454
|
+
emit(entry, { type: "model.streaming", seq: next_seq(entry), payload: payload.merge("delta" => event.content) })
|
|
455
|
+
when Ask::Agent::Events::ThinkingDelta
|
|
456
|
+
emit(entry, { type: "model.thinking", seq: next_seq(entry), payload: payload.merge("delta" => event.content) })
|
|
457
|
+
when Ask::Agent::Events::ToolExecutionStart
|
|
458
|
+
emit(entry, {
|
|
459
|
+
type: "tool.use", seq: next_seq(entry),
|
|
460
|
+
payload: payload.merge("toolName" => event.name, "input" => event.arguments, "toolCallId" => event.id)
|
|
461
|
+
})
|
|
462
|
+
when Ask::Agent::Events::ToolExecutionUpdate
|
|
463
|
+
emit(entry, {
|
|
464
|
+
type: "tool.delta", seq: next_seq(entry),
|
|
465
|
+
payload: payload.merge("toolName" => event.name, "toolCallId" => event.id, "partial" => event.partial_result)
|
|
466
|
+
})
|
|
467
|
+
when Ask::Agent::Events::ToolExecutionEnd
|
|
468
|
+
emit(entry, {
|
|
469
|
+
type: "tool.result", seq: next_seq(entry),
|
|
470
|
+
payload: payload.merge(
|
|
471
|
+
"toolName" => event.name, "toolCallId" => event.id,
|
|
472
|
+
"output" => tool_output(event.result),
|
|
473
|
+
"isError" => !!event.is_error,
|
|
474
|
+
"durationMs" => event.duration_ms
|
|
475
|
+
)
|
|
476
|
+
})
|
|
477
|
+
when Ask::Agent::Events::TodoUpdated
|
|
478
|
+
emit(entry, { type: "todos.updated", seq: next_seq(entry), payload: payload.merge("todos" => event.todos) })
|
|
479
|
+
when Ask::Agent::Events::PlanProposed
|
|
480
|
+
emit(entry, { type: "plan.proposed", seq: next_seq(entry), payload: payload.merge("plan" => event.plan) })
|
|
481
|
+
when Ask::Agent::Events::PlanApproved
|
|
482
|
+
emit(entry, { type: "plan.approved", seq: next_seq(entry), payload: payload.merge("plan" => event.plan) })
|
|
483
|
+
when Ask::Agent::Events::PlanRejected
|
|
484
|
+
emit(entry, { type: "plan.rejected", seq: next_seq(entry), payload: payload.merge("plan" => event.plan) })
|
|
485
|
+
when Ask::Agent::Events::Error
|
|
486
|
+
emit(entry, { type: "error", seq: next_seq(entry), payload: payload.merge("error" => { "message" => event.error.to_s }) })
|
|
487
|
+
end
|
|
488
|
+
end
|
|
489
|
+
|
|
490
|
+
def tool_output(result)
|
|
491
|
+
return result.to_s if result.nil?
|
|
492
|
+
return result.to_s if result.respond_to?(:to_s) && !result.respond_to?(:data) && !result.respond_to?(:message)
|
|
493
|
+
if result.respond_to?(:ok?)
|
|
494
|
+
result.ok? ? result.data.to_s : result.message.to_s
|
|
495
|
+
else
|
|
496
|
+
result.to_s
|
|
497
|
+
end
|
|
498
|
+
end
|
|
499
|
+
|
|
500
|
+
# ── Subscription / emission ──
|
|
501
|
+
|
|
502
|
+
def subscribe_session(entry, &block)
|
|
503
|
+
@mutex.synchronize { entry[:subscribers] << block }
|
|
504
|
+
block
|
|
505
|
+
end
|
|
506
|
+
|
|
507
|
+
def unsubscribe_session(entry, subscription)
|
|
508
|
+
@mutex.synchronize { entry[:subscribers].delete(subscription) }
|
|
509
|
+
end
|
|
510
|
+
|
|
511
|
+
def emit(entry, event)
|
|
512
|
+
if event[:type] == "model.streaming"
|
|
513
|
+
@mutex.synchronize do
|
|
514
|
+
entry[:streaming_text] = entry[:streaming_text].to_s + event.dig(:payload, "delta").to_s
|
|
515
|
+
end
|
|
516
|
+
end
|
|
517
|
+
subscribers = @mutex.synchronize { entry[:subscribers].dup }
|
|
518
|
+
subscribers.each { |sub| sub.call(event) }
|
|
519
|
+
end
|
|
520
|
+
|
|
521
|
+
def emit_approval(entry, action, status)
|
|
522
|
+
payload = {
|
|
523
|
+
"sessionId" => entry[:session]&.id,
|
|
524
|
+
"actionId" => action.id,
|
|
525
|
+
"toolName" => action.tool_name,
|
|
526
|
+
"args" => action.args,
|
|
527
|
+
"message" => action.message,
|
|
528
|
+
"autoApprovable" => action.auto_approvable,
|
|
529
|
+
"status" => status.to_s
|
|
530
|
+
}
|
|
531
|
+
type = status == :pending ? "approval.required" : "approval.updated"
|
|
532
|
+
emit(entry, { type: type, seq: next_seq(entry), payload: payload })
|
|
533
|
+
end
|
|
534
|
+
|
|
535
|
+
def accumulated_text(entry)
|
|
536
|
+
@mutex.synchronize { entry[:streaming_text].to_s }
|
|
537
|
+
end
|
|
538
|
+
|
|
539
|
+
def action_hash(action)
|
|
540
|
+
{
|
|
541
|
+
"id" => action.id,
|
|
542
|
+
"tool_call_id" => action.tool_call_id,
|
|
543
|
+
"tool_name" => action.tool_name,
|
|
544
|
+
"args" => action.args,
|
|
545
|
+
"auto_approvable" => action.auto_approvable,
|
|
546
|
+
"status" => action.status.to_s,
|
|
547
|
+
"message" => action.message
|
|
548
|
+
}
|
|
549
|
+
end
|
|
550
|
+
|
|
551
|
+
def next_seq(entry)
|
|
552
|
+
@mutex.synchronize { entry[:seq] += 1 }
|
|
176
553
|
end
|
|
177
554
|
end
|
|
178
555
|
end
|
|
@@ -90,10 +90,12 @@ module Ask
|
|
|
90
90
|
case event["type"]
|
|
91
91
|
when "assistant"
|
|
92
92
|
msg = event["message"] || {}
|
|
93
|
-
(msg["content"] || []).each do |
|
|
94
|
-
if
|
|
95
|
-
delta =
|
|
93
|
+
(msg["content"] || []).each do |content_block|
|
|
94
|
+
if content_block["type"] == "text" && content_block["text"]
|
|
95
|
+
delta = content_block["text"]
|
|
96
96
|
accumulated += delta
|
|
97
|
+
# NB: the inner loop variable must not shadow the
|
|
98
|
+
# method's &block — block.call streams to the caller.
|
|
97
99
|
block.call({
|
|
98
100
|
type: "model.streaming", seq: 2,
|
|
99
101
|
payload: { "delta" => delta, "sessionId" => session_id }
|
|
@@ -55,7 +55,7 @@ module Ask
|
|
|
55
55
|
return [] unless available?
|
|
56
56
|
|
|
57
57
|
db = open_db
|
|
58
|
-
rows = db.execute(<<~SQL, [directory, directory])
|
|
58
|
+
rows = db.execute(<<~SQL, [directory, directory, limit])
|
|
59
59
|
SELECT id, title, updated_at, preview
|
|
60
60
|
FROM threads
|
|
61
61
|
WHERE (cwd = ? OR ? LIKE cwd || '/%')
|
|
@@ -99,7 +99,7 @@ module Ask
|
|
|
99
99
|
return nil unless available?
|
|
100
100
|
|
|
101
101
|
db = open_db
|
|
102
|
-
row = db.get_first_row(<<~SQL, workspace_path, workspace_path)
|
|
102
|
+
row = db.get_first_row(<<~SQL, [workspace_path, workspace_path])
|
|
103
103
|
SELECT id, title, cwd FROM threads
|
|
104
104
|
WHERE (cwd = ? OR ? LIKE cwd || '/%')
|
|
105
105
|
AND archived = 0
|