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 +7 -0
- data/CHANGELOG.md +67 -0
- data/LICENSE +21 -0
- data/README.md +238 -0
- data/lib/ask/decisions/agent_adapter.rb +178 -0
- data/lib/ask/decisions/argument_resolver.rb +136 -0
- data/lib/ask/decisions/batcher.rb +59 -0
- data/lib/ask/decisions/cache.rb +67 -0
- data/lib/ask/decisions/calibration_harness.rb +112 -0
- data/lib/ask/decisions/calibration_report.rb +168 -0
- data/lib/ask/decisions/confidence_policy.rb +119 -0
- data/lib/ask/decisions/decision_state.rb +122 -0
- data/lib/ask/decisions/failure_classifier.rb +87 -0
- data/lib/ask/decisions/gate.rb +147 -0
- data/lib/ask/decisions/lint.rb +83 -0
- data/lib/ask/decisions/loop_detector.rb +142 -0
- data/lib/ask/decisions/mcp_helper.rb +23 -0
- data/lib/ask/decisions/output_judge.rb +135 -0
- data/lib/ask/decisions/quality_judge.rb +107 -0
- data/lib/ask/decisions/reader.rb +96 -0
- data/lib/ask/decisions/reflection_judge.rb +83 -0
- data/lib/ask/decisions/reranker.rb +78 -0
- data/lib/ask/decisions/static.rb +66 -0
- data/lib/ask/decisions/structured_state_loop.rb +137 -0
- data/lib/ask/decisions/tool_repairer.rb +114 -0
- data/lib/ask/decisions/tool_router.rb +137 -0
- data/lib/ask/decisions/triage.rb +161 -0
- data/lib/ask/decisions/typesafe.rb +189 -0
- data/lib/ask/decisions/version.rb +7 -0
- data/lib/ask/tools/decide.rb +130 -0
- data/lib/ask-decisions.rb +165 -0
- metadata +201 -0
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
|