ask-decisions 0.2.0 → 0.2.1

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: '09c0d2c768b78986cc452b899fca0579c7030fe8d4e9b067dcc6d38b8aebe544'
4
- data.tar.gz: 6af44122d2741d29f5897c655cba0f38e99596dff88fec8152a95237957d514f
3
+ metadata.gz: 3dd4560892d89a85e39ed1e73474268724f2f55fca39f1471e9e490e2044168c
4
+ data.tar.gz: d960989693373e6ce7afe0ddcec17bc88e59addafb89d5269fa67ef52bb9b299
5
5
  SHA512:
6
- metadata.gz: fd023d73dd39cffa709fbf27f651dfddfe39590bc77ca6874eac5d2c85fe10e8bcc3b1df51757eb6d612df6fde8f1691ad1cb704c74b26ace67349c845b6e8c3
7
- data.tar.gz: ef8451ad571c3d52d20b17641ddee5a01e24ae39ea1e7a1e976ab0743dd375d2775f27d569b4cc451111df9543359a2bd00ce0f4ef5cc40ee8ce2cb3cdefeb14
6
+ metadata.gz: 3e2616a2e442dd2c764e5dd3d020a7ba7086f40ba24acca442e272db358817727e4bbaa6c57e84b87fea7c3e64103a6c6a741d7cac24f29f72504708a086b6ea
7
+ data.tar.gz: 99ea57a135e7f4265245f246f908af5b821b4780ffbf9cafe8f68c94c9d63124e4298666b106c556ccbb9c9aa1be75bb45e690747f9c063fc1616ee053553248
data/CHANGELOG.md CHANGED
@@ -4,7 +4,39 @@ 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.0] - 2026-09-18
7
+ ## [0.2.1] - 2026-09-17
8
+
9
+ ### Added
10
+
11
+ - **The host owns the judgement: `Gate.new(provider, questions:, thresholds:)`.**
12
+ The questions a gate asks are a property of the host's tools, not of the gem.
13
+ A booking tool and a shell tool are not dangerous for the same reason, and
14
+ pi-jev's questions — "is this action destructive?", "does this send local data
15
+ off-machine?" — say nothing useful about booking an appointment. A host now
16
+ asks its own: "does this commit the customer to a booking?", "does this spend
17
+ the owner's money?". Every question must be armed with a threshold, and a
18
+ question without one is refused at construction rather than armed with a bar
19
+ that never fires. A threshold given for one of the default questions still
20
+ keeps the rest of the defaults, so raising one bar stays one line.
21
+
22
+ - **`OutputJudge.new(provider, questions:, advice:)`.** Same reasoning: what an
23
+ output *is* — a leak, a failure class, the advice to give — belongs to the
24
+ host. The two ids the result reads (`:leaks_secret`, `:failure_class`) stay
25
+ the gem's contract, and the outcome question must offer `no_failure` among its
26
+ criteria, because the judge runs after every judged call and not only after a
27
+ suspicious one; both are refused at construction when they are missing, since
28
+ a judge that reads nothing judges nothing, silently.
29
+
30
+ - **`AgentAdapter` passes the judgement through** — `gate_questions`,
31
+ `output_questions`, `output_advice` — so a host configures the guards for its
32
+ own tools in one place.
33
+
34
+ ### Notes
35
+
36
+ - 0.2.0 is what it was released as: the router removal below, and the
37
+ host-owned judgement arrived after it.
38
+
39
+ ## [0.2.0] - 2026-09-17
8
40
 
9
41
  ### Removed
10
42
 
@@ -106,12 +106,25 @@ module Ask
106
106
  Ask::Decisions.resolve_provider(name)
107
107
  end
108
108
 
109
+ # What the host judges, and how high the bar is. The questions are the
110
+ # host's because risk is: a booking tool and a shell tool are not
111
+ # dangerous for the same reason, and a gate written for one says nothing
112
+ # useful about the other.
109
113
  def gate_config
110
- { tools: @config[:gate_tools], thresholds: @config[:gate_thresholds] || {} }.compact
114
+ {
115
+ questions: @config[:gate_questions],
116
+ thresholds: @config[:gate_thresholds],
117
+ tools: @config[:gate_tools]
118
+ }.compact
111
119
  end
112
120
 
113
121
  def output_judge_config
114
- { tools: @config[:output_tools], output_limit: @config[:output_limit] }.compact
122
+ {
123
+ questions: @config[:output_questions],
124
+ advice: @config[:output_advice],
125
+ tools: @config[:output_tools],
126
+ output_limit: @config[:output_limit]
127
+ }.compact
115
128
  end
116
129
 
117
130
  def failure_classifier_config
@@ -49,11 +49,20 @@ module Ask
49
49
  }.freeze
50
50
 
51
51
  # @param provider [Ask::DecisionProvider] the decision provider to use
52
- # @param thresholds [Hash] override specific thresholds
52
+ # @param questions [Hash{Symbol => Decision::Noul,Decision::Score}] what
53
+ # to ask about a call. The defaults above are pi-jev's, written for a
54
+ # coding agent's tools; a host with other tools should say what risk
55
+ # means for them ("does this commit the customer to a booking?", "does
56
+ # this spend the owner's money?") rather than inherit a vocabulary
57
+ # about shell commands.
58
+ # @param thresholds [Hash{Symbol => Numeric}] the bar for each question.
59
+ # Every question needs one: a question with no threshold can never
60
+ # flag, and a gate that looks armed and never fires is worse than none.
53
61
  # @param tools [Array<String>, nil] tools to gate (nil = all)
54
- def initialize(provider, thresholds: {}, tools: nil)
62
+ def initialize(provider, questions: QUESTIONS, thresholds: DEFAULT_THRESHOLDS, tools: nil)
55
63
  @provider = provider
56
- @thresholds = DEFAULT_THRESHOLDS.merge(thresholds)
64
+ @questions = questions
65
+ @thresholds = arming_thresholds(questions, thresholds)
57
66
  @tools = tools
58
67
  end
59
68
 
@@ -71,7 +80,7 @@ module Ask
71
80
 
72
81
  result = @provider.evaluate(
73
82
  state: state,
74
- decisions: QUESTIONS
83
+ decisions: @questions
75
84
  )
76
85
 
77
86
  Verdict.new(result, @thresholds)
@@ -79,6 +88,27 @@ module Ask
79
88
 
80
89
  private
81
90
 
91
+ # Every question must be armed. A host that supplies its own questions
92
+ # and forgets a threshold would otherwise get a gate that silently
93
+ # ignores one of its own risk questions, which is the failure mode a
94
+ # gate exists to prevent.
95
+ #
96
+ # A threshold given for a question the defaults also ask keeps the rest
97
+ # of the defaults, so raising one bar is one line rather than a copy of
98
+ # the table.
99
+ def arming_thresholds(questions, thresholds)
100
+ keys = questions.keys.map(&:to_sym)
101
+ armed = DEFAULT_THRESHOLDS.slice(*keys).merge(thresholds.to_h.transform_keys(&:to_sym))
102
+ unarmed = keys - armed.keys
103
+
104
+ unless unarmed.empty?
105
+ raise ArgumentError,
106
+ "no threshold for #{unarmed.inspect}: a question that cannot fire is not a gate"
107
+ end
108
+
109
+ armed
110
+ end
111
+
82
112
  def build_state(tool:, args:, working_dir: nil, user_message: nil)
83
113
  {
84
114
  tool: tool,
@@ -46,12 +46,35 @@ module Ask
46
46
  }.freeze
47
47
 
48
48
  # @param provider [Ask::DecisionProvider]
49
- # @param tools [Array<String>, nil] tools to judge (nil = ["bash"])
49
+ # The class that means nothing went wrong. A host's outcome question has
50
+ # to offer it, because the judge runs after every judged call and not
51
+ # only after a suspicious one: without it, every successful call would
52
+ # read as a failure.
53
+ SUCCESS_CLASS = "no_failure"
54
+
55
+ # A host judges two things about an output: whether it leaked something
56
+ # (`:leaks_secret`), and what happened (`:failure_class`). Those two ids
57
+ # are the gem's contract and stay fixed; the questions' words, the
58
+ # classes they can answer with, and the advice per class are the host's —
59
+ # a coding agent's failures are code bugs and broken environments, a
60
+ # business's are "we don't offer that" and "the system is down".
61
+ #
62
+ # @param questions [Hash{Symbol => Decision}] what to ask about a result.
63
+ # Must answer under :leaks_secret and :failure_class, and the outcome
64
+ # question must offer the class above among its criteria.
65
+ # @param advice [Hash{String => String,nil}] one line per class the
66
+ # outcome question can answer with. A class with no line is advice the
67
+ # model does not get.
68
+ # @param tools [Array<String>, nil] tools to judge (nil = the gem's own
69
+ # default, the coding agent's shell tool — pass the host's own)
50
70
  # @param leak_threshold [Float] noul threshold for leak detection
51
71
  # @param failure_threshold [Float] confidence threshold for failure classification
52
72
  # @param output_limit [Integer] max characters of output to send
53
- def initialize(provider, tools: nil, leak_threshold: 0.90, failure_threshold: 0.60, output_limit: 2000)
73
+ def initialize(provider, questions: QUESTIONS, advice: ADVICE, tools: nil,
74
+ leak_threshold: 0.90, failure_threshold: 0.60, output_limit: 2000)
54
75
  @provider = provider
76
+ @questions = judgeable(questions)
77
+ @advice = advice
55
78
  @tools = tools || ["bash"]
56
79
  @leak_threshold = leak_threshold
57
80
  @failure_threshold = failure_threshold
@@ -70,12 +93,32 @@ module Ask
70
93
  truncated = truncate(output, @output_limit)
71
94
  state = { output: truncated, tool_arguments: truncate_values(args, 400) }
72
95
 
73
- result = @provider.evaluate(state: state, decisions: QUESTIONS)
74
- OutputResult.new(result, @leak_threshold, @failure_threshold)
96
+ result = @provider.evaluate(state: state, decisions: @questions)
97
+ OutputResult.new(result, @leak_threshold, @failure_threshold, advice: @advice)
75
98
  end
76
99
 
77
100
  private
78
101
 
102
+ # A host's questions have to answer the two things the result reads, and
103
+ # the outcome question has to be able to say that nothing went wrong.
104
+ # Both are silent failures otherwise — a judge that reads nothing judges
105
+ # nothing — so they are refused at construction instead.
106
+ def judgeable(questions)
107
+ missing = %i[leaks_secret failure_class] - questions.keys.map(&:to_sym)
108
+ unless missing.empty?
109
+ raise ArgumentError, "the output judge needs questions for #{missing.inspect}"
110
+ end
111
+
112
+ criteria = Array(questions[:failure_class].criteria&.keys)
113
+ unless criteria.include?(SUCCESS_CLASS)
114
+ raise ArgumentError,
115
+ "the failure_class question must offer #{SUCCESS_CLASS.inspect} among its criteria, " \
116
+ "or every successful call reads as a failure"
117
+ end
118
+
119
+ questions
120
+ end
121
+
79
122
  def truncate(str, limit)
80
123
  return "" if str.nil?
81
124
  str.length > limit ? "#{str[0, limit]}…[#{str.length - limit} chars elided]" : str
@@ -91,17 +134,17 @@ module Ask
91
134
  class OutputResult
92
135
  attr_reader :leak_noul, :failure_class, :failure_confidence, :advice
93
136
 
94
- def initialize(batch, leak_threshold, failure_threshold)
137
+ def initialize(batch, leak_threshold, failure_threshold, advice: ADVICE)
95
138
  leak_answer = batch["leaks_secret"]
96
139
  failure_answer = batch["failure_class"]
97
140
 
98
141
  @leak_noul = leak_answer&.noul || 0.0
99
142
  @leak_threshold = leak_threshold
100
143
 
101
- @failure_class = failure_answer&.choice || "no_failure"
144
+ @failure_class = failure_answer&.choice || SUCCESS_CLASS
102
145
  @failure_confidence = failure_answer&.confidence || 0.0
103
146
  @failure_threshold = failure_threshold
104
- @advice = ADVICE[@failure_class]
147
+ @advice = advice[@failure_class]
105
148
  end
106
149
 
107
150
  def leak?
@@ -109,7 +152,7 @@ module Ask
109
152
  end
110
153
 
111
154
  def failure?
112
- @failure_class != "no_failure" && @failure_confidence >= @failure_threshold
155
+ @failure_class != SUCCESS_CLASS && @failure_confidence >= @failure_threshold
113
156
  end
114
157
 
115
158
  def to_s
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Ask
4
4
  module Decisions
5
- VERSION = "0.2.0"
5
+ VERSION = "0.2.1"
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.0
4
+ version: 0.2.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kaka Ruto