ask-agent 0.39.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: e2b75f968977ca6b41df200a5dbba36cc73d799bc74e91ad3a201a3b9a2c78e7
4
- data.tar.gz: 19fe24495b1f70b44a320c98b77ee8c2fdf9d83a5642e4bf58c071f8e16ec45c
3
+ metadata.gz: 969e484bfb85499a953c458a677564c9e0419066dcb931d12f451d0297b1c355
4
+ data.tar.gz: 3f13c4c60d09bfe2918f2bd974cb90c82f06224f492575e52a0e8f8aea1ebac4
5
5
  SHA512:
6
- metadata.gz: 5c9057c8b69cbe470beb46d9c656bf56ea15abf1d5328a93bc1a66cbef9c8f69f77aa0cfeb9f64cce264c60278f4df0c9290e3d89b71c42816ff2755d91b3e5e
7
- data.tar.gz: 6df2b868b1430e00c9f2fab030ab48b1128de64b23a29b22f9c70963b977c777e20aaf75d5da529f772998116c453763b935ca92d4495efa21bd04efe309caf4
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
@@ -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)
@@ -689,10 +705,19 @@ end
689
705
  # --- Async (pending) tools ---
690
706
 
691
707
  # Registers a pending tool call (called by the loop when a tool
692
- # returned Ask::Result.pending). The background work completes later
693
- # 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.
694
714
  def register_pending_tool(tool_call_id, result)
695
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
696
721
  @pending_tools[tool_call_id] = result
697
722
  end
698
723
  emit(Events::ToolPending.new(name: result[:tool_name], id: tool_call_id))
@@ -718,6 +743,12 @@ end
718
743
  pending = @pending_tools.delete(tool_call_id)
719
744
  return false unless pending
720
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
+
721
752
  @chat.add_message(
722
753
  role: :tool,
723
754
  content: result[:message].to_s,
@@ -806,6 +837,24 @@ end
806
837
  )
807
838
  end
808
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
+
809
858
  policy = Ask::Agent::Policies::ApprovalPolicy.new(
810
859
  queue: queue,
811
860
  require_approval: policy_opts[:require_approval],
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Ask
4
4
  module Agent
5
- VERSION = "0.39.0"
5
+ VERSION = "0.40.0"
6
6
  end
7
7
  end
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.39.0
4
+ version: 0.40.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kaka Ruto