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 +4 -4
- data/CHANGELOG.md +51 -2
- data/README.md +20 -8
- data/lib/ask/decisions/agent_adapter.rb +15 -2
- data/lib/ask/decisions/gate.rb +34 -4
- data/lib/ask/decisions/output_judge.rb +51 -8
- data/lib/ask/decisions/reader.rb +1 -2
- data/lib/ask/decisions/version.rb +1 -1
- data/lib/ask-decisions.rb +0 -1
- metadata +1 -2
- data/lib/ask/decisions/tool_router.rb +0 -137
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3dd4560892d89a85e39ed1e73474268724f2f55fca39f1471e9e490e2044168c
|
|
4
|
+
data.tar.gz: d960989693373e6ce7afe0ddcec17bc88e59addafb89d5269fa67ef52bb9b299
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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
|
-
`
|
|
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
|
-
|
|
180
|
+
### Why there is no tool router
|
|
181
181
|
|
|
182
|
-
|
|
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
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
-
{
|
|
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
|
-
{
|
|
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
|
data/lib/ask/decisions/gate.rb
CHANGED
|
@@ -49,11 +49,20 @@ module Ask
|
|
|
49
49
|
}.freeze
|
|
50
50
|
|
|
51
51
|
# @param provider [Ask::DecisionProvider] the decision provider to use
|
|
52
|
-
# @param
|
|
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:
|
|
62
|
+
def initialize(provider, questions: QUESTIONS, thresholds: DEFAULT_THRESHOLDS, tools: nil)
|
|
55
63
|
@provider = provider
|
|
56
|
-
@
|
|
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:
|
|
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
|
-
#
|
|
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,
|
|
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:
|
|
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 ||
|
|
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 =
|
|
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 !=
|
|
155
|
+
@failure_class != SUCCESS_CLASS && @failure_confidence >= @failure_threshold
|
|
113
156
|
end
|
|
114
157
|
|
|
115
158
|
def to_s
|
data/lib/ask/decisions/reader.rb
CHANGED
|
@@ -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.
|
|
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,
|
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.
|
|
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
|