activeagent 1.3.1 → 1.5.0

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,258 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveAgent
4
+ module Evals
5
+ # Explains why a scenario did not pass and what would fix it.
6
+ #
7
+ # The fault is assigned from the replay's evidence, the most mechanical
8
+ # cause first, so a run that crashed is a `run_error` even if its empty
9
+ # answer would also have failed a content check:
10
+ #
11
+ # run_error — the run raised, or the agent returned nothing
12
+ # tool_error — a tool the agent called returned an error
13
+ # missing_capability — the agent said no tool covers the task
14
+ # expected_tool_not_called — the scenario expects a tool the agent did not call
15
+ # forbidden_content — the answer contains a pattern the scenario forbids
16
+ # missing_content — the answer lacks a pattern the scenario expects
17
+ # low_quality — the answer scored below the threshold
18
+ #
19
+ # Each fault carries a recommendation written from the evidence; a Judge
20
+ # can replace it with one that names the tool to add (Runner does this).
21
+ # Returns nil for a passing result.
22
+ class Diagnosis
23
+ FAULTS = %w[
24
+ run_error tool_error missing_capability expected_tool_not_called
25
+ forbidden_content missing_content low_quality judge_unavailable
26
+ ].freeze
27
+
28
+ # Phrasings an agent uses when nothing in its toolset covers the task.
29
+ # "find" and "see" are deliberately absent: "I can't find any…" and
30
+ # "I don't see…" report a negative result, not a missing capability.
31
+ REFUSED_VERBS = "have|access|retrieve|look up|query|check|view|search"
32
+ CAPABILITY_REFUSALS = [
33
+ /\bI(?:'m| am)? (?:do not |don't |cannot |can't |unable to |not able to )(?:currently )?(?:#{REFUSED_VERBS})\b/i,
34
+ /\b(?:no|don't have (?:a|any)) tools? (?:is |are )?(?:available|that can|to)\b/i,
35
+ /\bnot (?:something|able|possible) (?:I|to) (?:can|am able to )?(?:do|access|retrieve|look up)\b/i,
36
+ /\bI (?:don't|do not) have (?:the ability|a way|access|visibility|the tools?)\b/i,
37
+ /\boutside (?:of )?(?:my|the) (?:capabilities|available tools|scope)\b/i,
38
+ /\bcan(?:'|no)t (?:be )?(?:done|determined|answered) with (?:the|my) (?:current|available) tools\b/i
39
+ ].freeze
40
+
41
+ Result = Struct.new(:fault, :summary, :recommendation, :evidence, keyword_init: true) do
42
+ def to_h
43
+ {
44
+ "fault" => fault,
45
+ "summary" => summary,
46
+ "recommendation" => recommendation,
47
+ "evidence" => evidence
48
+ }
49
+ end
50
+ end
51
+
52
+ # @param scenario [Scenario]
53
+ # @param replay [Replay]
54
+ # @param scores [Hash] criterion key => 0.0..1.0 (nil when unscorable)
55
+ # @param score [Float, nil] the mean score
56
+ # @param available_tools [Array<String>] tool names the agent could call
57
+ # @param threshold [Float] the pass threshold for `score`
58
+ # @param agent_name [String] how the recommendations refer to the agent
59
+ # @param judge_keys [Array] keys in `scores` a judge graded the answer on
60
+ # (llm_judge criteria); `task_completion` always counts as one
61
+ def self.call(scenario:, replay:, scores:, score:, available_tools:, threshold: PASS_THRESHOLD, agent_name: "The agent",
62
+ judge_keys: [])
63
+ new(scenario:, replay:, scores:, score:, available_tools:, threshold:, agent_name:, judge_keys:).call
64
+ end
65
+
66
+ def initialize(scenario:, replay:, scores:, score:, available_tools:, threshold: PASS_THRESHOLD, agent_name: "The agent",
67
+ judge_keys: [])
68
+ @scenario = scenario
69
+ @replay = replay
70
+ @scores = scores || {}
71
+ @score = score
72
+ @available_tools = Array(available_tools).map(&:to_s)
73
+ @threshold = threshold
74
+ @agent_name = agent_name
75
+ @judge_keys = Array(judge_keys) | [ "task_completion" ]
76
+ end
77
+
78
+ def call
79
+ run_error || tool_error || missing_capability || expected_tool_not_called ||
80
+ forbidden_content || missing_content || low_quality
81
+ end
82
+
83
+ private
84
+
85
+ def answer
86
+ @replay.answer.to_s
87
+ end
88
+
89
+ def called_tools
90
+ @replay.tool_names
91
+ end
92
+
93
+ def agent
94
+ @agent_name
95
+ end
96
+
97
+ def run_error
98
+ if @replay.errored?
99
+ message = @replay.error.to_s
100
+ return result("run_error", "The run failed before #{agent.downcase} answered: #{message.truncate(200)}",
101
+ run_error_recommendation(message), "error" => message.truncate(1_000))
102
+ end
103
+ return nil if answer.present?
104
+
105
+ result("run_error", "#{agent} returned an empty answer.",
106
+ "The provider returned no content. Check the model name is one the provider serves and that the " \
107
+ "output budget leaves room for an answer after the tool calls.",
108
+ "error" => "empty answer")
109
+ end
110
+
111
+ def run_error_recommendation(message)
112
+ case message
113
+ when /credentials|api.?key|access_token|unauthori[sz]ed|401/i
114
+ "Add credentials for the provider this model runs on before comparing it."
115
+ when /model.*(not found|does not exist|unknown|unsupported)|404/i
116
+ "The provider rejected the model name. Check the spelling against the provider's catalog, or prefix " \
117
+ "it with the provider (`ollama/qwen3:8b`) so it runs where it exists."
118
+ when /rate limit|429|overloaded|529/i
119
+ "The provider throttled the run. Re-run the failed scenarios; if it recurs, run fewer scenarios per " \
120
+ "batch or compare fewer models at once."
121
+ else
122
+ "Inspect the run's error. A failure here is infrastructure — it says nothing about the answer yet."
123
+ end
124
+ end
125
+
126
+ def tool_error
127
+ failed = @replay.failed_tool_calls
128
+ return nil if failed.empty?
129
+
130
+ names = failed.map { |call| call["name"] }.uniq
131
+ detail = failed.first["detail"].to_s.truncate(300)
132
+ result("tool_error", "Tool #{names.join(', ')} returned an error while answering.",
133
+ "Fix the failing tool before judging the answer: #{names.join(', ')} errored with \"#{detail}\". " \
134
+ "If the arguments look wrong, tighten the tool's parameter descriptions so the model calls it " \
135
+ "correctly; if the tool itself broke, fix its implementation.",
136
+ "tools" => names, "detail" => detail, "arguments" => failed.first["arguments"])
137
+ end
138
+
139
+ def missing_capability
140
+ return nil unless CAPABILITY_REFUSALS.any? { |pattern| answer.match?(pattern) }
141
+ return nil if called_tools.any? && @score.to_f >= @threshold
142
+
143
+ missing = @scenario.expected_tools - @available_tools
144
+ recommendation =
145
+ if missing.any?
146
+ "#{agent} said it cannot do this, and the expected tool(s) #{missing.join(', ')} are not in its " \
147
+ "toolset. Add or enable them."
148
+ elsif @available_tools.empty?
149
+ "#{agent} has no tools, so it can only answer from its instructions. Give it a tool that reads the " \
150
+ "data this task needs."
151
+ else
152
+ "None of the available tools (#{@available_tools.join(', ')}) covers this task. Add a tool that does, " \
153
+ "or, if one of them should, rewrite its description so the model recognises when to use it."
154
+ end
155
+
156
+ result("missing_capability", "#{agent} said it lacks the ability to perform this task.", recommendation,
157
+ "refusal" => refusal_excerpt, "tools_available" => @available_tools, "tools_called" => called_tools)
158
+ end
159
+
160
+ def refusal_excerpt
161
+ pattern = CAPABILITY_REFUSALS.find { |candidate| answer.match?(candidate) }
162
+ match = answer.match(pattern)
163
+ return nil unless match
164
+
165
+ answer[[ match.begin(0) - 80, 0 ].max, 260].to_s.strip
166
+ end
167
+
168
+ def expected_tool_not_called
169
+ expected = @scenario.expected_tools
170
+ return nil if expected.empty? || (expected & called_tools).any?
171
+
172
+ unavailable = expected - @available_tools
173
+ recommendation =
174
+ if unavailable.any?
175
+ "The scenario expects #{unavailable.join(', ')}, which #{agent.downcase} does not have. Enable the " \
176
+ "tool (or add the server that provides it) and re-run."
177
+ elsif called_tools.any?
178
+ "#{agent} answered with #{called_tools.uniq.join(', ')} instead of #{expected.join(', ')}. Sharpen " \
179
+ "both tools' descriptions so the model can tell them apart, or say in the instructions which tool " \
180
+ "answers this kind of task."
181
+ else
182
+ "#{expected.join(', ')} is available but #{agent.downcase} answered without calling any tool. Tell " \
183
+ "it in the instructions to prefer tool-backed answers for this kind of task, and check the tool's " \
184
+ "description says what it returns."
185
+ end
186
+
187
+ result("expected_tool_not_called",
188
+ "Expected #{expected.join(' or ')} to be called; #{agent.downcase} called " \
189
+ "#{called_tools.uniq.presence&.join(', ') || 'nothing'}.",
190
+ recommendation, "expected" => expected, "called" => called_tools, "unavailable" => unavailable)
191
+ end
192
+
193
+ def forbidden_content
194
+ matched = @scenario.forbidden_patterns.select { |pattern| Scorer.matches_pattern?(answer, pattern) }
195
+ return nil if matched.empty?
196
+
197
+ result("forbidden_content", "The answer contains content the scenario forbids: #{matched.join(', ')}.",
198
+ "Add an explicit instruction against \"#{matched.first}\" and, if the phrase comes from a tool " \
199
+ "result, filter it in the tool rather than relying on the model to omit it.",
200
+ "matched" => matched)
201
+ end
202
+
203
+ def missing_content
204
+ missing = @scenario.expected_patterns.reject { |pattern| Scorer.matches_pattern?(answer, pattern) }
205
+ return nil if missing.empty?
206
+
207
+ recommendation =
208
+ if called_tools.empty? && @available_tools.any?
209
+ "#{agent} answered without calling a tool, so it could not have found \"#{missing.first}\". " \
210
+ "Instruct it to use its tools for this kind of task."
211
+ else
212
+ "The answer never mentions \"#{missing.first}\". Check whether the tool result contained it — if it " \
213
+ "did, the instructions should ask for it explicitly; if not, the tool needs to return it."
214
+ end
215
+
216
+ result("missing_content", "The answer is missing expected content: #{missing.join(', ')}.", recommendation,
217
+ "missing" => missing)
218
+ end
219
+
220
+ # A judge grade — the implicit task_completion score, or the llm_judge
221
+ # criteria the evaluation configured — measures the answer itself, so
222
+ # its mean has to reach the threshold on its own. Rule checks (a tool
223
+ # was called, a phrase is present) cannot carry a badly graded answer.
224
+ def low_quality
225
+ grades = @scores.slice(*@judge_keys).compact
226
+ grade = grades.any? ? (grades.values.sum / grades.size).round(3) : nil
227
+ failed_grade = grade && grade < @threshold
228
+ return nil unless failed_grade || (@score && @score < @threshold)
229
+
230
+ weakest = (failed_grade ? grades : @scores.compact).min_by { |_, value| value }
231
+ summary = if failed_grade
232
+ "#{graded_label(grades)} scored #{grade.round(2)} against a pass threshold of #{@threshold}"
233
+ else
234
+ "Scored #{@score.round(2)} against a pass threshold of #{@threshold}"
235
+ end
236
+ summary += ", weakest on #{weakest.first} (#{weakest.last.round(2)})" if weakest
237
+ recommendation =
238
+ if weakest
239
+ "Read the answer against the #{weakest.first.to_s.humanize.downcase} criterion and adjust the " \
240
+ "instructions where it falls short. A criterion that keeps scoring low across scenarios points at " \
241
+ "the instructions; one that fails on one scenario points at that task's tooling."
242
+ else
243
+ "Compare this answer with a passing one for a similar scenario and adjust the instructions."
244
+ end
245
+
246
+ result("low_quality", "#{summary}.", recommendation, "scores" => @scores)
247
+ end
248
+
249
+ def graded_label(grades)
250
+ grades.keys == [ "task_completion" ] ? "Task completion" : "Judged quality"
251
+ end
252
+
253
+ def result(fault, summary, recommendation, evidence = {})
254
+ Result.new(fault: fault, summary: summary, recommendation: recommendation, evidence: evidence.compact)
255
+ end
256
+ end
257
+ end
258
+ end
@@ -0,0 +1,212 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveAgent
4
+ module Evals
5
+ # A second model that scores answers, refines recommendations, and picks a
6
+ # winner. The gem owns the prompts and the parsing; the caller supplies the
7
+ # one thing that differs per stack — how to get a completion:
8
+ #
9
+ # judge = ActiveAgent::Evals::Judge.new(label: "claude-opus-5") do |instructions:, prompt:|
10
+ # RubyLLM.chat(model: "claude-opus-5").with_instructions(instructions).ask(prompt).content
11
+ # end
12
+ #
13
+ # Every method returns nil when the judge fails or answers unusably, so an
14
+ # evaluation degrades to rule scoring rather than aborting.
15
+ class Judge
16
+ SCORE_INSTRUCTIONS = "You are an impartial evaluation judge. Respond ONLY with JSON: " \
17
+ '{"score": <float between 0.0 and 1.0>}'
18
+ RECOMMEND_INSTRUCTIONS = "You diagnose why an AI agent failed a task and recommend the fix. Respond ONLY with JSON."
19
+ VERDICT_INSTRUCTIONS = "You are an impartial evaluation judge comparing model cohorts. Respond ONLY with JSON."
20
+
21
+ attr_reader :label
22
+
23
+ # @param label [String] how reports name the judge (usually its model)
24
+ # @yieldparam instructions [String] the system prompt
25
+ # @yieldparam prompt [String] the user prompt
26
+ # @yieldreturn [String] the completion text
27
+ def initialize(label:, &generate)
28
+ raise ArgumentError, "Judge.new needs a block that returns the model's completion" unless generate
29
+
30
+ @label = label
31
+ @generate = generate
32
+ end
33
+
34
+ # Scores `answer` against one llm_judge criterion, 0.0..1.0.
35
+ def score_criterion(criterion:, prompt:, answer:)
36
+ return nil if answer.blank?
37
+
38
+ guidance = criterion.dig("config", "prompt").presence || criterion["key"].to_s.humanize
39
+ parse_score(ask(SCORE_INSTRUCTIONS, <<~PROMPT))
40
+ Criterion: #{guidance}
41
+
42
+ The user asked:
43
+ ---
44
+ #{prompt.to_s.truncate(1_500)}
45
+ ---
46
+
47
+ The agent answered:
48
+ ---
49
+ #{answer.to_s.truncate(4_000)}
50
+ ---
51
+
52
+ Score the answer against the criterion from 0.0 (fails completely) to 1.0 (fully satisfies).
53
+ Respond only with JSON: {"score": <float>}
54
+ PROMPT
55
+ end
56
+
57
+ # Scores how well `answer` accomplishes the scenario's task, 0.0..1.0 —
58
+ # the single-criterion judgement for a scenario with no criteria of its own.
59
+ def score_task(scenario:, answer:)
60
+ return nil if answer.blank?
61
+
62
+ parse_score(ask(SCORE_INSTRUCTIONS, <<~PROMPT))
63
+ A user asked an assistant:
64
+ ---
65
+ #{scenario.prompt}
66
+ ---
67
+ #{"Context for the evaluator: #{scenario.notes.truncate(300)}\n" if scenario.notes.present?}
68
+ The assistant answered:
69
+ ---
70
+ #{answer.to_s.truncate(4_000)}
71
+ ---
72
+
73
+ Score from 0.0 (the task was not done — a refusal, a guess, or an unrelated answer) to 1.0 (the task
74
+ was done with specific, tool-backed data and a clear next step for the user).
75
+ Respond only with JSON: {"score": <float>}
76
+ PROMPT
77
+ end
78
+
79
+ # Asks what to change so the scenario passes. Returns a hash with any of
80
+ # `recommendation`, `suggested_tool` (`{ "name", "description" }`) and
81
+ # `instruction_change`, or nil.
82
+ def recommend(scenario:, replay:, diagnosis:, available_tools: {}, instructions: nil)
83
+ roster = available_tools.to_h.map { |name, description| "- #{name}: #{description.to_s.truncate(160)}" }.join("\n")
84
+ calls = replay.tool_calls.map do |call|
85
+ "- #{call['name']}#{' (errored)' if call['error']}: #{call['arguments'].to_json.truncate(200)}"
86
+ end.join("\n")
87
+
88
+ parsed = parse_object(ask(RECOMMEND_INSTRUCTIONS, <<~PROMPT))
89
+ An AI agent failed one evaluation scenario. Recommend the fix.
90
+
91
+ Agent instructions:
92
+ ---
93
+ #{instructions.to_s.truncate(2_000).presence || '(no instructions configured)'}
94
+ ---
95
+
96
+ Tools available to the agent:
97
+ #{roster.presence || '(none)'}
98
+
99
+ Scenario (the user's message):
100
+ #{scenario.prompt}
101
+ #{"Expected tools: #{scenario.expected_tools.join(', ')}" if scenario.expected_tools.any?}
102
+ #{"Notes: #{scenario.notes.truncate(300)}" if scenario.notes.present?}
103
+
104
+ Tools the agent called:
105
+ #{calls.presence || '(none)'}
106
+
107
+ The agent's answer:
108
+ ---
109
+ #{replay.answer.to_s.truncate(2_500).presence || '(empty)'}
110
+ ---
111
+
112
+ Detected fault: #{diagnosis.fault} — #{diagnosis.summary}
113
+
114
+ Say what to change so this scenario passes. If the agent lacks a tool for the task, describe the
115
+ tool to add. If the tools suffice, say what to change in the instructions.
116
+ Respond ONLY with JSON:
117
+ {"recommendation": "<two sentences at most>",
118
+ "suggested_tool": {"name": "snake_case_name", "description": "what it returns"} or null,
119
+ "instruction_change": "<the sentence to add or change>" or null}
120
+ PROMPT
121
+
122
+ parsed = parsed&.slice("recommendation", "suggested_tool", "instruction_change")&.compact
123
+ return nil if parsed.blank?
124
+
125
+ %w[recommendation instruction_change].each do |key|
126
+ parsed.delete(key) unless parsed[key].is_a?(String) && parsed[key].present?
127
+ end
128
+ parsed["suggested_tool"] = suggested_tool(parsed["suggested_tool"]) if parsed.key?("suggested_tool")
129
+ parsed.compact.presence
130
+ end
131
+
132
+ # Picks the model that best accomplishes the agent's goals from the
133
+ # per-model summaries (Report#summary_by_model). Returns
134
+ # `{ "winner", "rationale" }` or nil; a winner that is not one of the
135
+ # compared models is discarded.
136
+ def verdict(summaries, instructions: nil)
137
+ lines = summaries.map do |label, stats|
138
+ faults = (stats["faults"] || {}).map { |fault, count| "#{fault}×#{count}" }.join(", ")
139
+ "#{label}: pass rate #{stats['pass_rate']}%, mean score #{stats['avg_score'] || 'n/a'}, " \
140
+ "avg latency #{stats['avg_duration_ms'] || 'n/a'}ms, cost $#{stats['cost'] || 'n/a'}" \
141
+ "#{", faults: #{faults}" if faults.present?}"
142
+ end
143
+
144
+ parsed = parse_object(ask(VERDICT_INSTRUCTIONS, <<~PROMPT))
145
+ An AI agent ran the same scenarios under several models. Its goals:
146
+ ---
147
+ #{instructions.to_s.truncate(1_000).presence || '(no instructions configured)'}
148
+ ---
149
+
150
+ Results per model:
151
+ #{lines.join("\n")}
152
+
153
+ Which model best accomplishes the agent's goals, weighing task completion first and cost and
154
+ latency second? Respond ONLY with JSON: {"winner": "<model>", "rationale": "<at most two sentences>"}
155
+ PROMPT
156
+
157
+ return nil unless parsed && summaries.key?(parsed["winner"])
158
+
159
+ { "winner" => parsed["winner"], "rationale" => parsed["rationale"].to_s }
160
+ end
161
+
162
+ private
163
+
164
+ # The judge is asked for `{ "name", "description" }`; a bare string is
165
+ # taken as the name, and anything else is dropped rather than rendered
166
+ # as an empty tool.
167
+ def suggested_tool(tool)
168
+ case tool
169
+ when Hash
170
+ if tool["name"].is_a?(String) && tool["name"].present?
171
+ { "name" => tool["name"], "description" => tool["description"].is_a?(String) ? tool["description"] : "" }
172
+ end
173
+ when String
174
+ { "name" => tool, "description" => "" } if tool.present?
175
+ end
176
+ end
177
+
178
+ def ask(instructions, prompt)
179
+ @generate.call(instructions: instructions, prompt: prompt).to_s
180
+ rescue StandardError => e
181
+ warn_failure(e)
182
+ nil
183
+ end
184
+
185
+ def warn_failure(error)
186
+ message = "[ActiveAgent::Evals] judge #{label} failed: #{error.class}: #{error.message}"
187
+ if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
188
+ Rails.logger.warn(message)
189
+ else
190
+ warn(message)
191
+ end
192
+ end
193
+
194
+ def parse_score(content)
195
+ value = parse_object(content)&.dig("score")
196
+ return nil unless value.is_a?(Numeric) && value.finite?
197
+
198
+ value.to_f.clamp(0.0, 1.0)
199
+ end
200
+
201
+ def parse_object(content)
202
+ json = content.to_s[/\{.*\}/m]
203
+ return nil unless json
204
+
205
+ parsed = JSON.parse(json)
206
+ parsed.is_a?(Hash) ? parsed : nil
207
+ rescue JSON::ParserError
208
+ nil
209
+ end
210
+ end
211
+ end
212
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveAgent
4
+ module Evals
5
+ # A candidate model for a comparison run, resolved from the string a user
6
+ # types. `label` is that string verbatim and keys the model's cohort in a
7
+ # report; `provider` and `model` are what the run executes under:
8
+ #
9
+ # "anthropic/claude-sonnet-5" → anthropic, claude-sonnet-5
10
+ # "claude-haiku-4-5" → anthropic (inferred), claude-haiku-4-5
11
+ # "gpt-5-mini" → openai (inferred), gpt-5-mini
12
+ # "qwen3:8b" → ollama (inferred from the tag), qwen3:8b
13
+ # "meta-llama/llama-3.3-70b" → openrouter, meta-llama/llama-3.3-70b
14
+ # "openrouter/anthropic/claude-3" → openrouter, anthropic/claude-3
15
+ #
16
+ # `providers` lists the names a leading path segment may name; a vendor
17
+ # prefix that is not one of them routes through openrouter when that is
18
+ # available and otherwise stays part of the model name. A bare name no
19
+ # inference rule recognises runs under `default_provider`.
20
+ class ModelSpec
21
+ DEFAULT_PROVIDERS = %w[openai anthropic ollama openrouter].freeze
22
+
23
+ DEFAULT_INFERENCE_RULES = [
24
+ [ /\Aclaude/i, "anthropic" ],
25
+ [ /\A(gpt-|o\d|chatgpt|text-embedding)/i, "openai" ],
26
+ [ /:/, "ollama" ]
27
+ ].freeze
28
+
29
+ attr_reader :label, :provider, :model
30
+
31
+ def self.parse(value, default_provider:, providers: DEFAULT_PROVIDERS, inference_rules: DEFAULT_INFERENCE_RULES)
32
+ raw = value.to_s.strip
33
+ raise ArgumentError, "model name is blank" if raw.blank?
34
+
35
+ head, rest = raw.split("/", 2)
36
+ if rest.present? && providers.include?(head)
37
+ new(label: raw, provider: head, model: rest)
38
+ elsif rest.present? && providers.include?("openrouter")
39
+ new(label: raw, provider: "openrouter", model: raw)
40
+ else
41
+ new(label: raw, provider: infer_provider(raw, default_provider, inference_rules, providers), model: raw)
42
+ end
43
+ end
44
+
45
+ # Parses a comma-separated string or an array, dropping blanks and
46
+ # duplicates by label.
47
+ def self.parse_all(values, **options)
48
+ values = values.to_s.split(",") unless values.is_a?(Array)
49
+ values.map { |value| value.to_s.strip }.reject(&:blank?).uniq.map { |value| parse(value, **options) }
50
+ end
51
+
52
+ # The provider a bare model name runs under. A rule whose provider the
53
+ # caller does not offer is skipped, so an app without Ollama does not
54
+ # route `name:tag` there.
55
+ def self.infer_provider(model, default_provider, inference_rules, providers)
56
+ rule = inference_rules.find { |pattern, provider| model.match?(pattern) && providers.include?(provider) }
57
+ (rule ? rule.last : default_provider).to_s
58
+ end
59
+
60
+ def initialize(label:, provider:, model:)
61
+ @label = label
62
+ @provider = provider.to_s
63
+ @model = model
64
+ end
65
+
66
+ def to_h
67
+ { "label" => label, "provider" => provider, "model" => model }
68
+ end
69
+
70
+ def ==(other)
71
+ other.is_a?(ModelSpec) && to_h == other.to_h
72
+ end
73
+ alias eql? ==
74
+
75
+ def hash
76
+ to_h.hash
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "openssl"
6
+ require "uri"
7
+
8
+ module ActiveAgent
9
+ module Evals
10
+ # Publishes a completed report without replaying the agent. The caller must
11
+ # retain run_id when retrying: compatible collectors treat that identity as
12
+ # immutable within the authenticated account. Delivery is blocking and does
13
+ # not follow redirects with the account's bearer credential.
14
+ class Publisher
15
+ DEFAULT_ENDPOINT = "https://api.activeagents.ai/v1/evaluations"
16
+ MAX_BYTES = 2 * 1024 * 1024
17
+ class Error < StandardError; end
18
+
19
+ def initialize(api_key:, endpoint: DEFAULT_ENDPOINT, timeout: 10, open_timeout: 10)
20
+ @uri = URI.parse(endpoint.to_s)
21
+ unless @uri.is_a?(URI::HTTP) && @uri.host && !@uri.userinfo && !@uri.query && !@uri.fragment
22
+ raise ArgumentError, "Evaluation endpoint must be an HTTP(S) URL without credentials, query or fragment"
23
+ end
24
+ unless @uri.scheme == "https" || %w[localhost 127.0.0.1 ::1].include?(@uri.hostname)
25
+ raise ArgumentError, "Evaluation endpoint requires HTTPS except on loopback hosts"
26
+ end
27
+ raise ArgumentError, "Evaluation API key is required" if api_key.to_s.strip.empty?
28
+
29
+ @api_key = api_key.to_s
30
+ @timeout = Float(timeout)
31
+ @open_timeout = Float(open_timeout)
32
+ unless [ @timeout, @open_timeout ].all? { |value| value.finite? && value.positive? }
33
+ raise ArgumentError, "Evaluation delivery timeouts must be positive and finite"
34
+ end
35
+ rescue URI::InvalidURIError
36
+ raise ArgumentError, "Evaluation endpoint is not a valid URL"
37
+ end
38
+
39
+ # report may be a Report or its saved JSON hash. Full prompts, answers and
40
+ # tool results are included; applications should make publication opt-in.
41
+ def call(report:, run_id:, source:, agent_name:, suite:)
42
+ identities = { "run_id" => run_id, "source" => source, "agent_name" => agent_name, "suite" => suite }
43
+ identities.each do |key, value|
44
+ raise ArgumentError, "#{key} must be a nonempty string" unless value.is_a?(String) && !value.strip.empty?
45
+ end
46
+ body = JSON.generate(identities.merge("version" => 1, "report" => report.to_h))
47
+ raise Error, "Evaluation report exceeds the 2 MiB delivery limit; publish a smaller selection" if body.bytesize > MAX_BYTES
48
+
49
+ http = Net::HTTP.new(@uri.hostname, @uri.port)
50
+ http.use_ssl = @uri.scheme == "https"
51
+ http.open_timeout = @open_timeout
52
+ http.read_timeout = @timeout
53
+ http.write_timeout = @timeout
54
+ request = Net::HTTP::Post.new(@uri.request_uri)
55
+ request["Authorization"] = "Bearer #{@api_key}"
56
+ request["Content-Type"] = "application/json"
57
+ request["Accept"] = "application/json"
58
+ request.body = body
59
+ response = http.request(request)
60
+ unless %w[200 201].include?(response.code)
61
+ raise Error, "Evaluation delivery rejected (HTTP #{response.code}); retain the report and run_id for retry"
62
+ end
63
+
64
+ receipt = JSON.parse(response.body)
65
+ unless receipt.is_a?(Hash) && receipt["run_id"] == run_id && receipt["status"] == "complete" && receipt["id"] && receipt["evaluation_id"]
66
+ raise Error, "Evaluation collector returned an invalid completion receipt; retain the report and run_id for retry"
67
+ end
68
+ receipt
69
+ rescue JSON::ParserError
70
+ raise Error, "Evaluation collector returned invalid JSON; retain the report and run_id for retry"
71
+ rescue IOError, SocketError, SystemCallError, Timeout::Error, OpenSSL::SSL::SSLError => e
72
+ raise Error, "Evaluation delivery failed (#{e.class}); retain the report and run_id for retry"
73
+ end
74
+ end
75
+ end
76
+ end