ask-decisions 0.1.1 → 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: 0eecbf122542bb526917a22123bd02d15ed9aaee01db6c3283024858cbb5a867
4
- data.tar.gz: 6192d3b7bab381fe65d3f43119ee836598aa68b2639eae0fb87379b432bd838a
3
+ metadata.gz: 3dd4560892d89a85e39ed1e73474268724f2f55fca39f1471e9e490e2044168c
4
+ data.tar.gz: d960989693373e6ce7afe0ddcec17bc88e59addafb89d5269fa67ef52bb9b299
5
5
  SHA512:
6
- metadata.gz: f90ced1aac865272ac6bdbc55f734ba55be112d0178b3fdc07214b3758b12399606bc6ccb3df48f2ca20f2968288759fa02bf975e20add4e1faeb8f8b52fc7a3
7
- data.tar.gz: efad93c3e452c09bc1b0e188c6445066dd6787a13ed9d6d9698e2198f7a90a17faff13b7aa5c26f92bd87c7c0fcefd1edda23dd84f489ae15ef7ae1497216ef4
6
+ metadata.gz: 3e2616a2e442dd2c764e5dd3d020a7ba7086f40ba24acca442e272db358817727e4bbaa6c57e84b87fea7c3e64103a6c6a741d7cac24f29f72504708a086b6ea
7
+ data.tar.gz: 99ea57a135e7f4265245f246f908af5b821b4780ffbf9cafe8f68c94c9d63124e4298666b106c556ccbb9c9aa1be75bb45e690747f9c063fc1616ee053553248
data/CHANGELOG.md CHANGED
@@ -4,7 +4,56 @@ 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.1.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
40
+
41
+ ### Removed
42
+
43
+ - **`Ask::Decisions::ToolRouter` — a router that picks one of N tools from the
44
+ message alone.** It is gone because the measurement below is the argument
45
+ against it: routing to a tool was right 10/16 where routing to a lane was
46
+ right 19/20, on the same roster with the same model. Overlapping tools
47
+ cannot be separated by a message — which of seven knowledge tools holds the
48
+ answer is discovered by calling them — so a router answering that question
49
+ is guessing with confidence. The lane withholds the tools a turn cannot
50
+ need and the model chooses within the narrow roster, where it can see the
51
+ candidates' results.
52
+
53
+ Nothing in the ecosystem called it: not ask-agent, not ask-anychat, not any
54
+ app. Take this as the cheap moment — the class has no users yet.
55
+
56
+ ## [0.1.1] - 2026-09-18
8
57
 
9
58
  The first release, so everything here is new. It is a decision layer for the
10
59
  ask-rb ecosystem: ask Jev (or any System One model) typed questions and get
@@ -15,7 +64,7 @@ decide.
15
64
 
16
65
  - `Ask::Decisions::Reader` — asks a described set of options as one Choice,
17
66
  with anything else the caller needs riding along in the same request.
18
- `ToolRouter` and `Triage` are façades over it.
67
+ `Triage` is a façade over it.
19
68
  - `Ask::Decisions::Triage` — reads a message into a caller-defined lane and
20
69
  asks the mood and whether the person wants a human, all in one request.
21
70
  Measured against a 19-tool roster: lane-level routing was right 19/20 where
data/README.md CHANGED
@@ -177,16 +177,28 @@ not carry. A lane is the part that *is* decidable from the message alone.
177
177
  A reading should narrow, never grant: let the lane take tools away from a
178
178
  turn, and let the agent's own definition stay the ceiling.
179
179
 
180
- ## ToolRouter
180
+ ### Why there is no tool router
181
181
 
182
- Routes user turns to the right tool:
182
+ There was one `ToolRouter`, a Choice over a tool roster. It is gone, because
183
+ the measurement above is the argument against it: asked to pick one of
184
+ nineteen tools the answer was right 10 times in 16, and asked to pick a lane
185
+ 19 times in 20. Same model, same messages.
183
186
 
184
- ```ruby
185
- router = Ask::Decisions::ToolRouter.new(provider, tools: tool_roster)
186
- result = router.route(user_turn: "run the tests")
187
- result.tool # => "bash"
188
- result.confidence # => 0.92
189
- ```
187
+ The reason is structural, not a tuning problem. Overlapping tools cannot be
188
+ separated by a message: which of seven knowledge tools holds the answer is
189
+ discovered by *calling* them. Routing to a tool asks a question the message
190
+ does not carry, so a router that answers it is guessing with confidence.
191
+
192
+ What replaces it is the lane plus code:
193
+
194
+ - the lane withholds the tools the turn cannot need,
195
+ - the lane's pre-read fetches what the turn will obviously ask for,
196
+ - and the model chooses within the narrow roster, where choosing is a
197
+ decision it can actually make — because it can see the candidates' results.
198
+
199
+ If you do need a decider to pick a tool, the roster it picks from has to be
200
+ small and disjoint — a handful of tools a message can actually distinguish.
201
+ If it is not, the fix is a narrower lane or a pre-read, not a better prompt.
190
202
 
191
203
  ## ConfidencePolicy
192
204
 
@@ -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
@@ -12,8 +12,7 @@ module Ask
12
12
  # along in the same request.
13
13
  #
14
14
  # The caller owns *what* to ask — which options, in what words. This owns
15
- # asking it once and handing back the answers. `ToolRouter` and `Triage`
16
- # are both façades over it.
15
+ # asking it once and handing back the answers.
17
16
  #
18
17
  # reader = Ask::Decisions::Reader.new(
19
18
  # provider,
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Ask
4
4
  module Decisions
5
- VERSION = "0.1.1"
5
+ VERSION = "0.2.1"
6
6
  end
7
7
  end
data/lib/ask-decisions.rb CHANGED
@@ -15,7 +15,6 @@ require_relative "ask/decisions/gate"
15
15
  require_relative "ask/decisions/output_judge"
16
16
  require_relative "ask/decisions/reader"
17
17
  require_relative "ask/decisions/triage"
18
- require_relative "ask/decisions/tool_router"
19
18
  require_relative "ask/decisions/argument_resolver"
20
19
  require_relative "ask/decisions/decision_state"
21
20
  require_relative "ask/decisions/failure_classifier"
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.1.1
4
+ version: 0.2.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kaka Ruto
@@ -169,7 +169,6 @@ files:
169
169
  - lib/ask/decisions/static.rb
170
170
  - lib/ask/decisions/structured_state_loop.rb
171
171
  - lib/ask/decisions/tool_repairer.rb
172
- - lib/ask/decisions/tool_router.rb
173
172
  - lib/ask/decisions/triage.rb
174
173
  - lib/ask/decisions/typesafe.rb
175
174
  - lib/ask/decisions/version.rb
@@ -1,137 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Ask
4
- module Decisions
5
- # Routes a user turn to the right tool by asking Jev to pick from the
6
- # tool roster. Emits the same shape as a tool call so the existing
7
- # ToolExecutor can run it unchanged.
8
- #
9
- # router = Ask::Decisions::ToolRouter.new(provider, tools: tool_roster)
10
- # result = router.route(
11
- # user_turn: "check the weather in seattle tomorrow",
12
- # recent_turns: [...]
13
- # )
14
- # result.tool # => "web_search"
15
- # result.confidence # => 0.92
16
- # result.answer_directly? # => false
17
- #
18
- class ToolRouter
19
- # Non-tool outcomes that the Choice question includes.
20
- NON_TOOL_OUTCOMES = {
21
- "answer_directly" => "Answer the user directly without calling any tool",
22
- "ask_clarifying_question" => "Ask the user a clarifying question before acting",
23
- "none" => "No action needed; the turn is a follow-up or acknowledgment"
24
- }.freeze
25
-
26
- # The question the roster answers. The ids in +criteria+ are the tool
27
- # names, so the answer comes back as a tool the caller can run.
28
- INSTRUCTIONS = "Which tool should the assistant use to handle the user's latest request? " \
29
- "If no tool is needed, pick answer_directly, ask_clarifying_question, or none."
30
-
31
- # @param provider [Ask::DecisionProvider]
32
- # @param tools [Array<Hash>] tool roster, each with "name" and "description"
33
- # @param none_threshold [Float] below this confidence, fall back to LLM
34
- # @param criteria [Hash, nil] routing-grade descriptions, tool name =>
35
- # when to choose it. Worth supplying whenever the roster holds tools
36
- # that overlap: a tool's own description is written for the model that
37
- # already holds it, and two accurate descriptions can still fail to
38
- # separate their tools from the outside. Omitted, each tool's own
39
- # description is used.
40
- # @param limit [Integer] characters kept per description
41
- def initialize(provider, tools:, none_threshold: 0.5, criteria: nil, limit: 160)
42
- @provider = provider
43
- @tools = tools
44
- @none_threshold = none_threshold
45
- @criteria = criteria
46
- @limit = limit
47
- end
48
-
49
- # Route a user turn to a tool or non-tool outcome.
50
- #
51
- # @param user_turn [String] the latest user message
52
- # @param recent_turns [String, nil] recent conversation context (truncated)
53
- # @param model [String, nil] model override
54
- # @return [RouteResult]
55
- def route(user_turn:, recent_turns: nil, model: nil)
56
- state = build_state(user_turn: user_turn, recent_turns: recent_turns)
57
- RouteResult.new(reader.choice(reader.read(state: state, model: model)))
58
- end
59
-
60
- private
61
-
62
- # The roster, as the options of one Choice question.
63
- def reader
64
- @reader ||= Ask::Decisions::Reader.new(
65
- @provider,
66
- id: "tool.route",
67
- instructions: INSTRUCTIONS,
68
- options: described_roster,
69
- limit: @limit
70
- )
71
- end
72
-
73
- def described_roster
74
- described = @tools.each_with_object({}) do |tool, options|
75
- name = tool[:name] || tool["name"]
76
- options[name] = @criteria&.dig(name) || @criteria&.dig(name.to_s) ||
77
- tool[:description] || tool["description"] || ""
78
- end
79
- described.merge(NON_TOOL_OUTCOMES)
80
- end
81
-
82
- def build_state(user_turn:, recent_turns: nil)
83
- state = {user_turn: user_turn}
84
- state[:recent_turns] = truncate(recent_turns, 2000) if recent_turns
85
- state
86
- end
87
-
88
- def truncate(str, limit)
89
- return "" if str.nil?
90
- str.length > limit ? "#{str[0, limit]}…" : str
91
- end
92
-
93
-
94
- # Result of routing.
95
- class RouteResult
96
- attr_reader :choice_answer
97
-
98
- def initialize(choice_answer)
99
- @choice_answer = choice_answer
100
- end
101
-
102
- # The selected tool name or non-tool outcome.
103
- def tool = choice_answer&.choice
104
-
105
- def confidence = choice_answer&.confidence
106
-
107
- def probabilities = choice_answer&.probabilities
108
-
109
- # Should we call a tool, or handle this differently?
110
- def answer_directly? = tool == "answer_directly"
111
- def ask_clarifying? = tool == "ask_clarifying_question"
112
- def no_action? = tool == "none"
113
- def call_tool? = !answer_directly? && !ask_clarifying? && !no_action?
114
-
115
- # Is the confidence above the threshold for autonomous action?
116
- def confident?(threshold = nil)
117
- threshold ||= 0.7
118
- return false if confidence.nil?
119
- confidence >= threshold
120
- end
121
-
122
- # Should we fall back to the LLM loop?
123
- def fallback?(none_threshold = 0.5)
124
- confidence.nil? || confidence < none_threshold
125
- end
126
-
127
- def to_s
128
- if call_tool?
129
- "tool: #{tool} (#{('%.2f' % (confidence || 0))})"
130
- else
131
- "#{tool} (#{('%.2f' % (confidence || 0))})"
132
- end
133
- end
134
- end
135
- end
136
- end
137
- end