ask-decisions 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 0eecbf122542bb526917a22123bd02d15ed9aaee01db6c3283024858cbb5a867
4
+ data.tar.gz: 6192d3b7bab381fe65d3f43119ee836598aa68b2639eae0fb87379b432bd838a
5
+ SHA512:
6
+ metadata.gz: f90ced1aac865272ac6bdbc55f734ba55be112d0178b3fdc07214b3758b12399606bc6ccb3df48f2ca20f2968288759fa02bf975e20add4e1faeb8f8b52fc7a3
7
+ data.tar.gz: efad93c3e452c09bc1b0e188c6445066dd6787a13ed9d6d9698e2198f7a90a17faff13b7aa5c26f92bd87c7c0fcefd1edda23dd84f489ae15ef7ae1497216ef4
data/CHANGELOG.md ADDED
@@ -0,0 +1,67 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
6
+
7
+ ## [0.1.0] - 2026-09-18
8
+
9
+ The first release, so everything here is new. It is a decision layer for the
10
+ ask-rb ecosystem: ask Jev (or any System One model) typed questions and get
11
+ back answers with calibrated probabilities. LLMs generate text; deciders
12
+ decide.
13
+
14
+ ### Added
15
+
16
+ - `Ask::Decisions::Reader` — asks a described set of options as one Choice,
17
+ with anything else the caller needs riding along in the same request.
18
+ `ToolRouter` and `Triage` are façades over it.
19
+ - `Ask::Decisions::Triage` — reads a message into a caller-defined lane and
20
+ asks the mood and whether the person wants a human, all in one request.
21
+ Measured against a 19-tool roster: lane-level routing was right 19/20 where
22
+ tool-level routing was right 10/16 — the tools overlapped, and a lane is the
23
+ part of the decision the message actually carries.
24
+ - `Ask::Decisions::AgentAdapter` — wires `Gate`, `OutputJudge`,
25
+ `FailureClassifier`, `LoopDetector`, `QualityJudge`, `ReflectionJudge`,
26
+ `ToolRepairer` and `ConfidencePolicy` into ask-agent's `before_tool` /
27
+ `after_tool` hooks, so one config line activates the guard half:
28
+ `Ask::Agent.configure { |c| c.decision_provider = :typesafe }`.
29
+ - `Ask::Decision::Choice`, `Score`, `Noul` primitives in ask-core
30
+ - `Ask::DecisionResult::ChoiceAnswer`, `ScoreAnswer`, `NoulAnswer`, `Batch` in ask-core
31
+ - `Ask::DecisionProvider` base class + registry in ask-core
32
+ - `Ask::Decisions::Typesafe` — HTTP client for TypeSafe/System One API
33
+ - `Ask::Decisions::Batcher` — one-call batching for parallel questions
34
+ - `Ask::Decisions::Cache` — MD5-keyed result caching (120s TTL)
35
+ - `Ask::Decisions::Static` — canned answers for tests
36
+ - `Ask::Decisions::Lint` — anti-pattern detection (8 rules, 2 measured from pi-jev)
37
+ - `Ask::Decisions::Gate` — pre-tool-call intent judge (4 calibrated questions)
38
+ - `Ask::Decisions::OutputJudge` — post-tool-call leak detection + failure classification (6 classes)
39
+ - `Ask::Decisions::FailureClassifier` — retry logic with attempt tracking
40
+ - `Ask::Decisions::ToolRouter` — Choice over tool roster + non-tool outcomes
41
+ - `Ask::Decisions::ArgumentResolver` — enum→Choice, bool→Noul, free text→generator
42
+ - `Ask::Decisions::DecisionState` — compact state projection with token budget
43
+ - `Ask::Decisions::ConfidencePolicy` — risk-tiered act/review/escalate thresholds
44
+ - `Ask::Decisions::LoopDetector` — Jev-based stuck/repeating detection
45
+ - `Ask::Decisions::QualityJudge` — composite scoring over rubric dimensions
46
+ - `Ask::Decisions::ReflectionJudge` — Noul-based self-critique
47
+ - `Ask::Decisions::ToolRepairer` — Choice over valid tool names + enum values
48
+ - `Ask::Decisions::Reranker` — Score per query-passage pair for ask-rag
49
+ - `Ask::Decisions::StructuredStateLoop` — OCR/DOM→Jev→action for ask-computer
50
+ - `Ask::Decisions::CalibrationReport` — reliability curves per decision id
51
+ - `Ask::Decisions::CalibrationHarness` — run test cases and measure calibration
52
+ - `Ask::Tools::Decide` — bridge tool for LLM agents (requires ask-tools)
53
+ - `Ask::Decisions::MCPHelper` — one-liner to add decide to MCP servers
54
+ - `Ask.decide` and `Ask::Decisions.batch` convenience methods
55
+ - Provider registry: `Ask::DecisionProvider.register`
56
+ - `ask-tools` `param :enum` support (backward compatible)
57
+
58
+ ### Changed
59
+
60
+ - `Ask::Decisions::ToolRouter` takes `criteria:` — routing-grade descriptions,
61
+ tool name to when to choose it — and a `limit:`. A tool's own description is
62
+ written for the model that already holds the tool, so two accurate
63
+ descriptions can still fail to separate their tools from the outside.
64
+ - `Triage::Verdict#wants_human?` requires the probability to be *above* the
65
+ threshold. A noul at exactly 0.5 is the model saying it has no idea, which
66
+ is the one answer that must not read as consent.
67
+ - Requires `ask-core >= 0.12.0` for the decision vocabulary.
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Kaka Ruto
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,238 @@
1
+ # ask-decisions
2
+
3
+ Decision primitives and providers for the [ask-rb](https://github.com/ask-rb) ecosystem. Ask Jev (or any System One model) structured questions — classify, route, score, guard — and get typed answers with calibrated probabilities. LLMs generate text. Deciders decide.
4
+
5
+ ```ruby
6
+ result = Ask.decide(
7
+ state: "Help! My payouts have been failing for 3 days.",
8
+ decisions: {
9
+ "route" => Ask::Decision::Choice.new(
10
+ instructions: "Which team should handle this?",
11
+ criteria: { billing: "Payment issues", technical: "Bugs", sales: "Pricing" }
12
+ ),
13
+ "urgent" => Ask::Decision::Noul.new(
14
+ instructions: "Does this message convey urgency?"
15
+ )
16
+ }
17
+ )
18
+ # result["route"].choice # => "billing"
19
+ # result["route"].confidence # => 0.89
20
+ # result["urgent"].noul # => 0.96
21
+ ```
22
+
23
+ ## Installation
24
+
25
+ ```ruby
26
+ gem "ask-decisions"
27
+ ```
28
+
29
+ ## Setup
30
+
31
+ ```ruby
32
+ require "ask-decisions"
33
+
34
+ Ask::Decisions.configure do |c|
35
+ c.default_provider = :typesafe # or :static for tests
36
+ c.default_model = "jev-latest"
37
+ end
38
+ ```
39
+
40
+ ## The four verbs
41
+
42
+ Everything Jev does with LLMs falls into one of these patterns:
43
+
44
+ | Verb | Shape | Example |
45
+ |---|---|---|
46
+ | **Route** | decide → generate | Jev picks the tool; LLM writes the answer |
47
+ | **Filter** | generate → decide | LLM proposes facts; Jev keeps the good ones |
48
+ | **Replace** | decide, no generate | Triage, scoring, relevance — no text needed |
49
+ | **Guard** | decide ⟶ gate generate | Screen input/output; gate actions on confidence |
50
+
51
+ Anything expressible as a closed set becomes a decision. Anything that must produce new text stays a generation.
52
+
53
+ ## Core primitives
54
+
55
+ Three question types, three answer types:
56
+
57
+ ```ruby
58
+ # Choice — pick one option from a set
59
+ Ask::Decision::Choice.new(
60
+ instructions: "Which team?",
61
+ criteria: { billing: "Payments", technical: "Bugs", none: "None of these" }
62
+ )
63
+
64
+ # Score — rate on a spectrum
65
+ Ask::Decision::Score.new(
66
+ instructions: "How urgent?",
67
+ criteria: ["Not urgent", "Somewhat urgent", "Very urgent"]
68
+ )
69
+
70
+ # Noul — yes/no with probability
71
+ Ask::Decision::Noul.new(
72
+ instructions: "Does this convey urgency?"
73
+ )
74
+ ```
75
+
76
+ ## Batching
77
+
78
+ Jev evaluates all questions in one request in parallel. Send every question you might need:
79
+
80
+ ```ruby
81
+ result = Ask::Decisions.batch(state: "...") do |b|
82
+ b.ask("route", Ask::Decision::Choice.new(...))
83
+ b.ask("urgent", Ask::Decision::Noul.new(...))
84
+ b.ask("quality", Ask::Decision::Score.new(...))
85
+ end
86
+ # One API call, three answers.
87
+ ```
88
+
89
+ ## Confidence
90
+
91
+ Every choice/score answer carries calibrated confidence. Noul answers carry a value (0–1) with no separate confidence — use the distance from 0.5 as strength.
92
+
93
+ ```ruby
94
+ result["route"].confident?(0.7) # => true
95
+ result["urgent"].strength # => 0.46 (distance from 0.5)
96
+ result["urgent"].yes? # => true (noul >= 0.5)
97
+ ```
98
+
99
+ ## Swapping providers
100
+
101
+ The registry makes providers swappable:
102
+
103
+ ```ruby
104
+ # Use Jev in production
105
+ Ask::Decisions.configure { |c| c.default_provider = :typesafe }
106
+
107
+ # Use canned answers in tests
108
+ Ask::Decisions.configure { |c| c.default_provider = :static }
109
+
110
+ # Register a future provider
111
+ Ask::DecisionProvider.register(:openjev, MyProvider)
112
+ Ask::Decisions.configure { |c| c.default_provider = :openjev }
113
+ ```
114
+
115
+ ## Gate (pre-tool-call)
116
+
117
+ Calibrated intent judge — 4 questions in one request:
118
+
119
+ ```ruby
120
+ gate = Ask::Decisions::Gate.new(provider)
121
+ verdict = gate.judge(tool: "bash", args: { command: "rm -rf src" })
122
+ verdict.passed? # => false
123
+ verdict.flagged # => [:destructive, :beyond_scope]
124
+ ```
125
+
126
+ ## OutputJudge (post-tool-call)
127
+
128
+ Screens tool output for leaks and classifies failures:
129
+
130
+ ```ruby
131
+ judge = Ask::Decisions::OutputJudge.new(provider)
132
+ result = judge.judge(tool: "bash", output: "npm ERR! code ECONNRESET")
133
+ result.leak? # => false
134
+ result.failure_class # => "transient"
135
+ result.advice # => "Retry unchanged."
136
+ ```
137
+
138
+ ## Triage
139
+
140
+ Read a message once and answer everything a turn needs about it — which lane
141
+ it belongs to, how the person sounds, whether they want a human. All in one
142
+ request, so the extra questions cost no extra latency:
143
+
144
+ ```ruby
145
+ triage = Ask::Decisions::Triage.new(provider, lanes: {
146
+ "knowledge" => "Asks about the business, its services, prices, or hours",
147
+ "booking" => "Wants to book an appointment or asks what times are free",
148
+ "human" => "Wants to speak to a person, or describes an emergency",
149
+ "close" => "Says goodbye or is done",
150
+ "chat" => "Small talk or a greeting needing no action",
151
+ "unclear" => "None of these is clear; a clarifying question is needed first"
152
+ })
153
+
154
+ verdict = triage.read(message: "What time do you close on Saturdays?")
155
+ verdict.lane # => "knowledge"
156
+ verdict.confidence # => 1.0
157
+ verdict.certain?(0.7) # => true
158
+ verdict.sentiment # => 1.0 (0 = upset, 1 = neutral, 2 = warm)
159
+ verdict.wants_human? # => false
160
+ ```
161
+
162
+ ### Lanes, not tools
163
+
164
+ Route to a **lane**, then let code map the lane to its tools. Measured on a
165
+ 19-tool roster, same model and same messages:
166
+
167
+ | Routed to | Correct |
168
+ |---|---|
169
+ | one of the 19 tools | 10/16 |
170
+ | one of 7 lanes | **19/20** |
171
+
172
+ The tools overlapped — seven of them answered questions about the business,
173
+ and which one holds the answer is found by calling them, not by reading the
174
+ message. Routing straight to a tool asks for a distinction the message does
175
+ not carry. A lane is the part that *is* decidable from the message alone.
176
+
177
+ A reading should narrow, never grant: let the lane take tools away from a
178
+ turn, and let the agent's own definition stay the ceiling.
179
+
180
+ ## ToolRouter
181
+
182
+ Routes user turns to the right tool:
183
+
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
+ ```
190
+
191
+ ## ConfidencePolicy
192
+
193
+ Risk-tiered action gating:
194
+
195
+ ```ruby
196
+ policy = Ask::Decisions::ConfidencePolicy.new
197
+ policy.add_rule("bash", risk: :low)
198
+ policy.add_rule("rm", risk: :high)
199
+
200
+ policy.evaluate(tool: "bash", confidence: 0.6).action # => :act
201
+ policy.evaluate(tool: "rm", confidence: 0.8).action # => :review
202
+ ```
203
+
204
+ ## Calibration
205
+
206
+ Measure whether confidence is trustworthy on YOUR decisions:
207
+
208
+ ```ruby
209
+ harness = Ask::Decisions::CalibrationHarness.new(provider)
210
+ harness.add_case(
211
+ id: "urgent_ticket",
212
+ state: "Help! Server down!",
213
+ decisions: { "urgent" => Ask::Decision::Noul.new(instructions: "Is this urgent?") },
214
+ expected: { "urgent" => { noul_above: 0.7 } },
215
+ runs: 10
216
+ )
217
+ report = harness.run
218
+ puts report # reliability curve per decision id, accuracy by confidence band
219
+ ```
220
+
221
+ ## Lint
222
+
223
+ Catch anti-patterns before they hit the API:
224
+
225
+ ```ruby
226
+ warnings = Ask::Decisions::Lint.check({
227
+ "good" => Ask::Decision::Noul.new(instructions: "Is this urgent?"),
228
+ "bad" => Ask::Decision::Choice.new(
229
+ instructions: "Which team? Since this cannot be recovered...",
230
+ criteria: { a: "A", b: "B" }
231
+ )
232
+ })
233
+ # => ["bad: instructions contain a reasoning path...", "bad: Choice without a none/other option..."]
234
+ ```
235
+
236
+ ## License
237
+
238
+ MIT
@@ -0,0 +1,178 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ module Decisions
5
+ # Bridges ask-decisions into ask-agent. Configures an agent session
6
+ # to use decision providers for the gate, output judge, evaluator,
7
+ # reflector, failure classification, loop detection, and confidence
8
+ # policy — while leaving the LLM in place for text generation.
9
+ #
10
+ # This is the "seamlessly usable" integration: one config line
11
+ # activates the full decision layer.
12
+ #
13
+ # Ask::Agent.configure do |c|
14
+ # c.default_model = "gpt-4o"
15
+ # c.decision_provider = :typesafe
16
+ # end
17
+ #
18
+ # Or per-session:
19
+ #
20
+ # session = Ask::Agent::Session.new(
21
+ # model: "gpt-4o",
22
+ # decision_provider: :typesafe
23
+ # )
24
+ #
25
+ class AgentAdapter
26
+ attr_reader :provider, :gate, :output_judge, :failure_classifier,
27
+ :loop_detector, :confidence_policy, :quality_judge,
28
+ :reflection_judge, :tool_repairer
29
+
30
+ # @param provider_name [Symbol, String] the decision provider to use
31
+ # @param config [Hash] optional overrides
32
+ def initialize(provider_name, config = {})
33
+ @provider = resolve_provider(provider_name)
34
+ @config = config
35
+
36
+ # Build all decision components with the same provider.
37
+ @gate = Gate.new(@provider, **gate_config)
38
+ @output_judge = OutputJudge.new(@provider, **output_judge_config)
39
+ @failure_classifier = FailureClassifier.new(@provider, **failure_classifier_config)
40
+ @loop_detector = LoopDetector.new(@provider)
41
+ @confidence_policy = build_confidence_policy
42
+ @quality_judge = QualityJudge.new(@provider)
43
+ @reflection_judge = ReflectionJudge.new(@provider, **reflection_judge_config)
44
+ @tool_repairer = ToolRepairer.new(@provider)
45
+ end
46
+
47
+ # Generate before_tool hooks for ask-agent.
48
+ # Returns an array of callables that match the Hooks interface.
49
+ def before_tool_hooks
50
+ [build_gate_hook]
51
+ end
52
+
53
+ # Generate after_tool hooks for ask-agent.
54
+ def after_tool_hooks
55
+ [build_output_judge_hook]
56
+ end
57
+
58
+ # Build a tool-call repair function that uses the ToolRepairer.
59
+ def repair_tool_call(tool_call, available_tools)
60
+ @tool_repairer.repair(
61
+ attempted_tool: tool_call.name,
62
+ attempted_args: tool_call.arguments || {},
63
+ available_tools: available_tools
64
+ )
65
+ end
66
+
67
+ # Classify a tool result for retry decisions.
68
+ def classify_failure(tool:, output:, args: {}, attempt: 1)
69
+ @failure_classifier.classify(
70
+ tool: tool, output: output, args: args, attempt: attempt
71
+ )
72
+ end
73
+
74
+ # Check for agent loops.
75
+ def check_loop(recent_turns:, current_tool: nil, current_args: nil, turn_count: 0)
76
+ @loop_detector.check(
77
+ recent_turns: recent_turns,
78
+ current_tool: current_tool,
79
+ current_args: current_args,
80
+ turn_count: turn_count
81
+ )
82
+ end
83
+
84
+ # Evaluate answer quality.
85
+ def evaluate_quality(request:, response:, rubric: nil)
86
+ opts = { request: request, response: response }
87
+ opts[:rubric] = rubric if rubric
88
+ @quality_judge.evaluate(**opts)
89
+ end
90
+
91
+ # Run reflection.
92
+ def reflect(request:, response:, attempt: 1)
93
+ @reflection_judge.reflect(
94
+ request: request, response: response, attempt: attempt
95
+ )
96
+ end
97
+
98
+ # Evaluate confidence for an action.
99
+ def evaluate_confidence(tool:, confidence:)
100
+ @confidence_policy.evaluate(tool: tool, confidence: confidence)
101
+ end
102
+
103
+ private
104
+
105
+ def resolve_provider(name)
106
+ Ask::Decisions.resolve_provider(name)
107
+ end
108
+
109
+ def gate_config
110
+ { tools: @config[:gate_tools], thresholds: @config[:gate_thresholds] || {} }.compact
111
+ end
112
+
113
+ def output_judge_config
114
+ { tools: @config[:output_tools], output_limit: @config[:output_limit] }.compact
115
+ end
116
+
117
+ def failure_classifier_config
118
+ { max_retries: @config[:max_retries] }.compact
119
+ end
120
+
121
+ def reflection_judge_config
122
+ { max_reflections: @config[:max_reflections] }.compact
123
+ end
124
+
125
+ def build_confidence_policy
126
+ policy = ConfidencePolicy.new
127
+ @config[:risk_rules]&.each do |tool, risk|
128
+ policy.add_rule(tool, risk: risk)
129
+ end
130
+ policy
131
+ end
132
+
133
+ # Build a before_tool hook that runs the Gate.
134
+ def build_gate_hook
135
+ gate = @gate
136
+ lambda do |tool_call, context|
137
+ args = tool_call.respond_to?(:arguments) ? tool_call.arguments : {}
138
+ working_dir = context[:working_dir] || context["working_dir"]
139
+ user_message = context[:user_message] || context["user_message"]
140
+
141
+ verdict = gate.judge(
142
+ tool: tool_call.name,
143
+ args: args || {},
144
+ working_dir: working_dir,
145
+ user_message: user_message
146
+ )
147
+
148
+ if verdict.flagged?
149
+ { action: :block, reason: verdict.to_s }
150
+ else
151
+ { action: :proceed }
152
+ end
153
+ end
154
+ end
155
+
156
+ # Build an after_tool hook that runs the OutputJudge.
157
+ def build_output_judge_hook
158
+ judge = @output_judge
159
+ lambda do |tool_call, result|
160
+ output = result.respond_to?(:output) ? result.output : result.to_s
161
+ args = tool_call.respond_to?(:arguments) ? tool_call.arguments : {}
162
+
163
+ output_result = judge.judge(
164
+ tool: tool_call.name,
165
+ output: output.to_s,
166
+ args: args || {}
167
+ )
168
+
169
+ if output_result.leak?
170
+ { action: :block, reason: "Secret detected in output" }
171
+ else
172
+ { action: :proceed, failure_class: output_result.failure_class }
173
+ end
174
+ end
175
+ end
176
+ end
177
+ end
178
+ end
@@ -0,0 +1,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ module Decisions
5
+ # Resolves tool arguments by asking Jev to fill enum/bool params from the
6
+ # user's turn, while routing free-text params to the generator.
7
+ #
8
+ # This is the hybrid: Jev decides closed-set args, the LLM writes free-text.
9
+ #
10
+ # resolver = Ask::Decisions::ArgumentResolver.new(provider)
11
+ # result = resolver.resolve(
12
+ # tool_name: "linear.create_issue",
13
+ # params_schema: tool.params_schema,
14
+ # user_turn: "create a bug in the eng team about login failures",
15
+ # model: "jev-latest"
16
+ # )
17
+ # result.resolved_args # => {"team" => "eng", "priority" => "high"}
18
+ # result.needs_generation # => ["title", "description"]
19
+ # result.confidence # => 0.85
20
+ #
21
+ class ArgumentResolver
22
+ # @param provider [Ask::DecisionProvider]
23
+ def initialize(provider)
24
+ @provider = provider
25
+ end
26
+
27
+ # Resolve as many arguments as possible via Jev decisions.
28
+ #
29
+ # @param tool_name [String] the tool's name (for context in instructions)
30
+ # @param params_schema [Hash] the tool's JSON Schema
31
+ # @param user_turn [String] the user's message
32
+ # @param model [String, nil] model override
33
+ # @return [Resolution]
34
+ def resolve(tool_name:, params_schema:, user_turn:, model: nil)
35
+ properties = params_schema["properties"] || {}
36
+ required = Array(params_schema["required"] || [])
37
+
38
+ resolved = {}
39
+ needs_generation = []
40
+ questions = {}
41
+
42
+ properties.each do |param_name, prop_schema|
43
+ enum = prop_schema["enum"]
44
+ type = prop_schema["type"]
45
+ desc = prop_schema["description"] || param_name
46
+
47
+ if enum && !enum.empty?
48
+ # Closed set → Choice question
49
+ criteria = {}
50
+ enum.each { |v| criteria[v.to_s] = "#{v}" }
51
+ questions[param_name] = Ask::Decision::Choice.new(
52
+ instructions: "What value should the `#{param_name}` parameter be for #{tool_name}?",
53
+ criteria: criteria
54
+ )
55
+ elsif type == "boolean"
56
+ # Boolean → Noul
57
+ questions[param_name] = Ask::Decision::Noul.new(
58
+ instructions: "Should the `#{param_name}` parameter be true?"
59
+ )
60
+ else
61
+ # Free text → generator
62
+ needs_generation << param_name
63
+ end
64
+ end
65
+
66
+ if questions.empty?
67
+ return Resolution.new(
68
+ resolved: {},
69
+ needs_generation: required + (properties.keys - required),
70
+ confidence: nil,
71
+ result: nil
72
+ )
73
+ end
74
+
75
+ result = @provider.evaluate(
76
+ state: { tool_name: tool_name, user_turn: user_turn },
77
+ decisions: questions,
78
+ model: model
79
+ )
80
+
81
+ # Extract resolved values from answers.
82
+ questions.each_key do |param_name|
83
+ answer = result[param_name]
84
+ next unless answer
85
+
86
+ case answer
87
+ when DecisionResult::ChoiceAnswer
88
+ resolved[param_name] = answer.choice
89
+ when DecisionResult::NoulAnswer
90
+ resolved[param_name] = answer.yes?
91
+ end
92
+ end
93
+
94
+ Resolution.new(
95
+ resolved: resolved,
96
+ needs_generation: needs_generation,
97
+ confidence: result.min_confidence,
98
+ result: result
99
+ )
100
+ end
101
+
102
+ # Resolution result.
103
+ class Resolution
104
+ attr_reader :resolved, :needs_generation, :confidence, :result
105
+
106
+ def initialize(resolved:, needs_generation:, confidence:, result:)
107
+ @resolved = resolved
108
+ @needs_generation = needs_generation
109
+ @confidence = confidence
110
+ @result = result
111
+ end
112
+
113
+ # All args that can be filled right now (resolved + defaults for
114
+ # unmentioned optional params).
115
+ def auto_fill_args
116
+ resolved.dup
117
+ end
118
+
119
+ # Whether the LLM needs to generate any arguments.
120
+ def needs_generation? = !needs_generation.empty?
121
+
122
+ def confident?(threshold = 0.7)
123
+ return false if confidence.nil?
124
+ confidence >= threshold
125
+ end
126
+
127
+ def to_s
128
+ parts = []
129
+ resolved.each { |k, v| parts << "#{k}=#{v.inspect}" }
130
+ parts << "generate: #{needs_generation.join(', ')}" if needs_generation.any?
131
+ parts.join(", ")
132
+ end
133
+ end
134
+ end
135
+ end
136
+ end