ask-agent 0.24.1 → 0.25.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 +62 -0
- data/lib/ask/agent/compactor.rb +197 -17
- data/lib/ask/agent/configuration.rb +4 -2
- data/lib/ask/agent/session.rb +7 -2
- data/lib/ask/agent/version.rb +1 -1
- 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: bbb7c5376d7ba7cf03099123c5aab9f12dd9a10efca9906fd07ea2782b28d296
|
|
4
|
+
data.tar.gz: 8c82d88e763cdf7e765fec8837b22ab5dc8a5a7cbaca01d9ffff59c8328a1e99
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: f886aedfbed678ce466c545e9f20255320a5ac77930c60d164613e833345455c52de0256f9c1a9357cda6040a79c2f17ed6aa687c5b8f135c6d355a50ecea5b6
|
|
7
|
+
data.tar.gz: be66be79539f4f284c8741ec634f17c4e5a12d18e4cc182209be5ac5f8988101589b10dfe331234cc1bb6880aa1a2c0af0ffbe4817b9ab382dd3fbcb2d6fd6b1
|
data/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,65 @@
|
|
|
1
|
+
## [0.25.0] — 2026-08-02
|
|
2
|
+
|
|
3
|
+
### Added
|
|
4
|
+
|
|
5
|
+
- **Model-aware compaction reserve.** `Ask::Agent::Compactor` now derives
|
|
6
|
+
its context headroom from the model's declared `max_output_tokens`
|
|
7
|
+
(capped at 20,000) instead of a fixed 80% threshold. When the model
|
|
8
|
+
metadata is unavailable, a static 20,000-token reserve applies. A safety
|
|
9
|
+
floor clamps the reserve for tiny-window models so compaction can fire
|
|
10
|
+
usefully instead of triggering on every turn.
|
|
11
|
+
|
|
12
|
+
```ruby
|
|
13
|
+
# Default: compact when tokens exceed context_window - reserve
|
|
14
|
+
compactor = Ask::Agent::Compactor.new
|
|
15
|
+
|
|
16
|
+
# Legacy behavior: compact at 80% of the window
|
|
17
|
+
compactor = Ask::Agent::Compactor.new(threshold: 0.8)
|
|
18
|
+
|
|
19
|
+
# Explicit headroom
|
|
20
|
+
compactor = Ask::Agent::Compactor.new(reserve_tokens: 5_000)
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
- **Token-aware recent tail.** `keep_recent_tokens:` preserves the last N
|
|
24
|
+
tokens of conversation verbatim (default 8,000) and summarizes only what's
|
|
25
|
+
older — recent-context fidelity depends on the active work, not message
|
|
26
|
+
counts. When not configured, the legacy fixed message-count tail
|
|
27
|
+
(`keep_count:`, default 8) is used for backward compatibility.
|
|
28
|
+
|
|
29
|
+
- **Global compaction options** on `Ask::Agent.configure`:
|
|
30
|
+
`compactor_reserve_tokens` and `compactor_keep_recent_tokens` apply to all
|
|
31
|
+
sessions. `compactor_threshold` now defaults to `nil` (reserve mode).
|
|
32
|
+
|
|
33
|
+
- **`Compactor#compact_threshold_tokens`** — public accessor for the token
|
|
34
|
+
count at which compaction triggers (window × threshold, or
|
|
35
|
+
window − reserve).
|
|
36
|
+
|
|
37
|
+
### Fixed
|
|
38
|
+
|
|
39
|
+
- **`microcompact!` no longer crashes on long tool results.** `Ask::Message`
|
|
40
|
+
is immutable — `content=` never existed. The method now rebuilds the
|
|
41
|
+
message in place via `map!`, preserving `tool_call_id` and metadata.
|
|
42
|
+
|
|
43
|
+
### Changed
|
|
44
|
+
|
|
45
|
+
- `Compactor#extract_summary` is now public.
|
|
46
|
+
|
|
47
|
+
## [0.24.2] — 2026-07-30
|
|
48
|
+
|
|
49
|
+
### Fixed
|
|
50
|
+
|
|
51
|
+
- **Definitions discovered through an intermediate base class no longer
|
|
52
|
+
crash.** `Definition.inherited` appended to `@subclasses` on `self`, so
|
|
53
|
+
subclassing `Ask::Agent::Definition` through an application base class
|
|
54
|
+
(e.g. `class Agent < ApplicationAgent` in a Rails app) made the ivar nil
|
|
55
|
+
and raised `NoMethodError` on load. Tracking now reads the registry from
|
|
56
|
+
`Definition` itself, and `Definition.subclasses` reports the same list
|
|
57
|
+
regardless of receiver.
|
|
58
|
+
|
|
59
|
+
- **Changelog for 0.24.1.** The subclass-chain fix shipped as 0.24.1
|
|
60
|
+
without a changelog entry; it is documented here and republished as
|
|
61
|
+
0.24.2 so the gem content includes it.
|
|
62
|
+
|
|
1
63
|
## [0.24.0] — 2026-07-30
|
|
2
64
|
|
|
3
65
|
### Changed
|
data/lib/ask/agent/compactor.rb
CHANGED
|
@@ -2,6 +2,26 @@
|
|
|
2
2
|
|
|
3
3
|
module Ask
|
|
4
4
|
module Agent
|
|
5
|
+
# Context compaction for long sessions. When conversation tokens approach
|
|
6
|
+
# the model's context window, older messages are summarized and replaced
|
|
7
|
+
# with a structured summary, preserving a recent tail verbatim.
|
|
8
|
+
#
|
|
9
|
+
# Trigger modes:
|
|
10
|
+
# 1. Threshold — tokens exceed (context_window - reserve). Compact, no retry.
|
|
11
|
+
# 2. Overflow — LLM returned context overflow. Compact, then auto-retry.
|
|
12
|
+
#
|
|
13
|
+
# Reserve is model-aware: it defaults to the model's declared max output
|
|
14
|
+
# tokens (capped at {DEFAULT_RESERVE_TOKENS}), so compaction leaves exactly
|
|
15
|
+
# the headroom a single turn can consume. For models without declared
|
|
16
|
+
# limits, a static default applies. A safety floor clamps the reserve for
|
|
17
|
+
# tiny-window models so threshold compaction can fire usefully instead of
|
|
18
|
+
# triggering on every turn.
|
|
19
|
+
#
|
|
20
|
+
# The recent tail is also token-aware: {keep_recent_tokens} preserves the
|
|
21
|
+
# last N tokens of conversation verbatim (recent-context fidelity depends
|
|
22
|
+
# on the active work, not on message counts) and summarizes only what's
|
|
23
|
+
# older. When not configured, the legacy fixed message-count behavior is
|
|
24
|
+
# used for backward compatibility.
|
|
5
25
|
class Compactor
|
|
6
26
|
CONTEXT_WINDOWS = {
|
|
7
27
|
"gpt-4o" => 128_000,
|
|
@@ -15,25 +35,76 @@ module Ask
|
|
|
15
35
|
"deepseek-v4-pro" => 1_000_000,
|
|
16
36
|
}.tap { |h| h.default = 128_000 }
|
|
17
37
|
|
|
38
|
+
# Default headroom reserved for a single model turn when the model's
|
|
39
|
+
# max output tokens are unknown.
|
|
40
|
+
DEFAULT_RESERVE_TOKENS = 20_000
|
|
41
|
+
|
|
42
|
+
# Default verbatim recent-context tail preserved during compaction.
|
|
43
|
+
DEFAULT_KEEP_RECENT_TOKENS = 8_000
|
|
44
|
+
|
|
45
|
+
# Minimum reserve for tiny-window models (safety floor).
|
|
46
|
+
MIN_RESERVE_TOKENS = 1_024
|
|
47
|
+
|
|
48
|
+
# Legacy fixed message-count tail (backward compatibility).
|
|
49
|
+
DEFAULT_KEEP_COUNT = 8
|
|
50
|
+
|
|
51
|
+
# Conversations smaller than this are never compacted.
|
|
52
|
+
MIN_MESSAGES = 6
|
|
53
|
+
|
|
18
54
|
attr_accessor :chat, :llm
|
|
19
55
|
|
|
20
|
-
|
|
56
|
+
# @param threshold [Float, nil] Compact when tokens exceed
|
|
57
|
+
# context_window * threshold. When nil (default), compaction triggers
|
|
58
|
+
# at context_window - reserve_tokens (model-aware).
|
|
59
|
+
# @param strategy [Symbol] Reserved; :proactive is the only strategy.
|
|
60
|
+
# @param llm [Object, String, nil] LLM used for summarization. When nil,
|
|
61
|
+
# a heuristic summary is generated instead.
|
|
62
|
+
# @param reserve_tokens [Integer, nil] Explicit headroom for one turn.
|
|
63
|
+
# When nil, derived from the model's max output tokens (see
|
|
64
|
+
# {#derive_reserve}).
|
|
65
|
+
# @param keep_recent_tokens [Integer, nil] Explicit verbatim recent-tail
|
|
66
|
+
# budget. When nil, the legacy fixed message-count tail is preserved.
|
|
67
|
+
# @param keep_count [Integer] Legacy fixed tail in messages when
|
|
68
|
+
# keep_recent_tokens is nil.
|
|
69
|
+
# @param min_messages [Integer] Conversations with fewer messages are
|
|
70
|
+
# never compacted.
|
|
71
|
+
def initialize(threshold: nil, strategy: :proactive, llm: nil,
|
|
72
|
+
reserve_tokens: nil, keep_recent_tokens: nil,
|
|
73
|
+
keep_count: DEFAULT_KEEP_COUNT, min_messages: MIN_MESSAGES)
|
|
21
74
|
@threshold = threshold
|
|
22
75
|
@strategy = strategy
|
|
23
76
|
@llm = llm
|
|
77
|
+
@reserve_tokens = reserve_tokens
|
|
78
|
+
@keep_recent_tokens = keep_recent_tokens
|
|
79
|
+
@keep_count = keep_count
|
|
80
|
+
@min_messages = min_messages
|
|
24
81
|
@already_compacted = false
|
|
25
82
|
@overflow_recovered = false
|
|
26
83
|
end
|
|
27
84
|
|
|
28
85
|
def overflow_recovered? = @overflow_recovered
|
|
29
86
|
|
|
87
|
+
# Whether the conversation is close enough to the model's window that
|
|
88
|
+
# compaction should run. Triggers at either threshold mode
|
|
89
|
+
# (window * threshold) or reserve mode (window - reserve).
|
|
30
90
|
def should_compact?
|
|
31
91
|
return false unless @chat
|
|
32
|
-
|
|
92
|
+
estimate_total_tokens >= compact_threshold_tokens
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# The token count at which compaction triggers.
|
|
96
|
+
#
|
|
97
|
+
# @return [Integer]
|
|
98
|
+
def compact_threshold_tokens
|
|
33
99
|
window = context_window
|
|
34
|
-
|
|
100
|
+
return (window * @threshold).round if @threshold
|
|
101
|
+
|
|
102
|
+
window - reserve_tokens
|
|
35
103
|
end
|
|
36
104
|
|
|
105
|
+
# Run compaction, emitting start/end events.
|
|
106
|
+
#
|
|
107
|
+
# @param event_emitter [#emit, nil]
|
|
37
108
|
def run(event_emitter: nil)
|
|
38
109
|
return unless @chat
|
|
39
110
|
|
|
@@ -45,14 +116,16 @@ module Ask
|
|
|
45
116
|
event_emitter&.emit(Events::CompactionEnd.new(tokens_before: tokens_before, tokens_after: tokens_after, summary: extract_summary))
|
|
46
117
|
end
|
|
47
118
|
|
|
119
|
+
# Summarize older messages and replace them with a summary, preserving
|
|
120
|
+
# a recent tail. The tail is either token-based ({keep_recent_tokens})
|
|
121
|
+
# or the legacy fixed message count.
|
|
48
122
|
def compact!
|
|
49
123
|
return unless @chat
|
|
50
124
|
messages = @chat.messages.dup
|
|
51
|
-
return if messages.size <
|
|
125
|
+
return if messages.size < @min_messages
|
|
52
126
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
older = messages.first(messages.size - keep_count)
|
|
127
|
+
split_index = find_split_index(messages)
|
|
128
|
+
older = messages.first(split_index)
|
|
56
129
|
return if older.empty?
|
|
57
130
|
|
|
58
131
|
summary = if @llm
|
|
@@ -65,36 +138,148 @@ module Ask
|
|
|
65
138
|
@chat.add_message(role: :system, content: "[Previous conversation summary]: #{summary}")
|
|
66
139
|
end
|
|
67
140
|
|
|
141
|
+
# Aggressive overflow fallback: clears oversized tool results in place,
|
|
142
|
+
# keeping the conversation structure intact.
|
|
68
143
|
def microcompact!
|
|
69
144
|
return unless @chat
|
|
70
|
-
@chat.messages.
|
|
71
|
-
next unless msg.role == :tool
|
|
72
|
-
msg
|
|
145
|
+
@chat.messages.map! do |msg|
|
|
146
|
+
next msg unless msg.role == :tool
|
|
147
|
+
next msg unless msg.content.to_s.length > 200
|
|
148
|
+
|
|
149
|
+
Ask::Message.new(
|
|
150
|
+
role: :tool,
|
|
151
|
+
content: "[Tool result cleared by compaction]",
|
|
152
|
+
tool_call_id: msg.tool_call_id,
|
|
153
|
+
metadata: msg.metadata
|
|
154
|
+
)
|
|
73
155
|
end
|
|
74
156
|
end
|
|
75
157
|
|
|
158
|
+
# Recover from a context-overflow error: compact once, then fall back
|
|
159
|
+
# to micro-compaction on subsequent overflows in the same session.
|
|
76
160
|
def recover_from_overflow
|
|
77
161
|
if @already_compacted then microcompact! else compact! end
|
|
78
162
|
@already_compacted = true
|
|
79
163
|
@overflow_recovered = true
|
|
80
164
|
end
|
|
81
165
|
|
|
166
|
+
# Rough token estimate: ~4 characters per token.
|
|
167
|
+
#
|
|
168
|
+
# @param text [String]
|
|
169
|
+
# @return [Integer]
|
|
82
170
|
def estimate_tokens(text)
|
|
83
171
|
(text.to_s.length / 4.0).ceil
|
|
84
172
|
end
|
|
85
173
|
|
|
174
|
+
# Total estimated tokens across all messages, including tool-call
|
|
175
|
+
# payloads.
|
|
176
|
+
#
|
|
177
|
+
# @return [Integer]
|
|
86
178
|
def estimate_total_tokens
|
|
87
179
|
return 0 unless @chat
|
|
88
180
|
@chat.messages.sum { |msg| estimate_message_tokens(msg) }
|
|
89
181
|
end
|
|
90
182
|
|
|
183
|
+
# The model's context window. Consulted from the model catalog first,
|
|
184
|
+
# then the bundled table, then the default.
|
|
185
|
+
#
|
|
186
|
+
# @return [Integer]
|
|
91
187
|
def context_window
|
|
92
|
-
|
|
93
|
-
|
|
188
|
+
info = model_info
|
|
189
|
+
return info.context_window if info&.context_window
|
|
190
|
+
|
|
191
|
+
CONTEXT_WINDOWS[@chat.model.to_s] || CONTEXT_WINDOWS.default
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# Headroom reserved for one model turn. Explicit value wins; otherwise
|
|
195
|
+
# derived from the model's max output tokens with a safety floor.
|
|
196
|
+
#
|
|
197
|
+
# @return [Integer]
|
|
198
|
+
def reserve_tokens
|
|
199
|
+
@reserve_tokens || derive_reserve
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# Verbatim recent-tail budget in tokens.
|
|
203
|
+
#
|
|
204
|
+
# @return [Integer]
|
|
205
|
+
def keep_recent_tokens
|
|
206
|
+
@keep_recent_tokens || DEFAULT_KEEP_RECENT_TOKENS
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# The text of the most recent injected summary, or "" if none exists.
|
|
210
|
+
#
|
|
211
|
+
# @return [String]
|
|
212
|
+
def extract_summary
|
|
213
|
+
@chat.messages.each { |msg| return msg.content.to_s if msg.content.to_s.start_with?("[Previous conversation summary]") }
|
|
214
|
+
""
|
|
94
215
|
end
|
|
95
216
|
|
|
96
217
|
private
|
|
97
218
|
|
|
219
|
+
def model_info
|
|
220
|
+
Ask::ModelCatalog.find(@chat.model.to_s)
|
|
221
|
+
rescue Ask::ModelNotFound, NameError
|
|
222
|
+
nil
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# Derive the reserve from the model's declared max output tokens,
|
|
226
|
+
# capped at {DEFAULT_RESERVE_TOKENS}. Applies a safety floor for
|
|
227
|
+
# tiny-window models: if the reserve would consume half or more of the
|
|
228
|
+
# window, clamp it to a third so threshold compaction can fire usefully
|
|
229
|
+
# instead of triggering on every turn.
|
|
230
|
+
#
|
|
231
|
+
# @return [Integer]
|
|
232
|
+
def derive_reserve
|
|
233
|
+
info = model_info
|
|
234
|
+
max_output = info&.max_output_tokens.to_i
|
|
235
|
+
reserve = if max_output > 0
|
|
236
|
+
[DEFAULT_RESERVE_TOKENS, max_output].min
|
|
237
|
+
else
|
|
238
|
+
DEFAULT_RESERVE_TOKENS
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
window = context_window
|
|
242
|
+
if window > 0 && reserve * 2 >= window
|
|
243
|
+
reserve = [MIN_RESERVE_TOKENS, window / 3].max
|
|
244
|
+
end
|
|
245
|
+
reserve
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
# Index of the first message in the recent tail. Token-based when
|
|
249
|
+
# keep_recent_tokens is configured; otherwise the legacy fixed
|
|
250
|
+
# message-count tail.
|
|
251
|
+
#
|
|
252
|
+
# @param messages [Array<Ask::Message>]
|
|
253
|
+
# @return [Integer]
|
|
254
|
+
def find_split_index(messages)
|
|
255
|
+
if @keep_recent_tokens
|
|
256
|
+
find_token_split_index(messages)
|
|
257
|
+
else
|
|
258
|
+
[messages.size - @keep_count, 0].max
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
# Walk from the end of the conversation accumulating tokens until the
|
|
263
|
+
# keep_recent_tokens budget is consumed. The split never keeps fewer
|
|
264
|
+
# than MIN_MESSAGES recent messages — the tail always retains a recent
|
|
265
|
+
# exchange intact.
|
|
266
|
+
#
|
|
267
|
+
# @param messages [Array<Ask::Message>]
|
|
268
|
+
# @return [Integer]
|
|
269
|
+
def find_token_split_index(messages)
|
|
270
|
+
budget = keep_recent_tokens
|
|
271
|
+
index = messages.size
|
|
272
|
+
|
|
273
|
+
messages.reverse_each do |msg|
|
|
274
|
+
budget -= estimate_message_tokens(msg)
|
|
275
|
+
index -= 1
|
|
276
|
+
break if budget <= 0
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
# Never keep fewer than MIN_MESSAGES recent messages
|
|
280
|
+
[index, messages.size - MIN_MESSAGES].min
|
|
281
|
+
end
|
|
282
|
+
|
|
98
283
|
def estimate_message_tokens(message)
|
|
99
284
|
base = estimate_tokens(message.content.to_s)
|
|
100
285
|
if message.tool_call? && message.respond_to?(:tool_calls) && message.tool_calls
|
|
@@ -141,11 +326,6 @@ module Ask
|
|
|
141
326
|
elsif @llm.is_a?(String) then Ask::Agent::Chat.new(model: @llm)
|
|
142
327
|
else Ask::Agent::Chat.new(model: Ask::Agent.configuration.default_model) end
|
|
143
328
|
end
|
|
144
|
-
|
|
145
|
-
def extract_summary
|
|
146
|
-
@chat.messages.each { |msg| return msg.content.to_s if msg.content.to_s.start_with?("[Previous conversation summary]") }
|
|
147
|
-
""
|
|
148
|
-
end
|
|
149
329
|
end
|
|
150
330
|
end
|
|
151
331
|
end
|
|
@@ -6,7 +6,7 @@ module Ask
|
|
|
6
6
|
attr_accessor :default_model, :default_provider, :default_max_turns,
|
|
7
7
|
:compactor_enabled, :compactor_threshold, :parallel_tool_execution,
|
|
8
8
|
:max_tool_retries, :prompt_caching, :default_evaluator_model,
|
|
9
|
-
:audit_log
|
|
9
|
+
:audit_log, :compactor_reserve_tokens, :compactor_keep_recent_tokens
|
|
10
10
|
|
|
11
11
|
# @return [Middleware::Pipeline] the middleware pipeline for provider calls
|
|
12
12
|
attr_reader :middleware
|
|
@@ -18,7 +18,9 @@ module Ask
|
|
|
18
18
|
@default_model = "gpt-4o"
|
|
19
19
|
@default_max_turns = 25
|
|
20
20
|
@compactor_enabled = true
|
|
21
|
-
@compactor_threshold =
|
|
21
|
+
@compactor_threshold = nil
|
|
22
|
+
@compactor_reserve_tokens = nil
|
|
23
|
+
@compactor_keep_recent_tokens = nil
|
|
22
24
|
@parallel_tool_execution = true
|
|
23
25
|
@max_tool_retries = 3
|
|
24
26
|
@prompt_caching = true
|
data/lib/ask/agent/session.rb
CHANGED
|
@@ -364,9 +364,14 @@ module Ask
|
|
|
364
364
|
end
|
|
365
365
|
|
|
366
366
|
def build_compactor(config)
|
|
367
|
+
global = Ask::Agent.configuration
|
|
367
368
|
compactor = Compactor.new(
|
|
368
|
-
threshold: config[:threshold] ||
|
|
369
|
-
strategy: config[:strategy] || :proactive
|
|
369
|
+
threshold: config[:threshold] || global.compactor_threshold,
|
|
370
|
+
strategy: config[:strategy] || :proactive,
|
|
371
|
+
reserve_tokens: config[:reserve_tokens] || global.compactor_reserve_tokens,
|
|
372
|
+
keep_recent_tokens: config[:keep_recent_tokens] || global.compactor_keep_recent_tokens,
|
|
373
|
+
keep_count: config[:keep_count],
|
|
374
|
+
min_messages: config[:min_messages]
|
|
370
375
|
)
|
|
371
376
|
compactor.chat = @chat
|
|
372
377
|
compactor
|
data/lib/ask/agent/version.rb
CHANGED