ask-decisions 0.2.2 → 0.2.3

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: a6d66ad26316914f34f043aa0ac1cad2ebd0756db251bb893475d9ab41b70749
4
- data.tar.gz: 0663d597f9a6781923be49c80642b0bb70689c11c9f4557497880cfc8128477b
3
+ metadata.gz: e94c483511119929cad9bbdf2d0925ca498e4b4f98f1119c05ac3c26a239edfc
4
+ data.tar.gz: e09632e3fbaa5f2dca5ffc8692afe586d1818b985db8afaa600f3498e614ab9f
5
5
  SHA512:
6
- metadata.gz: e6dd919a98dd01107d2ea456c653ffbdf31d5580a2d02a19fe0769ffffa32bf2285b5547f3e0f63b1c40d3fb6de32e65ee1371a14127ed9b367a39e5cd1d022a
7
- data.tar.gz: '0493127671b11ccaa3306ce7efea04c11b9395ca5d63b61ee50e9894e45738e0170dfc3ea5c7082566d25d675bd5311795c1f08f98621dcd8538f353a4f961e1'
6
+ metadata.gz: a55fb6e583a983afd05acde0048d203d2e81c3098ef3cfa9d278214a3e4cd2de47ba2bd0f78fcb572f078da1c246fc4dc2aa22f2d23380cd79dc7d5c5fdbfe70
7
+ data.tar.gz: 57e4a24f195eb27e518e88e7340850e27a90499c2a22ac5a3069cca63d787fee3623f350ccf893fb0794f4eb13667656fd82abff369b9ce71c0c62b30f1a0490
data/CHANGELOG.md CHANGED
@@ -4,6 +4,16 @@ 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.3] - 2026-09-18
8
+
9
+ ### Added
10
+
11
+ - **`Ask::Decisions::ThresholdJudge`** — binary accept/reject judge for
12
+ simple gate decisions. Runs a single Noul question and thresholds the
13
+ result. Use this when you need "does this pass?" without the full
14
+ rubric of `QualityJudge`. Returns a `ThresholdVerdict` with `passed?`,
15
+ `noul`, and `confidence`.
16
+
7
17
  ## [0.2.2] - 2026-09-18
8
18
 
9
19
  ### Fixed
@@ -44,6 +44,16 @@ module Ask
44
44
  @tool_repairer = ToolRepairer.new(@provider)
45
45
  end
46
46
 
47
+ # Build a decision-based compactor for the current provider.
48
+ #
49
+ # compactor = adapter.build_compactor(preserve_recent: 6)
50
+ # result = compactor.compact(session.messages)
51
+ # result.messages # => pruned conversation
52
+ #
53
+ def build_compactor(**opts)
54
+ Ask::Decisions::Compactor.new(@provider, **opts)
55
+ end
56
+
47
57
  # Generate before_tool hooks for ask-agent.
48
58
  # Returns an array of callables that match the Hooks interface.
49
59
  def before_tool_hooks
@@ -0,0 +1,412 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ module Decisions
5
+ # Decision-based conversation compactor. Replaces lossy summarization with
6
+ # Jev-scored pruning: every tool call and result is evaluated for relevance,
7
+ # and the irrelevant ones are dropped or truncated — everything kept stays
8
+ # verbatim.
9
+ #
10
+ # This is the Ruby equivalent of the fast-jev-compaction approach: rather
11
+ # than asking an LLM to summarize old turns (which loses file paths, exact
12
+ # errors, constraints, and command details), Jev scores each tool call and
13
+ # result independently, and the compactor applies three-tier decisions.
14
+ #
15
+ # compactor = Ask::Decisions::Compactor.new(provider)
16
+ # result = compactor.compact(messages)
17
+ # result.messages # => pruned message list
18
+ # result.stats # => { kept: 12, dropped: 5, truncated: 3, ... }
19
+ # result.reduction_ratio # => 0.29 (29% of tool content removed)
20
+ #
21
+ # Message format (hashes):
22
+ #
23
+ # { role: "user", content: "Fix the failing test" }
24
+ # { role: "assistant", content: "",
25
+ # tool_calls: [{ id: "call_1", name: "Read", input: { path: "src/a.rb" } }] }
26
+ # { role: "tool", content: "file contents...",
27
+ # tool_call_id: "call_1" }
28
+ #
29
+ # The first message is always kept. The most recent +preserve_recent+
30
+ # messages are never touched. Everything else is a candidate for pruning.
31
+ #
32
+ class Compactor
33
+ # Approximate chars per token for budget calculations.
34
+ CHARS_PER_TOKEN = 4
35
+
36
+ # Jev Noul questions asked per tool call.
37
+ CALL_QUESTION = "Should this tool call be kept — does knowing it was " \
38
+ "made still matter for the current conversation?"
39
+ RESULT_QUESTION = "Should this tool result be kept verbatim — are its " \
40
+ "contents still needed and would re-running the tool " \
41
+ "not suffice?"
42
+
43
+ # @param provider [Ask::DecisionProvider] the Jev/decision provider
44
+ # @param keep_threshold [Float] minimum Noul probability to keep (0.0–1.0)
45
+ # @param preserve_recent [Integer] newest messages never touched
46
+ # @param max_state_tokens [Integer] token ceiling for state sent to Jev
47
+ # @param max_request_tokens [Integer] token ceiling per batch request
48
+ # @param truncate_head_chars [Integer] chars retained from dropped results
49
+ # @param goal [String, nil] ongoing task description for Jev context
50
+ def initialize(provider,
51
+ keep_threshold: 0.5,
52
+ preserve_recent: 4,
53
+ max_state_tokens: 25_000,
54
+ max_request_tokens: 30_000,
55
+ truncate_head_chars: 300,
56
+ goal: nil)
57
+ @provider = provider
58
+ @keep_threshold = keep_threshold
59
+ @preserve_recent = preserve_recent
60
+ @max_state_tokens = max_state_tokens
61
+ @max_request_tokens = max_request_tokens
62
+ @truncate_head_chars = truncate_head_chars
63
+ @goal = goal
64
+ end
65
+
66
+ # Compact a conversation by scoring every tool call and result with Jev,
67
+ # then dropping or truncating the irrelevant ones.
68
+ #
69
+ # @param messages [Array<Hash>] conversation messages
70
+ # @return [Result] compacted messages and stats
71
+ def compact(messages)
72
+ messages = normalize(messages)
73
+ return Result.new(messages.dup, original_count: messages.size) if messages.size < 2
74
+
75
+ pairs = build_pairs(messages)
76
+ pinned = compute_pinned(messages)
77
+
78
+ return Result.new(messages.dup, original_count: messages.size) if pairs.empty?
79
+
80
+ state = build_state(messages, pairs)
81
+ questions = build_questions(pairs, pinned)
82
+ batch = batch_and_execute(state, questions)
83
+ decisions = extract_decisions(batch, pairs)
84
+ pruned = apply_decisions(messages, decisions, pinned)
85
+ stats = compute_stats(messages, pruned, decisions)
86
+
87
+ Result.new(pruned, stats: stats, original_count: messages.size)
88
+ end
89
+
90
+ private
91
+
92
+ # ── Normalization ────────────────────────────────────────────────
93
+
94
+ def normalize(messages)
95
+ Array(messages).map { |m| m.is_a?(Hash) ? m : hashify(m) }
96
+ end
97
+
98
+ def hashify(msg)
99
+ {
100
+ role: msg.respond_to?(:role) ? msg.role.to_s : "unknown",
101
+ content: msg.respond_to?(:content) ? msg.content.to_s : msg.to_s,
102
+ tool_calls: extract_tool_calls_from_object(msg),
103
+ tool_call_id: msg.respond_to?(:tool_call_id) ? msg.tool_call_id : nil
104
+ }.compact
105
+ end
106
+
107
+ def extract_tool_calls_from_object(msg)
108
+ return nil unless msg.respond_to?(:tool_calls) && msg.tool_calls
109
+ Array(msg.tool_calls).map do |tc|
110
+ {
111
+ id: tc.respond_to?(:id) ? tc.id : tc[:id],
112
+ name: tc.respond_to?(:name) ? tc.name : tc[:name],
113
+ input: tc.respond_to?(:arguments) ? tc.arguments : (tc[:input] || tc[:arguments])
114
+ }
115
+ end
116
+ end
117
+
118
+ # ── Pairing ──────────────────────────────────────────────────────
119
+
120
+ # Group messages into tool_call / tool_result pairs by tool_call_id.
121
+ # Returns an array of { call_msg:, result_msg:, call: } hashes.
122
+ def build_pairs(messages)
123
+ call_index = {}
124
+ messages.each_with_index do |msg, idx|
125
+ Array(msg[:tool_calls]).each do |tc|
126
+ call_index[tc[:id]] = { call_msg_idx: idx, call: tc }
127
+ end
128
+ end
129
+
130
+ pairs = []
131
+ messages.each_with_index do |msg, idx|
132
+ tcid = msg[:tool_call_id]
133
+ next unless tcid && call_index[tcid]
134
+
135
+ entry = call_index[tcid]
136
+ pairs << {
137
+ call_msg_idx: entry[:call_msg_idx],
138
+ result_msg_idx: idx,
139
+ call: entry[:call],
140
+ call_id: tcid
141
+ }
142
+ end
143
+
144
+ pairs.sort_by { |p| p[:call_msg_idx] }
145
+ end
146
+
147
+ # First message and the most recent +preserve_recent+ messages are pinned.
148
+ def compute_pinned(messages)
149
+ pinned = Set.new([0])
150
+ start = [messages.size - @preserve_recent, 1].max
151
+ (start...messages.size).each { |i| pinned.add(i) }
152
+ pinned
153
+ end
154
+
155
+ # ── State ────────────────────────────────────────────────────────
156
+
157
+ # Build the state Jev sees: full conversation with tool results replaced
158
+ # by short placeholder notes. Tool inputs are included, text is included,
159
+ # nothing is summarized.
160
+ def build_state(messages, pairs)
161
+ result_index = {}
162
+ pairs.each { |p| result_index[p[:result_msg_idx]] = p }
163
+
164
+ lines = messages.each_with_index.map do |msg, idx|
165
+ role = msg[:role] || "unknown"
166
+ content = msg[:content].to_s
167
+
168
+ if result_index[idx]
169
+ result_index[idx][:result_preview] = content
170
+ chars = content.length
171
+ "[tool result: ok, #{chars} chars (omitted)]"
172
+ elsif msg[:tool_calls] && !msg[:tool_calls].empty?
173
+ tc_strs = msg[:tool_calls].map do |tc|
174
+ input_str = format_input(tc[:input])
175
+ "#{tc[:name]}(#{input_str})"
176
+ end
177
+ tc_strs.empty? ? content : "#{content}\n#{tc_strs.join("\n")}"
178
+ else
179
+ content
180
+ end
181
+ end
182
+
183
+ goal_section = @goal ? "\n\nGoal: #{@goal}" : ""
184
+ { conversation: lines, goal: @goal }.compact
185
+ end
186
+
187
+ def format_input(input)
188
+ return "" unless input
189
+ str = input.is_a?(String) ? input : JSON.generate(input)
190
+ str.length > 100 ? "#{str[0, 100]}..." : str
191
+ end
192
+
193
+ # ── Questions ────────────────────────────────────────────────────
194
+
195
+ def build_questions(pairs, pinned)
196
+ questions = {}
197
+ pairs.each do |pair|
198
+ next if pinned.include?(pair[:call_msg_idx]) && pinned.include?(pair[:result_msg_idx])
199
+
200
+ call_id = pair[:call_id]
201
+ tc = pair[:call]
202
+ input_str = format_input(tc[:input])
203
+
204
+ questions["keep_call_#{call_id}"] = Ask::Decision::Noul.new(
205
+ instructions: "#{CALL_QUESTION}\n\nTool: #{tc[:name]}\nInput: #{input_str}"
206
+ )
207
+ questions["keep_result_#{call_id}"] = Ask::Decision::Noul.new(
208
+ instructions: "#{RESULT_QUESTION}\n\nTool: #{tc[:name]}\nInput: #{input_str}"
209
+ )
210
+ end
211
+ questions
212
+ end
213
+
214
+ # ── Batching ─────────────────────────────────────────────────────
215
+
216
+ # Split questions into batches that fit the request token budget,
217
+ # send each batch concurrently (sequentially in Ruby, but the same
218
+ # pattern as fast-jev-compaction), and merge results.
219
+ def batch_and_execute(state, questions)
220
+ return empty_batch if questions.empty?
221
+
222
+ batches = partition_batches(state, questions)
223
+ return execute_batch(state, questions) if batches.size <= 1
224
+
225
+ results = batches.map { |batch| execute_batch(state, batch) }
226
+ merge_batches(results)
227
+ end
228
+
229
+ def partition_batches(state, questions)
230
+ state_tokens = estimate_tokens(JSON.generate(state))
231
+ pairs = questions.to_a
232
+ batches = []
233
+ current = {}
234
+ current_tokens = state_tokens
235
+
236
+ pairs.each do |id, decision|
237
+ q_tokens = estimate_tokens(JSON.generate(decision.to_h))
238
+ if current_tokens + q_tokens > @max_request_tokens && current.any?
239
+ batches << current
240
+ current = {}
241
+ current_tokens = state_tokens
242
+ end
243
+ current[id] = decision
244
+ current_tokens += q_tokens
245
+ end
246
+
247
+ batches << current if current.any?
248
+ batches
249
+ end
250
+
251
+ def execute_batch(state, questions)
252
+ @provider.evaluate(
253
+ state: JSON.generate(state),
254
+ decisions: questions
255
+ )
256
+ end
257
+
258
+ def empty_batch
259
+ Ask::DecisionResult::Batch.new(answers: {})
260
+ end
261
+
262
+ def merge_batches(batches)
263
+ merged = {}
264
+ batches.each do |batch|
265
+ batch.answers.each { |k, v| merged[k] = v }
266
+ end
267
+ Ask::DecisionResult::Batch.new(answers: merged)
268
+ end
269
+
270
+ # ── Decisions ────────────────────────────────────────────────────
271
+
272
+ def extract_decisions(batch, pairs)
273
+ decisions = {}
274
+ pairs.each do |pair|
275
+ call_id = pair[:call_id]
276
+ keep_call_answer = batch["keep_call_#{call_id}"]
277
+ keep_result_answer = batch["keep_result_#{call_id}"]
278
+
279
+ keep_call = keep_call_answer&.noul || 0.0
280
+ keep_result = keep_result_answer&.noul || 0.0
281
+
282
+ decisions[call_id] = {
283
+ keep_call: keep_call,
284
+ keep_result: keep_result,
285
+ action: classify_action(keep_call, keep_result)
286
+ }
287
+ end
288
+ decisions
289
+ end
290
+
291
+ # Three-tier decision classification.
292
+ def classify_action(keep_call, keep_result)
293
+ if keep_result >= @keep_threshold
294
+ :keep
295
+ elsif keep_call >= @keep_threshold
296
+ :truncate
297
+ else
298
+ :drop
299
+ end
300
+ end
301
+
302
+ # ── Apply ────────────────────────────────────────────────────────
303
+
304
+ def apply_decisions(messages, decisions, pinned)
305
+ pairs = build_pairs(messages)
306
+ remove_indices = Set.new
307
+ truncate_indices = {}
308
+
309
+ pairs.each do |pair|
310
+ decision = decisions[pair[:call_id]]
311
+ next unless decision
312
+
313
+ case decision[:action]
314
+ when :drop
315
+ remove_indices.add(pair[:call_msg_idx])
316
+ remove_indices.add(pair[:result_msg_idx])
317
+ when :truncate
318
+ truncate_indices[pair[:result_msg_idx]] = pair[:call]
319
+ end
320
+ end
321
+
322
+ messages.each_with_index.filter_map do |msg, idx|
323
+ next if remove_indices.include?(idx)
324
+
325
+ if truncate_indices[idx]
326
+ truncate_message(msg, truncate_indices[idx])
327
+ else
328
+ msg
329
+ end
330
+ end
331
+ end
332
+
333
+ def truncate_message(msg, call)
334
+ original = msg[:content].to_s
335
+ return msg if original.length <= @truncate_head_chars
336
+
337
+ head = original[0, @truncate_head_chars]
338
+ truncated_content = "#{head}\n...[result truncated: #{original.length - @truncate_head_chars} chars omitted — #{call[:name]} result was kept as call-only]"
339
+ msg.merge(content: truncated_content)
340
+ end
341
+
342
+ # ── Stats ────────────────────────────────────────────────────────
343
+
344
+ def compute_stats(original, pruned, decisions)
345
+ action_counts = decisions.values.tally { |d| d[:action] }
346
+ original_chars = original.sum { |m| m[:content].to_s.length }
347
+ pruned_chars = pruned.sum { |m| m[:content].to_s.length }
348
+
349
+ {
350
+ messages_before: original.size,
351
+ messages_after: pruned.size,
352
+ kept: action_counts[:keep] || 0,
353
+ truncated: action_counts[:truncate] || 0,
354
+ dropped: action_counts[:drop] || 0,
355
+ tool_pairs_evaluated: decisions.size,
356
+ chars_before: original_chars,
357
+ chars_after: pruned_chars,
358
+ reduction_ratio: original_chars > 0 ? (1.0 - pruned_chars.to_f / original_chars) : 0.0
359
+ }
360
+ end
361
+
362
+ # ── Token estimation ─────────────────────────────────────────────
363
+
364
+ def estimate_tokens(text)
365
+ str = text.to_s
366
+ letters = str.count("a-zA-Z")
367
+ digits = str.count("0-9")
368
+ others = str.length - letters - digits
369
+ (letters / 6.0 + digits / 2.0 + others).ceil
370
+ end
371
+
372
+ # ── Batch result ─────────────────────────────────────────────────
373
+
374
+ def extract_call_id(answer_id, prefix)
375
+ answer_id.to_s.sub(/\A#{prefix}/, "")
376
+ end
377
+
378
+ # ── Result ───────────────────────────────────────────────────────
379
+
380
+ # Holds the compacted messages and compaction statistics.
381
+ class Result
382
+ attr_reader :messages, :stats, :original_count
383
+
384
+ def initialize(messages, stats: {}, original_count: nil)
385
+ @messages = messages
386
+ @stats = stats
387
+ @original_count = original_count || messages.size
388
+ end
389
+
390
+ # Fraction of tool content removed (0.0 = nothing removed, 1.0 = everything).
391
+ def reduction_ratio
392
+ stats[:reduction_ratio] || 0.0
393
+ end
394
+
395
+ # Whether any tool calls were dropped or truncated.
396
+ def compacted?
397
+ (stats[:dropped] || 0) > 0 || (stats[:truncated] || 0) > 0
398
+ end
399
+
400
+ def to_s
401
+ if compacted?
402
+ "compacted #{stats[:messages_before]}→#{stats[:messages_after]} messages " \
403
+ "(#{stats[:dropped]} dropped, #{stats[:truncated]} truncated, " \
404
+ "#{(reduction_ratio * 100).round(1)}% reduction)"
405
+ else
406
+ "no compaction needed (#{original_count} messages)"
407
+ end
408
+ end
409
+ end
410
+ end
411
+ end
412
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ module Decisions
5
+ # Binary accept/reject judge — simpler than QualityJudge when you
6
+ # just need "does this pass?" without a full rubric. Runs a single
7
+ # Noul question and thresholds the result.
8
+ #
9
+ # judge = Ask::Decisions::ThresholdJudge.new(provider)
10
+ # verdict = judge.evaluate(
11
+ # subject: "The code changes look correct and have tests.",
12
+ # question: "Is this code change safe to merge?"
13
+ # )
14
+ # verdict.passed? # => true
15
+ # verdict.confidence # => 0.92
16
+ #
17
+ # Use QualityJudge when you need multi-dimensional scoring.
18
+ # Use ThresholdJudge when you need a fast binary gate.
19
+ #
20
+ class ThresholdJudge
21
+ # @param provider [Ask::DecisionProvider]
22
+ def initialize(provider)
23
+ @provider = provider
24
+ end
25
+
26
+ # Evaluate a subject against a yes/no question.
27
+ #
28
+ # @param subject [String] the content to evaluate
29
+ # @param question [String] the yes/no question
30
+ # @param threshold [Float] minimum noul value to pass (default 0.7)
31
+ # @return [ThresholdVerdict]
32
+ def evaluate(subject:, question:, threshold: 0.7)
33
+ decisions = {
34
+ "verdict" => Ask::Decision::Noul.new(
35
+ instructions: question
36
+ )
37
+ }
38
+
39
+ result = @provider.evaluate(state: subject, decisions: decisions)
40
+ ThresholdVerdict.new(result, threshold)
41
+ end
42
+
43
+ # Verdict from threshold evaluation.
44
+ class ThresholdVerdict
45
+ attr_reader :result, :threshold
46
+
47
+ def initialize(result, threshold)
48
+ @result = result
49
+ @threshold = threshold
50
+ end
51
+
52
+ # The raw noul value (0–1, where 1 = strong yes).
53
+ def noul
54
+ answer = @result["verdict"]
55
+ answer&.noul || 0.0
56
+ end
57
+
58
+ # Did the subject pass the threshold?
59
+ def passed?
60
+ noul >= @threshold
61
+ end
62
+
63
+ # Confidence: distance from 0.5 (0 = uncertain, 0.5 = certain).
64
+ def confidence
65
+ (noul - 0.5).abs
66
+ end
67
+
68
+ def to_s
69
+ status = passed? ? "PASS" : "FAIL"
70
+ "#{status} (noul: #{('%.2f' % noul)}, confidence: #{('%.2f' % confidence)})"
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Ask
4
4
  module Decisions
5
- VERSION = "0.2.2"
5
+ VERSION = "0.2.3"
6
6
  end
7
7
  end
data/lib/ask-decisions.rb CHANGED
@@ -21,12 +21,14 @@ require_relative "ask/decisions/failure_classifier"
21
21
  require_relative "ask/decisions/loop_detector"
22
22
  require_relative "ask/decisions/confidence_policy"
23
23
  require_relative "ask/decisions/quality_judge"
24
+ require_relative "ask/decisions/threshold_judge"
24
25
  require_relative "ask/decisions/reflection_judge"
25
26
  require_relative "ask/decisions/tool_repairer"
26
27
  require_relative "ask/decisions/reranker"
27
28
  require_relative "ask/decisions/structured_state_loop"
28
29
  require_relative "ask/decisions/calibration_report"
29
30
  require_relative "ask/decisions/calibration_harness"
31
+ require_relative "ask/decisions/compactor"
30
32
 
31
33
  # The Decide tool requires ask-tools (Ask::Tool base class). Load conditionally
32
34
  # so the core gem works without a tools dependency.
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.2
4
+ version: 0.2.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kaka Ruto
@@ -154,6 +154,7 @@ files:
154
154
  - lib/ask/decisions/cache.rb
155
155
  - lib/ask/decisions/calibration_harness.rb
156
156
  - lib/ask/decisions/calibration_report.rb
157
+ - lib/ask/decisions/compactor.rb
157
158
  - lib/ask/decisions/confidence_policy.rb
158
159
  - lib/ask/decisions/decision_state.rb
159
160
  - lib/ask/decisions/failure_classifier.rb
@@ -168,6 +169,7 @@ files:
168
169
  - lib/ask/decisions/reranker.rb
169
170
  - lib/ask/decisions/static.rb
170
171
  - lib/ask/decisions/structured_state_loop.rb
172
+ - lib/ask/decisions/threshold_judge.rb
171
173
  - lib/ask/decisions/tool_repairer.rb
172
174
  - lib/ask/decisions/triage.rb
173
175
  - lib/ask/decisions/typesafe.rb