ask-decisions 0.2.4 → 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: e64f40bca45e7b9bc9a11f33fabe1e178c30cfd9e61f4460395b77eb8092cc5a
4
- data.tar.gz: 4e32792a9c4bdc411731beb94d9a078e98d92f3135aaa4da779090fd6c4f96c9
3
+ metadata.gz: 9969a46203cf5653cacf020b9e5399945d137c846b88fcc52b1b6d91828de484
4
+ data.tar.gz: e172bd808e1a61019d9dd65dc2073f41776d46ee64e0233fa5333017a2c6945b
5
5
  SHA512:
6
- metadata.gz: e6338ccd1b049f19612e7b33d4e7dbedce5192d4789c29ac62bf6fa075107fa7184764b71c3df9e0edc6284e8d9ed0013225d5ee84a6be9858b5d3ca49d2b874
7
- data.tar.gz: f9c69aca54eaae484d182ab78c7265de776fafff15e5b4813908028dac1857e8df4f85428fc9474282dd54b4418a6c9981e8cb5e76673f028593daff8be3102b
6
+ metadata.gz: 2e9547a817b4b6e6dc2d21b00c4ca0118fb087505781937e9ad1e591eb4737b60c3eb156a32f76a346120bd0344ba376678522dd30151739bb25153adb0d7ff6
7
+ data.tar.gz: bea3578951f0d0df5473f8a65412021cc040c044f9244ba484d1e2dc91577065aedf87b457176b8397f933150ed1cb1e760a187ca545af2f6c93d8db44cf48f2
data/CHANGELOG.md CHANGED
@@ -4,6 +4,23 @@ 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
+
7
24
  ## [0.2.4] - 2026-09-18
8
25
 
9
26
  ### Fixed
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,8 +146,8 @@ module Ask
145
146
  end
146
147
 
147
148
  # First message and the most recent +preserve_recent+ messages are pinned.
148
- # Pinned messages are never removed, but their tool calls are still
149
- # evaluated by Jeva recent tool result can still be irrelevant.
149
+ # Pinned messages are never removed, and a pair living entirely inside
150
+ # them is never scored Jev does not see pinned content.
150
151
  def compute_pinned(messages)
151
152
  pinned = Set.new([0])
152
153
  start = [messages.size - @preserve_recent, 1].max
@@ -154,11 +155,6 @@ module Ask
154
155
  pinned
155
156
  end
156
157
 
157
- # Whether a message index is safe to remove (not pinned).
158
- def removable?(idx, pinned)
159
- !pinned.include?(idx)
160
- end
161
-
162
158
  # ── State ────────────────────────────────────────────────────────
163
159
 
164
160
  # Build the state Jev sees: full conversation with tool results replaced
@@ -276,6 +272,19 @@ module Ask
276
272
 
277
273
  # ── Decisions ────────────────────────────────────────────────────
278
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
+
279
288
  def extract_decisions(batch, pairs)
280
289
  decisions = {}
281
290
  pairs.each do |pair|
@@ -312,6 +321,9 @@ module Ask
312
321
  pairs = build_pairs(messages)
313
322
  remove_indices = Set.new
314
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
315
327
 
316
328
  pairs.each do |pair|
317
329
  decision = decisions[pair[:call_id]]
@@ -319,13 +331,24 @@ module Ask
319
331
 
320
332
  case decision[:action]
321
333
  when :drop
322
- # Only remove messages that are not pinned.
323
- if removable?(pair[:call_msg_idx], pinned)
324
- remove_indices.add(pair[:call_msg_idx])
325
- end
326
- if removable?(pair[:result_msg_idx], pinned)
327
- remove_indices.add(pair[:result_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
328
348
  end
349
+
350
+ remove_indices.add(pair[:result_msg_idx])
351
+ dropped_results.add(pair[:call_id])
329
352
  when :truncate
330
353
  truncate_indices[pair[:result_msg_idx]] = pair[:call]
331
354
  end
@@ -337,11 +360,29 @@ module Ask
337
360
  if truncate_indices[idx]
338
361
  truncate_message(msg, truncate_indices[idx])
339
362
  else
340
- msg
363
+ prune_dropped_calls(msg, dropped_results)
341
364
  end
342
365
  end
343
366
  end
344
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
+
345
386
  def truncate_message(msg, call)
346
387
  original = msg[:content].to_s
347
388
  return msg if original.length <= @truncate_head_chars
@@ -364,7 +405,8 @@ module Ask
364
405
  kept: action_counts[:keep] || 0,
365
406
  truncated: action_counts[:truncate] || 0,
366
407
  dropped: action_counts[:drop] || 0,
367
- tool_pairs_evaluated: decisions.size,
408
+ pinned: action_counts[:pinned] || 0,
409
+ tool_pairs_evaluated: decisions.size - (action_counts[:pinned] || 0),
368
410
  chars_before: original_chars,
369
411
  chars_after: pruned_chars,
370
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.4"
5
+ VERSION = "0.2.5"
6
6
  end
7
7
  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.4
4
+ version: 0.2.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kaka Ruto