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,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ module Decisions
5
+ # Decision-based quality judge — replaces the LLM-as-judge evaluator
6
+ # with calibrated Jev scores. Uses composite scoring across rubric
7
+ # dimensions plus a verdict choice.
8
+ #
9
+ # judge = Ask::Decisions::QualityJudge.new(provider)
10
+ # verdict = judge.evaluate(
11
+ # request: "What's the weather in Seattle?",
12
+ # response: "It's 72°F and sunny in Seattle today.",
13
+ # rubric: { accuracy: "Is the answer factually correct?", completeness: "Does it fully address the request?" }
14
+ # )
15
+ # verdict.accepted? # => true
16
+ # verdict.scores # => { accuracy: 4.0, completeness: 3.5 }
17
+ #
18
+ class QualityJudge
19
+ # Default rubric dimensions.
20
+ DEFAULT_RUBRIC = {
21
+ accuracy: "Is the answer factually correct and free of hallucinations?",
22
+ completeness: "Does the answer fully address the user's request?",
23
+ clarity: "Is the answer clear, well-organized, and easy to understand?"
24
+ }.freeze
25
+
26
+ # @param provider [Ask::DecisionProvider]
27
+ def initialize(provider)
28
+ @provider = provider
29
+ end
30
+
31
+ # Evaluate a response against a rubric.
32
+ #
33
+ # @param request [String] the original user request
34
+ # @param response [String] the assistant's response
35
+ # @param rubric [Hash{Symbol => String}] dimension → instruction
36
+ # @param threshold [Float] minimum weighted score to accept
37
+ # @return [QualityVerdict]
38
+ def evaluate(request:, response:, rubric: DEFAULT_RUBRIC, threshold: 2.5)
39
+ questions = {}
40
+ rubric.each do |dim, instruction|
41
+ questions[dim.to_s] = Ask::Decision::Score.new(
42
+ instructions: instruction,
43
+ criteria: [
44
+ "Poor — factually wrong, incomplete, or unclear",
45
+ "Fair — partially correct but missing key aspects",
46
+ "Good — correct and complete, minor issues only",
47
+ "Excellent — thorough, accurate, and well-presented"
48
+ ]
49
+ )
50
+ end
51
+
52
+ questions["verdict"] = Ask::Decision::Choice.new(
53
+ instructions: "Should this response be accepted, revised, or blocked?",
54
+ criteria: {
55
+ "accept" => "The response is good enough to deliver to the user",
56
+ "revise" => "The response has issues that can be fixed with minor edits",
57
+ "block" => "The response is fundamentally wrong or harmful and must be regenerated"
58
+ }
59
+ )
60
+
61
+ state = { request: request, response: response }
62
+ result = @provider.evaluate(state: state, decisions: questions)
63
+
64
+ QualityVerdict.new(result, rubric.keys, threshold)
65
+ end
66
+
67
+ # Verdict from quality evaluation.
68
+ class QualityVerdict
69
+ attr_reader :result, :scores, :verdict, :threshold
70
+
71
+ def initialize(result, dimensions, threshold)
72
+ @result = result
73
+ @threshold = threshold
74
+ @scores = {}
75
+
76
+ dimensions.each do |dim|
77
+ answer = result[dim.to_s]
78
+ @scores[dim] = answer&.score || 0.0
79
+ end
80
+
81
+ verdict_answer = result["verdict"]
82
+ @verdict = verdict_answer&.choice || "accept"
83
+ end
84
+
85
+ def accepted? = @verdict == "accept"
86
+ def revised? = @verdict == "revise"
87
+ def blocked? = @verdict == "block"
88
+
89
+ # Weighted average score across dimensions.
90
+ def average_score
91
+ return 0.0 if @scores.empty?
92
+ @scores.values.sum / @scores.size
93
+ end
94
+
95
+ # Should the response be accepted based on the score threshold?
96
+ def score_accepts?
97
+ average_score >= @threshold
98
+ end
99
+
100
+ def to_s
101
+ scores_str = @scores.map { |k, v| "#{k}: #{('%.1f' % v)}" }.join(", ")
102
+ "#{@verdict} (#{scores_str}, avg: #{('%.1f' % average_score)})"
103
+ end
104
+ end
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ module Decisions
5
+ # One request, several answers, about one piece of state.
6
+ #
7
+ # The questions in a decision call are independent and run in parallel, so
8
+ # asking three costs no more latency than asking one. That makes "ask
9
+ # everything this call might need" the right instinct rather than a false
10
+ # economy — and makes this the shared shape for doing it: a Choice over a
11
+ # described set of options, with anything else the caller needs riding
12
+ # along in the same request.
13
+ #
14
+ # The caller owns *what* to ask — which options, in what words. This owns
15
+ # asking it once and handing back the answers. `ToolRouter` and `Triage`
16
+ # are both façades over it.
17
+ #
18
+ # reader = Ask::Decisions::Reader.new(
19
+ # provider,
20
+ # id: "lane",
21
+ # instructions: "Which of these best describes what the person wants?",
22
+ # options: {"knowledge" => "Asks about the business", "booking" => "Wants to book"},
23
+ # also: {"urgent" => Ask::Decision::Noul.new(instructions: "Is this urgent?")}
24
+ # )
25
+ # batch = reader.read(state: {message: "What time do you close?"})
26
+ # reader.choice(batch).choice # => "knowledge"
27
+ # batch["urgent"].noul # => 0.12
28
+ #
29
+ class Reader
30
+ # How much of an option's description to keep. A description is the
31
+ # separator between one option and the others, and a long one buries the
32
+ # part that separates. What is dropped is said out loud, because a
33
+ # silently clipped option reads as a whole one.
34
+ DEFAULT_LIMIT = 160
35
+
36
+ # The number of characters the elision marker itself needs.
37
+ ELISION_ROOM = 20
38
+
39
+ attr_reader :id, :instructions, :options
40
+
41
+ # @param provider [Ask::DecisionProvider]
42
+ # @param id [String, Symbol] the question id the Choice is asked under.
43
+ # Ids are for the caller's code and are never sent to the model.
44
+ # @param instructions [String] the question the options answer
45
+ # @param options [Hash{String => String}] option => what belongs in it
46
+ # @param also [Hash{String => Decision::Choice,Decision::Score,Decision::Noul}]
47
+ # further questions asked in the same request
48
+ # @param limit [Integer] characters kept per option description
49
+ def initialize(provider, id:, instructions:, options:, also: {}, limit: DEFAULT_LIMIT)
50
+ @provider = provider
51
+ @id = id.to_s
52
+ @instructions = instructions
53
+ @options = options
54
+ @also = also
55
+ @limit = limit
56
+
57
+ if @also.key?(@id)
58
+ raise ArgumentError, "#{@id.inspect} is asked twice: once as the choice, once in also"
59
+ end
60
+ end
61
+
62
+ # Ask everything about +state+ in one request.
63
+ #
64
+ # @param state [String, Hash, Array] the whole context, sent once
65
+ # @param model [String, nil] model override
66
+ # @return [Ask::DecisionResult::Batch]
67
+ def read(state:, model: nil)
68
+ @provider.evaluate(state: state, decisions: questions, model: model)
69
+ end
70
+
71
+ # The Choice answer, or nil when nothing came back for it.
72
+ def choice(batch) = batch[@id]
73
+
74
+ private
75
+
76
+ def questions
77
+ {id => choice_question}.merge(@also)
78
+ end
79
+
80
+ def choice_question
81
+ Ask::Decision::Choice.new(instructions: instructions, criteria: described_options)
82
+ end
83
+
84
+ def described_options
85
+ options.transform_values { |description| truncate(description) }
86
+ end
87
+
88
+ def truncate(value)
89
+ text = value.to_s
90
+ return text if text.length <= @limit
91
+
92
+ "#{text[0, @limit - ELISION_ROOM]}…[#{text.length - @limit + ELISION_ROOM} chars elided]"
93
+ end
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ module Decisions
5
+ # Decision-based reflection — replaces the LLM self-critique with a
6
+ # simple Noul: "is there a concrete, actionable improvement?"
7
+ #
8
+ # judge = Ask::Decisions::ReflectionJudge.new(provider)
9
+ # verdict = judge.reflect(
10
+ # request: "Fix the bug in login.rb",
11
+ # response: "I found the issue...",
12
+ # attempt: 2
13
+ # )
14
+ # verdict.improve? # => true
15
+ # verdict.done? # => false
16
+ #
17
+ class ReflectionJudge
18
+ QUESTIONS = {
19
+ has_improvement: Ask::Decision::Noul.new(
20
+ instructions: "Is there a concrete, actionable improvement that would make this response better for the user?"
21
+ ),
22
+ quality: Ask::Decision::Score.new(
23
+ instructions: "How close is this response to being fully satisfactory?",
24
+ criteria: [
25
+ "Poor — major issues remain",
26
+ "Fair — some improvements possible",
27
+ "Good — minor polish only",
28
+ "Excellent — ready to deliver"
29
+ ]
30
+ )
31
+ }.freeze
32
+
33
+ # @param provider [Ask::DecisionProvider]
34
+ # @param max_reflections [Integer] stop after this many improvement rounds
35
+ def initialize(provider, max_reflections: 3)
36
+ @provider = provider
37
+ @max_reflections = max_reflections
38
+ end
39
+
40
+ # Decide whether to improve or stop.
41
+ #
42
+ # @param request [String] the original user request
43
+ # @param response [String] the current response
44
+ # @param attempt [Integer] which reflection round (1-based)
45
+ # @return [ReflectionVerdict]
46
+ def reflect(request:, response:, attempt: 1)
47
+ state = { request: request, response: response, attempt: attempt }
48
+ result = @provider.evaluate(state: state, decisions: QUESTIONS)
49
+ ReflectionVerdict.new(result, attempt, @max_reflections)
50
+ end
51
+
52
+ # Verdict from reflection.
53
+ class ReflectionVerdict
54
+ attr_reader :result, :attempt, :max_reflections
55
+
56
+ def initialize(result, attempt, max_reflections)
57
+ @result = result
58
+ @attempt = attempt
59
+ @max_reflections = max_reflections
60
+ end
61
+
62
+ def improve?
63
+ noul = @result["has_improvement"]
64
+ quality = @result["quality"]
65
+ # Improve if there's a concrete improvement AND quality isn't excellent
66
+ (noul&.noul || 0) >= 0.5 && (quality&.score || 0) < 3.5
67
+ end
68
+
69
+ def done?
70
+ !improve? || attempt >= @max_reflections
71
+ end
72
+
73
+ def quality_score
74
+ @result["quality"]&.score || 0.0
75
+ end
76
+
77
+ def to_s
78
+ done? ? "done (quality: #{('%.1f' % quality_score)})" : "improve (quality: #{('%.1f' % quality_score)})"
79
+ end
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ module Decisions
5
+ # Reranks retrieved passages using Jev. One Score question per
6
+ # query–candidate pair, scored on a relevance rubric.
7
+ #
8
+ # This is the ask-rag integration: replace a cosine floor with a
9
+ # per-passage relevance judgment. TypeSafe's CLERC cookbook reports
10
+ # top-1 accuracy 5% → 18%, top-10 38% → 62%.
11
+ #
12
+ # reranker = Ask::Decisions::Reranker.new(provider)
13
+ # ranked = reranker.rerank(
14
+ # query: "How do I reset my password?",
15
+ # passages: [
16
+ # { id: "doc1", text: "To reset your password, go to Settings..." },
17
+ # { id: "doc2", text: "Our pricing plans start at $9/month..." }
18
+ # ]
19
+ # )
20
+ # ranked.first # => { id: "doc1", score: 3.8, confidence: 0.9 }
21
+ #
22
+ class Reranker
23
+ # @param provider [Ask::DecisionProvider]
24
+ def initialize(provider)
25
+ @provider = provider
26
+ end
27
+
28
+ # Rerank passages by relevance to a query.
29
+ #
30
+ # @param query [String] the search query
31
+ # @param passages [Array<Hash>] each with :id and :text
32
+ # @param model [String, nil] model override
33
+ # @return [Array<Hash>] sorted by score descending, each with :id, :score, :confidence
34
+ def rerank(query:, passages:, model: nil)
35
+ return [] if passages.empty?
36
+
37
+ questions = passages.each_with_object({}) do |passage, h|
38
+ id = passage[:id] || passage["id"] || "passage_#{h.size}"
39
+ text = passage[:text] || passage["text"] || ""
40
+
41
+ h["rel_#{id}"] = Ask::Decision::Score.new(
42
+ instructions: "How relevant is this passage to answering the query?",
43
+ criteria: [
44
+ "Not relevant — unrelated to the query",
45
+ "Marginally relevant — shares some keywords but doesn't address the query",
46
+ "Relevant — partially answers the query",
47
+ "Highly relevant — directly answers the query"
48
+ ]
49
+ )
50
+ end
51
+
52
+ state = { query: query, passages: passages.map { |p|
53
+ { id: p[:id] || p["id"], text: truncate(p[:text] || p["text"] || "", 1000) }
54
+ }}
55
+
56
+ result = @provider.evaluate(state: state, decisions: questions, model: model)
57
+
58
+ passages.each_with_index.map do |passage, i|
59
+ id = passage[:id] || passage["id"] || "passage_#{i}"
60
+ answer = result["rel_#{id}"]
61
+ {
62
+ id: id,
63
+ text: passage[:text] || passage["text"],
64
+ score: answer&.score || 0.0,
65
+ confidence: answer&.confidence || 0.0
66
+ }
67
+ end.sort_by { |p| -p[:score] }
68
+ end
69
+
70
+ private
71
+
72
+ def truncate(str, limit)
73
+ return "" if str.nil?
74
+ str.length > limit ? "#{str[0, limit]}…" : str
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ module Decisions
5
+ # A provider that returns canned answers. For tests and offline fixtures.
6
+ #
7
+ # Ask::Decisions.configure do |c|
8
+ # c.default_provider = :static
9
+ # end
10
+ #
11
+ # # Or per-call:
12
+ # Ask::Decisions.decide(
13
+ # state: "...",
14
+ # decisions: { "route" => Ask::Decision::Choice.new(instructions: "...", criteria: {...}) },
15
+ # provider: :static
16
+ # )
17
+ #
18
+ class Static < Ask::DecisionProvider
19
+ attr_reader :answers
20
+
21
+ def initialize(answers: {})
22
+ super()
23
+ @answers = answers.transform_keys(&:to_s).freeze
24
+ end
25
+
26
+ # @param state [String, Hash, Array] ignored
27
+ # @param decisions [Hash{String => Decision::Choice|Decision::Score|Decision::Noul}]
28
+ # used to produce placeholder answers for any missing fixtures
29
+ # @return [DecisionResult::Batch]
30
+ def evaluate(state:, decisions:, model: nil)
31
+ answers = decisions.each_with_object({}) do |(id, decision), h|
32
+ h[id.to_s] = @answers[id.to_s] || default_answer(id, decision)
33
+ end
34
+
35
+ Ask::DecisionResult::Batch.new(answers: answers, model: "static")
36
+ end
37
+
38
+ private
39
+
40
+ def default_answer(id, decision)
41
+ case decision
42
+ when Decision::Choice
43
+ key = decision.criteria.keys.first
44
+ Ask::DecisionResult::ChoiceAnswer.new(
45
+ id: id,
46
+ choice: key,
47
+ probabilities: Hash[decision.criteria.keys.map { |k| [k, 1.0 / decision.criteria.size] }],
48
+ confidence: 1.0
49
+ )
50
+ when Decision::Score
51
+ Ask::DecisionResult::ScoreAnswer.new(
52
+ id: id,
53
+ score: (decision.criteria.size / 2.0),
54
+ legend: Hash[decision.criteria.each_with_index.map { |c, i| [i.to_s, c] }],
55
+ probabilities: Hash[decision.criteria.each_with_index.map { |_, i| [i.to_s, 1.0 / decision.criteria.size] }],
56
+ confidence: 1.0
57
+ )
58
+ when Decision::Noul
59
+ Ask::DecisionResult::NoulAnswer.new(id: id, noul: 0.5)
60
+ else
61
+ raise ArgumentError, "Unknown decision type: #{decision.class}"
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ module Decisions
5
+ # A structured-state computer-use loop: extract state from a screen
6
+ # (OCR or DOM), ask Jev to pick the next action, execute it, repeat.
7
+ #
8
+ # This is the ask-computer integration. Instead of a vision-LLM loop
9
+ # (expensive, slow), we use OCR/DOM → Jev → action at ~$0.0002/step.
10
+ #
11
+ # loop = Ask::Decisions::StructuredStateLoop.new(provider)
12
+ # result = loop.run(
13
+ # goal: "open Safari and search for Ruby gems",
14
+ # state_fn: -> { extract_screen_state() }, # returns Hash
15
+ # action_fn: ->(action) { execute_action(action) }, # returns void
16
+ # max_steps: 20
17
+ # )
18
+ # result.completed? # => true
19
+ # result.steps # => 8
20
+ #
21
+ class StructuredStateLoop
22
+ ACTIONS = {
23
+ click: "Click on an element identified by its position or label",
24
+ type: "Type text into the focused input field",
25
+ scroll: "Scroll the page or window in a direction",
26
+ key: "Press a keyboard key or combination",
27
+ wait: "Wait for the screen to update",
28
+ done: "The goal has been achieved"
29
+ }.freeze
30
+
31
+ # @param provider [Ask::DecisionProvider]
32
+ def initialize(provider)
33
+ @provider = provider
34
+ end
35
+
36
+ # Run the decision loop until the goal is achieved or max steps hit.
37
+ #
38
+ # @param goal [String] what the user wants to accomplish
39
+ # @param state_fn [Proc] returns a Hash representing the current screen state
40
+ # @param action_fn [Proc] executes an action Hash, returns void
41
+ # @param max_steps [Integer] safety limit
42
+ # @return [LoopResult]
43
+ def run(goal:, state_fn:, action_fn:, max_steps: 20)
44
+ history = []
45
+ max_steps.times do |step|
46
+ state = state_fn.call
47
+ decision = decide_next(goal: goal, state: state, history: history)
48
+
49
+ if decision.done?
50
+ return LoopResult.new(completed: true, steps: step + 1, history: history)
51
+ end
52
+
53
+ action = decision.action
54
+ action_fn.call(action)
55
+ history << { step: step, state_summary: summarize_state(state), action: action }
56
+ end
57
+
58
+ LoopResult.new(completed: false, steps: max_steps, history: history)
59
+ end
60
+
61
+ private
62
+
63
+ def decide_next(goal:, state:, history:)
64
+ recent = history.last(5).map { |h| "#{h[:action][:type]}: #{h[:action][:target]}" }
65
+ state_text = summarize_state(state)
66
+
67
+ questions = {
68
+ action_type: Ask::Decision::Choice.new(
69
+ instructions: "What is the next action to achieve the goal?",
70
+ criteria: ACTIONS.transform_keys(&:to_s)
71
+ ),
72
+ target: Ask::Decision::Noul.new(
73
+ instructions: "Is the goal already achieved based on the current state?"
74
+ )
75
+ }
76
+
77
+ result = @provider.evaluate(
78
+ state: {
79
+ goal: goal,
80
+ current_state: state_text,
81
+ recent_actions: recent,
82
+ step: history.size
83
+ },
84
+ decisions: questions
85
+ )
86
+
87
+ action_type = result["action_type"]&.choice&.to_sym || :wait
88
+ goal_met = result["target"]&.noul&.>= 0.8
89
+
90
+ StepDecision.new(
91
+ action_type: action_type,
92
+ goal_met: goal_met,
93
+ confidence: result["action_type"]&.confidence
94
+ )
95
+ end
96
+
97
+ def summarize_state(state)
98
+ return state.to_s if state.is_a?(String)
99
+ state.map { |k, v| "#{k}: #{v}" }.join("\n")
100
+ end
101
+
102
+ # A single step decision.
103
+ class StepDecision
104
+ attr_reader :action_type, :confidence
105
+
106
+ def initialize(action_type:, goal_met:, confidence: nil)
107
+ @action_type = action_type
108
+ @goal_met = goal_met
109
+ @confidence = confidence
110
+ end
111
+
112
+ def done? = @goal_met
113
+
114
+ def action
115
+ { type: @action_type, target: nil }
116
+ end
117
+ end
118
+
119
+ # Result of the loop.
120
+ class LoopResult
121
+ attr_reader :steps, :history
122
+
123
+ def initialize(completed:, steps:, history:)
124
+ @completed = completed
125
+ @steps = steps
126
+ @history = history
127
+ end
128
+
129
+ def completed? = @completed
130
+
131
+ def to_s
132
+ @completed ? "completed in #{steps} steps" : "not completed after #{steps} steps"
133
+ end
134
+ end
135
+ end
136
+ end
137
+ end
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ module Decisions
5
+ # Decision-based tool call repair — replaces the LLM repair pass
6
+ # with a Choice over valid tool names and enum values. This is a
7
+ # real type check, not a prompt-and-hope.
8
+ #
9
+ # repairer = Ask::Decisions::ToolRepairer.new(provider)
10
+ # result = repairer.repair(
11
+ # attempted_tool: "bsh", # typo
12
+ # attempted_args: { command: "ls" },
13
+ # available_tools: [
14
+ # { name: "bash", description: "Run a shell command" },
15
+ # { name: "read", description: "Read a file" }
16
+ # ]
17
+ # )
18
+ # result.repaired_tool # => "bash"
19
+ # result.repaired? # => true
20
+ #
21
+ class ToolRepairer
22
+ # @param provider [Ask::DecisionProvider]
23
+ def initialize(provider)
24
+ @provider = provider
25
+ end
26
+
27
+ # Attempt to repair a malformed tool call.
28
+ #
29
+ # @param attempted_tool [String] the tool name the LLM tried to use
30
+ # @param attempted_args [Hash] the arguments it passed
31
+ # @param available_tools [Array<Hash>] the valid tool roster
32
+ # @return [RepairResult]
33
+ def repair(attempted_tool:, attempted_args:, available_tools:)
34
+ tool_names = available_tools.map { |t| t[:name] || t["name"] }
35
+
36
+ # If the tool name is valid, try to repair args only.
37
+ if tool_names.include?(attempted_tool)
38
+ return repair_args(
39
+ tool_name: attempted_tool,
40
+ args: attempted_args,
41
+ tools: available_tools
42
+ )
43
+ end
44
+
45
+ # Tool name is invalid — ask Jev to pick the right one.
46
+ question = Ask::Decision::Choice.new(
47
+ instructions: "The assistant tried to call a tool named '#{attempted_tool}' which does not exist. " \
48
+ "Which available tool is closest to what the assistant intended?",
49
+ criteria: tool_names.each_with_object({}) { |n, h|
50
+ desc = available_tools.find { |t| (t[:name] || t["name"]) == n }
51
+ h[n] = desc ? (desc[:description] || desc["description"] || n) : n
52
+ }
53
+ )
54
+
55
+ result = @provider.evaluate(
56
+ state: { attempted_tool: attempted_tool, attempted_args: attempted_args },
57
+ decisions: { "corrected_tool" => question }
58
+ )
59
+
60
+ corrected = result["corrected_tool"]&.choice
61
+ RepairResult.new(
62
+ repaired_tool: corrected || attempted_tool,
63
+ repaired_args: attempted_args,
64
+ repaired: corrected && corrected != attempted_tool,
65
+ confidence: result["corrected_tool"]&.confidence
66
+ )
67
+ end
68
+
69
+ private
70
+
71
+ def repair_args(tool_name:, args:, tools:)
72
+ tool_def = tools.find { |t| (t[:name] || t["name"]) == tool_name }
73
+ schema = tool_def&.dig(:params_schema) || tool_def&.dig("params_schema") || {}
74
+
75
+ resolver = ArgumentResolver.new(@provider)
76
+ resolution = resolver.resolve(
77
+ tool_name: tool_name,
78
+ params_schema: schema,
79
+ user_turn: args.to_json
80
+ )
81
+
82
+ merged = resolution.auto_fill_args.merge(args)
83
+ RepairResult.new(
84
+ repaired_tool: tool_name,
85
+ repaired_args: merged,
86
+ repaired: resolution.needs_generation?,
87
+ confidence: resolution.confidence
88
+ )
89
+ end
90
+
91
+ # Result of tool call repair.
92
+ class RepairResult
93
+ attr_reader :repaired_tool, :repaired_args, :confidence
94
+
95
+ def initialize(repaired_tool:, repaired_args:, repaired:, confidence: nil)
96
+ @repaired_tool = repaired_tool
97
+ @repaired_args = repaired_args
98
+ @repaired = repaired
99
+ @confidence = confidence
100
+ end
101
+
102
+ def repaired? = @repaired
103
+
104
+ def to_s
105
+ if repaired?
106
+ "repaired: #{repaired_tool}(#{repaired_args.keys.join(', ')})"
107
+ else
108
+ "unrepairable"
109
+ end
110
+ end
111
+ end
112
+ end
113
+ end
114
+ end