ask-agent 0.38.0 → 0.40.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: f3486ed15f739d216a4b0dd46e8efdb29cc6cc7689a70ea4a2a6ef5477444037
4
- data.tar.gz: 628f14e1905695cf814eb3ff49012f924d30ce13c92c25ef425ec695dc37502f
3
+ metadata.gz: 969e484bfb85499a953c458a677564c9e0419066dcb931d12f451d0297b1c355
4
+ data.tar.gz: 3f13c4c60d09bfe2918f2bd974cb90c82f06224f492575e52a0e8f8aea1ebac4
5
5
  SHA512:
6
- metadata.gz: 7cd3f07a7dbcaa19379815eb1999f225bef3baa938ab34e018c0d1c0d755edbd5e78ba67881f65098e4b3a59b2f6696e75e1a6bfa2bba3ba17c9cb151918c9ae
7
- data.tar.gz: 8644f8522aa5ea4719bb6e02d6dc19ce85d66f2e5e53765da02fcb8a002c67540f60ce31f787abb091a4e76b80a83fdb82d538b2866506385fa8c66e94f54d4a
6
+ metadata.gz: 3ee591ac269d606a3b8c07e1ea8dee3407174351efcf7fe45fd2972318382feac61be8175bd9110d4307fcef67bade241fd991ebb121711dae569bd831c08e60
7
+ data.tar.gz: debc87ce3557c9e40883078958fa2614b1c136ae7bd703512f8faa052b865586ccba4d69aab0eed9cb6e4ebc27537724d4bf95a6d5f0bcbb785b92f91861f676
data/CHANGELOG.md CHANGED
@@ -1,3 +1,26 @@
1
+ ## [0.40.0] — 2026-08-10
2
+
3
+ ### Fixed
4
+
5
+ - **Approval race: completing a queued tool call now always lands.**
6
+ The approval policy queues a tool call inside the executor (before_tool
7
+ hook), but the loop only registered the pending call *after* the executor
8
+ returned. An approval landing in that window found nothing to complete —
9
+ the call was then registered as a ghost pending entry and the turn never
10
+ settled. Two changes close it:
11
+ - `ApprovalQueue` gains an `on_submit` callback that fires **before** the
12
+ auto-approval drain; `Session#build_approval` (and the plan queue) wire
13
+ it to `register_pending_tool`, so the pending call exists the moment the
14
+ action is queued.
15
+ - `Session#register_pending_tool` skips re-registration for calls that
16
+ were already resolved (`action_id` check) or recently completed
17
+ (bounded `@recently_completed` set), so a late loop registration cannot
18
+ resurrect a completed call.
19
+
20
+ ### Added
21
+
22
+ - `ApprovalQueue#on_submit` accessor and `on_submit:` initializer argument.
23
+
1
24
  ## [0.38.0] — 2026-08-07
2
25
 
3
26
  ### Added
@@ -55,9 +55,24 @@ module Ask
55
55
  # auto-approval rules keyed by tool name. An action is auto-applied
56
56
  # only when its tool is listed here with +true+ AND the action itself
57
57
  # is marked auto_approvable.
58
- def initialize(on_approve: nil, on_reject: nil, auto_approve: nil)
58
+ # @!attribute [rw] on_approve
59
+ # Called with an {Action} when it is approved and applied. The
60
+ # session wires this to execute the real tool call; can be replaced
61
+ # after construction (e.g. by queue subclasses that also emit events).
62
+ # @!attribute [rw] on_reject
63
+ # Called with an {Action} when it is rejected.
64
+ # @!attribute [rw] on_submit
65
+ # Called with the new {Action} when it is submitted — BEFORE the
66
+ # auto-approval drain runs, so subscribers can register the pending
67
+ # call before it is applied. The session wires this to register the
68
+ # pending tool call, closing the race where an approval lands while
69
+ # the executor is still in flight.
70
+ attr_accessor :on_approve, :on_reject, :on_submit
71
+
72
+ def initialize(on_approve: nil, on_reject: nil, auto_approve: nil, on_submit: nil)
59
73
  @on_approve = on_approve
60
74
  @on_reject = on_reject
75
+ @on_submit = on_submit
61
76
  @auto_approve = auto_approve || {}
62
77
  @actions = {}
63
78
  @next_id = 1
@@ -90,6 +105,11 @@ module Ask
90
105
  action
91
106
  end
92
107
 
108
+ # Notify BEFORE the drain: an auto-approvable action is applied
109
+ # (and possibly completed) inside drain, and listeners need to
110
+ # observe the submission first.
111
+ @on_submit&.call(action)
112
+
93
113
  drain
94
114
  action.id
95
115
  end
@@ -53,8 +53,9 @@ module Ask
53
53
  @prompt_caching = prompt_caching.nil? ? config.prompt_caching : prompt_caching
54
54
  end
55
55
 
56
- def ask(message = nil, &block)
57
- @messages << Ask::Message.new(role: :user, content: message.to_s) if message
56
+ def ask(message = nil, attachments: nil, &block)
57
+ validate_attachment_modalities!(attachments)
58
+ @messages << Ask::Message.new(role: :user, content: merge_attachments(message, attachments)) if message || attachments
58
59
 
59
60
  stream = block_given?
60
61
  tool_defs = @tools.map { |t| Ask::ToolDef.from_tool(t) }
@@ -79,10 +80,11 @@ module Ask
79
80
  response_msg
80
81
  end
81
82
 
82
- def add_message(role:, content: nil, tool_call_id: nil, tool_calls: nil)
83
+ def add_message(role:, content: nil, tool_call_id: nil, tool_calls: nil, attachments: nil)
84
+ validate_attachment_modalities!(attachments) if role == :user
83
85
  @messages << Ask::Message.new(
84
86
  role: role,
85
- content: content,
87
+ content: merge_attachments(content, attachments),
86
88
  tool_call_id: tool_call_id,
87
89
  tool_calls: tool_calls
88
90
  )
@@ -112,6 +114,42 @@ module Ask
112
114
 
113
115
  MAX_CHAT_RETRIES = 3
114
116
 
117
+ # Merge attachments into message content as content blocks: a plain
118
+ # string becomes a Text block followed by the attachment blocks;
119
+ # Array content has the blocks appended. Returns the content
120
+ # unchanged when there are no attachments.
121
+ def merge_attachments(content, attachments)
122
+ return content if attachments.nil? || attachments.empty?
123
+
124
+ blocks = content.is_a?(Array) ? content.dup : (content ? [Ask::Content::Text.new(content.to_s)] : [])
125
+ Ask::Attachment.wrap_all(attachments).each do |item|
126
+ blocks << (item.is_a?(Ask::Attachment) ? item.to_content : item)
127
+ end
128
+ blocks
129
+ end
130
+
131
+ # :inline attachments must be within the model's input modalities.
132
+ # :context attachments are plain text (a manifest line) and always
133
+ # supported. The check is skipped when the catalog has no modality
134
+ # info for the model.
135
+ def validate_attachment_modalities!(attachments)
136
+ return if attachments.nil? || attachments.empty?
137
+
138
+ inline = Ask::Attachment.wrap_all(attachments).select { |a| a.is_a?(Ask::Attachment) && a.inline? }
139
+ return if inline.empty?
140
+
141
+ modalities = @model_info.respond_to?(:modalities) ? @model_info.modalities : nil
142
+ supported = modalities.is_a?(Hash) ? Array(modalities[:input] || modalities["input"]) : []
143
+ return if supported.empty? || supported.include?("*")
144
+
145
+ unsupported = inline.reject { |a| supported.include?(a.type.to_s) }
146
+ return if unsupported.empty?
147
+
148
+ raise Ask::Agent::UnsupportedAttachmentError,
149
+ "Model #{@model_id} cannot receive #{unsupported.map(&:type).uniq.join(', ')} attachments " \
150
+ "(supported input modalities: #{supported.join(', ')})"
151
+ end
152
+
115
153
  def provider
116
154
  @test_provider || @provider ||= build_provider
117
155
  end
@@ -17,12 +17,12 @@ module Ask
17
17
  @max_consecutive_tool_turns = max_consecutive_tool_turns
18
18
  end
19
19
 
20
- def run_turn(chat:, message:, tools:, tool_executor:, compactor:, hooks:, event_emitter:, session_id: nil, persist: nil, tool_call_repair: nil, steer_source: nil)
20
+ def run_turn(chat:, message:, tools:, tool_executor:, compactor:, hooks:, event_emitter:, session_id: nil, persist: nil, tool_call_repair: nil, steer_source: nil, attachments: nil)
21
21
  raise MaxTurnsExceeded if @turn_count >= @max_turns
22
22
 
23
23
  event_emitter.emit(Events::TurnStart.new)
24
24
 
25
- response = chat.ask(message) do |chunk|
25
+ response = chat.ask(message, attachments: attachments) do |chunk|
26
26
  if chunk.content.to_s.strip.length > 0
27
27
  event_emitter.emit(Events::TextDelta.new(content: chunk.content))
28
28
  end
@@ -6,6 +6,10 @@ require "time"
6
6
  module Ask
7
7
  module Agent
8
8
  class Session
9
+ # Max ids remembered as "recently completed" to guard against late
10
+ # loop registrations resurrecting ghost pending calls.
11
+ RECENTLY_COMPLETED_MAX = 200
12
+
9
13
  attr_reader :id, :chat, :tools, :turn_count, :created_at, :messages
10
14
  attr_reader :tool_calls_made, :total_input_tokens, :total_output_tokens, :total_cost
11
15
 
@@ -39,6 +43,7 @@ module Ask
39
43
  @deleted = false
40
44
  @abort_requested = false
41
45
  @pending_tools = {}
46
+ @recently_completed = []
42
47
  @pending_mutex = Mutex.new
43
48
  @followup_pending = false
44
49
  @turn_count = 0
@@ -63,7 +68,6 @@ module Ask
63
68
  @todos_enabled = !!todos
64
69
  @todo_list = TodoList.new if @todos_enabled
65
70
  @todo_list&.subscribe { |entries| emit(Events::TodoUpdated.new(todos: entries)) }
66
-
67
71
  # Durable memory (memory_write / memory_search tools). An instance
68
72
  # with its own namespace and state adapter; nil disables memory.
69
73
  @memory = memory
@@ -107,7 +111,19 @@ module Ask
107
111
  end
108
112
  @plan_queue = ApprovalQueue.new(
109
113
  on_approve: ->(action) { approve_plan(action) },
110
- on_reject: ->(action) { reject_plan(action) }
114
+ on_reject: ->(action) { reject_plan(action) },
115
+ # Same race closure as the tool approval queue: register the
116
+ # pending call at submit time so plan approvals land even when
117
+ # the executor is still in flight.
118
+ on_submit: ->(action) {
119
+ register_pending_tool(action.tool_call_id, {
120
+ tool_name: action.tool_name,
121
+ message: action.message || "Plan awaiting approval",
122
+ status: "pending",
123
+ tool_call_id: action.tool_call_id,
124
+ action_id: action.id
125
+ })
126
+ }
111
127
  ) if @plan_mode
112
128
 
113
129
  @tools = resolve_tools(tools)
@@ -196,7 +212,7 @@ module Ask
196
212
  # (only when the +artifacts:+ option is enabled)
197
213
  attr_reader :artifact_store
198
214
 
199
- def run(message, tools: nil, reset: true)
215
+ def run(message, tools: nil, reset: true, attachments: nil)
200
216
  raise "Session deleted" if @deleted
201
217
  raise "Session already running" if @running
202
218
 
@@ -230,6 +246,7 @@ module Ask
230
246
  response = @loop.run_turn(
231
247
  chat: @chat,
232
248
  message: message,
249
+ attachments: attachments,
233
250
  tools: active_tools,
234
251
  tool_executor: @tool_executor,
235
252
  compactor: @compactor,
@@ -443,7 +460,7 @@ module Ask
443
460
  data[:messages].each do |msg|
444
461
  session.chat.add_message(
445
462
  role: msg[:role].to_sym,
446
- content: msg[:content],
463
+ content: deserialize_content(msg[:content]),
447
464
  tool_call_id: msg[:tool_call_id]
448
465
  )
449
466
  end
@@ -662,8 +679,11 @@ end
662
679
  # @param message [String]
663
680
  # @param expected_turn_id [Integer, nil] the turn id the caller
664
681
  # believes is current; nil skips the check
682
+ # @param attachments [Array<Ask::Attachment>, nil] files to attach
683
+ # (applied when the session is idle; queued steers keep the
684
+ # message text only — the loop resolves queued messages as text)
665
685
  # @return [Hash] {status: :stale|:queued|:steered, turn_id: Integer}
666
- def steer(message, expected_turn_id: nil)
686
+ def steer(message, expected_turn_id: nil, attachments: nil)
667
687
  @steer_mutex.synchronize do
668
688
  if expected_turn_id && expected_turn_id != @turn_id
669
689
  return { status: :stale, turn_id: @turn_id }
@@ -673,7 +693,7 @@ end
673
693
  return { status: :queued, turn_id: @turn_id }
674
694
  end
675
695
  end
676
- @chat.add_message(role: :user, content: message.to_s)
696
+ @chat.add_message(role: :user, content: message.to_s, attachments: attachments)
677
697
  { status: :steered, turn_id: @turn_id }
678
698
  end
679
699
 
@@ -685,10 +705,19 @@ end
685
705
  # --- Async (pending) tools ---
686
706
 
687
707
  # Registers a pending tool call (called by the loop when a tool
688
- # returned Ask::Result.pending). The background work completes later
689
- # via #complete_pending_tool.
708
+ # returned Ask::Result.pending, or at approval-queue submit time).
709
+ # The background work completes later via #complete_pending_tool.
710
+ #
711
+ # A call can be resolved (approved/rejected) while the executor is
712
+ # still in flight; when the loop then registers the same call, the
713
+ # registration is skipped so no ghost pending entry is left behind.
690
714
  def register_pending_tool(tool_call_id, result)
691
715
  @pending_mutex.synchronize do
716
+ return if @recently_completed.include?(tool_call_id)
717
+ if (action_id = result[:action_id]) && @approval_queue
718
+ action = @approval_queue[action_id]
719
+ return if action && action.status != :pending
720
+ end
692
721
  @pending_tools[tool_call_id] = result
693
722
  end
694
723
  emit(Events::ToolPending.new(name: result[:tool_name], id: tool_call_id))
@@ -714,6 +743,12 @@ end
714
743
  pending = @pending_tools.delete(tool_call_id)
715
744
  return false unless pending
716
745
 
746
+ # Remember the id briefly so a late loop registration (from a
747
+ # completion that landed while the executor was in flight) cannot
748
+ # resurrect it as a ghost pending call.
749
+ @recently_completed << tool_call_id
750
+ @recently_completed.shift if @recently_completed.size > RECENTLY_COMPLETED_MAX
751
+
717
752
  @chat.add_message(
718
753
  role: :tool,
719
754
  content: result[:message].to_s,
@@ -802,6 +837,24 @@ end
802
837
  )
803
838
  end
804
839
 
840
+ # Custom queues (subclasses, event-emitting wrappers) may come
841
+ # without callbacks — wire the session's defaults so approvals
842
+ # actually execute the tool call.
843
+ queue.on_approve ||= ->(action) { apply_approved_action(action) }
844
+ queue.on_reject ||= ->(action) { reject_pending_action(action) }
845
+ # Register the pending tool call the moment the action is queued —
846
+ # before the auto-approval drain — so completions always match even
847
+ # when an approval lands while the executor is still in flight.
848
+ queue.on_submit ||= ->(action) {
849
+ register_pending_tool(action.tool_call_id, {
850
+ tool_name: action.tool_name,
851
+ message: action.message || "Pending approval",
852
+ status: "pending",
853
+ tool_call_id: action.tool_call_id,
854
+ action_id: action.id
855
+ })
856
+ }
857
+
805
858
  policy = Ask::Agent::Policies::ApprovalPolicy.new(
806
859
  queue: queue,
807
860
  require_approval: policy_opts[:require_approval],
@@ -959,7 +1012,7 @@ end
959
1012
  data[:messages].each do |msg|
960
1013
  target.chat.add_message(
961
1014
  role: msg[:role].to_sym,
962
- content: msg[:content],
1015
+ content: self.class.deserialize_content(msg[:content]),
963
1016
  tool_call_id: msg[:tool_call_id]
964
1017
  )
965
1018
  end
@@ -977,13 +1030,26 @@ end
977
1030
  @tools.reject { |t| t.is_a?(Ask::Skills::LoadSkillTool) }
978
1031
  end
979
1032
 
1033
+ # Persist content blocks as their +to_h+ hashes so attachments
1034
+ # survive save/load; plain messages stay strings.
1035
+ def self.serialize_content(message)
1036
+ message.content_blocks ? message.content_blocks.map(&:to_h) : message.content.to_s
1037
+ end
1038
+
1039
+ # Rebuild message content from a persisted value: block hashes are
1040
+ # reconstructed via Ask::Content.from_h (deep_symbolize_keys may have
1041
+ # symbol keys — from_h normalizes).
1042
+ def self.deserialize_content(content)
1043
+ content.is_a?(Array) ? content.map { |block| Ask::Content.from_h(block) } : content
1044
+ end
1045
+
980
1046
  def persist!
981
1047
  payload = {
982
1048
  id: @id,
983
1049
  messages: @chat.messages.map { |m|
984
1050
  {
985
1051
  role: m.role,
986
- content: m.content.to_s,
1052
+ content: self.class.serialize_content(m),
987
1053
  tool_call_id: m.tool_call_id,
988
1054
  created_at: Time.now.iso8601
989
1055
  }
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Ask
4
4
  module Agent
5
- VERSION = "0.38.0"
5
+ VERSION = "0.40.0"
6
6
  end
7
7
  end
data/lib/ask/agent.rb CHANGED
@@ -18,6 +18,7 @@ module Ask
18
18
  class ToolExecutionError < Error; end
19
19
  class CompactionFailed < Error; end
20
20
  class SessionNotPersisted < Error; end
21
+ class UnsupportedAttachmentError < Error; end
21
22
 
22
23
  class UnknownAgent < Error; end
23
24
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ask-agent
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.38.0
4
+ version: 0.40.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kaka Ruto