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: ed62b6a25b44845e1a4e2c8c3d1b00c07e49f108a53448e7691d5f2468af76bc
4
- data.tar.gz: 985d2a2d0e93157d590afa9223a4434c8f481205f026cf46db5fa4bec751e763
3
+ metadata.gz: b1b9421ae222addaf585ea8b26333306078843d0574a4b71a34cecaa3fc4ff79
4
+ data.tar.gz: 8c7d16435c702703f0f661474077389c28d4fc796aafe196bfce4ccd1f7019b2
5
5
  SHA512:
6
- metadata.gz: 173a8066a3b737d8dc9ef5cb3b5e1d514fbab7a25df8a96acb91f064d25f2badecb8f8219d5658b4a04565a46f15d4c0f9c051274f68f4fc0a777d9d2d1eafa7
7
- data.tar.gz: 7d3b34d1ae1c65e8b7f22eb19ef1e61dbe4b86a1fa9c97eab807decea3c2c9e0d84ac95e1d4e1fab2d4c562e8c32f500237a21f21f5f8f2b412b16680ff027cd
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
- # No app-server, no external binary sessions run in the same process.
11
- # Perfect for deployments where running a separate process is impractical.
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(model: "deepseek-v4-flash", provider: "opencode_go")
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
- def initialize(model:, provider:, tools: [], max_turns: 25, **session_opts)
24
- # Lazy require these gems are optional for the gem but required for this adapter
25
- begin
26
- require "ask-llm-providers"
27
- require "ask/agent"
28
- rescue LoadError => e
29
- raise "Missing dependency for AskAgent adapter: #{e.message}. Add ask-agent and ask-llm-providers to your Gemfile."
30
- end
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
- def start
42
- return if @started
43
- klass = Ask::Provider.resolve(@provider_slug)
44
- compat = klass.respond_to?(:compat_config) ? klass.compat_config : {}
45
- api_key = ENV[compat[:alternate_env].to_s] || ENV[compat[:api_key_env].to_s] || ENV["#{@provider_slug.upcase}_API_KEY"]
46
- @provider = klass.new(api_key: api_key)
47
- @started = true
48
- end
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
- def create_session(workspace_path, mode: nil)
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
- @sessions ||= {}
66
- @sessions[sid] = { workspace: workspace_path, mode: mode, created_at: Time.now }
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&.dig(session_id) || {}
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" => 0 }
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
- # Run synchronously — returns when done
83
- result = run_session(session_id, content, attachments: attachments)
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
- # Build the ask-agent chat with our pre-configured provider
92
- chat = build_chat
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
- # Emit turn.started
100
- block.call({ type: "turn.started", seq: 1, payload: { "sessionId" => session_id } })
221
+ subscription = subscribe_session(entry, &block)
222
+ emit(entry, { type: "turn.started", seq: next_seq(entry), payload: { "sessionId" => session_id } })
101
223
 
102
- # Wire streaming events
103
- session.on_event do |event|
104
- case event
105
- when Ask::Agent::Events::TextDelta
106
- block.call({
107
- type: "model.streaming", seq: 2,
108
- payload: { "delta" => event.content, "sessionId" => session_id }
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
- block.call({
122
- type: "turn.failed", seq: 3,
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
- def get_workspace_state(workspace_path)
137
- # No workspace state to report
138
- {}
139
- end
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
- # Build an AskAgent adapter from config.
142
- # Reads ASK_AGENT_MODEL, ASK_AGENT_LLM_PROVIDER, ASK_AGENT_MAX_TURNS from ENV.
143
- def self.from_config(model: nil, llm_provider: nil, max_turns: nil, **)
144
- new(
145
- model: model || ENV.fetch("ASK_AGENT_MODEL", "deepseek-v4-flash"),
146
- provider: llm_provider || ENV.fetch("ASK_AGENT_LLM_PROVIDER", "opencode_go"),
147
- max_turns: (max_turns || ENV.fetch("ASK_AGENT_MAX_TURNS", "10")).to_i
148
- )
149
- end
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
- private
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 build_chat
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: @model_id,
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
- def run_session(session_id, content, attachments: nil)
169
- chat = build_chat
170
- session = Ask::Agent::Session.new(
171
- model: chat,
172
- max_turns: @max_turns,
173
- **@session_opts
174
- )
175
- session.run(content, attachments: attachments)
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 |block|
94
- if block["type"] == "text" && block["text"]
95
- delta = block["text"]
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
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Ask
4
4
  module CodingProviders
5
- VERSION = "0.2.1"
5
+ VERSION = "0.3.0"
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ask-coding-providers
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.1
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kaka Ruto