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 +4 -4
- data/CHANGELOG.md +23 -0
- data/lib/ask/agent/approval_queue.rb +21 -1
- data/lib/ask/agent/chat.rb +42 -4
- data/lib/ask/agent/loop.rb +2 -2
- data/lib/ask/agent/session.rb +76 -10
- data/lib/ask/agent/version.rb +1 -1
- data/lib/ask/agent.rb +1 -0
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 969e484bfb85499a953c458a677564c9e0419066dcb931d12f451d0297b1c355
|
|
4
|
+
data.tar.gz: 3f13c4c60d09bfe2918f2bd974cb90c82f06224f492575e52a0e8f8aea1ebac4
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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
|
data/lib/ask/agent/chat.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
data/lib/ask/agent/loop.rb
CHANGED
|
@@ -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
|
data/lib/ask/agent/session.rb
CHANGED
|
@@ -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
|
|
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:
|
|
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
|
}
|
data/lib/ask/agent/version.rb
CHANGED
data/lib/ask/agent.rb
CHANGED