ask-decisions 0.2.3 → 0.2.5

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: e94c483511119929cad9bbdf2d0925ca498e4b4f98f1119c05ac3c26a239edfc
4
- data.tar.gz: e09632e3fbaa5f2dca5ffc8692afe586d1818b985db8afaa600f3498e614ab9f
3
+ metadata.gz: 9969a46203cf5653cacf020b9e5399945d137c846b88fcc52b1b6d91828de484
4
+ data.tar.gz: e172bd808e1a61019d9dd65dc2073f41776d46ee64e0233fa5333017a2c6945b
5
5
  SHA512:
6
- metadata.gz: a55fb6e583a983afd05acde0048d203d2e81c3098ef3cfa9d278214a3e4cd2de47ba2bd0f78fcb572f078da1c246fc4dc2aa22f2d23380cd79dc7d5c5fdbfe70
7
- data.tar.gz: 57e4a24f195eb27e518e88e7340850e27a90499c2a22ac5a3069cca63d787fee3623f350ccf893fb0794f4eb13667656fd82abff369b9ce71c0c62b30f1a0490
6
+ metadata.gz: 2e9547a817b4b6e6dc2d21b00c4ca0118fb087505781937e9ad1e591eb4737b60c3eb156a32f76a346120bd0344ba376678522dd30151739bb25153adb0d7ff6
7
+ data.tar.gz: bea3578951f0d0df5473f8a65412021cc040c044f9244ba484d1e2dc91577065aedf87b457176b8397f933150ed1cb1e760a187ca545af2f6c93d8db44cf48f2
data/CHANGELOG.md CHANGED
@@ -4,6 +4,34 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
6
6
 
7
+ ## [0.2.5] - 2026-09-18
8
+
9
+ ### Fixed
10
+
11
+ - **Dropping one of several tool calls no longer orphans its siblings.**
12
+ When an assistant message carried multiple tool calls and Jev dropped
13
+ one, the Compactor removed the whole message — including the calls Jev
14
+ said to keep, stranding their results without a call. A drop now removes
15
+ the dropped call from its message; the message survives when it still
16
+ has text or surviving calls, and disappears only when the drop empties it.
17
+ - **A drop touching a pinned message keeps the compactor's promises.** A
18
+ result is never left without its call, and a pinned message is never
19
+ removed — a drop against either now downgrades to keeping the pair
20
+ (truncating the result when only the call is pinned), and the stats say
21
+ what actually happened: fully pinned pairs report `pinned` instead of
22
+ counting as evaluated work.
23
+
24
+ ## [0.2.4] - 2026-09-18
25
+
26
+ ### Fixed
27
+
28
+ - **`resolve_provider` memoizes the provider instance.** Every call to
29
+ `resolve_provider` previously created a new `Typesafe` (or other provider)
30
+ object. In a pipeline with 10+ decision calls per cycle, this meant 10+
31
+ redundant allocations with identical configuration. The provider is now
32
+ cached for the lifetime of the process — it holds only read-only state
33
+ (API key, base URL, model, timeout) set once at boot.
34
+
7
35
  ## [0.2.3] - 2026-09-18
8
36
 
9
37
  ### Added
data/README.md CHANGED
@@ -213,6 +213,31 @@ policy.evaluate(tool: "bash", confidence: 0.6).action # => :act
213
213
  policy.evaluate(tool: "rm", confidence: 0.8).action # => :review
214
214
  ```
215
215
 
216
+ ## Compactor
217
+
218
+ Decision-based conversation compaction. Summaries are lossy — a file path,
219
+ an exact error, a one-time constraint can vanish. The Compactor never
220
+ rewrites anything: Jev scores every tool call and result, and the irrelevant
221
+ ones are dropped or truncated. Everything kept stays verbatim.
222
+
223
+ ```ruby
224
+ compactor = Ask::Decisions::Compactor.new(provider, preserve_recent: 6)
225
+ result = compactor.compact(session.messages)
226
+
227
+ result.messages # => pruned conversation
228
+ result.stats # => { kept: 12, dropped: 5, truncated: 3, reduction_ratio: 0.29, ... }
229
+ result.reduction_ratio # => 0.29
230
+ ```
231
+
232
+ Three tiers per tool call: result still needed → keep both verbatim; call
233
+ matters, result doesn't → keep the call, truncate the result to a short
234
+ head; neither matters → remove both. The first message and the most recent
235
+ `preserve_recent:` messages are pinned — never touched, never scored.
236
+
237
+ Fail open: a failed decision call means keep the transcript and fall back to
238
+ summarization. A compaction that doesn't happen this turn happens next turn;
239
+ a lost constraint is gone for good.
240
+
216
241
  ## Calibration
217
242
 
218
243
  Measure whether confidence is trustworthy on YOUR decisions:
@@ -81,6 +81,7 @@ module Ask
81
81
  questions = build_questions(pairs, pinned)
82
82
  batch = batch_and_execute(state, questions)
83
83
  decisions = extract_decisions(batch, pairs)
84
+ mark_pinned_pairs(decisions, pairs, pinned)
84
85
  pruned = apply_decisions(messages, decisions, pinned)
85
86
  stats = compute_stats(messages, pruned, decisions)
86
87
 
@@ -145,6 +146,8 @@ module Ask
145
146
  end
146
147
 
147
148
  # First message and the most recent +preserve_recent+ messages are pinned.
149
+ # Pinned messages are never removed, and a pair living entirely inside
150
+ # them is never scored — Jev does not see pinned content.
148
151
  def compute_pinned(messages)
149
152
  pinned = Set.new([0])
150
153
  start = [messages.size - @preserve_recent, 1].max
@@ -162,7 +165,6 @@ module Ask
162
165
  pairs.each { |p| result_index[p[:result_msg_idx]] = p }
163
166
 
164
167
  lines = messages.each_with_index.map do |msg, idx|
165
- role = msg[:role] || "unknown"
166
168
  content = msg[:content].to_s
167
169
 
168
170
  if result_index[idx]
@@ -180,7 +182,6 @@ module Ask
180
182
  end
181
183
  end
182
184
 
183
- goal_section = @goal ? "\n\nGoal: #{@goal}" : ""
184
185
  { conversation: lines, goal: @goal }.compact
185
186
  end
186
187
 
@@ -195,6 +196,8 @@ module Ask
195
196
  def build_questions(pairs, pinned)
196
197
  questions = {}
197
198
  pairs.each do |pair|
199
+ # Skip pairs where both the call and result live in pinned messages.
200
+ # Pinned messages are never touched — Jev does not score them.
198
201
  next if pinned.include?(pair[:call_msg_idx]) && pinned.include?(pair[:result_msg_idx])
199
202
 
200
203
  call_id = pair[:call_id]
@@ -269,6 +272,19 @@ module Ask
269
272
 
270
273
  # ── Decisions ────────────────────────────────────────────────────
271
274
 
275
+ # A pair whose call and result both live in pinned messages was never
276
+ # scored. Record that as its own action so the stats describe Jev's
277
+ # work, not the pinning.
278
+ def mark_pinned_pairs(decisions, pairs, pinned)
279
+ pairs.each do |pair|
280
+ next unless pinned.include?(pair[:call_msg_idx]) && pinned.include?(pair[:result_msg_idx])
281
+
282
+ if (decision = decisions[pair[:call_id]])
283
+ decision[:action] = :pinned
284
+ end
285
+ end
286
+ end
287
+
272
288
  def extract_decisions(batch, pairs)
273
289
  decisions = {}
274
290
  pairs.each do |pair|
@@ -305,6 +321,9 @@ module Ask
305
321
  pairs = build_pairs(messages)
306
322
  remove_indices = Set.new
307
323
  truncate_indices = {}
324
+ # call_id => true when the call survives although its result does not
325
+ # (a :drop whose result is pinned downgrades to keeping the call).
326
+ dropped_results = Set.new
308
327
 
309
328
  pairs.each do |pair|
310
329
  decision = decisions[pair[:call_id]]
@@ -312,8 +331,24 @@ module Ask
312
331
 
313
332
  case decision[:action]
314
333
  when :drop
315
- remove_indices.add(pair[:call_msg_idx])
334
+ call_pinned = pinned.include?(pair[:call_msg_idx])
335
+ result_pinned = pinned.include?(pair[:result_msg_idx])
336
+
337
+ if call_pinned || result_pinned
338
+ # A result is never left without its call, and a pinned message
339
+ # is never removed. A drop touching a pinned message keeps the
340
+ # pair — the call verbatim, the result truncated to its head.
341
+ if result_pinned
342
+ decision[:action] = :keep
343
+ else
344
+ truncate_indices[pair[:result_msg_idx]] = pair[:call]
345
+ decision[:action] = :truncate
346
+ end
347
+ next
348
+ end
349
+
316
350
  remove_indices.add(pair[:result_msg_idx])
351
+ dropped_results.add(pair[:call_id])
317
352
  when :truncate
318
353
  truncate_indices[pair[:result_msg_idx]] = pair[:call]
319
354
  end
@@ -325,11 +360,29 @@ module Ask
325
360
  if truncate_indices[idx]
326
361
  truncate_message(msg, truncate_indices[idx])
327
362
  else
328
- msg
363
+ prune_dropped_calls(msg, dropped_results)
329
364
  end
330
365
  end
331
366
  end
332
367
 
368
+ # A drop removes the tool result message; the call itself leaves the
369
+ # assistant message that carried it. A message survives when it has text
370
+ # or surviving calls — it is removed only when the drop empties it.
371
+ def prune_dropped_calls(msg, dropped_results)
372
+ return msg unless msg[:tool_calls] && dropped_results.any?
373
+
374
+ surviving = msg[:tool_calls].reject { |tc| dropped_results.include?(tc[:id]) }
375
+ return msg if surviving.size == msg[:tool_calls].size
376
+
377
+ if surviving.empty? && msg[:content].to_s.empty?
378
+ nil
379
+ elsif surviving.empty?
380
+ msg.except(:tool_calls)
381
+ else
382
+ msg.merge(tool_calls: surviving)
383
+ end
384
+ end
385
+
333
386
  def truncate_message(msg, call)
334
387
  original = msg[:content].to_s
335
388
  return msg if original.length <= @truncate_head_chars
@@ -342,7 +395,7 @@ module Ask
342
395
  # ── Stats ────────────────────────────────────────────────────────
343
396
 
344
397
  def compute_stats(original, pruned, decisions)
345
- action_counts = decisions.values.tally { |d| d[:action] }
398
+ action_counts = decisions.values.map { |d| d[:action] }.tally
346
399
  original_chars = original.sum { |m| m[:content].to_s.length }
347
400
  pruned_chars = pruned.sum { |m| m[:content].to_s.length }
348
401
 
@@ -352,7 +405,8 @@ module Ask
352
405
  kept: action_counts[:keep] || 0,
353
406
  truncated: action_counts[:truncate] || 0,
354
407
  dropped: action_counts[:drop] || 0,
355
- tool_pairs_evaluated: decisions.size,
408
+ pinned: action_counts[:pinned] || 0,
409
+ tool_pairs_evaluated: decisions.size - (action_counts[:pinned] || 0),
356
410
  chars_before: original_chars,
357
411
  chars_after: pruned_chars,
358
412
  reduction_ratio: original_chars > 0 ? (1.0 - pruned_chars.to_f / original_chars) : 0.0
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Ask
4
4
  module Decisions
5
- VERSION = "0.2.3"
5
+ VERSION = "0.2.5"
6
6
  end
7
7
  end
data/lib/ask-decisions.rb CHANGED
@@ -122,13 +122,19 @@ module Ask
122
122
  end
123
123
 
124
124
  # Resolve a provider by name, raising on unknown.
125
+ # Memoized: the provider is stateless after boot (API key,
126
+ # base URL, model, timeout are all read-only), so one instance
127
+ # per configuration lifetime is safe and avoids redundant
128
+ # allocations across the pipeline.
125
129
  def resolve_provider(name)
126
- klass = Ask::DecisionProvider.resolve(name)
127
-
128
- if configuration.provider_options.any?
129
- klass.new(**configuration.provider_options)
130
- else
131
- klass.new
130
+ @resolved_provider ||= begin
131
+ klass = Ask::DecisionProvider.resolve(name)
132
+
133
+ if configuration.provider_options.any?
134
+ klass.new(**configuration.provider_options)
135
+ else
136
+ klass.new
137
+ end
132
138
  end
133
139
  end
134
140
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ask-decisions
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.3
4
+ version: 0.2.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kaka Ruto