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 +4 -4
- data/CHANGELOG.md +28 -0
- data/README.md +25 -0
- data/lib/ask/decisions/compactor.rb +60 -6
- data/lib/ask/decisions/version.rb +1 -1
- data/lib/ask-decisions.rb +12 -6
- 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: 9969a46203cf5653cacf020b9e5399945d137c846b88fcc52b1b6d91828de484
|
|
4
|
+
data.tar.gz: e172bd808e1a61019d9dd65dc2073f41776d46ee64e0233fa5333017a2c6945b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
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
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|