ask-coding-providers 0.2.1 → 0.3.1

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: f1e380717ee69fa2d0da0df5a2c706e2b6e8ef3509ea6f6baad0c96d170d62f3
4
+ data.tar.gz: 21d327fadd79f5fbbdce88c71e1009b2f45b03431bd7cf29e258f4bcb07b3b05
5
5
  SHA512:
6
- metadata.gz: 173a8066a3b737d8dc9ef5cb3b5e1d514fbab7a25df8a96acb91f064d25f2badecb8f8219d5658b4a04565a46f15d4c0f9c051274f68f4fc0a777d9d2d1eafa7
7
- data.tar.gz: 7d3b34d1ae1c65e8b7f22eb19ef1e61dbe4b86a1fa9c97eab807decea3c2c9e0d84ac95e1d4e1fab2d4c562e8c32f500237a21f21f5f8f2b412b16680ff027cd
6
+ metadata.gz: 975feb731eb168bd1a291c395e6421fd882ca9b0a23766602dde39352a187cb79c354df70d531e4516eb6050854e2086bbe3c629185e7751ea6d54c6554eca3a
7
+ data.tar.gz: 23cf6fe197707a8a0d456fbc8299f004cbb87134b17393a634305d227d1ada59b1bf476458ad482cc734317be8b87ae0972e240f6bc87a5d2a08ee1576707c57
@@ -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,170 @@ 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
+ # @param system_prompt [String, nil] system prompt override for this
151
+ # session (takes precedence over any system_prompt in session_opts)
152
+ def create_session(workspace_path, mode: nil, model: nil, system_prompt: nil)
63
153
  ensure_started
64
154
  sid = "sess_#{SecureRandom.uuid}"
65
- @sessions ||= {}
66
- @sessions[sid] = { workspace: workspace_path, mode: mode, created_at: Time.now }
155
+ @mutex.synchronize do
156
+ @sessions[sid] = {
157
+ workspace: workspace_path,
158
+ mode: mode,
159
+ model: model || @model_id,
160
+ system_prompt: system_prompt,
161
+ created_at: Time.now,
162
+ session: nil,
163
+ subscribers: [],
164
+ seq: 0,
165
+ turn_active: false
166
+ }
167
+ end
67
168
  sid
68
169
  end
69
170
 
70
171
  def resume_session(session_id)
71
172
  ensure_started
72
- @sessions&.dig(session_id) || {}
173
+ entry = @sessions[session_id]
174
+ return {} unless entry
175
+ {
176
+ "session_id" => session_id,
177
+ "workspace" => entry[:workspace],
178
+ "model" => entry[:model],
179
+ "created_at" => entry[:created_at].iso8601
180
+ }
181
+ end
182
+
183
+ def list_sessions(workspace_path: nil, limit: 20)
184
+ ensure_started
185
+ @mutex.synchronize do
186
+ @sessions
187
+ .select { |_sid, e| workspace_path.nil? || e[:workspace] == workspace_path }
188
+ .sort_by { |sid, e| [e[:created_at], sid] }
189
+ .reverse
190
+ .first(limit)
191
+ .map { |sid, e| { session_id: sid, workspace: e[:workspace], created_at: e[:created_at].iso8601 } }
192
+ end
73
193
  end
74
194
 
75
195
  def subscribe(session_id, after_seq: 0)
76
196
  ensure_started
77
- { "eventSeq" => 0 }
197
+ { "eventSeq" => session_entry(session_id)[:seq] }
78
198
  end
79
199
 
80
200
  def send_message(session_id, content, attachments: nil)
81
201
  ensure_started
82
- # Run synchronously — returns when done
83
- result = run_session(session_id, content, attachments: attachments)
202
+ result = nil
203
+ send_and_stream(session_id, content, attachments: attachments) do |ev|
204
+ result = ev.dig(:payload, "response") if ev[:type] == "turn.completed"
205
+ end
84
206
  { "response" => result }
85
207
  end
86
208
 
209
+ # Send a message and stream translated events to the block.
210
+ #
211
+ # Runs the session's loop synchronously; when tools queue for
212
+ # approval the turn pauses and this method waits (up to turn_timeout)
213
+ # for the queue to drain, so subscribers receive the full turn —
214
+ # including follow-up turns that run when approvals resolve.
215
+ #
216
+ # @yield [Hash] events with :type, :seq, :payload
87
217
  def send_and_stream(session_id, content, turn_timeout: 600.0, attachments: nil, &block)
88
218
  return enum_for(:send_and_stream, session_id, content, turn_timeout: turn_timeout, attachments: attachments) unless block
89
219
  ensure_started
90
220
 
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
- )
221
+ entry = session_entry(session_id)
222
+ session = (entry[:session] ||= build_session(entry))
98
223
 
99
- # Emit turn.started
100
- block.call({ type: "turn.started", seq: 1, payload: { "sessionId" => session_id } })
224
+ subscription = subscribe_session(entry, &block)
225
+ emit(entry, { type: "turn.started", seq: next_seq(entry), payload: { "sessionId" => session_id } })
101
226
 
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 }
227
+ begin
228
+ result = run_with_approvals(entry, session, content, attachments: attachments, turn_timeout: turn_timeout)
229
+ if session.abort_requested?
230
+ emit(entry, { type: "turn.aborted", seq: next_seq(entry), payload: { "sessionId" => session_id } })
231
+ else
232
+ emit(entry, {
233
+ type: "turn.completed", seq: next_seq(entry),
234
+ payload: {
235
+ "response" => (result || accumulated_text(entry)).to_s,
236
+ "sessionId" => session_id,
237
+ "tokenCount" => session.total_input_tokens + session.total_output_tokens
238
+ }
109
239
  })
110
240
  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
241
  rescue => e
121
- block.call({
122
- type: "turn.failed", seq: 3,
242
+ emit(entry, {
243
+ type: "turn.failed", seq: next_seq(entry),
123
244
  payload: { "error" => { "message" => e.message }, "sessionId" => session_id }
124
245
  })
246
+ ensure
247
+ unsubscribe_session(entry, subscription)
248
+ entry[:turn_active] = false
125
249
  end
250
+ nil
251
+ end
252
+
253
+ # ── Approval controls ──
254
+
255
+ # Approve one queued tool action. Continues the turn (follow-up
256
+ # turns run in this thread and stream to active subscribers).
257
+ #
258
+ # @return [Array<Ask::Agent::ApprovalQueue::Action>]
259
+ def approve_action(session_id, action_id)
260
+ queue = approval_queue(session_id)
261
+ queue ? queue.approve(action_id) : []
262
+ end
263
+
264
+ def reject_action(session_id, action_id)
265
+ queue = approval_queue(session_id)
266
+ queue ? queue.reject(action_id) : []
267
+ end
268
+
269
+ # Approve all pending tool actions.
270
+ def approve_all(session_id)
271
+ queue = approval_queue(session_id)
272
+ queue ? queue.approve_all : []
273
+ end
274
+
275
+ def reject_all(session_id)
276
+ queue = approval_queue(session_id)
277
+ queue ? queue.reject_all : []
278
+ end
279
+
280
+ # Actions still awaiting a decision, as plain hashes.
281
+ def pending_approvals(session_id)
282
+ queue = approval_queue(session_id)
283
+ return [] unless queue
284
+ queue.pending_actions.map { |a| action_hash(a) }
285
+ end
286
+
287
+ # The pending plan awaiting approval (plan mode), or nil.
288
+ def pending_plan(session_id)
289
+ session = session_entry(session_id)[:session]
290
+ return nil unless session&.plan_queue
291
+ action = session.plan_queue.pending_actions.first
292
+ action && action_hash(action)
293
+ end
294
+
295
+ # Approve / reject the proposed plan (plan mode).
296
+ def approve_plan(session_id)
297
+ session_entry(session_id)[:session]&.plan_queue&.approve_all || []
298
+ end
299
+
300
+ def reject_plan(session_id)
301
+ session_entry(session_id)[:session]&.plan_queue&.reject_all || []
302
+ end
303
+
304
+ # Abort the current turn. The loop exits at the next checkpoint and
305
+ # the stream emits turn.aborted.
306
+ def abort(session_id)
307
+ session_entry(session_id)[:session]&.abort
126
308
  end
127
309
 
128
310
  def get_events(session_id, after_seq:, limit: nil)
@@ -133,46 +315,246 @@ module Ask
133
315
  # No reverse requests in basic mode
134
316
  end
135
317
 
136
- def get_workspace_state(workspace_path)
137
- # No workspace state to report
138
- {}
139
- end
318
+ def get_workspace_state(workspace_path)
319
+ # No workspace state to report
320
+ {}
321
+ end
322
+
323
+ def session_directory(session_id)
324
+ entry = @mutex.synchronize { @sessions[session_id] }
325
+ entry && entry[:workspace]
326
+ end
327
+
328
+ # Message history for a session, newest first. Empty for unknown or
329
+ # not-yet-run sessions.
330
+ def session_history(session_id, limit: 100)
331
+ entry = @mutex.synchronize { @sessions[session_id] }
332
+ return [] unless entry
333
+ session = entry[:session]
334
+ return [] unless session
335
+ session.messages.last(limit).reverse.map do |m|
336
+ { text: m.content.to_s, role: m.role.to_s, origin: "ask_agent" }
337
+ end
338
+ end
140
339
 
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
340
+ # Build an AskAgent adapter from config.
341
+ # Reads ASK_AGENT_MODEL, ASK_AGENT_LLM_PROVIDER, ASK_AGENT_MAX_TURNS
342
+ # from ENV.
343
+ def self.from_config(model: nil, llm_provider: nil, max_turns: nil, **)
344
+ new(
345
+ model: model || ENV.fetch("ASK_AGENT_MODEL", "deepseek-v4-flash"),
346
+ provider: llm_provider || ENV.fetch("ASK_AGENT_LLM_PROVIDER", "opencode_go"),
347
+ max_turns: (max_turns || ENV.fetch("ASK_AGENT_MAX_TURNS", "10")).to_i
348
+ )
349
+ end
150
350
 
151
- private
351
+ private
152
352
 
153
353
  def ensure_started
154
354
  raise "Adapter not started. Call #start first." unless @started
155
355
  end
156
356
 
157
- def build_chat
357
+ def session_entry(session_id)
358
+ entry = @mutex.synchronize { @sessions[session_id] }
359
+ raise ArgumentError, "Unknown session: #{session_id}" unless entry
360
+ entry
361
+ end
362
+
363
+ def approval_queue(session_id)
364
+ session_entry(session_id)[:session]&.approval_queue
365
+ end
366
+
367
+ # ── Session construction ──
368
+
369
+ def build_session(entry)
370
+ prompt = entry[:system_prompt] || @session_opts[:system_prompt]
371
+ chat = build_chat(entry[:model], prompt)
372
+
373
+ queue = EmittingApprovalQueue.new(
374
+ on_submit: ->(a) { emit_approval(entry, a, :pending) },
375
+ on_status: ->(a) { emit_approval(entry, a, a.status) }
376
+ )
377
+
378
+ approval_cfg =
379
+ case @approval
380
+ when APPROVAL_REQUIRE then { queue: queue, require_approval: @approval_required }
381
+ when APPROVAL_AUTO then { queue: queue }
382
+ when APPROVAL_OFF then nil
383
+ else
384
+ raise ArgumentError, "approval must be one of #{APPROVAL_MODES.inspect}, got #{@approval.inspect}"
385
+ end
386
+
387
+ Ask::Agent::Session.new(
388
+ model: chat,
389
+ tools: @tools,
390
+ max_turns: @max_turns,
391
+ approval: approval_cfg,
392
+ plan_mode: @plan_mode,
393
+ todos: @todos,
394
+ **@session_opts
395
+ ).tap do |session|
396
+ session.on_event { |event| translate_event(entry, session, event) }
397
+ end
398
+ end
399
+
400
+ def build_chat(model_id, system_prompt = nil)
158
401
  chat = Ask::Agent::Chat.new(
159
- model: @model_id,
402
+ model: model_id,
160
403
  provider: @provider_slug,
161
404
  tools: @tools
162
405
  )
406
+ chat.with_instructions(system_prompt) if system_prompt
163
407
  # Inject pre-configured provider to bypass Ask::Auth.resolve
164
408
  chat.instance_variable_set(:@provider, @provider)
165
409
  chat
166
410
  end
167
411
 
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)
412
+ # ── Turn lifecycle ──
413
+
414
+ # Run the agent loop, then wait — up to turn_timeout — for the turn
415
+ # to fully settle: idle, no pending tools, and an empty approval
416
+ # queue. Approvals resolve from other threads; each resolution may
417
+ # trigger follow-up turns inside ask-agent (via
418
+ # complete_pending_tool), whose events stream to the same
419
+ # subscribers. The settle grace period absorbs the gap between the
420
+ # queue draining and a follow-up turn starting.
421
+ def run_with_approvals(entry, session, content, attachments:, turn_timeout:)
422
+ result = session.run(content, attachments: attachments)
423
+ entry[:turn_active] = true
424
+ deadline = monotonic + turn_timeout
425
+ settle_count = 0
426
+
427
+ loop do
428
+ return result if session.abort_requested?
429
+ if turn_settled?(session, entry)
430
+ settle_count += 1
431
+ return result if settle_count >= SETTLE_POLLS
432
+ else
433
+ settle_count = 0
434
+ end
435
+
436
+ if monotonic > deadline
437
+ session.abort
438
+ raise Timeout::Error, "Turn timed out after #{turn_timeout}s"
439
+ end
440
+ sleep SETTLE_POLL_INTERVAL
441
+ end
442
+ end
443
+
444
+ def turn_settled?(session, _entry)
445
+ queue = session.approval_queue
446
+ !session.running? &&
447
+ !session.pending_tools? &&
448
+ (queue.nil? || queue.pending_actions.empty?)
449
+ end
450
+
451
+ def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
452
+
453
+ # ── Event translation (ask-agent events → adapter events) ──
454
+
455
+ def translate_event(entry, session, event)
456
+ payload = { "sessionId" => session.id }
457
+ case event
458
+ when Ask::Agent::Events::TextDelta
459
+ emit(entry, { type: "model.streaming", seq: next_seq(entry), payload: payload.merge("delta" => event.content) })
460
+ when Ask::Agent::Events::ThinkingDelta
461
+ emit(entry, { type: "model.thinking", seq: next_seq(entry), payload: payload.merge("delta" => event.content) })
462
+ when Ask::Agent::Events::ToolExecutionStart
463
+ emit(entry, {
464
+ type: "tool.use", seq: next_seq(entry),
465
+ payload: payload.merge("toolName" => event.name, "input" => event.arguments, "toolCallId" => event.id)
466
+ })
467
+ when Ask::Agent::Events::ToolExecutionUpdate
468
+ emit(entry, {
469
+ type: "tool.delta", seq: next_seq(entry),
470
+ payload: payload.merge("toolName" => event.name, "toolCallId" => event.id, "partial" => event.partial_result)
471
+ })
472
+ when Ask::Agent::Events::ToolExecutionEnd
473
+ emit(entry, {
474
+ type: "tool.result", seq: next_seq(entry),
475
+ payload: payload.merge(
476
+ "toolName" => event.name, "toolCallId" => event.id,
477
+ "output" => tool_output(event.result),
478
+ "isError" => !!event.is_error,
479
+ "durationMs" => event.duration_ms
480
+ )
481
+ })
482
+ when Ask::Agent::Events::TodoUpdated
483
+ emit(entry, { type: "todos.updated", seq: next_seq(entry), payload: payload.merge("todos" => event.todos) })
484
+ when Ask::Agent::Events::PlanProposed
485
+ emit(entry, { type: "plan.proposed", seq: next_seq(entry), payload: payload.merge("plan" => event.plan) })
486
+ when Ask::Agent::Events::PlanApproved
487
+ emit(entry, { type: "plan.approved", seq: next_seq(entry), payload: payload.merge("plan" => event.plan) })
488
+ when Ask::Agent::Events::PlanRejected
489
+ emit(entry, { type: "plan.rejected", seq: next_seq(entry), payload: payload.merge("plan" => event.plan) })
490
+ when Ask::Agent::Events::Error
491
+ emit(entry, { type: "error", seq: next_seq(entry), payload: payload.merge("error" => { "message" => event.error.to_s }) })
492
+ end
493
+ end
494
+
495
+ def tool_output(result)
496
+ return result.to_s if result.nil?
497
+ return result.to_s if result.respond_to?(:to_s) && !result.respond_to?(:data) && !result.respond_to?(:message)
498
+ if result.respond_to?(:ok?)
499
+ result.ok? ? result.data.to_s : result.message.to_s
500
+ else
501
+ result.to_s
502
+ end
503
+ end
504
+
505
+ # ── Subscription / emission ──
506
+
507
+ def subscribe_session(entry, &block)
508
+ @mutex.synchronize { entry[:subscribers] << block }
509
+ block
510
+ end
511
+
512
+ def unsubscribe_session(entry, subscription)
513
+ @mutex.synchronize { entry[:subscribers].delete(subscription) }
514
+ end
515
+
516
+ def emit(entry, event)
517
+ if event[:type] == "model.streaming"
518
+ @mutex.synchronize do
519
+ entry[:streaming_text] = entry[:streaming_text].to_s + event.dig(:payload, "delta").to_s
520
+ end
521
+ end
522
+ subscribers = @mutex.synchronize { entry[:subscribers].dup }
523
+ subscribers.each { |sub| sub.call(event) }
524
+ end
525
+
526
+ def emit_approval(entry, action, status)
527
+ payload = {
528
+ "sessionId" => entry[:session]&.id,
529
+ "actionId" => action.id,
530
+ "toolName" => action.tool_name,
531
+ "args" => action.args,
532
+ "message" => action.message,
533
+ "autoApprovable" => action.auto_approvable,
534
+ "status" => status.to_s
535
+ }
536
+ type = status == :pending ? "approval.required" : "approval.updated"
537
+ emit(entry, { type: type, seq: next_seq(entry), payload: payload })
538
+ end
539
+
540
+ def accumulated_text(entry)
541
+ @mutex.synchronize { entry[:streaming_text].to_s }
542
+ end
543
+
544
+ def action_hash(action)
545
+ {
546
+ "id" => action.id,
547
+ "tool_call_id" => action.tool_call_id,
548
+ "tool_name" => action.tool_name,
549
+ "args" => action.args,
550
+ "auto_approvable" => action.auto_approvable,
551
+ "status" => action.status.to_s,
552
+ "message" => action.message
553
+ }
554
+ end
555
+
556
+ def next_seq(entry)
557
+ @mutex.synchronize { entry[:seq] += 1 }
176
558
  end
177
559
  end
178
560
  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.1"
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.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kaka Ruto