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
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ask
|
|
4
|
+
module Decisions
|
|
5
|
+
# Classifies tool output into failure categories and provides actionable
|
|
6
|
+
# advice. Wraps the OutputJudge with a retry-oriented interface.
|
|
7
|
+
#
|
|
8
|
+
# classifier = Ask::Decisions::FailureClassifier.new(provider)
|
|
9
|
+
# verdict = classifier.classify(
|
|
10
|
+
# tool: "bash",
|
|
11
|
+
# output: "npm ERR! code ECONNRESET",
|
|
12
|
+
# args: { command: "npm test" },
|
|
13
|
+
# attempt: 1
|
|
14
|
+
# )
|
|
15
|
+
# verdict.retryable? # => true
|
|
16
|
+
# verdict.advice # => "Retry unchanged."
|
|
17
|
+
# verdict.should_retry? # => true (attempt 1 < max_retries)
|
|
18
|
+
#
|
|
19
|
+
class FailureClassifier
|
|
20
|
+
DEFAULT_MAX_RETRIES = 3
|
|
21
|
+
|
|
22
|
+
# @param provider [Ask::DecisionProvider]
|
|
23
|
+
# @param max_retries [Integer] maximum retry attempts before giving up
|
|
24
|
+
# @param output_limit [Integer] max chars of output to send
|
|
25
|
+
def initialize(provider, max_retries: DEFAULT_MAX_RETRIES, output_limit: 2000)
|
|
26
|
+
@judge = OutputJudge.new(provider, output_limit: output_limit)
|
|
27
|
+
@max_retries = max_retries
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Classify a tool result and produce a retry decision.
|
|
31
|
+
#
|
|
32
|
+
# @param tool [String] the tool name
|
|
33
|
+
# @param output [String] the tool's stdout/stderr
|
|
34
|
+
# @param args [Hash] the original tool arguments
|
|
35
|
+
# @param attempt [Integer] which attempt this is (1-based)
|
|
36
|
+
# @return [ClassifyResult]
|
|
37
|
+
def classify(tool:, output:, args: {}, attempt: 1)
|
|
38
|
+
judge_result = @judge.judge(tool: tool, output: output, args: args)
|
|
39
|
+
ClassifyResult.new(judge_result, attempt, @max_retries)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Result of classifying a tool output.
|
|
43
|
+
class ClassifyResult
|
|
44
|
+
attr_reader :judge_result, :attempt, :max_retries
|
|
45
|
+
|
|
46
|
+
def initialize(judge_result, attempt, max_retries)
|
|
47
|
+
@judge_result = judge_result
|
|
48
|
+
@attempt = attempt
|
|
49
|
+
@max_retries = max_retries
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def failure_class = judge_result.failure_class
|
|
53
|
+
def advice = judge_result.advice
|
|
54
|
+
def leak? = judge_result.leak?
|
|
55
|
+
|
|
56
|
+
# Is this a failure that could succeed on retry?
|
|
57
|
+
def retryable?
|
|
58
|
+
return false if leak?
|
|
59
|
+
return false if failure_class == "no_failure"
|
|
60
|
+
# transient and environment are retryable with backoff
|
|
61
|
+
%w[transient environment].include?(failure_class)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Should we actually retry right now? (attempt < max_retries)
|
|
65
|
+
def should_retry?
|
|
66
|
+
retryable? && attempt < max_retries
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Should we stop retrying and report to the user?
|
|
70
|
+
def give_up?
|
|
71
|
+
!retryable? || attempt >= max_retries
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Human-readable summary of the classification.
|
|
75
|
+
def to_s
|
|
76
|
+
if leak?
|
|
77
|
+
"LEAK detected in output"
|
|
78
|
+
elsif failure_class == "no_failure"
|
|
79
|
+
"no failure"
|
|
80
|
+
else
|
|
81
|
+
"#{failure_class}: #{advice} (attempt #{attempt}/#{max_retries})"
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ask
|
|
4
|
+
module Decisions
|
|
5
|
+
# Pre-tool-call gate: judges intent before a tool executes.
|
|
6
|
+
# Calibrated thresholds from pi-jev's measured set (0.03–0.99 range on
|
|
7
|
+
# destructive, 0.10–0.96 on beyond_scope).
|
|
8
|
+
#
|
|
9
|
+
# gate = Ask::Decisions::Gate.new(provider)
|
|
10
|
+
# verdict = gate.judge(
|
|
11
|
+
# tool: "bash",
|
|
12
|
+
# args: { command: "rm -rf src && git push --force origin main" },
|
|
13
|
+
# working_dir: "/project",
|
|
14
|
+
# user_message: "clean up the old code"
|
|
15
|
+
# )
|
|
16
|
+
# verdict.passed? # => false
|
|
17
|
+
# verdict.flagged # => [:destructive, :beyond_scope]
|
|
18
|
+
# verdict.to_s # => "blocked by destructive (0.99) and beyond_scope (0.98)"
|
|
19
|
+
#
|
|
20
|
+
class Gate
|
|
21
|
+
# Default calibrated questions from pi-jev.
|
|
22
|
+
QUESTIONS = {
|
|
23
|
+
destructive: Ask::Decision::Noul.new(
|
|
24
|
+
instructions: "Is this action destructive?"
|
|
25
|
+
),
|
|
26
|
+
exfiltration: Ask::Decision::Noul.new(
|
|
27
|
+
instructions: "Does this send local data or secrets off-machine?"
|
|
28
|
+
),
|
|
29
|
+
beyond_scope: Ask::Decision::Noul.new(
|
|
30
|
+
instructions: "Does this affect anything beyond what the user asked for?"
|
|
31
|
+
),
|
|
32
|
+
impact: Ask::Decision::Score.new(
|
|
33
|
+
instructions: "How much damage if the user did not want it?",
|
|
34
|
+
criteria: [
|
|
35
|
+
"No damage — read-only or easily reversible",
|
|
36
|
+
"Minor — cosmetic or recoverable from version control",
|
|
37
|
+
"Moderate — data loss or service disruption, reversible with effort",
|
|
38
|
+
"Severe — permanent data loss, security breach, or system-wide impact"
|
|
39
|
+
]
|
|
40
|
+
)
|
|
41
|
+
}.freeze
|
|
42
|
+
|
|
43
|
+
# Default thresholds from pi-jev calibration.
|
|
44
|
+
DEFAULT_THRESHOLDS = {
|
|
45
|
+
destructive: 0.90,
|
|
46
|
+
exfiltration: 0.70,
|
|
47
|
+
beyond_scope: 0.85,
|
|
48
|
+
impact: 2.50 # on the 0–3 scale
|
|
49
|
+
}.freeze
|
|
50
|
+
|
|
51
|
+
# @param provider [Ask::DecisionProvider] the decision provider to use
|
|
52
|
+
# @param thresholds [Hash] override specific thresholds
|
|
53
|
+
# @param tools [Array<String>, nil] tools to gate (nil = all)
|
|
54
|
+
def initialize(provider, thresholds: {}, tools: nil)
|
|
55
|
+
@provider = provider
|
|
56
|
+
@thresholds = DEFAULT_THRESHOLDS.merge(thresholds)
|
|
57
|
+
@tools = tools
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Judge a tool call before execution.
|
|
61
|
+
#
|
|
62
|
+
# @param tool [String] the tool name
|
|
63
|
+
# @param args [Hash] the tool arguments
|
|
64
|
+
# @param working_dir [String, nil] current working directory
|
|
65
|
+
# @param user_message [String, nil] the latest user message (truncated)
|
|
66
|
+
# @return [Verdict]
|
|
67
|
+
def judge(tool:, args:, working_dir: nil, user_message: nil)
|
|
68
|
+
return Verdict.pass if @tools && !@tools.include?(tool)
|
|
69
|
+
|
|
70
|
+
state = build_state(tool: tool, args: args, working_dir: working_dir, user_message: user_message)
|
|
71
|
+
|
|
72
|
+
result = @provider.evaluate(
|
|
73
|
+
state: state,
|
|
74
|
+
decisions: QUESTIONS
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
Verdict.new(result, @thresholds)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
def build_state(tool:, args:, working_dir: nil, user_message: nil)
|
|
83
|
+
{
|
|
84
|
+
tool: tool,
|
|
85
|
+
arguments: truncate_values(args, 400),
|
|
86
|
+
working_directory: working_dir,
|
|
87
|
+
user_message: truncate_string(user_message, 1200)
|
|
88
|
+
}.compact
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def truncate_values(hash, limit)
|
|
92
|
+
hash.transform_values do |v|
|
|
93
|
+
v.is_a?(String) && v.length > limit ? "#{v[0, limit]}…[#{v.length - limit} chars elided]" : v
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def truncate_string(str, limit)
|
|
98
|
+
return nil if str.nil?
|
|
99
|
+
str.length > limit ? "#{str[0, limit]}…" : str
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Verdict from a gate judgment.
|
|
103
|
+
class Verdict
|
|
104
|
+
attr_reader :result, :flagged, :scores
|
|
105
|
+
|
|
106
|
+
def initialize(result, thresholds)
|
|
107
|
+
@result = result
|
|
108
|
+
@flagged = []
|
|
109
|
+
@scores = {}
|
|
110
|
+
|
|
111
|
+
thresholds.each do |key, threshold|
|
|
112
|
+
answer = result[key.to_s]
|
|
113
|
+
next unless answer
|
|
114
|
+
|
|
115
|
+
value = answer.respond_to?(:noul) ? answer.noul : answer.score
|
|
116
|
+
@scores[key] = value
|
|
117
|
+
|
|
118
|
+
if answer.respond_to?(:noul)
|
|
119
|
+
@flagged << key if value >= threshold
|
|
120
|
+
else
|
|
121
|
+
# Score: flagged when the score exceeds the threshold
|
|
122
|
+
@flagged << key if value && value >= threshold
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def passed? = @flagged.empty?
|
|
128
|
+
def flagged? = !@flagged.empty?
|
|
129
|
+
|
|
130
|
+
def to_s
|
|
131
|
+
if flagged?
|
|
132
|
+
"flagged by #{@flagged.map { |k| "#{k} (#{@scores[k]})" }.join(' and ')}"
|
|
133
|
+
else
|
|
134
|
+
"passed"
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Pass-through verdict for ungated tools.
|
|
140
|
+
class Verdict
|
|
141
|
+
def self.pass
|
|
142
|
+
new(Ask::DecisionResult::Batch.new(answers: {}), {})
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ask
|
|
4
|
+
module Decisions
|
|
5
|
+
# Lints decision definitions before they hit the API. Warns on patterns
|
|
6
|
+
# known to reduce Jev's accuracy (from TypeSafe's jaggedness page and
|
|
7
|
+
# measured failures in pi-jev).
|
|
8
|
+
#
|
|
9
|
+
# Run in dev/test as a rake task or at definition time:
|
|
10
|
+
#
|
|
11
|
+
# warnings = Ask::Decisions::Lint.check(decisions)
|
|
12
|
+
# warnings.each { |w| puts "WARNING: #{w}" }
|
|
13
|
+
#
|
|
14
|
+
module Lint
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
# Lint a hash of decisions.
|
|
18
|
+
#
|
|
19
|
+
# @param decisions [Hash{String => Ask::Decision::Choice|Score|Noul}]
|
|
20
|
+
# @return [Array<String>] warnings (empty if clean)
|
|
21
|
+
def check(decisions)
|
|
22
|
+
warnings = []
|
|
23
|
+
decisions.each do |id, decision|
|
|
24
|
+
warnings.concat(check_one(id, decision))
|
|
25
|
+
end
|
|
26
|
+
warnings
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# @private
|
|
30
|
+
def check_one(id, decision)
|
|
31
|
+
w = []
|
|
32
|
+
instructions = decision.respond_to?(:instructions) ? decision.instructions : ""
|
|
33
|
+
|
|
34
|
+
# Reasoning paths in instructions — measured to cause failures
|
|
35
|
+
# ("cannot be recovered from version control" → 0.77 on destructive instead of 0.99).
|
|
36
|
+
if instructions.match?(/\b(because|since|unless|therefore|still recoverable|cannot be recovered|which means|this implies)\b/i)
|
|
37
|
+
w << "#{id}: instructions contain a reasoning path or justification clause. " \
|
|
38
|
+
"Ask the plain property instead — measured to reduce accuracy."
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Math/arithmetic
|
|
42
|
+
if instructions.match?(/\b(how many|count|total|sum|average|multiply|add up|how much)\b/i)
|
|
43
|
+
w << "#{id}: instructions ask for counting or arithmetic. " \
|
|
44
|
+
"Jev is unreliable at math — compute in Ruby."
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Date comparison
|
|
48
|
+
if instructions.match?(/\b(earlier|later|which date|which comes first|how many days between|what day)\b/i)
|
|
49
|
+
w << "#{id}: instructions ask for date comparison. " \
|
|
50
|
+
"Extract components as Choices and compare in Ruby."
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Large cardinality without warning
|
|
54
|
+
if decision.respond_to?(:criteria) && decision.criteria.is_a?(Hash) && decision.criteria.size > 255
|
|
55
|
+
w << "#{id}: #{decision.criteria.size} options exceeds Jev's cardinality limit of 255. " \
|
|
56
|
+
"Use a two-stage rank → shortlist → rerank."
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Score with fewer than 2 levels
|
|
60
|
+
if decision.is_a?(Decision::Score) && decision.criteria.size < 2
|
|
61
|
+
w << "#{id}: Score needs at least 2 criteria levels (got #{decision.criteria.size})."
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Missing none/other on Choice (soft warning)
|
|
65
|
+
if decision.is_a?(Decision::Choice) &&
|
|
66
|
+
!decision.criteria.keys.any? { |k| k.to_s.downcase.match?(/\b(none|other|none of the above|unknown)\b/) }
|
|
67
|
+
w << "#{id}: Choice without a none/other option. " \
|
|
68
|
+
"Consider adding one so the model can reject all options."
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Derived questions — measured to overlap across phrasings
|
|
72
|
+
# ("is it safe to run this again unchanged" → 0.73–0.96 vs 0.37–0.66, ambiguous).
|
|
73
|
+
# Derive the answer from a classification instead.
|
|
74
|
+
if instructions.match?(/\b(is it safe to|can this be|should this be retried|is it okay to run again)\b/i)
|
|
75
|
+
w << "#{id}: question asks what code can derive from a classification. " \
|
|
76
|
+
"Ask the primitive (e.g. failure_class) and derive advice in code."
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
w
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ask
|
|
4
|
+
module Decisions
|
|
5
|
+
# Detects agent loops using Jev — whether the agent is repeating itself,
|
|
6
|
+
# making progress, or stuck. Replaces the naive exact-match ×3 approach
|
|
7
|
+
# with a judgment over the conversation trajectory.
|
|
8
|
+
#
|
|
9
|
+
# detector = Ask::Decisions::LoopDetector.new(provider)
|
|
10
|
+
# verdict = detector.check(
|
|
11
|
+
# recent_turns: [...],
|
|
12
|
+
# current_tool: "bash",
|
|
13
|
+
# current_args: { command: "npm test" },
|
|
14
|
+
# turn_count: 8
|
|
15
|
+
# )
|
|
16
|
+
# verdict.stuck? # => true
|
|
17
|
+
# verdict.progressing? # => false
|
|
18
|
+
# verdict.advice # => "repeat"
|
|
19
|
+
#
|
|
20
|
+
class LoopDetector
|
|
21
|
+
QUESTIONS = {
|
|
22
|
+
repeating: Ask::Decision::Noul.new(
|
|
23
|
+
instructions: "Is the assistant repeating the same action or producing the same output as in the recent conversation?"
|
|
24
|
+
),
|
|
25
|
+
stuck: Ask::Decision::Noul.new(
|
|
26
|
+
instructions: "Is the assistant stuck — unable to make progress on the user's request?"
|
|
27
|
+
),
|
|
28
|
+
progress: Ask::Decision::Score.new(
|
|
29
|
+
instructions: "How much progress has the assistant made toward completing the user's request?",
|
|
30
|
+
criteria: [
|
|
31
|
+
"No progress — same state as before",
|
|
32
|
+
"Minimal — tried something but it failed or was undone",
|
|
33
|
+
"Some — partially addressed the request",
|
|
34
|
+
"Significant — most of the work is done",
|
|
35
|
+
"Complete — the request appears to be fulfilled"
|
|
36
|
+
]
|
|
37
|
+
)
|
|
38
|
+
}.freeze
|
|
39
|
+
|
|
40
|
+
# Thresholds for loop detection.
|
|
41
|
+
STUCK_THRESHOLD = 0.7 # noul >= 0.7 means stuck
|
|
42
|
+
REPEAT_THRESHOLD = 0.7 # noul >= 0.7 means repeating
|
|
43
|
+
PROGRESS_THRESHOLD = 1.5 # score < 1.5 means little progress
|
|
44
|
+
|
|
45
|
+
# @param provider [Ask::DecisionProvider]
|
|
46
|
+
def initialize(provider)
|
|
47
|
+
@provider = provider
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Check whether the agent is looping or stuck.
|
|
51
|
+
#
|
|
52
|
+
# @param recent_turns [Array<Hash>] recent conversation messages
|
|
53
|
+
# @param current_tool [String, nil] the tool being called now
|
|
54
|
+
# @param current_args [Hash, nil] its arguments
|
|
55
|
+
# @param turn_count [Integer] how many turns have happened
|
|
56
|
+
# @return [LoopVerdict]
|
|
57
|
+
def check(recent_turns:, current_tool: nil, current_args: nil, turn_count: 0)
|
|
58
|
+
state = build_state(
|
|
59
|
+
recent_turns: recent_turns,
|
|
60
|
+
current_tool: current_tool,
|
|
61
|
+
current_args: current_args,
|
|
62
|
+
turn_count: turn_count
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
result = @provider.evaluate(state: state, decisions: QUESTIONS)
|
|
66
|
+
LoopVerdict.new(result)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
def build_state(recent_turns:, current_tool:, current_args:, turn_count:)
|
|
72
|
+
state = {
|
|
73
|
+
turn_count: turn_count,
|
|
74
|
+
recent_actions: extract_actions(recent_turns)
|
|
75
|
+
}
|
|
76
|
+
if current_tool
|
|
77
|
+
state[:current_tool] = current_tool
|
|
78
|
+
state[:current_args] = current_args
|
|
79
|
+
end
|
|
80
|
+
state
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def extract_actions(turns)
|
|
84
|
+
return [] unless turns
|
|
85
|
+
turns.last(6).filter_map do |turn|
|
|
86
|
+
next unless turn[:tool_calls] || turn["tool_calls"]
|
|
87
|
+
calls = turn[:tool_calls] || turn["tool_calls"]
|
|
88
|
+
Array(calls).map do |call|
|
|
89
|
+
name = call[:name] || call["name"]
|
|
90
|
+
args = call[:arguments] || call["arguments"] || {}
|
|
91
|
+
"#{name}(#{args.keys.join(', ')})"
|
|
92
|
+
end
|
|
93
|
+
end.flatten
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Verdict from loop detection.
|
|
97
|
+
class LoopVerdict
|
|
98
|
+
attr_reader :result
|
|
99
|
+
|
|
100
|
+
def initialize(result)
|
|
101
|
+
@result = result
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def repeating?
|
|
105
|
+
noul = @result["repeating"]
|
|
106
|
+
noul && noul.noul >= REPEAT_THRESHOLD
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def stuck?
|
|
110
|
+
noul = @result["stuck"]
|
|
111
|
+
noul && noul.noul >= STUCK_THRESHOLD
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def progress_score
|
|
115
|
+
score = @result["progress"]
|
|
116
|
+
score&.score || 0.0
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def progressing?
|
|
120
|
+
progress_score >= PROGRESS_THRESHOLD
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# The recommended action based on the verdict.
|
|
124
|
+
def advice
|
|
125
|
+
return "stop" if repeating? && !progressing?
|
|
126
|
+
return "pivot" if stuck?
|
|
127
|
+
return "continue" if progressing?
|
|
128
|
+
"monitor"
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def to_s
|
|
132
|
+
parts = []
|
|
133
|
+
parts << "repeating" if repeating?
|
|
134
|
+
parts << "stuck" if stuck?
|
|
135
|
+
parts << "progress: #{('%.1f' % progress_score)}"
|
|
136
|
+
parts << "→ #{advice}"
|
|
137
|
+
parts.join(", ")
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ask
|
|
4
|
+
module Decisions
|
|
5
|
+
# Helper for adding the decide tool to an MCP server.
|
|
6
|
+
#
|
|
7
|
+
# require "ask-decisions/mcp_helper"
|
|
8
|
+
#
|
|
9
|
+
# # In your MCP server setup:
|
|
10
|
+
# tools = [Ask::Decisions::MCPHelper.tool, ...other_tools...]
|
|
11
|
+
# server = Ask::MCP::Adapters::ToolServer.new(tools)
|
|
12
|
+
#
|
|
13
|
+
module MCPHelper
|
|
14
|
+
# Returns an Ask::Tools::Decide instance configured with the
|
|
15
|
+
# current decision provider. This is the tool to pass to
|
|
16
|
+
# Ask::MCP::Adapters::ToolServer.
|
|
17
|
+
def self.tool
|
|
18
|
+
require "ask/tools/decide"
|
|
19
|
+
Ask::Tools::Decide.new
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ask
|
|
4
|
+
module Decisions
|
|
5
|
+
# Post-tool-call output judge: screens the result for leaks and classifies
|
|
6
|
+
# failures. Two questions, one request (~126ms median from pi-jev).
|
|
7
|
+
#
|
|
8
|
+
# judge = Ask::Decisions::OutputJudge.new(provider)
|
|
9
|
+
# result = judge.judge(
|
|
10
|
+
# tool: "bash",
|
|
11
|
+
# output: "npm ERR! code ECONNRESET",
|
|
12
|
+
# args: { command: "npm test" }
|
|
13
|
+
# )
|
|
14
|
+
# result.leak? # => false
|
|
15
|
+
# result.failure_class # => "transient"
|
|
16
|
+
# result.advice # => "retry unchanged"
|
|
17
|
+
#
|
|
18
|
+
class OutputJudge
|
|
19
|
+
# Calibrated questions from pi-jev.
|
|
20
|
+
QUESTIONS = {
|
|
21
|
+
leaks_secret: Ask::Decision::Noul.new(
|
|
22
|
+
instructions: "Does this output contain a secret, credential, private key, or access token?"
|
|
23
|
+
),
|
|
24
|
+
failure_class: Ask::Decision::Choice.new(
|
|
25
|
+
instructions: "What kind of failure is this?",
|
|
26
|
+
criteria: {
|
|
27
|
+
"no_failure" => "The command succeeded or produced normal output",
|
|
28
|
+
"transient" => "A temporary error — network timeout, rate limit, DNS hiccup. Safe to retry unchanged.",
|
|
29
|
+
"environment" => "A missing dependency, wrong version, port in use, or wrong permissions on a file. Fix the environment before retrying.",
|
|
30
|
+
"code_bug" => "A type error, syntax error, assertion failure, or test failure in the code itself. The code must change.",
|
|
31
|
+
"permission" => "Access denied, insufficient privileges. Do not retry without elevated permissions.",
|
|
32
|
+
"user_error" => "Bad invocation, wrong arguments, or incorrect usage by the caller."
|
|
33
|
+
}
|
|
34
|
+
)
|
|
35
|
+
}.freeze
|
|
36
|
+
|
|
37
|
+
# Advice table: maps failure class to a one-line instruction.
|
|
38
|
+
# Derived from pi-jev's CLASS_ADVICE — adding a class is a row.
|
|
39
|
+
ADVICE = {
|
|
40
|
+
"no_failure" => nil,
|
|
41
|
+
"transient" => "Retry unchanged.",
|
|
42
|
+
"environment" => "Fix the environment before retrying (missing dependency, port conflict, wrong version).",
|
|
43
|
+
"code_bug" => "The code must change — do not retry the same command.",
|
|
44
|
+
"permission" => "Do not retry without elevated permissions.",
|
|
45
|
+
"user_error" => "Fix the invocation arguments."
|
|
46
|
+
}.freeze
|
|
47
|
+
|
|
48
|
+
# @param provider [Ask::DecisionProvider]
|
|
49
|
+
# @param tools [Array<String>, nil] tools to judge (nil = ["bash"])
|
|
50
|
+
# @param leak_threshold [Float] noul threshold for leak detection
|
|
51
|
+
# @param failure_threshold [Float] confidence threshold for failure classification
|
|
52
|
+
# @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)
|
|
54
|
+
@provider = provider
|
|
55
|
+
@tools = tools || ["bash"]
|
|
56
|
+
@leak_threshold = leak_threshold
|
|
57
|
+
@failure_threshold = failure_threshold
|
|
58
|
+
@output_limit = output_limit
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Judge a tool result after execution.
|
|
62
|
+
#
|
|
63
|
+
# @param tool [String] the tool name
|
|
64
|
+
# @param output [String] the tool's stdout/stderr
|
|
65
|
+
# @param args [Hash] the original tool arguments
|
|
66
|
+
# @return [OutputResult]
|
|
67
|
+
def judge(tool:, output:, args: {})
|
|
68
|
+
return OutputResult.empty unless @tools.include?(tool)
|
|
69
|
+
|
|
70
|
+
truncated = truncate(output, @output_limit)
|
|
71
|
+
state = { output: truncated, tool_arguments: truncate_values(args, 400) }
|
|
72
|
+
|
|
73
|
+
result = @provider.evaluate(state: state, decisions: QUESTIONS)
|
|
74
|
+
OutputResult.new(result, @leak_threshold, @failure_threshold)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
private
|
|
78
|
+
|
|
79
|
+
def truncate(str, limit)
|
|
80
|
+
return "" if str.nil?
|
|
81
|
+
str.length > limit ? "#{str[0, limit]}…[#{str.length - limit} chars elided]" : str
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def truncate_values(hash, limit)
|
|
85
|
+
hash.transform_values do |v|
|
|
86
|
+
v.is_a?(String) && v.length > limit ? "#{v[0, limit]}…[#{v.length - limit} chars elided]" : v
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Typed result from the output judge.
|
|
91
|
+
class OutputResult
|
|
92
|
+
attr_reader :leak_noul, :failure_class, :failure_confidence, :advice
|
|
93
|
+
|
|
94
|
+
def initialize(batch, leak_threshold, failure_threshold)
|
|
95
|
+
leak_answer = batch["leaks_secret"]
|
|
96
|
+
failure_answer = batch["failure_class"]
|
|
97
|
+
|
|
98
|
+
@leak_noul = leak_answer&.noul || 0.0
|
|
99
|
+
@leak_threshold = leak_threshold
|
|
100
|
+
|
|
101
|
+
@failure_class = failure_answer&.choice || "no_failure"
|
|
102
|
+
@failure_confidence = failure_answer&.confidence || 0.0
|
|
103
|
+
@failure_threshold = failure_threshold
|
|
104
|
+
@advice = ADVICE[@failure_class]
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def leak?
|
|
108
|
+
@leak_noul >= @leak_threshold
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def failure?
|
|
112
|
+
@failure_class != "no_failure" && @failure_confidence >= @failure_threshold
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def to_s
|
|
116
|
+
parts = []
|
|
117
|
+
parts << "LEAK (#{@leak_noul})" if leak?
|
|
118
|
+
parts << "#{@failure_class} (#{'%.2f' % @failure_confidence})" if failure?
|
|
119
|
+
parts.empty? ? "clean" : parts.join(", ")
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Empty result when tool is not judged.
|
|
124
|
+
def self.empty_result_class
|
|
125
|
+
OutputResult
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
class OutputResult
|
|
129
|
+
def self.empty
|
|
130
|
+
new(Ask::DecisionResult::Batch.new(answers: {}), 0.9, 0.6)
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|