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.
@@ -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