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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e5268e3474f109d7471749692a535a89b266e5a9d10c20499a565087500eaee8
4
- data.tar.gz: 24fc0068d51f1eedc2e0d6f427234db468db375e915a4383533086993dab8b5d
3
+ metadata.gz: bbb7c5376d7ba7cf03099123c5aab9f12dd9a10efca9906fd07ea2782b28d296
4
+ data.tar.gz: 8c82d88e763cdf7e765fec8837b22ab5dc8a5a7cbaca01d9ffff59c8328a1e99
5
5
  SHA512:
6
- metadata.gz: 8dddca135834b42108d217fc09a121a4fc4fd4ad44da326849b9654f30ba393bc115b44a3bc756433a7cbb8bd1b729d02dc246feb540e6f0ff39dc2aff1e56ad
7
- data.tar.gz: 5cde0691f9aa8bd6723c20344211c50cc18e15ffdf392d97710896d4752b8343c583faab5de7e4156e261049676dbe06e2136162a8d10d1b48e9ae3e174f7786
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
@@ -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
- def initialize(threshold: 0.8, strategy: :proactive, llm: nil)
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
- current = estimate_total_tokens
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
- current >= window * @threshold
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 < 6
125
+ return if messages.size < @min_messages
52
126
 
53
- keep_count = [messages.size, 8].min
54
- recent = messages.last(keep_count)
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.each do |msg|
71
- next unless msg.role == :tool
72
- msg.content = "[Tool result cleared by compaction]" if msg.content.to_s.length > 200
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
- model = @chat.model.to_s
93
- CONTEXT_WINDOWS[model]
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 = 0.8
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
@@ -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] || 0.8,
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
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Ask
4
4
  module Agent
5
- VERSION = "0.24.1"
5
+ VERSION = "0.25.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.24.1
4
+ version: 0.25.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kaka Ruto