activeagent 1.3.0 → 1.4.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,205 @@
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
+ parsed["suggested_tool"] = suggested_tool(parsed["suggested_tool"]) if parsed.key?("suggested_tool")
126
+ parsed.compact.presence
127
+ end
128
+
129
+ # Picks the model that best accomplishes the agent's goals from the
130
+ # per-model summaries (Report#summary_by_model). Returns
131
+ # `{ "winner", "rationale" }` or nil; a winner that is not one of the
132
+ # compared models is discarded.
133
+ def verdict(summaries, instructions: nil)
134
+ lines = summaries.map do |label, stats|
135
+ faults = (stats["faults"] || {}).map { |fault, count| "#{fault}×#{count}" }.join(", ")
136
+ "#{label}: pass rate #{stats['pass_rate']}%, mean score #{stats['avg_score'] || 'n/a'}, " \
137
+ "avg latency #{stats['avg_duration_ms'] || 'n/a'}ms, cost $#{stats['cost'] || 'n/a'}" \
138
+ "#{", faults: #{faults}" if faults.present?}"
139
+ end
140
+
141
+ parsed = parse_object(ask(VERDICT_INSTRUCTIONS, <<~PROMPT))
142
+ An AI agent ran the same scenarios under several models. Its goals:
143
+ ---
144
+ #{instructions.to_s.truncate(1_000).presence || '(no instructions configured)'}
145
+ ---
146
+
147
+ Results per model:
148
+ #{lines.join("\n")}
149
+
150
+ Which model best accomplishes the agent's goals, weighing task completion first and cost and
151
+ latency second? Respond ONLY with JSON: {"winner": "<model>", "rationale": "<at most two sentences>"}
152
+ PROMPT
153
+
154
+ return nil unless parsed && summaries.key?(parsed["winner"])
155
+
156
+ { "winner" => parsed["winner"], "rationale" => parsed["rationale"].to_s }
157
+ end
158
+
159
+ private
160
+
161
+ # The judge is asked for `{ "name", "description" }`; a bare string is
162
+ # taken as the name, and anything else is dropped rather than rendered
163
+ # as an empty tool.
164
+ def suggested_tool(tool)
165
+ case tool
166
+ when Hash
167
+ { "name" => tool["name"].to_s, "description" => tool["description"].to_s } if tool["name"].present?
168
+ when String
169
+ { "name" => tool, "description" => "" } if tool.present?
170
+ end
171
+ end
172
+
173
+ def ask(instructions, prompt)
174
+ @generate.call(instructions: instructions, prompt: prompt).to_s
175
+ rescue StandardError => e
176
+ warn_failure(e)
177
+ nil
178
+ end
179
+
180
+ def warn_failure(error)
181
+ message = "[ActiveAgent::Evals] judge #{label} failed: #{error.class}: #{error.message}"
182
+ if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
183
+ Rails.logger.warn(message)
184
+ else
185
+ warn(message)
186
+ end
187
+ end
188
+
189
+ def parse_score(content)
190
+ match = content.to_s.match(/"score"\s*:\s*(\d+(?:\.\d+)?)/)
191
+ match && match[1].to_f.clamp(0.0, 1.0)
192
+ end
193
+
194
+ def parse_object(content)
195
+ json = content.to_s[/\{.*\}/m]
196
+ return nil unless json
197
+
198
+ parsed = JSON.parse(json)
199
+ parsed.is_a?(Hash) ? parsed : nil
200
+ rescue JSON::ParserError
201
+ nil
202
+ end
203
+ end
204
+ end
205
+ 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,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveAgent
4
+ module Evals
5
+ # What one run of the agent on one scenario produced — the value the
6
+ # Runner's `replay` callable returns.
7
+ #
8
+ # @!attribute answer
9
+ # @return [String, nil] the agent's final answer
10
+ # @!attribute tool_calls
11
+ # @return [Array<Hash>] one hash per call: `"name"`, and optionally
12
+ # `"arguments"`, `"error"` (true when the tool failed), `"detail"`
13
+ # (the error or a result preview), `"duration_ms"`
14
+ # @!attribute error
15
+ # @return [String, nil] the exception when the run raised before answering
16
+ # @!attribute cost
17
+ # @return [Numeric, nil] estimated spend, when the caller prices tokens
18
+ # @!attribute metadata
19
+ # @return [Hash] anything the caller wants carried onto the Result (a run id, a chat id)
20
+ Replay = Struct.new(:answer, :tool_calls, :duration_ms, :input_tokens, :output_tokens, :error, :cost, :metadata,
21
+ keyword_init: true) do
22
+ def initialize(answer: nil, tool_calls: [], duration_ms: nil, input_tokens: nil, output_tokens: nil,
23
+ error: nil, cost: nil, metadata: {})
24
+ super(
25
+ answer: answer,
26
+ tool_calls: Array(tool_calls).map { |call| call.respond_to?(:to_h) ? call.to_h.stringify_keys : { "name" => call.to_s } },
27
+ duration_ms: duration_ms,
28
+ input_tokens: input_tokens,
29
+ output_tokens: output_tokens,
30
+ error: error&.to_s,
31
+ cost: cost,
32
+ metadata: (metadata || {}).to_h
33
+ )
34
+ end
35
+
36
+ # Builds a Replay for a run that raised, so a failing scenario is scored
37
+ # and diagnosed like any other rather than aborting the evaluation.
38
+ def self.failed(error, **attributes)
39
+ new(error: error.is_a?(Exception) ? "#{error.class}: #{error.message}" : error.to_s, **attributes)
40
+ end
41
+
42
+ def errored?
43
+ error.present?
44
+ end
45
+
46
+ def tool_names
47
+ tool_calls.map { |call| call["name"].to_s }
48
+ end
49
+
50
+ def failed_tool_calls
51
+ tool_calls.select { |call| call["error"] }
52
+ end
53
+
54
+ def total_tokens
55
+ input_tokens.to_i + output_tokens.to_i
56
+ end
57
+
58
+ def to_h
59
+ super.compact
60
+ end
61
+ end
62
+ end
63
+ end